Skip to main content
캐시된 파일이 오래됐으면 무조건 다시 내려받아야 할까요? 사실은 “그 파일 아직 같은 버전이에요?”라고 먼저 물어볼 수 있어요.
CDN, Cache, 그리고 Edge Delivery에서는 캐시가 복사본을 가까운 곳에 두고 재사용한다는 큰 그림을 봤어요. 그리고 Cache-Control과 Age 헤더에서는 그 복사본이 아직 fresh인지, CDN Cache Status 헤더에서는 CDN이 방금 캐시를 어떻게 처리했는지 읽었죠. 이번에는 stale이 된 사본을 버리기 전에 한 번 더 묻는 장면을 볼게요.
서버가 보기에 지금 최신 버전도 "app-css-v17"이라면, 본문 전체를 다시 보낼 필요가 없어요.
처음 보면 304는 “응답이 비어 있는데 괜찮은 건가?”처럼 보여요. 괜찮아요. 이건 실패가 아니라 이미 가진 사본을 그대로 써도 된다는 확인 응답이에요. 오늘 질문은 이거예요.
“캐시는 어떤 단서를 들고 서버에 다시 확인하고, 서버는 언제 304로 대답할까요?”
HTTP validator인 ETagLast-Modified의 의미는 RFC 9110의 validator 절을 바닥에 두고, 캐시가 stale 응답을 다시 확인하는 revalidation 흐름은 RFC 9111의 validation 절을 기준으로 잡을게요.
여기서는 브라우저와 CDN에서 자주 보는 조건부 GET, ETag, Last-Modified, 304 Not Modified 흐름에 집중해요. If-Match로 쓰기 충돌을 막는 optimistic locking 같은 API 설계는 살짝만 언급하고, 캐시 재검사 장면을 중심으로 볼게요.

도서관에서 책을 새로 빌리기 전에 판 번호를 먼저 물어봐요

도서관에서 책을 이미 한 권 갖고 있다고 해볼게요. 그런데 며칠이 지나서 혹시 새 판이 나왔는지 궁금해요. 이때 도서관에 이렇게 물을 수 있죠.
“제가 가진 책이 17판인데요, 지금도 17판인가요?”
도서관 직원이 “네, 아직 17판이에요”라고 말해주면, 같은 책을 다시 들고 올 필요가 없어요. 내가 가진 책을 그대로 읽으면 돼요. 웹 캐시도 비슷해요. 핵심은 ETagLast-Modified캐시된 사본을 다시 확인하기 위한 단서라는 점이에요. Cache-Control이 “얼마 동안 그냥 믿을지”를 말한다면, validator는 “오래됐을 때 무엇으로 비교할지”를 말해요. 이 그림에서 304는 새 데이터를 주지 않는 응답이에요. 대신 캐시에게 네가 가진 본문을 계속 써도 된다고 확인해주는 역할을 해요.

먼저 응답에 validator가 있는지 봐요

조건부 요청은 아무 근거 없이 만들어지지 않아요. 처음 받은 응답에 비교할 단서가 있어야 해요.
여기서 중요한 단서는 두 개예요. 둘 다 validator로 쓸 수 있지만 성격은 달라요. RFC 9110은 ETag 값을 opaque validator로 봐요. 즉 클라이언트가 "hero-v12" 안쪽의 의미를 해석하려고 하면 안 돼요. 해시처럼 보일 수도 있고, 버전 번호처럼 보일 수도 있지만, 클라이언트에게 중요한 건 문자열이 서버의 현재 validator와 맞는지예요.
ETag: "abc123"을 보고 “이건 SHA 해시겠지”라고 단정하지 마세요. 서버 구현자가 어떤 방식으로 만들었는지는 구현 차이예요. 캐시 입장에서는 나중에 같은 값인지 비교할 수 있으면 돼요.

stale이 되면 조건부 요청으로 다시 확인해요

응답이 fresh일 때는 캐시가 보통 서버에 묻지 않고 사본을 재사용할 수 있어요. 그런데 freshness lifetime이 끝나면 이야기가 달라져요.
60초가 지난 뒤 같은 URL을 다시 요청하면, 브라우저나 CDN은 이런 요청을 만들 수 있어요.
이걸 조건부 요청이라고 불러요. 그냥 “파일 주세요”가 아니라, 조건이 붙어 있어요.
app-v17과 다를 때만 새 본문을 주세요.”
서버가 확인한 현재 ETag"app-v17"이면 이렇게 응답할 수 있어요.
본문은 없거나 매우 작아요. 캐시는 이미 저장해둔 본문을 꺼내고, 304 응답에 함께 온 새 헤더 메타데이터로 저장된 응답을 fresh하게 갱신할 수 있어요. 이 흐름이 중요한 이유는, 네트워크 왕복은 여전히 필요할 수 있지만 큰 본문 전송은 피할 수 있기 때문이에요. 이미지, CSS, JavaScript처럼 본문이 큰 응답에서는 차이가 꽤 커요.

