axios 보안 패치를 올렸다. 여기까지는 흔한 작업이다.

그런데 이 작업에서 두 번, 모든 자동 검사가 통과한 상태에서 실패가 나왔다. 한 번은 컨테이너가 죽었고, 한 번은 파일이 조용히 사라졌다. 둘 다 우리가 쓴 코드는 한 줄도 바뀌지 않은 자리에서 터졌다.

이 글은 그 두 메커니즘과, 그래서 검증 방식을 어떻게 바꿨는지에 대한 기록이다.


TL;DR

  • 버전 숫자는 배경이다. 위험한 건 그 버전이 끌고 오는 것들이다.
  • 빌드 도구의 의존성 추적기(@vercel/nft)와 런타임(Node)이 같은 패키지의 다른 파일을 정답으로 봤다. 빌드는 성공하고 컨테이너만 죽는다.
  • 이 실패를 자동 검사가 하나도 잡지 못했다. 사람이 파드 상태를 보고 발견했다.
  • FormData 가 조용히 JSON 이 되는 회귀도 있었다. 타입 체크·빌드·HTTP 200 이 전부 통과한다.
  • 그래서 지면 단위 검증이 필요해졌고, 인증된 브라우저 세션 안에서 동일 출처 fetch 로 전 라우트를 훑는 방법으로 자동화했다. 배포된 앱 27개·지면 334개를 앱당 호출 한 번으로 덮었다.
  • 발행 후 덧붙임: 반나절 만에 같은 실패 양식이 리뷰에서 네 번 더 나왔다. 한 번 고쳐서 끝나는 종류가 아니었고, 그중 하나는 원리적으로 QA 로 잡을 수 없는 것이었다.

배경은 짧게

0.26.x 대의 axios 를 쓰고 있었고 CVE 대응이 필요했다. 0.x 유지보수 라인의 패치 최소 버전인 0.33.0 을 골랐다. 1.x 직행은 타입·런타임 변경 폭이 커서 범위를 나눴다.

버전 선택 자체는 어렵지 않다. 공지에 적힌 최소 버전을 고르면 된다. 이 글에서 중요한 건 그다음이다.

전에 Next.js 보안 패치를 다루면서 「버전만 올리고 끝내지 않기」를 썼다. 그때는 그 라이브러리가 직접 노출하는 표면을 나눠서 봤다. 이번에 만난 건 한 겹 아래였다 — 업그레이드가 새로 끌고 온 전이 의존성이 만든 표면이다.


사건 1 — 번들러와 런타임이 같은 파일을 다르게 읽었다

배포는 성공했다. 워크플로는 초록이었다. 그런데 컨테이너 로그가 이랬다.

✓ Ready in 782ms
Error: Cannot find module '/app/node_modules/async-function/require.mjs'
  code: 'MODULE_NOT_FOUND'

async-function 이라는 패키지는 직접 설치한 적이 없다. 코드에서 부른 적도 없다.

왜 이 패키지가 거기 있었나

의존성을 거슬러 올라가면 이렇다.

axios 0.33.0
  └─ form-data          ← 0.33 이 Node 어댑터용으로 새로 요구
      └─ es-set-tostringtag
          └─ get-intrinsic
              ├─ async-function
              ├─ async-generator-function
              └─ generator-function

전이 의존성 4단계 아래다. 그리고 0.26.x 에는 form-data 의존이 없었으므로 이 경로 자체가 존재하지 않았다.

진짜 원인은 exports 조건 하나

세 패키지는 package.json 의 exports 맵에 module-sync 조건을 쓴다. Node 20.19 / 22.10 부터 지원되는 조건부 exports 로, CommonJS 에서 require 할 때도 ESM 파일을 쓸 수 있게 해준다.

문제는 이 조건을 아는 쪽과 모르는 쪽이 갈렸다는 것이다.

  module-sync 결과
Next 14 의 standalone 의존성 추적기 (@vercel/nft) 모름 default 로 해석 → index.js 만 산출물에 복사
런타임 Node 22 require.mjs 로 해석 → 그 파일을 찾음

추적기는 index.js 를 복사했고, 런타임은 require.mjs 를 찾았다. 그 사이에 아무 경고도 없다.

