캐시가 뭔가요: 한 번 받아온 것을 다시 쓰는 법

같은 요청에 저장해 둔 값을 돌려주는 캐시 구조를 나타낸 대표 이미지
한 번 받아 둔 것을 다시 씁니다.

캐시는 한 번 받아 온 결과를 잠시 저장해 두고 다시 쓰는 것입니다. 같은 걸 또 물어보면 원본에 다시 가지 않고 저장해 둔 값을 바로 줍니다. 개념은 이게 전부인데, 실제로 쓸 때 정해야 할 게 몇 가지 있습니다. 이 글은 제가 지원금 정보 화면에 캐시를 붙이며 직접 부딪힌 문제와, 웹 표준 문서가 말하는 캐시의 규칙을 함께 정리했습니다.

왜 쓰나

제 경우는 지원금 정보를 보여주는 화면이었습니다. 방문자가 페이지를 열 때마다 수천 건을 처음부터 다시 받아왔습니다. 느리고, 원본 서비스에도 부담입니다.

그런데 이 자료는 하루에 몇 번씩 바뀌는 게 아닙니다. 방금 받아 온 값을 잠시 두고 쓰면 될 일이었습니다. 캐시는 이렇게 "같은 질문에 같은 답이 돌아오는 구간"이 길수록 효과가 큽니다. 반대로 질문할 때마다 답이 달라지는 자료라면 저장해 둘 이유가 없습니다.

캐시가 있을 때와 없을 때의 요청 흐름을 비교한 이미지
저장된 게 있으면 원본까지 안 갑니다.

정해야 하는 것 하나

캐시에서 어려운 건 저장이 아니라 얼마나 오래 두느냐입니다.

  • 너무 짧으면 효과가 없습니다. 매번 다시 받는 것과 비슷해집니다.
  • 너무 길면 옛날 정보를 새것처럼 보여줍니다. 마감된 항목이 계속 떠 있는 식입니다.

기준은 “원본이 얼마나 자주 바뀌나”와 “틀린 값을 보여줬을 때 얼마나 곤란한가”입니다. 저는 6시간으로 잡았습니다. 지원금 정보가 하루에 여러 번 바뀌지 않고, 반나절 늦어도 큰 문제가 아니라고 봤습니다.

6시간 캐시가 실제로 움직이는 순서

숫자로 따라가면 이해가 쉽습니다. 6시간은 21,600초(6 x 60 x 60)입니다. 같은 6시간 캐시가 시간에 따라 어떻게 행동하는지 표로 그렸습니다.

받은 뒤 시간캐시 안 값의 나이판정일어나는 일
0시간(처음)0저장 없음원본에서 받아 와 저장합니다.
1시간3,600초신선저장된 값을 바로 보여 줍니다.
5시간 59분21,540초신선마지막으로 바로 보여 주는 구간입니다.
6시간21,600초기한 도달다음 요청에서 원본에 다시 받습니다.
6시간 이후, 원본이 안 바뀜새로 받으면 0으로 리셋다시 신선같은 값을 새로 6시간 동안 씁니다.
중간에 손으로 비움삭제저장 없음다음 요청이 원본에서 받아 옵니다.

표에서 눈여겨볼 곳은 6시간이 되는 순간입니다. 그 시점에 값이 저절로 지워지는 게 아니라, 다음 요청이 올 때 비로소 원본에 다시 갑니다. 방문자가 새벽에 없으면 그 시간 동안 아무 일도 일어나지 않습니다. 이 점을 알아 두면 "6시간이 지났는데 왜 아직 옛 값이 보이지?" 하는 의문의 상당수가 풀립니다. 다만 이 순서는 저장한 시각과 지금 시각을 비교하는 구조를 기준으로 한 설명이고, 사용하는 저장소마다 만료된 값을 지우는 시점은 다를 수 있습니다.

웹 표준이 말하는 "신선"과 "묵은" 값

브라우저와 CDN 같은 웹의 캐시에도 똑같은 사고방식이 있습니다. HTTP 캐시 표준인 RFC 9111은 저장된 응답의 나이가 신선 수명보다 작으면 "신선(fresh)", 넘으면 "묵은(stale)" 상태라고 구분합니다. 신선 수명은 서버가 Cache-Control: max-age로 알려 주는 값이 우선이고, 공용 캐시에는 s-maxage가 먼저 적용됩니다. 둘 다 없으면 Expires 헤더에서 Date를 뺀 값을 쓰고, 그마저 없을 때만 캐시가 알아서 추정합니다.

MDN의 예를 그대로 옮기면 이렇습니다. max-age=604800(일주일)으로 내려온 응답이 공용 캐시에 이미 하루(Age: 86400) 머물렀다면, 남은 신선 시간은 604,800에서 86,400을 뺀 518,400초입니다. 그리고 묵어졌다고 해서 바로 버려지지는 않습니다. 서버에 "아직 같은가요?"를 물어 같다는 답을 받으면 다시 쓸 수 있습니다.

