infrastructure troubleshooting

CloudFront와 Next.js 사이, 두 분류기가 어긋났을 때 — 디바이스 캐시 오염 추적기

PC 환경에서 모바일 화면이 노출되는 캐시 오염 이슈를 두 달에 걸쳐 추적한 기록입니다. 1차 부분 대응이 어떻게 더 큰 비용을 만들었는지, CDN 캐시 분리와 Next.js dynamic rendering의 상호작용을 어디서 놓쳤는지 정리합니다.

cloudfront isr caching debugging retrospective

시작하며

포케코리아(poke-korea.com)를 운영하면서 가장 오래 끌었던 이슈 중 하나는 “PC로 접속했는데 모바일 화면이 나온다”는 제보였다. 처음 발견했을 때 한 페이지를 고쳤고 “끝났다”고 생각했다. 두 달 뒤 같은 종류의 버그가 다른 페이지에서 다시 터졌고, 그제서야 1차 대응이 증상만 가린 채 구조적 원인을 그대로 남겨뒀다는 걸 깨달았다.

이 글은 그 추적 과정과 함께, 부분 대응이 어떻게 더 큰 비용을 만들었는지에 대한 회고다.

시스템 구조

본론에 들어가기 전에 운영 환경의 캐시 구조를 정리해 둔다.

브라우저


[CloudFront edge]
  ├─ CF Function (viewer-request): User-Agent → x-device-type 헤더 생성
  ├─ Cache Policy: 캐시 키에 x-device-type 포함 → 디바이스별 캐시 분리
  └─ x-device-type 헤더를 origin 으로 forward


[Origin: Next.js (App Router)]
  ├─ ISR (revalidate=31536000) — 대부분의 정적 페이지
  ├─ Dynamic rendering — searchParams 사용 페이지 (실질적 동작)
  └─ headers().get('user-agent') 로 device 판정

CloudFront Function에서 User-Agent를 보고 x-device-type 헤더를 만들어 CDN 캐시 키에 포함시킨다. 이렇게 하면 같은 URL이라도 디바이스별로 다른 캐시가 저장된다.

Origin인 Next.js에서는 headers().get('user-agent')로 한 번 더 디바이스를 판정해서 모바일/데스크톱 컴포넌트를 분기 렌더링한다.

겉으로 보면 안전한 구조다. 그런데 같은 일을 두 군데에서 하고 있다는 점이 함정이었다.

1차 발견 (v1.35.1) — 홈 화면의 디바이스 분기 오작동

증상

상용 배포와 CloudFront 캐시 초기화 직후, 홈 페이지에서 PC 환경임에도 모바일 화면이 표시되는 문제가 발생했다. 다른 페이지에서는 발생하지 않았고, 홈 페이지에서만 재현됐다.

당시의 진단

홈 페이지의 revalidate 값이 1시간(3600초)으로 짧게 설정되어 있던 게 차이점이었다.

브라우저 → CloudFront (x-device-type 기준 캐시 분리)
        → Next.js ISR (디바이스 구분 없이 단일 캐시)

다른 페이지들은 revalidate = 31536000(1년)으로 설정되어 있어, 빌드 시점에 데스크톱 HTML이 ISR 캐시에 prerender 되고 런타임 재생성이 사실상 일어나지 않는다. 그래서 “홈 페이지만의 문제”라고 결론 내렸다.

당시의 대응

src/app/page.tsx 한 곳만 변경했다.

// 변경 전
export const revalidate = 3600;

// 변경 후
export const dynamic = 'force-dynamic';

CloudFront가 CDN 레벨에서 x-device-type 기준으로 캐시를 분리해주고 있으니, Next.js 단의 ISR을 제거하고 매 요청마다 정확한 디바이스 판단을 수행하도록 변경한 것이다.

changelog 말미에는 이런 메모를 남겼다.

layout.tsxheaders() 호출에 await가 누락되어 있으나, 각 페이지에서 직접 detectUserAgent를 호출하고 있어 현재 동작에는 영향 없음 (별도 개선 권장)

“별도 개선 권장”이라고 적어두고, 후속 작업은 진행하지 않았다. 이 결정이 두 달 뒤 더 큰 비용으로 돌아왔다.

재발 (v1.35.x 이후) — /list 페이지의 같은 증상

증상

홈 페이지를 고친 지 두 달쯤 지났을 무렵, 이번엔 포켓몬 도감 리스트 페이지(/list)에서 동일한 증상이 보고됐다. 패턴도 비슷했다.

홈 페이지에서 적용했던 진단을 그대로 가져오면 설명이 되지 않았다. /listrevalidate = 31536000로 설정되어 있어서 1차 대응 당시의 분류상 “안전한 페이지”였기 때문이다.

