택배 코드 리팩토링을 위한 TDD 도입기

택배 코드 리팩토링을 위한 TDD 도입기

생성일
2023년 09월 04일
태그
react-testing-library

Background

현재 사내에서는 CU 알뜰택배 서비스를 성공적으로 개발하고, 실제 서비스로 배포하였다. 웹개발팀에서는 두 분이 크게 고생을 해주셨는데, 이후에 세븐일레븐, GS 반값택배 작업들이 하반기에 계속 예정이 되어있다는 청천벽력같은 소식이 들려왔다.
서비스는 성공적으로 출시되었지만, 사실 배송 프로세스는 굉장히 복잡하기도 하고, 모든 팀원이 참여해서 개발을 하지 않았기 때문에 코드 공유가 잘 되지 않았다. 배포 마감일의 압박이 있기도 하여서 개발에만 집중하느라 코드가 어느순간 꼬이기도 하였다.
문제 상황은 다음과 같았다.
  1. 배송 프로세스 개발 작업은 팀의 특정 두 분이 담당하셨고, 매우 복잡하다.
  1. 외부 업체를 끼고 개발을 하기 때문에 배포 일정에 대한 압박이 있었고, 코드가 어느 순간 꼬이기 시작했다.
  1. 두 분이 빠르게 개발을 하셨기 때문에 코드에 대한 공유가 잘 되지 않았다.

리팩토링을 제안하다…

한 달 정도의 시간을 두 분이 굉장히 고생해주셨는데, 이런 상황이라면 하반기 내내 두 분이 고생을 하셔야할 것 같았다. 따라서 나는, 두 분께 현재 배송 프로세스의 코드 상황 공유를 요청드렸고, 리팩토링을 통해 나와 다른 분이 코드 파악 및 개선을 하며, 궁극적으로는 세븐일레븐 반값 택배 개발을 담당하기를 제안드렸다.
 
다행히 제안을 좋게 생각해주셔서 택배 프로세스 코드를 리팩토링하기로 결정하였다. 사실 대규모로 리팩토링을 하는 것이 아닌, 우선은 전반적인 코드 파악과 작은 부분부터 시작하는 것이 목표였다.
 
아무리 작은 부분을 리팩토링한다고 해도, 코드 리팩토링을 해서 서비스 플로우에서 오류가 발생하면 크리티컬한 핵심 서비스 플로우이기 때문에 테스트 코드가 필수라고 생각이 들었다. 항상 테스트 코드 관련해서 공부해야지 생각만 하고 막막했는데, 공부를 하면서 직접 도입을 할 수 있는 기회가 생겨서 너무 기뻤다.

어느 것부터 리팩토링해볼까?

작은 부분부터 리팩토링을 진행하기로 하였고, 테스트 코드 처음부터 택배 프로세스의 전체를 커버하는 것이 아닌, 리팩토링을 하는 부분을 커버하는 테스트 코드를 작성하기로 하였다.
 
택배 프로세스 코드에서 배송지 추가 페이지에서 문제가 있었다. 배송지는 보내는 분과 받는 분의 배송지가 존재하는데, url은 다음과 같다.
 
배송지 추가 페이지 (보내는 분)
/address-regist
배송지 추가 페이지 (받는 분)
/address-regist?addressTarget=receiver (일반 편의점 택배)
/address-regist?addressTarget=receiver&deliveryCorpCode=501 (CU 알뜰택배)
 