@vercel/nftnodeFileTraceconditions 옵션 없이 호출한다. 기본값은 ["node"] 이고, @vercel/nft 배포본 어디에도 module-sync 라는 문자열이 없다. 모르는 조건이니 무시하는 게 아니라 존재 자체를 모른다.

나머지 두 개는 어떻게 찾았나

처음엔 로그에 찍힌 async-function 하나만 넣었다. 그런데 그건 증상이 알려준 것이지 원인이 알려준 게 아니다. 같은 조건을 쓰는 형제가 더 있으면 다음 배포에서 똑같이 죽는다.

그래서 산출물의 node_modules 를 통째로 훑어 exportsmodule-sync 를 쓰는 패키지를 전수 조사했다.

# 산출물 안의 모든 package.json 에서 module-sync 조건을 쓰는 것만
grep -rl '"module-sync"' .next/standalone/node_modules --include=package.json

두 개가 더 나왔다. async-generator-functiongenerator-function. 셋 다 같은 저자의 같은 패턴이었다.

로그가 알려준 것에서 멈추면 다음 사고가 예약된다. 로그는 표본 하나이고, 필요한 건 그 표본이 속한 집합이다.

이걸 자동으로 잡은 검사는 하나도 없었다

되짚어 보면 이렇다.

  • 타입 체크 — 통과. 타입과 무관하다
  • 빌드 — 통과. 빌드 시점엔 그 파일이 필요 없다
  • 배포 워크플로 — success. 이미지 빌드와 매니페스트 커밋까지가 워크플로의 일이다
  • 헬스 체크 — 구버전 파드가 아직 트래픽을 받고 있어서 초록

사람이 파드 상태를 보고 발견했다. 자동화는 그 뒤에 따라왔다.

이 순서가 이 글의 출발점이다. 파이프라인이 초록이라는 건 “파이프라인이 검사하도록 만들어진 것들이 통과했다”는 뜻이지, “문제가 없다”는 뜻이 아니다. 그리고 이런 종류의 불일치는 애초에 검사 항목으로 존재한 적이 없다.

진단 과정에서 두 번 틀렸다

이 부분도 남겨둔다.

첫 번째 오진 — “베이스 이미지의 Node 버전이 드리프트했다”고 판단했다. 저장소 루트의 Dockerfilenode:18-alpine 이었기 때문이다. 그런데 실제 배포는 다른 경로의 Dockerfile 을 쓰고 있었고, 그쪽은 처음부터 node:22-alpine 이었다. 낡은 파일이 눈에 먼저 띈다는 이유만으로 근거가 됐다.

이튿날 자동 리뷰 봇도 같은 파일을 읽고 같은 방향으로 틀린 지적을 냈다. 사람도 기계도 똑같이 걸린 함정이었다.

두 번째 오진 — “axios 와 무관한 기존 문제”라고 판단했다. async-function 이 lockfile 에 원래 있었기 때문이다. 하지만 lockfile 에 있는 것과 서버 런타임 의존성 그래프에 들어오는 것은 다르다. 끌어온 건 이번 변경이 맞았다.

두 오진의 공통점은 먼저 눈에 띈 증거로 결론을 냈다는 것이다. 그럴듯한 설명이 측정을 대신하면 대개 이렇게 된다. 설명이 매끄러울수록 더 확인하지 않게 된다.

수정

Next 의 outputFileTracingIncludes 로 세 패키지를 산출물에 강제 포함시켰다. 다만 처음 쓴 형태는 이랬다.

outputFileTracingIncludes: {
  '**': ['../../../../node_modules/async-function/**', /* ... */],
}

이 상대경로는 앱이 특정 깊이에 있다는 전제에 의존한다. 그리고 깊이가 틀리면 glob 이 0건 매칭되는데, Next 14 는 0건에 대해 경고도 에러도 내지 않는다. 추적 코드에 결과 길이 검사도, 로깅 호출도 없다.

즉 같은 실패 양식이 그대로 돌아온다. 빌드는 성공하고 컨테이너만 죽는다.

이걸 확인하려면 컨테이너와 같은 조건이 필요했다. 로컬 기본 설정으로는 재현되지 않는다 — 패키지 매니저의 링커 방식이 다르면 애초에 node_modules 가 없거나 배치가 달라진다.

