바퀴를 개선하는 이미지

달리는 기차의 바퀴 교체하기 2. Restructuring

한재엽 · 토스페이먼츠 Frontend Developer
2024년 2월 23일

앞선 글 1. Planning에선 문제를 어떻게 정의하는지에 대해 다뤘어요. 구체적인 내용을 기약하고 글을 마무리했는데요, 이 글에선 구체적으로 어떤 작업들을 진행했는지 소개해요.

재구조화

앞선 글에서 소개했듯이 '재구조화'라는 단어는 리팩토링보다 좀 더 거시적인 관점에서의 개선을 뜻해요. 이번 프로젝트에서는 테스트 코드의 리팩토링 내성을 가장 먼저 개선하려고 했는데요, 그 이야기를 자세히 해볼게요.

리팩토링 내성

리팩토링은 다음과 같이 정의할 수 있습니다.

소프트웨어 공학에서 결과를 변경하지 않고 코드의 구조를 재조정하는 것

그리고 ‘리팩토링에 내성이 있다.’ 라는 말은 이렇게 풀 수 있어요.

제품 코드를 리팩토링 할 때, 테스트 코드를 변경하지 않고도 가능하며 테스트 코드 실행 결과가 달라지지 않는다.

리팩토링을 제대로 했다면 기능 변경이 없어야 해요. 테스트 코드 결과도 동일해야 합니다. 그 결과, 우리는 테스트 코드 실행 결과를 통해 기존에 동작하고 있던 기능이 그대로 잘 동작하고 있음을 보장할 수 있습니다.

테스트 코드를 신뢰하기 위해선 이 리팩토링 내성이 중요해요. 기능을 변경하지 않았는데 테스트가 실패하는 경우를 거짓된 제보라고 해요. 하나의 테스트 케이스라도 ‘거짓된 제보’를 하면 테스트 코드 전체를 의심해야 합니다. 그렇기에 리팩토링 내성을 먼저 개선하게 됐어요.

식별

리팩토링 내성을 낮추는 3가지를 식별했어요.

  1. 테스트 케이스 실행 순서에 의존하고 있는 경우
  2. 내부 구현에 의존하고 있는 경우
  3. 외부 의존성에 의존하고 있는 경우

1. 테스트 케이스 실행 순서에 의존하고 있는 경우

나쁜 테스트 코드 냄새로 쉽게 만날 수 있어요. 이런 경우 대부분 우연히 동작하고 있습니다.

테스트 케이스끼리 같은 mock 객체를 참조하거나 어떤 상태를 공유하면 실행 순서에 의존하게 되고 테스트가 간헐적으로 실패하곤 합니다. 특히 병렬로 실행하는 환경에서 실패합니다. 대표적인 예시로 session storage를 직접 참조하는 경우, cleanup이 제대로 이뤄지지 않아 의도치 않은 동작이 발생하곤 해요.

테스트 케이스가 독립적으로 실행되지 못하고 있는 경우이기 때문에 독립적으로 수행될 수 있도록 수정해주면 해결됩니다. Mock의 범위(scope)를 제한하고 공유하고 있는 상태들을 전부 제거했습니다. 쉬운 문제지만 테스트 케이스를 작성하다 보면 자주 마주쳐요.

Action

  • 테스트 케이스의 결과는 실행 순서에 의존하면 안 된다.
  • 객체를 공유하는 것 대신 필요한 객체를 반환하는 Builder 함수를 만들어 사용하자.

2. 내부 구현에 의존하고 있는 경우

기능과 구현, 인터페이스와 세부 사항. 이런 관계에 대해 들어보셨을 텐데요, 기능을 테스트하는 테스트 코드에서 구현에 의존하게 되면 문제가 발생해요. 작성 당시엔 큰 문제가 없지만 제품 코드가 꾸준히 유지보수되어야 하는 상황에선 아쉬운 점이 있습니다.

아래와 같이 Page라는 간단한 컴포넌트를 테스트 해볼게요.

function Page() {
  const data = useUserResource();

  return <div>{data.name}</div>
}

name을 잘 렌더링 하는지 테스트하는 코드는 다음과 같은 모습입니다.

