본 내용은 셀메이트 개발자 컨퍼런스 D² 에서 발표된 내용을 조금 각색하여 구성하였습니다.

안녕하세요. 셀메이트 상품개발팀에서 벡엔드 직무를 맡고 있는 최성욱입니다.

상품개발팀은 많고 다양한 판매처에서 주문을 수집하고 정제하여 저장합니다. 수십개의 많은 판매처를 다루는 만큼 에외처리가 복잡하고 다양한데요. 때문에 로직을 수정할 경우 높은 집중력이 요구됩니다.

하지만 신중히 작업을 진행하더라도 사람인 만큼 작업 중 실수가 나오는 경우가 종종 있습니다. 주문수집은 고객의 업무와 직결되는 만큼 작은 실수가 치명적일 수 있는데요. 이에 작업자의 실수가 일어났을 경우 미리 파악하고 방어할 수 있는 시스템이 필요합니다.

상품개발팀에서는 이러한 목적을 달성 할 수 있도록 테스트 코드를 작성하는 규칙이 있습니다. 최대한 작업자의 실수를 미연에 방지하고 견고한 로직을 완성할 수 있도록 상품개발팀이 고민하고 도입해나가고 있는 테스트코드에 대해 소개하고자 합니다.

신입으로 합류하면서 겪은 실수 2가지

신입으로 들어와서 업무에 아직 충분히 적응하지 못한 시절 지금 생각만해도 아찔한 경험을 있는데요. 테스트코드가 왜 필요한지 직접적으로 느끼게 된 사례인 만큼 간단하게 공유드려보고자 합니다.

땀흘리는곰돌이

1. 택배사 연동 로그 수정

배송을 위해 택배사로 부터 송번을 채번해달라는 요청을 보내면, 택배사는 필요한 송번들을 셀메이트로 전달해줍니다. 해당 로직에서 로그를 남기는 부분을 개선해달라는 요청이 있었는데요. 이를 위해 택배사 공통로직에 로그를 남기는 부분에 조금 수정이 필요했습니다.

수정이라고 해도 이미 셀메이트 내부에서 사용하는 로그시스템을 활용하는 중 이여서 많은 부분을 수정하지 않아도 되었습니다. 로직을 열심히 작성하고 실제 테스트를 진행해보고 잘 되는 것을 확인한 후, 뿌듯한 마음으로 커밋하고 MR을 살펴보고 있었습니다.

조금 더 개선하면 좋을 부분이 보여 아주 작은 수정을 하고 (그러지 말았었야 했는데) 여러번 테스트 해보았으니 문제가 없을 것이라는 자신감에 커밋을 하고 배포를 위해 MR을 작성하여 요청하게 됩니다. 그리고 곧 11시에 배포가 진행되었습니다.

아뿔사. 바로 9분후에 이슈가 들어왔습니다. 우연히 실제 업체에 파견을 나가있던 사수가 문제를 눈으로 확인했습니다. 문제 상황이 확인되는 즉시 개발팀에 유선으로 상황이 전파 되었고, CX팀에 이슈가 접수되기도 전에 많은 고객사의 업무가 중단되는 것을 막기위해 코드는 바로 원복이 되었습니다.

2. 주문수집 중 저장오류

이번엔 며칠 뒤, 타 판매처의 연동프로젝트를 진행하던 중 판매처 특수 로직을 분기하는 일이 있었습니다. 고객의 요청으로 일반적으로 셀메이트로 수집되지 않는 주문을 수집하여 보여줄 수 있게끔 예외처리를 진행하는 일이였습니다.

모든 판매처가 거쳐가는 공통 로직이자 중요한 로직 중 일부를 수정하는 일이였기에 신중을 기해 작업했지만, 변경사항이 800줄이 넘어가는 MR에서 실제 문제가 생겼던 한 줄의 오류코드가 발견하지 못하고 배포를 진행합니다.

배포 다음날 10시 30분쯤에 주문이 이상하게 생성되고 있다는 오류가 들어오고 이슈를 통해 문제상황을 확인합니다. 급하게 코드를 수정하여 배포했지만, 문제는 코드가 다시 정상으로 수정되기 전까지의 20시간동안 생성된 주문 중 발송된 주문이 있을경우 셀메이트가 보상을 해야하는 처지에 놓이게 됩니다.

상황을 확인한 제 등에서는 다시 식은땀이 줄줄 흐르기 시작했습니다.

