10주차 과제의 목표 문장이 이랬다. "배포했다"가 아니라 어떤 코드가 어떤 검증을 통과해서 어떤 환경에 나갔는지 설명할 수 있는 상태로 마무리할 것.
앞의 절반은 이미 있었다. 8주차에 단위·통합 테스트를 망가뜨려 확인했고, 9주차에 로그를 세어 E2E 대상을 골랐다. 문제는 뒤쪽이었다. 그 검증들을 어디에 세워야 실제로 무언가를 막는가.
처음엔 쉬운 문제인 줄 알았다. 과제 표에 권장 위치가 이미 적혀 있었으니까 — lint는 모든 PR, E2E는 핵심 플로우 PR, Lighthouse는 정기 실행. 그대로 옮겨 적으면 끝날 것 같았다.
그런데 build 앞에서 멈췄다.
"싸니까 넣는다"로는 build를 설명할 수 없었다
게이트를 고르는 축으로 세 개를 잡고 시작했다. 실행 비용, 실패 변동성, 실패 비용. 이 셋으로 대부분은 정리된다. lint는 싸고 결정적이니 넣고, Lighthouse는 흔들리니 뺀다.
그런데 build를 이 셋에 넣으면 애매해진다. 빌드는 제일 비싼 검증이라는 게 보통의 전제고, 그렇다면 뒤로 미루는 게 맞아 보인다. 실제로 "build는 빼고 typecheck·lint로 대신하자"는 검토를 한 번 했다. 두 가지를 확인하고 접었다.
첫째, 비싸다는 전제가 성립하지 않았다. 재 보니 build와 lint가 거의 같았다 — 클린 빌드 기준 7.25초 대 6.15초, 오늘 다시 재도 4.52초 대 3.76초다. Next 16의 Turbopack 빌드라 그렇다. CI 전체에서 시간을 잡아먹는 건 빌드가 아니라 Playwright Chromium 설치(23초)와 툴체인 셋업(21초) 같은 고정비였다.
둘째가 더 중요했다. 'use client' 없이 useState를 쓰는 서버 컴포넌트를 임시 라우트로 만들어 셋을 각각 돌려 봤다.
typecheck exit: 0 (통과)
lint exit: 0 (통과)
build exit: 1 (실패)
You're importing a module that depends on `useState` into a React Server
Component module. This API is only available in Client Components.
타입 시스템은 서버/클라이언트 경계를 안 본다. useState의 타입은 호출 위치와 무관하게 유효하니까. 이 경계는 빌드 단계의 RSC 컴파일러만 확인한다.
그래서 축을 하나 더 세웠다. 대체 가능성 — 이 검증이 아니면 못 잡는 실패가 있는가.
이 축을 넣자 표가 다시 정리됐다.
| 게이트 | required | 결정적이었던 축 |
|---|---|---|
| 환경 변수 검증 | ○ | 대체 불가 — 값 누락은 빌드를 통과하고 배포된 화면에서 드러난다 |
| lint · typecheck · unit·integration | ○ | 비용 낮음 + 변동성 없음 |
| production build | ○ | 대체 불가 — RSC 경계는 빌드만 잡는다 |
| E2E (spec 4개 전부) | ○ | 대체 불가 — 통합 테스트는 MSW 경계 안이라 실제 쿠키·미들웨어·세션 만료를 못 본다 |
| Lighthouse | ✕ | 변동성 높음 — CI 부하로 흔들려 무관한 PR을 막는 빈도가 잡는 회귀보다 많다 |
typecheck는 이 축에서 보면 대체 가능하다(build가 같은 오류를 잡는다). 그래도 남긴 건 build보다 빠르고, 타입 오류만 있을 때 실패 지점이 명확하게 드러나서다. 대체 가능하지만 비용에서 앞선다 — 이렇게 적어 두면 나중에 이 칸을 다시 볼 때 내가 뭘 보고 남겼는지 알 수 있다.
job을 쪼갰더니 벽시계가 안 줄었다
게이트를 정하고 나니 배치 문제가 남았다. 기존 check는 && 체인이었다.
"check": "pnpm test && pnpm lint && pnpm typecheck && pnpm build && pnpm test:e2e"
첫 실패에서 멈추니 한 번에 하나씩만 확인하게 되고, required 체크 이름도 하나뿐이다. 그래서 quality와 e2e 두 job으로 나눴는데, 여기서 자연스러워 보이는 선택지가 하나 있었다. e2e에 needs: quality를 걸고 .next를 artifact로 넘기는 것. 빌드를 두 번 안 해도 되니까.
실측이 반대를 가리켰다.
[quality] success 16:48:24 -> 16:49:23 (59초)
[e2e] success 16:49:00 -> 16:50:16 (76초)
↑ quality 종료 전 시작 = 병렬
| 구성 | 벽시계 |
|---|---|
needs 있음 (순차) | 59 + 76 = 135초 |
needs 없음 (병렬, 현재) | max(59, 76) = 76초 |
e2e가 build를 중복 실행해 약 10초를 버린다. 그런데 그 중복을 없애려면 .next를 넘겨받아야 하고, 그러려면 quality 59초를 통째로 기다려야 한다. 10초 중복이 59초 대기보다 싸다.
"그럼 5개로 더 쪼개면?"도 안 줄어든다. 어느 구성이든 e2e가 제일 느리고, 그 비용 대부분이 Playwright 설치 23초라 job을 어떻게 나눠도 안 사라진다. 쪼갤수록 job당 고정비 21초만 늘어난다.
needs를 안 거는 진짜 이유는 시간이 아니었다. lint가 깨지면 e2e가 실행조차 안 되고, lint를 고쳐 다시 push한 뒤에야 E2E도 깨져 있었다는 걸 알게 된다. && 체인이 싫어서 step마다 if: always()를 붙여 놓고 needs를 거는 건, 그 왕복을 job 레벨에서 되살리는 셈이다.
막힌다고 적어 두는 것과 막히는 걸 보는 것
게이트를 다 세우고 나니, 이게 정말 막는지는 아직 확인한 적이 없다는 게 남았다. 그래서 일부러 깨뜨렸다. 소재는 타입 오류 하나로 골랐다 — 한 원인으로 여러 지점이 동시에 실패해 여러 설계를 한 번에 볼 수 있어서다.
// src/shared/config/ciGateProbe.ts
export function probeGate(count: number): string {
return count; // TS2322: Type 'number' is not assignable to type 'string'
}
| job | step | 결과 |
|---|---|---|
quality | 환경 변수 검증 | success |
quality | Lint | success |
quality | Typecheck | failure |
quality | Test | success |
quality | Build | failure |
e2e | Build | failure |
e2e | Run E2E | skipped |
PR은 BLOCKED가 되고 머지 버튼이 잠겼다. 임시 파일을 지우자 CLEAN으로 풀렸다. 한 번의 실행으로 네 가지가 같이 확인됐다 — required가 실제로 막는다, if: always() 덕에 Typecheck 실패 뒤에도 Test가 돌았다, e2e가 quality를 안 기다렸다, 체크 이름이 둘로 분리돼 각각 required로 잡혔다.
덤으로 하나 알았다. push가 애초에 안 됐다. .husky/pre-push가 tsc --noEmit을 돌리고 있어서 --no-verify로 우회해야 했다. 게이트가 CI에만 있는 줄 알았는데 로컬에도 한 층 있었고, 그건 문서 어디에도 안 적혀 있었다.
CI가 원리적으로 못 보는 층
여기까지 하고 나서 같은 질문 — "이게 아니면 못 잡는가" — 을 배포 쪽으로 밀어 봤다. 그러자 CI가 구조적으로 볼 수 없는 것이 드러났다.
이 프로젝트는 라우트 16개가 전부 요청 시 서버 렌더링이고, 서버는 매 요청 자기 API를 절대 URL로 부른다.
// src/shared/config/appOrigin.ts
export function getAppOrigin(): string {
if (process.env.VERCEL_ENV === 'preview' && process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`; // ← Preview 배포에서만 실행
}
const origin = process.env.APP_ORIGIN;
if (!origin) throw new Error('APP_ORIGIN이 설정되지 않았습니다.');
return origin;
}
VERCEL_ENV·VERCEL_URL은 Vercel이 주입한다. CI에도 로컬에도 없다. 단위 테스트가 vi.stubEnv로 분기를 검증하지만, 실제 주입값이 무엇인지는 배포에서만 드러난다.
그래서 smoke test를 배포 URL 대상으로 3경로만 두되, 판정 수준을 "HTTP 200"이 아니라 **"목록에 항목이 1개 이상 있다"**로 잡았다. getAppOrigin()이 틀려도 페이지는 200을 반환할 수 있어서다. 서버 컴포넌트가 데이터 없이 렌더되거나 에러 경계가 fallback을 그리면 상태 코드는 정상이다.
그리고 실제로 거기 버그가 있었다.
Preview 배포는 Vercel Standard Protection 뒤에 있는데, 서버가 자기 API를 부르는 요청에는 방문자의 인증 쿠키가 없다. 그 요청은 302로 보내지고, fetch가 리다이렉트를 따라가 로그인 HTML을 200으로 받아 res.json()에서 터진다. prefetchQuery가 실패를 삼켜서 화면은 멀쩡해 보였다. 브라우저가 같은 쿼리를 다시 받아 왔으니까.
선택지는 둘이었다. 앱 코드에 bypass secret을 넣어 헤더로 보내거나, 방문자가 연 URL로 방문자 쿠키를 실어 보내거나. 후자를 골랐다 — secret이 앱에 들어오지 않으면 유출 경로가 구조적으로 없다. 근소한 차이가 아니라는 건 재 보고 알았다. 로컬 서버로 리다이렉트를 흉내 내 봤더니 커스텀 헤더는 다른 origin으로 갈 때도 그대로 전달되고, 쿠키는 fetch가 제거한다. bypass 방식은 redirect: 'manual' 같은 방어가 전부 지켜져야 성립하는데, 그 리다이렉트 경로는 측정하기 전까지 내 검토 목록에 아예 없었다.
수정 전후를 같은 방법으로 3회씩 쟀다.
| 수정 전 | 수정 후 | |
|---|---|---|
| HTML 응답 완료 중앙값 | 17,021 ms | 821 ms |
브라우저 api/home 재요청 | 3회 모두 발생 | 0 |
서버가 만든 <title> | 3회 모두 기본값 | 3회 모두 홈 데이터 반영 |
17초는 성능 문제가 아니었다. 실패하는 self-fetch를 TanStack Query가 재시도하며 기다린 시간이었다. 그리고 이건 CI를 아무리 늘려도 안 잡힌다.
10주를 한 줄로 줄이면
돌아보니 10주 동안 바뀐 건 코드보다 판단의 근거를 어디서 가져오는가였다.
| 주차 | 무엇으로 정했나 |
|---|---|
| 2 | 코드 냄새 심각도 — 내 주관 |
| 5~6 | 상태의 위치, FSD 계층 — 원칙 |
| 7 | LCP 8,225 → 538 ms — 조건을 고정한 5회 측정 |
| 8 | 테스트를 망가뜨려 여섯 곳 중 다섯 곳 |
| 9 | E2E 대상 — 7,514세션 로그 |
| 10 | required 게이트 — 일부러 실패시킨 PR |
10주차에 새로 배운 건 기술이 아니라 이 질문의 쓸모였다. **"이건 이게 아니면 못 잡는가"**는 무엇을 넣을지만 정하는 게 아니라 무엇을 빼도 되는지, 그리고 어떤 층은 지금 아무도 안 보고 있는지까지 같이 알려준다. Lighthouse가 required에서 빠진 것도, smoke test가 생긴 것도, 17초짜리 버그가 나온 것도 전부 이 질문의 결과다.
물론 다 못 했다. Sentry는 못 넣었고, NEXT_PUBLIC_ENV도 소비처가 없어 미뤘다. 클라이언트 컴포넌트의 SSR 경로에서 Preview 분기가 도는지도 아직 실측이 없다. 그래도 이번엔 뭐가 비었는지를 안다는 상태로 끝났다. 7주차에 RootLayout에 넣고 잊어버린 ?? 'http://localhost:3000' 폴백 같은 건, 비어 있는 줄도 몰랐던 쪽이었다. 로컬에서만 발동하니 CI가 영영 못 잡는다.