배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
배포가 끝나면 서버는 보통 새 빌드의 파일만 갖고 있습니다. 그런데 어제 열어둔 탭은 어제의 JavaScript를 그대로 들고 있습니다. 그 탭에서 다음 페이지로 넘어가면, 이미 사라진 옛 청크를 요청하다가 ChunkLoadError가 납니다.
에러만 문제인 것도 아닙니다. 클라이언트 버그를 고쳐 배포해도, 이미 열려 있던 탭은 새로고침하기 전까지 옛 코드로 계속 돌아갑니다. 서버 액션처럼 서버와 클라이언트가 함께 바뀌는 코드라면, 옛 탭이 새 서버에는 없는 액션을 호출해 요청이 실패하기까지 합니다.
ChunkLoadError를 잡아 "화면을 다시 불러와 주세요"라고 안내하는 방식은 에러가 난 뒤의 처리입니다. 그 시점에는 사용자가 하던 작업이 이미 끊겨 있습니다. 그래서 에러가 나기 전에 알아채는 방법을 찾아봤습니다.
| 방식 | 한계 |
|---|---|
Next.js deploymentId | 불일치를 페이지 이동 때만 감지하고, 감지하면 묻지 않고 전체 새로고침해 화면 상태가 사라짐 |
| 주기적 폴링 | 방치된 탭에서도 계속 요청이 나가고, 비용을 예측하기 어려움 |
| 탭으로 돌아올 때 확인 | 사용자가 돌아오는 순간에만 서버에 한 번 확인함. 채택 |
탭으로 돌아오는 순간에 확인하기로 하고 나니 남은 결정이 셋이었습니다. 무엇을 비교해 새 빌드라고 볼지, 언제 어떻게 확인할지, 알아챈 뒤 무엇을 할지입니다.
먼저 비교할 값입니다. 처음에는 빌드 해시를 비교하려 했습니다. 클라이언트 번들과 서버에 같은 해시를 심어 두고, 다르면 새 빌드가 있다고 보는 방식입니다.
그런데 롤링 배포 중에는 옛 서버와 새 서버가 섞여서 응답합니다. 이때 이미 새 번들을 받은 탭이 아직 교체되지 않은 옛 서버에 물으면, 해시가 다르니 "새 빌드가 있다"고 잘못 판단합니다. 방금 연 탭에 새로고침하라는 안내가 뜨는 겁니다.
그래서 해시 대신 배포마다 커지는 순번을 썼습니다. 빌드할 때 CI 실행 번호를 클라이언트와 서버 양쪽에 심어 두고 "서버 순번이 내 것보다 클 때만" 새 빌드로 봅니다.
해시 비교는 "다르다"만 알 수 있지만, 순번은 어느 쪽이 새것인지까지 압니다. 새로 연 탭이 옛 서버에 물어도 안내는 뜨지 않습니다. 어제 탭이 옛 서버에 물으면 이번에는 알아채지 못하지만, 다음 확인에서 잡힙니다. 롤백으로 서버 순번이 작아질 때도 마찬가지로 안내하지 않습니다. 틀리더라도 새 빌드를 늦게 알아챌 뿐, 없는 새 빌드를 있다고 하지는 않습니다. 오탐과 미탐 중 사용자에게 해가 없는 쪽으로 틀리게 한 것입니다.
다음은 "돌아온 순간"을 무엇으로 잡을지입니다. 탭이 다시 보일 때와 창에 포커스가 돌아올 때, 그리고 끊겼던 네트워크가 다시 연결될 때 확인합니다.
const checkWhenVisible = () => {
if (document.visibilityState !== 'visible') return
void checkNewBuild()
}
document.addEventListener('visibilitychange', checkWhenVisible)
window.addEventListener('focus', checkWhenVisible)
window.addEventListener('online', checkWhenVisible)
창을 자주 오갈 때마다 요청이 나가지 않도록 확인 사이에는 최소 간격을 두었습니다. 반대로 탭을 떠나지 않고 계속 쓰는 사용자에게는 확인할 순간이 오지 않습니다. 이 방식으로는 막을 수 없는 경우라, ChunkLoadError를 잡는 일반적인 에러 처리에 맡깁니다.
확인 요청이 실패할 수도 있습니다. 네트워크 오류, 타임아웃, 형식이 맞지 않는 응답은 모두 "모름"으로 처리해, 확인 장치의 오동작이 안내로 이어지지 않게 했습니다. 순번 비교와 합치면 판단은 이 두 줄입니다.
const serverSequence = await fetchServerSequence() // 실패하면 null(모름)
if (serverSequence === null || serverSequence <= clientSequence) return
showNewBuildToast()
응답이 제대로 와도 중간 어느 계층에 캐시된 옛 순번이라면 새 빌드를 알아채지 못합니다. 그래서 응답에 Cache-Control: no-store를 붙였습니다.
마지막으로 새 빌드를 알아챈 뒤에 무엇을 할지입니다. 처음에는 "안전할 때는 자동으로 새로고침"하는 방안을 검토했습니다. 진행 중인 요청이 없고, 열린 모달이 없고, 폼 화면이 아닐 때라는 조건이었습니다.
하지만 이 조건은 전부 추정입니다. 놓친 경우가 하나만 있어도 사용자가 쓰던 내용이 날아갑니다. 작업이 끊기지 않게 하려고 만든 장치가 오히려 작업을 날리는 셈입니다. 새 폼 화면이 생길 때마다 조건 목록을 갱신해야 하는 유지 비용도 생깁니다. 그래서 자동 새로고침은 하지 않고, 항상 안내만 띄웠습니다.
새로고침은 사용자가 직접 눌러야 하므로, 안내는 자동으로 사라지거나 스와이프로 닫히지 않는 토스트를 썼습니다.
SPA는 한 번 받은 코드로 오래 동작하기 때문에, 배포와 배포 사이에 열려 있던 탭이 옛 빌드에 남는 문제를 피하기 어렵습니다. 첫 화면은 서버가 그려도 그 뒤로는 클라이언트 라우팅으로 움직이는 Next.js도 다르지 않았습니다. 그래서 새 빌드를 어떻게 알아챌지, 알아챈 뒤 어떻게 새 버전으로 넘어가게 할지를 정해야 했습니다.
두 결정에서 모두 기술적으로 더 확실한 방법보다 틀렸을 때 사용자가 손해를 보지 않는 쪽을 골랐습니다. 사용자 화면을 바꾸는 장치일수록 맞을 때의 이득보다 틀릴 때의 피해를 먼저 따져야 한다는 것을 배웠습니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?
검색엔진은 페이지를 읽을 수는 있어도 그 안의 숫자가 무엇을 뜻하는지는 모릅니다. 메타 태그를 다 넣어도 검색 결과가 파란 링크 한 줄에 머무는 이유를 정리했습니다.
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.
문서는 Server Action이 큐에 쌓인다고만 말합니다. 그 큐가 어디까지 하나로 묶이는지는 직접 재 보기 전에는 알 수 없었습니다.