Deallo

아키텍처 · API

CI 도입 — 타입이 못 잡는 런타임 동작을 테스트로 고정

Orval로 API 레이어를 전면 교체하던 중, tsc는 초록불인데 실제 요청 포맷이 바뀌는 게 무서웠다. 타입 체크가 통과한다고 안전한 게 아니라는 걸 그때 처음 몸으로 알았다. 그래서 타입이 못 잡는 런타임 동작을 테스트로 박제하고, 그 테스트를 husky에 넣었다 빼서 GitHub Actions로 옮긴 뒤, 마지막엔 CI 자체를 병렬화했다. 어느 결정 하나, 한 번에 끝난 게 없었다.

tsc가 통과해도 런타임은 다르다

API 레이어를 통째로 교체하는데, 타입 체크만 믿을 수 없었다. 타입과 무관하게 런타임에서만 드러나는 동작이 세 군데 있었다.

  • paramsSerializerpageable 중첩 객체를 flatten해서 평평한 쿼리로 펴낸다. tsc는 통과해도 실제 직렬화 결과가 달라지면 서버가 못 알아듣는다
  • comma format — 배열 파라미터가 ?tags=TX,CA로 직렬화되는지. 타입 시그니처상으로는 string[] 하나일 뿐, 어떤 문자열로 펼쳐지는지는 타입이 보장하지 않는다
  • 인터셉터_silent 플래그로 특정 요청의 에러 토스트를 끄는지, 401에서 리다이렉트가 도는지. 전부 런타임에서만 검증 가능한 부수효과다

이 셋은 공통점이 있다. 타입이 같아도 동작이 바뀔 수 있는 지점이라는 것. Orval이 생성한 코드로 호출 레이어를 갈아끼우면, 시그니처는 동일한데 직렬화 한 줄, 인터셉터 한 줄이 미묘하게 달라져도 컴파일러는 아무 말을 안 한다. 결론은 분명했다 — 전면 교체 마이그레이션에는 런타임 동작을 고정하는 테스트가 먼저 깔려야 한다. 이 테스트가 곧 마이그레이션의 안전망이 됐다.

도구 결정 — Vitest, 그리고 MSW를 일부러 뺐다

검토결정
Jest vs VitestVitestNext.js 공식 지원, ESM 네이티브, 빠름
MSW 도입보류이 단계는 인프라 레벨 검증만으로 충분
axios-mock-adapter검토 후 기각최종은 orvalInstance 자체를 mock

MSW를 뺀 게 의외의 결정이었다. 보통 API 테스트라고 하면 네트워크를 흉내 내는 MSW부터 떠올리는데, 이번에 검증하려던 건 "서버가 무슨 응답을 주느냐"가 아니라 "내 클라이언트가 어떤 요청을 만들어 보내느냐"였다. 직렬화 포맷·인터셉터 설정값은 요청이 나가기 직전까지의 동작이라, HTTP를 끝까지 흉내 낼 필요가 없었다. 그래서 HTTP 경계를 차단하는 가장 얕은 지점 — orvalInstance를 mock하는 방식으로 갔다. axios까지 가는 호출 형태를 직접 들여다보는 것이다.

// orvalInstance를 mock해 HTTP 레이어를 차단 — 호출 인자(요청 형태)를 직접 검증
const { mockOrvalInstance } = vi.hoisted(() => ({ mockOrvalInstance: vi.fn() }))
vi.mock('@/shared/api/orval-instance', () => ({ orvalInstance: mockOrvalInstance }))

뒤에 도메인별 behavioral 테스트가 붙으면서 MSW가 필요해지는 단계가 따로 오지만, 이 시점엔 "필요해지면 그때" 로 미뤘다. 인프라 안전망을 까는 데 mock 스택을 한 겹 더 쌓는 건 과했다.

테스트 범위 — 인프라 안전망 17개부터

거대한 마이그레이션 앞에서 모든 동작을 테스트로 덮을 수는 없었다. 대신 "여기가 깨지면 전 도메인이 조용히 망가진다"는 인프라 지점부터 17개로 박았다.