초기 가설 5개

분석을 시작하면서 다섯 가지 시나리오를 의심했다.

가설메커니즘의심 강도
A-1CF function/캐시 정책 변경 직후 일시적 오작동중간
A-2이전 캐시 정책 시점에 박힌 잔재중간
A-3CloudFront POP 정책 전파 불일치낮음
A-4searchParams와 캐시 키의 상호작용높음
A-5Lighthouse 모바일 모드가 cache fill을 트리거높음

A-1, A-2는 변경 이력 자체가 없어서 빠르게 폐기됐다. A-3는 공식 문서에 전파 시간이 명시되어 있지만, 영구히 어긋나는 known issue는 없었다. 남은 건 A-4와 A-5였다.

/list 코드 분석 — dynamic rendering의 함정

/list 페이지 코드를 다시 들여다봤다.

// src/app/list/page.tsx
export const revalidate = 31536000; // 1년

const ListPage = async ({ searchParams }: PageProps) => {
  const headersList = await headers();
  const userAgent = headersList.get('user-agent') || '';
  const isMobile = detectUserAgent(userAgent);

  const {
    type,
    isMega,
    isRegion,
    // ...
  } = await searchParams;
  // ...
};

여기서 결정적인 단서가 나왔다. Next.js 공식 문서는 이렇게 명시한다.

Pages that use searchParams are opted into dynamic rendering because they need to be processed on each request.

revalidate가 명시되어 있어도 searchParams를 사용하면 dynamic rendering으로 자동 전환된다. 즉 /list는 코드에 1년 캐시가 적혀 있지만 실제로는 매 요청마다 page 함수가 실행되는 dynamic 페이지였다.

이건 1차 대응 당시의 진단을 정면으로 뒤집는 사실이었다.

“다른 페이지들은 빌드 시점에 이미 데스크톱 HTML이 ISR 캐시에 생성되어 있어 런타임에 재생성되지 않음”

이 진단은 searchParams를 사용하는 페이지에는 적용되지 않는다. /list는 ISR 안전망이 처음부터 작동하지 않았다.

Lighthouse UA 추적 — 두 분류기의 불일치

여기까지 와서 가설 A-5를 다시 들여다봤다. Lighthouse 모바일 모드가 운영 사이트에서 자주 실행되고 있었는데, Lighthouse의 기본 user-agent 중 일부 버전이 이런 형태였다.

Mozilla/5.0 (Linux; Android 11; ...) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/...

소문자 mobile 키워드가 없고 Android만 들어 있는 UA였다. 이 UA를 두 분류 시스템에 넣어 봤다.

시스템분류 키워드Android Bare UA 판정
CF functionmobile, iphone, ipod, blackberry, opera mini, windows phonedesktop
Next.js 정규식/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/imobile

같은 UA를 두 시스템이 다르게 판정하고 있었다. CloudFront Function의 키워드 배열에는 android가 없는데, Next.js 정규식에는 있었다.

실제 검증 — curl + UA 매트릭스

추측을 확정으로 바꾸기 위해 실측이 필요했다. 응답 본문에서 모바일/데스크톱을 구별할 수 있는 식별자(컴포넌트마다 다른 광고 슬롯 ID)를 활용해, 다양한 UA로 동일 URL에 동시 요청을 보내는 스크립트를 작성했다.

detect() {
  local label="$1" ua="$2"
  local headers=$(mktemp) body=$(mktemp)
  curl -s -D "$headers" -A "$ua" 'https://poke-korea.com/list' > "$body"

  local xcache=$(grep -i 'x-cache:' "$headers" | sed 's/^x-cache: //i')
  local age=$(grep -i '^age:' "$headers" | sed 's/^age: //i')

  # 본문에 박혀 있는 컴포넌트별 식별자로 모바일/데스크톱 판정
  local mobile_count=$(grep -c 'MOBILE_MARKER' "$body")
  local desktop_count=$(grep -c 'DESKTOP_MARKER' "$body")

  local verdict="?"
  if [ "$mobile_count" -gt 0 ] && [ "$desktop_count" -eq 0 ]; then verdict="MOBILE"
  elif [ "$desktop_count" -gt 0 ] && [ "$mobile_count" -eq 0 ]; then verdict="DESKTOP"
  fi

  printf "%-22s | %-12s | age=%-6s | %s\n" "$label" "$xcache" "${age:-N/A}" "$verdict"
  rm -f "$headers" "$body"
}

1차 검증 결과

UAx-cacheage본문 판정
PC Mac ChromeHit4529DESKTOP
PC Windows ChromeHit4530DESKTOP
Android BareHit4530DESKTOP
iPhone SafariMissN/AMOBILE
Android+MobileMissN/AMOBILE
iPad SafariMissN/AMOBILE
Empty UAHit4530DESKTOP