it('Page render name', () => {
  render(<Page />);

  expect(getByText('토스')).toBeInDocument();
});

이 때, useUserResource이 반환해야 하는 값을 정의해야 하기 때문에 mock을 사용하곤 합니다.

const mockUseUserResource = mock('./useUserResource');

it('Page render name', () => {
  mockUseUserResource.returnValue({
    name: '토스',
  });
  render(<Page />);

  expect(getByText('토스')).toBeInDocument();
});

이런 테스트 코드는 ‘구현’에 의존하고 있어요.

저희는 Page 컴포넌트가 name 이라는 값을 잘 렌더링 하는지 테스트하려고 합니다. 그런데 useUserResource 라는 hook이 정의되어 있는 ‘파일의 경로’를 변경하면 테스트가 깨집니다. 파일 경로는 Page 컴포넌트 입장에서는 중요한 정보가 아니며 세부 사항이라고 할 수 있습니다.

테스트 하려는 기능과 상관없는 구현을 변경할 때, 테스트 실행 결과가 달라지면 리팩토링 내성이 약하다고 판단합니다. 구현에 의존하고 있는 부분을 제거하거나 암묵적인 의존성을 계약으로 드러내야 내부 구현에 의존하지 않는 테스트 코드가 만들어지고 리팩토링 내성을 높일 수 있습니다.

기능과 구현은 입장에 따라 달라지는 상대적인 개념입니다. useUserResource라는 hook 입장에서 경로는 중요한 요소이지만 Page 컴포넌트 입장에선 중요한 요소가 아니었던 거죠.

Action

  • 테스트 코드에 드러나는 내부 구현을 제거하자.
  • 암묵적인 의존성을 명시적인 계약으로 드러내자.

3. 외부 의존성에 의존하고 있는 경우

팀에서 가장 많은 논의가 이뤄진 주제인데요, 먼저 우리 제품의 입장에서 ‘외부’라는 경계를 어떻게 정의할 것인가 이야기를 나눴어요. 어디부터가 외부인지에 따라 외부 의존성인 것과 아닌 것이 달라졌기 때문이예요.

서버 API를 호출하기 위한 Network 계층은 외부 의존성으로 쉽게 정의할 수 있었지만, React와 같은 라이브러리는 결정이 애매했습니다. 외부 의존성으로 정의할 경우 의존성 관리에 들어가는 비용 대비 실익이 큰지 검토가 필요했기 때문이에요. 사용자의 행동을 추적하기 위한 로깅 모듈은 어떨까요? 모듈에 불과한 이 코드는 외부 의존성일까요? Local Storage, Session Storage처럼 당연하게 사용하고 있던 Web API들은 외부 의존성일까요?

Action 1. 외부 의존성 식별

Clean Architecture 다이어그램은 한번쯤 보셨을 것이라 생각합니다.

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

몇몇 분들은 “프론트엔드 애플리케이션과 클린 아키텍처는 맞지 않는다.”고도 하시죠. 클린 아키텍처는 도메인 로직을 중심에 두고 레이어를 구분하는데요, 이 레이어를 왜 나누는지 이해해야 클린 아키텍처를 이해하게 됩니다. 저희는 클린 아키텍처가 제시하는 설계를 그대로 답습하는 것이 아니라 이 ‘레이어’를 나눴어요.

도메인 모델을 중심으로 비즈니스 로직(Use Case)을 구성하면 무엇이 바깥으로 밀려날까 고민했고 정의한 경계를 기반으로 식별한 의존성은 다음과 같습니다.

  1. API Client - 서버와 API 통신을 수행하는 network client
  2. Storage - local storage, session storage 처럼 persistence 를 담당하는 구현체
  3. Bridge - 서로 다른 window 끼리 통신할 수 있는 인터페이스를 제공하는 라이브러리 구현체
  4. Logging - 서비스의 흐름이나 유저 행동을 기록하는 로깅 구현체
  5. Runtime - browser history, user agent 구현체

Action 2. 외부 의존성 제거