별도 워크트리를 하나 만들어 컨테이너와 같은 조건(런타임 Node 버전 · 링커 방식 · output: 'standalone')으로 맞춘 뒤 빌드했다. 그제야 산출물에서 .mjs 가 빠지는 걸 눈으로 볼 수 있었다.

작업 트리를 건드리지 않고 조건만 바꾼 사본을 만드는 것 — 이게 이런 종류의 버그에선 거의 필수다. 로컬에서 재현이 안 되면 배포마다 추측으로 고치게 된다.

그래서 깊이를 상수로 두지 않고 lockfile 을 위로 탐색해 저장소 루트를 찾아 매번 계산하도록 바꿨다.

const findRepositoryRoot = (startDir) => {
  let dir = startDir;
  while (!fs.existsSync(path.join(dir, 'yarn.lock'))) {
    const parent = path.dirname(dir);
    if (parent === dir) throw new Error('저장소 루트를 특정할 수 없다');
    dir = parent;
  }
  return dir;
};

기준점을 process.cwd() 로 잡은 이유가 있다. Next 의 프로젝트 디렉터리가 곧 cwd 이고, __dirname 은 기준점으로 못 쓴다 — Yarn PnP 에서는 .yarn/__virtual__/... 가상 경로가 나오고, --preserve-symlinks 에서는 node_modules 안쪽이 나온다. __dirname 기준으로 ../../.. 을 세는 방법도 검토했지만 기각했다. 그건 「앱이 루트에서 네 단계 아래」라는 전제를 「공통 모듈이 루트에서 세 단계 아래」라는 전제로 바꾸는 것일 뿐, 전제 자체가 사라지지 않는다.


사건 2 — FormData 가 조용히 JSON 이 됐다

두 번째는 성격이 다르다. 아무것도 죽지 않는다.

axios 0.28 부터 transformRequest 가 이렇게 바뀌었다.

// 이전
if (utils.isFormData(data) || ...) return data;      // 원본 그대로 통과

// 이후
if (isFormData) return hasJSONContentType            // json 헤더면 JSON 문자열로
  ? JSON.stringify(formDataToJSON(data)) : data;

공통 API 계층이 기본 헤더에 Content-Type: application/json 을 박아두는 건 흔한 패턴이다. 그리고 그 계층을 통해 post() 로 FormData 를 보내는 호출부가 있다면 — 전부 걸린다.

증상이 고약하다.

타입 체크 통과 · 빌드 통과 · HTTP 200 응답 · 파일 내용만 사라짐

서버는 {"couponId":"123","file":{}} 같은 걸 받는다. 파일 필드가 빈 객체가 된다. 에러가 안 나므로 로그에도 안 남고, 모니터링에도 안 잡힌다. 다음 분기에 정산이 안 맞아야 발견되는 종류다.

이런 호출부가 11곳 있었다.

각각 고치는 대신 공통 계층 한 곳에서 막았다. FormData 본문이면 application/json 헤더를 빼서 브라우저가 boundary 를 붙이게 둔다.

// axios 내부(utils.isFormData)와 동일한 판정.
// instanceof FormData 로 하면 iframe 등 다른 realm 에서 온 객체를 놓친다.
const isFormDataPayload = (v) => Object.prototype.toString.call(v) === '[object FormData]';

앞으로 추가될 호출부까지 덮인다는 게 중요했다.

이 사건이 다음 절의 이유다. 타입 체크와 빌드로는 못 잡는 회귀가 실재한다는 걸 확인했으니, 화면 단위로 볼 수밖에 없었다.


그래서 지면 검증을 자동화했다

여기가 이 작업에서 제일 재사용할 만한 부분이다.

조건이 나빴다

  • 공통 API 계층을 앱 28개가 공유한다. 한 곳을 고치면 전부 확인 대상이다
  • 정적 라우트만 세도 339개 지면
  • 전부 SSO 뒤에 있다. curl 로는 로그인 페이지만 받는다
  • 브라우저 자동화로 339개 지면을 하나씩 넘기는 건 현실적이지 않다

방법

