운영 중인 자매 레포 두 개에 뒤늦게 jest 테스트를 도입했습니다. 단위·컴포넌트·통합, 그리고 채팅의 socket 이벤트까지 네 단계를 한 달 남짓 차례로 깔았고, 그 사이엔 코드를 만진 뒤에야 보이는 함정과 결정의 두께가 있었습니다.
시작점: 테스트 한 줄 없는 레포 두 개
운영 중인 자매 레포가 둘 있습니다. 하나는 어드민, 하나는 서비스. 둘 다 Next.js 15 위에 Mongoose, NextAuth, FCM, SMS 발송기가 얽혀 돌고 있고, 그 위에서 권한 검증·메시지 발송·푸시 알림이 매일 인증과 결제와 소통을 처리합니다.
두 레포는 본업 옆에서 운영해 온 사이드 프로젝트입니다. 인원이 적고 운영 대응과 기능 추가가 늘 우선 순위에 있었으니 테스트 인프라는 자연스럽게 뒤로 밀려 있었습니다. 그래도 이쯤이면 깔아야겠다는 생각은 늘 있었습니다 — 그런데 jest도, vitest도, testing-library도 devDependencies에 한 줄이 없었고, 배포 스크립트는 곧장 git reset --hard + pm2 restart로 운영 VM에 코드를 떨어뜨립니다. 사람 눈검토 하나만 통과하면 그대로 라이브시켰습니다.
미루던 일을 끝내기로 했습니다. 단위·컴포넌트·통합 세 종류의 테스트 자동화부터 도입하기로 했습니다.
자매 레포는 거울처럼 닮아있다
두 레포가 자매라는 사실이 결정적이었습니다. 모델 네이밍, 디렉터리 구조, NextAuth 설정, 로깅 헬퍼 — 절반쯤은 거울처럼 닮아 있습니다. 그래서 전략은 단순했습니다.
어드민에서 먼저 부딪힌다. 그 뒤에 서비스로 같은 패턴을 옮긴다.
테스트 인프라 결정, 디렉터리 위치, mock 표준 블록, 함정 우회 등 다양한 시도를 내부 인원만 사용하는 어드민에서 도전해볼 수 있었습니다. 그렇게 한 번 결정한 것들을 두 번째 레포에서 처음부터 다시 고민하지 않아도 됐습니다.
각 단계 종료 시점의 테스트 수는 이렇습니다.
| 단계 | 어드민 | 서비스 |
|---|---|---|
| 1. 단위 (utils 순수 함수) | 81 | 98 |
| 2. + 컴포넌트·단위 확장 | 185 | 233 |
| 3. + 통합 (in-memory Mongo) | 196 | 391 |
| 4. + Socket.IO | — | 422 |
서비스 쪽이 더 큰 이유는 단순합니다. 사용자 수가 많고, 채팅·푸시·모더레이션 같은 비동기 결합이 어드민에는 없습니다.
1단계: 가장 무난한 곳, utils의 순수 함수
처음부터 큰 지점을 건드리면 거기서 막힌 채 한 발도 못 나갈 수도 있을 것 같았습니다. 그래서 시작은 외부 I/O가 없는 순수 함수 5~10개. 권한 체크, 형식 검증, 상대시간 포매팅 같은 것들. DB·네트워크·파일·시간이 결합된 함수는 단위 테스트 범위 밖으로 미리 빼놨습니다.
여기서 첫 큰 함정과, 그 함정 뒤에 숨어 있던 구조적 문제 하나를 함께 마주쳤습니다.
top-level await, 그리고 그 뒤의 레거시
DB 헬퍼 파일 최상단에 이런 줄이 있었습니다.
const db = await dbConnect();
모듈 최상단의 await. 이게 들어간 사연은 이렇습니다. 한참 전 어느 시점에 DB 접속이 불안정해 보이는 증상이 있었고, 모듈 로드 시점에 미리 연결을 잡아두면 안전해질 거라고 봤다고 합니다. 그 뒤에 실제로 안정화되었는지 무관하게 이 줄은 남았습니다. 한 번 박힌 부수효과를 빼는 PR은 운영 리스크가 있어 보였고, 빼려는 시도도 하지 못 하고 있었습니다.
테스트를 깔면서 이 한 줄이 가진 구조적 비용을 보게 됐습니다.
첫째, 이 모듈은 import만 해도 DB에 연결합니다. 모듈 안의 순수 변환 함수 하나만 쓰고 싶어도 같이 연결이 트리거됩니다. 빌드 도구, 분석 도구, 그리고 테스트가 이 파일을 직간접 import하는 순간 mongoose가 깨어납니다. 의존 그래프 어느 한 구석에 이 파일이 끼어 있기만 하면 됩니다. 명시적인 호출이 없어도 일어나는 일은 추적하기 어렵습니다.
둘째, top-level await는 ES Module에서만 동작합니다. 운영에서는 ESM이 처리하지만 jest는 SWC 트랜스파일 결과를 CommonJS로 실행합니다. 그래서 jest는 syntax 단계에서 막힙니다. mongoose를 mock해도 문법 자체가 안 통하니 우회가 안 됩니다.
셋째, 연결 실패가 모듈 로드 실패와 같아집니다. 원래 이 코드는 “안정성”을 위해 들어간 것인데, 실제로는 연결이 한 번 어긋날 때 그 모듈을 의존하는 모든 곳이 한꺼번에 부서질 수 있는 단일 실패점을 만든 셈입니다. 일반적으로는 첫 호출 시점에 연결하고 이후 캐시하는 lazy 패턴이 더 안전합니다. mongoose 자체가 이미 그렇게 동작합니다.
그렇다고 이번 PR에서 이 줄을 빼버리는 건 범위를 넘는 일이었습니다. 그래서 우회를 택했습니다. 순수한 변환 헬퍼만 새 파일로 분리하고, 원본은 거기서 re-export 합니다. 호출처 import 경로는 한 줄도 안 바뀝니다.
// 새로운 파일로 헬퍼 분리
export function simplifyItem(...) { ... }
export function simplifyList(...) { ... }
// 기존 헬퍼는 수정
export { simpleItem, simpleList } from './data';
이 패턴은 두 레포 모두에 적용됐고, 단위 테스트가 가능한 영역을 한 폴더로 모아두는 부수 효과도 있었습니다. 원래 줄은 그대로 두되, 그 뒤에 가려져 있던 순수 함수만 꺼내 놓은 셈입니다.
1월과 2월의 경계, 그리고 잠금이라는 선택
식별자 검증 함수 하나에서 작은 모호함을 만났습니다. 첫 네 자리에서 연도를 뽑아 정해진 범위 안에 들어오는지 보는 함수였는데, 그 범위의 끝이 시간 의존이었습니다. 정확히는 “현재 시점이 2월 이후면 올해까지 허용, 1월이면 작년까지만”이라는 형태였습니다. 사실 제가 만든 거니 1월과 2월의 경계가 왜 거기에 있었는지 의도는 기억이 났지만, 코드만 봐서는 모호한 부분이 있었습니다.
이런 종류의 애매한 케이스 앞에서 한 번 멈춥니다. 명확하게 고칠 것인가, 일단 둘(잠글) 것인가. 고치면 어딘가에서 의도치 않게 의존하던 부수효과를 건드릴 수 있고, 잠그면 모호한 동작이 그대로 굳습니다. 테스트 도입만이 목적이니만큼 현재 동작을 잠그는 쪽을 택했습니다. fake timer로 1월 31일과 2월 1일을 두 번 다 시스템 시각으로 박아놓고, 각 시점의 결과가 코드가 지금 내놓는 그 답과 같다는 사실을 굳혔습니다. 의문점들은 따로 정리해서 나중에 열어보기로 했습니다. 안전망이 먼저고, 정정은 그 다음입니다.
2단계: 사용자가 보는 방식으로, 컴포넌트
단위 다음은 컴포넌트입니다. 비즈니스 로직은 다시 단위에서, 외부 라이브러리 본체는 테스트하지 않는다는 원칙으로 — getByRole/getByLabelText 우선, 구현 세부 의존은 피합니다. 여기서는 함정이 줄줄이 따라왔습니다. 그 중에 몇 개만 보여드리면…
jest.mock과 path alias의 어긋남
테스트 파일에서 jest.mock('@/utils/log', ...)를 호출했더니 “Cannot find module ’@/…’ from jest.mock” 에러가 났습니다. 이상한 점은 같은 파일의 일반 import는 잘 동작했다는 겁니다. 원인은 next/jest의 자동 alias 매핑이 일반 import에는 적용되지만 jest.mock 첫 인자에는 적용되지 않는다는 점이었습니다. 해결책은 moduleNameMapper를 jest config에 명시하는 것. 한 줄짜리 수정이지만 next/jest가 다 알아서 해줄 거라고 믿었던 만큼 멀리 돌아 찾았습니다.
// jest.config.js
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
fake timer와 userEvent의 미묘한 충돌
상대시간을 표시하는 컴포넌트가 있어서 시간을 고정해야 했습니다. jest.useFakeTimers()를 켜고 표시되는 텍스트를 검증했더니 그 부분은 잘 통과합니다. 그런데 같은 컴포넌트에서 userEvent.click() 같은 상호작용을 함께 검증하려고 했을 때 테스트가 영원히 끝나지 않았습니다.
원인은 @testing-library/user-event 14버전 이후의 동작 방식에 있습니다. 이 라이브러리는 사용자 입력을 시뮬레이션할 때 내부적으로 setTimeout을 써서 키 입력 사이의 미세한 지연이나 비동기 시퀀스를 흉내 냅니다. fake timer가 켜져 있으면 이 setTimeout이 advance될 때까지 기다리는데, 누가 advance를 호출해주지 않으면 그대로 멈춰버립니다. 그래서 await user.click(...)이 영영 resolve되지 않습니다.
정공법은 userEvent를 setup할 때 jest의 timer advance 함수를 넘기는 것입니다.
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
});
이 setup을 매 케이스에서 새로 만들고 userEvent를 그걸로 통일하면 충돌이 없어집니다. 일부 단순한 케이스에서는 그냥 fireEvent로 우회하는 쪽이 더 가볍기도 합니다 — 인터랙션이 한 번의 클릭으로 끝나고 키 시퀀스가 없는 경우. 두 방식을 다 알고 있다가 케이스의 무게에 따라 골랐습니다.
”월”이 두 곳에서 매칭
날짜 선택 컴포넌트 테스트가 가끔 빨갛게 떴습니다. 무작위한 게 아니라 특정 날짜 셀을 찾을 때만 그랬는데, 원인은 단순했습니다. 날짜 셀에는 “12월 3일” 같은 문자열이 있고, 같은 화면에 요일로서의 “월”이 있었습니다. getByText('월') 같은 검증이 두 곳에 동시에 매칭됐습니다. 검증 대상을 고유한 요일 — 일·화·수·금 — 으로 바꾸자 깔끔해졌습니다. 테스트 쿼리는 사용자가 보는 것을 흉내 내야 하지만, 사용자가 보는 화면에는 겹치는 글자가 흔히 있다는 점을 새삼 깨달았습니다.
로깅이 온 우주를 끌어옵니다
컴포넌트가 logger를 import만 해도 GCP Logging 패키지가 같이 로드되고, 그 패키지가 ESM 전환된 uuid를 끌고 들어와서 jest CJS에서 산산조각 납니다. 정확히 무엇이 무엇을 import하는지는 깊이 들어가기 시작하면 끝이 없어서, 그냥 logger를 표준 mock 블록으로 차단하는 쪽을 택했습니다.
jest.mock('@/log', () => ({
__esModule: true,
default: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
이 블록은 모든 테스트 파일 상단에 들어가게 됐고, 결국 통합 단계에서도 그대로 쓰이는 표준 우회법이 됐습니다.
3단계: 가장 자주 깨지는 결합, 통합 테스트
이 단계가 가장 무거웠습니다. 원칙은 “mock으로 다 막으면 의미 없음, Fake 선호, 5분 이내, 테스트 상호 의존 금지”. 그래서 도구는 mongodb-memory-server. 진짜 MongoDB 바이너리를 in-process로 띄워 mongoose 쿼리를 진짜로 돌립니다. Docker도 CI 추가 셋업도 필요 없습니다.
첫 결합 지점은 인증 흐름이었습니다. 다른 어떤 곳보다 사고 났을 때 영향이 큽니다. 정상 로그인, 사용자 식별자 변환, 누락된 필드, 존재하지 않는 사용자, 인증 대기 상태, 삭제된 사용자, 인증 모델 누락, 비밀번호 불일치 — 한 함수에 케이스를 모았습니다. mongoose 쿼리는 진짜, 비밀번호 해시 비교도 진짜. 외부 SDK만 mock. 첫 PR은 이 흐름 하나로 머지했고, 그 다음 PR에서 가입·계정 복구·미들웨어·알림 fan-out·글 작성·댓글·좋아요·광고·신고·탈퇴 같은 시나리오를 한꺼번에 추가했습니다.
이 단계의 함정 몇 개를 적어둡니다.
jsdom과 mongoose의 환경 충돌
jest의 기본 환경을 jsdom으로 통일해놓은 상태였습니다. 컴포넌트 테스트엔 그게 맞기 때문입니다. 그런데 mongoose를 같은 환경에서 쓰려고 했더니 bson 내부의 ESM 처리가 jsdom 환경에서 폭발했습니다. 두 환경을 jest config에서 project로 분리하는 길도 있었지만, 가장 단순한 해결은 통합 테스트 파일 상단에 환경 docblock을 한 줄 박는 것이었습니다.
/** @jest-environment node */
이 한 줄로 해당 파일만 Node 환경으로 분리됩니다. 다른 환경 옵션은 모두 그대로 둡니다.
fake timer가 mongoose의 심장을 멈춥니다
시간 의존 쿼리를 검증하려면 시간 고정이 필요한데, jest.useFakeTimers()를 그냥 켜면 mongoose의 connection이 안 끝납니다. mongoose는 내부적으로 setTimeout을 써서 connection 유지나 ping을 처리하는데, fake timer가 그것까지 일괄 통제하면 어느 advance도 일어나지 않으니 연결이 영영 establish되지 않습니다.
해결은 어느 타이머를 fake로 잡고 어느 타이머를 진짜로 둘지 명시하는 것이었습니다. 결국 setSystemTime만 살리고 나머지는 거의 다 풀어주는 긴 목록을 적게 됐습니다.
jest.useFakeTimers({
doNotFake: [
'setTimeout', 'setInterval', 'setImmediate',
'clearTimeout', 'clearInterval', 'clearImmediate',
'nextTick', 'queueMicrotask',
'requestAnimationFrame', 'cancelAnimationFrame',
'requestIdleCallback', 'cancelIdleCallback',
'hrtime', 'performance',
],
});
이쯤 되면 “fake timer”라는 이름이 무색하지만, 시간에 의존하는 함수 테스트에는 이 정도면 충분했습니다.
top-level await가 다시 등장합니다
1단계에서 우회했던 DB 헬퍼 문제가 통합 단계에서 또 옵니다. 통합 테스트는 모델을 실제로 import해서 진짜 쿼리를 돌리니까요. 그래서 표준 mock 블록을 한 번 더 만들었습니다. 모듈 전체를 mock하되, 순수 변환 함수만 requireActual로 실제 구현을 끌어옵니다.
jest.mock('@/database', () => {
const data = jest.requireActual('@/data');
return {
__esModule: true,
simplifyItem: data.simplifyItem,
simplifyList: data.simplifyList,
db: {},
};
});
레거시 한 줄이 만든 그림자가 두 단계에 걸쳐 다른 형태의 우회법을 요구한 셈입니다. 이쯤 되면 그 한 줄을 빨리 빼내버려야 할 거 같기도 하네요.
모델 스키마의 작은 함정
mongoose 모델 중에는 required: true이면서 default: ''로 빈 문자열 기본값이 설정된 필드가 종종 있었습니다. 운영에서는 어딘가에서 자연스럽게 채워지거나 빈 문자열로도 통과해도 무방한 흐름이었지만, 통합 테스트의 시드 데이터에서는 이런 필드를 명시하지 않으면 첫 실행이 빨갛게 떴습니다. 시드 보일러플레이트가 늘어나는 비용을 감수하고 시나리오마다 시드 헬퍼 함수를 작성해서 재사용했습니다.
4단계: 채팅이라는 비동기 데이터, Socket.IO
여기서부터 자매 레포가 갈라집니다. 서비스에만 채팅이 있고, 어드민엔 socket server 자체가 없습니다. 미러링할 자리가 없으니 처음 도입하는 셈입니다. 채팅은 사용자 체감이 즉각인 영역입니다. 메시지가 안 가거나 입력 중 표시가 안 사라지면 바로 컴플레인이 들어오니 테스트는 꼭 필요했습니다.
도구는 socket.io 공식 권장 패턴 그대로 — http.createServer + 서버 초기화 함수 + socket.io-client. mock 없이 진짜 서버와 진짜 클라이언트를 인-프로세스로 호스팅합니다.
beforeAll(async () => {
httpServer = createServer();
ioServer = initializeSocketIO(httpServer);
await new Promise<void>(resolve => {
httpServer.listen(0, () => {
port = (httpServer.address() as any).port;
resolve();
});
});
});
listen(0)은 OS 자동 할당입니다. 병렬 실행 시 포트 충돌이 없습니다. 클라이언트는 forceNew: true + transports: ['websocket']로 polling fallback의 timing 이슈를 피했고, 연결 정리는 매 케이스 disconnect로 했습니다.
검증한 곳은 셋이었습니다.
핸들러 통합 — 여러 클라이언트→서버 이벤트가 룸에 어떻게 broadcast되는지 검증합니다. 같은 룸의 다른 socket은 받지만 본인은 받지 않는다는 socket.to의 본인 제외 동작, 다른 룸으로 새는지 여부, 수신자 식별자가 따로 있을 때 다른 룸으로 fan-out하되 이미 같은 룸에 있다면 중복 emit을 막는 분기까지.
서버 액션에서 emit하는 곳 — 메시지 모더레이션 결과가 특정 조건일 때 서버 액션이 룸으로 broadcast를 쏘는 흐름이 있습니다. 메모리 mongo와 socket server를 동시에 셋업하고, 진짜 client로 broadcast 수신을 검증했습니다. mock하지 않은 진짜 연결입니다.
클라이언트 reconnect 정책 — 클라이언트 측 Provider가 socket을 만들 때 reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000 같은 값을 옵션으로 넘깁니다. 이 값들이 바뀌면 사용자 체감이 즉각 달라질 수 있지만, 코드 리뷰에서 작은 숫자 한두 개가 바뀌는 건 놓치기 쉽습니다. 그래서 reconnect 동작 자체는 라이브러리 책임으로 두고, 우리가 넘기는 값만 잠갔습니다. 5회를 3회로 바꾸거나 1초를 5초로 바꾸면 빨강이 뜹니다.
함정은 socket 특유의 비동기 race condition들이었습니다. emit 이후 처리 시점을 알 길이 없으므로 짧은 settle(50ms) 헬퍼로 우회했고, “수신하지 않는다”는 부정 케이스 검증은 짧은 timeout과 함께 rejects.toThrow(/timeout/) 패턴으로 잡았습니다. socket.to(...)가 본인 제외 broadcast라는 점을 잊으면 “왜 본인이 자기 메시지를 못 받지?”라는 디버깅에 시간을 잃습니다.
그 사이에 있었던 결정들
테스트를 쓰다 보면 “이 코드 의도가 뭐지” 싶은 곳들이 나타납니다. 비슷한 동작이 두 진입점에서 후처리가 미묘하게 다르거나, 받는 값들의 집합에서 어떤 한 값만 다르게 다뤄지거나, 부가 처리가 실패해도 본 흐름이 의도적으로 그대로 진행되거나.
이런 곳마다 fix 충동이 듭니다. 그러나 테스트 도입 PR에서 동작을 동시에 바꾸면 PR의 책임이 흐려집니다. 그래서 채택한 정책은 단순했습니다.
현재 동작을 그대로 잠근다. 의문점은 따로 정리한다. fix는 별도 PR로.
회귀 안전망이 먼저고, 의도 정정은 그 다음입니다. 안전망이 깔린 뒤에 고치면, 정정이 또 다른 회귀를 만들지 않는지 즉시 확인할 수 있습니다. 안전망 없이 고치면, 고친 결과가 원래 의도였는지 영영 알 수 없습니다.
그리고 마지막 검증으로 매 단계 머지 직전에 의도적 깨기를 했습니다. 권한 체크의 some을 every로, 토글 함수의 !checked를 checked로, 인증 흐름의 정상 상태 비교를 엉뚱한 값으로, send-message 핸들러의 socket.to를 io.to로. 매번 빨강을 한 번씩 확인한 뒤 원복. 코드는 커밋하지 않습니다. 테스트가 실제로 회귀를 잡는다는 증거는 한 번씩 본인 눈으로 봐야 안심이 됩니다.
도입은 끝났고, 그 뒤에 남은 것들
한 달 남짓의 결과는 단계별로 정확히 81 → 185 → 196, 그리고 98 → 233 → 391 → 422. npm test 한 번이 10초 안쪽에 도는 상태입니다. 처음 시작할 땐 “테스트가 있는 상태”를 만드는 것 자체가 목적이었고, 지금은 그 상태가 됐습니다.
다만 도입이 끝났다는 게 모든 게 완성됐다는 뜻은 아닙니다. 더 해야할 게 많이 남아 있고, 각각이 또 한 단계의 작은 프로젝트입니다.
좀 빠르게 해볼 수 있는 건 CI 워크플로입니다. 지금은 로컬에서만 npm test가 돕니다. PR마다 자동으로 돌리려면 GitHub Actions 같은 곳에 워크플로를 정의해야 합니다. 첫 도입은 단순하지만, in-memory mongo binary 캐시 전략이나 socket 테스트의 flaky 안정성 같은 결정이 따라옵니다.
그 다음은 시각 회귀. 컴포넌트가 의도치 않게 시각적으로 변했는지를 자동으로 잡으려면 Storybook 같은 것으로 컴포넌트 카탈로그를 만들고 Chromatic 같은 도구로 스크린샷 diff를 뜨는 게 좋습니다. 이번에 도입한 컴포넌트 테스트는 동작 회귀만 잡지 픽셀 회귀는 잡지 못합니다.
E2E는 그보다 한 칸 더 멉니다. Playwright 같은 브라우저 자동화 도구로 실제 사용자 시나리오를 재생합니다. 통합 테스트가 핸들러·액션 단위였다면, E2E는 로그인부터 메시지 전송까지 한 흐름으로 묶입니다. 비용이 가장 크지만 가장 사용자 가까이에서 검증하는 층입니다.
마지막으로 현재 동작을 잠가놨던 의문점들의 정정. 잠가둔 의문점은 잠긴 채로 영원히 두라는 의미가 아닙니다. 안전망이 깔린 지금이 오히려 하나씩 고치기에 가장 좋은 시점입니다.
테스트 자동화라는 중요한 도입은 끝났지만, 이 도입이 이제 막 나아가야할 새로운 길을 깔아주었습니다.
그리고 앞으로는 테스트부터 도입하면서 프로젝트를 시작하기로 결심했습니다.