본문 바로가기
Infra2026년 9월 23일17분 읽기

nginx 캐시 때문에 앱 업데이트가 "최신"으로 뜬 이유 — latest.json 24시간 캐시 사건

YS
김영삼
조회 13
nginx 캐시 때문에 앱 업데이트가 "최신"으로 뜬 이유 — latest.json 24시간 캐시 사건

새 버전을 올렸는데 데스크톱 앱이 계속 "최신"이라고 답한다면, 업데이트 매니페스트가 어딘가에서 캐시되고 있을 확률이 가장 높다. 내 경우 범인은 nginx가 /uploads/ 전체에 강제로 붙이던 24시간짜리 Cache-Control 헤더였다.

서버의 파일도, 서명도, 설치 파일도 전부 멀쩡했다. 그런데 사용자 앱은 하루 전 버전 정보를 붙들고 있었다. 이 글은 그 원인을 찾은 과정과, 정적 파일 캐시 정책에서 "자주 바뀌는 작은 파일"을 따로 떼어내는 방법을 정리한 기록이다.

3줄 요약
  • Tauri updater가 폴링하는 latest.json이 nginx의 max-age=86400 규칙에 같이 묶여 최대 하루 동안 옛 버전 정보를 돌려주고 있었다.
  • 정규식 location은 접두사 location보다 우선하므로, latest.json만 정규식으로 잡아 no-cache, no-store로 덮으면 나머지 설치 파일 캐시는 그대로 둘 수 있다.
  • 캐시는 파일 확장자가 아니라 "얼마나 자주 바뀌는가"로 나눠야 한다. 버전이 박힌 파일은 영구 캐시, 매니페스트는 캐시 금지가 맞다.

PART 01증상: 새 버전을 올렸는데 "최신입니다"

나는 youngsam.net에서 Tauri로 만든 데스크톱 앱 두 개를 배포한다. 터미널 앱 VibeTerm과 naru다. Tauri에는 자체 업데이터가 있어서, 앱이 정해진 주소의 매니페스트 파일을 읽고 거기 적힌 버전이 지금 버전보다 높으면 업데이트를 안내한다. 내 경우 그 주소가 youngsam.net/uploads/<앱>/latest.json이다.

매니페스트는 작은 JSON이다. 최신 버전 번호, 릴리스 노트, 플랫폼별 설치 파일 주소와 서명이 들어 있다. 새 버전을 배포할 때 나는 설치 파일을 올리고 이 JSON을 새 내용으로 바꾼다. 순서만 지키면 문제가 날 구석이 별로 없어 보이는 구조다.

삽질 기록 · 업데이트 버튼이 거짓말을 했다
새 버전을 올렸는데, 앱에서 "업데이트"를 누르면 돌아오는 답은 "최신 버전입니다"였다. 처음엔 내가 매니페스트를 잘못 올렸나 싶어 서버의 파일을 열어 봤다. 버전 번호는 새 것이었다. 서명 문자열도 맞았고 설치 파일도 제자리에 있었다. 브라우저로 주소를 열어 보면 새 JSON이 보였다. 그런데 앱만 옛날 이야기를 했다. 서버 쪽에 이상이 하나도 없는데 결과가 틀리니, 그 사이 어딘가에서 응답이 재사용되고 있다는 뜻이었다. 응답 헤더를 찍어 보고 나서야 Cache-Control: public, max-age=86400가 눈에 들어왔다.

이게 함정이었다. 브라우저로 열었을 때는 내가 새로고침을 하거나 처음 받는 상황이라 새 내용이 보였지만, 앱 쪽 HTTP 클라이언트는 그 헤더를 근거로 이전 응답을 계속 써도 된다고 판단할 수 있다. 앱 하나만의 문제가 아니라, 중간에 캐시가 낄 수 있는 모든 경로에서 같은 일이 생길 수 있다.

PART 02원인: /uploads/ 전체에 걸린 24시간 강제 캐시