다음과 같이 쿼리로 컴포넌트들을 분리하다보니, 모두 같은 페이지에 존재했다. UI는 비슷하지만, 역할이 다른 컴포넌트들이 하나의 페이지에 너무 응집되어 코드의 가독성이 떨어진다고 느껴졌다.
또한, 현재 서비스에서 배송지 수정 기능은 삭제된 상태인데, editState 라는 지역변수를 통해서 여러가지 분기처리가 이루어져 있었다.
예시로, 배송지 추가 mutation 함수에 다음의 로직들이 전부 들어있었다.
// 배송지 추가 const submitDeliveryInfo = debounce( async (submitInfo: { [key: string]: string }) => { const checkState = addressTarget === 'receiver' ? { receiveUserName: submitInfo.receiveUserName, receiveUserPhoneNo: submitInfo.receiveUserPhoneNo, ...(!isHalfDelivery && { receiveUserAddress: submitInfo.receiveUserAddress, }), ...(isHalfDelivery && { shopName: submitInfo.shopName }), } : { ...inputAddress, }; if (!checkAllStateIsReady(checkState)) { return; } if ( isHalfDelivery && ((submitInfo.shopAddress.startsWith('제주특별자치도') && !senderSelectedAddress.deliveryAddress.startsWith( '제주특별자치도', )) || (!submitInfo.shopAddress.startsWith('제주특별자치도') && senderSelectedAddress.deliveryAddress.startsWith('제주특별자치도'))) ) { openAlert({ text: '제주 지역 접수 시 제주도 내 편의점으로만\n배송 가능합니다.(내륙 ↔ 제주 불가)', bottomText: '확인', }); return; } if (addressTarget === 'receiver') { // 상태 업데이트 setStoreInfo({ shopCode: submitInfo.shopCode, shopName: submitInfo.shopName, shopAddress: submitInfo.shopAddress, shopPostNo: submitInfo.shopPostNo, shopTelNo: submitInfo.shopTelNo, }); setReceiverAddress({ receiveUserName: submitInfo.receiveUserName, receiveUserPhoneNo: submitInfo.receiveUserPhoneNo, }); initialState.receiveUserName = ''; initialState.receiveUserPhoneNo = ''; } if ( addressTarget !== 'receiver' && pathname.includes('/address-regist') && !isHalfDelivery ) { addAddress(inputAddress); return; } if ( addressTarget !== 'receiver' && pathname.includes('/address-edit') && !isHalfDelivery && inputAddress.deliverySeq ) { editAddress(inputAddress); return; } resetData(); goBack(); }, );
하나의 함수가 무려 80줄 가까이 작성되어 있었다.
 
그래서, 우선 다음과 같이 리팩토링을 하기로 하였다.
  1. 배송지 수정 관련 로직을 전부 제거한다.
  1. 배송지 추가 페이지를 보내는 분, 받는 분 2개의 페이지로 분리한다.

리팩토링에 앞서.. 테스트 코드 없이는 너무 불안해..!

택배 프로세스는 중고나라 서비스의 핵심 서비스이다. 리팩토링을 진행하고 몇 번의 클릭으로 테스트를 마무리하고, live 서비스에 배포하기는 너무나도 위험하고 두려웠다.
 
배송지 추가 관련해서 리팩토링을 진행하기 때문에, 다음과 같은 시나리오로 테스트 코드를 작성하기로 하였다. 두 시나리오 모두 CU 알뜰택배와 일반 편의점 택배 케이스 2가지로 나뉜다.

보내는 분 배송지 추가 시나리오

  1. CU 알뜰택배 or 일반 편의점 택배 선택
  1. 택배 신청 페이지로 진입
  1. 보내는 분 > 변경 버튼 클릭
  1. 배송지 관리 페이지 진입
  1. 배송지 추가 버튼 클릭
  1. 배송지 추가 페이지 진입
  1. 배송지 추가
  1. 배송지 관리 페이지에 추가된 배송지 확인

받는 분 배송지 추가 시나리오

  1. CU 알뜰택배 or 일반 편의점 택배 선택
  1. 택배 신청 페이지로 진입
  1. 받는 분 > 배송지 추가 버튼 클릭
  1. 배송지 추가 페이지 진입
  1. 배송지 추가
  1. 택배 신청 페이지에 받는 분 배송지 확인
 
해당 글에서는 CU 알뜰택배에서 받는 분 배송지를 추가하는 시나리오의 테스트 케이스를 작성하고, 시행착오를 겪었던 내용들을 공유하려고 한다.

테스트 코드 작성!

무작정 테스트 코드를 작성하는 것이 아니라, 테스트 환경을 구축하는 일도 생각보다 까다로웠다. 실제 서비스되고 있는 환경을 구축해야 했는데, 테스트 환경에서는 조금 다른 점이 있었다.

MemoryRouter

