Vitest로 서버 유틸을 고정하는 이유 — E2E 시대의 단위 테스트 재배치

Vitest테스트단위테스트Next.js품질

초록

Playwright E2E는 “브라우저에서 사용자 흐름이 여전히 도는가”를 묻는 데 강력한 도구입니다. 그러나 가격 계산 한 줄, 권한 가드 한 분기, 웹훅 서명 검증 헬퍼처럼 입력 대비 출력이 결정적인 순수 로직을 모두 E2E로 검증하려 하면, 스위트가 느려지고 셀렉터 변경에 취약해지며, 특히 AI 코딩 도구가 리팩터링한 뒤의 회귀를 늦게 발견하게 됩니다. 본 글은 Vitest(또는 동급의 단위 테스트 러너)가 E2E를 대체하는 도구가 아니라, 테스트 피라미드의 아래층을 경제적으로 두껍게 만드는 별개의 층이라는 주장을 중심으로 전개합니다. 이를 위해 먼저 피라미드가 뒤집혔을 때 나타나는 증상을 진단하고, 단위 테스트로 먼저 고정할 대상을 선정하는 기준을 제시하며, Next.js 환경에서 단위 테스트가 특히 어려운 이유와 그 처방으로서의 관심사 분리를 설명합니다. 마지막으로 AI가 코드를 생성하는 환경에서 단위 테스트가 갖는 역할—창의성을 억압하는 장치가 아니라 창의성이 스코프 밖으로 새는 것을 막는 난간—을 논의합니다.


1. 서론

테스트 전략에 대한 논쟁은 종종 “E2E냐 단위 테스트냐”라는 잘못된 이분법으로 흐릅니다. 실제로는 두 계층이 서로 다른 종류의 리스크를 담당하며, 어느 한쪽만으로는 다른 쪽의 역할을 대신할 수 없습니다. E2E 테스트는 실제 브라우저 환경에서 여러 컴포넌트, 라우팅, 네트워크 계층이 함께 동작할 때만 드러나는 통합 실패—예를 들어 버튼이 렌더링되지만 클릭 핸들러가 잘못된 엔드포인트를 호출하는 경우—를 잡아냅니다. 반면 단위 테스트는 이러한 통합을 생략하고, 하나의 함수나 모듈이 주어진 입력에 대해 명세된 출력을 내는지를 빠르고 결정적으로 검증합니다.

문제는 많은 팀이, 특히 초기 단계의 사이드 프로젝트나 소규모 팀이, “테스트를 짠다”는 행위 자체를 E2E와 동일시하는 경향이 있다는 점입니다. 이 동일시는 팀이 성장하면서 스스로 깨닫기보다, 대개 스위트가 너무 느려지거나 flaky해진 뒤에야 뒤늦게 문제로 인식되는 경우가 많습니다. 이는 부분적으로 E2E가 “실제로 동작하는 것을 보여준다”는 심리적 만족을 주기 때문이고, 부분적으로는 단위 테스트를 짜기 위해 필요한 관심사 분리 작업—순수 로직을 프레임워크 의존성으로부터 떼어내는 작업—이 초기 개발 속도를 늦추는 것처럼 느껴지기 때문입니다. 그러나 이 판단은 장기적으로 역전됩니다. 프로젝트가 성장하고, 특히 AI 코딩 도구가 대규모 리팩터링을 자동으로 수행하는 환경에서는, 단위 테스트가 부족한 코드베이스일수록 회귀를 늦게 발견하고, 그 발견 비용은 사람이 손으로 코드를 읽는 대신 프로덕션 인시던트로 대체됩니다.

본 글은 이 문제를 “어느 쪽이 더 낫다”는 우열 논쟁이 아니라, 비용 대비 회귀 가치라는 경제적 프레임으로 재구성합니다. 이 프레임을 택하는 이유는, 우열 논쟁이 결국 “두 계층 모두 필요하다”는 뻔한 결론으로 끝나 실무적 지침을 주지 못하는 반면, 경제적 프레임은 “지금 이 순간 어디에 시간을 먼저 투자해야 하는가”라는 구체적 질문에 답할 수 있기 때문입니다. 이 프레임 아래에서, 단위 테스트를 먼저 붙여야 할 대상과 E2E로 남겨야 할 대상을 구분하는 기준을 도출하는 것이 본 글의 핵심 목표입니다.


2. 배경

2.1 테스트 피라미드의 원래 취지

테스트 피라미드라는 비유는 원래, 실행 속도가 빠르고 결정적인 테스트(단위)를 많이 두고, 실행 속도가 느리고 환경 의존적인 테스트(E2E)를 적게 두라는 권고에서 출발했습니다. 여기서 “적게”는 “가치가 낮다”는 뜻이 아니라 “비용이 높으므로 신중하게 배치해야 한다”는 뜻입니다. E2E는 결제 완료, 로그인 성공, 핵심 전환 흐름처럼 “돈이 되거나 신뢰가 걸린” 소수의 해피 패스에 집중되어야 하며, 이 소수의 흐름이 실제 브라우저에서 깨지지 않는다는 확신을 주는 것이 그 역할입니다.

