토요일 오후, 서비스 전체가 흰 화면이 되면서 배운 것들

장애를 계기로 우리 프론트엔드 인프라를 처음부터 따라가 본 기록

구성과 수치는 실제 환경을 그대로 옮기지 않았다. 흐름이 보이도록 단순화하고 각색했다.


1. 토요일 오후

금요일 퇴근 무렵 보안 담당자가 PaloAlto IPS 설정을 변경했다. 그날은 아무 일도 없었다. 주말 트래픽도 평소와 다르지 않았고 알림도 조용했다.

다음 날 토요일 오후에 서비스가 멈췄다. 웹뷰, 모바일/PC 페이지, 이벤트 랜딩까지 전부였다. 사용자에게 내려간 건 503 Service Unavailable 한 줄이 적힌 흰 배경이었다.

우리는 마이데이터 기반 서비스를 만든다. 사용자 입장에서는 자기 계좌와 자산을 보러 앱을 열었는데 아무것도 안 나오는 상황이다. 무슨 일이 벌어졌는지, 언제 복구되는지 알 방법이 없다. 고객센터로 문의가 몰렸는데 그쪽도 상황을 모르긴 마찬가지였다.

인프라 담당자와 노트북을 챙겨 나갔다. 우리 서비스는 폐쇄망이라 직접 가야만 한다.

2. 파드는 멀쩡했다

내부망에 붙어서 제일 먼저 본 건 EKS였다. 파드가 죽었으면 이야기가 간단하니까.

그런데 파드는 정상이었다. Running 상태였고 재시작 횟수도 그대로였다. 내부에서 파드에 직접 요청을 넣어보니 Next.js가 HTML을 잘 렌더링해서 돌려줬다.

애플리케이션은 살아 있는데 사용자에게 도달하지 않고 있었다. 그렇다면 문제는 파드 앞 어딘가다. 여기서 질문이 하나 생겼다. 사용자가 주소창에 도메인을 입력하고 화면이 뜨기까지, 요청은 정확히 어디를 어떤 순서로 지나오는가.

프론트엔드 개발자로 일하면서 이 질문에 끝까지 답해본 적이 없었다. 배포하면 뜨는 거였고, 안 뜨면 인프라팀에 물어보는 거였다. 그날 오후를 그 경로를 따라가는 데 썼다.

3. 사용자 요청은 어디를 지나오는가

인프라팀에서 받은 네트워크 흐름도와 실제 설정을 대조하면서 그린 그림이다.

장애 이전 인프라 아키텍처

순서대로 짚어보면 이렇다.

인터넷에서 들어온 요청은 IGW를 통해 프론트엔드 VPC로 진입한다. 퍼블릭 서브넷에서 PaloAlto IPS를 만나 침입 탐지와 정책 검사를 받는다. EC2 위에 올라간 VM-Series였고 인스턴스는 한 대다.

이 장비가 하나 더 하는 일이 있는데, 5절에서 이게 결정적으로 중요해진다. VPC의 DNS 역할을 겸하고 있었다. DHCP option set이 이 장비를 가리켜서, VPC 안의 모든 워크로드가 이름을 해석할 때 여기를 거쳤다. EKS의 CoreDNS도 클러스터 바깥 이름은 이쪽으로 포워딩했다.

IPS를 통과하면 프라이빗 서브넷의 internal ALB로 넘어간다. 인터넷에 노출되지 않고 사설 IP만 가지는 로드밸런서다. ALB의 타겟은 애플리케이션 파드가 아니라 Istio Ingress Gateway다. 여기서부터 서비스 메시 안이다.

