[요즘 우아한 개발] 단위 테스트로 복잡한 도메인의 프론트엔드 프로젝트 정복하기

이 글은 《우아한 요즘 개발》에서 발췌했습니다.

골든래빗 출판사

#Jest    #리액트    #타입스크립트    #단위테스트

2022.08.18

이찬호

최근 저는 복잡한 도메인의 서비스를 개발하는 개발자라면 공감할 만한 문제를 겪고 있습니다. “찬호 님, 서비스가 B마트일 때, 공급사 반품 상세페이지에서 공급사 계정으로 로그인한 사용자가 역분개*한 상품의 마감일을 변경할 수 있나요?” 개발을 하다 보면 이런 질문이 자주 들어옵니다. 예전 같으면 제가 아는 선에서 바로 대답할 수 있을 간단한 질문에도 최소 10분은 하던 일을 멈추고 확인할 만큼 플랫폼이 복잡해졌습니다. 기능을 확인하는 것뿐 아니라 코드를 리팩터링하고, 그 코드에 새 기능을 추가하는 것도 개발 초기보다 훨씬 어려워졌죠.

* 전년도의 미지급/미수/선급/선수잔액을 당기초에 제거하는 회계 처리

 

SCM을 개발할수록 걱정이 늘어간다

SCM(Supply Chain Management)은 공급망 관리 플랫폼으로, 공급자로부터 고객에게 이르는 전체적인 연결고리 관계와 구매, 재고, 물류, 정산관리 등의 여러 기능을 포함하는 서비스입니다. 저희는 SCM을 플랫폼화해서 우아한형제들 안에 있는 여러 서비스(배민상회, B마트, 배민문방구 등)에서 사용합니다. SCM이 복잡도가 높은 도메인인데, 이를 플랫폼화하면서 복잡도가 더 높아졌습니다. 기존에 각 서비스에서 운영하던 것을 하나로 합치다 보니 플랫폼이지만 서비스만을 위한 기능들이 생겼기 때문입니다.

 

 

앞의 화면은 SCM 내에서 가장 복잡도가 높은 발주서 상세페이지 중 상품 정보를 보여주는 컴포넌트입니다. 이 상품 정보 컴포넌트에 있는 마감일 열을 구현한 코드에 테스트를 추가하고, 리팩터링을 진행해보겠습니다.

코드를 작성하기에 앞서 발주서 상세페이지에서 개발할 때 파악해야 하는 조건들을 살펴보겠습니다.

 

  • 서비스 : 배민상회, B마트, 배민문방구
    • 로그인한 사용자가 어느 서비스의 사용자인지 확인하는 조건
  • 사용자 : 내부 사용자(MD), 외부 사용자(공급사), 외부 사용자(물류사), 외부 사용자(공급사+물류사)
    • 사용자 역할마다 사용할 수 있는 기능이 다름
  • 발주/반품(발주유형) : 발주(구매발주, 판매발주), 공급사 반품(구매발주 반품, 판매 발주 반품)
  • 발주 아이템 상태 : 발주요청, 입고진행, 입고완료, 발주마감, 발주대기, 발주취소
  • 반품 아이템 상태 : 반품요청, 반품진행, 반품완료, 반품마감, 반품취소

 

위의 조건을 고려하니 화면에 보이는 컴포넌트는 간단한 센터 정보와 상품 정보 테이블뿐이지만, 상품 정보에 들어가는 코드는 총 1,700줄이 넘습니다. 이렇게 도메인과 코드가 복잡해지면서 아래와 같은 걱정거리가 생겼습니다.

 

  1. 새 기능 추가 : 발주 상세페이지에 새로운 기능을 추가하기가 두려워졌습니다.
  2. 리팩터링 : 리팩터링을 하고 싶은데 너무 복잡해서 엄두가 나질 않았습니다.
  3. 서비스 동작 확인 : PM이 어떻게 동작하는 건지 질문을 해와도 파악하는 데 시간이 오래 걸렸습니다.
  4. 협업 : 저 말고 다른 사람이 이 코드를 보면서 작업할 수 있을지 두려웠습니다.

 

서비스가 살아 있고 요구사항은 계속 생기는데, 코드가 복잡해졌다고 개발자가 걱정하고 두려워하고만 있으면 안 되겠죠. 테스트를 진행해 이 두려움을 뚫어보겠습니다.

 

SCM에도 테스트를 작성해보자!