핵심 관찰은 두 가지였다.

  1. PC Mac, PC Windows, Android Bare, Empty UA가 모두 같은 age=4530으로 hit → 같은 ‘desktop’ 키 캐시에 박혀 있음
  2. iPhone, Android+Mobile, iPad는 모두 Miss → ‘mobile’ 키 캐시가 비어 있었음

즉, Android Bare UA가 CF function에서는 desktop으로 분류되어 ‘desktop’ 키에 들어갔고, 그 자리에 마침 이전 PC 요청이 채워둔 데스크톱 HTML이 있어서 결과적으로 정상 응답을 받았다.

오염 시나리오 재구성

문제는 운이 좋지 않은 순간이다. 무효화 직후 첫 ‘desktop’ 키 요청자가 Android Bare UA였다면?

1. CF function: Android Bare → 'mobile' 키워드 매칭 안 됨 → desktop 분류
2. CDN 'desktop' 키 cache miss → origin으로 forward
3. Origin Next.js: headers().get('user-agent') → Android 매칭 → mobile 판정
4. 모바일 HTML 응답 → CDN 'desktop' 키에 모바일 HTML 박힘 (1년)
5. 이후 진짜 PC 사용자 도달 → cache hit → 모바일 화면 노출

이 시나리오로 그동안의 모든 증상이 일관되게 설명됐다. 가설 A-5(Lighthouse) + A-4(/list의 dynamic 동작) + CF function/Next.js 키워드 불일치가 결합된 결과였다.

무효화의 역설

분석 도중에 발견한 또 하나의 사실이 있다. “이슈가 터지면 캐시를 무효화하면 해결된다”는 운영 패턴 자체가 이슈 발현 확률을 키우고 있었다.

CloudFront 무효화의 정확한 동작을 정리하면 이렇다.

캐시 레이어무효화로 비워지는가
CloudFront edge 캐시비워짐
Next.js ISR 캐시 (.next/cache)그대로
Apollo Client in-memory 캐시그대로
브라우저 캐시그대로

여기서 두 가지가 따라온다.

  1. 무효화 = 캐시 미스 동시 다발 이벤트

    • 평소엔 path별 자연 만료가 분산되어 있어 origin 부담이 적다
    • 무효화는 그 분산을 0으로 만들고 origin으로 thundering herd를 발생시킨다
  2. 무효화 직후 cache fill의 첫 요청자가 누구인지가 모든 걸 결정한다

    • 첫 요청자가 진짜 PC UA → ‘desktop’ 키에 데스크톱 HTML 박힘 → 정상
    • 첫 요청자가 Android Bare UA → CF function: desktop / Next.js: mobile → ‘desktop’ 키에 모바일 HTML 박힘 → 오염

무효화는 청소 행위가 아니라 레이스 컨디션 윈도우를 여는 트리거 행위였다. “이슈가 터지면 무효화”라는 패턴이 매번 새로운 cache fill 윈도우를 열어줬고, Lighthouse 모바일 측정이 그 윈도우 안에 도달할 확률을 키웠다.

1차 대응 — CF function 키워드 통일

오염이 발생할 수 있는 경로를 세 군데에서 차단할 수 있었다.

옵션차단 시점변경 라인재배포 비용부작용
CF function 키워드 통일CDN edge1줄Functions publish없음
Next.js x-device-type 우선 읽기origin25개 페이지풀 빌드+재시작폴백 검증 필요
/listdynamic 명시없음 (실제 동작 변화 없음)2줄풀 빌드+재시작없음

CF function 수정이 가장 앞단에서 차단하고, 변경 범위가 작고, 재배포가 무중단이라는 이유로 선택됐다.

// 변경 전
var mobileKeywords = [
  'mobile', 'iphone', 'ipod', 'blackberry', 'opera mini', 'windows phone',
];

// 변경 후 — Next.js 정규식과 일치
var mobileKeywords = [
  'mobile', 'android', 'iphone', 'ipod', 'blackberry',
  'iemobile', 'opera mini', 'webos', 'windows phone',
];

// iPad 명시적 desktop 처리 (iPadOS 13 이후 Safari가 기본적으로 desktop 모드 동작)
if (userAgent.indexOf('ipad') > -1) {
  isMobile = false;
}

CF function publish → 전파 대기 → 전체 캐시 무효화 후 재검증한 결과, Android Bare가 변경 전 DESKTOP에서 변경 후 MOBILE로 전환됨을 확인했다. 사용자 영향이 가장 큰 오염 경로가 차단됐다.

잘못된 의사결정에 대한 회고