긴급하게 사후 처리를 하며 보상 범위 파악을 위해 쿼리 작성을 진행했습니다. 제 얼굴이 많이 사색이 되었는지 사수분이 옆에서 쿼리 작성을 도와주시면서 그렇게 큰일이 아닐 수 있으니 너무 걱정하지 말라고 이야기 해주셨는데, 정말 쿼리를 작성하고 확인하지 보상 대상이 전체 판매처 대상이 (수백개의 업체) 아닌 소수의 고객이라는 것을 확인합니다. 그나마 놀란 심장을 진정시키며 가슴을 쓸어내렸습니다.

다행히 에러의 범위는 크지 않았습니다. 몇몇 고객에서 특수한 옵션이 켜져있을 때 특수한 주문에 적용되는 계산이 잘못 된 것이였습니다. 보상범위는 크지 않았지만, 마음속으로 재발방지를 다짐하며 개인적으로는 다시는 겪고 싶지 않은 일이였습니다.

인간의욕심은끝이없고같은실수를반복한다

테스트코드를 작성하자

몇번 이렇게 상황을 겪고 보니 정말 이러다 큰 실수가 발생하면 어쩌지? 하고 생각하게 됩니다. 사람이 코드를 확인하는 것에는 한계가 있어 열심히 검토해도 실수가 생길 수 있는 가능성이 있습니다. 특히 판매처가 다양하고 수집되는 주문의 케이스도 정말 다양하기에 사전에 방지하지 못하고 놓치는 부분이 충분히 발생할 수 있습니다.

특히 신입이나 도메인 로직에 익숙하지 않은 업무처리를 하게 되면 이러한 경향이 조금 더 도드라지게 나타나게 되는데요. 다행히 벡엔드 개발팀이 사용하는 PHP 언어의 라라벨 프레임워크는 테스트를 잘 지원하고 있기 때문에 이를 이용하여 테스트코드를 작성할 수 있습니다.

테스트코드를 작성하면 개발의 안정도를 높이고, 견고성도 챙길 수 있죠. 다른 사람이 짠 로직을 해칠 기회도 줄어듭니다. 마침 시니어분도 해당 케이스를 다른 사람이 놓치지 않도록 테스트케이스를 작성해달라고 말씀하셨습니다.

간단하게 판매처에서 주문을 가지고 오는 상황을 예시로 테스트코드를 설명해보겠습니다. 라라벨을 통해 구성하지만, 개념은 다른 언어와 크게 다르지 않습니다.

함수의 이름은 test로 시작

PHPUnit로고

지금 사용하고 있는 라이브러리는 PHPUnit 입니다. PHPUnit 은 JS 진영의 JUnit, python 진영의 pytest 와 비슷하게 정해진 디렉토리에서 함수는 test 접두사로 시작해야 한다는 컨벤션을 가지고 있습니다.

test 접두사로 시작하는 함수를 생성하면 어떤 테스트코드를 실행해야 하는지 지정해주지 않아도 PHPUni 가 정해진 디렉토리의 모든 테스트 코드를 찾아 자동으로 테스트 실행중 진행합니다.

이제, 함수를 작성했다면 안의 구현은 어떻게 구성해야 할까요?

테스트의 기본 3 요소

기본3요소ppt

테스트는 기본적으로 사전조건, 입력(행위), 기대결과 로 구성됩니다.

이러한 패턴은 GWT(Given - When - Then), AAA(Arange - Act - Assert) 등과 같이 여러가지 이름으로 불리지만 본질적으로는 3가지 구성요소로 나누어 생각한다는 점에 있습니다.

각각을 나열하면 다음과 같습니다.

이제 3구성 요소를 하나하나 천천히 살펴보겠습니다.

  • 사전조건
  • 입력
  • 기대결과

사전조건

사전조건은 실제로 주문이 입력되기 전에 WEB 혹은 DB 의 상태로 준비하는 것을 의미합니다. 주문 수집의 경우 고객이 셀메이트에 로그인 한 상태여야 하고, 주문을 수집하기 위한 사전 정보들을 사이트에 모두 입력한 상황에서 주문수집을 진행합니다. 또 판매처가 생성되어 있어야 하고, 판매처에서는 입력될 주문들이 있어야 겠죠. 주문 수집이 일어나기 전까지의 상황을 테스트코드에서 미리 정의하고 준비하는 과정이 이에 속합니다.