식별한 외부 의존성을 어떻게 제거할 수 있을지 기술적인 고민이 이어졌어요. Dependency Injection Container가 대중적인 Backend 생태계와 달리 Frontend 생태계는 그렇지 않아요. Angular Framework를 제외하면 의존성 관리를 위한 프레임워크가 없었어요. tsyringe, inversify 두 가지를 검토했는데요, 당장엔 의존성을 주입할 수 있는 구조만 필요해서 라이브러리 도입은 나중으로 미뤘습니다. React 기반에선 컴포넌트에 유기적인 주입이 필요했고 Context API 기반으로 의존성들을 주입해보기로 했습니다.

Action 3. 외부 의존성 mocking

외부 의존성으로 식별됐으나 따로 관리하지 않고 단순히 mocking으로 처리하는 의존성도 있습니다. window 객체, Router 모듈이 대표적인 예입니다. 분명 외부 의존성으로 관리하면 비즈니스 로직의 순수성을 더 잘 보장할 수 있었지만 제품 코드의 변경이 크고 개선에 필요한 비용 대비 실익이 크지 않다고 판단했습니다. 추후 구조를 조금 더 개선하게 된다면 mocking한 부분을 의존성으로 관리하여 도메인 레이어에서 걷어낼 수 있을 거라 생각합니다.

결과물

  • 독립적인 테스트 환경을 구성했어요.
    • 테스트가 실패하면 테스트 코드 내부만 보고 해결할 수 있게 됐어요.
  • 이해하기 쉬운 테스트 코드가 만들어졌어요.
    • 암묵적인 입출력이 없다 보니 제품이 어떻게 동작하는지 명확하게 알 수 있었어요.
    • 테스트 코드만 봐도 새로운 테스트 코드를 작성하는데 필요한 맥락을 알 수 있게 됐어요.
  • 비즈니스 요구사항을 분석하는데 수월해졌어요.
    • 기존의 기능과 새로운 요구사항의 논리적 충돌이 발생할 때 테스트 코드를 통해 검증할 수 있게 됐어요.
    • 실제 동작하는 코드를 가장 잘 반영하고 있는 문서로 활용하고 있어요.

Talk is cheap

네트워크 계층을 예로 소개해보려고 해요. 서버 API를 호출하기 위해 존재하는 Network 계층을 외부 의존성으로 정의하고 이를 APIClient라는 추상으로 관리했어요.

기존에는 컴포넌트에서 직접 클라이언트에 접근해서 서버 API를 호출하곤 했어요.

// AS-IS
import { brandpayClient } from 'remote/brandpayClient';

function Component() {
  return (
    <Button
      onClick={() => brandpayClient.addAccount(data)}
    >
      계좌 등록하기
    </Button>
  )
}

이런 케이스를 3단계로 나눠 접근했습니다.

1. 추상화

네트워크 계층에서 발생하고 있는 일을 기능으로 정의하여 추상화하고 이를 *-Client 라는 인터페이스로 표현

interface BrandpayClientService {
  addAccount: ReturnType<typeof addAccount>;
  // ... 다른 기능들
}

2. 구현

Client에 정의한 ‘메세지’들을 구현

class BrandpayClient implements BrandpayClientService {
  addAccount(payload: Payload) {
    return this.post(API_PATH, { body: payload });
  }
}

3. 적용

의존성 컨테이너 적용

function ApiClientProvider() {
  const brandpayClient = useState(() => {
    return new BrandpayClient(params);
  });
  
  return (
    <BrandpayClientProvider client={brandpayClient}>
      {children}
    </BrandpayClientProvider>
  )
}
function App() {
  return (
    <ApiClientProvider>
      <Component {...props} />
    </ApiClientProvider>
  )
}

의존성 참조

function Component() {
  const brandpayClient = useBrandpayClient();

  return (
    <Button
      onClick={() => brandpayClient.addAccount(data)}
    >
      계좌 등록하기
    </Button>
  )
}

4. 테스트 코드 작성

it('계좌를 추가하면 계좌 등록 완료 페이지로 이동한다', async () => {
  const user = userEvent.setup();

  render(
    <BrandpayClientProvider
      client={{
        addAccount: () => Promise.resolve(응답_스키마_fixture), 
      }}
    >
      <ARSVerificationPage />
    </BrandpayClientProvider>
  )

  await user.click(await screen.findByText(`인증 전화 받기`));

  await waitFor(() => {
    expect(mockRouter.pathname).toBe(계좌_등록_완료_페이지);
  })
})