그런데 실무에서는 이 피라미드가 뒤집히는 경우가 흔합니다. E2E 프레임워크는 설치와 첫 테스트 작성이 직관적이고, “버튼을 클릭하면 페이지가 바뀐다”는 서술이 자연스럽게 테스트 코드로 옮겨지기 때문에, 팀은 단위 테스트를 짜는 대신 새로운 기능마다 E2E 시나리오를 하나씩 추가하는 습관에 빠지기 쉽습니다. 이렇게 쌓인 E2E 스위트는 시간이 지나면서 CI 실행 시간을 늘리고, UI의 작은 변경(예: 버튼의 텍스트나 DOM 구조 변경)에도 다수의 테스트가 동시에 깨지는 취약성을 드러냅니다.

2.2 뒤집힌 피라미드의 증상

E2E만 많은 레포에서 관찰되는 증상은 비교적 일관적입니다.

  • CI 파이프라인의 실행 시간이 길어지고, 병렬화에도 한계가 있습니다.
  • 셀렉터나 텍스트 변경 하나로 여러 테스트가 연쇄적으로 실패합니다.
  • “빨간불”이 실제 제품 버그가 아니라 테스트 자체의 취약성(flakiness)인 비율이 점점 높아집니다.
  • 실패의 원인을 파악하는 데 걸리는 시간이 늘어나, 팀이 실패한 테스트를 스킵하거나 무시하는 문화가 생깁니다.

반대로 단위 테스트만 많고 E2E가 없는 레포의 증상도 존재합니다. 개별 함수는 모두 초록불이지만, 실제 화면에서 버튼을 눌렀을 때 아무 일도 일어나지 않는—즉 컴포넌트 간의 연결, 라우팅, 실제 네트워크 호출이 깨진—상황이 배포 이후에야 발견됩니다. 이는 단위 테스트가 “부품이 개별적으로 옳다”는 것만 보증하고 “부품이 조립되었을 때도 옳다”는 것은 보증하지 않기 때문입니다.

이 두 증상을 나란히 놓고 보면, 건강한 배분이 무엇인지에 대한 직관을 얻을 수 있습니다. 단위 테스트는 순수 계산·가드·파서·서명 검증·키 생성처럼 빠르고 결정적인 대상에, 통합 테스트는 데이터베이스나 큐가 연결된 소수의 유스케이스에, E2E는 결제·로그인·핵심 전환처럼 돈이 되거나 신뢰가 깨지는 해피 패스 소수에 배분되어야 합니다.

2.2-1 두 증상이 동시에 나타나는 경우

실무에서는 두 증상이 배타적이지 않고 동시에 나타나는 경우도 관찰됩니다. 이는 E2E 스위트는 비대해졌지만 그 스위트가 실제로는 소수의 핵심 흐름만 다루고, 나머지 대부분의 비즈니스 로직(요금 계산, 권한 판정 등)은 어느 계층에서도 검증되지 않는 “테스트의 공백 지대”가 존재하는 경우입니다. 이 지대는 E2E 스위트가 이미 충분히 크고 느리기 때문에 “테스트가 있다”는 인상을 주지만, 실제로는 가장 회귀 대가가 큰 로직이 아무 계층에서도 검증되지 않는 위험한 상태입니다. 이런 상태는 팀이 스스로 “테스트가 부족하다”고 인식하지 못한 채 오랫동안 방치되기 쉽습니다. 이 공백은 커버리지 도구로도 잘 드러나지 않을 수 있는데, E2E가 코드 경로를 우연히 지나가기만 해도 커버리지 수치에는 반영되기 때문입니다. 실제로 그 경로의 입력-출력 관계가 의미 있게 검증되었는지는 커버리지 수치와 별개의 질문입니다. 커버리지 수치를 팀의 유일한 품질 지표로 삼는 관행은, 바로 이런 공백 지대를 은폐하는 부작용을 낳을 수 있습니다.

단위 층을 강조한 테스트 피라미드

2.3 커버리지 퍼센트라는 유혹적인 오해

“테스트 커버리지 90% 달성”과 같은 목표는 관리 지표로서 매력적이지만, 이 지표는 무엇을 테스트했는지에 대해 아무것도 말해주지 않습니다. 100%에 가까운 UI 스냅샷 커버리지를 가진 코드베이스라도, 결제 금액 반올림 처리 한 줄이 테스트되지 않았다면 그 한 줄의 실패가 조직에 훨씬 더 큰 피해를 줄 수 있습니다. 따라서 본 글은 커버리지 퍼센트를 목표로 두는 대신, 어떤 로직을 먼저 테스트할지에 대한 선정 기준을 먼저 세우는 것을 제안합니다. 지표는 선정 기준이 이미 정해진 뒤에 결과를 확인하는 보조 도구로만 사용되어야 합니다.