우리는 모노레포라 하나의 도메인 아래 여러 앱이 들어가 있다. VirtualService가 URL 경로를 보고 어느 앱으로 보낼지 결정한다. /app/*은 웹뷰, /m/*/pc/*는 모바일/PC 페이지, /event/*/promo/*는 랜딩으로 간다.

각 파드에는 Envoy 사이드카가 붙어 있어서 들어오고 나가는 트래픽이 전부 여기를 통과한다. 그 뒤에 Next.js 컨테이너가 있고, 파드 수는 HPA가 조절한다.

한 가지 더. 프론트엔드 VPC와 백엔드 VPC는 피어링으로 연결돼 있는데, 이 구간은 HTTP 평문이다. TLS는 사용자와 맞닿는 앞단에서만 종료되고 내부 구간은 암호화하지 않는다. 마이데이터 서비스라 백엔드에는 인증이나 계좌 조회처럼 개인정보가 담긴 API가 있고, 그 응답도 이 구간을 평문으로 지난다. 이 사실은 나중에 캐시 정책을 짤 때 다시 떠올리게 된다.

경로를 다 적고 나면 이렇게 된다. IGW → PaloAlto IPS → internal ALB → Istio Ingress Gateway → Envoy 사이드카 → Next.js. 여섯 단계다. 그리고 눈에 들어온 건 이 경로에 분기가 하나도 없다는 사실이었다. 어느 한 칸이라도 막히면 그 뒤는 전부 의미가 없다.

4. NAT Gateway는 이 경로에 없었다

흐름도를 처음 봤을 때 헷갈린 게 NAT Gateway였다. 퍼블릭 서브넷에 큼직하게 그려져 있으니 사용자 요청이 여기를 지나가는 줄 알았다.

지나가지 않는다. NAT Gateway는 나가는 트래픽만 담당한다. 밖에서 안으로 들어오는 연결은 여기를 통과하지 못한다. 일방향 회전문에 가깝다.

동작은 이렇다. 퍼블릭 서브넷에 놓여서 자기 자신은 인터넷으로 나갈 수 있고, 프라이빗 서브넷의 라우팅 테이블이 0.0.0.0/0을 NAT Gateway로 향하게 걸어둔다. 그러면 공인 IP가 없는 리소스도 출발지 주소를 NAT Gateway 것으로 바꿔 인터넷에 나갈 수 있다.

우리 환경에서 이걸 쓰는 주체는 애플리케이션 파드가 아니었다. 파드의 외부 통신은 정책으로 막혀 있다. PaloAlto IPS가 위협 시그니처와 보안 패치를 받아올 때 쓰는 통로였다. 어느 문으로 나갈지는 인스턴스가 고르는 게 아니라 서브넷의 라우팅 테이블이 정한다. 분리수거 요일이 세대가 아니라 동 단위로 정해지는 것과 비슷하다. IPS의 관리용 인터페이스는 프라이빗 서브넷에 따로 있고, 그 서브넷의 기본 경로가 NAT Gateway를 향한다. NAT Gateway는 Elastic IP를 물고 있어 나가는 트래픽의 출발지가 항상 같으니, 업데이트 서버 쪽에서 접근 허용 목록을 IP로 관리해도 문제가 없다.

여기까지 정리하고 나니 흐름도의 나머지가 제대로 읽히기 시작했다.

5. 장애는 DNS에서 시작됐다

원인은 금요일의 설정 변경이었다. 그리고 그게 하필 DNS였다.

장애 발생 지점

핵심은 internal ALB에는 공인 IP가 존재하지 않는다는 점이다. 사설 IP만 갖는다. 그러니 이 ALB로 가려면 DNS가 반드시 사설 IP를 돌려줘야 한다.

DNS는 같은 이름이라도 누구에게 묻느냐에 따라 답이 다르다. 사내 전화번호부에서 찾으면 내선번호가 나오고, 114에 물으면 대표번호가 나오는 것과 같다.

PaloAlto가 VPC의 DNS를 겸하고 있었으니, 이 장비의 상위 리졸버가 바뀌면 VPC 안에서 이름을 해석한 결과가 통째로 달라진다. 사내 존을 보던 리졸버에서 바깥을 보는 리졸버로 넘어가면서, 사설 IP가 나와야 할 자리에 공인 IP가 나왔다. 그 주소로는 ALB에 닿을 수 없다.

사용자가 본 503도 여기서 나온 것으로 추정하고 있다. IPS가 ALB 쪽으로 연결을 넘기려다 타임아웃이 나자 자신이 503을 응답한 것이다. DNS가 깨졌는데 증상은 엉뚱한 계층의 상태 코드로 나타난다. 원인 찾기가 어려웠던 이유 중 하나다.

변경 직후에는 아무 일도 일어나지 않았다. 경로 곳곳의 캐시가 이전 해석 결과를 붙들고 있었기 때문이다. PaloAlto의 FQDN 객체 캐시는 해석에 실패해도 직전 값을 한동안 유지하고, CoreDNS는 TTL 동안 상위에 묻지 않으며, 애플리케이션과 커넥션 풀은 이미 맺어둔 연결을 계속 재사용한다. 어느 겹이 정확히 몇 시간을 버텼는지까지는 인프라팀의 영역이라 나도 끝까지 확인하지는 못했다. 확실한 건 결과다. 변경 후 만 하루 동안 서비스는 멀쩡했고, 토요일 오후 캐시가 만료돼 재조회가 일어난 순간 바뀐 설정이 그제서야 실제로 적용됐다.

이 지연이 조사를 어렵게 만들었다. 배포도 없었고 코드도 그대로인데 갑자기 터졌으니, 초기에는 아무도 전날 방화벽 변경을 후보로 올리지 않았다. 장애 조사에서 변경 이력을 볼 때 직전 몇 시간만 보면 안 되고 캐시 TTL만큼 거슬러 올라가야 한다는 걸 이때 배웠다.

한 가지 더 짚어둘 게 있다. 재발 방지책으로 IPS를 이중화하고 앞에 ALB를 두는 안이 나왔는데, 그 안은 이번 장애를 막지 못한다. 인스턴스를 두 대로 늘려도 금요일에 바뀐 설정은 두 대 모두에 똑같이 들어간다. 이중화는 하드웨어나 AZ가 죽는 무작위 고장에 대한 대비지, 모든 인스턴스에 동시에 영향을 주는 설정 오류에는 소용이 없다.

6. 응답 헤더를 읽는 계층이 하나도 없었다

경로를 정리하고 나서 다음 질문이 생겼다. 우리 서비스는 SSG와 ISR을 꽤 많이 쓴다. 이벤트 랜딩은 거의 전부 정적이고 모바일/PC 페이지도 상당수가 ISR이다. 미리 만들어둔 HTML인데 왜 이것까지 전부 죽었을까.

파드 응답 헤더를 확인해보니 Next.js는 제 할 일을 하고 있었다. Next.js는 렌더링 방식에 따라 Cache-Control을 알아서 다르게 내려보낸다. 우리가 쓰는 Pages Router(Next.js 14) 기준으로 실제 내려오는 값은 이렇다.

렌더링 방식 응답 헤더
정적 페이지 s-maxage=31536000, stale-while-revalidate
ISR 페이지 (revalidate: 60) s-maxage=60, stale-while-revalidate
동적 페이지 (SSR) private, no-cache, no-store, max-age=0, must-revalidate

stale-while-revalidate에 값이 없는 게 눈에 걸릴 텐데, 버전 차이다. 값이 붙는 형식(stale-while-revalidate={expire - revalidate})은 expireTime 설정이 생긴 이후의 App Router 문서 기준이고, 우리 버전의 Pages Router는 값 없이 내려보낸다. 표준(RFC 5861)상 이 지시어는 값이 필수라서, 값이 없으면 CDN이 무시해도 이상하지 않다. 이 사실은 11절에서 다시 만난다.

s-maxage는 공유 캐시 전용 지시어다. CDN이나 리버스 프록시처럼 여러 사용자가 함께 쓰는 캐시만 이 값을 읽는다. 브라우저는 무시하고 max-age만 본다.

그래서 이 헤더를 읽어줄 주체가 경로 어딘가에 있어야 하는데, 다시 훑어보니 없었다.

계층 s-maxage를 읽나
PaloAlto IPS 검사 장비다. 캐시 기능이 없다
internal ALB L7 라우팅만 한다. 캐시하지 않는다
Istio Ingress Gateway Envoy에 HTTP 캐시 필터가 있지만 기본 구성에서 꺼져 있다
Envoy 사이드카 마찬가지

단 하나의 계층도 이 헤더를 읽지 않았다. Next.js는 성실하게 캐시 정책을 내려보내는데 수신자가 없는 상태였다.

원래는 앞단에 CloudFront가 있었다고 한다. 인프라 개편 과정에서 제거됐고, 그 뒤로 이 헤더들은 아무도 안 읽는 문자열이 됐다.

ISR 캐시 자체는 동작한다. Next.js가 .next/cache에 만들어둔 HTML을 들고 있으니 getStaticProps를 매번 다시 돌리지는 않는다. 절약된 건 렌더링 비용이다. 하지만 요청이 여섯 단계 경로를 전부 통과해야 한다는 사실은 그대로였고, 이번 장애를 만든 건 그 경로 쪽이었다.

7. 파드마다 캐시를 따로 들고 있었다

.next/cache를 들여다보다가 이번 장애와는 별개인 문제를 하나 발견했다.

ISR 페이지와 파드별 캐시

HPA로 파드가 여러 개 뜨면 캐시도 파드 수만큼 따로 생긴다. 공유되지 않는다. 재검증 타이머도 파드마다 독립적으로 돈다. revalidate: 60을 걸어놨다면 그 60초는 파드별로 각자 세는 시간이지 서비스 전체의 시간이 아니다.

그래서 같은 URL을 새로고침해도 어느 파드에 붙느냐에 따라 다른 버전의 HTML이 내려올 수 있다. 재생성 역시 파드마다 한 번씩 일어나서, 같은 페이지를 replica 수만큼 중복해서 만든다.

Next.js는 이 문제를 위해 cacheHandler 옵션을 제공한다. 캐시 백엔드를 파일시스템 대신 다른 것으로 갈아끼우는 인터페이스다. Redis 같은 원격 저장소에 붙이거나 S3를 통해 파드 간에 동기화하는 구현들이 나와 있다. 그러면 모든 파드가 같은 HTML을 바라보고 재생성도 한 번만 일어난다. (물론 여기서 더 고려할 것들이 많다. 여러 Pod가 동시에 하나의 저장소를 바라보게 되니 동시성 문제들이 생길 수 있다. 네임스페이스 등으로 빌드id 같은 값으로 구분하는 등 추가 고려가 필요하다. 그러나 백엔드 개발자에게 물어보니 그정돈 원래 Redis를 구축할 때 고려하는 요소라고 한다ㅎ 하지만 백엔드 개발자가 프론트 배포 환경을 이해못하면 잘못 설정할 수 있으니 알아둘 필요는 있다)

우리는 도입하지 않았다. 페이지 내용이 파드마다 몇십 초 어긋나는 정도는 서비스 성격상 문제가 되지 않았고, 그 정도 일관성을 얻자고 Redis를 하나 더 운영할 이유가 없었다. 이런 특성이 있다는 걸 확인한 선에서 정리해뒀다.

8. 장애 중에 고객에게 말을 걸 방법이 없었다

기술적인 원인 규명과 재발 방지는 인프라팀이 할 일이다. 프론트엔드 리드로서 내가 붙잡고 있던 건 다른 지점이었다.

장애가 나는 동안 우리는 고객에게 아무 말도 할 수 없었다.

공지를 띄우려면 그 공지 페이지도 우리 서버가 내려줘야 한다. 그런데 그 서버로 가는 길이 막혀서 장애가 난 거다. 안내를 보여주려는 순간 안내를 보여줄 수단도 함께 죽어 있다.

사용자가 본 건 브라우저 기본 에러 화면이었다. 로고도 없고, 설명도 없고, 언제 복구되는지도 없다.

복구 시간을 줄이는 건 내 손을 벗어난 일이지만, 그동안 사용자가 무엇을 보고 있을지는 프론트엔드가 책임질 영역이라고 봤다. 그래서 목표를 두 개로 잡았다. 장애 상황에서도 최소한의 화면과 안내가 뜰 것, 그리고 그 안내 내용을 실시간으로 바꿀 수 있을 것. 두 번째가 더 중요했다. 고정 문구만 띄우면 “점검 중입니다”에서 끝나지만, 내용을 바꿀 수 있으면 예상 복구 시각이나 대체 수단을 안내할 수 있다.

9. CloudFront를 앞에 두면 어떻게 되는지 그려봤다

인프라팀, 데브옵스 개발자와 함께 스테이징 환경에 구성해보기로 했다. 최종적으로 그린 그림은 이렇다.

스테이징에서 구성한 CloudFront 포함 아키텍처

오리진은 기존 공인 진입점을 그대로 쓴다. internal ALB는 공인 IP가 없어 CloudFront가 직접 붙을 수 없고, 캐시 미스와 동적 요청은 지금의 여섯 단계 경로로 들어간다. 프라이빗 ALB를 오리진으로 직접 연결하는 VPC Origins라는 선택지도 있지만, 이번에는 진입 경로를 바꾸지 않는 쪽이 검토 범위를 줄였다.

비용 논리부터 정리했다. CloudFront는 단순히 얹는 비용이 아니다. 오리진에서 사용자로 나가던 데이터 전송을 대신 처리한다. AWS 오리진에서 CloudFront로 가는 트래픽에는 요금이 붙지 않고, CloudFront에서 사용자로 나가는 단가는 EC2나 ALB에서 직접 나가는 것보다 싸다. 정적 자산 요청이 오리진에 도달하지 않으니 파드가 처리할 요청 수가 줄고, HPA 스케일이 내려가면서 EKS 비용도 함께 줄어든다.

성능 이득도 정리했다. 엣지에서 TLS를 종료하니 핸드셰이크 왕복이 줄고, 정적 자산과 SSG 페이지는 오리진까지 가지 않으니 TTFB가 개선된다. 국내 사용자가 대부분이라 지리적 거리로 인한 이득은 크지 않지만, 여섯 단계 경로를 건너뛰는 것만으로 차이가 난다.

보안 쪽 반론도 미리 준비했다. 캐시로 응답하면 IPS 검사를 건너뛰는 것 아니냐는 질문인데, 캐시 히트는 이미 검사를 통과한 응답을 재사용하는 것이고 캐시 미스와 동적 요청은 여전히 오리진으로 간다. 엣지 계층은 CloudFront에 AWS WAF를 붙여 보완하기로 했다.

그런데 실제로 붙여보니, 장애 대응이라는 이득만큼이나 신경 써야 할 것이 함께 늘어났다. 캐시 계층이 하나 생긴다는 게 어떤 의미인지 그때부터 파고들었다.

10. CloudFront가 요청을 처리하는 방식부터

CloudFront를 “앞에 두면 빨라지는 것” 정도로 알고 있었는데, 설정을 직접 만지려니 개념부터 정리해야 했다.

CloudFront 요청 처리 흐름과 네 가지 정책

정책이 네 종류인데 역할이 각각 다르다.

Cache Behavior는 경로 패턴으로 규칙을 고르는 라우팅 계층이다. /_next/static/*, /_next/image*, /api/*, /* 같은 패턴마다 서로 다른 정책 묶음을 붙일 수 있다. 더 구체적인 패턴이 먼저 매칭되도록 순서를 잡아야 한다.

Cache Policy는 “같은 요청인가?”를 결정한다. query string, header, cookie 중 무엇을 cache key에 넣을지 고르고 TTL 범위도 여기서 정한다. 여기 포함되지 않은 값은 아무리 달라도 같은 캐시를 쓴다.

Origin Request Policy는 “캐시 미스일 때 오리진에 무엇을 넘길까?”를 결정한다. cache key에는 넣지 않지만 Next.js가 처리에 필요한 값을 여기서 전달한다. X-Request-IDX-Forwarded-For처럼 로깅과 추적에는 필요하지만 응답 내용을 바꾸지는 않는 값들이 대표적이다. Cache Policy에 넣은 값은 자동으로 오리진에도 전달되므로, 여기에는 추가로 보낼 것만 적으면 된다.

Response Headers Policy는 사용자에게 나갈 응답 헤더를 붙인다. CORS, CSP, HSTS 같은 것들이다. 여기서 Cache-Control을 추가해도 CloudFront 자신의 저장 동작은 바뀌지 않는다. 그건 Cache Policy와 오리진 응답 헤더가 결정한다.

이걸 다 이해하고 나서 남은 문장은 하나였다.

같은 cache key로 묶이는 모든 요청은, 누구에게나 같은 응답을 돌려줘도 안전해야 한다.

캐시 키를 너무 좁게 잡으면 서로 달라야 할 응답이 한 칸에 들어가고, 너무 넓게 잡으면 적중률이 0에 수렴한다. 그런데 이 두 실수의 무게가 같지 않다. 적중률이 낮으면 느려지고 비용이 늘 뿐이지만, 달라야 할 응답을 같은 키로 묶으면 남의 정보가 나간다.

그래서 경로마다 세 가지를 확인하는 순서로 정했다. 누구에게나 같은 응답인가, 어떤 입력이 응답을 바꾸는가, Next.js 처리에는 필요하지만 응답을 바꾸지는 않는 값은 무엇인가. 첫 질문에서 “아니오”가 나오면 그 경로는 캐싱을 끈다.

11. 캐시 지시어 세 개 설계하기

캐시를 켜기로 한 경로에는 어떤 헤더를 내려보낼지 정해야 한다. 6절에서 봤듯 우리 파드가 내려보내는 기본 헤더는 stale-while-revalidate에 값이 없어서 CDN에 그대로 먹히길 기대하기 어렵다. 그래서 세 지시어 모두 값을 명시해 직접 설계했다.

json
Cache-Control: s-maxage=60, stale-while-revalidate=60, stale-if-error=86400

세 지시어가 각각 다른 시간대를 담당한다.

캐시 헤더 세 지시어의 시간대별 동작

s-maxage=60은 엣지가 이 응답을 신선(fresh)하다고 보는 시간이다. 60초 동안은 요청이 와도 오리진에 묻지 않는다. max-age 대신 s-maxage를 쓴 건 브라우저와 CDN의 캐시 수명을 분리하기 위해서다. 브라우저 캐시는 사용자가 강제 새로고침하기 전까지 손댈 수 없지만 CDN 캐시는 무효화로 언제든 걷어낼 수 있다.

stale-while-revalidate=60은 신선도가 끝난 뒤 60초를 담당한다. 이 지시어가 없으면 만료 직후 들어온 요청은 오리진 응답을 기다려야 한다. 붙여두면 일단 캐시에 있던 이전 응답을 즉시 돌려주고 백그라운드로 새 응답을 받아온다. 사용자는 기다리지 않고 다음 사람부터 새 내용을 본다. 만료 순간 요청이 몰려도 오리진으로 나가는 재검증은 한 번으로 정리되는 부수 효과도 있다.

stale-if-error=86400이 이번 장애를 위해 넣은 지시어다. 오리진이 500번대를 반환하거나 아예 연결되지 않을 때, 신선도가 지났어도 24시간 동안은 캐시에 남아 있는 이전 응답을 대신 내려준다. 토요일에 이게 있었다면 캐시에 들어 있던 페이지들은 계속 떴을 것이다. 데이터는 조금 오래됐겠지만 흰 화면보다는 낫다.

CloudFront에서 어떻게 켜나

여기서 한동안 헤맸다. 콘솔을 아무리 뒤져도 stale-if-error 토글이 없다.

이 지시어들은 콘솔 설정 항목이 아니라 오리진이 내려보낸 응답 헤더를 CloudFront가 읽어서 동작하는 방식이었다. AWS가 2023년 5월에 두 지시어 지원을 추가했고 추가 요금 없이 모든 엣지 로케이션에서 동작한다. 우리가 할 일은 Next.js가 헤더를 제대로 내려보내게 하는 것뿐이다.

다만 Cache Policy의 Minimum TTL과 Maximum TTL이 오리진 헤더보다 우선하는 경우가 있다. Maximum TTL이 짧게 잡혀 있으면 s-maxage나 stale 기간이 그 값으로 잘린다. 캐시 정책이 오리진 헤더를 존중하도록 두고, TTL 범위가 의도한 값을 담을 수 있는지 확인해야 한다.

참고로 CloudFront는 오리진에 연결할 수 없고 Minimum이나 Maximum TTL이 0보다 크면 기본적으로도 이전 객체를 내려주는 동작이 있다. 이걸 끄고 싶으면 stale-if-error=0을 명시하면 된다.

12. 개인정보가 엣지에 남지 않게 하기

설계하면서 가장 조심한 부분이다.

CloudFront Cache Policy의 Minimum TTL이 0보다 크면, 오리진이 no-cache, no-store, private를 보내도 CloudFront는 그 최소 시간만큼 캐시한다. 오리진 헤더를 무시한다.

문제는 이게 예외적인 설정이 아니라는 점이다. 널리 쓰이는 관리형 정책 CachingOptimized의 Minimum TTL이 1초다. 1초가 짧아 보여도 트래픽이 몰리는 경로라면 그사이 여러 사용자가 같은 캐시를 받는다.

여기에 캐시 키 문제가 겹친다. CachingOptimized는 cookie와 query string을 cache key에 넣지 않는다. 그러면 엣지에 저장된 하나의 응답을 모든 사용자가 공유한다.

text
사용자 A: GET /mypage   Cookie: session=user-a
  → Next.js 응답: <div>철수님의 자산 요약</div>
  → CloudFront가 "/mypage" 키로 저장

사용자 B: GET /mypage   Cookie: session=user-b
  → cache key에 cookie가 없으므로 HIT
  → <div>철수님의 자산 요약</div> 반환

캐시 버그가 아니라 개인정보 노출이다. 3절에서 적었듯이 우리 백엔드 API 응답에는 계좌와 자산 정보가 들어 있고, 그 구간은 평문으로 오간다.

Next.js SSR이 기본으로 private, no-store를 내려보내니 안전하다고 생각하기 쉬운데, CloudFront 설정 한 줄이 그걸 무력화할 수 있다는 게 함정이다.

주의할 게 하나 더 있다. Set-Cookie가 담긴 응답이 캐시되면, 이후 캐시 히트를 받은 다른 사용자에게도 그 Set-Cookie가 그대로 전달될 수 있다. 쿠키 전달 설정과는 별개로, 세션이 발급되는 응답은 애초에 캐시 대상에서 빼야 한다.

그래서 경로를 성격별로 나누고 캐시 정책을 따로 걸었다.

경로 성격 캐시 정책
/app/* 웹뷰 · 로그인 · SSR CachingDisabled · Min·Max·Default TTL 전부 0
/m/* /pc/* ISR 혼합 Minimum TTL 0 · 오리진 헤더 존중
/event/* /promo/* SSG 적극적으로 캐시
/_next/image* 이미지 최적화 url w q를 cache key에 포함
/_next/static/* 빌드 산출물 장기 캐시 · 파일명에 해시가 있어 immutable

“오리진 헤더 존중”에는 관리형 정책 UseOriginCacheControlHeaders도 있다. 다만 이 정책은 쿠키 전부를 캐시 키에 넣기 때문에 공개 페이지 적중률이 바닥난다. 그래서 Minimum TTL 0에 캐시 키를 직접 고른 커스텀 정책을 만들어 썼다.

/_next/image*를 따로 뺀 이유가 있다. 이 경로는 url, w, q 쿼리가 서로 다른 이미지를 만드는데, CachingOptimized는 쿼리를 cache key에 넣지 않는다. 그대로 적용하면 서로 다른 크기와 품질의 이미지가 같은 캐시로 취급된다.

쿠키를 캐시 키에 넣어 사용자별로 분리하는 방법도 있지만, 적중률이 사실상 0이 되면서 실수 여지만 커진다. 개인화 경로는 캐싱 자체를 끄는 쪽이 안전하다고 봤다.

Next.js 쪽에서는 ISR 페이지가 s-maxagestale-while-revalidate를 기본으로 내려보내지만 stale-if-error는 붙지 않으니 직접 추가해야 한다. 랜딩처럼 로그인과 무관한 공개 경로에만 걸고, 모바일/PC 쪽은 ISR 중에서도 공개 경로만 골라 같은 값을 적용했다. 개인화가 섞인 응답은 기본값 private, no-store를 그대로 둔다.

javascript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        // 로그인 상태와 무관한 공개 경로에만 적용한다.
        // 모바일/PC의 공개 ISR 경로도 같은 방식으로 추가한다.
        source: '/(event|promo)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 's-maxage=60, stale-while-revalidate=60, stale-if-error=86400',
          },
        ],
      },
    ]
  },
}

이 방식이 프리렌더된 페이지에 실제로 적용되는지는 로컬 빌드로 확인했다. ISR과 SSG 페이지에는 위 값이 그대로 나가고, SSR 페이지는 기본값을 유지한다.

source/:path*로 잡아 전역에 거는 건 위험하다. 경로별로 나눠서 걸고, 개인화가 들어가는 응답은 private, no-store를 유지하는 게 맞다.

13. 안내 페이지와 공지를 인프라 밖에 두기

캐시만으로는 부족하다. 캐시가 만료된 사용자나 처음 들어온 사용자는 여전히 볼 게 없다.

그래서 기존 인프라를 전혀 타지 않는 경로를 하나 만들었다.

S3 버킷에 장애 안내 HTML과 공지사항 JSON을 올리고, CloudFront에 이 버킷을 별도 오리진으로 등록했다. /_status/* 경로의 비헤이비어를 따로 만들어 이쪽으로 보냈다.

이 경로의 요청은 IPS도, ALB도, Istio도, EKS도 지나지 않는다. CloudFront에서 S3로 바로 간다.

안내 HTML은 CSS와 이미지를 전부 인라인해서 파일 하나로 완결되게 만들었다. 안내 페이지가 기존 인프라의 청크나 폰트를 참조하면, 장애 때는 그것들도 같이 죽어 있다.

여기에 커스텀 에러 응답을 붙였다. 오리진이 502나 503을 반환하면 S3에 올려둔 안내 HTML을 대신 내려주도록 매핑했다. 에러 응답에도 TTL을 걸 수 있어서 장애 중에 오리진을 계속 두들기지도 않는다.

11절의 stale-if-error와는 별개의 장치라는 점을 짚어둔다. stale-if-error는 오리진 헤더만으로 동작하고, 내려주는 것도 캐시에 남아 있는 그 URL의 옛 복사본이라 CloudFront에 따로 지정할 게 없다. 커스텀 에러 응답은 반대로 CloudFront 배포 설정이다. 에러 코드별로 대신 내려줄 페이지 경로를 매핑해야 하고, 그 경로가 S3 오리진으로 가는 비헤이비어를 타야 한다. 순서도 정해져 있다. 오리진이 5xx를 반환하면 CloudFront는 먼저 캐시의 stale 복사본을 찾고, 그마저 없거나 기간이 지났을 때 커스텀 에러 응답으로 넘어간다.

오리진 장애 때 다른 오리진으로 요청을 넘기는 오리진 그룹(failover)이라는 선택지도 있다. 다만 그건 S3가 같은 경로 구조로 사이트 전체를 받아줄 수 있을 때 이야기라, 안내 페이지 하나를 보여주는 용도로는 커스텀 에러 응답이 맞다.

프론트엔드에서는 요청이 5xx로 실패하면 /_status/notice.json을 조회해 화면에 띄우도록 했다. 공지 내용은 S3 객체만 바꾸면 즉시 반영되니, 대응 중에 상황을 보면서 문구를 갱신할 수 있다.

14. 배포가 끝나면 캐시는 어떻게 되나

스테이징에 올려놓고 배포를 돌려보다가 후속 과제를 발견했다.

우리 배포는 소스 레포에서 도커 이미지를 빌드해 ECR에 올리고, 그 태그를 GitOps 레포에 커밋하면 ArgoCD가 감지해서 Helm으로 롤링 업데이트를 도는 구조다.

롤링 업데이트가 끝나도 CloudFront는 그걸 모른다. 엣지에는 이전 빌드의 HTML이 s-maxage 기간 동안 남아 있다. 배포는 끝났는데 사용자는 옛날 화면을 본다.

우리는 이전 빌드 산출물을 정리하지 않아서 옛 HTML이 참조하는 청크도 그대로 남아 있다. 그래서 화면이 깨지지는 않는다. 산출물을 정리하는 파이프라인이라면 이야기가 달라진다. 청크 파일명에는 빌드마다 새 콘텐츠 해시가 박히기 때문에, 옛 HTML이 이미 사라진 청크를 요청하면서 ChunkLoadError가 날 수 있다.

같은 성격의 문제가 하나 더 있다. revalidateTag()revalidatePath()로 on-demand 재검증을 걸어도 그건 Next.js 서버 캐시만 비운다. CloudFront는 s-maxage가 만료될 때까지 자기 캐시를 계속 내려준다. 재검증을 CDN까지 전파하려면 그 호출과 함께 CDN 무효화를 같이 쳐야 하고, 이때 HTML과 RSC 변형의 키를 둘 다 지워야 한다.

해결 방향은 배포가 끝난 시점에 캐시를 무효화하는 것이다. 그럼 “배포가 끝났다”를 누가 아는가. 소스 레포의 CI는 이미지를 올린 시점까지만 알고, GitOps 커밋은 의도를 기록할 뿐이다. 롤링 업데이트가 실제로 완료돼 새 파드가 트래픽을 받기 시작한 시점을 아는 건 ArgoCD뿐이다.

그래서 PostSync 훅에 Job을 붙였다. 동기화가 정상적으로 끝난 뒤에만 실행된다.

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: cloudfront-invalidation
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      serviceAccountName: cloudfront-invalidator
      containers:
        - name: invalidate
          image: amazon/aws-cli:2
          command: ["/bin/sh", "-c"]
          args:
            - |
              aws cloudfront create-invalidation \
                --distribution-id "$DISTRIBUTION_ID" \
                --paths "/" "/event/*" "/promo/*"
          env:
            - name: DISTRIBUTION_ID
              valueFrom:
                configMapKeyRef:
                  name: cloudfront-config
                  key: distributionId

권한은 IRSA로 붙였다. cloudfront-invalidator 서비스 어카운트에 cloudfront:CreateInvalidation만 가진 IAM 역할을 연결했고, 액세스 키를 시크릿에 넣는 방식은 피했다.

무효화 경로를 /*로 잡지 않은 게 중요하다. /_next/static/*은 파일명이 해시로 갈리면서 URL 자체가 달라지므로 무효화할 필요가 없다. 새 경로는 애초에 캐시에 없으니 자연스럽게 오리진에서 받아온다. 무효화가 필요한 건 URL이 그대로인 HTML 문서뿐이다. /m/*을 넣지 않은 것도 계산이 있다. s-maxage가 60초라 배포 후 늦어도 2분 안에 자연 갱신되니, 매 배포마다 지울 실익이 없다. 경로를 좁히면 월 1,000개 경로 무료 한도 안에서 운영할 수 있고(와일드카드 하나도 1개로 센다) 반영도 빠르다.

다만 이 경로 목록은 캐시 키와 함께 움직여야 한다. 지금은 캐시 키에 쿼리스트링이 없어서 /만으로 루트가 지워지지만, 16절처럼 _rsc를 캐시 키에 넣는 순간 /는 루트의 쿼리 변형(/?_rsc=...)을 지우지 못한다. 그때는 /* 같은 와일드카드로 바꿔야 한다. App Router 전환에서 잊기 쉬운 부분이다.

hook-delete-policy: HookSucceeded를 걸어 성공한 Job은 자동으로 정리되게 했다. 안 그러면 배포할 때마다 Job 오브젝트가 쌓인다.

15. App Router로 가면 훨씬 복잡해진다

여기까지가 지금 우리 서비스 기준이다. 대부분 Page Router를 쓰고 있어서 고민의 범위가 이 정도에서 끝났다.

그런데 우리는 App Router 전환을 계획하고 있었다. 그래서 전환한 뒤에도 이 설정이 그대로 유효한지 확인해봐야 했고, 공식 문서를 읽어보니 고려할 것이 확실히 늘었다.

App Router에서 같은 URL이 만드는 응답 변형과 캐시 키

같은 URL이 네 가지 응답을 만든다

App Router에서는 하나의 pathname이 요청 방식에 따라 전혀 다른 형태의 응답을 돌려준다. /products를 예로 들면, 브라우저 주소창으로 들어오면 HTML 문서 전체를 받고, <Link>로 이동하면 페이지를 다시 그리지 않기 위해 RSC payload만 받는다. prefetch와 segment prefetch도 각각 다른 응답이다.

CDN이 이 차이를 구분하지 못하면 문제가 생긴다. HTML을 캐시해둔 상태에서 RSC 요청에 그 HTML을 돌려주면, RSC를 기대하던 클라이언트 라우터가 처리하지 못해 소프트 내비게이션이 깨지고 전체 새로고침이 일어난다.

Vary로는 부족해서 나온 _rsc

Next.js는 이 변형을 구분하려고 rsc, next-router-prefetch, next-router-state-tree 같은 커스텀 요청 헤더를 확인하고, 응답에 Vary 헤더를 남겨 CDN에 알린다.

문제는 CloudFront를 포함한 다수의 CDN이 추가 설정 없이는 Vary 기반 분기 캐싱을 처리하지 못한다는 점이다. 그래서 Next.js는 클라이언트 라우팅 요청 URL에 ?_rsc=...를 붙인다. 이 값은 관련 요청 헤더들의 상태를 조합해 만든 해시다. 헤더 상태를 URL 식별자로 바꿔서, CDN이 URL만 보고도 변형을 가를 수 있게 만든 장치다.

CloudFront 기본값으로는 깨진다

“URL에 값이 붙는다면 CloudFront는 기본 설정으로 둬도 되지 않을까” 싶었는데, 그렇지 않다.

CachingOptimized는 query string을 cache key에 넣지 않는다. 그러면 /products/products?_rsc=abc123이 개념적으로 같은 키가 된다. 먼저 캐시된 쪽이 양쪽 요청 모두에 나가고, 어느 방향이든 깨진다.

그래서 두 가지를 반드시 맞춰야 한다. Cache Policy의 cache key에 _rsc를 포함시키고, Origin Request Policy로 rsc 헤더를 오리진에 전달해야 한다. 후자가 유실되면 캐시 이전에 Next.js가 애초에 HTML을 만들어 보낸다.

안전장치가 하나 더 있다. _rsc 값이 맞지 않은 RSC 요청이 도착하면 서버가 올바른 해시가 붙은 URL로 307 리다이렉트를 보낸다. CDN이 이 리다이렉트를 따라가도록 구성해야 한다.

나머지 헤더들은 성격이 조금 다르다. next-router-state-tree가 빠지면 서버가 타겟팅된 세그먼트 대신 전체 payload를 돌려주고, next-url이 빠지면 인터셉팅 라우트가 동작하지 않고 일반 페이지로 대체된다. 응답이 커지거나 기능이 줄어들 뿐 프로토콜이 깨지지는 않는다. 반드시 지켜야 하는 건 rsc 헤더와 _rsc 쿼리다.

앞으로는 경로 이름으로 갈린다

공식 문서에 따르면 Next.js 팀은 이 방식이 CDN 설정 실수를 유발하기 쉽다고 보고, 캐시에 영향을 주는 입력을 전부 URL 경로에 담는 방향을 설계하고 있다.

text
전체 페이지 RSC   /my/page.rsc
세그먼트 RSC      /my/page.segments/path/to/segment.segment.rsc

이 구조가 자리잡으면 CDN은 커스텀 헤더를 들여다보거나 Vary를 이해할 필요 없이, pathname을 캐시 키로 쓰고 표준 Cache-Control만 지키면 된다. 아직 설계 단계라 지금 당장 기댈 수는 없지만, 전환 시점을 잡을 때 참고할 만한 정보였다.

이름이 비슷한 cacheHandlers

App Router 자료를 읽다 보면 7절의 cacheHandler와 이름이 거의 같은 cacheHandlers를 만나게 된다. 복수형이다. 둘은 다른 것을 저장한다.

세 개의 캐시 계층

cacheHandler는 Next.js 서버가 만들어낸 페이지와 데이터 결과물을 저장한다. ISR로 만든 HTML이나 fetch 결과가 여기 들어간다.

cacheHandlers는 Cache Components를 위한 것으로, 렌더링 도중 'use cache'로 표시한 함수와 컴포넌트의 실행 결과를 저장한다.

typescript
async function ProductInfo({ id }) {
  'use cache'
  return expensiveOperation(id)
}

같은 인자로 다시 호출됐을 때 실행을 건너뛰고 결과를 재사용하려면 그 결과를 어딘가 보관해야 한다. 단일 프로세스라면 메모리로 충분하지만 파드가 여러 개면 각자의 메모리가 달라서, 공유하려면 원격 저장소가 필요하다. cacheHandlers는 그 저장소를 꽂는 자리다. 기본 구현이 있으니 직접 설정할 일은 아직 없다.

이건 Cache Components를 도입할 때 별도 포스팅으로 정리할 생각이다. CloudFront 정책을 보수적으로 잡는다면 지금 다루는 문제와 직접 얽히지는 않는다. 이 두 캐시는 파드 안에서 일어나는 일이고, CloudFront는 밖으로 나간 응답을 다루기 때문이다.

16. 그래서 /*는 꺼두고 시작하기로 했다

App Router까지 살펴보고 나서 초기 정책을 다시 잡았다. /* 비헤이비어는 캐싱을 끄고 시작한다.

/*는 다른 비헤이비어에 걸리지 않은 모든 요청을 받는 기본 규칙이다. 여기로 공개 페이지, 로그인 페이지, 사용자별 페이지, HTML, RSC 요청이 전부 들어온다. CloudFront는 Next.js 코드를 읽지 못하므로 /products가 공개 정적 페이지이고 /mypage가 사용자별 동적 페이지라는 걸 알 방법이 없다.

이 결정의 근거는 두 방향 실수의 무게가 다르다는 것이다.

캐시를 안 켜서 생기는 문제는 성능과 비용이다. 오리진 요청이 늘고 파드가 더 뜬다. 되돌리기 쉽고 지표로 바로 보인다.

잘못 캐시해서 생기는 문제는 정확성과 보안이다. 사용자별 응답이 남에게 나가고, HTML과 RSC가 섞이고, /search?q=apple 결과가 q=banana 요청에 나간다. 이런 건 지표에 안 잡히고 사용자 신고로 알게 된다.

캐시를 꺼도 CloudFront에서 얻는 것은 남아 있다. TLS 종료, WAF, DDoS 방어, /_next/static/* 정적 파일 캐싱, 오리진 접근 제어, 12절의 경로별 정책과 13절의 장애 안내 경로까지 전부 그대로 동작한다. 실제로 우리가 이 도입에서 얻으려던 것 대부분이 여기 들어 있다.

그다음 순서는 이렇게 잡았다.

text
1. /* 캐싱 비활성화 상태로 배포
2. RSC · 로그인 · Server Action 동작 검증
3. 공개 페이지 식별
4. Minimum TTL 0 정책으로 해당 경로만 캐시 활성화
5. HTML/RSC cache key 검증
6. 무효화 전략 연결
7. 캐시 범위 확대

정확성과 사용자 격리를 먼저 확보하고, 그다음에 캐시 키를 좁혀 적중률을 올리는 순서다. 반대로 하면 되돌리는 비용이 훨씬 크다.

17. 도입되지 않았다

스테이징에서 여기까지 확인하고 제안했지만, 운영에는 반영되지 않았다.

결정은 네이티브 앱 레이어에서 장애를 안내하는 방향으로 났다. 주요 서비스가 웹뷰이니 그쪽을 먼저 보호하자는 판단이었고, 우선순위에서 밀린 이유도 명확했다.

내 제안은 장애를 막아주지 않는다. 발생한 장애의 사용자 경험을 완화할 뿐이다. 방지책과 완화책이 같은 줄에 놓이면 방지책이 먼저 가는 게 맞다.

납득이 가는 결정이기도 했다. 웹뷰는 네이티브 셸이 감싸고 있으니, 웹뷰 로딩이 실패했을 때 앱에서 에러 화면을 띄우는 쪽이 네트워크 경로와 무관하게 동작한다. CloudFront를 거치는 것보다 확실하다.

대신 서비스가 웹뷰 바깥으로 확장돼 이 구조가 필요해지면 다시 꺼내기로 했다. 스테이징 구성과 설계 문서는 그때 쓸 수 있게 남겨뒀다.

18. 남은 것

이 작업으로 SPOF가 사라지지는 않았다. 캐시 미스와 개인화된 동적 요청은 여전히 여섯 단계 경로를 그대로 통과한다. 경로 자체의 이중화는 조직의 결정이 필요한 일이고, 이번 장애가 설정 오류에서 비롯됐다는 점을 감안하면 이중화만으로 해결되지도 않는다.

정작 남은 건 다른 쪽이다.

나는 네트워크를 따로 공부한 적이 없다. 강의를 듣거나 책을 편 게 아니라, 우리 서비스가 어떻게 돌아가는지 궁금해서 흐름도를 한 칸씩 따라간 게 전부다. 그런데 그것만으로 서브넷이 왜 나뉘는지, NAT Gateway가 어느 방향만 열어주는지, 로드밸런서가 왜 사설 IP만 갖는지가 손에 잡혔다. s-maxage 한 줄을 이해하려다 VPC 구조까지 훑게 됐고, CloudFront를 붙여보려다 캐시 키와 RSC 응답 변형까지 읽게 됐다.

그 뒤로 달라진 건 볼 수 있는 지점이 늘었다는 것이다. 응답이 이상하거나 배포가 반영되지 않을 때, 예전에는 인프라팀에 물어보고 기다렸는데 이제는 어디를 먼저 확인해야 할지 짐작이 간다. 매일 쓰는 서비스가 이미 좋은 학습 자료였다는 걸 늦게 알았다.


참고