Next.js 트러블슈팅: CORS와 Version Skew 에러 원인부터 해결까지

Next.js 트러블슈팅: CORS와 Version Skew 에러 원인부터 해결까지

요약: 이 글에서는 Next.js 기반의 SSR 환경에서 겪은 CORS 에러와 Version Skew 문제를 해결한 실무 경험을 공유합니다. 충전 서비스 개발 중 발생한 에러를 다양한 가설과 테스트를 통해 원인을 파악하고, crossOrigin, dynamic import, SessionStorage 등을 활용해 문제를 해결한 과정을 다룹니다. 단순한 기술 설명을 넘어서 일정 압박, 심리적 부담, 배포 전략 등 실무에서 마주할 수 있는 현실적인 상황도 함께 공유합니다.

💡 리뷰어 한줄평

john.bosco 웹 애플리케이션을 개발한다면 굉장히 익숙한, cors 라는 키워드에 대한 트러블 슈팅을 다룹니다. cors 라는 키워드만 보면 내용이 단순할것 같지만, 실무에서 겪을 수 있는 일정 문제와 사내 개발환경의 특성 안에서 적절한 해결방안을 찾아가는 흐름에 초점을 두신다면, 글을 더욱 재미있게 읽을 수 있을것 같습니다.

larry.charry 글을 읽는 내내 실제 장애를 겪은 것 같은 긴장감이 느껴졌습니다. SSR 프레임워크, 특히 nextjs를 사용한다면 누구나 마주할 수 있는 잠재적 문제들에 대한 값진 해결 과정을 공유하는 경험기라고 생각합니다.

시작하며

안녕하세요, 카카오페이에서 프론트엔드 개발을 하고 있는 라즈입니다.

이번 글에서는 머니 충전 서비스 프론트엔드를 개발할 때 겪었던 장애를 공유하고자 합니다. 구체적으로는 Next.js 기반 서비스에서 프론트엔드 개발자에게 익숙한 CORS(Cross-Origin Resource Sharing) 에러와 다소 생소할 수 있는 Version Skew 문제의 원인을 어떻게 파악하고 해결했는지를 설명합니다. 단순히 에러의 기술적인 원인과 해결 방법뿐만 아니라, 문제 해결 과정에서 제가 겪었던 심리적인 압박, 일정 관리와 실패 대비의 중요성까지 여러분들이 간접적으로 경험하실 수 있도록 구성했습니다.

Next.js 서버를 직접 운영하는 분들에게 언젠가 참고하거나 문제 해결의 실마리가 되는 글이 되었으면 좋겠습니다.

카카오페이 머니 충전 서비스 소개

들어가기 앞서 간략하게 머니 충전 서비스(이하 충전)를 소개해드리겠습니다.

충전은 카카오페이에 연결된 계좌의 잔액을 가져와 카카오페이 머니로 충전하는 서비스입니다. 기존에 네이티브 앱으로 구현되어 있던 기능을 웹뷰로 전환해야 했기 때문에, 사용자가 최대한 화면을 빠르게 보기 위한 초기 로딩 속도가 중요했습니다. 이를 위해 Next.js(v13, Pages Router) 기반의 SSR 방식을 채택했습니다.

머니 충전 서비스

충전 서비스의 사용자 플로우는 충전 금액 입력 화면충전 완료 화면, 두 단계로 비교적 단순합니다. 사용자가 충전을 완료하면, 서버 API 응답으로 받은 충전 결과 데이터를 상태(State)에 저장한 후 완료 화면으로 이동하여 결과를 보여주는 구조입니다.

첫번째 CORS 에러

/** 충전 금액 입력 화면 */
const result = await charge({ amount });

setChargeResponse(result);

router.push(ROUTES.CHARGE_SUCCESS);
/** 충전 완료 화면 */

// 개발 환경이 아닌 경우 발생하면 안됨
if (!chargeResponse) {
  captureInvalidChargeResponseException();
  return null;
}

return <ChargeSuccess chargeResponse={chargeResponse} />;