nginx 설정을 열어 보니 이유는 단순했다. /uploads/ 아래 모든 응답에 대해 앱이 보내는 Cache-Control 헤더를 숨기고, 대신 public, max-age=86400을 강제로 붙이고 있었다. 업로드 폴더에는 이미지와 설치 파일 같은 큰 파일이 대부분이라, 한 번 받으면 하루 정도 재사용하라는 규칙은 그때는 합리적이었다.

문제는 매니페스트도 같은 폴더에 있었다는 점이다. 규칙은 파일 종류를 가리지 않았다. 설치 파일과 똑같이 매니페스트도 "하루 동안 다시 묻지 말 것"이라는 꼬리표를 달고 나갔다. 결국 배포 직후 하루 동안은, 이미 한 번 매니페스트를 받은 클라이언트가 새 버전의 존재를 모를 수 있었다.

서버는 전부 정상인데 결과만 틀린 전형적인 캐시 문제였다.
구분서버 상태클라이언트가 본 것
설치 파일새 버전 파일 정상 업로드애초에 요청하지 않음
서명새 서명으로 교체 완료옛 매니페스트 안의 옛 서명
latest.json새 버전 번호로 갱신최대 24시간 전 내용 재사용
응답 헤더public, max-age=86400 강제"아직 신선하다"고 판단

proxy_hide_header로 앱의 헤더를 숨기고 add_header로 덮는 방식은 강력하다. 앱 코드에서 무엇을 보내든 nginx가 최종 결정을 내린다. 그 강력함 때문에 예외가 필요한 파일까지 한꺼번에 묶어버린 것이다. 솔직히 처음 설정할 때는 같은 폴더에 매니페스트가 들어올 거라고 생각하지 못했다.

영삼
내 생각
캐시 정책은 "이미지냐 JSON이냐" 같은 파일 종류로 나누면 안 된다. 기준은 그 URL의 내용이 얼마나 자주 바뀌는가 하나다. 같은 주소에서 내용이 바뀌는 파일과, 주소 자체가 버전마다 달라지는 파일은 완전히 다른 취급을 받아야 한다. 나는 이 사건 이후 캐시 규칙을 볼 때 확장자보다 "이 주소는 덮어쓰기가 되는가"를 먼저 묻는다.

PART 03해결: 정규식 location으로 매니페스트만 떼어내기

고치는 방법은 두 가지가 떠올랐다. 하나는 /uploads/의 강제 캐시를 아예 걷어내는 것, 다른 하나는 매니페스트만 예외로 빼는 것이다. 앞의 방법은 설치 파일과 이미지까지 매번 재검증하게 만들어 손해가 크다. 그래서 매니페스트만 정확히 집어내기로 했다.

여기서 nginx의 location 선택 규칙이 도움이 된다. nginx는 요청 주소에 맞는 접두사 location 중 가장 긴 것을 기억해 둔 뒤, 정규식 location을 설정 파일 순서대로 검사하고, 맞는 정규식이 있으면 그쪽을 쓴다(접두사 쪽에 ^~나 =를 붙인 경우는 예외다). 그러니 /uploads/ 접두사 블록은 그대로 두고, 그 옆에 매니페스트만 잡는 정규식 블록을 하나 추가하면 된다.

nginx 설정 예시 (업스트림 이름은 예시)
# 업데이트 매니페스트: 같은 주소에서 내용이 바뀌므로 캐시 금지
location ~ ^/uploads/[^/]+/latest\.json$ {
    proxy_hide_header Cache-Control;
    add_header Cache-Control "no-cache, no-store, must-revalidate" always;
    proxy_pass http://app_upstream;   # 기존 /uploads/ 블록과 같은 전달 설정
}

# 나머지 업로드 파일: 기존처럼 24시간 캐시
location /uploads/ {
    proxy_hide_header Cache-Control;
    add_header Cache-Control "public, max-age=86400" always;
    proxy_pass http://app_upstream;
}