3. 분석틀

본 글은 “무엇을 단위 테스트로 먼저 고정할 것인가”라는 질문에 답하기 위해, 다음 두 축으로 구성된 선정 기준을 제시합니다.

  1. 회귀의 대가(cost of regression): 이 로직이 실패했을 때 발생하는 피해의 크기. 금전적 손실, 보안 침해, 데이터 오염 여부로 판단합니다.
  2. 테스트의 경제성(economics of testing): 이 로직을 순수 함수로 격리할 수 있는지, 네트워크나 DOM 없이 결정적으로 검증 가능한지로 판단합니다.

두 축이 모두 높은 영역—회귀 대가가 크면서도 순수 함수로 격리하기 쉬운 로직—이 단위 테스트의 최우선 대상입니다. 반대로 회귀 대가가 작고 격리도 어려운 로직(예: 순수하게 시각적인 애니메이션 타이밍)은 단위 테스트의 투자 대상에서 제외하는 것이 합리적입니다. 이어지는 본론에서는 이 두 축을 실제 대상 목록에 적용합니다.


4. 본론

4.1 처음 고르기 좋은 다섯 영역과 그 선정 이유

가격·세금·할인 계산. 문자열과 숫자, 소수점 처리의 작은 실수가 곧바로 청구·환불 문제로 이어집니다. 회귀의 대가가 가장 명확하게 금전적이며, 동시에 이 계산은 대체로 순수 함수로 작성 가능하므로 테스트 경제성도 높습니다.

권한(authorization) 가드. “이 역할(role)이 이 행동(action)을 수행할 수 있는가”라는 판정은 미들웨어의 인증(authentication)과는 별개의 관심사입니다. 이 판정 로직을 순수 함수로 분리해 두면 테스트가 쉬워지고, 세션·인증 골격이 담당하는 “누구인지 확인하는 계층”과 책임이 명확히 분리됩니다. 권한 판정이 잘못되면 데이터 유출이나 무단 변경으로 이어지므로 회귀 대가가 큽니다.

슬러그·날짜·frontmatter 정규화. 콘텐츠 파이프라인에서 다룬 것처럼, 파일 기반 콘텐츠 시스템에서 슬러그 생성이나 날짜 파싱이 조용히 깨지면 목록과 상세 페이지가 서로 다른 데이터를 보여주는 침묵의 불일치가 발생합니다. 이런 종류의 버그는 에러를 던지지 않기 때문에 발견이 늦고, 발견되었을 때는 이미 여러 페이지에 잘못된 데이터가 퍼져 있습니다. 슬러그·날짜 파싱처럼 조용히 실패하는 로직일수록, 명시적인 예외를 던지는 로직보다 단위 테스트의 상대적 가치가 더 큽니다.

웹훅 서명 검증 헬퍼. 결제 웹훅 처리의 전제 조건인 서명 검증 로직은 네트워크 호출 없이 고정된 fixture(예제 페이로드와 서명 값)로 완벽하게 테스트할 수 있습니다. 이 검증이 실패하면 위조된 웹훅이 통과하거나, 정상 웹훅이 거부되는 두 가지 실패 모드가 모두 심각합니다. 두 실패 모드 모두 실제 결제 트래픽이 오가기 전까지는 발견되지 않을 수 있으므로, 배포 전 픽스처 기반 테스트로 미리 고정해 두는 것이 유일하게 신뢰할 수 있는 사전 방어입니다.

rate limit 키 생성·IP 정규화. 엣지 rate limit에서 다룬 키 생성 규칙이 코드에만 존재하고 테스트로 고정되지 않으면, 리팩터링 시 키의 형식이 미묘하게 바뀌어 한도가 의도치 않게 느슨해지거나 빡세지는 회귀가 발생할 수 있습니다.

단위 테스트로 먼저 고정할 영역

4.2 대상 선정을 위한 체크리스트

위 다섯 영역을 일반화하면, 다음과 같은 질문에 “예”로 답할 수 있는 로직이 단위 테스트의 우선 대상입니다.

  • 이 함수가 실패하면 돈, 권한, 또는 데이터 정합성에 직접적인 영향을 미치는가?
  • 이 함수를 실행하기 위해 브라우저나 실제 데이터베이스가 필요한가, 아니면 순수한 입력-출력으로 표현 가능한가?
  • 이 로직의 명세를 “입력 예제와 기대 출력”의 표로 작성할 수 있는가?
  • 최근 이 로직이 리팩터링되었거나, 앞으로 자주 리팩터링될 가능성이 있는가?

네 질문 모두에 “예”로 답할 수 있다면, 이는 E2E로 검증하기에는 비효율적이고 단위 테스트로 검증하기에는 이상적인 대상입니다.