충전 서비스 개발 당시 만약의 상황에 대비하고 타입 추론의 이점을 살리기 위해 충전 완료 페이지에서 충전 결과 상태 값이 비어있는 경우 Sentry로 에러를 로깅하도록 구현해 두었습니다.

센트리 알람
센트리 알람

그런데 QA까지 무사히 마치고 충전 서비스를 배포한 뒤 진입점을 열자마자 해당 Sentry 에러 알람이 폭발적으로 발생하기 시작했습니다. 처음으로 운영 환경에서 마주한 장애 상황이었기 때문에 순간 당황했지만, 즉시 진입점을 롤백하고 원인을 파악하기 시작했습니다.

에러의 원인을 찾기 위한 의식의 흐름

가장 먼저 에러 발생 빈도를 확인해보니 전체 트래픽 대비 발생한 비율이 상당히 낮았습니다. 아마 이 때문에 QA 단계에서 미처 발견하지 못했던 것 같습니다. 특이한 점은 에러가 한 번 발생하면 순서대로 서버에서 1번, 브라우저에서 3번, 총 4번 발생했고, 재현되지 않는 기기에서는 계속해서 문제가 없었지만, 재현되는 기기에서는 100% 확률로 문제가 발생했습니다.

코드를 아무리 다시 봐도 동선상 발생할 수 없는 구조였습니다. 충전 금액 입력 화면에서 API로 받아온 결과값을 상태에 저장한 뒤 충전 완료 화면으로 이동하는 것이 전부이기 때문입니다. 상태 업데이트가 비동기로 처리되므로 타이밍 이슈일 가능성까지 고려했지만, 만약 타이밍 이슈였다면 에러가 발생하더라도 결국 상태 업데이트가 완료되고 화면도 그려져야 했는데 그러지 않았습니다.

곰곰이 생각해보니 에러 로그가 항상 서버에서 시작된다는 점이 의아했습니다. 완료 화면으로 이동은 client-side navigation이기 때문에 데이터는 서버에 요청하더라도 실제 렌더링은 브라우저에서 이루어지기 때문입니다. 그래서 ‘어떤 이유로 화면이 강제로 새로고침되면서 상태가 초기화되고, 그 결과 에러가 발생하는 것은 아닐까?’ 라는 가설을 세우고 검증 방법을 고민하기 시작했습니다.

화면이 새로고침 되더라도 화면이 그려지려면, 상태를 URL 혹은 브라우저 스토리지에 저장하면 됩니다. 하지만 당시에는 여러 현실적인 이유로 이러한 방법을 선택할 수 없는 상황이었습니다. 그렇다면 새로고침이 되는 원인을 찾아야하는데, 테스트 기기에서는 재현이 되지 않아 로그를 확인할 수가 없었습니다.

재현과 에러 파악

정말 다행히도 시간이 좀 지나고 나니 테스트 기기에서도 재현이 되어 로그를 확인할 수 있었습니다. 에러 메시지는 Failed to load static props였습니다. 이어서 Web Inspector도 연결해서 자세히 확인해보니 몇 가지를 알 수 있었습니다.

  1. 충전 요청시 CSS 파일에서 CORS 에러 발생
  2. 충전 응답을 받고 완료페이지로 이동하지만, 곧바로 페이지가 새로고침 됨
  3. 에러가 발생했던 동일한 CSS 파일이 새로고침된 완료 페이지에서 정상적으로 불러와짐

결국 원인은 CSS 파일 요청에서 발생한 CORS 에러였습니다. 다행히도 한번 경험한 적이 있어 빠르게 원인을 파악할 수 있었습니다.

CSS 파일에서 CORS 에러가 발생하는 이유

Next.js에서 client-side navigation으로 페이지 이동시 CORS 에러가 발생하는 이유는 다음과 같았습니다.

카카오페이에서 FE 프로젝트를 배포하면 정적 파일은 CDN에 업로드 됩니다. 따라서 Next.js 기반 충전 서비스 도메인에서 CDN에 업로드 되어있는 CSS 파일으로의 요청은 CORS 요청이 됩니다.

