안녕하세요, 페미위키 개발팀 낙엽1124입니다. 페미위키는 로그인하지 않은 방문자에게 보여 줄 문서를 caddy-mwcache라는 직접 만든 캐시에 담아 두고 있는데요, 이번 글에서는 이 캐시가 어떻게 만들어졌는지와, 얼마 전 캐시가 문서를 17개밖에 담지 못하던 버그를 고친 일을 소개드리려고 합니다. 이 글은 웹 서버와 HTTP 캐시에 대해 아는 분을 대상으로 합니다.
배경
미디어위키는 문서가 바뀌면 그 문서의 주소로 PURGE 요청을 보내 앞단의 캐시에게 옛 내용을 지우라고 알려 주는 동작이 있습니다. 위키백과는 이 요청을 Varnish로 받는데,[1] 페미위키는 서버가 한 대뿐이라 캐시 서버를 따로 두기 어려웠고 웹 서버로는 이미 Caddy를 쓰고 있었습니다. 그래서 2020년 12월 CPU 사용량이 갑자기 늘어난 일을 계기로 로그인하지 않은 방문자의 요청을 캐시하기로 하고,[2] Caddy 모듈로 직접 만들어 2021년 4월에 첫 버전을 내놓았습니다.[3][4]
캐시 구조
응답은 Go로 된 메모리 캐시 라이브러리 Ristretto에 담고, 캐시할지 말지는 위키미디어의 Varnish와 비슷한 규칙으로 정하게 했습니다. 예를 들어 세션이나 토큰 쿠키, Authorization 헤더가 있는 요청은 로그인한 사용자일 수 있으니 캐시를 거치지 않고,[5] 응답에 private, no-cache, no-store나 Set-Cookie가 붙어 있으면 캐시하지 않으며, 얼마나 오래 보관할지는 미디어위키가 보내는 s-maxage를 따릅니다.
sessionCookiePattern = regexp.MustCompile(`([sS]ession|Token)=`)
func hasSessionCookie(r *http.Request) bool {
cookie := r.Header.Get("Cookie")
return sessionCookiePattern.MatchString(cookie)
}
참고로 처음 만들었을 때는 PURGE가 캐시까지 오지 않는 문제가 있었는데요, 미디어위키는 $wgInternalServer가 비어 있으면 https://…인 $wgServer로 PURGE를 보내고, Caddy는 이걸 프록시 터널(CONNECT) 요청으로 받아 HTTPS로 넘겨 버렸기 때문입니다.[6] 지금은 $wgInternalServer를 http://127.0.0.1:80으로 두어 해결했습니다.
17개만 담던 캐시
2026년 9월, 대문은 늘 캐시에서 나가는데 보통 문서는 몇 번을 다시 열어도 새로 렌더된다는 것을 알게 됐습니다. 같은 주소를 두 번 열어 응답마다 붙는 x-request-id를 비교해 볼 때 캐시에서 나간 응답은 캐싱할 때의 값이 그대로 나오기 때문에 아래와 같이 구분할 수 있었습니다.[7]
/w/페미위키:대문 x-request-id: 539669cff53359cf8874d69d
/w/페미위키:대문 x-request-id: 539669cff53359cf8874d69d
/w/설경구 x-request-id: b32ef97ff7d12cf14c30ce70
/w/설경구 x-request-id: fd9e121cfaf1ee53b06a97ca
원인은 두 가지가 겹쳤는데, 우선 캐시에 넣을 때 항목마다 비용을 1로 매기고 있어서, 설정의 max_cost가 바이트가 아닌 항목 개수로 쓰이고 있었습니다. 또 Ristretto는 IgnoreInternalCost를 켜지 않으면 항목마다 내부 구조체 크기인 56을 비용에 더하기 때문에[8] 비용 1인 항목이 실제로는 57을 차지해서 max_cost를 57로 나눈 만큼만 담을 수 있었습니다. MaxCost가 1,000인 캐시에 항목 5,000개를 넣어 보면 이렇게 됩니다.
held: 17
hit: 17 miss: 4983 keys-added: 5000 keys-evicted: 4983 cost-added: 285000 ... hit-ratio: 0.00
그래서 비용을 응답의 바이트 수로 매기는 max_cost_bytes 설정을 새로 만들었습니다. 다만 오픈 소스로 공개한 caddy-mwcache를 쓰는 사람이 어딘가 있을지도 모르니 하위 호환을 위해 기존 max_cost는 그대로 두었고, 바이트로 셀 때는 내부 구조체 크기도 실제로 쓰는 메모리이니 함께 세도록 했습니다.[9]
cost := int64(1)
if m.costInBytes {
cost = int64(len(val))
}
캐시 적중률
캐시를 17개에서 1,000개로, 다시 64MiB로 늘렸는데도 실제 캐시에서 나간 요청 수는 5분에 155~195건으로 거의 그대로였습니다. 원인 파악을 위해 9월 25일 오전 11시부터 한 시간 동안 들어온 문서 요청을 세어 확인했습니다.[10]
| 요청 | 1,691 |
| 서로 다른 주소 | 1,545 |
| 같은 주소를 두 번 이상 요청한 경우 | 146 (8.6%) |
| 캐시에서 나간 요청 | 96 |
| 캐시 사용량 | 64MiB 중 19.7MB |
즉, 같은 주소가 다시 요청된 경우가 146건뿐이니 캐시를 아무리 키워도 그보다 많이 히트할 수가 없어 보였습니다. 주소 1,498개가 딱 한 번씩만 요청됐는데, 이건 크롤러가 위키를 처음부터 끝까지 훑을 때의 모양 그대로이고 일례로 한 IP는 3.5~5.7초 간격으로 247건을 요청하고 있었습니다.
그 밖의 개선
적중률을 올리지는 못했지만, 그 과정에서 다음과 같은 개선을 수행하였습니다.
- 캐시에서 꺼낸 응답도 Caddy가 매번 다시 gzip으로 압축하고 있어서, 운영 서버에서 Caddy가 쓰는 CPU의 60%쯤이 압축이었습니다. 지금은 담을 때 한 번만 압축해 두고 그대로 내보냅니다.[11]
load.php는 세션을 쓰지 않으니(MW_NO_SESSION) 세션 쿠키가 있어도 캐시에서 내보내게 했습니다. 회원가입 문서에서 쿠키를 받은 크롤러가 스타일시트를 몇 초에 한 번씩 새로 만들게 하고 있었기 때문입니다.[12]- 위키미디어의
static.php를 따라하여,[13] 스킨 아이콘처럼 주소에 버전 해시가 붙은 정적 파일은 디스크의 파일과 해시가 맞으면 1년 동안 캐시해도 된다고 알리게 했습니다. - 캐시 키에 호스트를 넣어서, 나중에 호스트마다 다른 화면을 보여 주게 되더라도 다른 호스트의 문서가 섞여 나가지 않게 했습니다.[14][15]
- 같은 문서를 여러 요청이 동시에 찾으면 미디어위키에는 한 번만 요청하고,[16] 브라우저가 가진 사본이 그대로면 본문 없이
304로 답하며Age헤더도 붙이게 개선했습니다.[17] - 문서를 방문자의 언어별로 따로 담아서 최근 켠 인터페이스 언어 자동 선택과 함께 쓸 수 있게 했습니다.[18]
맺음말
캐시를 처음 도입할 때만 해도 위키에서 캐시는 클수록 좋다고 생각했고, 또 막연히 효과가 있겠거니 하고 있었는데 최근 페미위키는 크롤러가 대부분인 상황 때문에 효과가 거의 없다는 것을 처음 숫자로 확인할 수 있었습니다. 지금은 페미위키 앞에 CloudFront도 두고 있어서 load.php 같은 요청은 대부분 CloudFront에서 나가고, caddy-mwcache는 그 뒤에서 나머지 문서들을 받게 하는 등 많은 개선이 있었습니다.
caddy-mwcache는 AGPL 라이선스로 공개하고 있으니, 서버 한 대로 미디어위키를 운영하시는 분이라면 도입을 고려해봐 주시면 반갑겠습니다.
출처
- ↑ “Manual:Varnish caching”. 《MediaWiki》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Cache anonymous request · Issue #219 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Implement cache · Pull Request #3 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Deploy caddy-mwcache · Pull Request #491 · femiwiki/docker-mediawiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache/mwcache.go at main · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “테스트 서버 상에서 caddy-mwcache 사용 중 purge가 안 됨 · Issue #239 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache: make max_cost a byte budget · Issue #452 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “ristretto/cache.go at v2.4.2 · dgraph-io/ristretto”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: add max_cost_bytes, a budget spent in bytes · Pull Request #141 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache: caddy reload drops the whole cache · Issue #454 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: store pages gzipped · Pull Request #164 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: serve load.php from the cache to requests with a session cookie · Pull Request #167 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: add a static block that caches static files by their version hash · Pull Request #160 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “The cache key has no host · Issue #170 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat!: put the request's host in the cache key · Pull Request #171 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: coalesce concurrent misses for one page · Pull Request #197 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: answer conditional requests on hits and send Age · Pull Request #198 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: store a variant per Accept-Language with vary_accept_language · Pull Request #207 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