4.2-1 선정 기준을 적용한 반례 — 테스트하지 않아도 되는 것

선정 기준의 가치는 무엇을 테스트할지 알려주는 것뿐 아니라, 무엇을 테스트하지 않아도 되는지를 명확히 해주는 데에도 있습니다. 예를 들어 순수하게 시각적인 애니메이션의 정확한 타이밍 값(예: “페이드인이 정확히 300ms에 끝나는가”)은 회귀의 대가가 낮고—틀려도 사용자가 약간의 어색함을 느낄 뿐 금전적·보안적 피해는 없습니다—테스트의 경제성도 낮습니다. 애니메이션 타이밍을 검증하려면 실제 렌더링 환경에서 프레임 단위 측정이 필요한 경우가 많아, 순수 함수 테스트로 환원하기 어렵습니다. 이런 대상에 단위 테스트를 작성하는 것은 투자 대비 효과가 낮은 선택입니다.

마찬가지로, 서드파티 라이브러리의 내부 동작을 재검증하는 테스트(예: “정말로 lodash의 debounce가 지연을 만드는가”)도 우선 대상에서 제외해야 합니다. 이는 우리 코드의 회귀가 아니라 의존성의 정확성을 검증하는 것이며, 이미 그 라이브러리가 자체 테스트 스위트로 보증하는 내용을 중복해서 검증하는 낭비입니다. 우리가 작성해야 할 테스트는 “그 라이브러리를 우리가 의도한 방식으로 올바르게 호출하고 있는가”에 한정되어야 합니다.

4.3 Next.js에서 단위 테스트가 어려운 이유

Next.js와 같은 풀스택 프레임워크에서 단위 테스트를 시도할 때 흔히 부딪히는 어려움은, 브라우저 전용 API, headers()cookies()와 같은 요청 스코프 함수, 그리고 환경 변수 읽기가 모듈의 최상단에 얽혀 있는 경우입니다. 이런 모듈을 그대로 import해서 테스트하려고 하면, 테스트 코드는 이 모든 의존성을 목(mock)으로 채워야 하고, 이 목 설정 자체가 테스트보다 복잡해지는 “목 지옥(mocking hell)”에 빠집니다.

이 문제에 대한 처방은 새롭지 않습니다. 순수 로직을 lib/ 또는 server/domain/과 같은 별도의 디렉터리로 추출하고, Route Handler나 Server Action은 이 순수 로직을 호출하는 얇은 어댑터로만 남기는 것입니다. 이 분리가 이루어지면 Vitest는 Node 환경에서 어떤 프레임워크 의존성도 없이 빠르게 도메인 로직을 검증할 수 있고, 어댑터 계층은 상대적으로 단순하기 때문에 E2E나 소수의 통합 테스트로 커버하는 것으로 충분해집니다.

이 분리의 부가적인 효과는, AI 코딩 도구가 어댑터 계층(라우팅, 요청 파싱)을 자유롭게 리팩터링하더라도 도메인 테스트가 그 아래 있는 비즈니스 로직의 회귀를 즉시 감지한다는 점입니다. 어댑터와 도메인이 얽혀 있으면, AI가 어댑터를 고치는 과정에서 도메인 로직도 함께 미묘하게 바뀌었는지 여부를 사람이 매번 diff를 읽어 확인해야 합니다.

4.3-1 분리 작업의 실제 단계

기존 코드베이스에 이미 어댑터와 도메인이 얽혀 있는 경우, 이 분리를 한 번에 수행하는 것은 위험합니다. 실무적으로 안전했던 접근은 다음 순서를 따르는 것입니다. 먼저 Route Handler나 Server Action 내부에서 순수하게 계산·판정만 담당하는 부분을 식별합니다. 이 부분은 대개 headers()cookies(), 데이터베이스 클라이언트를 직접 참조하지 않는 코드 블록입니다. 다음으로 이 블록을 별도 함수로 추출하되, 필요한 값(사용자 ID, 요청 본문 등)은 인자로 전달받도록 시그니처를 바꿉니다. 이 시점에서 어댑터는 데이터를 모아 도메인 함수를 호출하고 결과를 HTTP 응답으로 변환하는 얇은 껍데기로 축소됩니다. 마지막으로, 추출된 도메인 함수에 대해 단위 테스트를 작성하여 이번 리팩터링이 기존 동작을 보존했는지 확인합니다.

이 순서에서 중요한 것은, 테스트를 나중에 작성하는 것이 아니라 추출 직후에 바로 작성하는 것입니다. 추출과 테스트 작성 사이의 간격이 벌어지면, 그 사이에 다른 변경이 섞여 들어와 “추출 전과 추출 후가 동일한 동작을 보장한다”는 확인이 어려워집니다.

4.4-1 픽스처 설계 — 예제 표를 코드로 옮기기