SSR로 페이지가 처음부터 그려지는 경우, <script> 혹은 <link rel="stylesheet"> 태그로 발생하는 요청은 CORS 요청이 아니기 때문에 Origin 헤더를 포함하지 않고, 따라서 서버도 상응하는 Access-Control-Allow-Origin(이하 ACAO) 헤더를 보내지 않습니다.

반면 client-side navigation으로 페이지가 전환될 때는 다릅니다. Next.js는 페이지 전환에 필요한 리소스(JS, CSS 등)를 미리 가져오거나(prefetch) 동적으로 로드하기 위해 fetch API를 사용합니다. Fetch API는 CORS 요청이므로 요청에 Origin 헤더가 포함되고, 서버도 이에 맞춰 ACAO 헤더를 포함하여 응답해야 합니다.

https://github.com/vercel/next.js/blob/v13.1.6/packages/next/src/client/route-loader.ts#L304

브라우저에 Origin 헤더가 없는 요청에 대한 ACAO 헤더가 없는 응답이 캐싱된 상태를 가정해보겠습니다. Origin 헤더가 포함된 fetch 요청이 발생하면, 브라우저는 캐시된 응답을 사용하려 하지만 이 응답은 ACAO 헤더가 없으므로 CORS 정책을 위반하기 때문에 에러를 발생시킵니다.

앞선 영상을 다시 보면 첫번째 fetch 타입의 CSS 파일 요청에서 CORS 에러가 발생하고, 두번째 stylesheet 타입의 CSS 파일 요청은 응답이 정상적으로 내려오는 것을 볼 수 있습니다. 에러는 브라우저가 fetch 타입 요청의 응답으로 이미 캐싱되어 있는 stylesheet 타입 요청에 대한 응답을 사용하기 때문에 발생하고 있었습니다.

세 가지 해결 방법과 선택한 방법

문제가 요청 간 차이로 발생하는 캐시 문제라는 것을 파악하고 해결하기 위한 세 가지 방법을 추렸습니다.

  1. Vary: Origin 헤더 사용
  2. next.config.jscrossOrigin: 'anonymous' 옵션 사용
  3. dynamic import를 이용한 지연 로딩

Vary: Origin

HTTP 응답의 Vary 헤더를 사용하면 특정 URL 요청에 대해 캐시된 응답을 사용할 수 있는지 판단할 때 URL 외에 추가로 확인해야 할 헤더 목록을 지정할 수 있습니다. 실제로 WHATWG 스펙 문서에도 현재 발생 중인 에러가 예시로 적혀 있습니다.

응답의 Vary 헤더에 Origin이 포함되어 있지 않은 경우, 브라우저는 두 요청에 대해 같은 캐시 응답을 사용할 수 있습니다.

만약 응답의 Vary 헤더에 Origin이 포함되어 있다면, 브라우저는 요청의 Origin 헤더 값에 따라 캐시된 응답을 구분하여 사용합니다.

FE 프로젝트 정적 파일을 서빙하고 있는 CDN 서버에서는 Origin 헤더가 있는 경우에만 응답에 Vary: Origin 헤더를 추가하고 있었습니다. 이론적으로는 모든 응답에 Vary: Origin을 내려주면 해결될 수 있었지만, 당장 적용이 불가능하고 CDN 서버 수정이 필요하다는 제약조건이 있었습니다. 그래서 이 방법은 선택하지 않았습니다.

추후에는, 요청에 Origin 헤더가 포함된 경우에만 응답에 Vary: Origin 헤더를 추가하는 것이 하나의 best practice라는 것도 알게 되었습니다.

crossOrigin: anonymous

두번째 방법은 Next.js의 crossOrigin: anonymous 옵션 사용입니다.

위 옵션은 모든 <script>, <link rel="stylesheet"> 태그에 crossOrigin: 'anonymous' 옵션을 추가해 CORS 요청으로 만듭니다. 문제가 CSS 요청에 Origin 헤더가 포함되기도 하고 포함되지 않기도 하기 때문에 발생하기에, 모든 요청에 Origin 헤더를 포함해 CORS 요청으로 만들면 잘못 캐싱될 일이 없고, 따라서 에러도 발생하지 않게 됩니다.