정규식 ^/uploads/[^/]+/latest\.json$은 "업로드 폴더 바로 아래 앱 이름 폴더 하나, 그 안의 latest.json"만 잡는다. [^/]+로 폴더 한 단계만 허용했기 때문에 다른 위치에 우연히 같은 이름의 파일이 있어도 엉뚱하게 걸리지 않는다. 점은 이스케이프해야 한다. 안 하면 latestXjson 같은 주소까지 맞는다.

always도 빼먹으면 안 된다. 이게 없으면 add_header는 성공 응답 같은 일부 상태 코드에만 헤더를 붙인다. 매니페스트가 잠깐 오류를 낸 순간의 응답까지 캐시에 남지 않게 하려면 모든 응답에 붙이는 편이 안전하다. 설정을 바꾼 뒤에는 nginx -t && systemctl reload nginx로 문법을 먼저 검사하고 무중단으로 다시 읽었다.

주의할 점 location 블록 안에 add_header를 하나라도 쓰면 바깥(server 블록)에서 상속받던 add_header들이 그 블록에서는 적용되지 않는다. 보안 헤더를 server 수준에 몰아 두었다면, 새로 만든 정규식 블록에서 빠지지 않았는지 꼭 응답 헤더로 확인해야 한다.

PART 04확인: 헤더 한 줄로 끝나는 검증

설정을 바꿨다고 끝이 아니다. 실제로 나가는 헤더를 눈으로 봐야 한다. 나는 아래 두 줄로 확인했다. 하나는 매니페스트, 하나는 같은 폴더의 설치 파일이다. 둘의 결과가 달라야 정상이다.

1
매니페스트 헤더 확인
curl -sI https://youngsam.net/uploads/<앱>/latest.json | grep -i cache-control 결과가 no-cache, no-store, must-revalidate인지 본다.
2
설치 파일 헤더 확인
같은 폴더의 버전별 설치 파일 주소로 같은 명령을 돌려 여전히 max-age=86400이 붙는지 본다. 이쪽까지 no-cache가 되면 정규식이 너무 넓은 것이다.
3
앱 재시작 후 업데이트 확인
이미 옛 응답을 들고 있던 클라이언트는 앱을 다시 시작하면 새 매니페스트를 받는다. 업데이트 버튼이 새 버전을 안내하는지 확인한다.
4
보안 헤더 누락 점검
새 location 블록 응답에 기존 보안 헤더가 그대로 붙는지 함께 본다.

설정을 바꾼 뒤에는 앱을 다시 시작하면 새 매니페스트를 받아 업데이트 안내가 정상으로 떴다. 결국 문제는 처음부터 끝까지 헤더 한 줄이었다. 서버 파일을 몇 번이나 다시 열어 본 시간이 아까웠다. 이 일 이후로 "서버는 맞는데 결과가 틀리다"는 느낌이 들면 제일 먼저 응답 헤더부터 찍는다.

영삼
내 생각
업데이트 확인 실패는 다른 장애보다 더 위험하다고 본다. 페이지가 안 뜨면 누군가 알려주지만, 업데이트가 "최신"이라고 거짓말을 하면 아무도 모른다. 사용자는 정상 동작이라고 믿고 옛 버전을 계속 쓴다. 보안 수정이 담긴 버전이라면 그 하루가 그대로 노출 기간이 된다. 눈에 안 띄는 실패일수록 설정 단계에서 막아야 한다.

PART 05반대 방향의 사건: 웹폰트는 너무 짧게 캐시하고 있었다