이번 이슈가 두 달간 끌린 이유는 명백히 1차 대응의 의사결정에 있었다. 회고로 남길 만한 지점을 네 가지로 정리한다.

1. “다른 페이지는 안전하다”는 진단이 불완전했다

1차 대응의 진단은 정적 라우트에는 부분적으로 맞았지만, searchParams를 사용하는 페이지가 dynamic rendering으로 자동 전환된다는 사실을 인지하지 못한 상태였다. /listrevalidate = 31536000로 적혀 있어도 실제로는 dynamic이었고, ISR 안전망이 처음부터 없었다.

진단의 범위를 좁힌 채로 “홈만의 문제”라고 결론 내린 게 첫 번째 실수다.

2. “CDN 캐시 분리가 origin 분기를 보장한다”는 명제는 틀렸다

1차 대응의 암묵적 전제는 이랬다.

“CloudFront가 x-device-type으로 캐시 분리하면 origin 분기와 정렬되어 원인이 해결된다”

CDN 캐시 분리는 사후 분리일 뿐이다. origin이 무엇을 응답하는지에 관여하지 않는다. origin이 잘못된 응답을 주면 CDN은 그걸 충실히 디바이스별로 저장한다.

올바르게 다시 쓰면 이렇다.

두 분류기(CF function ↔ Next.js)가 같은 소스를 보고 같은 결과를 내야 비로소 안전하다. 이번 이슈는 그 전제가 깨졌을 때 무슨 일이 벌어지는지를 보여줬다.

3. “현재 동작에는 영향 없음”이라는 판단이 가장 비싼 결정이었다

1차 대응 changelog 말미의 한 줄.

“현재 동작에는 영향 없음 (별도 개선 권장)”

이 한 줄로 미해결 숙제를 인지하고도 후속 작업을 미뤘다. 결과는 이렇다.

“현재 동작에는 영향 없음”이라는 판단은 보통 “지금은 안 터지지만 조건이 갖춰지면 터진다”의 다른 표현이다. 미해결 항목은 위험 우선순위와 함께 작업 큐에 명시적으로 등록해야 한다는 점을 늦게 배웠다.

4. 무효화의 효과를 잘못 학습했다

“무효화하면 해결된다”는 경험이 반복되면서 무효화에 의존하게 됐고, 근본 원인 탐색을 미루는 결과로 이어졌다. 실제로는 무효화로 해결됐던 게 운이 아니라 ISR이 빌드 시점 데스크톱 HTML로 보증되어 있어 결정론적으로 작동했던 것이었는데, 그 메커니즘을 이해하지 못한 채 “무효화의 효과”로 잘못 귀인했다.

이 잘못된 학습이 운영 패턴(매번 전체 무효화) 자체를 의심하지 않게 만들었고, 그 패턴이 이슈 발현 확률을 키우는 악순환으로 이어졌다.

잔존 위험과 후속 작업

1차 대응(CF function 키워드 통일)으로 사용자 영향이 가장 큰 경로는 차단됐지만, 구조적으로는 아직 남은 위험이 있다.

iPad 정책 불일치

CF function은 iPad를 desktop으로 처리하지만, Next.js 정규식은 여전히 iPad를 mobile로 매칭한다.

// src/module/device.module.ts
const userAgentExp =
  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
//                    ^^^^ 정규식에 남아 있음

iPad 사용자가 무효화 직후 첫 ‘desktop’ 키 요청자가 되면 동일한 메커니즘으로 오염이 재현될 수 있다.

후속 작업 우선순위

순위작업효과
1device.module.ts에서 iPad 정규식 제거iPad 경로 차단
2Next.js가 x-device-type 헤더를 우선 읽도록 변경두 분류기가 항상 같은 소스를 보게 됨
3/list, /movesrevalidate 제거하고 dynamic 명시코드 의도 명확화

운영 측면에서도 “업데이트마다 전체 무효화” 패턴 대신 변경된 path만 specific 무효화하는 방향으로 바꾸는 게 좋겠다고 판단했다.

마치며

이슈 자체보다 더 오래 기억에 남는 건 1차 대응의 의사결정 과정이다. “증상이 사라졌으니 끝났다”는 감각, “별도 개선 권장”이라는 메모 한 줄, “CDN이 알아서 해주겠지”라는 막연한 신뢰. 각각은 작은 판단이었는데, 합쳐지자 두 달 뒤 더 큰 비용이 됐다.

이 글이 비슷한 구조(CDN 디바이스 분기 캐시 + Next.js App Router)를 운영하는 사람에게 한 가지라도 단서가 됐으면 한다. 캐시 시스템에서 같은 일을 두 군데에서 하고 있다면, 두 곳이 정말 같은 결과를 내고 있는지 한 번 더 의심해 볼 가치가 있다.

참고 자료