과거 다른 프로젝트에서 동일한 문제를 겪었을 때 이 옵션을 검토했었습니다. 당시 이론상 문제는 없어보였습니다. 다만 충전 서비스가 속한 서버는 충전 뿐만 아니라 다른 서비스를 함께 운영 중이었기 때문에, 예상치 못한 사이드 이펙트를 고려해 적용하지 않았습니다.

이번에는 카카오웹툰 기술블로그에서 동일한 에러를 같은 방법으로 해결한 글을 참고하였고, 직면한 문제에 적절한 해결 방법이라고 판단하여 이 옵션을 선택했습니다.

빠듯한 일정

당시 제 일정이었습니다.

문제 해결 방안을 찾은 시점은 목요일 밤이었습니다. 다음 주부터 내부 사정으로 일주일간 배포가 중단되는 코드 프리징 기간이 시작될 예정이었습니다. 코드 프리징이 끝나는 다음 주 화요일부터 클라이언트 앱 릴리즈(이하 클라 릴리즈)가 예정되어 있었기에 다음 날인 금요일까지 문제를 반드시 해결해야 하는 촉박한 상황이었습니다.

출근해서 여러 운영 디바이스를 모아서 테스트 해보고, 재현되는 상태를 만들어서도 테스트 해보고 다행히도 문제가 없다는 것을 확인했습니다. 아주 작은 트래픽이 들어오는 진입점을 오픈하고 이상 없으면 배포를 나가려고 했으나…

테스트 과정에서 현재 CORS 에러와 무관한 다른 문제가 있다는 것을 발견했습니다. 수정 자체는 간단했지만, 운영 배포를 진행하기에는 시간이 부족해 코드 프리징이 끝나는 월요일에 배포하기로 결정했습니다.

CORS 에러는 해결했지만, 실제 운영 트래픽을 받기 전까지는 100% 확신할 수 없었기 때문에 불확실성 속에서 코드 프리징 기간을 보내고 있었습니다.

두번째 CORS 에러

그러던 어느 날, 충전 서비스를 운영 중인 서버의 다른 서비스에서 스타일이 깨져 보이는 현상을 발견했습니다.

에러 로그를 확인해보니 CSS 파일 요청에 대한 응답에 ACAO 헤더가 없어서 CORS 에러가 발생하고 있었습니다.

특이한 재현 경로

단순한 방법으로 재현이 되지 않았지만, 어떻게든 재현 경로를 파악해야 한다는 절박함으로 수많은 테스트를 반복해서 아래의 재현 경로를 찾을 수 있었습니다.

  • 서비스 진입 후 강력 새로고침 (미재현)
  • 새로고침 (미재현)
  • 서비스 닫고 재진입 (미재현)
  • 새로고침 (재현)
특이한 재현 경로

또한 안드로이드 웹뷰(Chrome 기반) 환경에서만 발생했고, 특히 브라우저 프로세스가 완전히 종료되었다가 새로 시작되어 진입하는 경우에만 재현되었습니다. 아래 두 가지가 그 예시입니다.

  • 웹뷰인 경우 새로운 웹뷰를 열 때
  • 브라우저인 경우 크롬 프로세스 종료 후 창 복구를 하는 경우

crossOrigin 옵션 제거

직감적으로 최초 CORS 에러를 해결하기 위해 추가했던 crossOrigin: 'anonymous' 옵션이 원인일 것 같다는 생각에 해당 옵션을 제거하자 다행히 스타일 깨짐 현상이 사라졌습니다.

굉장히 의아했습니다. crossOrigin: 'anonymous' 옵션은 모든 요청에 Origin 헤더를 넣어주기 때문에 모든 요청은 CORS 요청이 되고, 서버에서는 이에 상응하는 ACAO 헤더를 내려줍니다. 그런데 정작 발생하는 에러는 ACAO 헤더가 없다는 CORS 에러였습니다.

