통계 캐시: "스케줄러 필수"라고 써놓고 cache-aside로 뒤집은 이유
안녕하세요, 고준서입니다. 동아리움이라는 대학 동아리 지원 서비스의 백엔드를 팀(백엔드 3인, 프론트엔드 3인)에서 맡고 있고, 지원자 통계 기능은 제가 전담했어요. 지원폼마다 성별, 학부, 학번, 일자별 지원 추이를 집계해서 로그인 안 한 사람도 볼 수 있게 하는 API입니다. 로그인이 없으니 조회 부하를 막을 장치가 캐시뿐이었고, 설계 이슈에는 “스케줄러가 미리 계산해 두는 캐시는 선택이 아니라 필수”라고 적혀 있었습니다. 그런데 8월 11일 새벽에 그걸 뒤집었어요. 스케줄러 없이, 요청이 올 때 저장값이 있으면 쓰고 없으면 그 자리에서 계산해 30분 저장하는 방식(cache-aside)으로요. 이유는 넷이었습니다. 아무도 안 보는 폼은 계산 비용이 0이 되고, 서버가 늘어도 분산 락이 필요 없고, 스케줄러 방식도 결국 요청 시점 계산을 품고 있어서 두 방식이 포함 관계이고, Redis가 죽었을 때 낡은 값 대신 실시간 계산으로 넘어갈 수 있다는 것.
이 글은 그 넷이 어떻게 나왔는지, 이슈의 “필수”를 뒤집을 때 사고 이력을 근거로 삼지 않은 이유, 그리고 TTL과 장애 처리에서 제가 되물은 것을 이슈와 PR, 그날 대화 기록으로 따라갑니다. 조회량이나 지원폼 수, 집계 쿼리 시간 같은 숫자는 이 글에 없어요. 그때 재지 않았고, 규모상 괜찮다는 판단만 있었습니다. 코드 초안과 테스트 골격은 Claude Code가 쓰고 저는 PR을 읽고 되묻고 머지하는 쪽이었어요. 이 결정에 닿기 전에 통계 전체를 한 덩어리로 담은 PR(#336, 50개 파일 4,121줄)을 닫고 관심사별로 14개로 쪼갠 일이 있었는데, 그건 이 결정과 별개라 여기서는 “스케줄러 항목이 그 과정에서 맨 뒤로 밀렸다”까지만 적습니다.
이슈에는 “필수”라고 적혀 있었습니다
설계 이슈(#335)의 조회 부하 대책은 이랬습니다.
비로그인 조회를 허용하고 별도 조회 빈도 제한을 두지 않으므로, 사전 계산 캐시가 유일한 부하 방어 수단이다. 7번 단계는 선택이 아니라 필수다.
스케줄러가 10분마다 미리 집계해서 저장해 두고, 조회는 그 저장값만 읽게 하자는 뜻입니다. 8월 10일 밤에 나머지 PR을 다 머지하고 나니 남은 게 이 항목이었고, Claude가 두 가지 안을 내놓았어요. A는 스케줄러 없는 cache-aside, B는 이슈에 적힌 대로 스케줄러가 주기적으로 미리 계산하는 방식. 그러면서 3월에 팀에서 스케줄러가 정상 이미지를 지운 사고(핫픽스 커밋)가 있었으니 A를 권한다고 했습니다.
8월 11일 0시 1분에 제가 답했어요.
근데 구조적으로도 a가 더 적합해보이는데
그리고 Claude가 바로 A로 진행하려는 걸 끊었습니다.
아니아니 그냥 진짜 둘을 비교해봐. 그렇지않아? 아닌가? 사고이력이든 뭐든 잊고 진짜 시스템설계 입장에서 봐봐
사고가 났으니까 피하자는 건 이유가 아니라고 생각했거든요. 그 사고는 이미지 정리 스케줄러의 조건문 문제였지 스케줄러라는 방식의 문제가 아니었고, 그걸 근거로 삼으면 다음에 스케줄러가 맞는 자리에서도 피하게 됩니다. 그래서 나온 비교가 첫 문단의 넷입니다. 스케줄러는 아무도 안 보는 지원폼까지 10분마다 계산하는데, 요청 시점에 계산하면 안 보는 폼의 비용은 0이다. 서버가 두 대면 스케줄러는 인스턴스마다 돌아서 분산 락을 또 얹어야 한다. 스케줄러 방식도 결국 저장값이 없는 순간엔 요청 시점 계산이 필요하니 B는 A에 스케줄러를 더한 것이다. 그리고 Redis가 죽었을 때 A는 실시간 계산으로 넘어가면 그만이지만 B는 저장값이 낡은 채로 남는다. 이슈가 걱정했던 “만료 순간에 요청이 몰려 집계 쿼리가 한꺼번에 나가는 문제”는 단일 대학 동아리 서비스 규모에서는 실질 위험이 아니라고 봤고요. 이 마지막 판단이 숫자 없는 판단이라는 건 끝에 다시 적습니다.
flowchart LR
subgraph before["이슈 원안: 스케줄러가 미리 계산"]
S[스케줄러 10분 주기] -->|모든 지원폼 집계| R1[(Redis)]
Q1[조회 요청] -->|읽기만| R1
R1 -.->|저장값 없음| X[조회 불가]
end
subgraph after["채택: 요청 시점 계산"]
Q2[조회 요청] --> R2[(Redis)]
R2 -->|있음| Q2
R2 -->|없음 또는 장애| DB[(DB 집계)]
DB -->|30분 저장| R2
end
그림 1. 두 안의 흐름. 위쪽(채택안)은 Redis가 죽어도 DB 집계로 넘어갑니다.
그래서 A로 갔고, “필수”라고 적혀 있던 항목은 이슈 코멘트에 “cache-aside TTL 캐시로 대체”로 바뀌었습니다.
저장은 통째로, 마스킹은 응답에서
조회 코드는 이게 전부예요.
// StatisticsServiceImpl.java:57-61
StatisticsResponseDto full = cache.find(clubApplyFormId).orElseGet(() -> {
StatisticsResponseDto computed = calculate(form, StatisticsDimension.defaults());
cache.put(clubApplyFormId, computed);
return computed;
});
저장할 때는 마스킹하지 않은 전체 결과를 담고, 재식별 방지 마스킹과 항목 선택은 응답을 만들 때 합니다. 그래야 저장 키가 지원폼 하나로 끝나거든요. 키에는 statistics:v1: 접두사를 두어서 응답 모양이 바뀌면 v2로 올리게 했고, Jackson이 calculatedAt의 +09:00을 UTC로 바꿔 버리는 문제가 있어서 캐시 전용 ObjectMapper에서 ADJUST_DATES_TO_CONTEXT_TIME_ZONE을 껐습니다.
ttl 30분으로 하고
같은 날 오전에 PR(#353)을 보다가 이렇게 물었습니다.
ttl 30분으로 하고, 언제 산출된건지 프론트에 표시될 수 있게 넘겨주는건?
TTL은 저장값을 얼마나 오래 쓰느냐예요. 처음 기본값은 60초였는데, 30분으로 늘리는 대신 응답의 calculatedAt이 조회 시각이 아니라 그 저장값이 계산된 시각이라는 걸 계약에 명시했습니다. 화면에서 “몇 시 기준”이라고 보여줄 수 있게요. 그러자 CodeRabbit이 바로 하나를 잡았습니다. 저장값을 쓰는 경우에도 지원자가 적어서 가려진 응답에는 calculatedAt에 지금 시각을 넣고 있었어요. 방금 정한 계약과 정면으로 어긋나는 코드라 그것도 고쳤습니다.
Redis가 죽으면 어떻게 되는지는 8월 18일에 머지 전에 다시 물었습니다.
redis 연결 실패의 경우에는 어떻게 나타나?
redis 캐시는 각 동아리별로 다른거 맞지?
캐시 구현체가 예외를 밖으로 내보내지 않게 돼 있었어요.
// RedisStatisticsCache.java:47-51
} catch (Exception e) {
// 캐시 장애/역직렬화 실패는 조회를 막지 않는다. 미스로 간주해 실시간 계산으로 폴백한다.
log.warn("통계 캐시 조회 실패 clubApplyFormId={} : {}", clubApplyFormId, e.toString());
return Optional.empty();
}
저장 실패도 같은 식으로 삼키고 다음 요청이 다시 계산합니다. RedisStatisticsCacheTest의 find_onError_returnsEmpty, put_onError_doesNotThrow 두 테스트가 이 동작을 고정하고 있어요. 그걸 확인하고 머지했습니다.
남은 것
같은 지원폼에 같은 순간 요청이 들어오면 각자 계산하고 각자 저장합니다. 규모상 괜찮다는 판단만 있지 잰 적은 없어요. 캐시가 얼마나 맞고 있는지 세는 지표도 없어서, 스케줄러를 안 쓴 이유 중 하나였던 “안 보는 폼은 계산 0”이 실제로 얼마나 0인지도 모릅니다. 그러니 첫 문단의 넷 중 셋은 구조에서 나온 이유이고, 하나(요청이 몰리는 문제가 실질 위험이 아니라는 것)는 아직 숫자로 확인되지 않은 판단입니다.
이슈에 “필수”라고 적어 둔 걸 뒤집은 건 결국 사고 이력 때문이 아니라 비교표 때문이었고, 그 비교를 하게 만든 건 “잊고 봐봐” 한마디였습니다. 다음에 비슷한 자리가 오면 사고 이력은 근거 목록에서 빼고 구조만 놓고 비교할 것이고, “규모상 괜찮다”고 적는 항목에는 언제 재 볼지를 같이 적어 둘 생각입니다.
함께 읽을 만한 글
-
훅은 확정이고 프롬프트는 확률이라는데, 훅도 깨집니다
AI에게 지키라고 적어둔 규칙 하나를 프롬프트 대신 훅으로 강제하기로 한 결정, 그 훅이 같은 날 상대경로 때문에 깨진 일, 그리고 비교하지 않은 것
-
AI가 쓴 문장마다 근거가 살아 있는지 기계로 세기
위키의 사실 주장 30개가 소스 파일과 줄 번호로 지금도 추적되는지를 LLM 없이 채점하는 지표와, 비밀 값 필터 두 개를 같은 하네스에서 비교한 기록
-
우리 마스킹은 총합에만 조건이 걸려있는데
항목별로 가리면 뺄셈으로 복원된다. 지원자 3명 미만이면 분포를 통째로 감추기로 한 이유와 그 구현이 틀렸던 자리
-
라우트와 OpenAPI가 어긋나면 CI가 막게 한 이유, 그리고 경로만 보는 가드의 한계
API 문서를 유일한 기준이라 정해 놓고 사람이 눈으로 지키던 걸, 경로와 메서드만 대조하는 테스트로 CI에 넘긴 결정과 두 달 뒤 확인한 구멍
-
친구가 0명인 첫날, 누구를 추천할 것인가
친구가 0명인 첫날엔 공통 친구로 목록을 못 채운다. 초안을 버리고 프로필의 목표 유사도를 주력으로 둔 이유