
프론트엔드 무중단 배포, 진짜로 중단이 없을까?
개발을 하다 보면 "무중단 배포"라는 말을 자주 듣게 됩니다. 이는 서버를 내리지 않고 새로운 버전을 배포하여 사용자가 서비스 중단을 느끼지 못하고 서비스를 안정적으로 운영할 수 있게 하는 것을 의미합니다. 요즘엔 이미 널리 쓰이는 개념이지만, 프론트엔드에서의 무중단 배포에서는 생각보다 복잡한 문제들이 숨어있습니다.
물론 단순히 "새 버전을 올리면 끝"이라고 생각하기 쉽지만 실제로는 그렇지 않습니다. 특히 React나 Vue, Angular 같은 CSR(Client Side Rendering) 환경에서 코드 스플리팅을 사용하고 있다면 말이죠
코드 스플리팅과 무중단 배포가 무슨 상관이지?
사실 표면적으로는 큰 연관이 없어 보일 수 있지만, 무중단 배포의 본래 목적을 생각해보면 코드 스플리팅이 방해할 수 있다는 걸 느꼈습니다.
그렇다면 먼저, 무중단 배포에 대해서 알아봐야하겠죠?
무중단 배포란?
백엔드에서 무중단 배포는 보통 로드 밸런서를 이용해 여러 서버 중 일부를 순차적으로 업데이트하는 방식으로 진행됩니다. 롤링 배포, 블루-그린 배포, 카나리 배포 같은 전략들이 있고, 이미 잘 정립된 방법론들이 존재합니다.
프론트엔드도 비슷한 원리를 적용할 수 있다고 생각했습니다. 새로운 버전의 정적 파일들을 서버에 올리고, 사용자가 새로고침하거나 새로 접속할 때 최신 버전을 받아가도록 하는 것입니다. 언뜻 보면 간단해 보입니다.
하지만 여기서 한 가지 중요한 차이점이 있습니다. 백엔드는 요청이 올 때마다 서버에서 처리하지만, 클라이언트에서는 한 번 브라우저에 로드되면 사용자가 새로고침하기 전까지는 계속 그 버전을 사용한다는 점입니다. 이게 가장 큰 차이라고 볼 수 있습니다.
무중단 배포하면 끝일까?
간단한 정적 웹사이트라면 새 버전을 올리는 것만으로도 충분할 수 있습니다. 하지만 실제로 많이 쓰고 있는 복잡한 웹사이트라면 어떨까요?
가장 먼저 생각나는 문제는 API 서버와의 호환성입니다. 프론트엔드가 새 버전으로 배포될 때 API 서버도 함께 변경되는 경우가 많은데, 이때 아직 사용자가 새로고침을 하지 않은 이전 버전의 프론트엔드 코드가 새로운 API를 호출하면 오류가 발생할 수 있습니다.
실제로 API도 수정하고 그게 맞춰서 클라이언트에도 수정하여 동시 배포한 상황에서도 서비스를 이용하던 사용자들이 갑자기 오류를 만나는 일이 있었습니다. 사용자의 브라우저에는 여전히 이전 버전의 코드가 실행되고 있었고, 이 코드가 새로운 API 응답을 제대로 처리하지 못했던 것입니다.
그리고 두 번째는 코드 스플리팅을 적용했을 경우입니다.
실제로 API와 상관없이 프론트 화면에서 수정한 후 무중단배포를 했음에도 불구하고 사용자들이 버튼이 작동을 안한다던지 하는 피드백이 들어왔고
그 때 의문이 생겼습니다
무중단 배포라고 했는데 왜 오류가 발생할까? 게다가 CSR 환경이라 배포 중에도 사용자가 서비스를 이용하며 모든 파일을 받아 내부에서 동작하기 때문에 문제가 없을 줄 알는데..
이때 떠오른 원인은 코드 스플리팅이었습니다.
스플리팅된 파일이 빌드 시마다 해시값이 변경되면, 기존 사용자들이 이미 참조하고 있는 이전 빌드의 JS 파일 해시와 달라져서 요청이 불일치하게 되고, 이로 인해 문제가 발생한 것이라고 생각이 들었습니다.
알아보니까 실제로 그랬습니다.
CSR에서 코드 스플리팅을 하는 경우 생기는 문제점
코드 스플리팅은 애플리케이션을 여러 개의 청크(chunk) 파일로 나누어, 필요할 때만 로드하는 기법입니다. 초기 로딩 시간을 줄이고 성능을 개선하는 데 도움이 되지만, 배포 시에는 새로운 문제를 만들어냅니다.
// React에서 코드 스플리팅 예시
const LazyComponent = React.lazy(() => import('./LazyComponent'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)
}
웹팩 같은 번들러는 코드 스플리팅을 할 때 각 청크 파일에 해시값을 포함한 고유한 이름을 붙입니다. 예를 들어 main.abc123.js, chunk1.def456.js 같은 식입니다. 이 해시값은 파일 내용이 바뀔 때마다 달라집니다.
문제는 사용자가 서비스를 이용하는 도중에 새 버전이 배포되었을 때 발생합니다. 사용자의 브라우저에는 이전 버전의 index.html이 로드되어 있고, 이 파일은 이전 버전의 해시값을 가진 청크 파일들을 참조하고 있습니다.
그런데 사용자가 페이지 내에서 다른 라우트로 이동하거나 특정 기능을 사용하려고 할 때, 아직 로드되지 않은 청크 파일이 필요하다면 어떻게 될까요? 브라우저는 이전 버전의 해시값을 가진 파일을 요청하게 됩니다.
// 사용자가 새로운 페이지로 이동할 때
import('./pages/NewPage').then((module) => {
// 브라우저가 chunk2.ghi789.js를 요청하지만
// 서버에는 이미 chunk2.jkl012.js만 존재한다면?
})
네, 이 상황에서 가장 큰 문제는 브라우저가 이전 버전의 해시가 붙은 청크 파일을 요청하지만, 서버에는 이미 새로운 해시가 붙은 청크 파일만 존재하기 때문에 404 에러가 발생한다는 점입니다. 결과적으로, 사용자는 기능을 정상적으로 이용하지 못하고, 앱이 깨지거나 오류가 발생할 수 있습니다.
그렇다면 어떻게 해결해야 할까?
이러한 문제들을 해결하기 위해 여러 방법을 생각해봤습니다. 핵심은 한 번 배포된 리소스는 고유한 경로를 가져서 절대로 자신의 파일을 잃어버리지 않도록 하는 것입니다.
- API 버전 관리
먼저 API 호환성 문제의 경우는 프론트엔드와 백엔드가 함께 변경되는 경우에는 API 버전 관리가 필수라고 생각합니다. 가장 간단한 방법은 URL 경로에 버전을 포함하는 것입니다.
// 이전 버전의 프론트엔드
const response = await fetch('/api/v1/users')
// 새 버전의 프론트엔드
const response = await fetch('/api/v2/users')
이렇게 하면 이전 버전의 프론트엔드는 계속해서 v1 API를, 새 버전은 v2 API를 호출하게 되어 호환성 문제를 피할 수 있을 것입니다. 다만 API 서버에서 여러 버전을 동시에 유지해야 하는 부담이 있긴 합니다.
- 정적 파일의 버전 관리
그리고 청크 관련된 문제에서 가장 먼저 떠올린 해결책은 이전 버전의 청크 파일들을 즉시 삭제하지 않는 것이었습니다. 새 버전을 배포할 때 새로운 해시값을 가진 파일들을 추가하되, 이전 버전의 파일들은 일정 기간 유지하는 방식입니다.
# 배포 전
/static/js/main.abc123.js
/static/js/chunk1.def456.js
# 배포 후
/static/js/main.abc123.js # 이전 버전 유지
/static/js/chunk1.def456.js # 이전 버전 유지
/static/js/main.xyz789.js # 새 버전 추가
/static/js/chunk1.uvw012.js # 새 버전 추가
이렇게 하면 이전 버전의 index.html을 가진 사용자도 문제없이 필요한 청크 파일들을 로드할 수 있을 것입니다.
- index.html의 즉시 업데이트
index.html 파일은 새 버전 배포와 동시에 즉시 업데이트되어야 한다고 생각합니다. 이 파일이 브라우저나 CDN에 과도하게 캐싱되면 사용자가 새로고침해도 이전 버전을 받게 되기 때문입니다.
# nginx 설정 예시
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
이런 설정을 통해 사용자가 새로고침하거나 새로 접속할 때 항상 최신 버전의 index.html을 받도록 할 수 있을 것입니다.
- 사용자에게 업데이트 알림
경우에 따라서는 사용자에게 새 버전이 배포되었음을 알리고 페이지 새로고침을 유도하는 것도 괜찮은 방법이 아닐까 생각합니다.
// 주기적으로 버전 체크
setInterval(async () => {
const response = await fetch('/api/version')
const { version } = await response.json()
if (version !== currentVersion) {
if (confirm('새로운 버전이 배포되었습니다. 페이지를 새로고침하시겠습니까?')) {
window.location.reload()
}
}
}, 30000) // 30초마다 체크
다만 이 방법은 사용자 경험 측면에서 조금 거슬릴 수 있어서, 언제 어떻게 알림을 보여줄지는 더 고민이 필요한 부분입니다.
마치며
코드 스플리팅과 무중단 배포 문제는 단순해 보이지만, 실제 운영 환경에서는 여러 변수를 고려해야 해서 쉽지 않았습니다.
개인적으로는, 이전 버전 파일을 무작정 오래 보관하기보다는 2~3개 버전 정도 유지하거나 일정 기간 후 정리하는 정책이 현실적이라고 생각합니다. 너무 오래 남겨두면 관리 부담이 커지니까요.
또, 404 오류가 발생하는 빈도와 위치를 모니터링하면서 정책을 점진적으로 조정하는 것도 필요하다고 봅니다. 실제 서비스에서는 이런 데이터를 기반으로 판단하는 게 효과적이라고 생각합니다.
마지막으로, 배포 순서나 롤백 절차 같은 내부 프로세스를 명확히 정하는 것도 실패 가능성을 줄이는 데 큰 도움이 된다고 생각합니다.
물론 모든 환경에 딱 맞는 정답은 없기 때문에, 직접 운영 환경에서 여러 시도를 해보고 자신만의 최적화된 배포 전략을 찾아가는 과정이 필요할 것 같습니다.