Vercel에 올렸는데 해외 사용자가 느리다고 한다
Next.js 프로젝트를 Vercel에 배포했다. 서울에서 테스트하면 쾌적하다. 그런데 미국에 있는 팀원이 "왜 이렇게 느리냐"고 한다. 코드 문제가 아니다.
서버가 어디 있는지부터 생각해보면 금방 이해된다. Vercel 서버리스 함수는 특정 리전에서 실행된다. 설정하지 않으면 기본값이 us-east-1, 미국 버지니아다. 한국에서 접속해도 요청이 태평양을 건너 미국까지 갔다 돌아오는 셈이다. 그런데도 국내에서 빠른 건 CDN 덕분이다.
정적 파일(HTML, CSS, JS, 이미지)은 Vercel이 전 세계 엣지 네트워크에 미리 뿌려놓는다. 서울 사용자는 서울 근처 서버에서 파일을 받는다. 그런데 CDN 설정이 미흡하거나 엣지에 파일이 없으면 원본 서버까지 왕복해야 한다. 거리가 멀수록 오래 걸린다.
CDN이 정확히 무엇인지 여기서부터 풀어본다.
CDN이 하는 일
편의점 비유가 가장 빠르다.
서울에 창고 하나를 가진 회사가 있다. 제주도 사람이 그 회사 물건을 사려면 서울 창고에서 배송을 기다려야 한다. 하루, 이틀. 그런데 회사가 전국 편의점 체인과 계약해 각 편의점에 물건을 미리 쌓아뒀다. 이제 제주도 사람은 동네 편의점에서 바로 산다.
CDN이 그렇게 동작한다.
- 원본 서버(오리진): 서울 창고. 실제 파일이 있는 곳
- 엣지 서버(POP, Point of Presence): 전국 편의점. 세계 곳곳에 흩어진 캐시 서버
- 캐시: 편의점 재고. 원본에서 복사해온 파일
사용자가 사이트에 접속하면 CDN은 그 사용자에게 가장 가까운 엣지 서버에서 파일을 내려보낸다. 미국 사용자는 미국 엣지에서, 일본 사용자는 도쿄 엣지에서 받는다. 원본 서버까지 요청이 가지 않는다.
처음 접속하는 사람이 해당 엣지에 없는 파일을 요청하면, 그때 한 번 원본에서 가져온다. 그 다음부터는 엣지가 가지고 있다가 바로 내려보낸다. 이걸 캐시 히트라고 한다.
CDN이 없으면 어떻게 되나
속도 차이를 숫자로 보면 납득이 빠르다.
서울 서버에서 뉴욕 사용자에게 파일을 전달하는 거리는 약 11,000km다. 빛의 속도로만 계산해도 왕복 74ms다. 실제 인터넷은 라우터를 수십 번 거치니 왕복 200ms 이상이 기본이다.
반면 뉴욕 근처 CDN 노드에서 뉴욕 사용자에게 전달하면 왕복 5ms 이하다.
HTML 하나, JS 번들 하나, CSS 하나, 이미지 여러 개. 첫 페이지 로딩에 파일이 10개가 넘어가면 200ms 차이가 10번 쌓인다. 체감으로 "이 사이트 느리다"가 된다.
CDN이 없으면:
- 모든 사용자가 원본 서버까지 왕복한다
- 원본 서버에 트래픽이 집중된다
- 서버 비용이 올라간다
- DDoS 공격이 원본에 직접 들어온다
Vercel 쓰면 CDN이 자동으로 되나
그렇다. 단, 정적 자산에 한해서.
Vercel에 Next.js 앱을 올리면 빌드 결과물(JS 번들, CSS, 이미지 등)은 자동으로 Vercel 엣지 네트워크에 배포된다. 한국, 미국, 유럽 어디서 접속해도 가장 가까운 엣지에서 파일을 받는다. Cloudflare Pages도 마찬가지다. 배포하면 300개 이상 도시에 파일이 뿌려진다.
여기서 헷갈리는 부분이 있다.
정적 파일은 CDN 적용, API는 리전 배포
Vercel의 app/api/ 아래 API Routes, getServerSideProps로 동적으로 생성하는 페이지는 CDN 캐시가 아니라 특정 리전의 서버리스 함수로 처리된다. 기본 리전이 us-east-1이면 한국 사용자 요청도 미국까지 갔다 온다.
Vercel Pro 이상이면 API 함수도 여러 리전에 배포할 수 있고, Edge Functions를 쓰면 API 로직도 엣지에서 실행된다. 무료 플랜은 리전 선택이 제한적이다.
정리하면:
- 정적 파일(HTML, CSS, JS, 이미지): 기본 CDN으로 전 세계 빠름
- API 응답: 리전 서버리스 함수, 가까운 리전 선택이 중요
- SSR 동적 페이지: 서버리스 함수와 같은 제약
배포했는데 왜 이전 버전이 보이나
새 버전을 배포했다. 새로고침을 눌렀는데 이전 화면이 그대로다. 캐시 문제다.
CDN 엣지는 파일을 캐시하면서 "얼마 동안 유지할지" 정보를 같이 저장한다. TTL(Time To Live)이다. 서버가 내려보낸 Cache-Control 헤더에서 읽는다.
Cache-Control: max-age=3600
이렇게 설정하면 CDN은 1시간 동안 파일을 캐시하고 원본 서버에 다시 확인하지 않는다. 1시간 안에 새 버전을 올려도 TTL이 만료될 때까지 구 버전이 서빙된다.
해결 방법은 두 가지다.
방법 1: 파일명에 해시 넣기
Vite나 Next.js 같은 빌드 툴이 기본으로 해주는 방식이다. JS 파일명을 app.js가 아니라 app.3f7d9c.js처럼 내용 기반 해시를 붙여 만든다. 내용이 바뀌면 파일명이 바뀐다. CDN 입장에서는 완전히 새로운 파일이므로 원본에서 새로 가져온다.
이 전략을 쓰면 JS, CSS, 이미지 캐시를 아주 길게 잡아도 된다.
Cache-Control: max-age=31536000, immutable
1년 캐시. 파일이 바뀌면 URL도 바뀌니까 안전하다.
방법 2: 캐시 무효화(Invalidation)
CI/CD 파이프라인에서 배포 완료 후 CDN에 "이 경로 캐시 버려"를 명령하는 방식이다. Cloudflare는 대시보드나 API로 캐시 퍼지(purge)를 할 수 있다. AWS CloudFront는 invalidation 요청을 보낸다.
Vercel은 배포할 때 자동으로 처리해줘서 대부분 신경 쓸 필요가 없다. Cloudflare 같은 별도 CDN을 앞에 붙이는 경우에 이 과정이 필요하다.
HTML 캐시는 짧게 잡아야 한다
HTML은 JS, CSS와 다르다. 파일명에 해시를 붙이기 어렵고(URL이 /about, /contact 같은 경로라서), HTML 자체가 어떤 JS 파일을 불러올지 알려주는 역할을 한다. HTML 캐시를 길게 잡으면 업데이트된 JS 파일 이름이 달라졌는데 구 HTML이 구 JS를 참조하는 문제가 생긴다.
# HTML
Cache-Control: no-cache
# JS, CSS, 이미지 (해시 파일명)
Cache-Control: max-age=31536000, immutable
직접 CDN을 붙여야 하는 경우
EC2, 도커, VPS에 직접 서버를 올린 경우 CDN이 자동으로 붙지 않는다. 선택지는 두 가지다.
Cloudflare CDN
도메인을 Cloudflare로 옮기고 DNS 설정에서 A 또는 CNAME 레코드를 프록시 모드로 켜면 된다. 대시보드에서 주황색 구름 아이콘이 활성화된 상태가 프록시 모드다. 이 상태에서 들어오는 HTTP/S 요청은 Cloudflare가 먼저 받아 처리한다.
무료 플랜에서도 기본 CDN, DDoS 방어, SSL 인증서 자동 발급이 된다. 개인 프로젝트나 소규모 서비스라면 무료로 충분하다.
주의할 점이 하나 있다. Cloudflare 기본 설정은 HTML 같은 동적 콘텐츠를 캐시하지 않는다. 정적 파일(CSS, JS, 이미지)만 캐싱한다. "cache everything" 옵션을 아무 생각 없이 켜면 로그인 상태에 따라 달라지는 페이지가 다른 사람 화면을 보여주는 심각한 문제가 생길 수 있다.
AWS CloudFront
S3에 정적 파일을 올리고 CloudFront를 앞에 붙이는 패턴이다. AWS 생태계 안에서 EC2, Lambda, API Gateway와 연동이 자연스럽다.
설정이 Cloudflare보다 복잡하다. IAM 권한, Distribution 설정, 캐시 Behavior 구성을 각각 해야 한다. 캐시 무효화도 스크립트로 따로 관리하는 경우가 많다. 동영상 스트리밍, 대규모 트래픽, Lambda@Edge 같은 고급 기능이 필요할 때 유리하다.
처음 시작이라면 Cloudflare를 먼저 써보는 게 낫다.
캐시 때문에 생기는 다른 문제들
API 응답은 함부로 캐시하면 안 된다
CDN이 정적 파일만 캐시하면 문제없다. API 응답을 무분별하게 캐시하면 터진다.
사용자마다 다른 응답을 내려보내는 API가 있다고 하자. 로그인한 사용자 정보를 돌려주는 /api/me 같은 엔드포인트. CDN이 A의 요청 응답을 캐시했는데 B가 같은 URL로 요청하면 A의 데이터가 B에게 보일 수 있다.
인증이 필요한 API 응답은 캐시를 꺼야 한다.
Cache-Control: no-store
또는 Cloudflare에서 해당 경로는 캐시하지 않도록 Cache Rules를 따로 설정한다.
CDN 추가 후 갑자기 404, CORS 에러가 뜨는 경우
CDN 도메인이 바뀌면서 자산 경로가 어긋나서 생기는 문제다. 기존 코드에서 /static/logo.png로 참조했는데 CDN 도메인이 cdn.myapp.com/assets/logo.png라면 경로가 안 맞는다. 배포 설정에서 assetPrefix나 basePath를 CDN 도메인으로 맞춰줘야 한다.
결국 바이브 코더가 기억해야 할 것
CDN을 "빨라지는 마법 옵션" 정도로만 알면 캐시 문제나 보안 문제가 생겼을 때 어디서부터 봐야 할지 막막해진다. 아래 네 가지만 기억해도 대부분의 상황에 대응할 수 있다.
- Vercel, Cloudflare Pages는 정적 파일 CDN이 자동 적용된다. API와 SSR 페이지는 아니다.
- 배포 후 이전 버전이 보이면 캐시 문제다. JS, CSS는 파일명 해시로 해결하고 HTML은 짧은 캐시로 예방한다.
- 직접 서버를 올렸다면 Cloudflare부터 붙여보자. 무료 플랜으로 시작해도 충분하다.
- 인증이 필요한 API 응답은 캐시를 끈다.
Cache-Control: no-store를 기억.
CDN 개념을 한 번 잡아두면 배포할 때마다 헷갈리던 상황들이 정리된다. 해외 사용자 느리다는 소리 들었을 때 뭘 확인해야 할지도 바로 안다.