비슷한 일이 하나 더 있었다. 이번엔 정반대였다. 사이트의 웹폰트(/fonts/*.woff2)가 max-age=0으로 나가고 있었다. 방문할 때마다 브라우저가 서버에 "이 폰트 아직 그대로냐"고 다시 묻는 구조다. 응답이 304로 짧게 끝나긴 해도 왕복 시간만큼 폰트 적용이 늦어진다.

그 결과 페이지가 대체 폰트로 먼저 그려졌다가 잠시 후 바뀌는 일이 잦았다. 나는 "폰트가 깨진 것 같다"는 말을 들었다. 실제로 깨진 건 아니고 늦게 뜬 것이다. 사용자 입장에서는 그 둘을 구분할 이유가 없다. 그냥 사이트가 이상해 보인다.

latest.json너무 길었다
  • 같은 주소, 내용이 수시로 바뀜
  • 24시간 캐시가 붙어 옛 정보 재사용
  • 해결: no-cache, no-store
웹폰트 woff2너무 짧았다
  • 한 번 정하면 사실상 안 바뀜
  • max-age=0이라 매번 재검증
  • 해결: max-age=31536000, immutable

처음에는 nginx가 public 폴더를 root로 직접 서빙하게 바꾸려 했다. 그런데 웹앱 계정 소유 파일을 nginx가 읽을 권한이 없어서 404가 났다. 권한을 넓히는 건 내키지 않았다. 그래서 매니페스트 때와 똑같은 방식으로 갔다. 요청은 그대로 앱으로 프록시하고, 폰트 경로만 헤더를 덮어써서 max-age=31536000, immutable로 바꿨다.

두 사건을 나란히 놓고 보니 교훈이 같았다. 문제는 캐시를 "켰냐 껐냐"가 아니라, 파일의 성격과 캐시 기간이 어긋났다는 데 있었다. 바뀌는 파일을 오래 붙잡았고, 안 바뀌는 파일을 매번 다시 물었다.

PART 06캐시를 "바뀌는 주기"로 나누는 법

이 두 번의 경험으로 내 캐시 규칙은 세 칸으로 정리됐다. 확장자나 폴더가 아니라 "그 주소의 내용이 바뀌는가, 바뀐다면 얼마나 자주인가"가 기준이다.

분류예시내가 쓰는 Cache-Control
같은 주소, 내용이 바뀜업데이트 매니페스트, 상태 확인 APIno-cache, no-store, must-revalidate 또는 no-store
주소에 버전이 박힘버전이 들어간 설치 파일, 해시가 붙은 빌드 산출물max-age=31536000, immutable
거의 안 바뀌지만 덮어쓸 수 있음업로드 이미지, 일반 첨부public, max-age=86400 정도
사실상 고정웹폰트max-age=31536000, immutable

버전 고정 URL은 영구 캐시가 맞다. 파일명에 버전이 들어 있으면 새 버전은 새 주소로 나가므로, 옛 주소의 내용이 바뀔 일이 없다. 이런 파일에 짧은 캐시를 거는 건 그냥 낭비다. 반대로 매니페스트처럼 "주소는 고정, 내용은 변동"인 파일은 절대 길게 캐시하면 안 된다. 매니페스트의 존재 이유가 "지금 최신이 뭔지"를 알려주는 것이기 때문이다.

이 구분을 지키면 매니페스트는 작고 자주 받고, 실제 무거운 설치 파일은 버전 주소로 오래 캐시되는 구조가 된다. 트래픽 부담도 거의 없다. 매니페스트는 수백 바이트에서 몇 KB 수준이라 매번 받아도 티가 안 난다.

버전 고정 URL 습관 설치 파일 이름에 버전을 꼭 넣어 두면 캐시 문제의 절반이 사라진다. 같은 이름으로 파일을 덮어쓰는 순간, 그 파일은 "자주 바뀌는 파일" 칸으로 옮겨 가야 한다는 걸 기억해야 한다.

PART 07그 뒤에 만든 것: no-store 업데이트 확인 API

Tauri 앱은 매니페스트 파일 방식을 쓰지만, BoltPDF 같은 Windows 프로그램은 사정이 다르다. 이쪽에는 DB를 직접 읽어 최신 버전을 알려주는 업데이트 확인 API를 따로 만들었다. 프로그램이 /api/update/<slug>?version=x 형태로 자기 버전을 보내면, 서버가 DB에 등록된 최신 버전과 비교해 답한다.

이 API의 응답에는 처음부터 Cache-Control: no-store를 붙였다. 매니페스트 사건을 겪은 뒤라 "업데이트 정보는 어떤 계층에서도 저장되면 안 된다"는 원칙을 설계 단계에서 넣은 것이다. DB를 고치는 순간 바로 반영되고, 중간에 낡은 응답이 끼어들 여지가 없다. 이 API의 자세한 설계는 이 글의 범위가 아니라 따로 정리한다.

영삼
내 생각
정적 파일 매니페스트 방식이 나쁘다는 얘기는 아니다. 단순하고, 서버 코드가 필요 없고, Tauri 같은 도구가 기본으로 지원한다. 다만 그 단순함은 캐시 정책이 정확할 때만 성립한다. 업로드 폴더에 일괄 캐시 규칙을 걸어 두는 사이트라면, 매니페스트를 올리는 순간 그 규칙에서 빼는 것까지가 배포 작업의 일부라고 생각한다.
이번 일로 정리한 원칙
  1. "서버는 정상인데 결과가 틀리다"면 응답 헤더부터 본다. 캐시는 대부분 거기서 드러난다.
  2. 캐시 기간은 파일 종류가 아니라 같은 주소의 내용이 바뀌는 주기로 정한다.
  3. 업데이트 매니페스트는 no-cache, no-store, 버전이 박힌 설치 파일과 웹폰트는 immutable로 길게 간다.
  4. nginx에서는 정규식 location이 접두사 location보다 우선한다는 점을 이용해 예외 파일만 정확히 떼어낸다.
  5. 예외 블록을 만든 뒤에는 curl -sI로 예외 파일과 일반 파일 양쪽의 헤더를 모두 확인한다.

자주 묻는 질문

Tauri 앱 업데이트가 새 버전을 못 찾고 계속 최신이라고 나오는 이유는 뭔가요?

서버의 매니페스트(latest.json)가 갱신됐는데도 중간 캐시가 옛 응답을 돌려주는 경우가 흔하다. 매니페스트 주소의 응답 헤더에 긴 max-age가 붙어 있는지 먼저 확인해 보자. 서명이나 버전 형식 문제라면 보통 다른 오류가 나지, 조용히 최신이라고 하지는 않는다.

nginx에서 특정 파일만 캐시를 끄려면 어떻게 하나요?

해당 파일만 맞는 정규식 location을 추가하고 그 안에서 Cache-Control을 no-cache로 덮어쓰면 된다. nginx는 맞는 정규식 location이 있으면 접두사 location보다 그쪽을 우선 사용한다. 단, 접두사 location에 ^~를 붙였다면 정규식 검사를 건너뛰므로 주의해야 한다.

add_header에 always를 꼭 붙여야 하나요?

always가 없으면 nginx는 200, 304 같은 일부 상태 코드 응답에만 헤더를 붙인다. 오류 응답까지 캐시 금지를 확실히 하려면 always를 붙이는 편이 안전하다. 업데이트 매니페스트처럼 잘못 캐시되면 곤란한 응답이라면 특히 그렇다.

업데이트 매니페스트와 설치 파일의 캐시 시간은 각각 얼마가 적당한가요?

같은 주소에서 내용이 바뀌는 매니페스트는 캐시하지 않는 것이 맞다. 파일명에 버전이 들어간 설치 파일은 내용이 바뀔 일이 없으므로 1년과 immutable 같은 긴 캐시가 적당하다. 같은 이름으로 덮어쓰는 설치 파일이라면 매니페스트처럼 짧게 다뤄야 한다.

웹폰트가 늦게 떠서 깨진 것처럼 보이는 건 캐시 문제일 수 있나요?

그럴 수 있다. 폰트에 max-age=0 같은 짧은 캐시가 붙어 있으면 방문할 때마다 재검증 요청이 생기고, 그 왕복 시간만큼 폰트 적용이 늦어진다. 파일이 바뀌지 않는 폰트라면 긴 max-age와 immutable을 주는 것이 일반적이다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록