인증된 브라우저 세션 안에서, 동일 출처 fetch 로 그 앱의 모든 라우트를 호출한다.

const routes = ['/list', '/create', '/detail', /* ... */];
const results = [];
let i = 0;

const worker = async () => {
  while (i < routes.length) {
    const r = routes[i++];
    try {
      const res = await fetch(r, { credentials: 'same-origin', redirect: 'manual' });
      results.push({ r, s: res.status });
    } catch (e) {
      results.push({ r, s: 'ERR' });
    }
  }
};

await Promise.all(Array.from({ length: 4 }, worker));  // 동시 4개

포인트가 몇 가지 있다.

동일 출처라서 세션 쿠키가 그대로 실린다. 로그인 한 번이면 그 앱의 전 지면을 볼 수 있다.

이게 SSR 을 실제로 태운다. 페이지 HTML 을 fetch 하면 getServerSideProps 가 서버에서 돈다. 즉 서버 사이드 HTTP 클라이언트 경로가 그대로 실행된다. 이번 작업처럼 공통 HTTP 계층을 건드렸을 때 정확히 보고 싶은 지점이다.

리다이렉트를 따라가면 안 된다. 따라가는 순간 인증이 필요한 앱들이 전부 로그인 페이지로 수렴한다. 이건 실제로 겪었다 — 처음에 셸에서 curl -L 로 훑었더니 14개 앱이 전부 같은 번들 해시를 반환했다. 앱 14개를 측정한 게 아니라 로그인 앱 하나를 14번 측정하고 있었다. 브라우저 안에서는 redirect: 'manual' 이 같은 역할을 한다.

라우트 목록은 파일 시스템에서 뽑는다. pages/ 를 훑어 정적 라우트만 남기면 된다. 동적 라우트([id])는 유효한 식별자가 필요하니 제외한다.

비용은 앱당 호출 한 번이다. 실제로 334개 지면을 몇 분 안에 끝냈다. 339개 중 미배포 앱과 로그아웃 라우트를 뺀 수다.

판정 기준을 먼저 정했다

여기서 한 번 크게 틀렸기 때문에 적어둔다.

처음에는 배포 성공 표시만 믿고 검증했다. 그런데 그때 서빙되던 건 구버전이었다. 파드가 기동 직후 죽어서 옛 파드가 계속 트래픽을 받고 있었다.

더 나쁜 건, 그때 확인한 지표들이 두 버전에서 똑같이 나오는 값이었다는 점이다. 그래서 통과로 읽혔다. 지표가 두 버전을 구분하지 못하면 그건 검사가 아니라 통과가 보장된 의식이다.

전에도 같은 함정에 빠진 적이 있다. 그때는 확인 대상이 틀렸고, 이번엔 확인 지표가 틀렸다. 증상은 같다 — 열심히 확인했는데 아무것도 확인되지 않았다.

그래서 순서를 바꿨다.

  1. 번들에서 실제 서빙 중인 라이브러리 버전을 먼저 확인한다 — 신버전 식별자가 있고 구버전 문자열이 0건인지
  2. 그다음에 지면을 훑는다

1번을 통과 못 하면 2번 결과는 아무 의미가 없다.

이 순서 덕에 나중에 한 번 더 걸렀다. 배포 직후 어떤 앱이 구버전을 서빙하고 있어서 실패로 의심했는데, 롤아웃이 아직 안 끝난 상태였다. 조금 뒤 buildId 가 바뀐 걸 확인하고 나서야 통과로 기록했다.

이 방법이 못 잡는 것

정직하게 적어둔다.

  • 클라이언트 렌더와 클라이언트 XHR 은 안 본다. fetch 는 HTML 만 받는다. React 가 마운트되고 데이터를 부르는 과정은 실행되지 않는다
  • 그래서 앱마다 대표 화면 하나씩은 실제로 띄워서 네트워크·콘솔·렌더를 봤다
  • 동적 라우트는 빠진다
  • 서버가 200 을 주면서 내용이 틀린 경우는 못 잡는다 — 사건 2 같은 실패는 이 스윕으로 안 걸린다

마지막 항목이 중요하다. 이 방법은 만능이 아니라 “SSR 경로가 살아 있는가”를 넓게 확인하는 도구다. 조용한 데이터 손실은 여전히 전송 직전 요청을 들여다봐야 한다.