혹시 Chrome 브라우저의 첫 진입 시 요청에 차이점이 있어 CORS가 아닌 요청이 잘못 캐싱되고 있는 것은 아닐까 추측했지만, Chrome DevTools와 HAR 파일을 확인해도 도저히 크롬에서 첫 진입시 발생하는 네트워크 요청이나 로그를 확인할 수 있는 방법을 찾을 수 없었습니다.

무력감과 자책감이 밀려왔습니다.

이 날이 목요일이었습니다. 당장 다음 주 화요일부터 클라 릴리즈가 예정되어 있어 시간적 압박이 매우 컸습니다. 이런 상황에서 crossOrigin: 'anonymous' 옵션이 어떻게 새로운 CORS 에러를 유발하는지 원인을 더 깊이 파고들기보다는, 다른 서비스의 안정성을 먼저 확보하는 것이 중요하다고 판단했습니다. 결국 코드 프리징 기간이었음에도 불구하고, 해당 옵션을 적용했던 운영 배포를 롤백했습니다. 이후 처음 고려했던 세 가지 해결 방안 중 마지막 방법이었던 dynamic import 방식을 적용하기로 결정했습니다.

dynamic import

dynamic import는 문제를 근본적으로 해결하기보다는 우회하는 방법이라고 생각해 최후의 수단으로 고려했었습니다. 문제가 발생하는 충전 완료 페이지를 별도 컴포넌트로 분리하고, dynamic import를 사용하여 Client-Side Rendering(이하 CSR) 되도록 구현했습니다. CSR을 하면 fetch를 이용한 CORS 요청 자체가 발생하지 않기 때문에 CORS 문제를 회피할 수 있습니다.

import dynamic from 'next/dynamic';

const ChargeSuccess = dynamic(() => import('pages/charge/success'), {
  ssr: false,
});

const Page = () => {
  return <ChargeSuccess />;
};

export default Page;

이 방법은 전에 다른 문제를 해결하기 위해 사용했던 경험이 있어 어느 정도 자신이 있었지만, 이번에도 실제 운영 트래픽을 받기 전까지는 완전히 안심할 수 없었습니다.

이때부터 혹시 예상치 못한 문제가 발생하더라도 빠르게 롤백할 수 있도록 카나리(Canary) 배포블루 그린(Blue-Green) 배포를 도입하여 안정성을 강화했습니다.

카나리 배포란 새 버전을 소수의 사용자에게 먼저 점진적으로 노출시켜 위험을 줄이는 배포 전략입니다. 블루 그린 배포란 똑같은 환경 두 개(블루, 그린)를 두고 트래픽을 한 번에 전환시켜서 빠르고 안전한 배포와 롤백을 가능하게 하는 전략입니다.

대망의 배포

대망의 월요일. 긴장한 상태로 배포를 나갔습니다.

배포 당시 그라파나 대시보드 (진한 녹색이 404 에러)
배포 당시 그라파나 대시보드 (진한 녹색이 404 에러)

배포 파이프라인이 카나리 단계에 진입하자마자 404 에러가 발생하기 시작했습니다. 순간 롤백을 고려했지만, 카나리 단계라는 변수를 제거하고 전체 배포가 된 상황을 확인해야 한다는 생각이 강하게 들어 블루 그린 배포의 빠른 롤백을 믿고 전체 배포를 진행하기로 결정했습니다.

다행히도 전체 배포를 나간 후부터는 에러가 발생하지 않았습니다. 충분한 시간 동안 모니터링한 후 드디어 안도의 한숨을 내쉴 수 있었습니다.

셀프 플래그

그렇게 해결되었다고 생각하고 밀려있던 개발을 하던 평화로운 다음날 오후…

Version Skew

Version Skew는 배포 과정 등에서 여러 버전의 코드가 동시에 서버에서 실행될 때, 사용자 요청이 구버전과 신버전 사이를 오가면서 발생하는 버전 불일치 문제입니다. 이로 인해 클라이언트와 서버 간의 기대값이 달라져 에러가 발생하거나 데이터가 깨지는 등 예상치 못한 동작을 유발할 수 있습니다.