바뀌었으면 304가 아니라 200과 새 본문이 와요

이번에는 파일이 바뀐 장면이에요. 브라우저가 예전 사본을 들고 있어요.
그런데 서버의 현재 버전은 "app-v18"이에요. 그러면 304가 아니라 새 본문이 담긴 200 OK가 와야 해요.
이 장면을 표로 보면 더 선명해요. 그래서 304는 “캐시가 맞았다”는 신호에 가깝고, 200은 “새 본문이 필요했다”는 신호에 가까워요. 둘 다 정상일 수 있어요.
해시가 붙은 정적 파일처럼 URL이 내용 버전을 이미 담고 있다면, 긴 max-age로 아예 재검사 왕복을 줄이는 편이 더 나을 수 있어요. 304는 본문 전송을 줄여주지만, 서버나 CDN에 확인하러 가는 왕복 자체를 없애주지는 않아요.

If-None-Match와 If-Modified-Since는 우선순위가 달라요

조건부 요청에는 대표적으로 두 가지가 있어요.
둘을 같이 보내는 경우도 볼 수 있어요. 이때는 If-None-Match가 더 정확한 비교 단서로 취급돼요. RFC 9110도 If-None-Match가 있으면 If-Modified-Since는 무시하라고 정리해요. 처음 읽을 때는 이렇게 보면 좋아요. Last-Modified 기반 비교는 날짜라서 직관적이지만, 초 단위로만 보이거나 서버 시계와 배포 시스템 차이의 영향을 받을 수 있어요. 아주 짧은 시간 안에 같은 리소스가 여러 번 바뀌는 시스템에서는 ETag가 더 정확한 단서가 되기 쉬워요. 이 그림은 조건부 요청을 읽는 순서를 보여줘요. 처음에는 If-None-Match가 있는지부터 보고, 없으면 If-Modified-Since를 보면 덜 헷갈려요.

strong ETag와 weak ETag는 같은 이름표처럼 보여도 비교 힘이 달라요

ETag에는 가끔 W/가 붙어요.
W/는 weak validator라는 뜻이에요. 아주 거칠게 말하면: 예를 들어 HTML을 만들 때 공백이나 생성 시각 주석처럼 작은 차이가 있을 수 있지만, 사용자에게 보여줄 의미는 같다고 볼 수 있는 경우가 있어요. 이때 서버는 weak ETag를 쓸 수 있어요. 캐시 재검사에서 자주 보는 If-None-Match는 weak 비교를 사용할 수 있어요. 그래서 W/"app-v17" 같은 값도 cache validation에는 쓸 수 있어요. 하지만 range 요청처럼 정확한 바이트 조각을 이어 붙여야 하는 장면에서는 strong validator가 더 중요해져요.
W/가 붙었다고 캐시에 못 쓰는 건 아니에요. 다만 “정확히 같은 바이트”라는 강한 약속은 아니므로, 다운로드 이어받기나 range 처리 같은 장면에서는 의미가 달라질 수 있어요.

브라우저 Network 탭에서는 304와 200의 크기를 같이 봐요

브라우저 Network 탭에서 이런 줄을 볼 수 있어요.
여기서 304는 본문을 다시 받지 않았다는 신호예요. 그런데 브라우저 화면에서는 최종적으로 CSS가 적용되고 이미지가 보이기 때문에, “응답 본문이 없는데 화면은 왜 나오지?” 싶을 수 있어요. 답은 간단해요. 브라우저가 이미 가진 cached body를 쓴 거예요.
개발자 도구에서 304를 보면 “브라우저가 아무것도 못 받았다”가 아니라, “서버가 기존 사본을 써도 된다고 확인했다”로 읽어야 해요.

curl로는 두 번 요청해보면 흐름이 보여요

실제 확인은 curl로도 할 수 있어요. 먼저 헤더만 받아서 validator를 봐요.
예를 들어 이런 응답을 받았다고 해볼게요.
그다음 같은 ETag로 조건부 요청을 직접 보내요.
서버의 현재 버전이 같으면 이런 모양이 나올 수 있어요.
날짜 기반으로도 확인할 수 있어요.
이런 실험을 할 때는 CDN이나 브라우저 캐시 조건 때문에 결과가 달라질 수 있어요. 같은 URL이라도 요청 헤더, cache key, CDN 지역, purge 상태가 다르면 200, 304, HIT, REVALIDATED가 다르게 보일 수 있거든요.