테스트 환경에서의 라우팅 테스트를 위해서는 평소에 사용하는 BrowserRouter 등이 아닌, MemoryRouter 의 사용이 필요했다.
notion image
MemoryRouter 는 위치를 내부적으로 배열의 형태로 저장하기 때문에 테스트 환경에 아주 적합하다고 한다. Recoil로 관리하는 전역상태와 tanstack query로 관리하는 서버 사이드의 상태를 참조하기 위해 다음과 같이 렌더링하는 유틸리티 함수를 작성하였다.
import { ReactElement, ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; import GlobalStyles from '@lib/styles/GlobalStyles'; const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, retry: false, }, }, }); function Wrapper( { children }: { children: ReactNode }, option?: { route: string }, ): ReactElement { return ( <RecoilRoot> <QueryClientProvider client={queryClient}> <GlobalStyles /> <MemoryRouter initialEntries={[option?.route ?? '/']}> {children} </MemoryRouter> </QueryClientProvider> </RecoilRoot> ); } export default Wrapper;
initialEntries 는 테스트 환경에서 처음으로 렌더링 되는 라우트를 지정할 수 있다.
 
테스트 코드를 작성하기 위한 기본 세팅은 끝났다. 이제 CU 알뜰택배 > 받는 분 배송지 추가 시나리오 테스트 코드를 작성해보려고 한다.

작성하기

첫 번째로, 테스트 시나리오에서 사용되는 페이지들을 모두 테스트 이전에 렌더링 시켜주는 작업이 필요했다. 해당 기능을 하는 함수를 작성했다.
편의점 택배 신청 페이지와 배송지 추가 페이지를 렌더링하고, 첫 렌더링되는 페이지를 편의점 택배 신청 페이지로 지정하는 역할을 한다.
const setup = () => { act(() => { render( <> <Route path="/cvs-regist/:type/:seq/:orderStatus?"> <CvsDeliveryRegistPage /> </Route> <Route path="/address-regist/receiver"> <ReceiverAddressRegistPage /> </Route> </>, { wrapper: (props) => Wrapper(props as { children: ReactNode }, { route: '/cvs-regist/product/46998411', }), }, ); }); };
 
테스트 시나리오에서 첫 번째 페이지는 편의점 택배 신청 페이지에서의 팝업이다. 해당 팝업에서 CU 알뜰택배 옵션을 선택하면 팝업이 사라지면서 편의점 택배 신청 페이지가 보이게 된다.
notion image
notion image
해당 팝업은 react-portal 로 구현되어 있기 때문에, 테스트 환경에서도 root에 추가를 해줘야했다.
describe('편의점 택배 신청 페이지', () => { const portalRoot = document.createElement('div'); portalRoot.setAttribute('id', 'popUpPortal'); document.body.appendChild(portalRoot); });
 
