- 발행일
Jinja가 캐시 버스터를 컴파일 시점에 굳혔다 — JS를 고쳐도 브라우저가 옛 파일을 쓰던 이유
Jinja가 캐시 버스터를 컴파일 시점에 굳혔다
JS 파일을 고치고 새로고침했는데 화면이 그대로입니다. 강력 새로고침을 하면 바뀌어요. 그러다 한참 뒤 다른 일로 서버를 다시 띄우면, 그때부터는 보통 새로고침으로도 잘 바뀝니다.
두어 번은 제가 저장을 안 했나 싶어 넘어갔는데 패턴이 보였습니다. 서버 프로세스가 살아 있는 동안에는 JS·CSS 수정이 브라우저에 안 닿습니다. 파이썬 파일을 아무거나 건드려 runserver가 재기동하면 그때 한 번 풀리고, 다시 굳어요.
이 프로젝트에는 정확히 그걸 막으라고 만들어 둔 필터가 이미 있었습니다. 템플릿 48개 파일이 이렇게 부르고 있었고요.
<script src="{{ 'physical_architecture/js/editor/context.js' | static | add_hash }}"></script>
캐시를 깨라고 만든 필터가 캐시를 굳히고 있었던 겁니다.
요청마다 새 값을 만들려던 코드
당시 구현은 한 줄이었습니다.
def add_hash(url_str):
return f'{url_str}?k={str(round(time.time() * 1000))}'
읽으면 "부를 때마다 현재 시각(ms)을 붙인다"로 읽힙니다. 실제로 그렇게 동작한다면 캐시가 아예 안 걸리는 과한 구현이지, 안 깨지는 구현은 아니에요. 그런데 화면에 나오는 값은 한 서버 프로세스 안에서 계속 같았습니다.
?k=1756123380145 ← 새로고침해도
?k=1756123380145 ← 몇 분 뒤에도
?k=1756123380145 ← 다른 화면에서도 (같은 파일이면)
time.time()이 렌더마다 안 불린다는 뜻입니다.
Jinja는 상수만 들어오는 표현식을 컴파일 시점에 한 번 접는다
Jinja는 템플릿을 파이썬 코드로 컴파일하면서 입력이 전부 상수인 표현식을 미리 계산해 결과를 박아 넣습니다. constant folding이에요.
호출부를 다시 봅니다.
{{ 'physical_architecture/js/editor/context.js' | static | add_hash }}
'...js'는 리터럴이고, static도 add_hash도 그 리터럴 하나만 받는 평범한 필터입니다. 변수가 하나도 없어요. Jinja 입장에서는 이 표현식의 값이 렌더마다 달라질 이유가 없으므로, 템플릿이 처음 컴파일될 때 한 번 계산해서 문자열로 굳혀 둡니다.
그 "한 번"이 언제냐가 증상의 전부였습니다. 템플릿 컴파일은 그 템플릿을 처음 그릴 때 일어나고 결과는 프로세스가 살아 있는 동안 유지되니까, ?k=에는 서버가 뜬 직후의 시각이 박힙니다.
여기에 runserver의 성질이 겹칩니다. 개발 서버는 파이썬 파일이 바뀔 때만 다시 뜹니다. JS·CSS만 고치면 프로세스가 그대로라 컴파일된 템플릿도 그대로고, ?k=도 그대로예요. 브라우저는 URL이 같으니 캐시를 씁니다. 고친 줄 알고 한참 헤매게 되는 조합입니다.
무엇을 바꿀 것인가
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| ① 그대로 두고 개발 중엔 강력 새로고침 | 코드 변경 0 | 사람의 규율에 의존. 운영에서도 값이 프로세스 수명에 묶여 배포 후 캐시가 안 깨질 수 있음 | 기각 |
| ② 호출부에 변수를 하나 섞어 폴딩 회피 | 필터는 안 건드림 | 48개 파일 호출부를 전부 고쳐야 하고, 새로 쓰는 사람이 빠뜨리면 조용히 되돌아감 | 기각 |
③ @pass_context로 폴딩만 막고 값은 요청 시각 | 폴딩은 확실히 풀림 | 요청마다 URL이 달라져 운영에서 정적 파일 캐시가 통째로 죽음 | 기각 |
④ @pass_context + 값은 파일 수정시각 | 파일이 바뀔 때만 URL이 바뀜 | 렌더마다 stat 한 번, 파일을 못 찾으면 캐시가 안 깨짐 | 채택 |
③이 아깝습니다. 증상만 보면 ③으로 끝나는데, 그러면 원래 한 줄짜리 구현이 의도했던 그대로 동작하게 될 뿐이에요. 요청마다 새 값을 붙이는 건 캐시 무력화지 캐시 갱신이 아닙니다. 증상을 없애는 것과 원래 하려던 일을 하게 만드는 건 다릅니다.
@pass_context는 context를 안 써도 필요하다
바꾼 코드에서 제일 설명이 필요한 부분입니다.
@pass_context
def add_hash(context, url_str):
"""정적 파일 URL 에 파일 수정시각을 붙여 캐시를 깬다.
⚠️ `@pass_context` 는 context 를 안 써도 반드시 필요하다. `'js/x.js' | static | add_hash`
는 입력이 전부 상수라 **Jinja 가 컴파일 시점에 한 번 접어 버린다**(constant folding).
그대로 두면 예전처럼 서버가 뜬 시각이 URL 에 굳어, JS·CSS 만 고쳤을 때 브라우저가 계속
옛 파일을 쓴다. 컨텍스트를 받는 필터는 접히지 않아 렌더마다 다시 계산된다.
"""
stamp = _static_mtime(url_str)
return f"{url_str}?k={stamp}" if stamp else url_str
context 인자를 본문에서 한 번도 안 씁니다. 안 쓸 거면 왜 받느냐 — 받는다는 사실 자체가 폴딩을 막기 때문입니다. 컨텍스트에 의존하는 필터는 렌더 시점의 상태에 따라 값이 달라질 수 있으므로 Jinja가 미리 접지 않아요.
이건 나중에 읽는 사람이 "안 쓰는 인자네" 하고 지우기 딱 좋은 자리라, 지우면 무엇이 되돌아오는지를 docstring에 적어 뒀습니다. 데코레이터 한 줄을 지우면 증상은 조용히 돌아오고, 돌아온 증상은 이번처럼 며칠 헤매야 원인에 닿습니다.
값은 파일 수정시각, 못 찾으면 3단으로 물러난다
def _static_mtime(url_str):
"""URL 에 대응하는 파일의 수정시각(ms). 못 찾으면 None."""
prefix = settings.STATIC_URL or ""
if not prefix.startswith("/"):
prefix = "/" + prefix
path = url_str.split("?", 1)[0]
if not path.startswith(prefix):
return None
relative = path[len(prefix):]
candidate = finders.find(relative)
if not candidate and getattr(settings, "STATIC_ROOT", None):
# 운영은 collectstatic 결과만 있고 finders 가 못 찾는 경우가 있다
candidate = os.path.join(settings.STATIC_ROOT, relative)
try:
return round(os.path.getmtime(candidate) * 1000)
except (TypeError, OSError):
return None
finders.find() → STATIC_ROOT 직접 조합 → 그래도 없으면 None. None이면 ?k= 없이 원래 URL을 그대로 돌려줍니다. 개발과 운영의 정적 파일 배치가 다르기 때문에 필요한 갈래입니다 — STATIC_ROOT는 이 프로젝트에서 운영 설정에만 있고, 개발에서는 finders가 답을 냅니다.
수정시각을 고른 이유는 파일이 바뀔 때만 URL이 바뀌기 때문입니다. 개발 중에는 저장하는 순간 값이 달라져 즉시 반영되고, 운영에서는 배포 전까지 값이 고정이라 브라우저 캐시가 그대로 살아 있어요. 요청 시각을 쓰던 원래 의도와 정확히 반대 방향입니다.
stat을 렌더마다 부르는 것도 의도한 선택이라 주석에 남겼습니다.
# 정적 파일 경로 → 수정시각. 같은 요청 안에서 같은 파일을 여러 번 물어도 stat 은 한 번이면
# 되지만, 캐싱하면 개발 중 파일을 고쳐도 반영이 안 된다 — stat 은 싸므로 매번 본다.
캐싱하는 순간 이 글의 버그를 다른 층에서 다시 만드는 셈이에요.
검증
- 서버를 띄운 채로
static/js/instanceTree.js를 저장 → 보통 새로고침만으로?k=값이 바뀌고 새 파일이 오는 것 확인. 고치기 전에는 같은 절차로 값이 안 바뀌는 것을 먼저 재현했습니다. - 파일을 안 건드리고 새로고침 10회 →
?k=불변 확인. ③안이었다면 여기서 매번 달라집니다. ?k=가 붙은 URL을 직접 요청해 200과 파일 내용 일치 확인.- 존재하지 않는 정적 경로를 필터에 통과시켜
?k=없는 원래 URL이 나오는 것 확인 — 500이 아니라 조용히 물러나는 쪽. - 변경 범위는
sdms/jinja2.py한 파일 38줄 추가·3줄 삭제. 템플릿 48개 파일 호출부는 한 글자도 안 고쳤습니다.
남은 것 · 한계
import time이 죽은 채로 남아 있습니다.time.time()을 걷어냈는데 상단 import는 안 지웠어요.sdms/jinja2.py에서time.을 쓰는 곳은 이제 없습니다. 지우면 되는 한 줄인데 이 글을 쓰다가 알았습니다.- 운영에서
collectstatic결과의 수정시각은 배포 시각입니다. 내용이 안 바뀐 파일도 배포할 때마다 URL이 달라져 다시 내려받게 돼요. 내용 해시를 쓰는ManifestStaticFilesStorage가 이 문제의 정석인데, 이 프로젝트는STATICFILES_STORAGE를 지정하지 않은 상태로 두고 수정시각으로 갔습니다. 개발 편의를 위해 운영의 재다운로드를 감수한 선택이고, 지금은 그 비용을 안 재봤습니다. - 폴백의 마지막 단계가 조용합니다. 파일을 못 찾으면
?k=없이 원래 URL이 나가는데, 그러면 그 파일만 캐시가 영영 안 깨집니다. 화면은 정상으로 보이고 로그도 안 남아요. 경고 로그 한 줄이 있어야 하는 자리입니다. - 렌더마다
stat을 부릅니다. 정적 파일이 많은 화면일수록 syscall이 늘어나는데, 몇 번인지 재보지 않았습니다. 캐싱하면 개발 반영이 죽는다는 이유로 안 했지, 비용을 확인하고 괜찮다고 판단한 게 아닙니다. - 같은 폴딩 함정이 이 프로젝트의 다른 필터에도 있는지 안 훑었습니다. 상수만 받는 사용자 정의 필터는 전부 같은 조건이고, 값이 렌더마다 달라져야 하는 필터가 또 있다면 지금도 조용히 굳어 있을 겁니다.
관련 글: Tailwind v3에서 v4로 170개 파일을 옮겼다 · 관리자 화면 12개가 익명에 열려 있었다