이런 식으로 정의한 메세지에 대해 응답을 정의하고 이에 기대하는 동작만 검증할 수 있게 됐습니다.

앞으로 풀어야 할 과제

Runtime Error

이제 테스트 코드를 믿고 자신감 있게 리팩토링 할 수 있게 됐어요. 코드를 모듈화하고 폴더 구조를 마음껏 수정해도 테스트 케이스의 통과 여부를 믿고 배포할 수 있게 됐어요. 다만 런타임에서 발생하는 버그는 단위 테스트, 통합 테스트 레벨에서 알기 어려워요. 이를 보완하기 위해 E2E 테스트 도입을 고려하고 있어요. 트레이드 오프가 명확한 E2E 테스트이기에 어떻게 하면 잘할 수 있을지 고민입니다.

Provider Hell

JavaScript를 공부하면 콜백-헬(Callback Hell)이라는 용어를 만나는데요, 테스트 코드를 작성할 때 여러 의존성을 주입해주기 위해 여러 계층의 Provider가 필요해졌습니다. 트레이드 오프가 있는 의사결정이지만 손이 많이 가더라구요. 의존성 컨테이너를 개선해볼 수 있지 않을까 하는 아쉬움이 남았습니다.

// 3개의 중첩 Provider
it('동적으로 약관을 서버에서 불러와 렌더링 한다', async () => {
    render(
      <SDKBridgeTestProvider bridge={{ ... }}>
        <BasicClientProvider client={{ ... }}>
          <AuthClientProvider client={{ ... }}>
            <AgreementPage />
          </AuthClientProvider>
        </BasicClientProvider>
      </SDKBridgeTestProvider>
    );

    expect(await screen.findByText(termTitle)).toBeInTheDocument();
});

관리해야 하는 의존성이 많아질수록 의존성 간의 협력과 싱글톤으로 관리해야 하는 의존성 등 관리 체계에 복잡도가 올라가는데요, 잘 관리할 수 있는 방법을 연구하려고 합니다.

맺으며

‘믿을 건 테스트 코드 뿐’이라는 생각이 들었어요. 쉽게 바뀌는 담당자에 비해 제품은 항상 그 자리에서 실행되고 있습니다. 제품의 변경사항을 따라가지 못하는 문서에 비해 테스트 코드는 지금 이 코드가 어떻게 동작하는지 설명해줄 수 있었습니다. 제품 수명이 길어질수록 테스트 코드도 중요해졌습니다.

어떻게 하면 테스트 코드를 잘 작성할 수 있을까? 고민을 했어요. 테스트 코드에 대한 지식도 중요할테고 심리적인 부담도 없어야 할 것입니다. 더 나아가 테스트 코드를 먼저 작성하는 습관도 중요하다는 의견도 나왔어요.

‘처음부터 설계를 잘했더라면 어땠을까?’라는 생각도 하게 됐습니다. 테스트 코드를 작성하기에 용이한 설계를 해두면 처음부터 테스트 코드를 작성하지 않더라도 나중에 테스트 코드가 필요할 때 빠르게 작성할 수 있지 않을까? 라는 이야기도 나눴습니다.

현실과 많이 타협을 하면서 적절한 설계란 무엇일까 고민을 많이 했는데요, 좋은 엔지니어링이란 비용, 시간, 사기 또는 기회 비용 등 어떤 문제에 대해 가장 효율적인 해결책을 찾는 행위라고 생각합니다. 어떤 비용은 즉시 지불해야 하고 어떤 비용은 부채로 떠안아야 합니다. 이것을 잘 하기 위해서 비용을 분명하게 정의하고 적절하게 분배하는 것이 중요합니다. 이 글이 제품에 들어가는 비용을 판단해보고 개선이 필요한 시점이 아닌지 되돌아보는 계기를 제공하면 좋겠습니다.

함께 읽으면 좋은 글

Write 한재엽 Review 박성현 Edit 박수연, 한주연

재엽님과 일해보고 싶다면?
댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.