4.1절에서 언급한 “입력 예제와 기대 출력의 표”는 실제 테스트 코드에서 픽스처(fixture)라는 형태로 구현됩니다. 좋은 픽스처 설계의 핵심은, 각 테스트 케이스가 왜 존재하는지를 테스트 이름이나 주석이 아니라 입력값 자체가 말해주도록 구성하는 것입니다. 예를 들어 할인 계산 로직을 테스트한다면, 각 케이스는 “일반 가격”, “경계값(할인율 0%)”, “경계값(할인율 100%)”, “음수 입력(방어적 케이스)”처럼 서로 다른 대표 범주를 하나씩 담아야 합니다. 같은 범주를 여러 번 반복하는 케이스를 늘리는 것은 커버리지 수치는 올리지만 실질적인 방어력은 거의 늘리지 않습니다.

픽스처를 별도 파일로 분리해 두면, 이후 명세가 바뀌었을 때(예: 할인율 상한이 변경되었을 때) 테스트 코드 자체를 건드리지 않고 픽스처 값만 갱신하는 것으로 대응할 수 있는 경우가 많습니다. 이는 테스트 코드와 명세 데이터를 분리함으로써, 명세 변경과 테스트 로직 변경을 서로 다른 diff로 추적할 수 있게 하는 부가적인 이점도 제공합니다.

4.4 환경 변수와 시간·난수 의존성 처리

환경 변수는 테스트 실행 시 실제 값 대신 stub된 값을 주입해야 하며, 어떤 경우에도 실제 시크릿을 CI 환경에 그대로 노출해서는 안 됩니다. 또한 현재 시각이나 난수에 의존하는 로직—예를 들어 만료 시각 계산이나 토큰 생성—은 시계나 난수 생성기를 함수의 인자로 주입할 수 있도록 설계하는 편이, 테스트 실행 시점에 따라 결과가 달라지는 flaky 테스트를 줄이는 데 도움이 됩니다. 이는 함수 시그니처를 약간 더 명시적으로 만드는 비용을 감수하고, 결정성(determinism)이라는 더 큰 이득을 얻는 절충입니다.

4.5 통합 테스트의 자리

단위 테스트가 순수 로직을, E2E가 사용자 흐름을 담당한다면, 그 사이의 통합 테스트는 무엇을 담당할까요. 이 질문에 명확히 답하지 못하면, 팀은 통합 테스트를 “단위 테스트보다 좀 더 현실적인 단위 테스트” 정도로 느슨하게 이해하게 되고, 결과적으로 통합 테스트의 개수가 통제되지 않고 늘어나는 경향을 보입니다. 실제 데이터베이스나 큐와 연결된 소수의 핵심 유스케이스—예를 들어 “주문 생성 시 재고가 정확히 차감되는가”와 같이 여러 순수 함수와 저장소가 함께 동작해야만 검증할 수 있는 로직—가 이 계층의 대상입니다. 이 계층은 단위 테스트보다 느리고 E2E보다는 빠르며, 개수는 적게 유지하는 것이 원칙입니다. 통합 테스트를 과도하게 늘리면 단위 테스트의 장점(속도, 결정성)과 E2E의 장점(실제 사용자 경험 검증)을 모두 잃은 중간 지대에 갇히게 됩니다.


5. 논의

5.1 AI 코딩 시대에 단위 테스트의 역할 변화

생성 코드는 종종 “그럴듯해 보이는 분기”를 많이 만들어냅니다. 문법적으로 옳고, 얼핏 보면 논리적으로도 타당해 보이지만, 실제로는 경계 조건이나 예외 케이스를 놓친 코드가 자연스럽게 섞여 들어갑니다. E2E 테스트는 이러한 코드가 만들어내는 여러 분기 중 실제로 사용자가 밟는 소수의 경로만을 검증하기 때문에, 나머지 분기의 결함은 오랫동안 발견되지 않을 수 있습니다.

가격·권한과 같이 명세가 명확한 로직을 예제 표로 고정해 두면, 모델이 해당 로직을 리팩터링하거나 “개선”하려고 시도할 때 표가 즉시 깨지고, 이는 CI에서 즉각적인 신호로 나타납니다. 이 신호는 사람이 diff를 한 줄씩 읽어야만 알 수 있었던 정보를, 자동화된 실패 메시지 하나로 압축해서 전달합니다. 이런 의미에서 단위 테스트는 AI의 창의성을 억압하는 장치가 아닙니다. 오히려 모델이 자유롭게 구현 방식을 바꾸도록—즉 창의성을 발휘하도록—허용하면서도, 그 창의성이 명세된 스코프 밖으로 새어나가지 않도록 하는 난간의 역할을 합니다. 난간이 있으면 모델은 더 과감하게 리팩터링을 시도할 수 있고, 사람 리뷰어는 매 diff를 처음부터 끝까지 읽는 대신 “테스트가 통과하는가”라는 더 저렴한 질문으로 리뷰 부담을 줄일 수 있습니다.