추가로, CU 알뜰택배 옵션을 선택하고 해당 페이지로 잘 이동하는지에 대한 테스트 코드를 작성했다.
describe('편의점 택배 신청 페이지', () => { const portalRoot = document.createElement('div'); portalRoot.setAttribute('id', 'popUpPortal'); document.body.appendChild(portalRoot); // 매 테스트마다 CU 알뜰택배를 클릭하여 편의점 택배 신청 페이지로 이동한다. beforeEach(async () => { setup(); await waitFor(() => { userEvent.click(screen.getByText('CU 알뜰택배')); }); }); test('CU 알뜰택배 신청 페이지로 이동한다.', async () => { await waitFor(() => { expect(screen.getByText('CU 알뜰택배 신청')).toBeVisible(); }); }); });
test block이 변경되면 매번 초기화 되기 때문에, 모든 test block이 실행되기 이전에 알뜰 택배 신청 페이지로 이동시키기 위해 beforeEach 메서드를 활용하였다.
유저가 옵션을 클릭한 후에 컴포넌트들이 나타나기 때문에 비동기적 처리를 위하여 waitFor 메서드를 활용하였다.
유저의 클릭, 타이핑 이벤트 등을 userEvent 를 통하여 깔끔하게 처리가 가능했다. 찾아보니, userEvent 말고도 기본적으로 testing-library에 내장되어있는 fireEvent 라는 메서드가 존재했다.
 
fireEvent vs userEvent
  • fireEvent
    • 이벤트를 발생시켜 컴포넌트의 상태 변화 및 렌더링을 테스트하는데 사용된다.
  • userEvent
    • fireEvent 보다 더 사용자가 실제로 사용하는 것처럼 이벤트를 발생시킨다. 클릭이 발생한다면 발생하기까지 발생하는 다른 이벤트들도 거치게 된다.
 
공식문서에서도 userEvent 사용을 권장하고, 실제 유저가 사용했을 때의 DOM 테스트를 하고 싶었기 때문에 userEvent 를 사용하기로 하였다.
 
다음은, 알뜰택배 신청 페이지에서 받는 분 배송지 추가 버튼이 잘 렌더링 되어있는지와 해당 버튼을 클릭했을 때 배송지 추가 페이지로 잘 이동하는지에 대한 테스트 코드를 작성했다.
test('배송지 추가 버튼이 노출되어야 한다', async () => { const linkToRegist = screen.getByTestId('receiver_link_to_regist'); expect(linkToRegist).toBeVisible(); expect(linkToRegist).toHaveTextContent('배송지 추가'); userEvent.click(linkToRegist); await waitFor(() => { expect( screen.getByText((content, element) => { return ( content === '배송지 추가' && element?.tagName.toLowerCase() === 'h1' ); }), ).toBeVisible(); }); });
notion image
 
배송지 추가 페이지까지의 이동은 테스트 코드로 확인이 가능하기 때문에, 이후의 test block에서는 첫 페이지 렌더링을 배송지 추가 페이지로 변경하기로 했다. 위에서 작성한 setUp 메서드를 다음과 같이 리팩토링하였다.
const setup = (initialRoute = '/cvs-regist/product/46998411') => { act(() => { render( <> <Route path="/cvs-regist/:type/:seq/:orderStatus?"> <CvsDeliveryRegistPage /> </Route> <Route path="/address-regist/receiver"> <ReceiverAddressRegistPage /> </Route> </>, { wrapper: (props) => Wrapper(props as { children: ReactNode }, { route: initialRoute, }), }, ); }); };
인수로 initialRoute 를 받고, 첫 렌더링되는 페이지를 커스텀할 수 있도록 하였다.
 
다음은 배송지 정보 필드들을 채우고, 완료 버튼을 눌러 알뜰 택배 신청 페이지에 받는 분 배송지 정보가 잘 들어가있는지 확인하는 코드를 작성했다.
test('배송지 추가의 필드를 모두 입력하고, 완료 버튼을 누르면 신청 페이지에서 받는 분 정보가 세팅된다.', async () => { setUp('/address-register/receiver'); const linkToRegist = screen.getByTestId('receiver_link_to_regist'); userEvent.click(linkToRegist); // 이름, 휴대폰 번호 타이핑 이벤트 const inputName = screen.getByTestId('receiver_input_name'); const inputTel = screen.getByTestId('receiver_input_tel'); const inputShop = screen.getByTestId('receiver_input_shop'); await userEvent.type(inputName, '김테스트'); await userEvent.type(inputTel, '01012345678'); userEvent.click(inputShop); // 편의점 input 클릭 > 교대 검색 > 첫번째 점포 클릭 const searchShopInput = screen.getByTestId('search_shop'); await userEvent.type(searchShopInput, '교대'); expect(searchShopInput).toHaveValue('교대'); const shopItem = await screen.findByTestId('shop_list_1'); expect(shopItem).toBeInTheDocument(); userEvent.click(shopItem); // 배송지 추가 완료 버튼 클릭 const submitButton = screen.getByTestId('receiver_address_submit'); userEvent.click(submitButton); // 배송지 추가 후 받는 분 정보 세팅 체크 await waitFor(() => { const receiverNameText = screen.getAllByTestId('receiver_info_text'); expect(receiverNameText[0].textContent).toBe('김테스트'); expect(receiverNameText[1].textContent).toBe('010-1234-5678'); expect(receiverNameText[2].textContent).toBe('CU메트로교대점'); }); });
notion image
 
notion image
notion image
notion image
 
비록 간단한 테스트 코드이지만, 작성하고 배송지 추가 페이지를 분리할 때 어느 부분에서 오류가 발생하는지 빠르게 체크가 가능했고, 별다른 QA 진행없이 운영환경에 배포할 수 있었다.
 
추가로, 테스트를 실행하면 로그가 똑같이 찍히니 테스트 코드 단에서의 디버깅이 어려우면 해당 컴포넌트 혹은 메서드에서 console.log 를 통해 좀 더 편하게 디버깅을 할 수 있다!

References

댓글

guest