params-serializer.test.ts   — comma format 직렬화                (6)
orval-instance.test.ts      — orvalInstance → axiosInstance 호출  (5)
axios-interceptor.test.ts   — 설정값 + 설계 의도 기술            (6)

axios-interceptor.test.ts는 단순히 값을 확인하는 게 아니라 설계 의도를 코드로 적어두는 성격이 강했다. _silent가 왜 있는지, 401에서 무엇을 해야 하는지를 테스트 본문이 설명하게 했다. 이 17개가 "API 레이어를 갈아엎어도 요청 포맷·인터셉터는 그대로"를 보장했고, 덕분에 여러 작업이 병렬로 엔티티를 교체하는 동안에도 회귀를 조용히 흘려보내지 않았다.

husky에 넣었다가 뺀 이유 — 빠른 피드백 vs 완전한 검증

처음엔 욕심을 냈다. pre-commit 훅에서 전체 테스트를 돌렸다.

# .husky/pre-commit (초기) — 커밋마다 풀 테스트
if ! pnpm test; then
  echo "테스트 실패"
  exit 1
fi

17개 시절엔 커밋당 ~800ms. 아무 문제 없었다. 그런데 뒤이어 behavioral 테스트가 붙으면서 테스트가 65개 이상으로 늘었고, 사소한 오타 하나 고치는 커밋에도 풀 테스트가 도는 게 부담이 됐다. 커밋의 리듬을 테스트가 끊었다.

여기서 역할을 분리했다. 로컬 훅은 빠른 피드백, CI는 완전한 검증 — 같은 검사를 두 곳에서 중복 실행할 이유가 없었다.

단계검사이유
pre-commit (husky)lint + tsc빠름. 로컬 코딩 실수를 즉시 잡는다
CI (GitHub Actions)lint + tsc + test + build느려도 됨. 완전한 검증
# .husky/pre-commit (최종)
pnpm lint-staged --concurrent false   # lint + prettier
pnpm tsc --noEmit                     # 타입 체크
# pnpm test 는 CI로 이동

여기서 한 가지가 분명해졌다 — 테스트를 CI로 옮긴 순간, CI가 없으면 테스트는 완전히 무력화된다. 로컬 훅이 더 이상 테스트를 안 돌리니, CI가 유일한 실행 지점이 된 것이다. CI 도입이 "있으면 좋은 것"이 아니라 "없으면 테스트가 죽는" 필수 조건으로 바뀌었다.

GitHub Actions 셋업 — 막힌 곳 셋

워크플로 골격 자체는 단순했다.

on:
  pull_request:                 # 모든 PR (base 브랜치 무관)
  push:
    branches: [develop, main]
 
# steps: install → tsc → lint → test → build

핵심 결정 하나 — generate:api를 CI에 넣지 않았다. schemas.ts와 엔티티 파일이 전부 git에 추적되므로, CI에서 외부 swagger 서버를 호출할 이유가 없었다. swagger가 바뀌면 로컬에서 생성해 커밋 하는 흐름이라, CI는 외부 의존 없이 닫힌 채로 돈다.

골격은 단순했지만 세 곳에서 막혔다.

#증상원인해결
1Multiple versions of pnpm specifiedaction 설정의 version: 9package.jsonpackageManager 충돌with: { version: 9 } 제거 → packageManager 필드 자동 감지
2YAML 파싱 오류version: 9 줄만 지우고 빈 with: 키가 잔류with: 블록 자체를 삭제
3중간 브랜치 PR에서 CI 미실행branches: [develop, main] 필터가 base 브랜치를 제한pull_request에서 branches 필터 제거

3번이 특히 헷갈렸다. 기능 브랜치를 또 다른 작업 브랜치로 보내는 PR에서 CI가 안 도는데, 처음엔 토큰이나 권한을 의심했다. 실제 원인은 pull_requestbranches 필터가 머지 대상(base) 브랜치를 기준으로 거른다는 것 — develop/main으로 가는 PR만 통과시키고 있었던 것이다. 필터를 빼서 base가 무엇이든 모든 PR이 검증받게 했다.

