배포 후 운영 환경에서 본 오류 가운데 가장 유용했던 것 중 하나는 겉보기에는 거의 평범했습니다.
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
같은 오류 흐름에는 분석 도구 실패, 광고 스크립트 오류, 일반적인 Script error. 메시지, 중단된 동영상 재생도 섞여 있었습니다. 대부분은 잡음이었습니다. 하지만 이 경우는 달랐습니다. 로드에 실패한 리소스가 제 Next.js 애플리케이션 자체에 속했기 때문입니다. 브라우저가 실제로 그 파일을 가져오지 못했다면 페이지 일부가 동작을 멈출 수 있습니다.
다만 그 로그만으로는 청크가 왜 실패했는지 알 수 없었습니다. 일시적인 네트워크 문제일 수도 있고, 프록시나 CDN 장애일 수도 있으며, 파일이 정말 없었을 수도 있습니다. 또는 서버가 이미 이전 빌드를 새 빌드로 바꾼 뒤에도 오래된 페이지가 이전 배포의 청크를 계속 요청하고 있었을 수 있습니다.
마지막 경우는 과소평가하기 쉽습니다. 새 배포 자체는 완전히 정상일 수 있기 때문입니다. 새 방문자는 새 버전을 문제없이 받지만, 몇 시간 전에 열어 둔 탭은 조용히 이전 버전의 클라이언트로 남아 있습니다.
이 글은 바로 그 호환성 공백을 다룹니다. 오래된 Next.js 탭이 배포 후 왜 깨질 수 있는지, 오래된 HTML과 사라진 /_next/static 리소스가 어떻게 버전 불일치를 만드는지, 지나치게 공격적인 정리가 왜 문제를 키우는지, 그리고 이미 애플리케이션을 열어 둔 사용자를 깨진 페이지에 남겨 두지 않기 위해 배포, 이전 자산 보존, 모니터링, 복구를 어떻게 설계할지를 설명합니다.
첫 번째 교훈: 모든 스크립트 실패를 배포 오류로 단정하지 않는다
원래 오류 흐름에는 성격이 완전히 다른 실패들이 한데 섞여 있었습니다. 외부 분석 및 광고 스크립트는 콘텐츠 차단기, DNS 필터, 개인정보 보호 기능, 지역 제한, 백신 프로그램, 사용자 네트워크 때문에 차단될 수 있습니다. 동영상의 play() Promise는 이후의 pause() 호출 때문에 중단될 수 있으며, 애플리케이션 자체가 고장 난 것은 아닐 수 있습니다. 출처가 다른 스크립트에서 발생한 일반적인 Script error. 역시 진단하기에는 정보가 너무 부족한 경우가 많습니다.
반면 애플리케이션 자체의 Next.js 청크가 로드되지 않은 상황은 다른 우선순위로 봐야 합니다. 유용한 경계는 “JavaScript 오류가 있느냐 없느냐”가 아니라 대략 다음과 같습니다.
외부 리소스 로드 실패
-> 대개 측정 기능이나 선택 기능에 영향
애플리케이션 자체 /_next/static/*.js 로드 실패
-> 애플리케이션 코드 일부를 사용할 수 없을 수 있음
이 구분이 중요한 이유는 지나치게 시끄러운 오류 수집기가 실제로 깨진 페이지와 연관된 문제를 가릴 수 있기 때문입니다. 제 경우 중요한 사건은 /_next/static/chunks/9253.647385b4be0958e4.js 요청이었습니다. 로그는 애플리케이션 자체 스크립트 로드가 실패했다는 점은 증명했지만, 배포 버전 불일치가 원인이라는 점까지 증명하지는 못했습니다.
저는 이 증거의 경계를 의도적으로 분명히 둡니다. 그럴듯한 원인과 확인된 원인은 다릅니다.
열려 있는 브라우저 탭은 사실상 이전 릴리스의 클라이언트다
이 문제를 보는 방식을 바꿔 준 사고방식은 단순합니다. 배포가 끝난 뒤에도 애플리케이션의 여러 버전이 동시에 살아 있을 수 있습니다.
릴리스 A가 10:00에 서비스 중이라고 가정해 보겠습니다. 사용자가 페이지를 열고 해당 경로에 필요한 HTML과 JavaScript를 받습니다. 10:30에 릴리스 B가 A를 대체합니다. 새 방문자는 B를 받습니다. 하지만 이미 열려 있는 탭은 서버가 바뀌었다는 이유만으로 자동으로 B가 되지 않습니다.
그 탭에는 여전히 다음이 남아 있을 수 있습니다.
- 릴리스 A에서 로드한 JavaScript 런타임;
- A가 만든 경로와 청크 참조;
- A에서 미리 가져온 탐색 데이터;
- A가 실행되는 동안 생성된 React 상태;
- A에서 이미 내려받은 코드 분할 모듈;
- A에 속하지만 아직 내려받지 않은 모듈에 대한 참조.
마지막 항목에서 문제가 눈에 보이기 시작합니다.
페이지가 앞으로 필요로 할 모든 청크가 이미 브라우저 캐시에 있다면 사용자는 아무 문제도 느끼지 못할 수 있습니다. 하지만 현대적인 Next.js 애플리케이션은 코드를 나눕니다. 경로 이동, 동적 가져오기, 모달, 편집기, 나중에 사용하는 기능 등이 추가 JavaScript 파일을 필요로 할 수 있습니다. 그러면 오래된 런타임은 릴리스 A에서 유효했던 URL로 리소스를 요청합니다.
서버가 그 리소스를 계속 보유하고 있다면 정상적으로 이어질 수 있습니다. 배포 과정에서 삭제했다면 릴리스 B 자체는 완전히 정상이어도 오래된 클라이언트는 404를 받을 수 있습니다.
내용 해시가 붙은 청크는 장기 캐시를 전제로 한다
Next.js는 실제로 변경되지 않는 리소스에 의도적으로 긴 캐시 정책을 적용합니다. 현재 자체 호스팅 문서는 파일 이름에 SHA 해시가 들어간 불변 리소스가 예를 들어 다음과 같은 1년 정책으로 제공된다고 설명합니다.
Cache-Control: public, max-age=31536000, immutable
내용이 바뀌면 URL도 바뀌므로 합리적인 정책입니다. 내용에서 이름이 결정되는 파일은 매 요청마다 재검증할 필요가 없습니다. 이후 빌드가 다른 바이트를 만들면 다른 리소스 URL을 만들어야 합니다.
여기서 놓치기 쉬운 중요한 결과가 있습니다. 오래된 문서나 런타임이 계속 참조할 수 있는 동안에는 오래된 URL도 여전히 의미가 있습니다.
브라우저가 해시 리소스를 1년 동안 캐시할 수 있어도, 배포 전에 바로 그 파일을 한 번도 내려받지 않았고 처음 필요한 순간에 원본 서버가 이미 파일을 삭제했다면 아무 도움이 되지 않습니다.
따라서 “정적 파일은 불변이다”와 “이전 릴리스의 정적 파일을 즉시 삭제해도 된다”는 같은 말이 아닙니다. 불변성은 오래된 리소스를 안전하게 보관할 수 있게 해 주지만, 오래된 클라이언트가 그 리소스를 요청하지 않게 만드는 것은 아닙니다.
현재 Next.js 자체 호스팅 안내서는 다중 서버 또는 롤링 배포에서 나타나는 버전 불일치의 증상으로 JavaScript나 CSS 파일 누락을 명시합니다. 두 서버가 동시에 다른 버전을 제공하는 상황이 아니라 오래된 탭과 방금 갱신된 원본 서버 사이에 불일치가 생긴 경우도 같은 종류의 문제입니다.
버전은 여러 방식으로 서로 어긋날 수 있다
“캐시 문제”라는 표현은 진단으로 쓰기에는 너무 모호합니다. 저는 적어도 네 가지 원인으로 나눠 봅니다. 필요한 해결책이 서로 다르기 때문입니다.
1. 오래된 탭이 배포 전에 한 번도 로드하지 않은 리소스를 요청한다
오래 열어 둔 탭에서 발생하는 전형적인 상황입니다. 문서와 런타임은 릴리스 A에서 왔습니다. 릴리스 B가 서버의 파일을 교체합니다. 이후 사용자가 A의 지연 로드 청크가 필요한 동작을 실행합니다. A의 리소스가 삭제되었다면 요청은 실패합니다.
2. 오래된 HTML이 이미 사라진 청크를 가리킨다
CDN, 역방향 프록시, Service Worker, 브라우저 캐시, 정적 호스팅 계층은 예상보다 오랫동안 오래된 HTML 문서를 유지할 수 있습니다. 그 HTML은 여전히 릴리스 A의 참조를 포함하지만 원본 서버에는 B만 남아 있을 수 있습니다.
HTML에 실수로 긴 immutable 정책이 적용되면 특히 위험합니다. 해시가 붙은 JavaScript와 HTML을 같은 캐시 객체처럼 다루면 안 됩니다. 청크는 URL이 내용에 따라 버전이 나뉘므로 불변으로 다룰 수 있습니다. 어떤 청크 URL 묶음이 함께 동작해야 하는지 결정하는 것은 HTML입니다.
3. 롤링 배포나 여러 인스턴스가 서로 다른 릴리스를 섞어 제공한다
부하 분산기 뒤에 Next.js 인스턴스가 두 개 있다고 해 봅시다. 하나는 이미 B이고, 다른 하나는 아직 A입니다. 문서는 한 버전에서 오고, 이후 탐색 요청은 다른 버전에 도착할 수 있습니다. 현재 Next.js 문서는 이를 버전 불일치라고 부르며, 리소스 누락, Server Functions 불일치, 탐색 실패를 일으킬 수 있다고 설명합니다.
가장 안전한 기본값은 한 번만 빌드하고, 같은 배포에 참여하는 모든 인스턴스에서 동일한 빌드 산출물을 실행하는 것입니다. Next.js 자체 호스팅 문서도 각 복제본을 독립적으로 다시 빌드하기보다 모든 컨테이너가 같은 빌드와 일관된 빌드 식별자를 사용하도록 권장합니다.
4. 배포 자체가 파일을 잘못된 순서로 공개한다
오래된 탭이 없어도 원자적이지 않은 업로드는 잠깐 동안 성립할 수 없는 상태를 만들 수 있습니다.
새 HTML은 이미 공개됨
+
새 청크 파일은 아직 사용 불가
또는 반대로,
오래된 HTML은 아직 공개됨
+
오래된 청크 파일은 이미 삭제됨
아주 짧은 시간이어도 충분합니다. 사용자가 한 번만 그 순간에 들어오면 됩니다.
위험한 배포 방식은 “모두 교체하고 이전 트리를 삭제”하는 것이다
간단한 배포 스크립트는 흔히 다음과 같은 형태에서 시작합니다.
build
rsync --delete new-output/ production/
restart
운영 디렉터리가 항상 최신 빌드와 정확히 일치하므로 매력적입니다. 하지만 오래 살아 있는 클라이언트에는 좋지 않습니다.
해시가 붙은 정적 리소스에서는 디렉터리를 한 릴리스만 남기도록 정리해도 브라우저에 주는 이점이 거의 없습니다. URL이 다르므로 오래된 파일과 새 파일은 충돌하지 않습니다. 삭제하면 주로 디스크 공간을 아낄 뿐이고, 그 대신 오래된 클라이언트가 가진 유효한 참조를 잠재적인 404로 바꿉니다.
이제 저는 오래된 청크를 쓰레기가 아니라 배포 호환성을 유지하는 자산으로 봅니다.
모든 빌드를 영원히 보관하자는 뜻은 아닙니다. 정리는 최신 릴리스 게시의 부수 효과가 아니라 별도의 보존 정책이어야 합니다.
이전 리소스 보존은 유용하지만 유한한 기간만으로는 완전한 해결책이 아니다
자체 호스팅 환경에서는 오래된 /_next/static 리소스를 일정 유예 기간 동안 보관할 수 있습니다. 적절한 기간은 사용 방식에 따라 다릅니다. 페이지를 열어 2분 읽고 떠나는 사이트와 하루 종일 열어 두는 애플리케이션은 위험 특성이 다릅니다.
최소 보존 기간은 다음처럼 생각하면 유용합니다.
보존 기간 >=
오래된 HTML이 남을 것으로 예상되는 시간
+ 오래 열어 둔 탭의 현실적인 생존 시간
+ 되돌리기 가능 기간
+ 배포 전파 여유
수학적인 보장은 아닙니다. 브라우저 탭은 몇 주 동안 열어 둘 수도 있습니다. 어떤 유한한 시간 값으로도 오래된 클라이언트의 실패 가능성을 완전히 없앨 수는 없습니다.
그래서 저는 여러 층으로 보호하는 설계를 선호합니다.
- 일반적인 이전 세션이 계속 동작할 만큼 충분한 기간 동안 앞선 릴리스의 불변 리소스를 보관한다.
- 버전 불일치를 감지해 클라이언트가 현재 릴리스로 이동할 수 있게 한다.
- 리소스가 실제로 사라졌다면 한 번의 안전한 새로고침 또는 사용자에게 보이는 복구 경로를 제공한다.
- 애플리케이션 자체 청크 누락을 감시해 실제 데이터에 따라 보존 기간을 조정한다.
보존 계층이 대부분의 실패를 막습니다. 복구 계층은 어떤 유한한 보존 기간으로도 완전히 없앨 수 없는 나머지 상황을 처리합니다.
파일 나이만 보고 오래된 청크를 무작정 삭제하지 않는다
“7일보다 오래된 파일을 모두 삭제” 같은 단순한 규칙도 틀릴 수 있습니다. 현재 릴리스가 내용이 바뀌지 않아 수정 시각만 오래된 해시 파일을 그대로 재사용하고 있을 수 있기 때문입니다.
더 강한 불필요 자산 정리 방식은 릴리스를 인식해야 합니다.
- 호환 기간 안에 있는 모든 릴리스의 매니페스트 또는 리소스 목록을 보관한다.
- 그 릴리스들이 참조하는 모든 리소스 경로의 합집합을 만든다.
- 그 보호 집합 안에 있는 것은 절대 삭제하지 않는다.
- 참조되지 않는 리소스도 추가 유예 기간이 지난 뒤에만 제거한다.
작은 배포에 이 구조가 너무 복잡하다면, 정적 리소스 디렉터리를 의도적으로 넉넉하게 유지하는 편이 드문 클라이언트 장애를 조사하는 것보다 싸게 먹히는 경우가 많습니다. 해시 파일은 특히 잘 맞습니다. 같은 내용은 자연스럽게 안정된 URL을 재사용하고, 적어도 같은 해시 이름 아래 전혀 다른 내용을 덮어쓸 수 없습니다.
제가 피하는 규칙은 단순합니다. 공유 /_next/static 트리에서 --delete를 실행하는 작업을 새 릴리스를 활성화하는 동일 작업 안에 넣지 않습니다.
Next.js에는 명시적인 버전 불일치 보호가 있지만 이전 리소스를 보관해 주는 기능은 아니다
현재 Next.js는 버전 불일치 보호를 위해 deploymentId를 지원합니다. 설정은 다음처럼 할 수 있습니다.
// next.config.js
const nextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
}
module.exports = nextConfig
현재 Next.js deploymentId 문서에 따르면, 이를 설정하면 프레임워크가 관리하는 정적 리소스 URL에 ?dpl=<deploymentId>가 붙고, 클라이언트 측 탐색 요청에는 배포 정보가 포함되며, 서버는 응답에 자신의 배포 식별자를 전달합니다. Next.js가 탐색 중 불일치를 발견하면 호환되지 않는 데이터로 클라이언트 내부 전환을 계속하는 대신 전체 페이지 탐색을 수행할 수 있습니다.
?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id
유용하지만 기능이 실제로 하는 것보다 더 많은 일을 기대하면 안 됩니다. 문서는 Next.js가 들어오는 ?dpl= 값을 읽어 특정 버전으로 요청을 라우팅하지 않는다고 명시합니다. 이 값은 오래된 캐시를 피하기 위한 것입니다. 자체 호스팅 원본 서버가 오래된 파일을 물리적으로 지웠다면 쿼리 매개변수가 파일을 되살리지는 못합니다.
그래서 저는 deploymentId를 불일치 감지와 복구 수단으로 사용하지, 깔끔한 배포나 이전 리소스 보존을 대신하는 기능으로 보지는 않습니다.
버전을 인식하는 라우팅을 제공하는 플랫폼에서는 인프라가 더 많은 일을 할 수 있습니다. 예를 들어 현재 Vercel의 버전 불일치 보호 문서는 프레임워크 관리 요청이 처음 해당 클라이언트를 서비스한 배포로 계속 전달되게 하는 버전 고정을 설명합니다. 이는 플랫폼 기능이며, 임의의 Nginx나 CDN 설정에 당연히 있다고 가정하지 않습니다.
빌드 식별자와 배포 식별자는 관련 있지만 서로 다른 문제를 푼다
Next.js는 next build 실행 중 빌드 식별자도 생성합니다. 여러 컨테이너가 같은 배포를 제공해야 한다면 각 서버가 독립적으로 빌드했다는 이유로 조용히 서로 다른 빌드가 되어서는 안 됩니다.
결정적인 빌드 식별자는 Git 커밋 같은 릴리스 식별자에 연결할 수 있습니다.
// next.config.js
const nextConfig = {
generateBuildId: async () => process.env.GIT_SHA,
deploymentId: process.env.GIT_SHA,
}
module.exports = nextConfig
이 예시는 설명을 위한 것이며 제 운영 코드에서 복사한 것이 아닙니다. 중요한 구조 원칙은 하나의 논리적 릴리스가 자신을 제공하는 모든 인스턴스에서 하나의 일관된 빌드 산출물과 하나의 배포 정체성을 가져야 한다는 점입니다.
generateBuildId는 Next.js 빌드를 식별합니다. deploymentId는 버전 불일치 보호와 오래된 캐시 회피를 위해 명시적으로 문서화되어 있습니다. 서로 관련은 있지만 같은 말처럼 다루면 문제 분석이 더 어려워집니다.
새 문서로 트래픽을 전환하기 전에 리소스를 먼저 공개한다
더 안전한 배포 순서는 의도적으로 비대칭입니다. 새 불변 리소스는 아무도 참조하기 전에 미리 존재해도 됩니다. 반대로 새 HTML은 아직 사용할 수 없는 리소스를 참조하면 안 됩니다.
개념적으로는 다음 순서를 원합니다.
1. 릴리스 B를 한 번만 빌드한다
2. B의 /_next/static 리소스를 업로드한다
3. 필요한 리소스를 실제로 가져올 수 있는지 확인한다
4. B의 서버와 런타임을 시작하거나 준비한다
5. B의 상태를 확인한다
6. 새 문서 트래픽을 원자적으로 B로 전환한다
7. A의 정적 리소스는 계속 이용 가능하게 둔다
8. B를 모니터링한다
9. 이전 리소스는 나중에 정리한다
애플리케이션이 정적 내보내기 방식이어도 원칙은 같습니다. 버전이 붙은 리소스를 먼저 올리고, 그다음 이를 참조하는 HTML을 공개합니다. 역방향 프록시 뒤에서 서버 측 렌더링을 한다면 새 서버를 준비한 뒤 정상 상태가 확인되었을 때만 트래픽을 전환합니다.
되돌리기도 대칭적으로 설계해야 합니다. 이전 릴리스 디렉터리와 정적 리소스를 보관하면 사고 후에 옛 파일을 재구성하려 애쓰지 않고 애플리케이션을 되돌릴 수 있습니다.
그렇다고 모든 되돌리기가 안전해지는 것은 아닙니다. 데이터베이스 마이그레이션이나 호환되지 않는 백엔드 계약 때문에 JavaScript 파일이 남아 있어도 이전 애플리케이션 버전이 동작하지 않을 수 있습니다. 정적 리소스 보존은 정적 호환성 문제를 해결할 뿐, 시스템 전체의 모든 릴리스 호환성 문제를 해결하지는 않습니다.
공유 불변 리소스 디렉터리는 단순한 자체 호스팅에 잘 맞는다
소규모 Nginx 배포에서는 현재 애플리케이션 릴리스와 공유 정적 리소스 저장소를 분리하는 방식이 단순하고 효과적입니다.
예시 구조는 다음과 같습니다.
/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/
/srv/app/shared/_next/static/...
각 배포는 새 /_next/static 파일을 공유 디렉터리에 추가하고, 아직 보존해야 하는 이전 릴리스의 파일은 삭제하지 않습니다. Nginx는 이 경로를 불변 캐시 정책으로 제공할 수 있습니다.
location ^~ /_next/static/ {
root /srv/app/shared;
add_header Cache-Control "public, max-age=31536000, immutable";
}
이 설정은 예시일 뿐, 제가 실제로 사용한 정확한 Nginx 설정이라는 뜻은 아닙니다. 실제 배포에서는 권한, MIME 형식, 압축 변형, CDN 동작, 출력 배치 구조를 고려해야 합니다.
중요한 점은 구조입니다. 현재 릴리스를 가리키는 변경 가능한 포인터와, 주로 새 파일을 덧붙이는 버전별 리소스 저장소는 서로 다른 수명 주기를 가집니다.
HTML에는 해시 청크와 다른 캐시 정책이 필요하다
문제를 가장 쉽게 다시 만드는 방법은 HTML을 내용 해시 리소스처럼 캐시하는 것입니다.
동적으로 렌더링되는 Next.js 페이지에서 프레임워크는 사용자별 동적 출력에 대해 보통 캐시하지 않는 응답 의미를 사용합니다. 정적 페이지와 ISR 페이지는 다른 정책을 따르며 CDN이 정상적으로 캐시할 수 있습니다. Nginx가 제공하는 정적 내보내기는 운영자가 설정한 헤더에 더 크게 좌우됩니다.
그래서 저는 “웹사이트 전체”에 하나의 캐시 규칙을 적용하지 않습니다. 객체 유형별로 봅니다.
/_next/static의 해시 리소스
긴 max-age
immutable
보관해도 안전
HTML / 경로 문서
새 릴리스로 이동할 수 있어야 함
정책은 렌더링 방식에 따라 달라짐
자신이 참조하는 리소스보다 오래 살아서는 안 됨
RSC / 탐색 데이터 / API 데이터
별도의 호환성과 최신성 규칙
CDN을 사용한다면 캐시 설계에 따라 배포 후 새 문서 경로를 무효화해야 할 수 있습니다. 반대로 새 릴리스가 나왔다는 이유만으로 CDN의 오래된 해시 청크를 지우는 것은 대개 역효과입니다. 원본도 이미 삭제했다면 오래된 클라이언트를 살릴 수 있던 마지막 복사본까지 없애는 셈입니다.
Next.js CDN 캐시 안내서는 페이지 캐시와 /_next/static 리소스에 적용되는 1년짜리 immutable 정책을 분리해 설명한다는 점에서 유용합니다.
자동 새로고침은 복구 도구이지 주된 배포 전략이 아니다
청크 실패에 대한 흔한 대응은 “페이지를 다시 불러오면 된다”입니다. 전체 페이지 탐색이 현재 빌드를 참조하는 최신 문서를 가져오기 때문에 많은 경우 해결됩니다.
하지만 모든 스크립트 오류에서 무조건 다시 불러오면 새로운 문제가 생깁니다.
- 외부 스크립트 실패도 불필요한 새로고침을 일으킬 수 있습니다.
- 실제 서버 장애에서는 무한 새로고침 반복이 생길 수 있습니다.
- 저장하지 않은 양식의 사용자 입력을 잃을 수 있습니다.
- 전체 페이지 탐색에서는 React 컴포넌트 상태가 사라집니다.
- 같은 깨진 배포가 다시 실패할 뿐일 수도 있습니다.
현재 Next.js 문서도 버전 불일치 복구에 사용하는 전체 페이지 탐색이 useState 같은 컴포넌트 상태를 잃게 할 수 있는 반면 URL이나 지속 브라우저 저장소의 상태는 남을 수 있다고 경고합니다.
클라이언트 복구를 넣는다면 대상은 좁고 시도는 한 번뿐이어야 합니다. 예시는 다음과 같습니다.
const RECOVERY_KEY = 'next-chunk-recovery-attempted'
function isOwnNextAsset(url: string) {
try {
const parsed = new URL(url, window.location.href)
return (
parsed.origin === window.location.origin &&
parsed.pathname.startsWith('/_next/static/')
)
} catch {
return false
}
}
window.addEventListener(
'error',
(event) => {
const target = event.target
if (!(target instanceof HTMLScriptElement)) return
if (!isOwnNextAsset(target.src)) return
reportChunkFailure({
page: window.location.href,
asset: target.src,
})
if (sessionStorage.getItem(RECOVERY_KEY)) return
sessionStorage.setItem(RECOVERY_KEY, '1')
window.location.reload()
},
true,
)
이 코드는 의도적으로 예시에 그칩니다. 운영 구현에서는 스타일시트 청크, 알려진 프레임워크 오류 형태, 새로고침이 사용자의 작업을 망가뜨리는 흐름, 정상 로드 후 복구 표식을 지우는 방법도 고려해야 합니다.
편집기, 결제 흐름, 긴 입력 양식에서는 강제 새로고침보다 “새 버전이 있습니다. 작업을 저장한 뒤 페이지를 다시 불러오세요” 같은 안내가 더 나을 수 있습니다.
모니터링 데이터는 이것이 실제 버전 불일치인지 판단할 수 있어야 한다
“스크립트를 로드하지 못했다”는 메시지만으로는 충분하지 않습니다. 삭제된 오래된 청크와 우연한 네트워크 장애를 구분하려면 배포 맥락이 필요합니다.
유용한 항목은 다음과 같습니다.
- 로드에 실패한 리소스 URL;
- 현재 페이지 URL;
- 리소스가 애플리케이션 자체에 속하는지 여부;
- 클라이언트가 볼 수 있는 릴리스 또는 배포 식별자;
- 브라우저와 운영체제;
navigator.onLine은 연결 증명이 아니라 약한 신호로 사용;- 페이지 로드 후 경과 시간;
- 오류가 배포 직후에 발생했는지 여부;
- 첫 복구 시도였는지 여부;
- 서버 측에서 볼 수 있다면 HTTP 상태;
- 원본이나 프록시에서 실제로 해당 요청을 처리한 릴리스.
이런 정보가 있으면 패턴을 훨씬 더 잘 해석할 수 있습니다.
서로 다른 네트워크의 많은 사용자가 오래된 해시 청크 URL을 요청하고 릴리스 직후 원본이 404를 반환한다면, 이전 리소스를 보존하지 않은 것이 강한 설명이 됩니다. 한 사용자만 HTTP 응답 없는 네트워크 오류를 본다면 버전 불일치라는 확신은 훨씬 약해집니다. 청크가 200을 반환하지만 MIME 형식이 잘못됐거나 HTML 오류 문서가 온다면 보존 문제만이 아니라 라우팅이나 프록시 설정 문제입니다.
또한 애플리케이션 자체 청크 실패와 외부 리소스 실패는 별도의 경보로 분리할 것입니다. 이것이 원래 로그에서 가장 직접적으로 도출되는 모니터링 개선입니다. 의미 있는 신호가 애플리케이션 고장과 무관한 대량의 브라우저 잡음에 섞여 있었기 때문입니다.
재현 시험은 단순하지만 오래된 탭을 그대로 유지해야 한다
이 종류의 오류는 일반적인 릴리스 시험에서 쉽게 빠집니다. 엔지니어들은 배포 직후 페이지를 새로고침하는 경향이 있는데, 그러면 확인하려는 조건 자체가 사라집니다.
더 나은 수동 시험은 다음과 같습니다.
- 릴리스 A를 배포한다.
- 브라우저 캐시를 켠 상태로 운영 환경과 비슷한 조건에서 탭을 연다.
- 일부 경로나 지연 기능이 아직 로드되지 않도록 애플리케이션 일부만 사용한다.
- 그 탭을 열린 채로 둔다.
- 릴리스 B를 배포한다.
- 오래된 탭은 새로고침하지 않는다.
- 전에 로드하지 않았던 코드가 필요한 경로나 동적 기능을 실행한다.
- Network와 Console을 확인한다.
- 오래된 리소스 URL이 계속 200을 반환하는지 확인한다.
- 필요할 때 버전 불일치 감지가 통제된 전체 페이지 탐색을 수행하는지 확인한다.
같은 시험을 앞단에 CDN이 있는 경우, 롤링 배포 중 서버 인스턴스가 두 개인 경우, 설정한 보존 기간이 지난 경우에도 반복합니다.
미묘한 시험 실수 하나는 DevTools에서 모든 요청에 대해 “Disable cache”를 켜는 것입니다. 일부 진단에는 유용하지만 브라우저 동작이 달라집니다. 오래 열린 탭 시나리오는 브라우저 캐시 자체가 시스템의 일부이므로 현실적인 캐시 조건에서도 확인해야 합니다.
모든 청크 실패가 이전 파일 보존으로 해결되는 것은 아니다
보존이 강력한 이유는 하나의 구체적인 원인을 해결하기 때문입니다. 이를 또 다른 만능 설명으로 만들면 안 됩니다.
애플리케이션 자체 청크는 다음 이유로도 실패할 수 있습니다.
- 요청이 서버에 도달하지 못했습니다.
- 연결이 중간에 끊겼습니다.
- 브라우저 확장 기능이 요청을 차단했습니다.
- CDN 경계 노드에 일시 장애가 있었습니다.
- Nginx가 경로를 잘못 전달했습니다.
- 서버가 JavaScript 대신 HTML 오류 문서를 반환했습니다.
- 압축이나
Content-Encoding이 손상되었습니다. - 파일 권한이 잘못되었습니다.
- 불완전한 배포에서 청크가 업로드되지 않았습니다.
- 파일은 있었지만 너무 일찍 삭제되었습니다.
- 클라이언트와 서버가 서로 호환되지 않는 릴리스를 사용했습니다.
응답 코드와 발생 시점이 중요합니다. 매 릴리스 직후 오래된 내용 해시 URL에서 반복되는 404는 한 모바일 네트워크에서 발생한 ERR_CONNECTION_RESET 한 번과 전혀 다른 이야기를 합니다.
그래서 저는 원래 사건을 “오래된 HTML이 사이트를 깨뜨렸다는 것을 증명했다”라고 다시 쓰지 않을 것입니다. 그 점은 증명하지 못했습니다. 애플리케이션 자체 청크의 실제 실패를 관찰했고, 버전 불일치를 설계 단계에서 줄일 가치가 있는 심각한 실패 원인 중 하나로 확인했을 뿐입니다.
가장 안전한 배포는 오래된 클라이언트도 릴리스 범위의 일부로 본다
더 근본적인 실수는 배포가 어느 한 순간 A를 B로 완전히 바꾼다고 생각하는 것입니다.
서버에서는 심볼릭 링크나 컨테이너 오케스트레이터가 그렇게 보이게 할 수 있습니다. 하지만 네트워크에는 오래된 CDN 객체가 남을 수 있습니다. 브라우저에서는 B가 활성화된 뒤에도 A의 문서가 오래 실행될 수 있습니다. 롤링 릴리스 중에는 두 서버 버전이 동시에 활성화될 수 있습니다. 되돌리기에서는 B가 사라지고 A가 다시 현재 버전이 될 수도 있습니다.
따라서 실제 릴리스 범위는 한 점이 아니라 시간 구간입니다.
저는 이제 Next.js 애플리케이션 배포 규칙을 이 생각을 중심으로 정합니다.
- 논리적 릴리스마다 한 번만 빌드한다. 복제본이 조용히 서로 다른 산출물을 만들게 두지 않는다.
- 불변 리소스를 그 참조보다 먼저 공개한다.
- 오래된 해시 리소스를 의도적으로 정한 호환 기간 동안 보존한다.
- 변경 가능한 HTML에 해시 청크와 같은 캐시 정책을 주지 않는다.
- 배포 방식에서 버전 불일치가 생길 수 있다면
deploymentId를 사용한다. - 호스팅 플랫폼이 실제로 버전 인식 라우팅을 제공할 때만 플랫폼 수준 보호를 이용한다.
- 복구는 한 번만 수행하고 사용자의 현재 상태를 고려한다.
- 애플리케이션 자체 청크 실패를 별도의 운영 신호로 감시한다.
- 오래된 탭을 연 채로 배포를 시험한다.
- 오래된 리소스는 새 릴리스 활성화 중이 아니라 나중에 정리한다.
지금 제가 사용하는 원칙
빌드가 성공하고 새로 연 페이지가 정상이라는 사실만으로, 이미 애플리케이션을 사용 중이던 사람에게도 배포가 안전했다고 증명할 수는 없습니다.
오래된 탭은 쓸모없는 잔여물이 아닙니다. 실제 이전 릴리스를 실행하는 실제 클라이언트입니다.
배포를 이렇게 보기 시작한 뒤 청크 문제는 훨씬 덜 모호해졌습니다. 내용 해시는 리소스에 안정된 정체성을 주고, 장기 캐시는 그 정체성을 효율적으로 활용하게 합니다. 하지만 배포 시스템은 충분한 기간 동안 그 정체성을 존중하거나, 클라이언트가 통제된 방식으로 앞으로 이동할 방법을 제공해야 합니다.
모든 이전 릴리스를 영원히 살려 둘 필요는 없습니다. 오래된 클라이언트와 새 서버가 정당하게 공존하는 기간을 시스템이 견디기만 하면 됩니다.
제가 중요하게 보는 배포 계약은 이것입니다. 새 사용자는 새 릴리스를 받고, 기존 사용자는 현재 버전이 여전히 요청할 수 있는 파일을 잃지 않으며, 남은 버전 불일치는 깨진 페이지가 아니라 의도된 복구 경로로 이어져야 합니다.