확인만 하고 다시 받지 않는 방법

묵은 값을 만났을 때 통째로 다시 받는 대신 확인만 하는 방법이 있습니다. 서버가 응답에 ETag(내용에 따라 서버가 만든 표식)를 붙여 두면, 브라우저는 다음에 그 표식을 If-None-Match에 담아 보냅니다. 서버가 보기에 같은 내용이면 본문 없이 304 Not Modified만 돌려보냅니다. 내용이 바뀌었으면 200 OK와 함께 새 본문을 보냅니다. MDN은 304에 본문이 없어서 전송량이 아주 작다고 설명합니다. Last-Modified와 If-Modified-Since 쌍도 같은 역할을 하며, 둘이 같이 오면 If-None-Match가 우선입니다.

그래서 "묵었다 = 다시 받는다"가 아닙니다. 묵었다는 건 "쓰기 전에 확인이 필요하다"는 뜻일 뿐입니다.

Cache-Control 지시어 한눈에 보기

서버가 캐시에게 규칙을 전하는 창구가 Cache-Control 헤더입니다. 자주 만나는 지시어를 MDN과 RFC 9111 기준으로 정리했습니다.

지시하는 일잘 맞는 자료
max-age=초받은 뒤 그 초 동안은 "신선"하다고 보고 서버에 묻지 않고 씁니다.내용이 가끔만 바뀌는 자료
no-cache저장은 하되, 쓰기 전에 매번 서버에 "아직 같은가요?" 하고 확인합니다.자주 바뀌는 HTML 문서
no-store아예 저장하지 않습니다.남기면 안 되는 민감한 응답
private보는 사람의 브라우저 같은 개인 캐시에만 저장합니다.로그인한 사람마다 다른 화면
publicAuthorization 헤더가 있어도 저장할 수 있게 허용합니다.공용으로 나눠 써도 되는 자료
immutable내용이 절대 안 바뀌니 확인도 필요 없다고 알립니다.파일 이름에 해시가 붙은 정적 파일

가장 흔한 오해는 no-cache입니다. 이름 때문에 "저장하지 않는다"로 읽기 쉬운데, 실제 뜻은 저장은 하되 쓰기 전에 반드시 확인한다입니다. 저장 자체를 막는 건 no-store입니다. 둘을 바꿔 쓰면 의도와 정반대의 동작이 나옵니다.

파일 종류별로 이렇게 나눕니다

web.dev는 두 가지로 나누라고 안내합니다. 파일 이름에 해시나 버전이 붙어 내용이 바뀌면 주소도 바뀌는 정적 파일(스타일시트, 스크립트 등)은 max-age=31536000(1년, 365 x 86,400초)처럼 아주 길게 두고, immutable을 더해도 됩니다. 다만 web.dev는 immutable을 일부 브라우저가 무시한다고 덧붙입니다. 반면 주소가 고정이고 내용이 바뀌는 HTML 문서 같은 자원은 no-cache로 두어 매번 확인하게 합니다. 길게 둬도 되는 이유는 내용이 바뀌면 주소가 달라져 새 파일로 인식되기 때문입니다.

제 지원금 화면은 서버 쪽 저장소라 이 헤더를 직접 쓰지는 않았지만, "바뀌는 빈도에 맞춰 기간을 정한다"는 원리는 똑같았습니다.

크기 제한에 걸린다

실제로 만들면서 걸린 부분입니다. 저장 공간에 한 덩어리로 넣을 수 있는 크기가 정해져 있습니다. 제가 쓴 곳은 100KB였는데 자료는 그보다 훨씬 컸습니다.

해결은 조각으로 나눠 넣는 것입니다. 다만 꺼낼 때 주의할 게 있습니다.

// 조각이 하나라도 빠지면 통째로 버린다for (var i = 0; i < parts; i++) {  if (!got[key + '_' + i]) return null;   // 반쪽짜리를 쓰느니 다시 받는다  text += got[key + '_' + i];}

조각 중 하나가 먼저 사라질 수 있습니다. 그걸 확인 안 하고 이어 붙이면 깨진 자료가 화면에 나갑니다. 없으면 전체를 무효로 하고 새로 받는 편이 안전합니다.

서버 쪽 저장소 문서는 이렇게 적혀 있습니다

구글 앱스 스크립트의 CacheService 공식 문서(Cache 클래스)에는 이 글에서 다룬 고민이 숫자로 정리돼 있습니다. 키 하나에 담을 수 있는 데이터는 100KB까지이고, 보존 시간을 따로 정하지 않으면 600초(10분) 동안 유지됩니다. 직접 정하는 경우에는 1초부터 21,600초(6시간)까지만 허용됩니다. 또 보존 시간은 보장이 아니라 "제안"에 가깝습니다. 문서는 캐시에 데이터가 많이 쌓이면 기한 전에 지워질 수 있다고 밝히고, 없는 키를 읽으면 null이 돌아온다고 설명합니다.