기존에 작성된 코드에 테스트를 추가하는 방식은 다음과 같습니다.

  1. 기존에 동작하는 코드를 기반으로 사용자 시나리오를 작성합니다.
    1. 상황에 따라 어떻게 동작하는지를 전부 분류합니다.
    2. 기존에 작성해준 조건들이 빈약하거나 고려하지 못했던 에지 케이스*를 발견할 수 있습니다. 추가로 정리해줍니다.
  2. 사용자 시나리오를 기반으로 테스트 코드를 작성합니다.
    1. 1-1처럼 고려하지 못했던 에지 케이스를 발견한 게 아니라면, 이미 동작하는 코드를 기반으로 테스트를 작성하는 거라 전부 성공 케이스가 나옵니다.
  3. 리팩터링
    1. 컴포넌트를 분리하고, 코드를 분리하고, 로직을 간단하게 변경합니다.
    2. 여기서 테스트 코드의 강력함을 경험할 수 있습니다.

* 주요한 결과 그룹이 아닌 소수의 특정 상황

 

현재 사항 파악(화면, 코드) 및 코드 분리

테스트를 작성해볼 예시는 상품 정보 테이블 안에 있는 마감일입니다.

두 이미지에 보이는 것처럼 같은 발주서에 같은 상태임에도 불구하고 로그인한 사용자 역할에 따라 다른 화면을 보여주고 있습니다.

 

 

마감일은 renderClosedDate( )라는 함수를 호출해 렌더됩니다.

renderClosedDate( ) 함수를 살펴보면 DatePicker를 출력 하는 경우, 마감일을 텍스트로 출력 하는 경우, (이력보기)를 출력하는 경우 3개의 조건으로 나뉘어 있습니다. 각 컴포넌트가 특정 조건에 맞을 때 결과를 반환하는 방식입니다.

 

 

이 renderClosedDate( ) 함수를 테스트하기 편하도록 ClosedDateColumn.tsx 컴포넌트로 분리하겠습니다.

 

• ClosedDateColumn.tsx와 ClosedDateColumn.test.tsx •

 

테스트를 작성해야 하니 ClosedDateColumn.test.tsx까지 만들어주면 이제 테스트를 작성할 준비가 되었습니다.

 

기존에 동작하는 코드를 기반으로 사용자 시나리오 작성

컴포넌트로 분리한 코드를 보면서 어떻게 동작하는지 사용자 시나리오를 정리합니다.

 

// 마감일 / 마감일 수정 표기
1. 내부 사용자일 때
    1-1. 마감일이 있을 때
        1-1-1. 역분개를 했을 때
            - DatePicker를 보여준다.
            - 마감일을 수정할 수 있다.
        1-1-2. 역분개를 하지 않았을 때
            - 마감일을 텍스트로 보여준다.
    1-2. 마감일이 없을 때
            - '-'를 텍스트로 보여준다.
2. 외부 사용자일 때
    2-1. 마감일이 있을 때
            - 마감일을 텍스트로 보여준다.
    2-2. 마감일 없을 때
            - '-'를 텍스트로 보여준다.

// (이력 보기) 표기
1. 내부 사용자일 때
    1-1. 변경 이력이 있을 때
        - '(이력 보기)'를 보여준다.
    1-2. 변경 이력이 없을 때
        - 아무것도 보여주지 않는다.
2. 외부 사용자일 때
    - 아무것도 보여주지 않는다.

 

코드에서 if문이 있듯, 시나리오를 작성할 때도 특정 조건을 기준으로 분기하며 작성해줍니다. 코드에서 if문 안에 여러 조건이 복잡하게 작성되어 있는 것을 하나씩 풀어서 분류해봅니다. 이 과정을 통해 내가 작성했던 코드의 맹점들을 발견하게 될 수도 있습니다. 조건을 풀어서 나열하면 제 조건문이 MECE(Mutually exclusive and collectively exhaustive)*한지 검증하기도 좋습니다. 작성한 코드를 기반으로 사용자 시나리오가 정리됐다면, 실제 서비스에서 작성한 시나리오를 토대로 제대로 동작하는지 확인합니다. 문제가 없다면 확인을 마치고 테스트를 작성해도 됩니다.

* Mutually Exclusive Collectively Exhaustive의 약자. 상호배제와 전체포괄

 

사용자 시나리오를 기반으로 테스트 코드 작성

앞에서 작성한 사용자 시나리오를 바탕으로 테스트를 작성합니다. if문 조건을 기준으로 컨텍스트를 나누고, 해당 컨텍스트 안에서 실행되어야 하는 테스트를 작성해서 하나씩 테스트합니다.