하나 더. “서버가 뜨는가”만 보는 스모크는 이 버그를 놓칠 수 있다. 이번엔 운 좋게 부팅 직후 죽었지만, 문제의 패키지가 지연 로드되는 위치에 있었다면 서버는 멀쩡히 뜨고 첫 요청에서 500 이 났을 것이다.

그래서 배포 후 최소 단위는 「기동 확인」이 아니라 「기동 + 요청 한 건」 이어야 한다. 판정 기준도 200 이 아니라 5xx 가 아님 이다 — 인증 리다이렉트(302/307)는 정상이고, 그것도 서버가 앱 코드를 실행했다는 증거다.


덧붙임 — 발행 후 반나절 만에 네 번 더 나왔다

이 글을 올리고 반나절 동안 리뷰가 붙었다. 같은 실패 양식이 네 번 더 나왔다. 전부 이 글에 쓴 수정 안에서였다.

① 고치면서 한 겹 아래에 같은 걸 남겼다

experimental 이 통째로 덮이는 건 막았는데, 그 안의 outputFileTracingIncludes 는 여전히 얕게 덮였다. 앱이 라우트별 include 를 하나만 추가해도 module-sync 목록이 사라진다.

{ outputFileTracingIncludes: { '**': [...] }, ...{ outputFileTracingIncludes: { '/api/foo': [...] } } }
// → '**' 살아있나: false

리뷰어의 표현이 정확했다 — “이게 정확히 이 PR 이 이미 고친 실패 양식과 같은 종류”. 한 층을 막고 그 아래 층을 그대로 둔 것이다.

② 가드와 대상이 같은 것을 다르게 봤다

FormData 판정 헬퍼에 “axios 내부와 동일한 판정” 이라고 주석을 달았는데, 실제로는 두 갈래 중 하나만 봤다.

// axios 의 isFormData
toString.call(thing) === pattern ||
(isFunction(thing.toString) && thing.toString() === pattern)   // ← 이걸 놓쳤다

두 판정이 어긋나면 가드는 안 걸리는데 transformRequest 는 FormData 로 인식한다. 즉 이 가드가 막으려던 실패가 가드를 우회해 돌아온다. 이 글의 「사건 1」과 구조가 똑같다 — 두 컴포넌트가 같은 대상을 다르게 본다.

③ 가정을 없앴다고 생각했는데 다른 가정이 남아 있었다

깊이 하드코딩을 없애면서 “전제가 사라졌다” 고 썼다. 아니었다. 계산된 glob 은 여전히 루트 node_modules 만 가리킨다. 패키지가 앱 로컬로 내려가면 0건이다.

그리고 이 저장소에서 그건 가정이 아니었다.

앱 로컬 node_modules   24개 존재
get-intrinsic          루트 1.2.4  +  중첩 1.3.1 ×3   ← module-sync 3종을 끌어오는 바로 그 버전

오늘 안 빗나가는 이유는 async-function 의 버전이 하나뿐이라 루트에 남기 때문일 뿐이다. 버전이 갈리는 순간 조용히 빗나간다.

④ 가드가 호출부의 표기 하나로 우회된다

앞의 셋은 전부 「내가 같은 걸 다른 층에 또 남겼다」였다. 네 번째는 성격이 다르다.

FormData 가드는 Content-Type 을 제거해서 동작한다. 그런데 'Content-Type' 키만 정확히 비교하고 제거했다.

호출부가 { 'content-type': 'application/json' } 을 넘김      ← 소문자 표기
→ 병합 결과 { 'Content-Type': ..., 'content-type': ... }     두 키 공존
→ 가드는 'Content-Type' 만 제거
→ 남은 'content-type' 을 axios 가 normalizeHeaderName 으로 정규화해 읽음
→ FormData 가 JSON 으로 직렬화                                가드 우회

HTTP 헤더 이름은 대소문자를 구분하지 않는다. axios 도 정규화한 뒤 본다. 가드만 구분했다.

이건 QA 로 잡을 수 없다

여기가 중요하다. 앞의 셋은 「검사에서 안 잡혔다」인데, 이건 「잡힐 수 없다」 다.

