테스트 코드 왜 필요했을까?

드뮴·2025년 1월 3일
5

🪴 개발일지

목록 보기
1/6
post-thumbnail

하나의 파일에 모든 로직을?

화상회의 기능을 구현하는데 회의 자체에 참여할 인원을 5명으로 제한했기 때문에 P2P 방식으로 회의에 참여하는 사람들이 모두 연결되는 Mesh 방식을 사용해 구현했다. WebRTC를 이용해서 화상회의 기능을 개발하는게 처음이었고, 소켓 통신을 하다보니 올바르게 작성하는지에 대한 확신이 없었다.

그래서 세션 페이지 코드 파일 하나에 관련 로직을 모두 작성하였다. 이 코드가 제대로 동작하는지 검증이 필요했고 이 코드가 실제로 구현 가능한지 한 파일에 프로토타이핑을 해서 가능함을 확인했다. 이를 통해 코드가 정상적으로 동작하는지 쉽게 파악할 수 있었다.

그러나 기능이 추가되면서 코드가 길어지면서 로직이 복잡해지는 문제가 있었다. 기능 개발이 완료된 상태가 아니었고 앞으로 기능이 더 추가될 예정이었는데 코드를 봤을 때 정확히 어떤 코드가 어떤 동작을 하는지 알아보려해도 가독성이 좋지 않았다.

실제로 서버 측에서 보내는 데이터의 형식이 바뀌어 화상회의 기능이 동작하지 않는 문제가 있었다. 그런데 서버 측의 문제인지 인지하지 못한 상태에서 세션 페이지 코드를 하나씩 읽어나가는데 오류를 찾으려하니 코드가 너무 길고 가독성이 좋지 않아 오류를 찾기가 생각보다 어렵다고 느껴졌다.

🚨 앞으로 기능이 추가되면 코드가 더 길어지고 복잡해질 것이었고, 현재 상태에서도 오류가 발생했을 때 가독성이 좋지 않아 찾는 속도가 느리고 흐름 파악이 쉽지 않아 파일을 기능 별로 분리할 필요성을 느꼈다.


우선 세션 페이지 코드에서 비즈니스 로직을 useSession 훅으로 1차로 분리했다. 그럼에도 300줄이 넘어가고 있었다.

  • 화상회의의 모든 기능을 담고 있었기 때문에 로직이 정상적으로 동작하는 것을 보장하면서 리팩토링해야했다.
  • 개발 기간이 정해져있기 때문에 개발 일정에 차질이 가지 않도록 리팩토링 후에도 정상적으로 기능이 동작함을 보장해야했다.

위 2가지를 만족할 해결 방법으로 테스트 코드를 생각했다. 테스트 코드를 작성하고 안전하게 리팩토링을 진행하는 것을 목표로했다.


테스트 코드를 작성해야하는 이유

테스트 코드를 작성하는 경험은 딱 한 번이었다.

Jest로 간단한 함수가 특정 값을 반환하는지 테스트하는 것이었다. 그런데 WebRTC 관련 코드를 처음 작성해보아서 어떤 식으로 테스트를 해야하는지 몰랐고, 단위 테스트, 통합 테스트 등 다양한 종류의 테스트 중 어떤 테스트를 선택해야하는지 몰랐다.

내가 테스트 코드를 작성해야하는 이유는 다음과 같았다.

  1. 화상회의 코드가 작성되어있는 세션 페이지의 코드가 리팩토링 이후에도 정상적으로 동작해야한다.
  2. 리팩토링 도중 오류가 발생해서 정해진 개발 일정에 차질이 생기지 않게 해야한다.

테스트 코드 작성 경험이 거의 없어 이를 배우고 작성하는데 시간이 어느정도 걸릴거라 생각했지만 테스트 코드 없이 리팩토링을 진행하다 오류가 발생해서 수정하는 시간이 더 오래 걸릴거라 생각했다.

테스트 코드를 어떤식으로 작성할지 생각해본 결과, 파일 분리를 위한 리팩토링이므로 서버와 별개로 클라이언트 측에서 서버에 요청만 제대로 하는지 체크하면 된다고 생각했다.

📌 안전하게 리팩토링하기 위해 테스트 코드 작성을 결정했고, 클라이언트 측에서 요청을 제대로 보내는지만 확인하면 되기 때문에 단위 테스트를 하기로 결정했다.

단위 테스트란?
소프트웨어의 가장 작은 테스트 가능한 부분을 분리하여 검증한다. 즉, 하나의 함수/모듈이 예상대로 동작하는지 확인하는 테스트다. 다른 테스트나 외부 환경에 영향을 받지 않고 독립적으로 실행될 수 있어야하며, 데이터베이스나 외부 API에 의존하는 경우 이를 Mock으로 대체하여 테스트한다.