컨텍스트가 여러 개로 분리되어 있다는 말은 실제로 직접 확인하려면 복잡한 조건들을 다 맞춰가면서 확인해야 한다는 것인데, 여기에서 테스트의 강력함이 나타납니다. 프롭스 드릴링*으로 전달받는 변수나, 전역에서 사용하는 상태 관리 라이브러리에서 사용하는 값을 더미 데이터를 이용해 원하는 대로 렌더링하여 테스트할 수 있습니다. 테스트 코드를 작성하고 실행해봅니다. 이미 작성된 코드를 검증하는 테스트 코드라 실패하는 것 없이 잘 통과했습니다. 이제 두려울 것이 없어졌습니다. 테스트를믿고 코드를 리팩터링하겠습니다.

* props drilling. 상위 컴포넌트가 하위 컴포넌트로 Props를 전달할 때 발생하는 구조적 문제

 

리팩터링

마감일 코드 분리하기

 

• 이 두 개를 ClosedDate.tsx로 분리해보자 •

 

시나리오를 작성한 것을 보면, 마감일과 DatePicker 같은 시나리오로 묶을 수 있고 이력 보기는 테스트도 따로 분리할 수 있습니다. 이 기준에 따라 컴포넌트도 작게 분리했습니다.

마감일과 DatePicker를 보여주는 컴포넌트 ClosedDate.tsx와 테스트를 수행하는 ClosedDate.test.tsx 파일로 분리합니다.

새로 작성한 컴포넌트 ClosedDate.tsx를 ClosedDateColumn.tsx 컴포넌트에 있던 기존 코드를 대체해서 넣고 테스트가 제대로 동작하는지 확인합니다. 둘 다 잘 동작합니다. 변경한 코드가 기존 코드와 정확하게 같은 동작을 한다는 것을 테스트로 보증받았습니다. 기쁜 마음으로 커밋을 하고 다음 리팩터링으로 넘어갑니다.

 

이력보기 코드 분리하기

 

• 컴포넌트를 DateChangeHistory.tsx로 옮겨보자 •

 

다음은 ‘(이력보기)’에 해당하는 코드를 컴포넌트 DateChangeHistory.tsx로 분리하고, 테스트 코드를 옮겨옵니다.

이 상태로 작성해둔 테스트 코드를 실행하면 어떻게 될까요?

 

내부 사용자일 때
  - 마감일 변경 이력이 있을 때
  [O] (이력보기)가 출력된다.
  - 마감일 변경 이력이 없을 때
  [X] 아무것도 노출되지 않는다.

외부 사용자(공급사, 물류사, 공급사 + 물류사)일 때
  - 마감일 변경 이력이 있을 때
  [X] 아무것도 노출되지 않는다.

DateChangeHistory > 내부사용자일 때 > 마감일 변경 이력이 없을 때 > 아무것도 노출되지 않는다.

 

시나리오대로 동작하지 않는다고 테스트 코드가 말해줍니다. 테스트를 통과하도록 조건문을 추가하겠습니다. 먼저 외부 사용자일 땐 ‘(이력 보기)’가 출력되지 않는다고 했으니 외부 사용자일 때 null을 반환하는 코드를 추가하고 테스트를 확인합니다.

다음으로 내부 사용자일 때, 변경 이력이 없는 경우 아무것도 노출되지 않는 상황을 처리하는 코드를 추가하고 테스트를 확인하니 전부 통과되었네요.

기쁜 마음으로 커밋을 합니다. 이제 이 코드는 정상적으로 동작하니 DateChangeHistory.tsx 컴포넌트를 ClosedDate Column.tsx 컴포넌트에 반영합니다.

이렇게 테스트 코드를 반영해 복잡했던 코드를 안전하고 깔끔하게 리팩터링했습니다.

 

효과는 굉장했다!

사용자 시나리오 검증이 훨씬 간편해졌습니다. 이전에는 크롬 시크릿창을 사용하여 다른 권한을 가진 사용자 ID로 로그인하여 확인해야 했지만, 이제는 테스트 코드에서 FIXTURE를 사용하여 원하는 상황을 검증할 수 있습니다.

기존에 가지고 있던 두려움도 해소할 수 있었습니다. 컴포넌트를 간결하게 분리한 덕분에 발주 상세페이지에서 정리되지 않은 코드에 새로운 것을 추가해야 하는 위험이 사라졌고 테스트 코드가 기존 동작에 대해서 안정성을 보장해주니 새 기능을 추가하다 발생하는 부작용 걱정을 덜 수 있게 되었습니다. 이젠 오히려 테스트 코드를 작성하면서 기존 코드들에 안정성을 불어넣고 싶은 마음이 더 커졌습니다. 또한 기존에는 PM이 어떻게 동작하는 건지 질문을 해와도 파악하는 데 시간이 오래 걸렸는데 이젠 코드를 볼 필요 없이 테스트 시나리오로 금방 파악할 수 있습니다. 새로운 담당자가 이 코드를 작업해야 하는 상황이 와도 테스트 시나리오가 곧 명세가 되니 기본적인 도메인 지식만 익히면 어떻게 동작하는지 파악하면서 작업할 수 있을 겁니다.

