화상회의
기능을 구현하는데 회의 자체에 참여할 인원을 5명으로 제한했기 때문에 P2P 방식으로 회의에 참여하는 사람들이 모두 연결되는 Mesh 방식을 사용해 구현했다. WebRTC를 이용해서 화상회의 기능을 개발하는게 처음이었고, 소켓 통신을 하다보니 올바르게 작성하는지에 대한 확신이 없었다.
그래서 세션 페이지 코드 파일 하나에 관련 로직을 모두 작성하였다. 이 코드가 제대로 동작하는지 검증이 필요했고 이 코드가 실제로 구현 가능한지 한 파일에 프로토타이핑을 해서 가능함을 확인했다. 이를 통해 코드가 정상적으로 동작하는지 쉽게 파악할 수 있었다.
그러나 기능이 추가되면서 코드가 길어지면서 로직이 복잡해지는 문제가 있었다. 기능 개발이 완료된 상태가 아니었고 앞으로 기능이 더 추가될 예정이었는데 코드를 봤을 때 정확히 어떤 코드가 어떤 동작을 하는지 알아보려해도 가독성이 좋지 않았다.
실제로 서버 측에서 보내는 데이터의 형식이 바뀌어 화상회의 기능이 동작하지 않는 문제가 있었다. 그런데 서버 측의 문제인지 인지하지 못한 상태에서 세션 페이지 코드를 하나씩 읽어나가는데 오류를 찾으려하니 코드가 너무 길고 가독성이 좋지 않아 오류를 찾기가 생각보다 어렵다고 느껴졌다.
🚨 앞으로 기능이 추가되면 코드가 더 길어지고 복잡해질 것이었고, 현재 상태에서도 오류가 발생했을 때 가독성이 좋지 않아 찾는 속도가 느리고 흐름 파악이 쉽지 않아 파일을 기능 별로 분리할 필요성을 느꼈다.
우선 세션 페이지 코드에서 비즈니스 로직을 useSession 훅으로 1차로 분리했다. 그럼에도 300줄이 넘어가고 있었다.
위 2가지를 만족할 해결 방법으로 테스트 코드를 생각했다. 테스트 코드를 작성하고 안전하게 리팩토링을 진행하는 것을 목표로했다.
테스트 코드를 작성하는 경험은 딱 한 번이었다.
Jest
로 간단한 함수가 특정 값을 반환하는지 테스트하는 것이었다. 그런데 WebRTC 관련 코드를 처음 작성해보아서 어떤 식으로 테스트를 해야하는지 몰랐고, 단위 테스트, 통합 테스트 등 다양한 종류의 테스트 중 어떤 테스트를 선택해야하는지 몰랐다.
내가 테스트 코드를 작성해야하는 이유는 다음과 같았다.
테스트 코드 작성 경험이 거의 없어 이를 배우고 작성하는데 시간이 어느정도 걸릴거라 생각했지만 테스트 코드 없이 리팩토링을 진행하다 오류가 발생해서 수정하는 시간이 더 오래 걸릴거라 생각했다.
테스트 코드를 어떤식으로 작성할지 생각해본 결과, 파일 분리를 위한 리팩토링이므로 서버와 별개로 클라이언트 측에서 서버에 요청만 제대로 하는지 체크하면 된다고 생각했다.
📌 안전하게 리팩토링하기 위해 테스트 코드 작성을 결정했고, 클라이언트 측에서 요청을 제대로 보내는지만 확인하면 되기 때문에 단위 테스트를 하기로 결정했다.
단위 테스트란?
소프트웨어의 가장 작은 테스트 가능한 부분을 분리하여 검증한다. 즉, 하나의 함수/모듈이 예상대로 동작하는지 확인하는 테스트다. 다른 테스트나 외부 환경에 영향을 받지 않고 독립적으로 실행될 수 있어야하며, 데이터베이스나 외부 API에 의존하는 경우 이를 Mock으로 대체하여 테스트한다.
테스트를 통해 어떤 동작을 정확히 테스트 할지도 목표를 정해야했다. 이를 위해서 useSession 코드가 어떤 기능을 하고 있는지 분석을 하는게 필요했다.
useSession 훅에서의 기능이 무엇이 있는지 파악한 후 테스트할 기능을 다음과 같이 정리했다.
각 기능 별로 어떻게 동작하는지 파악한 후 테스트할 내용을 정리했다. 테스트할 내용을 보면 알겠지만 연결을 시도하는지, 요청을 보내는지에 대한 동작만 확인하고 실제 연결을 해서 어떤 응답을 받아오는지는 테스트하지 않는다.
React
+Vite
+Typescript
로 프로젝트를 생성해서 테스트를 Jest
가 아닌 vitest
로 하는 것도 괜찮겠다고 생각했었다.
결론적으로는
Jest
와testing-library/react
를 이용해 테스트를 진행했다.
테스트 러너로 Vitest가 아닌 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와 함께 사용해주었고, 해당 함수를 실행하여 상태를 업데이트해준다. 단위 테스트를 하며 가장 어려웠던 건 모킹이었다. 실제 서버와의 연결 유무는 중요하지 않고 클라이언트가 요청을 하는지만 확인하면 되었기 때문에 서버와 연결되었다 가정하고 응답과 같은 부분을 모킹을 해야했다.
모킹이란?
모킹
은 테스트하고자하는 코드가 의존하는 함수, 클래스에 대해 가짜를 만들어서 돌아가게 하는 것이다. 주로 단위 테스트에서 객체나 함수의 동작을 가짜로 만들어서 실제 코드와의 상호작용 없이 테스트를 수행한다.
모킹을 만들 때는 원본이 반환하는 모든 값을 포함해야하고, 실제 구현과 동일한 타입을 가져야한다. 소켓부터 peerConnection까지 다 모킹을 해주었다.
// 소켓 동작 모킹
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 링크
/hooks
/session
├─ useMediaDevices.ts
├─ usePeerConnection.ts
├─ useMediaStreamCleanup.ts
├─ usePeerConnectionCleanup.ts
├─ useReaction.ts
├─ useSocketEvents.ts
└─ useSession.ts
useSession을 기준으로 peerConnection 관리, 리액션 기능, 소켓 이벤트 관리 등의 큰 기능 별로 분리할 수 있었다.
현재에는 더 많은 기능이 추가되었고, 일부 동작이 초반과 달리 변경된 부분도 있다. 화상회의실에 입장할 때 체크하는게 달라지는 등과 같은 동작과 같은 것들이다. 그래서 현재는 변경해야하는 테스트나 추가해야할 테스트도 있겠지만, 해당 테스트 코드를 작성하던 시점 즉, 리팩토링을 시도하려던 시점에서 테스트 코드를 작성해서 유용하게 사용할 수 있었다.
테스트 코드의 필요성에 대해 느낀 적이 없었고, 테스트를 할 때 테스트 코드를 작성하기보다는 실제 사용해보며 테스트하는게 편하다 느꼈었다. 그런데 이번에 리팩토링을 시도하며 테스트 코드가 단순히 검증하는 도구인걸 넘어 리팩토링을 할 때 안전장치 역할을 할 수 있다는 것을 배울 수 있었다.
부스트캠프에서 체크아웃 질문으로 이번 주에 인상깊었던 문제해결 경험을 적는 날에 적었던 메시지다.
테스트 코드 경험이 거의 없어 이를 학습하고 시도해봐도 될까하는 고민도 있었다. 테스트 코드 없이 리팩토링을 시도하다 개발 일정에 차질이 생기는 것도 큰 문제이지만 테스트 코드를 도입했다가 오히려 제대로 작성하지 못해서 테스트 코드에 시간을 많이 투자하게 되어서 오히려 개발 일정에 차질이 생기지 않을까하는 고민도 있었다.
그럼에도 테스트 코드를 작성을 시도했다. 만약 테스트 코드 작성이 성공으로 끝나지 않더라도 이 과정에서 코드 동작을 더 깊이 이해할 수 있을거라 생각했고, 실제로도 각 기능을 테스트하기 위해 어떤 결과가 나와야하는지 고민하는 과정에서 코드 흐름을 더 명확하게 파악할 수 있었다.
처음 작성해보는 테스트 코드라 완벽하지 않은 테스트 코드였을 수도 있다. (실제로 많은 엣지 케이스에 대한 고려는 하지 못했고, 큰 기능 위주로만 테스트를 진행했다.) 그러나 이 경험을 통해서 테스트 코드가 단순히 오류를 검출하는 도구의 의미를 넘어 코드를 이해하고 개선하는데 도움을 주고 리팩토링을 진행할 때 안전장치 역할을 해주는 도구라는 것을 배울 수 있었다.