요청 헤더를 소문자로 넘기는 호출부가 저장소에 0건이다. 소문자 표기가 몇 군데 있긴 한데 전부 응답 헤더를 읽는 코드다. 즉 이 코드 경로는 실행되지 않는다.

실행되지 않는 경로는 27개 앱을 배포하고 334지면을 훑어도 관측되지 않는다. 브라우저를 아무리 돌려도 존재하지 않는 호출부는 요청을 보내지 않는다. 앞 절에서 만든 전 지면 스윕도, 계약 점검 스크립트도, 여기엔 원리적으로 닿지 못한다.

「지금 그런 호출부가 없다」는 안전의 근거가 아니라 관측 불가의 이유였다. 그리고 이런 건 코드를 읽어야만 나온다.

그래서 배운 것

같은 실패 양식은 한 번 고쳐서 끝나지 않는다. 층을 바꿔 다시 나타난다. 「조용히 0건이 되고 빌드는 성공한다」는 구조를 한 번 만났으면, 그 구조가 코드 안에 몇 군데나 더 있는지를 세어봐야 했다. 나는 만난 자리만 고쳤다.

그리고 네 번 다 사람이 잡았다. 타입 체크·빌드·계약 점검·전 지면 스윕을 다 통과한 뒤였다. 앞에서 “자동화가 못 잡은 걸 사람이 먼저 발견했다” 고 썼는데, 그게 배포 때 한 번이 아니라 리뷰에서 네 번 더 반복됐다.

그리고 ④가 알려준 게 하나 더 있다. 「잡히지 않았다」와 「잡힐 수 없다」는 다르다.

앞의 셋은 검사를 더 촘촘히 만들면 언젠가 걸린다. ④는 아니다. 실행되지 않는 코드 경로는 실행 기반 검증의 사정거리 밖이다. 스윕을 열 배로 늘려도, 앱을 백 개 배포해도 안 나온다.

거기에 닿는 방법은 지금으로선 코드를 읽는 것뿐이었다.

세 지적 모두 코드로 닫았고, 마지막 것은 완전히 막지 못해 빌드 로그에 경고를 남기는 선에서 그쳤다. 던져서 빌드를 세우지 않은 이유는, 패키지가 의존성에서 빠져 사라진 경우와 앱 로컬로 내려간 경우를 빌드 시점에 구분할 수 없어서다.


남는 것

이번 작업에서 얻은 판단 기준을 정리하면 이렇다.

버전 숫자는 시작이지 끝이 아니다. 위험은 그 버전이 새로 끌고 오는 전이 의존성, 그리고 그 의존성이 번들러·런타임과 맺는 관계에 있다. 릴리스 노트에는 안 적혀 있다.

“CI 초록”은 “문제 없음”이 아니다. 검사하도록 만들어진 것들이 통과했다는 뜻이다. 이번 두 사건 다 검사 항목으로 존재한 적이 없는 실패였다.

지표가 실패할 수 없으면 검사가 아니다. 두 버전에서 같은 값이 나오는 지표로는 아무것도 판정할 수 없다. 확인하기 전에 “이 지표는 어떤 경우에 빨간불이 되는가”를 먼저 답해야 한다.

넓은 검사와 깊은 검사는 서로를 대신하지 못한다. 전 지면 스윕은 SSR 경로가 죽었는지를 넓게 본다. 조용한 직렬화 회귀는 요청 하나를 깊게 들여다봐야 나온다. 둘 다 필요하다.

그리고 하나 더. 자동화가 못 잡은 걸 사람이 먼저 발견했다. 기계는 그다음에 메커니즘을 설명했다. 이 순서를 뒤집으려 하기보다, 사람이 이상하다고 느낀 지점을 빠르게 기계에 넘겨 근거를 만들게 하는 쪽이 현실적이었다.

덧붙임 절에서 적었듯, 그 순서는 배포 때 한 번으로 끝나지 않았다. 리뷰에서 네 번 더 반복됐다. 자동 검사를 다 통과한 뒤에 사람이 네 번 더 잡았다는 뜻이다. 그중 하나는 검사를 아무리 늘려도 닿지 않는 자리에 있었다.