2025년 5월, 운영 중인 Next.js 서비스에서 사용자 프로필 이미지가 간헐적으로 다른 유저의 이미지로 표시되는 현상이 발생했습니다.
주요 증상은 다음과 같았습니다.
현상 요약
- 동일한 이미지 요청임에도 불구하고 다른 이미지가 표시됨
- 브라우저 개발자 도구에서 _next/image?... 경로의 이미지 응답이 서버마다 다름
- 특정 환경에서 304 Not Modified 응답이 의도와 다르게 작동
원인 분석
문제는 최근 GCP 로드밸런서의 정책 변경과 Next.js 이미지 최적화 캐시 처리 방식이 충돌하면서 발생했습니다.
1. GCP 로드밸런서 정책 변경 (2025.04.28 적용)
Google Cloud는 2025년 4월 28일부터 다음과 같은 정책을 적용했습니다:
HTTP/1.1 트래픽에서 hop-by-hop 헤더를 참조하는 커스텀 헤더 사용 제한
(참조: GCP Release Notes)
해당 변경으로 인해 프록시나 로드밸런서가 Vary, Connection 등의 캐시 제어 헤더를 무시하거나 제거하는 사례가 발생할 수 있습니다.
2. 서버 간 이미지 캐시 불일치
Next.js는 _next/image 경로를 통해 이미지를 서버에서 리사이징하고 .next/cache/images에 저장합니다.
여러 서버가 분산되어 있는 환경(GCP에서 라운드 로빈 분산)에서는 아래 문제가 발생합니다:
- 서버 A와 B가 서로 다른 캐시 상태를 유지
- 동일한 요청에 대해 다른 이미지 결과를 반환
3. 세션 어피니티 미설정
로드밸런서에 세션 어피니티가 설정되지 않아 클라이언트 요청이 매번 다른 서버로 전달되며 충돌 확률이 증가했습니다.
문제 해결: unoptimized 설정
즉각적인 대응책으로 Next.js 이미지 최적화 기능을 비활성화했습니다.
이를 통해 서버에서 캐시 처리나 이미지 리사이징을 하지 않고, 브라우저가 원본 URL을 직접 요청하게 했습니다.
// Next.js 13 이상
// Next.js 13 이상에서는 next.config.js에서 전역 설정이 가능합니다:
// next.config.js
module.exports = {
images: {
unoptimized: true,
},
};
//Next.js 12에서는 전역 설정 불가
//하지만 Next.js 12 버전에서는 unoptimized 옵션이 전역 설정으로 지원되지 않으며, 개별 컴포넌트에서 직접 설정
<Image
src={userImageUrl}
alt="User profile"
width={100}
height={100}
unoptimized
/>
이 조치를 통해:
- 서버 간 이미지 결과 불일치 문제 해결
- 브라우저 캐시에 저장되어 재요청 시 일관성 확보
- 이미지 충돌 현상 즉시 해결됨
추후 대책 및 고려사항
이번 문제를 겪고 나서, 단순히 unoptimized 하나로 넘기기보단 조금 더 근본적인 개선이 필요하겠다는 생각이 들었습니다.
아래는 저희 팀이 실제로 검토했거나 적용 중인 후속 대응들입니다.
1. 세션 어피니티는 꼭 설정해두자
서버가 여러 대인 환경에서는 같은 사용자의 요청이 매번 다른 서버로 전달되면 캐시 충돌이 발생할 수 있습니다.
GCP 로드밸런서를 사용하는 경우, 백엔드 서비스 설정에서 세션 어피니티를 ‘Client IP’ 기반으로 설정해 주는 것이 좋습니다.
이를 통해 같은 클라이언트의 요청이 항상 동일한 서버로 전달되도록 유도할 수 있습니다.
2. 이미지 캐시 디렉토리는 공유하면 좋다
Next.js에서 이미지 최적화를 켜면 .next/cache/images에 서버별로 캐시가 쌓이는데, 이게 서버마다 다르면 똑같은 요청도 결과가 달라집니다.
가능하다면 이 디렉토리를 NFS나 Cloud Filestore 같은 네트워크 스토리지로 묶어서 공유하면 안정성이 높아집니다.
물론 운영 복잡도는 좀 올라갑니다.
3. 헤더 관련해서도 꼼꼼히 보자
디버깅 과정에서 Vary, Accept, Authorization 등의 헤더가 로드밸런서나 프록시에서 필터링되거나 누락되는 경우, 의도한 캐싱 동작이 깨질 수 있다는 사실을 확인했습니다.
특히 Vary 헤더는 브라우저와 CDN이 어떤 기준으로 캐시할지를 결정하는 핵심 요소이므로, 누락될 경우 동일한 URL 요청에 대해 서로 다른 응답이 캐시될 수 있습니다.
4. Cloud CDN도 쓴다면 캐시 정책을 체크
Cloud CDN을 이미지 경로에 적용하고 있다면, 단순히 활성화하는 것만으로는 부족합니다.
캐시 키 구성에 어떤 요소가 포함되는지, 특히 헤더 기반 분기가 고려되는지를 반드시 확인해야 합니다.
예를 들어 Vary 헤더를 포함한 상태에서 브라우저 포맷(WebP 등)에 따라 다른 응답이 나가도록 되어 있다면, 이를 CDN이 제대로 인식하고 처리하고 있는지 검토해야 합니다.
5. Next.js 13
Next.js 13부터는 _next/image 경로로 반환되는 이미지 응답에 Vary: Accept 헤더가 기본적으로 포함됩니다.
이 헤더는 브라우저가 지원하는 이미지 포맷(WebP, AVIF 등)에 따라 다른 리소스를 캐시할 수 있도록 돕는 역할을 합니다.
문제는 GCP 로드밸런서나 기타 프록시 계층에서 이 헤더가 차단되거나 제거되는 경우, 서버 또는 브라우저에 잘못된 이미지가 캐시될 수 있다는 점입니다.
Next.js 12에서는 이 헤더가 기본 포함되지 않기 때문에, 버전 업 이후 예상치 못한 캐시 충돌이 발생하는 경우가 생길 수 있습니다.
따라서 Next.js 13 이상을 사용하는 경우, Vary: Accept 헤더가 클라이언트까지 정확히 전달되고 있는지를 반드시 점검해야 합니다.
저희 팀은 Next 12를 사용중이라 이번 일을 통해 Next13으로 마이그레이션을 고려하고 있습니다.
결론
이번 문제는 단순한 이미지 충돌처럼 보였지만, 로드밸런서의 정책 변경, Next.js 내부 캐시 전략, 서버 아키텍처 구성이 복합적으로 얽힌 이슈였습니다.
unoptimized: true는 빠른 대응책으로 유효하지만, 장기적으로는 분산 환경에 맞는 캐시 전략과 로드밸런서 설정 최적화가 필요하며 비슷한 이슈를 겪고 있는 다른분들에게도 도움이 되는 글이면 좋겠습니다.
'Programming > React.js, Next.js' 카테고리의 다른 글
[Next.js] next/dynamic 2. 효율적으로 활용하기 (0) | 2024.12.10 |
---|---|
[Next.js] next/dynamic 1. 사용법과 빌드 과정 (0) | 2024.12.10 |
우테코(우아한테크코스) 프론트엔드 폴더 구조 톺아보기 (0) | 2024.08.05 |
[Next.JS] 성능개선 LCP 최적화 (0) | 2024.07.04 |
debounce와 throttle (0) | 2024.07.01 |