5.2 Cursor와 같은 에이전트형 워크플로와의 접점

에이전트형 코딩 도구를 사용하는 워크플로에서는, 사람이 매 변경을 실시간으로 검토하기 어려운 순간이 늘어납니다. 이때 단위 테스트 스위트는 “사람이 자리를 비운 사이에도 회귀를 잡아내는” 자동화된 검증 게이트의 역할을 합니다. 이는 단위 테스트를 짜는 행위가 개발 속도를 늦추는 오버헤드가 아니라, 오히려 자동화된 변경의 신뢰도를 높여 더 큰 자율성을 부여할 수 있게 하는 투자라는 관점으로 이어집니다.

이 관점은 Cursor 워크플로에서 다룬 “검증 근거” 개념과도 직접 연결됩니다. PR 본문에 “테스트를 추가했고, 다음 다섯 케이스가 통과함을 확인했다”는 문장이 있으면, 리뷰어는 그 케이스 목록만 확인하는 것으로 상당 부분의 검증 부담을 덜 수 있습니다. 반대로 검증 근거가 “로컬에서 확인함” 한 줄뿐이라면, 리뷰어는 에이전트가 실제로 무엇을 확인했는지 재구성해야 하고, 이 재구성 비용은 애초에 단위 테스트가 존재했다면 발생하지 않았을 비용입니다. 즉 단위 테스트는 코드의 정확성을 검증하는 도구이면서 동시에, 리뷰 과정에서 검증 근거를 저렴하게 전달하는 커뮤니케이션 도구이기도 합니다.

5.2-1 회귀 테스트와 결정론적 실행의 관계

AI 코딩 도구가 리팩터링을 수행한 뒤 “테스트가 통과했다”는 신호를 신뢰하려면, 그 테스트 스위트 자체가 결정적으로 동작해야 합니다. 만약 스위트에 flaky한 테스트(같은 코드에 대해 때로는 통과하고 때로는 실패하는 테스트)가 섞여 있다면, 실패 신호가 실제 회귀 때문인지 테스트 자체의 불안정성 때문인지 구분할 수 없습니다. 이 모호함은 사람이 매 실패를 수동으로 재확인하는 습관을 낳고, 이 습관이 반복되면 결국 “테스트가 실패해도 다시 돌려보면 통과할 것”이라는 안일한 태도로 이어져 진짜 회귀조차 재실행으로 덮어버리는 위험한 문화가 만들어집니다.

이 문제를 예방하는 방법은 이미 4.4절에서 다룬 시계·난수 주입 원칙을 스위트 전체에 일관되게 적용하는 것과, flaky한 테스트가 발견되는 즉시 격리하거나 스킵 처리하여 나머지 스위트의 신뢰도를 지키는 것과, 테스트 간에 공유 상태(모듈 레벨 변수, 파일 시스템의 임시 파일)가 남지 않도록 각 테스트를 격리하는 것입니다. AI가 자동으로 생성하거나 리팩터링한 테스트일수록, 이 격리 원칙이 지켜지고 있는지 사람이 한 번 더 확인하는 것이 안전합니다. 생성된 테스트가 우연히 실행 순서에 의존하는 경우, 처음 몇 번은 우연히 통과하다가 나중에 테스트 순서가 바뀌면서 실패하는 잠복 결함이 될 수 있습니다.

5.3 팀 규모에 따른 조정

1인 사이드 프로젝트와 다수의 엔지니어가 있는 팀에서 이 원칙의 적용 강도는 달라질 수 있습니다. 1인 프로젝트에서는 모든 로직에 대해 엄격한 테스트 커버리지를 요구하는 것이 과잉일 수 있으며, 본 글이 제시한 다섯 영역—가격, 권한, 콘텐츠 정규화, 웹훅 검증, rate limit 키—처럼 회귀 대가가 명확한 영역에 집중하는 것이 합리적입니다. 팀 규모가 커지고 여러 사람이 동시에 같은 코드베이스를 리팩터링하기 시작하면, 단위 테스트의 범위를 점진적으로 넓혀가는 것이 자연스러운 성장 경로입니다.

5.4 테스트 스위트의 실행 시간 관리

단위 테스트 층이 두꺼워질수록, 스위트 전체의 실행 시간도 함께 늘어납니다. 초기에는 몇 초에 끝나던 스위트가 수백 개의 테스트가 누적되면 수 분으로 늘어날 수 있고, 이는 개발자가 코드를 저장한 뒤 결과를 확인하기까지 기다리는 시간에 직접 영향을 줍니다. 이 문제를 관리하는 실무적인 방법은, 테스트를 실행 빈도에 따라 계층화하는 것입니다. 파일을 저장할 때마다 실행되는 워치 모드에서는 변경된 파일과 관련된 테스트만 실행하고, CI에서는 전체 스위트를 실행하는 방식이 일반적입니다. Vitest와 같은 러너는 파일 변경을 감지하여 관련된 테스트만 재실행하는 기능을 기본적으로 제공하므로, 이 계층화를 위해 별도의 인프라를 구축할 필요는 크지 않은 경우가 많습니다.