갑자기 Sentry 에러 알람이 엄청나게 찍히기 시작했습니다.

클라이언트 앱 릴리즈로 인한 웹뷰 진입점이 열렸기 때문에, 이전처럼 들어오는 트래픽을 차단하는 롤백은 불가능한 상황이었습니다.

식은땀이 흐르기 시작했습니다.

확인해보니 해당 서버에서 운영 중인 다른 서비스가 배포를 시작하고 카나리 단계에서 검증을 진행 중이었습니다. 해당 서비스의 배포를 중단(롤백)하니 충전 서비스의 404 에러도 즉시 멈추었습니다.

배포 시 발생하는 404 에러 원인 추정

문제를 미룰 수 없다고 판단해 다시 원인을 파악하기 시작했습니다. 발생하는 에러는 404 에러였고, 카나리 배포 이전에는 배포가 되는 과정에서, 카나리 배포 이후에는 카나리 단계에서 지속적으로 발생했습니다. 결국 두번째 CORS 에러 수정 배포를 나갈 때 카나리 단계에서 발생했던 404 에러와 같은 에러였습니다.

다른 FE 개발자분들과 논의와 이전 경험을 통해 SSR 서비스에서 롤링(Rolling) 배포 시 신규 진입점이나 페이지를 여는 경우 구버전 서버가 요청을 처리하지 못해 404 에러가 발생할 수 있다는 점은 인지하고 있었습니다. 하지만 충전의 경우 진입점만 없이 서비스는 배포되어 있는 상태였기 때문에 404 에러가 발생하는 것이 의아했습니다.

롤링 배포란 이전 버전의 서버 인스턴스를 새로운 버전으로 점진적으로 교체해나가는 방식입니다. 배포가 진행되는 동안에는 두 버전의 서버가 함께 공존하게 됩니다.

에러 발생 비율을 보니 전체 트래픽 대비 20%로, 카나리로 배포되는 비율과 유사했습니다. 이를 보고 문득 404 에러가 페이지 이동 시에 발생할 수도 있겠다는 생각이 들었습니다. 그래서 ‘사용자가 충전 금액 입력 화면에서 충전 완료 화면으로 이동할 때 버전이 달라서 발생하는 것은 아닐까’ 라는 가설을 세우고 검증 방법을 생각하기 시작했습니다.

카나리 비율 조절로 해결 시도

카나리 배포를 사용하지 않고 블루 그린 배포만 사용한다면 빠른 롤백이 가능하면서도 트래픽을 한번에 전환시킬 수 있지 않을까 생각해서 시도했습니다. 하지만 확인 결과 사내 배포 시스템에서는 블루 그린 배포 시 카나리 배포는 필수로 사용해야 했고, 카나리 배포의 최소 비율은 1% 이상이었습니다.

그래도 카나리 비율을 1%로 설정하고 배포를 하니 에러 빈도가 확연히 줄어든 것을 확인할 수 있었습니다.

weight 1로 카나리 배포
weight 1로 카나리 배포

이 배포로 두가지를 알 수 있었습니다.

  • 에러의 원인은 버전이 섞여서 발생한다.
  • 현재 사내 카나리 배포 구조에서는 배포 단계에서 버전 간 트래픽이 섞이는 것을 피할 수 없다.

때문에 원인을 더 상세히 파악하는 것이 필요했습니다.

BUILD_ID와 Version Skew

원인을 좀 더 깊이 파악해보니, 버전이 섞일 때 에러가 발생하는 원인은 다음과 같았습니다.

Next.js에서 client-side navigation을 하면 브라우저에서 페이지를 렌더링하기 위한 데이터를 Next.js 서버에 요청합니다.

(좌)페이지 이동 시 네트워크 요청 URL (우).next 디렉토리와 BUILD_ID 파일
(좌)페이지 이동 시 네트워크 요청 URL (우).next 디렉토리와 BUILD_ID 파일