on:
  pull_request:   # branches 없음 = 모든 PR

그다음 — CI가 3분으로 늘어 병렬화했다

테스트가 쌓이고 코드베이스가 커지자, 이번엔 CI 자체가 병목이 됐다. 단일 job 안에서 install → tsc → lint → test → build를 순차로 돌리니 wall-clock이 단계 합산에 가깝게 누적됐고, GitHub Actions 기준 3분 7초까지 늘어났다. PR 피드백이 늦어지면 리뷰 사이클이 통째로 길어진다.

먼저 로컬에서 각 단계를 실측했다. "test가 느리다"는 막연한 인상부터 깨졌다.

단계로컬 시간
install12.47s
tsc10.65s
lint21.68s
test9.06s
build24.19s
순차 총합78.05s

병목은 test가 아니라 lint·build였다. 그래서 테스트를 더 빠르게 만드는 대신, 워크플로 구조를 바꿨다 — 서로 독립적인 검증을 4개 job(typecheck / lint / test / build)으로 쪼개 병렬로 돌렸다. 각 검증은 독립적으로 성공/실패를 판단할 수 있으니 병렬화에 적합했다.

캐시도 손봤지만, 여기서 한 번 보수적으로 후퇴했다.

캐시결정이유
.eslintcache유지성능용. miss 나도 재실행으로 수렴, 가짜 성공 위험 낮음
.next/cache유지위와 동일, CI에서 안전하게 쓰는 편
tsconfig.tsbuildinfo제거셋 중 가장 민감. "빠름"보다 "설명 가능한 정확성"을 택함

처음엔 TypeScript incremental 캐시(tsconfig.tsbuildinfo)까지 복원 대상에 넣었다가, 리뷰를 거치며 뺐다. CI는 속도보다 "이 초록불을 신뢰해도 되는가" 가 우선이고, incremental 캐시는 셋 중 잘못된 성공을 유도할 여지가 가장 컸다. 동시에 .eslintcache 경로는 pnpm lint / lint:fix / lint-staged / .gitignore에서 전부 한 곳으로 통일했다 — 캐시를 도입했으면 생성 위치·무시 규칙·실행 경로가 같이 정리돼야 여러 곳에서 따로 노는 사고가 안 난다.

결과는 수치로 남겼다.

항목BeforeAfter개선
GitHub Actions 총 duration3m 7s1m 39s47%↓
로컬 순차 총합78.05s43.03s44.87%↓
로컬 병렬 예상치36.66s16.84s54.06%↓

로컬 43.03s와 Actions 1m 39s가 1:1로 안 맞는 건 당연하다. Actions에는 checkout·setup·네트워크· 러너 성능·병렬 orchestration 비용이 같이 들어가니까. 그래서 로컬 수치는 방향성을 보는 용도로 쓰고, 최종 판단은 실제 Actions 기준 3m 7s → 1m 39s로 못 박았다. 리뷰 피드백 대기 시간을 절반 가까이 줄인 셈이다.

남은 습관

  • "tsc 통과 = 안전"이 아니다. 직렬화·인터셉터처럼 타입 밖의 동작은 테스트로만 고정된다. 큰 마이그레이션일수록 "동작을 박제하는 테스트"를 먼저 깔고 시작해야, 갈아엎으면서도 자신 있게 간다
  • 테스트는 실행 지점이 사라지면 죽는다. husky에서 CI로 옮긴 순간 CI는 옵션이 아니라 필수가 됐다. 검사를 어디서 돌릴지 옮길 땐 "옮긴 곳이 정말 매번 도는가"부터 확인해야 한다
  • 느린 곳은 재보고 고쳐야 한다. "test가 느리겠지"라는 인상은 실측에서 깨졌고, 진짜 병목은 lint·build였다. 그래서 테스트 최적화가 아니라 워크플로 병렬화가 정답이었다
  • CI에선 속도보다 신뢰가 먼저다. incremental 캐시는 빠르지만, 잘못된 초록불을 만들 여지가 있으면 뺐다. CI의 초록불은 "믿고 머지해도 되는 신호"여야 한다