테스트를 작성하면서 제가 가지고 있던 걱정을 해소할 수 있었는데요, 실제로 테스트를 작성해보니 제가 예상하지 못했던 좋은 효과들이 있었습니다.

첫째, 컴포넌트를 분리해야 하는 명확한 기준과 근거가 생겼습니다. 기존에는 ‘컴포넌트가 너무 크니까 적당하게 나눠야겠다’라고 생각했다면 이제는 테스트하기 좋은 코드를 기준으로 나누게 되었습니다. 그리고 이 기준은 같이 일하는 팀원들과 논의할 때도 공통의 기준으로 삼았습니다. 다른 기준을 가지고 ‘어떤 기준이 더 적합한가?’가 아니라, 같은 기준 안에서 ‘어떻게 하면 더 테스트하기 좋은 코드일까?’를 고민하게 됐다는 점이 좋았습니다.

둘째, 기능동작에 대한 문서화를 따로 할 필요가 없어졌습니다. ‘나중에 이 부분에 대한 코드를 나 말고 다른 사람이 이해할 수 있을까?’ 고민이 항상 있었는데, 테스트 코드가 곧 명세가 되어버리니 따로 코드를 설명하기 위해 문서화할 필요가 없어졌습니다.

셋째, 코드가 간결해졌습니다. 테스트를 작성하기 위해 컴포넌트를 나누게 되는데, 이때 ‘어디까지가 같은 역할을 하는 코드인가?’를 고민하고이 고민은 자연스럽게 단일 책임 원칙*을 지키는 방향으로 이루어집니다. 결과적으로 더 나은 구조로 컴포넌트를 분리해서 사용하게 되었습니다.

마지막으로 MECE하게 시나리오를 작성하다 보니 숨겨진 에지 케이스를 찾아내게 됐습니다. 처음 기능을 구현할 때부터 고려하고 작성했으면 좋겠지만 그러지 못했던 코드에 대해 테스트 코드를 작성하면서 고려하지 못한 경우를 찾아내게 됩니다.

코드를 작성할 때 기능이 잘 동작하도록 구현하는 것이 가장 중요합니다. 기능이 잘 동작하면 다음은 같이 일하는 사람들과 협업하기 좋고 관리하기 좋은 코드를 고민하게 됩니다. 테스트 코드가 없을 땐 막연하게 ‘이렇게 하는 게 더 좋겠지’ 하고 혼자만의 기준으로 고민했다면, 이제는 테스트가 그 기준이 되어준다는 점이 예상하지 못한 큰 효과였습니다.

* 객체 지향 프로그래밍에서 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다는 원칙

 

◆◆◆

“찬호 님, 서비스가 B마트일 때, 공급사 반품 상세페이지에서 공급사 계정으로 로그인한 사용자가 역분개한 상품의 마감일을 변경할 수 있나요?” 정답은 “아니오”입니다. 테스트를 작성하기 전엔 마감일 관련 코드를 하나씩 보면서 답을 해야 했는데, 이제는 테스트 시나리오만 확인하면 쉽게 답을 할 수 있습니다. 단위 테스트를 통해 화면 렌더링과 동작을 테스트하는 것은 여러 테스트 방식 중 한 방식입니다. 그러나 이 테스트만으로도 개발자 입장에서 얻을 수 있는 수많은 이점이 있다는 것을 공유하고 싶었습니다. 저와 비슷한 고민을 하는 분들에게 테스트를 도입해보길 강력하게 추천하고 싶습니다.

저자 우아한 형제들

우아한형제들은 배달이 일상을 조금 더 행복하게 하도록 오늘도 달리고 있습니다. 평범한 사람들이 모여 비범한 성과를 만들어 내는 곳이될 수 있도록 건강한 조직문화를 만드는 일에 진심을 다합니다. 2016년부터 ‘우아한형제들 기술블로그’를 운영하며 개발 조직의 성장 과정을 기록하고 있습니다.

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
상호명 : 골든래빗 주식회사
(04051) 서울특별시 마포구 양화로 186, 5층 512호, 514호 (동교동, LC타워)
TEL : 0505-398-0505 / FAX : 0505-537-0505
대표이사 : 최현우
사업자등록번호 : 475-87-01581
통신판매업신고 : 2023-서울마포-2391호
master@goldenrabbit.co.kr
개인정보처리방침
배송/반품/환불/교환 안내