그런데, 생각보다 준비해야할 사항이 많으면, 테스트를 수행하기 전에 작성해야 할 코드가 너무 길어질 수 있습니다. 이를 위해 테스트코드에는 Fixture 라는 개념이 존재합니다.

Fixture

Fixture 는 고정된 데이터 셋으로 구성됩니다. 이러한 Fixture 를 이용하면 사전조건 준비를 빠르게 달성할 수 있는데요. 특히 여러 테스트에서 이용하는 공통적인 사전 구성환경이 있다면 여러 테스트 코드에서 재활용 할 수 있습니다.

셀메이트에서는 DB Fixture 를 이용하여 고객이 사이트에 로그인하여 기본 공급처와 기본 판매처를 가지고 있는 상태를 빠르게 제공합니다. 때문에 테스트가 수행되는 시점에서는 판매처만 생성하면 됩니다.

입력

입력은 고객이 실제로 주문을 수집하라는 버튼을 눌렀을 때 발생하는 행위입니다. 여기서는 FE 에서 버튼을 눌렀을 때 BE 보내는 API 가 이에 해당합니다. 실제로 주문을 수집하는 로직을 수행하라는 명령이 떨어졌을 때, 구현한 벡엔드 비즈니스 로직이 정확하게 동작해야 합니다.

Sampling & Mocking

그러나 다양한 이유로 테스트 중 실제로 외부통신을 이용해 주문수집을 실행할 수는 없습니다. 왜냐하면 판매처의 로그인아이디와 패스워드가 달라지는 경우도 있고, 판매처에서 수집해 올 수 있는 주문은 실시간으로 달라져 이후 기대결과를 예측하기가 불가능하기 때문입니다. 따라서 미리 판매처에서 주문을 내어주는 API의 결과값을 sampling 하여 테스트 중에서 외부 API와 통신하는 로직이 있을 때, 이를 진짜로 통신하지 않고 미리 저장했던 결과값을 내어주는 형태로 비즈니스 코드를 속이게 됩니다.

이러한 행위를 sampling과 mocking 이라고 하며, 라라벨에서는 facade 로 제공하는 Http 모듈을 통해 달성 할 수 있습니다.

아래는 라라벨에서 외부 통신을 호출하는 일반적인 코드인데요

1
2
// 라라벨에서 일반적으로 사용하는 API 통신
$response = Http::get('www.naver.com');

fake 를 이용하여 특정 url로 통신할 경우에는 미리 정해진 응답으로 반환하게 해보겠습니다.

1
2
3
// fake 를 통해 미리 응답 mocking
Http::fake(['www.naver.com' => Http::response()])
$response = Http::get('www.naver.com');

미리 저장한 API 호출에 대한 결과값을 Http::response() 의 인자로 지정하여 마치 외부 API 통신을 진행하는 것처럼 mocking 을 구성 할 수 있습니다.

fake를 사용하면 이제 비즈니스 로직은 외부와의 통신에 의존하지 않고, 미리 설정된 값을 통해 반환하므로 매번 같은 테스트를 수행 할 수 있습니다. 그런데 fake 는 지정되지 않은 url 까지는 방어하지 않기 때문에 테스트에서 실수가 일어나지 않도록 조심해야 합니다.

1
2
3
// fake 로 지정되지 않은 API 호출이 일어날 경우 실제 통신이 일어남
Http::fake(['www.naver.com' => Http::response()]);
$response = Http::get('www.google.com');

개발팀에서는 테스트 클래스를 생성할 때 상속하는 TestCase 클래스에서 미리 preventStaryRequest 를 적용하고 있습니다. 때문에 만약 해당 조치를 이행하지 않더라도 이미 모든 테스트에 자동으로 적용되기 때문에 개발자 실수에 의해 외부 API 통신이 실제로 실행되는 로직을 방어해 두었습니다.

1
2
3
4
// preventStrayRequest 를 통해 외부 API 방지
Http::preventStaryRequest();
Http::fake(['www.naver.com' => Http::response()]);
$response = Http::get('www.naver.com');
외부통신금지하기ppt

fake 구성 팁

1. 여러개의 fake 구성하기

보통의 판매처는 한번이 아닌 여러번의 API 통신을 거쳐 주문을 수집하게 됩니다. 예를 들면 로그인 -> 주문목록확인 -> 주문상세확인 등으로 나뉘는 경우를 들어 볼 수 있는데, 이처럼 여러개의 fake 가 필요한 경우 fake 를 추가해 주는 식으로 구성하게 됩니다.

  • 로그인
  • 주문목록조회
  • 주문상세조회
