연구 노트EthiLab

캐시 수명은 3분인데, 화면에는 아침 값이 떴다

만료된 캐시가 다음 요청에 묵은 값을 내주는 줄 몰랐다. 칸을 나눠 막았더니, 이번엔 처음부터 틀린 값이 칸 끝까지 굳었다.
📅 2026.10.26⏱ 읽기 약 6분
EthiLab오늘 시장과 종목의 위치돈이 몰린 테마와 종목의 위치를 웹에서 확인하세요바로가기 →

새로고침하면 멀쩡해지는 화면

장중 낮 12시 반, 테마 상세 표 한 칸이 이상하다는 지적을 받았다. 거래대금은 -, 등락률은 전부 0.00%였다. 새로고침하니 정상 값이 떴다.

원천부터 봤다. 시세를 주는 쪽은 그 시각에 정상 값을 주고 있었다. 그렇다면 문제는 우리 쪽에서 값을 들고 있는 곳, 캐시다.

단서는 순서였다. 표는 등락률 순으로 줄을 세우는데 그 칸은 종목코드 오름차순으로 서 있었다. 등락률이 전부 0이라 동률이 됐고, 동률은 코드 순으로 풀린 것이다. 등락률이 전부 0인 응답은 하루에 한 번 나온다. 장이 열리기 전이다.

12시 반 화면에 아침 9시 전 응답이 떠 있었다. 캐시 수명은 3분(180초)으로 잡아 두었는데 말이다.

수명은 "이보다 오래된 값은 안 준다"가 아니었다

캐시 수명을 이렇게 이해하고 있었다. 3분이 지나면 그 값은 버려지고, 다음 요청은 새 값을 받는다.

실제 동작은 달랐다. 이 사이트가 쓰는 프레임워크(Next.js)의 데이터 캐시는 수명이 지나도 값을 버리지 않는다. 만료 뒤 첫 요청에는 묵은 값을 그대로 내주고, 그 뒤에 조용히 새 값을 받아 둔다. 흔히 stale-while-revalidate라고 부르는 방식이다. 수명은 "이 시간이 지나면 다음 요청 때 갱신을 시작한다"는 뜻이었다.

방문이 많은 사이트라면 차이가 거의 안 보인다. 첫 요청을 누군가 먼저 맞고, 나머지는 새 값을 본다. 이 사이트는 방문이 적다. 오래 아무도 안 연 테마는 첫 방문자가 거의 항상 마지막으로 저장된 값을 본다. 그 테마를 마지막으로 누가 본 게 장 시작 전이면, 낮 12시 반의 첫 방문자는 아침 값을 받는다.

💡 방문이 적을수록 거의 모든 방문이 "만료 뒤 첫 요청"이다. 이런 캐시의 실효 수명은 설정값이 아니라 마지막 방문 간격이다.

처방: 시간 칸 번호를 캐시 키에 넣는다

캐시를 끄면 해결은 되지만, 그러면 모든 방문이 원천을 기다린다. 원하는 것은 "3분 안에서는 공유하되, 3분이 지나면 옛 값을 절대 다시 꺼내지 않는 것"이었다.

프레임워크는 요청 URL과 함께 요청 헤더까지 캐시 키에 넣는다. 그래서 수명 길이로 시간을 잘라 칸 번호를 매기고, 그 번호를 요청 헤더에 실었다. 원천 서버는 모르는 헤더라 무시한다. 칸이 바뀌는 순간 키가 달라지고, 옛 칸의 캐시는 더 이상 꺼낼 방법이 없다. 같은 칸 안에서는 종전처럼 공유된다.

대가는 하나다. 칸이 바뀐 뒤 첫 요청은 묵은 값 대신 원천을 기다린다. 아침 값을 보여 주는 것보다는 1초를 기다리는 편이 낫다.

다음 주 월요일 9시, 같은 화면이 다시 0이 됐다

고친 다음 운용일 아침, 9시 전부터 켜 둔 화면이 5분 동안 0.00%에 고정됐다. 9시 4분에 다시 불러도 0이었다. 9시 6분에 새로 연 탭은 정상이었다.

이번엔 묵은 값이 아니었다. 칸은 제대로 바뀌었다. 문제는 9시 정각 칸의 첫 요청이 받은 값이었다. 장이 막 열리는 순간 원천은 아직 장 전 상태(전부 0)를 주고 있었고, 그 응답이 새 칸에 저장됐다. 그다음부터는 칸이 끝날 때까지 모두가 그 값을 공유했다. 서버 로그가 남지 않아 확정은 못 했지만, 증상과 시각이 이 설명과 맞았다.

칸을 나눈 처방은 오래된 값을 막는다. 태어날 때부터 틀린 값은 막지 못한다. 오히려 칸 안에서는 그 값을 성실하게 모두에게 나눠 준다.

저장하기 전에 값을 의심한다

첫 시도는 캐시를 우회해 다시 받는 것이었다. 증상이 남았고, 매번 원천을 기다리느라 응답이 늦어졌다. 같은 방향으로 한 번 더 조이지 않고 구조를 바꿨다.

장중인데 응답이 전부 0이면, 그 응답은 아직 준비가 안 된 것으로 본다. 그때는 같은 칸 안에서 정해진 순서의 다른 키로 20초 간격, 최대 세 번 다시 받는다. 키가 결정적이라 다른 서버도 정상 응답이 앉은 키를 그대로 읽는다. 세 번 다 0이면 숫자를 지어내지 않고 "장 시작 직후라 등락률이 아직 반영되지 않았을 수 있습니다"를 띄운다.

다음 날 아침 9시부터 1분 반 간격으로 재 봤다.

시각0.00% 칸
09:00:36120 / 120
09:02:054 / 120
09:03:342 / 120

2분 안에 수렴했다. 남은 두세 칸은 원천에서도 실제로 보합인 종목이었다.

전부 0이 아니라 일부만 0인 응답

같은 날 9시 7분, 시총 상위 격자에서 6종목이 장 전 값으로 돌아가 있었다. 같은 응답 안의 다른 종목은 최신 값이었다. 같은 시각 원천에는 그런 종목이 하나도 없었다. 장 전 값이 섞인 응답이 다른 캐시 칸에 굳은 것이었다.

"전부 0이면 준비 안 됨"이라는 판정은 원리상 이것을 못 잡는다. 판정을 하나라도 등락률 0이면서 거래대금 0이면 준비 안 됨으로 넓혔다. 거래정지 종목은 원래 그 값이라 뺐다. 갱신 간격도 3분에서 1분으로 줄였다. 이틀 뒤 아침 같은 격자를 1분 간격으로 여섯 번 재니 0.00% 칸은 0~2개였고, 전부 실제 보합이었다.

남은 것

캐시 수명은 "최대 나이"가 아니라 "갱신 시작 시점"일 수 있다. 쓰는 캐시가 stale-while-revalidate인지 먼저 확인한다. 방문이 적은 화면일수록 그 차이가 그대로 사용자에게 간다.

칸을 나누면 오래된 값은 사라지지만, 처음 받은 값은 칸 끝까지 간다. 캐시는 값의 나이를 관리할 뿐 값의 진위는 관리하지 않는다. 진위는 저장하기 전에 봐야 한다.

"준비 안 된 응답"의 모양을 하나로 정하지 않는다. 전부 0만 잡으면 일부만 0인 응답이 빠져나간다. 하루에 한 번 바뀌는 데이터라면 그 전환 순간의 응답이 어떤 모양들로 오는지부터 실제로 재 본다.