잘못 읽기 쉬운 함정

1. 304를 에러처럼 읽기

304 Not Modified는 3xx라서 redirect 계열처럼 보이지만, 캐시 재검사에서는 정상적인 성공 흐름이에요. 새 위치로 보내는 리다이렉트가 아니라 저장된 응답을 계속 쓰라는 확인이에요.

2. ETag가 항상 파일 해시라고 믿기

ETag가 해시처럼 생길 수는 있어요. 하지만 표준 관점에서는 opaque value예요. 서버가 파일 inode, 수정 시각, 빌드 번호, 데이터베이스 버전, 내용 해시 등을 섞어 만들 수도 있어요. 클라이언트가 내부 의미를 해석하면 안 돼요.

3. ETag가 있으면 캐시가 무조건 빨라진다고 믿기

ETag는 본문 재전송을 줄이는 데 도움을 줘요. 하지만 stale이 된 뒤에는 확인 왕복이 필요할 수 있어요. 해시 파일명과 긴 max-age가 가능한 정적 자산이라면, 재검사보다 URL 버전 변경 전략이 더 효율적일 수 있어요.

4. Last-Modified만으로 아주 잦은 변경을 정확히 잡으려 하기

Last-Modified는 날짜 기반이라 사람이 읽기 쉽지만, 매우 짧은 시간 안에 같은 리소스가 여러 번 바뀌는 경우에는 충분히 정밀하지 않을 수 있어요. 그런 장면에서는 서버가 정확한 ETag를 제공하는 쪽이 더 나아요.

5. 304 응답 헤더를 무시하기

304에는 본문이 없더라도 메타데이터가 올 수 있어요. 캐시는 304 응답의 헤더를 바탕으로 저장된 응답의 헤더 정보를 갱신할 수 있어요. 그래서 304를 볼 때도 Cache-Control, ETag, Date, CDN 상태 헤더를 같이 봐야 해요.

실제 장면에서는 이렇게 좁혀봐요

캐시 재검사 문제를 볼 때는 아래 순서로 보는 게 좋아요. 표로 다시 정리하면 이렇게 읽을 수 있어요. 이 중에서 Vary도 중요해요. 같은 URL이라도 언어, 압축, 형식이 다르면 다른 representation일 수 있거든요. ETag 비교는 어떤 representation을 비교하는지와 함께 봐야 해요.

한 줄로 읽어보면

실제 헤더 몇 개를 놓고 읽어볼게요.

1. 같은 버전이라 304가 온 장면

이건 서버가 “네가 가진 app-v17을 계속 써도 돼요”라고 말한 장면이에요. 본문은 다시 내려오지 않지만, 캐시는 기존 본문을 재사용할 수 있어요.

2. 버전이 바뀌어 200이 온 장면

이건 서버가 “지금은 app-v18이에요. 새 본문을 받으세요”라고 말한 장면이에요. 캐시는 새 본문과 새 validator를 저장할 수 있어요.

3. 날짜로 재검사한 장면

이건 해당 시각 이후로 선택된 표현이 바뀌지 않았다고 볼 수 있는 장면이에요. 다만 가능하면 ETag가 있는지 먼저 보는 습관이 좋아요.

자, 정리해볼까요?

ETagLast-Modified는 cached response를 나중에 다시 확인하기 위한 validator예요.If-None-Match는 클라이언트가 가진 ETag를 들고 “이 버전이 아니면 새 본문을 주세요”라고 묻는 조건부 요청이에요.서버의 현재 ETag와 조건이 맞으면 304 Not Modified가 오고, 캐시는 기존 본문을 재사용할 수 있어요.바뀌었으면 304가 아니라 200 OK와 새 본문, 새 validator가 와요.If-None-MatchIf-Modified-Since가 같이 있으면 ETag 기반 조건을 먼저 보는 쪽으로 읽으면 돼요.304는 에러가 아니라 본문 재전송을 줄이는 정상적인 캐시 재검사 흐름이에요.
Cache-Control언제까지 그냥 믿을지를 말하고, ETag와 조건부 요청은 오래됐을 때 어떻게 다시 확인할지를 말해요. 그래서 캐시 문제를 읽을 때는 freshness와 validation을 함께 봐야 해요.

이어서 보면 좋은 글

이어서 볼 질문

다음에는 캐시가 아예 되면 안 되는 응답을 볼 거예요. Cookie, Authorization, Set-Cookie, private, no-store가 왜 캐시 가능성과 함께 읽혀야 하는지 이어서 열어볼 수 있어요.