1
2
3
4
5
Http::fake([
  'A' => Http::response(),
  'B' => Http::response(),
  'C' => Http::response(),
]);
2. 단일 엔드포인트를 여러번 호출하는 경우

추가적으로 문수집이 하루로 제한된 판매처에서 15일치의 주문을 가져와야 한다면 보통 API를 15번으로 나누서 송신해야 하는데, 이 경우 같은 URL 대상의 API를 query_params 혹은 body 만 바꿔 동일한 URL 을 호출 하게 됩니다. 이런 상황이 발생했을 때는 어떻게 처리하는게 좋을까요?

  • sequence 이용
    • 간단하고 쉬운 방법으로 API 호출 순서를 정확하게 알고있을 경우 비교적 쉽고 빠르게 fake를 생성할 수 있는 방법입니다.
    • 사용하고 테스트하기에느 간단하지만 개발자가 비즈니스 로직을 정확하게 파악하고 있어야 할 책임이 부여됩니다.
  • closure 이용
    • 복잡한 로직에서 주로 사용되며, API를 구분하여 응답을 내어주거나, 호출에 대한 검증이 필요할 때 사용할 수 있습니다.
    • if 분기처리가 많이 일어나서 fake 구성이 조금 복잡해 질 수 있습니다.

상품개발 벡엔드에서는 두 가지 정도를 주로 사용하고 있습니다. 각각 방법에 따라 장단점이 존재하니, 만드려고 하는 케이스에 따라 적절한 구성을 통해 테스트 환경을 구축하는 것을 추천합니다. 아래는 간단한 예시 코드입니다.

1
2
3
4
5
6
7
8
// sequnce 함수를 이용하는 방법
Http::fake([
  $url => Http::sequence()
    ->push($example['payload'], $example['statusCode'], $example['header'])
    ->push($example2['payload'], $example2['statusCode'], $example2['header'])
    ->push($example3['payload'], $example3['statusCode'], $example3['header'])
    ->push($example4['payload'], $example4['statusCode'], $example4['header'])
]);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// query_param 을 직접 확인하는 방법
Http::fake([
  'orders' => function (Request $request) {
    $orderStatus = $request->data()['orderStatus'];
    $salesType = $request->data()['salesType'];

    if ($salesType === 'PURCHASE') {
      $body = $this->loadJson('example.json');
      return Http::response(200, $body, []);
    } else {
      $body = $this->loadJson('example2.json');
      return Http::response(200, $body, []);
    }
  }
]);

기대결과

사전조건을 잘 설정하고 입력을 올바르게 수행했다면, 테스트의 결과는 이제 재실행해도 항상 동일하게 일어납니다. 기대결과는 입력이 수행되면서 주문이 잘 수집되고 난 이후에 실제 원하는 결과로 변경되었는지를 검증해야 합니다. 보통 API 테스트에서는 3가지 정도로 나누어 검증해볼 수 있습니다.

  • status 코드를 통해 API가 잘 동작했는지 확인
  • 반환된 body, json을 검사하여 원하는 값을 내줬는지 확인
  • DB를 검사하여 실제 주문이 잘 생성되었는지 DB 상태를 확인하기

보통 기대결과의 검사는 assert 문을 통해 이루어지게 됩니다.

assert 문

asssert 라는 단어에서는 어떤 주장을 참이라고 강하게 주장하다, 확신하다 의 의미가 담겨있습니다. assert 함수는 이러한 사전적의미를 바탕으로 테스트코드 중 기대하는 결과를 비교합니다. 테스트함수 내부에 있는 assert 는 모두 참이여야 테스트가 성공하며, 만약 틀린 assert 문이 존재할 경우 해당 구문에서 테스트는 실패로 기록되게 됩니다.