항목공식 문서에 적힌 값
키 하나에 담는 데이터 크기최대 100KB
보존 시간을 정하지 않고 저장할 때600초(10분)
직접 정할 수 있는 보존 시간1초 이상 21,600초(6시간) 이하
보존 시간 전에 사라지는 경우캐시에 데이터가 많이 쌓이면 기한 전에 지워질 수 있음
없는 키를 읽으면null을 돌려줌
직접 비우는 방법remove(key) 또는 removeAll(keys)

이 표가 말해 주는 것은 두 가지입니다. 첫째, 서버 쪽 저장소에는 크기와 기간의 상한이 있으므로 큰 자료는 조각으로 나눠야 하고, 6시간보다 오래 두려면 다른 저장 방식이 필요합니다. 둘째, 캐시 값은 언제든 없을 수 있으니 읽는 쪽에서 "없으면 원본에서 다시 받는다"는 길을 항상 열어 둬야 합니다. 앞에서 조각 하나라도 빠지면 통째로 버리라고 한 이유와 같습니다. 저장소마다 수치는 다르므로, 다른 서비스를 쓴다면 그 서비스의 문서에서 같은 항목을 먼저 찾아보세요.

캐시를 쓸 때 정할 세 가지를 정리한 설명 이미지
비우는 방법도 같이 만들어 둡니다.

비우는 방법을 같이 만든다

빠뜨리기 쉬운 부분입니다. 원본이 바뀌었는데 캐시 때문에 안 보일 때가 반드시 옵니다. 그때 손으로 비울 방법이 없으면 시간이 지나기를 기다리는 수밖에 없습니다.

저는 캐시를 지우는 함수를 따로 만들어 뒀습니다. 몇 줄이면 됩니다. 앞의 표 마지막 줄처럼, 손으로 비우면 다음 요청이 곧바로 원본에서 새 값을 받아 옵니다. 지원금 목록을 고친 직후에 이 함수를 한 번 부르면 6시간을 기다리지 않아도 됩니다.

브라우저 캐시와 다른 건가

이름은 같지만 자리가 다릅니다. 브라우저 캐시는 보는 사람 컴퓨터에 저장됩니다. 지금 이야기한 건 서버 쪽이라 모든 방문자가 같은 값을 공유합니다.

그래서 서버 쪽 캐시는 한 번만 받아 오면 되고, 브라우저 캐시는 사람마다 따로 쌓입니다. MDN은 이를 개인 캐시(브라우저)와 공용 캐시(프록시, CDN 등)로 나눕니다. 로그인한 사람마다 다른 내용을 공용 캐시에 두면 정보가 다른 사람에게 보일 수 있어서, 그런 응답에는 private를 붙이라고 안내합니다. 같은 주소라도 언어에 따라 내용이 갈리면 Vary: Accept-Language처럼 어떤 요청 헤더에 따라 달라지는지를 알려야 따로 저장됩니다.

새 값이 안 보일 때 확인하는 순서

원본을 고쳤는데 화면이 그대로라면 캐시가 여러 겹일 수 있습니다. 먼저 서버 쪽 저장소에 값이 남아 있는지 봅니다. 다음으로 브라우저가 이전 응답을 신선하다고 보고 그대로 쓰는지 확인합니다. 마지막으로 CDN이나 프록시 같은 중간 공용 캐시를 의심합니다. 어디서 막혔는지 모르는 채로 한 곳만 비우면 고쳐졌는지 알 수 없으니, 한 겹씩 비우고 그때마다 화면을 다시 확인하는 편이 빠릅니다.

한계

캐시는 “조금 오래된 값을 보여줘도 괜찮다”는 전제 위에서만 씁니다. 실시간이어야 하는 정보에는 맞지 않습니다. 재고나 가격처럼 틀리면 곤란한 것에는 쓰기 전에 한 번 더 생각해야 합니다. 정확성이 중요하지만 매번 통째로 받기는 부담스러운 자료라면, 앞서 본 것처럼 짧게 두고 확인만 하는 방식을 먼저 검토해 보세요.

적용 전에 확인할 것

  • 이 자료는 하루에 몇 번 바뀌나요? 바뀌는 빈도보다 훨씬 긴 기간을 잡지 않았나요?
  • 틀린 값이 몇 시간 보였을 때 곤란한 정도가 어느 쪽인가요?
  • 한 덩어리로 못 넣는 크기라면 조각 하나가 빠졌을 때 통째로 버리게 했나요?
  • 원본을 고친 직후 손으로 비울 방법이 있나요?
  • 로그인한 사람마다 다른 내용이라면 공용 캐시에 들어가지 않게 했나요?

확인 자료