테스트할 코드를 분석하기

테스트를 통해 어떤 동작을 정확히 테스트 할지도 목표를 정해야했다. 이를 위해서 useSession 코드가 어떤 기능을 하고 있는지 분석을 하는게 필요했다.

useSession 훅에서의 기능이 무엇이 있는지 파악한 후 테스트할 기능을 다음과 같이 정리했다.

각 기능 별로 어떻게 동작하는지 파악한 후 테스트할 내용을 정리했다. 테스트할 내용을 보면 알겠지만 연결을 시도하는지, 요청을 보내는지에 대한 동작만 확인하고 실제 연결을 해서 어떤 응답을 받아오는지는 테스트하지 않는다.


테스트 도구 결정하기

React+Vite+Typescript로 프로젝트를 생성해서 테스트를 Jest가 아닌 vitest로 하는 것도 괜찮겠다고 생각했었다.

결론적으로는 Jesttesting-library/react를 이용해 테스트를 진행했다.

테스트 러너로 Vitest가 아닌 Jest를 택한 이유는 다음과 같았다.

  • 이전에 간단한 테스트를 Jest로 해본 경험이 있었고, 테스트 경험이 부족한 상황에서는 조금이라도 익숙한 Jest가 낫다고 판단했다.
  • Vitest는 최신 도구라서 Jest보다 레퍼런스가 많지 않기 때문에 테스트 경험이 거의 없는 나에게는 레퍼런스가 많은 Jest가 좋은 선택이라 판단했다.

testing-library/react를 택한 이유는 다음과 같았다.

  • 커스텀 훅을 테스트할 때 renderHook을 사용했는데, renderHook은 컴포넌트 없이도 훅의 반환값과 상태를 직접 테스트할 수 있어서 간편했다. Enzyme의 경우에는 훅을 테스트하기 위해서 반드시 컴포넌트로 감싸서 테스트해야했다.
  • act 함수로 비동기 작업, 상태 업데이트를 테스트할 때 사용했다. 다른 테스트 라이브러리는 상태 업데이트를 기다리거나 비동기 작업을 처리하는게 복잡해보였는데, act로 간단하게 상태 업데이트 처리를 할 수 있었다.

testing-library/react는 내부 구현보다 실제 동작에 초점을 맞추어서 리팩토링을 하기 위해 테스트하는 나에게 알맞은 라이브러리라 판단했다.

실제 테스트 코드의 일부

const { result } = renderHook(() => useSession("test-session"));

await act(async () => {
  await result.current.joinRoom();
});

expect(mockSocket.emit).toHaveBeenCalledWith(SESSION_EMIT_EVENT.JOIN, {
  roomId: "test-session",
  nickname: "test-user",
});
  • renderHook으로 useSession 훅을 초기화해서 훅을 사용할 수 있는 환경을 만든다. renderHook이 테스트용 컴포넌트를 만들어주고 이 컴포넌트 안에서 useSession 훅이 실행된다. result.current를 통해 훅이 반환한 값에 접근할 수 있게 된다.
  • act 함수로 joinRoom 함수를 실행한다. 이 함수는 비동기 함수라 async/await와 함께 사용해주었고, 해당 함수를 실행하여 상태를 업데이트해준다.
  • expect를 통해 예상한 동작이 실제로 발생했는지 검증한다. 모킹한 소켓이 join 이벤트를 roomId, nickname 데이터와 함께 호출되었는지 확인한다.

가장 어려웠던 모킹하기

단위 테스트를 하며 가장 어려웠던 건 모킹이었다. 실제 서버와의 연결 유무는 중요하지 않고 클라이언트가 요청을 하는지만 확인하면 되었기 때문에 서버와 연결되었다 가정하고 응답과 같은 부분을 모킹을 해야했다.

모킹이란?
모킹은 테스트하고자하는 코드가 의존하는 함수, 클래스에 대해 가짜를 만들어서 돌아가게 하는 것이다. 주로 단위 테스트에서 객체나 함수의 동작을 가짜로 만들어서 실제 코드와의 상호작용 없이 테스트를 수행한다.

모킹을 만들 때는 원본이 반환하는 모든 값을 포함해야하고, 실제 구현과 동일한 타입을 가져야한다. 소켓부터 peerConnection까지 다 모킹을 해주었다.

Mock 객체 생성하기

// 소켓 동작 모킹
const mockSocket: MockSocket = {
  emit: jest.fn(),
  on: jest.fn(),
  off: jest.fn(),
  id: "mock-socket-id",
};

// 소켓 스토어 모킹
const mockSocketStore = {
  socket: null as MockSocket | null,
  connect: jest.fn(),
  disconnect: jest.fn(),
};

