목차
국내에서는 잘 뜨는 서비스인데, 해외 사용자에게서 화면이 뜨는 데 한참 걸린다는 제보가 온다. 서버는 한가하다.
지구 반대편에서 느리다
서버 자원을 봐도 여유가 있고, 응답 시간 로그도 정상이다. 첫 글에서 본 그 상황이다. 서버가 잰 시간과 사용자가 겪은 시간이 다르다.
차이는 서버 밖에 있다. 요청이 오가는 거리다.
서버를 늘리는 것으로는 이걸 못 줄인다. 같은 자리에 열 대를 세워도 지구 반대편까지의 거리는 그대로다. 앞 글의 로드 밸런서도 마찬가지다. 나눠주는 지점이 여전히 같은 곳에 있다.
거리가 곧 지연이다
신호가 아무리 빨라도 물리적으로 걸리는 시간이 있고, 중간의 장비들을 거치면서 더 붙는다. 그래서 멀수록 왕복 한 번이 비싸진다.
문제는 왕복이 한 번이 아니라는 것이다.
각 단계마다 오가는 왕복이 있다. 그리고 마지막 단계는 자원 개수만큼 반복된다. 왕복 하나의 비용에 왕복 횟수가 곱해지는 구조라서, 거리가 멀어지면 체감이 훨씬 크게 나빠진다.
여기서 방향이 정해진다. 왕복 횟수를 줄이거나, 왕복 거리를 줄이거나. 거리를 줄이려면 서버가 사용자 쪽에 있어야 한다.
사용자 가까이 사본을 둔다
**CDN(Content Delivery Network)**은 세계 곳곳에 캐시 서버를 두고, 사용자를 자기와 가장 가까운 곳으로 보낸다. 이 캐시 서버를 엣지(edge), 원래 우리 서버를 **오리진(origin)**이라고 부른다.
동작은 캐시 글의 그 그림과 똑같다. 엣지에 있으면 그대로 주고(히트), 없으면 오리진에서 받아와 저장한 뒤 준다(미스). 달라진 건 캐시가 어디에 있느냐뿐이다. 프로세스 안에서 프로세스 밖으로 꺼냈던 그 이동을, 이번엔 데이터센터 밖 사용자 근처까지 밀어낸 것이다.
효과는 첫 사용자만 멀리 다녀오고, 같은 지역의 그다음 사용자들은 가까운 곳에서 받는다는 것이다.
층이 다른 캐시다
여기서 헷갈리기 쉬운 게 있다. 브라우저에도 캐시가 있는데 CDN은 뭐가 다른가.
층이 셋이고, 뒤로 갈수록 더 많은 사람이 공유한다. 이 차이가 전부다.
브라우저 캐시는 그 사용자 한 명에게만 이득이다. 처음 오는 사용자에게는 아무 도움이 안 된다. CDN은 다른 사람이 먼저 받아둔 것을 뒤에 오는 사람이 쓴다. 그래서 첫 방문자에게도 효과가 있다.
이 층들에 “얼마나 오래 보관할지”를 지시하는 방법은 이미 있다. HTTP 캐시 헤더다. Cache-Control의 max-age와 s-maxage, private과 public이 정확히 누가 얼마나 보관할지를 나눠 정한다. 그 규칙은 HTTP 캐시 글이 자세히 다루므로 여기서는 그 헤더들이 CDN을 조종하는 손잡이라는 점만 짚는다. CDN 설정 화면에서 뭘 만지든, 결국 이 헤더들이 정하는 동작 위에서 논다.
CDN이 겨냥하는 병목
CDN은 지연만 줄이는 게 아니다. 병목 지도에서 두 칸을 동시에 건드린다.
거리에서 오는 지연. 지금까지 본 것이다. 사용자가 어디에 있든 가까운 곳에서 받는다.
오리진의 대역폭과 요청 수. 이게 더 크게 작용할 때도 많다. 이미지와 동영상은 용량이 크고, 트래픽이 늘면 네트워크 대역폭이 먼저 찬다. 첫 글의 자원 넷 중 네 번째다. 정적 자산을 CDN이 대신 내보내면 그 트래픽이 오리진에 안 온다.
그래서 CDN을 붙이면 오리진의 성격이 바뀐다. 파일을 내보내는 일이 빠지고 진짜 계산이 필요한 요청만 남는다. 같은 서버로 더 많은 사용자를 받는 셈이다.
무엇을 엣지에 두나
기본은 모두에게 같고 자주 안 바뀌는 것이다.
| 자원 | 엣지에 두기 |
|---|---|
| 이미지 · 폰트 · 동영상 | 아주 잘 맞는다 |
| 번들된 JS · CSS | 잘 맞는다 |
| 로그인 없이 보는 공개 페이지 | 조건부로 가능하다 |
| 사용자별 화면 · 장바구니 · 마이페이지 | 두면 안 된다 |
| 주문 · 결제 같은 쓰기 요청 | 캐시 대상이 아니다 |
기준은 캐시 글에서 세운 것과 같다. 응답이 사람마다 달라지면 공유 캐시에 두면 안 된다. 다만 여기서는 결과가 더 나쁘다. 로컬 캐시에서 키를 잘못 잡으면 서버 한 대 안에서 섞이지만, CDN에서 잘못 잡으면 한 지역의 사용자 전부에게 남의 화면이 나간다.
“로그인 사용자 이름이 박힌 HTML”이 엣지에 캐시된 사고는 실제로 반복해서 일어난다. 응답에 Cache-Control: private이나 no-store가 없고, CDN 쪽에서도 안 걸러지면 그대로 저장된다. 개인화된 응답은 엣지에 닿기 전에 스스로 “저장하지 마라”고 말해야 한다.
캐시 대상이 아닌 요청도 CDN을 지나가게 하는 경우는 흔하다. 캐시가 아니라 경로 최적화 때문이다. 엣지까지 가까운 구간에서 연결을 맺고, 엣지와 오리진 사이는 미리 열려 있는 좋은 경로로 가는 식이다. 이때 CDN은 캐시가 아니라 앞단 프록시로 일한다.
공유 캐시라서 조심할 것
CDN 사고의 대부분은 캐시 키에서 나온다. 앞의 캐시 글에서 “응답을 바꾸는 입력은 전부 키에 있어야 한다”고 했는데, CDN에서는 그 키를 우리가 직접 안 만든다. CDN이 정한 규칙이 키가 된다.
기본 키는 대개 URL이다. 그러면 URL이 같은데 응답이 달라지는 경우가 전부 위험해진다.
- 쿠키에 따라 다른 화면을 주는 페이지
Accept-Language에 따라 언어가 바뀌는 응답- 인증 헤더 유무로 내용이 달라지는 API
해결 방향은 둘이다. 응답이 “이 헤더가 다르면 다른 응답”이라고 알리거나(Vary), 애초에 URL을 다르게 만들거나다. 언어를 경로에 넣어 /ko/..., /en/...으로 나누면 키 문제가 사라진다. 헤더에 의존하는 것보다 URL로 가르는 편이 대체로 안전하다. Vary를 붙였다고 CDN이 자동으로 따라주는 것도 아니다 - 어떤 헤더를 캐시 키에 넣을지는 CDN 설정에서 따로 정하는 경우가 많고, 제품마다 다르다.
반대 방향의 낭비도 있다. 광고 추적용 쿼리 파라미터처럼 응답을 안 바꾸는 값이 URL에 붙으면 매번 다른 키가 되어 히트율이 무너진다. 같은 이미지가 파라미터 조합마다 따로 저장된다. 그래서 CDN에는 “이 파라미터는 키에서 무시하라”는 설정이 있다.
무효화 - 지우기와 이름 바꾸기
엣지에 잘못된 응답이 저장되면, 오리진을 고쳐도 안 고쳐진다. 엣지는 자기가 가진 걸 TTL이 끝날 때까지 계속 내준다.
방법이 둘이다.
퍼지(purge). CDN에게 “이 URL을 지워라”라고 지시한다. 즉시 조치가 되지만 한계가 있다. 전 세계 엣지에 반영되기까지 시간이 걸리고, 지운 직후에는 모두가 미스를 겪어 오리진으로 한꺼번에 몰린다. 원격 캐시 글에서 본 스탬피드가 여기서도 그대로 일어난다. 규모만 더 크다.
이름을 바꾼다. 파일 내용에서 뽑은 값을 이름에 박아두면(app.4f2a9c.js) 내용이 바뀔 때 URL이 바뀐다. 처음 보는 URL이라 엣지에 있을 수가 없다. 무효화라는 행위 자체가 필요 없어진다. HTTP 캐시 글이 브라우저 캐시에 대해 제안하는 그 방법인데, 층이 하나 늘어난 CDN에서 값이 더 크다. 브라우저 하나가 아니라 전 세계 엣지를 한꺼번에 우회하기 때문이다.
그래서 실제 구성은 대개 이렇게 나뉜다.
| 파일 | 캐시 | 이유 |
|---|---|---|
| 이름에 해시가 붙은 자산 | 아주 길게 | 바뀌면 이름이 바뀐다 |
| HTML 진입 문서 | 아주 짧게 또는 매번 확인 | 어느 이름의 자산을 쓸지 여기 적혀 있다 |
퍼지는 이 구조에서 사고 대응용으로 남는다. 평상시 배포는 이름 바꾸기로 처리한다.
오리진을 지키는 쪽으로도 쓴다
CDN의 또 다른 역할이 있다. 오리진에 가는 요청 수를 줄이는 것이다.
엣지가 세계 곳곳에 많다는 건, 캐시가 만료되는 순간 그 많은 엣지가 동시에 오리진에 물어본다는 뜻이기도 하다. 사용자 수가 아니라 엣지 수만큼 몰린다.
그래서 CDN은 대개 두 장치를 둔다.
요청 병합. 같은 자원을 동시에 여러 요청이 찾으면, 오리진에는 한 번만 물어보고 나머지는 그 결과를 나눠 받는다. 앞의 스탬피드 완화에서 “하나만 다시 만들게 한다”와 같은 발상이다.
중간 계층(오리진 실드). 엣지들이 오리진으로 직행하지 않고 지역별 중간 캐시를 한 번 거치게 한다. 오리진이 상대하는 상대 수가 줄어든다.
이 그림은 이 시리즈에서 계속 나온 모양이다. 여러 곳에서 오는 요청을 한 곳으로 모아 뒤를 보호한다. 커넥션 풀이 DB를 보호하던 방식과, 원격 캐시가 DB를 대신 받아주던 방식과 같다.
그래서 실무에선 어떻게 쓰이나
정적 자산부터 옮긴다. 이미지와 번들 파일을 CDN 경로로 내보내는 것만으로 오리진 대역폭이 크게 준다. 코드 변경도 자산 URL을 바꾸는 정도다.
붙인 뒤에 실제로 히트하는지 본다. CDN을 붙여놓고 캐시 헤더가 잘못돼 있으면 모든 요청이 오리진까지 간다. 겉보기에는 아무 문제가 없어서 효과가 없다는 사실 자체를 모른다. 응답에 붙는 히트·미스 표시 헤더로 확인한다. 첫 글의 규칙이 여기서도 그대로다.
개인화된 응답이 새어 나가는지 점검한다. 로그인 상태에서 보이는 페이지에 private이나 no-store가 붙어 있는지 확인한다. 이건 성능 문제가 아니라 사고다.
앞단이 늘었다는 걸 기억한다. 로드 밸런서 글에서 본 것처럼, 앞에 서는 장치가 하나 더 생기면 클라이언트 IP와 프로토콜 정보가 또 한 겹 감싸진다. CDN 뒤에 로드 밸런서, 그 뒤에 앱이 있으면 전달 헤더가 여러 값이 되고, 어디까지를 믿을지 정해야 한다.
API에는 신중하게 건다. 조회 API를 CDN에 캐시하면 극적으로 싸지지만, 개인화나 권한이 조금이라도 섞이면 위험이 크다. 공개된 데이터이고 사용자와 무관한 응답부터 시작하는 편이 안전하다.
정리
| 문제 | 서버를 늘려도 거리는 안 줄어든다 |
| CDN | 세계 곳곳 엣지에 사본을 두고 가까운 곳에서 준다 |
| 층 | 브라우저 캐시(개인) - 엣지(지역 공유) - 오리진. 뒤로 갈수록 많이 공유한다 |
| 겨냥하는 병목 | 거리에서 오는 지연 + 오리진의 대역폭과 요청 수 |
| 둘 것 | 모두에게 같고 자주 안 바뀌는 것. 개인화된 응답은 안 된다 |
| 캐시 키 | 기본은 URL. 헤더로 갈리는 응답은 URL로 가르는 편이 안전하다 |
| 무효화 | 퍼지는 사고 대응용. 평상시는 파일 이름에 해시를 박는다 |
| 오리진 보호 | 요청 병합과 중간 캐시로 오리진이 상대하는 수를 줄인다 |
여기까지가 더 잘 받는 법이다. 캐시로 덜 만들고, 여러 대로 나누고, 가까이 뒀다. 그런데 이 모든 걸 갖춰도 감당 못 할 만큼 들어오는 순간이 온다. 다음 글은 방향을 바꿔서, 안 받는 법을 본다.