테스트 코드를 위해 지원하는 assert 문은 정말 많은데요. 하나하나 다 설명드리고 싶지만, 양이 꽤 많다 보니 assert 의 자세한 사용법은 라라벨 공식문서(https://laravel.com/docs/11.x/http-tests#available-assertions) 를 참조하시길 부탁드리겠습니다.

assert 문을 이용해 간단하게 API 응답에 대한 코드를 검증해 보겠습니다.

1
2
$res = $this->getJson('/test/shop/order/insert');
$this->assertEquals(expected: 200, actual: $res->status(), message: $res->json());

보통 이런식으로 많이 작성하는데, 200 코드만 검증해도 많은 오류가 줄어들게 됩니다. 간단한 검증으로 효과를 가장 크게 느낄 수 있는 부분이며, 200 코드만 검사해도 정상응답이 반환되지 않아 유저들이 큰 불편함을 느끼는 것을 방어힐 수 있습니다.

두번째로 json 반환을 검사하는 방법으로 assertJson 을 이용합니다.

1
2
3
4
5
// response status 검증
$res = $this->getJson($url);
$res->assertJson(
  ['isSuccess' => true],
);

assertJson 함수는 실제 반환된 json 내부에서 부분집합의 개념으로 주어진 기대결과가 있는지를 검증합니다. 부분집합을 찾는다는 점이 매우 유용한데, 왜냐하면 필드가 추가적으로 추가되더라도 부분집합은 유지되기 때문에 단순 필드의 추가로는 테스트가 잘 깨어지지 않게 됩니다. 만약 키값의 type 까지 검증한다면 strict: true 옵션을 사용하면 됩니다.

마지막으로 DB를 검증하는 코드를 예시로 들어보겠습니다.

1
2
3
4
5
// DB 검증
$response = $this->getJson($url);
$this->assertTrue(발주정보::query()
  ->where('입력차수일련번호', 23)->first() !== null
); 

DB 검증의 경우는 실제 DB에 어떤 값이 들어가있는지를 검사하여 원하는 결과를 이뤄냈는지를 검사합니다. 주문수집시 생성된 주문 뿐만 아니라 남겨진 로그나, 수집시 생성되는 입력차수와 같은 부산물들을 다양하게 검증하여 테스트의 안정성을 높일 수 있습니다.

테스트의 라이프 사이클

이제 테스트 구성요소를 모두 작성하였으니 실제 테스트를 수행해 보겠습니다. 라라벨에서는 artisan 명령어를 이용해 테스트코드를 수행 할 수 있습니다.

1
2
3
4
5
6
7
php artisan test
# 혹은
php artisan test --filter 'IlsangGuDokFTest'
# 혹은
php artisan test --filter 'IlsangGuDokFTest::test_insert_order'
# 혹은
php artisan test --filter 'IlsangGuDokFTest::test_insert_order$'

테스트는 여러개의 테스트 함수가 모여 한개의 클래스를 이루고, 한개의 클래스가 모여 전체 테스트를 구성하게 됩니다. 그런데 테스트를 실행하면 실제로 무슨일이 벌어질까요?

테스트 이전과 테스트 이후의 DB 상태는 달라지면 안되기 때문에 transcaction (이하 TX) 을 이용합니다. 테스트 수행전 transaction 을 구성하고 태스트 수행후 rollback 을 진행하는 방법인데요. 간단하게 설명하면 다음과 같습니다.

테스트시작 - TX1 - 전체범위의 setUp - TX2 - 클래스범위의 setUp - TX3 - 테스트 함수 - rollback - rollback - rollback - 테스트 종료
라이프사이클ppt

이러한 구성을 통해 최대한 중복되지 않는 방향으로 테스트를 효율적으로 구성하여 테스트를 진행합니다.

setUp 함수

setUp 함수는 테스트함수를 수행하기 전 공통적으로 수행되어야 하는 코드 조각을 실행해주는 함수입니다. 보통 여러 테스트케이스에서 필요로하는 로직을 작성해둡니다. 테스트는 트랜젝션을 통해 수행되기에 만약 테스트 코드중 같은 사전조건을 구성해야하는 일이 있을 경우 (유저생성, 판매처 생성등) setUp 함수에 기록해 두면 TX 구성을 이용하여 테스트 코드의 효율성을 증가시킵니다.

보통은 테스트 케이스마다 판매처 생성이 필요하기 때문에 판매처 생성을 주로 setUp 에 기재합니다. 앞전에 이야기 했던 preventStrayRequest 의 경우도 모든 테스트 클래스가 상속받는 부모 TestCase 클래스의 setUp 에 지정해두고 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
class OrderFTest() {

  # 공통로직
  public function setUp() {...}
  
  public function test1() {...}
  public function test2() {...}
  public function test3() {...}
  public function test4() {...}
}

테스트의 에러시 확인 팁

실제 테스트를 수행하면 다음과 같이 결과를 보여주며, 클래스에 속한 테스트함수들의 결과 그리고 최종적인 테스트클래스의 결과를 보여줍니다.

테스트코드수행ppt

이때 사용한 assert 함수의 message 인자에 테스트가 실패했을 때 출력할 구문을 지정할 수 있습니다. 여기에 원인을 파악할 수 있는 실마리를 제공하면, 단순히 assert 구문이 실패했다는 내용보다 실패한 원인을 빠르게 파악하여 대응 할 수 있습니다.

실패이유확인하기

CI/CD 에서의 테스트 코드 이용

이제 이렇게 작성된 테스트 코드는 MR 생성 후 머지하는 과정에서 CI가 일어나기 전에 수행되게 되는데요. 이때 실패한 코드가 있을 경우 전체 슬랙에 알림이 가게됩니다. 때문에 문제가 생길것이라는 사안을 미리 알 수 있고, 머지된 코드가 배포되지 않습니다.

CI:CDppt
슬랙알림ppt

다만 머지 후 테스트가 일어나기 때문에 누군가 테스트에 실패하는 코드를 머지하면 이후 해당 테스트가 수정 될 때까지 모든 테스트가 실패합니다.

그렇기 때문에 MR 생성전 로컬에서 필수로 테스트 코드를 돌려봐야 하며, 로컬에서 성공했더라도 모종의 이유로 test 환경에서 실패하는 경우 빠르게 확인하여 대응해나가고 있습니다.

테스트의 범위

테스트는 범위와 환경에 따라 여러 갈래로 나눠집니다. 그 중 가장 유명한 두개를 꼽자면 UnitTest 와 APITest 입니다.

저희는 APITest 를 FeatureTest 라고 부르고 있는데요,비즈니스 로직의 추가/수정이 일어났을 경우 FTest를 필수로 이행하는 문화가 있습니다.

이후 개발자의 역량에 따라 UnitTest 를 사용하는 하이브리드 테스트 혹은 여러 테스트 함수를 추가하도록 하고 있습니다.

테스트의범위ppt
  • UnitTest
    • 리팩토링의 장점이나 테스트의 용이성 때문에 많이 권장되는 테스트 입니다.
  • FTest
    • 비즈니스 로직을 파악 API 용도 파악을 진행해야 테스트 작성이 가능합니다.

벡엔드팀이 아직 공통로직을 정의해나가고 있기도 하고, 판매처 하나를 구현해도 이후 정제와 저장하는 부분에서 분기문이 많기 때문에 판매처 수집에 있어서는 특히 결과까지 무사히 도달하는지 확인할 필요가 있습니다. 신입분들 혹은 처음 로직을 접하는 분들은 API를 소비하는 입장에서 도메인 로직 파악 후 테스트를 작성하므로, 로직이해를 돕기 위해 선 FTest를 권장하고 있습니다.

개발팀의 테스트 코드 정착기

순항중

개발팀은 3개의 프로젝트를 주요 프로젝트로 운영하고 있습니다. 각각 2016년, 2019년, 2021년에 시작되었는데요, 테스트코드는 2022년 9월 경에 도입되었습니다.

개인적인 원오원을 통해서 테스트코드를 도입하는 방향에 대해 논의가 이루어졌고, 초기에는 원오원의 개인 목표로서 테스트코드를 작성하기 시작했다고 합니다. 이후 2023년에는 테스트코드 문화 자체를 장려하여 코드를 계속 추가해나갔고, 결국 2024년에 들어와서 커버리지 30%를 달성하게 됩니다.

그러면서 테스트코드 도입초기에는 보이지 않았던 문제들이 하나둘씩 보이게 되었습니다.

FIRST

테스트 코드에는 FIRST 법칙이 있습니다. 각각 다음을 의미하는데요.

  • Fast: 테스트코드는 언제나 빠르게 실행되어 코드에 문제가 없는지 즉각적으로 피드백되어야 한다
  • Independent: 테스트케이스는 언제나 독립적으로 실행되어 다른 테스트코드에 영향이 없어야 한다
  • Repeatable: 테스트를 재실행해도 동일한 결과를 나타내야 한다
  • Self-Validating: 테스트는 오로지 성공 혹은 실패로만 기록되어 추가적인 작업없이 성공/실패 판단이 가능해야한다.
  • Timely: 테스트 코드는 적시에 작성되어 반영되어야한다.

먼저 First를 살펴보겠습니다.

First

2024년 1월 테스트 커버리가 30%가 넘어가면서, 전체 테스트코드의 실행 시간이 15분을 넘어가게됩니다. 코드에 오류가 있어 급하게 수정해야 하는 순간이 왔을 때 test 의 성공을 위해 15분을 기다려야 한다는 뜻인데요. 급한 코드 수정이 필요할 경우에 장애점이 될 뿐만 아니라 오류를 확인하는데 15분이라는 시간은 길어 보였습니다.

15분대기

따라서 적절하게 병렬수행할 수 있도록 테스트 환경을 재구성하고, QA환경의 경우에는 더 빠른 배포가 가능하도록 일반적인 CI 러너가 아닌 모 시니어님의 여분 컴퓨터를 이용하여 로컬 러너에서 빠르게 수행하게끔 변경되었습니다.

이러한 과정을 거쳐 5~8분 정도로 테스트 코드 수행시간을 단축시켜 좀 더 빠른 배포와 오류대응에 있어 유연하도록 구성하였습니다.

Repeatable

테스트 코드 수행중 아주가끔 관련된 로직을 수정하지 않았는데 실패하는 케이스가 있습니다. 로직 수정을 가하지 않아도, 테스트 코드를 재실행하면 성공하는 이상한 테스트케이스가 존재하였습니다.

코드를 살펴본결과 해당 테스트는 사전준비 단계에서 랜덤하게 생성된 데이터를 이용하고 있었는데요. 정말 드문 확률로 랜덤하게 생성된 데이터에 문제가 발생하여 테스트가 간간히 실패하고 있었던 것이 원인이였습니다.

Timely

마지막으로 테스트가 적시에 작성되어야 한다는 규칙이 있습니다. 여기서 말하는 적시란 TDD 에서 말하는 적시로 로직을 수정하기 전에 테스트를 작성해야 한다는 것에 있습니다.

TDD

TDD(Test Driven Development) 방법론은 본래 문제확인, 요구사항분석, 구현, 테스트, 배포에 이르는 각 과정을 약간 수정하여 구현전에 테스트를 작성하자는 주장인데요. 해당 방법론을 적용하면 다음과 같은 장점이 있다고 합니다.

  • 요구사항을 먼저 분석하게 되므로 로직이해가 빠르다.
  • 어떤 설계를 진행할지 먼저 고려하게 되므로 리팩토링이 쉽고 유연한 코드를 작성한다.
  • 코드 구현에 있어 테스트를 이용해 성공 실패를 결정하므로 빠른 피드백을 통한 개발 속도 상승이 가능하다.
TDDppt

요러한 장점이 있지만 아직 개발팀에서는 테스트코드의 성숙도가 충분하지 않아 TDD 도입까지는 시간이 더 필요할 것으로 생각됩니다. 또한 신입분들의 경우 테스트코드를 작성하는데 익숙하지 않고, 테스트를 짜기 위해 숙지해야할 비즈니스 로직이 많기 때문에 두 부분에 모두 익숙해지는데 시간이 필요합니다.

TDD 의 경우 시간이 좀 더 들어간다는 단점이 있는데 모든 로직에 TDD 를 적용하게 되면 개발 비용이 너무 올라가므로, 개발팀 내부에서는 우선 테스트 코드를 많이 추가하고 나중에 TDD 를 고려하는 방향으로 나아가기로 하였습니다.

테스트코드의 효과

야구:safe

2024년 현재 테스트 커버리지는 약 35% 정도의 테스트 커버리지를 달성하고 있습니다. 작성된 테스트 함수는 여러개의 프로젝트를 합쳐 약 900 개가 넘습니다. 5월 한달간 QA를 포함하여 115번 정도의 배포를 고려하면 비즈니스 로직은 한달간 9 만번 이상의 검증을 거친 셈입니다.

물론 테스트 커버리지가 지금은 주요 로직위주 커버되었고, 지엽적이고 많은 예외사항들에 대해서 천천히 추가해나가야 한다는 개선사항을 가지고 있지만, 테스트코드가 개발팀에게 가져오는 안정감은 상당합니다.

로직을 개선하거나 수정할 때 중요한 로직이 망가지거나 내가 작성한 로직이 다른사람의 테스트코드를 깨뜨리는 경우에 미리 알 수 있기 때문에 비교적 리팩토링에 있어 조금 더 너그러워 질 수 있습니다.

특히 PHP 버전, 라라벨 버전의 상승등의 프로젝트 전체에 영향을 줄 수 있는 업데이트를 진행하면서 이점을 많이 확인했습니다. 버전 업데이트 중 하위호환성이 깨진경우 어떤 코드가 깨져서 문제가 발생하고 있는지 직접 확인 할 수 있는 점이 많이 도움이 되었습니다.

아래는 CX팀에서 들어온 개발 이슈 수치입니다.

CS지표그래프

정확하게 테스트 코드로 인해 이슈가 줄었다라고 말하기에는 조금 더 면밀한 분석이 필요하지만, 그래프는 점차 우하향 그래프를 그려나가고 있습니다. 조금씩 에러 코드발생 빈도가 줄어들고 있어 긍정적인 효과를 계속해서 기대하고 있습니다.

더 나아가서

테스트코드를 작성하면서 추가적으로 팀이 시도하고 있는 몇가지를 소개해 드릴까 합니다.

테스트코드 컨벤션

개발팀에 테스트 코드가 정착됨에 따라서 팀별로 겪은 경험과 노하우를 공유하며 컨벤션을 세워나가고 있습니다. 간단하게 두개만 소개해 보겠습니다.

  • 테스트코드가 많아지니, 속도가 느려지므로 다양한 경우를 모두 함수로 만들기 보다 하나의 함수에서 다양한 케이스를 존재하도록 권장
  • 페이지네이션이나 청크단위 (주문수집 1일단위) 가 존재 할 경우 무조건 청크 단위 이상의 테스트를 진행하자

레거시코드에 테스트코드 도입

ASP 에서 PHP 로 열심히 이전하고 있지만, 아직 많은 양의 코드가 남아있는 ASP 프로젝트에서도 테스트 코드를 도입해보려 노력하고 있습니다.

A다만 프레임워크가 없어 connection 관리 주체가 복잡해 TX 를 사용할 수 없는 어려움이 존재하는데요. 주문수집을 진행했다면 이후 주문을 삭제하는 TearDown 과정을 개발자가 직접 구현해보는 방향으로 논의되고 있습니다.

통합테스트 (Integrity test)

여러 레포지토리에서 유기적으로 통신이 일어나서 동작해야 하는 비즈니스 로직의 경우 통합 테스트를 작성해야 합니다. 아직 이 부분이 셀메이트에 많지 않기 때문에 통합테스트가 작성되지 않았지만, 가까운 미래에 이러한 API 가 개발될 가능성이 있습니다. 이럴 경우 두 레포지토리를 동시에 세팅에서 테스를 진행해야 하는데요.

여러개의 Connection 에서는 TX 관리가 많이 복잡해 지기 때문에 또 하나의 도전이 될 것 같습니다.

종단간테스트 (E2E Test)

브라우저에서 클릭하여 벡엔드, DB 통신 후 실제 브라우저의 결과를 확인하는 종단간테스트의 경우 실제 유저 입장에서 진행하는 테스트라는 장점이 있습니다. 다만 해당 테스트는 일반적으로 매우 고비용으로 인지되기 때문에 도입에 대해서는 많은 논의가 필요합니다.

아직까지 종단간 테스트는 진행하고 있지 않지만 최근 QA 팀에서 판매처에 대한 QA 를 자동화하면 어떨까 하여 리서치 중에 있습니다.

이야기를 정리하며

테스트 코드를 도입하여 얻을 수 있는 부수적인 이득은 정말 많습니다. 변경에 좀 더 유연해지고, 개발의 신뢰성과 구성원의 안정성을 높아지는 유용한 효과를 직접 체험하고 있습니다. 테스트 코드는 정말 꼼꼼하게 테스트 해야한다는 느낌보다는 정말 중요한 내용을 방어하는 느낌으로 사용할 때 많은 효과를 체감하는 것같습니다.

특히 대규모 버전 업데이트나 리팩토링을 진행하는 팀원 입장에서는 테스트를 수행하는 것만으로도 코드의 문제점을 파악 할 수 있기 때문에 보다 빠른 업데이트가 가능했습니다.

테스트 코드를 작성하는 것이 시간적 비용적으로 쉽지만은 않은 일이지만, 중요한 로직이 있다면 반드시 테스트 코드를 작성해야 미연에 작업자의 실수를 방지할 수 있다고 생각합니다.

도입을 고려하고 계신다면 간단한 케이스만 우선 테스트 코드로 추가해보시는 건 어떨까요? 모든 서비스의 순항을 기원하며 이만 발표를 마치겠습니다. 감사합니다.

마무리ppt
드디어 끝!

이상 D² 에서 진행됬던 발표를 정리해 보았습니다. 긴글을 읽어주셔서 감사합니다.