// 미디어 스트림 모킹
const mockMediaStream = {
  getTracks: jest.fn().mockReturnValue([{ stop: jest.fn(), enabled: true }]),
};

// 토스트 알림 모킹
const mockToast = { success: jest.fn(), error: jest.fn() };

// navigate 모킹
const mockNavigate = jest.fn();

// peerConnections 모킹
const mockPeerConnections: MockPeerConnections = {
  current: {
    "peer-1": {
      ontrack: null,
      onicecandidate: null,
      oniceconnectionstatechange: null,
      onconnectionstatechange: null,
      close: jest.fn(),
    },
  },
};

모듈 모킹하기

jest.mock이란?
모듈 자체를 모킹하여 기본 동작을 정의한다. 특정 모듈을 모킹해 해당 모듈의 함수, 객체를 자동으로 대체할 수 있게 해준다.

  • jest.mock('모킹할 모듈')만 사용하면 기본 모킹으로 반환 값이나 동작을 명시적으로 설정하지 않아서 Jest에서 자동으로 모킹하기 때문에 특정 동작 제어가 필요없을 때 사용한다.
  • jest.mock('모킹할 모듈', () => ({ ... }))와 같이 해당 모듈이 반환할 구체적인 값을 정의해서 커스터마이징을 할 수 있다.
jest.mock("@hooks/useMediaDevices");

jest.mock("@hooks/usePeerConnection", () => ({
  __esModule: true,
  default: jest.fn().mockReturnValue({
    createPeerConnection: jest.fn(),
    closePeerConnection: jest.fn(),
    peers: [],
    setPeers: jest.fn(),
    peerConnections: { current: {} },
  }),
}));

jest.mock("@hooks/useToast", () => ({
  __esModule: true,
  default: () => mockToast,
}));

jest.mock("react-router-dom", () => ({
  useNavigate: jest.fn(),
}));

jest.mock("@stores/useSocketStore", () => ({
  __esModule: true,
  default: jest.fn(() => mockSocketStore),
}));

jest.mock("@hooks/useSocket", () => ({
  __esModule: true,
  default: () => {
    const store = useSocketStore();
    if (!store.socket) {
      store.connect("test-url");
    }
    return { socket: store.socket };
  },
}));

위와 같이 모킹을 하는데 시간을 제일 많이 썼던거 같다. 테스트 코드를 작성할 때 생긴 오류는 제대로 모킹하지 않아 생긴 오류가 대부분이었다. 모킹 자체를 처음해봐서 대충하고 오류가 생기면 고민하고를 반복했는데, 모킹할 모듈에 대해 제대로 파악하고 모킹하는게 중요하다는 걸 느꼈다.

📌 모킹을 할 때는 모킹할 모듈을 제대로 파악하는 것이 중요하다. 모킹 대상의 인터페이스를 정확히 파악하고 반환값이 무엇인지 정확히 파악한 후 모킹을 해야 실제 동작과 유사하게 흉내낼 수 있다.

모듈 모킹 시 호이스팅에 주의하기

🚨 jest.mock은 모듈이 로드되기 전에 실행된다. 호이스팅되어 외부 변수가 초기화되기 전에 접근하게 되고 이렇게 사용해서 undefined로 뜨는 문제가 있었다.

jest.mock("@hooks/usePeerConnection", () => ({
  __esModule: true,
  default: jest.fn().mockReturnValue({
    createPeerConnection: jest.fn(),
    closePeerConnection: jest.fn(),
    peers: [],
    setPeers: jest.fn(),
    peerConnections: { current: {} }, // ❎ 여기서 바로 mockPeerConnections을 사용할 수 없음
  }),
}));

describe("useSession Hook 테스트", () => {
  beforeEach(() => {
    jest.clearAllMocks();

    (usePeerConnection as jest.Mock).mockReturnValue({
      createPeerConnection: jest.fn(),
      closePeerConnection: jest.fn(),
      peers: [],
      setPeers: jest.fn(),
      peerConnections: mockPeerConnections, // ✅ beforeEach에서 사용하도록 수정
    });
  });
});

mockPeerConnection을 jest.mock 안에서 바로 사용하려했었는데 undefined 오류가 발생했다. jest.mock은 모듈이 로드되기 전에 실행되고 mockPeerConnection은 선언되지 않은 상태이기 때문에 문제가 생기는 것이었다. 따라서 beforeEach에서 사용해주도록 수정했다.

beforeEach는 테스트가 실행되기 전 특정 코드를 실행시켜주는 함수로 테스트 환경을 초기화하거나 테스트에 필요한 데이터를 설정하는 작업을 할 수 있다.