요청 URL을 자세히 확인해보면 중간에 ID 값이 포함되어 있습니다. 이 값은 Next.js에서 애플리케이션 버전을 구분하기 위해 매 빌드마다 새롭게 생성하는 BUILD_ID 값입니다. 이 BUILD_ID는 Next.js 프로젝트를 빌드할 때 .next 디렉토리 안에 BUILD_ID라는 파일명으로 생성됩니다.

카나리 배포 또는 롤링 배포 처럼 두 가지 버전이 공존하는 구간에서, 만약 요청이 현재 페이지를 렌더링할 때 응답을 받았던 서버와 다른 버전의 서버로 전달되는 경우, BUILD_ID가 포함된 요청 주소는 유효하지 않기 때문에 404 에러가 발생합니다. 그리고 Next.js 서버는 유효하지 않은 요청을 받은 경우 페이지를 강제로 새로고침하여 정상적인 상태로 복구를 시도합니다.

이 새로고침이 충전 결과값이 저장된 상태를 초기화하고 에러를 발생시키는 것이었습니다.

https://nextjs.org/docs/14/pages/building-your-application/deploying#version-skew

관련 키워드로 검색을 하다 보니 놀랍게도 Next.js 문서의 배포 섹션에서 제가 겪고 있는 상황이 그대로 적혀있는 것을 발견했습니다. 여러 아티클을 찾아보며 이 문제를 Version Skew로 부른 다는 것, 그리고 분산 환경에서는 항상 고려해야 할 문제 중 하나라는 것도 배우게 되었습니다.

결정

이렇게 모든 의문이 해결되었습니다. 상황을 요약하면 아래와 같았습니다.

  • 현재 사내 배포 구조에서 Version Skew를 피할 수 없다
  • Version Skew가 발생하면 에러가 발생한다

처음에는 이 상황이 Next.js의 버그처럼 느껴졌습니다. 하지만 프레임워크 관점에서 생각해보니, 페이지 로드에 실패했을 때 에러 화면을 보여주기보다는 페이지를 새로고침하여 복구를 시도하는 것이 합리적인 기본 동작일 수 있다는 생각이 들었습니다. 돌이켜보면 장애의 실제 원인도 Version Skew나 새로고침이 아니라 새로고침 했을 때 상태가 유지되지 않는 애플리케이션 구조였기 때문입니다.

문제를 다시 복기하고 정리해서 팀원들에게 내용을 공유한 뒤, Version Skew를 해결하기 위해 점진적으로 CSR로 이관하는 것을 진지하게 고려하던 중…

해결 방법

팀원분이 상태를 URL 혹은 브라우저 스토리지에 저장하는 것을 다시 고려해 보자고 제안해 주셨습니다.

위에서 언급했듯 초기에 현실적인 이유로 이 방법은 선택할 수 없다고 판단했었습니다. 하지만 일련의 장애를 겪고 나니 재검토의 필요성을 느꼈습니다. 다음 날 당시 선택할 수 없었던 제약 사항들을 면밀히 검토하고 내부적으로 논의한 결과, 새로고침을 대비해 충전 응답 상태값을 세션 스토리지(Session Storage)에 저장하는 것은 문제가 없다는 결론을 내렸습니다.

곧바로 상태를 세션 스토리지에 저장하는 수정 사항을 배포하고 지켜본 결과, 더 이상 에러는 발생하지 않았습니다. 그렇게 저의 길고 길었던 장애 대장정이 막을 내립니다.

그 이후

Version Skew 에러를 겪은 뒤, 배포 과정에서 발생하는 버전 불일치를 보다 근본적으로 해결할 수 있는 방법이 필요하다고 느꼈습니다.

해결 방법 중 하나로 Sticky Session(Session Affinity)이 떠올랐고, 이후 DevOps 팀과 협력하여 사내 배포 시스템에서도 Sticky Session이 적용된 배포가 가능하도록 기능을 추가했습니다.

스티키 세션(Sticky Session)은 로드 밸런싱 환경에서 특정 사용자의 요청이 세션 동안 항상 동일한 서버로 전달되도록 하는 설정입니다. 사용자가 여러 요청에 걸쳐 일관된 서버와 통신하도록 도와줍니다.