테스트가 느려지는 또 다른 흔한 원인은, 단위 테스트라고 표시된 테스트가 실제로는 파일 시스템이나 네트워크에 암묵적으로 의존하는 경우입니다. 예를 들어 환경 변수를 읽는 함수가 실제 .env 파일을 파싱한다면, 이 파일의 크기나 파싱 라이브러리의 성능이 테스트 속도에 영향을 줍니다. 진짜 단위 테스트는 이런 외부 의존성을 모두 제거한 순수 함수를 대상으로 해야 하며, 스위트가 느려지기 시작했다면 “단위 테스트로 분류된 테스트 중 실제로는 통합 테스트의 성격을 가진 것이 섞여 있지 않은가”를 먼저 점검하는 것이 원인 진단의 첫걸음입니다.

5.5 코드 리뷰에서 테스트를 리뷰하는 기준

테스트 코드도 프로덕션 코드만큼 리뷰의 대상이 되어야 하지만, 실무에서는 테스트 코드의 리뷰가 상대적으로 느슨하게 이루어지는 경향이 있습니다. 이는 “테스트는 통과하면 그걸로 충분하다”는 암묵적 가정에서 비롯되는데, 이 가정은 절반만 맞습니다. 통과하는 테스트라도, 실제로는 아무것도 검증하지 않는 테스트(assertion이 항상 참이 되도록 잘못 작성된 경우)나, 구현 세부사항에 지나치게 밀착되어 있어 향후 정상적인 리팩터링을 방해할 테스트가 있을 수 있습니다.

테스트 코드를 리뷰할 때 확인할 만한 질문은 다음과 같습니다. 이 테스트가 실패하도록 일부러 프로덕션 코드를 망가뜨려 보면 실제로 실패하는가(이 확인을 생략하면 “항상 통과하는 테스트”를 놓치기 쉽습니다). 이 테스트의 이름이 실패했을 때 무엇이 잘못되었는지 알려주는가. 이 테스트가 구현 방식이 아니라 계약(입력과 출력)을 검증하고 있는가. 이 세 질문에 모두 “예”로 답할 수 있는 테스트가, 장기적으로 유지보수 부담 없이 회귀를 잡아내는 테스트입니다.


6. 한계

본 글이 제시하는 선정 기준에도 몇 가지 한계가 있습니다. 아래 항목들은 이 기준을 실무에 적용하기 전에 미리 인지해 두면 좋은 것들입니다.

정성적 판단의 필요성. “회귀의 대가”와 “테스트의 경제성”이라는 두 축은 개념적으로는 명확하지만, 실제 판단에는 여전히 팀의 정성적 경험이 필요합니다. 자동화된 정량 지표만으로 이 판단을 완전히 대체할 수는 없습니다. 두 축을 숫자로 계량화하려는 시도(예: 회귀 대가를 예상 손실액으로, 테스트 경제성을 작성 소요 시간으로 환산)도 가능하지만, 이런 계량화 자체에 상당한 추정과 가정이 필요해 실무에서는 오히려 정성적 논의가 더 빠르고 정확한 합의를 이끌어내는 경우가 많습니다.

과도한 단위 테스트의 역효과. 순수 함수라고 해서 무조건 테스트를 작성하는 것이 항상 이득은 아닙니다. 구현 세부사항에 지나치게 밀착된 테스트(예: 내부 헬퍼 함수의 호출 순서를 검증하는 테스트)는 정상적인 리팩터링조차 테스트 실패로 만들어, 오히려 리팩터링을 억제하는 부작용을 낳을 수 있습니다. 테스트는 구현이 아니라 명세(무엇이 보장되어야 하는가)를 검증하도록 작성되어야 합니다. 이 원칙을 지키고 있는지 확인하는 간단한 방법은, 함수의 내부 구현을 완전히 다른 알고리즘으로 바꾸되 입력-출력 계약은 그대로 유지했을 때도 기존 테스트가 수정 없이 통과하는지를 스스로 점검해 보는 것입니다.

단위 테스트가 잡지 못하는 통합 실패. 아무리 단위 테스트 커버리지가 높아도, 컴포넌트 간의 연결이나 실제 배포 환경의 설정 문제는 여전히 E2E나 수동 검증이 필요합니다. 단위 테스트는 “부품이 옳다”는 것만 보증하며, 이 글의 서두에서 언급했듯 이는 “조립이 옳다”는 것과는 다른 문제입니다. 이 한계는 단위 테스트를 아무리 정교하게 설계해도 근본적으로 해소되지 않으며, 반드시 다른 계층(통합 테스트, E2E)과의 조합으로 보완되어야 합니다.