자바스크립트의 실행 컨텍스트 이해하기 (+ 스코프 체인, 호이스팅)
호이스팅에 대해 개념이 헷갈려서 호이스팅에 대해서는 따로 공부 후 위 포스팅에 적어두었다.


테스트 코드 작성 후 리팩토링 진행하기

PR 링크

  • 리팩토링 전에 테스트 코드를 실행해보고, 파일을 하나씩 기능 별로 분리할 때마다 테스트 코드를 실행하며 문제가 있는지 없는지 체크할 수 있었다.
  • 최종적으로 다 분리하고 나서 테스트 코드를 실행했을 때 모두 pass가 떴고 실제로 화상회의 기능 테스트를 했을 때도 문제가 없었다.

파일 분리 후 구조

/hooks
  /session
  ├─ useMediaDevices.ts
  ├─ usePeerConnection.ts
  ├─ useMediaStreamCleanup.ts
  ├─ usePeerConnectionCleanup.ts
  ├─ useReaction.ts
  ├─ useSocketEvents.ts
  └─ useSession.ts

useSession을 기준으로 peerConnection 관리, 리액션 기능, 소켓 이벤트 관리 등의 큰 기능 별로 분리할 수 있었다.

테스트 코드가 실제로 얼마나 좋았을까?

  • 리팩토링을 시도하기 전 불안함이 컸었다. 코드 분리를 시도하려다 핵심 기능인 화상회의가 동작하지 않아서 이를 수정하느라 정해진 개발 일정에 차질이 생기는게 가장 큰 걱정이었다. 그런데 테스트 코드를 작성하고 분리를 시작해서 앞의 걱정 없이 각 기능이 정상 동작하는지 확인하며 리팩토링을 진행할 수 있었다.
  • 또한 서버 측에서 리팩토링을 시도해서 화상회의 기능이 동작하지 않는 문제가 있었다. 이 때 서버 측에서의 리팩토링이 원인인지 모르고 테스트 코드를 실행해보았을 때 클라이언트 측 동작이 문제가 없는 것을 인지하고 서버 측 문제임을 빠르게 인지할 수 있었다.

현재에는 더 많은 기능이 추가되었고, 일부 동작이 초반과 달리 변경된 부분도 있다. 화상회의실에 입장할 때 체크하는게 달라지는 등과 같은 동작과 같은 것들이다. 그래서 현재는 변경해야하는 테스트나 추가해야할 테스트도 있겠지만, 해당 테스트 코드를 작성하던 시점 즉, 리팩토링을 시도하려던 시점에서 테스트 코드를 작성해서 유용하게 사용할 수 있었다.

테스트 코드의 필요성에 대해 느낀 적이 없었고, 테스트를 할 때 테스트 코드를 작성하기보다는 실제 사용해보며 테스트하는게 편하다 느꼈었다. 그런데 이번에 리팩토링을 시도하며 테스트 코드가 단순히 검증하는 도구인걸 넘어 리팩토링을 할 때 안전장치 역할을 할 수 있다는 것을 배울 수 있었다.


테스트 코드의 의미를 배울 수 있었다

부스트캠프에서 체크아웃 질문으로 이번 주에 인상깊었던 문제해결 경험을 적는 날에 적었던 메시지다.

테스트 코드 경험이 거의 없어 이를 학습하고 시도해봐도 될까하는 고민도 있었다. 테스트 코드 없이 리팩토링을 시도하다 개발 일정에 차질이 생기는 것도 큰 문제이지만 테스트 코드를 도입했다가 오히려 제대로 작성하지 못해서 테스트 코드에 시간을 많이 투자하게 되어서 오히려 개발 일정에 차질이 생기지 않을까하는 고민도 있었다.

그럼에도 테스트 코드를 작성을 시도했다. 만약 테스트 코드 작성이 성공으로 끝나지 않더라도 이 과정에서 코드 동작을 더 깊이 이해할 수 있을거라 생각했고, 실제로도 각 기능을 테스트하기 위해 어떤 결과가 나와야하는지 고민하는 과정에서 코드 흐름을 더 명확하게 파악할 수 있었다.

처음 작성해보는 테스트 코드라 완벽하지 않은 테스트 코드였을 수도 있다. (실제로 많은 엣지 케이스에 대한 고려는 하지 못했고, 큰 기능 위주로만 테스트를 진행했다.) 그러나 이 경험을 통해서 테스트 코드가 단순히 오류를 검출하는 도구의 의미를 넘어 코드를 이해하고 개선하는데 도움을 주고 리팩토링을 진행할 때 안전장치 역할을 해주는 도구라는 것을 배울 수 있었다.

profile
안녕하세오

0개의 댓글

관련 채용 정보