배운 점

장애를 겪으면서 배운 여러가지 교훈 중 두 가지를 공유드리고자 합니다.

SSR 서비스에서 Version Skew가 발생할 수 있음을 인지하기

Sticky Session을 적용하더라도 Version Skew를 완전히 해결할 수는 없습니다. 유저가 오랜 시간 앱을 백그라운드에 보내놓고 돌아왔는데 그 사이에 배포가 끝나버렸을 수도 있고, 이런 경우까지 대응하기 위해 기존 서버를 무한정 유지하고 있을 수도 없는 노릇이기 때문입니다.

이처럼 현실적으로 Version Skew를 완벽하게 방지하는 것은 어렵기 때문에, SSR 서비스라면 항상 이 사실을 염두에 두고 페이지가 예기치 않게 새로고침 되더라도 사용자 경험에 문제가 없도록 설계해야 합니다. 만약 Version Skew가 사용자 경험에 영향을 준다면, 서비스 특성에 맞게, 그리고 영향받는 유저의 비율을 고려하여 적절한 trade-off를 찾아야 합니다.

장애를 가정하고 대비하기

이번 경험으로 가장 크게 깨달은 것은 항상 장애 상황을 가정하고 대비해야 한다는 사실입니다.

장애를 해결하는 과정에서 기술적인 어려움 외에도 코드 프리징과 클라 릴리즈라는 미룰 수 없는 일정에 심리적인 압박을 많이 느꼈고, 결국 해결도 지연되면서 후속 일정에 영향을 미치게 되었습니다. 바쁜 일정 속에서도 예기치 못한 문제가 발생했을 때 대응할 수 있는 시간적, 기술적 버퍼가 항상 필요하다는 것을 느꼈습니다.

센트리나 그라파나 등으로 장애를 빠르게 탐지할 수 있는 환경도 꼭 마련해야 합니다. 첫 CORS 에러가 발생했을 때, 만약을 대비하는 마음에 둔 알람이 없었다면 많은 사용자가 빈 화면을 보고 이탈하더라도 CS가 접수되기 전까지는 인지하지 못했을지도 모릅니다.

마지막으로 에러 탐지 시스템이 마련되었다면, 문제 발생 시 원상태로 복구하거나 영향도를 최소화할 수 있는 방안을 마련해야 합니다. 저도 운영 배포로 많은 트래픽이 들어와야 문제 해결 여부를 판단할 수 있는 상황에서, 블루 그린 배포를 적용하고부터 빠른 롤백이 가능하다는 사실 만으로도 큰 심리적 안정감을 얻을 수 있었습니다.

장애는 상상하지 못한 이유로도 발생할 수 있습니다. 때문에 항상 예기치 못한 장애가 발생하더라도 대응할 수 있게 유연한 일정, 빠르게 장애를 탐지하기 위한 시스템, 그리고 장애 복구 방안이 필요하다는 것을 이번 경험으로 체득했습니다.

마치며

어떠셨나요?

다소 길고 상세했던 장애 해결 여정을 함께 해주셔서 감사합니다. 어쩌면 복잡했던 과정에 비해 최종 해결 방법이 다소 간단하게 느껴지셨을 수도, 혹은 제가 얻었다는 교훈이 너무 당연하게 들리셨을 수 있을 것 같습니다. 저 역시 글을 정리하며 비슷한 생각을 했습니다.

그럼에도 이 글을 단순히 장애의 원인과 해결 방법을 나열하는 것에 그치지 않았던 것은, 제가 당연하게 여겼던 원칙들을 이번 장애를 통해서야 비로소 온몸으로 체득할 수 있었기 때문입니다.

이 글을 통해 여러분도 제가 겪었던 장애를 간접적으로나마 체험하고 각자 상황에 맞는 교훈을 얻어가셨기를 바랍니다.

laz.z
laz.z

프론트엔드 개발을 하고 있는 라즈입니다. 자연스러운 사용자 경험을 제공하기 위한 최적화에 관심이 많습니다.