러너·도구의 시간적 한계. Vitest의 구체적인 설정 방식이나 API는 버전에 따라 달라질 수 있으므로, 본 글은 특정 설정 예제를 제시하는 대신 원칙 수준에서 논의를 유지했습니다. 구체적인 설정은 항상 해당 시점의 공식 문서를 따르는 것이 안전합니다.

픽스처 유지보수 비용. 4.4-1절에서 제안한 픽스처 분리 방식은 명세 변경 시 유지보수를 쉽게 해주지만, 픽스처 자체가 많아지면 그 픽스처들이 실제 프로덕션 데이터의 분포를 여전히 잘 대표하는지 별도로 검증해야 하는 부담이 생깁니다. 이 부담은 픽스처의 수가 늘어날수록 커지므로, 픽스처 역시 정기적으로 정리하고 중복을 제거하는 유지보수 작업이 필요합니다. 실제 사용자 데이터의 특이 케이스(예: 예상치 못한 유니코드 문자, 극단적으로 긴 입력)가 픽스처에 반영되지 않으면, 테스트가 통과하더라도 실제 환경에서는 여전히 결함이 발생할 수 있습니다. 이 글은 픽스처를 어떻게 프로덕션 데이터와 동기화할지에 대한 구체적 방법론은 다루지 않았습니다.

정성적 판단이 요구되는 경계 사례. 회귀 대가와 테스트 경제성이라는 두 축으로 대부분의 로직을 분류할 수 있지만, 두 축 모두 중간 정도인 경계 사례(예: 회귀 대가는 중간이고 격리는 다소 어려운 로직)에 대해서는 이 글의 기준만으로 명확한 답이 나오지 않습니다. 이런 경계 사례는 팀의 리스크 선호도에 따라 다르게 판단될 수 있으며, 이 글은 일반화된 답을 제시하지 않고 판단의 축만 제공했습니다.


7. 결론

Vitest는 Playwright의 경쟁자가 아니라, E2E가 비싸게 막아왔던 결정적 로직의 회귀를 싸게 막는 별도의 층입니다. 본 글은 이 층을 어떻게 배치할지에 대해, 피라미드가 뒤집혔을 때의 증상을 진단하고, 회귀의 대가와 테스트의 경제성이라는 두 축으로 대상을 선정하는 기준을 제시했습니다. 가격·권한·콘텐츠 정규화·웹훅 검증·rate limit 키와 같이 돈·신뢰·파이프라인이 걸린 순수 함수부터 고정하는 것이 투자 대비 효과가 가장 큰 시작점입니다.

Next.js와 같은 프레임워크에서는 도메인 로직을 프레임워크 어댑터로부터 분리하는 순간 테스트 가능성이 회복됩니다. 그리고 AI가 코드를 생성하고 리팩터링하는 환경에서는, 이 단위 테스트 층이 창의성을 억누르는 장치가 아니라 창의성이 안전하게 발휘될 수 있는 경계를 만드는 난간으로 기능합니다. 구체적인 러너 설정은 프로젝트의 버전에 맞춰 공식 문서를 따르면 충분하지만, 무엇을 먼저 고정할지에 대한 판단 기준은 도구가 바뀌어도 유지될 만한 가치가 있습니다.

이 판단 기준을 처음 적용하는 팀에게 실무적으로 권할 만한 순서는, 먼저 코드베이스에서 회귀가 발생했을 때 실제로 금전적·보안적 피해가 컸던 과거 사례를 목록으로 정리하고, 그 사례들이 어느 함수 또는 모듈에서 발생했는지 역추적하는 것입니다. 이 역추적 결과가 대개 4.1절에서 제시한 다섯 영역(가격, 권한, 콘텐츠 정규화, 웹훅 검증, rate limit 키)과 상당히 겹친다는 것을 확인하게 되며, 이 확인 과정 자체가 팀에게 “왜 이 영역부터 테스트해야 하는가”에 대한 구체적이고 설득력 있는 근거를 제공합니다. 추상적인 원칙만으로 팀을 설득하기보다, 자신의 코드베이스에서 실제로 발생했던 사고 이력을 근거로 우선순위를 정하는 것이 실무에서 더 빠르게 합의를 이끌어냅니다.

테스트 전략은 한 번 세우고 끝나는 것이 아니라, 코드베이스가 성장하고 새로운 사고가 발생할 때마다 다섯 영역의 목록을 갱신해야 하는 살아있는 문서입니다. 이 갱신 작업 자체를 정기적인 회고의 일부로 포함시키면, 테스트 전략이 특정 시점의 판단에 고정되지 않고 팀의 경험이 누적될수록 더 정교해지는 자산으로 성장합니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.