캐시를 도입하면 성능은 좋아진다. 하지만 동시에, 당신이 미처 생각하지 못한 동시성 버그도 함께 들어온다.
캐싱은 백엔드 개발자가 가장 먼저 꺼내는 성능 카드다. Redis든 로컬 인메모리든, "DB 부하를 줄이자"는 목표 하나로 캐시를 붙이는 건 어렵지 않다. 진짜 문제는 그 다음이다. 트래픽이 몰리는 순간, 캐시는 성능 도구가 아니라 장애의 진원지가 된다.
이 글에서는 실무에서 자주 마주치지만 놓치기 쉬운 캐시 동시성 문제 5가지를 정리한다.
1. Cache Stampede (Thundering Herd)
무슨 일이 일어나는가
캐시 키 하나가 만료되는 순간, 동시에 100개의 요청이 들어온다. 100개 모두 캐시 미스를 확인하고, 100개 모두 DB에 같은 쿼리를 날린다. 캐시를 쓰는 이유가 DB 보호인데, 정작 가장 필요한 순간에 보호막이 사라지는 것이다.
왜 놓치는가
개발 환경에서는 동시 요청이 1~2개이므로 절대 재현되지 않는다. 부하 테스트를 하더라도 캐시가 살아있는 동안은 문제가 없다. TTL 만료 직후의 수 밀리초에만 발생하기 때문에, 모니터링에서도 순간적인 스파이크로만 보인다.
해결 패턴
// ❌ 단순한 캐시 조회 — stampede에 무방비
async function getUser(id: string) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.findUser(id); // 동시 요청 N개가 전부 여기를 때린다
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
return user;
}
// ✅ Mutex Lock 패턴 — 한 번에 하나만 DB를 조회
async function getUserSafe(id: string) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const lockKey = `lock:user:${id}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
try {
const user = await db.findUser(id);
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
return user;
} finally {
await redis.del(lockKey);
}
} else {
// 락을 못 잡은 요청은 짧게 대기 후 재시도
await sleep(50);
return getUserSafe(id);
}
}
Probabilistic Early Expiration도 대안이다. TTL이 만료되기 전에 확률적으로 갱신을 시작하는 방식으로, 만료 시점의 동시 접근 자체를 줄인다.
// TTL 300초, beta 값으로 조기 갱신 확률 조절
function shouldRefreshEarly(cachedAt: number, ttl: number, beta: number = 1): boolean {
const elapsed = Date.now() / 1000 - cachedAt;
const remaining = ttl - elapsed;
return remaining - beta * Math.log(Math.random()) <= 0;
}
2. Read-Your-Writes 불일치
무슨 일이 일어나는가
사용자가 프로필을 수정한다. 서버는 DB를 업데이트하고 캐시를 삭제(또는 갱신)한다. 그런데 사용자가 곧바로 새로고침하면 수정 전 데이터가 보인다.
원인은 타이밍이다:
시간축 →
Thread A: DB UPDATE → cache DELETE → (완료)
Thread B: cache GET (히트, 아직 구버전) → 응답 반환
Thread B의 캐시 조회가 Thread A의 캐시 삭제보다 먼 밀리초 빨랐을 뿐인데, 사용자는 "저장이 안 됐나?" 하고 다시 저장 버튼을 누른다.
왜 놓치는가
단일 스레드 테스트에서는 항상 순서가 보장된다. 로드밸런서 뒤에 서버가 여러 대 있을 때, 또는 캐시 삭제와 조회 사이의 경합에서만 발생한다.
해결 패턴
Write-through 방식으로 캐시를 삭제하지 말고 즉시 새 값으로 덮어쓴다:
async function updateUser(id: string, data: Partial<User>) {
const updated = await db.updateUser(id, data);
// ❌ cache invalidation — 삭제와 조회 사이에 틈이 생긴다
// await redis.del(`user:${id}`);
// ✅ write-through — 새 값으로 즉시 교체
await redis.set(`user:${id}`, JSON.stringify(updated), 'EX', 300);
return updated;
}
MSA 환경에서 다른 서비스가 같은 캐시를 읽는 경우에는, 이벤트 기반 캐시 갱신이 더 안전하다:
// 업데이트 서비스: 이벤트 발행
await eventBus.publish('user.updated', { id, data: updated });
// 조회 서비스: 이벤트 구독하여 캐시 갱신
eventBus.subscribe('user.updated', async (event) => {
await redis.set(`user:${event.id}`, JSON.stringify(event.data), 'EX', 300);
});
3. Dog-Pile Effect와 캐시 워밍의 함정
무슨 일이 일어나는가
서버를 재시작하거나 Redis를 플러시한 후, 캐시가 완전히 비어있는 상태에서 트래픽을 받기 시작한다. 모든 요청이 캐시 미스이므로 DB에 전체 트래픽이 그대로 전달된다. Stampede가 하나의 키에서 발생하는 문제라면, Dog-Pile은 모든 키에서 동시에 발생하는 문제다.
왜 놓치는가
"배포 후 잠깐 느려지는 건 원래 그런 거지"라고 넘기기 때문이다. 하지만 트래픽이 충분하면 이 "잠깐"이 DB 커넥션 풀 고갈 → 타임아웃 폭주 → 서비스 전체 장애로 이어진다.
해결 패턴
// 배포 시 캐시 워밍 스크립트 — 트래픽 유입 전에 실행
async function warmCache() {
// 접근 빈도 상위 키를 미리 로드
const hotKeys = await db.query(`
SELECT id FROM users
ORDER BY last_accessed_at DESC
LIMIT 1000
`);
// 동시성 제한을 걸어 DB를 보호하면서 워밍
const CONCURRENCY = 10;
for (let i = 0; i < hotKeys.length; i += CONCURRENCY) {
const batch = hotKeys.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(async ({ id }) => {
const user = await db.findUser(id);
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
}));
}
}
추가로 stale-while-revalidate 패턴을 적용하면, 만료된 캐시라도 일단 반환하고 백그라운드에서 갱신할 수 있다:
async function getWithStaleWhileRevalidate(key: string, fetcher: () => Promise<any>) {
const raw = await redis.get(key);
if (raw) {
const { data, expireAt, staleUntil } = JSON.parse(raw);
const now = Date.now();
if (now < expireAt) return data; // 신선한 데이터
if (now < staleUntil) {
// stale이지만 허용 범위 — 일단 반환하고 백그라운드 갱신
refreshInBackground(key, fetcher);
return data;
}
}
// 완전히 없거나 stale 허용 범위도 초과
return fetchAndCache(key, fetcher);
}
4. Race Condition in Cache Invalidation (Double-Write 문제)
무슨 일이 일어나는가
두 개의 요청이 거의 동시에 같은 데이터를 수정한다. 각각 DB를 업데이트하고 캐시를 갱신하는데, DB 기준으로는 B가 최종 값이지만 캐시에는 A의 값이 남는다.
Thread A: DB UPDATE (v1→v2) ──────────────────→ cache SET (v2)
Thread B: DB UPDATE (v2→v3) → cache SET (v3) ↑
↑ A의 SET이 B보다 늦게 도착
→ 캐시에 v2가 최종값으로 남음 (DB는 v3)
왜 놓치는가
각 스레드의 로직은 완벽하다. "DB 업데이트 → 캐시 갱신" 순서를 지키고 있다. 문제는 두 스레드 간의 교차 순서인데, 코드 리뷰에서는 이 시나리오가 보이지 않는다.
해결 패턴
버전 기반 CAS(Compare-And-Swap):
async function updateAndCache(id: string, data: Partial<User>) {
// DB 업데이트 시 버전을 함께 증가
const updated = await db.query(`
UPDATE users SET name = $1, version = version + 1
WHERE id = $2
RETURNING *
`, [data.name, id]);
// 캐시에 쓸 때 버전 확인 — 더 높은 버전만 허용
const luaScript = `
local current = redis.call('GET', KEYS[1])
if current then
local cached = cjson.decode(current)
if cached.version >= tonumber(ARGV[2]) then
return 0 -- 이미 더 최신 버전이 캐시에 있음
end
end
redis.call('SET', KEYS[1], ARGV[1], 'EX', 300)
return 1
`;
await redis.eval(luaScript, 1, `user:${id}`, JSON.stringify(updated), updated.version);
}
또는 캐시 갱신 대신 삭제(invalidation) 전략을 쓰되, 짧은 TTL의 tombstone을 남겨서 즉시 재캐싱을 방지한다:
async function safeInvalidate(key: string) {
// 삭제 대신 짧은 TTL의 빈 마커를 남긴다
await redis.set(key, '__INVALIDATED__', 'EX', 2);
}
async function getWithTombstoneCheck(key: string, fetcher: () => Promise<any>) {
const cached = await redis.get(key);
if (cached === '__INVALIDATED__') {
// 다른 스레드가 방금 무효화함 — DB에서 최신값을 가져오되 잠시 대기
await sleep(50);
}
if (cached && cached !== '__INVALIDATED__') return JSON.parse(cached);
return fetchAndCache(key, fetcher);
}
5. 로컬 캐시와 분산 캐시의 정합성 괴리
무슨 일이 일어나는가
성능을 극대화하기 위해 Redis(L2) 앞에 인메모리 캐시(L1)를 둔다. 문제는 서버가 여러 대일 때, Server A의 L1과 Server B의 L1이 서로 다른 값을 가질 수 있다는 것이다.
Server A — L1: user:1 = {name: "김철수"} ← 3초 전에 캐싱
Server B — L1: user:1 = {name: "김영희"} ← 방금 갱신됨
Redis(L2): user:1 = {name: "김영희"} ← 최신
→ 로드밸런서가 A로 보내면 "김철수", B로 보내면 "김영희"
왜 놓치는가
개발 환경은 서버 1대이므로 L1이 항상 일관된다. 스테이징에서도 서버 2대 정도로는 재현 빈도가 낮다. "L1 TTL을 짧게 잡았으니 괜찮겠지"라고 생각하지만, 그 짧은 시간 동안의 불일치가 결제나 재고 같은 크리티컬 도메인에서는 치명적이다.
해결 패턴
Pub/Sub 기반 L1 무효화:
// 캐시 갱신 시 모든 서버에 무효화 알림
async function updateCache(key: string, value: any) {
await redis.set(key, JSON.stringify(value), 'EX', 300);
await redis.publish('cache:invalidate', key); // 모든 서버에게 알림
}
// 각 서버에서 구독
const subscriber = redis.duplicate();
subscriber.subscribe('cache:invalidate');
subscriber.on('message', (channel, key) => {
localCache.delete(key); // L1에서 즉시 제거
});
Caffeine + Redis 조합 예시 (Java/Spring 진영):
@CacheConfig(cacheNames = "users")
public class UserService {
private final Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(10)) // L1은 극단적으로 짧게
.build();
public User getUser(String id) {
// L1 → L2 → DB 순서로 조회
return localCache.get(id, key ->
Optional.ofNullable(redisTemplate.opsForValue().get("user:" + key))
.orElseGet(() -> loadFromDbAndCache(key))
);
}
}
정리: 캐시 동시성 체크리스트
| Cache Stampede | 인기 키 TTL 만료 + 동시 요청 | Mutex Lock 또는 Probabilistic Early Expiration |
| Read-Your-Writes 불일치 | 쓰기 직후 읽기 경합 | Write-through 또는 이벤트 기반 갱신 |
| Dog-Pile Effect | 서버 재시작/캐시 플러시 | 캐시 워밍 + stale-while-revalidate |
| Double-Write Race | 동시 업데이트의 캐시 갱신 순서 역전 | 버전 기반 CAS 또는 tombstone |
| L1-L2 정합성 괴리 | 멀티 서버 환경의 로컬 캐시 | Pub/Sub 무효화 + 극단적 짧은 L1 TTL |
마무리
캐시는 "붙이면 끝"이 아니다. 캐시를 도입하는 순간, 당신은 분산 시스템의 정합성 문제를 떠안은 것이다. 단일 스레드 테스트에서는 절대 드러나지 않고, 트래픽이 몰리는 프로덕션에서만 터지는 것이 캐시 동시성 버그의 특징이다.
"우리 서비스는 트래픽이 적으니까 괜찮아"라고 생각할 수 있다. 하지만 캐시를 쓸 정도의 서비스라면, 언젠가는 이 문제들을 만나게 된다. 그때 당황하지 않으려면, 캐시를 붙이는 시점에 동시성 시나리오를 함께 설계하는 습관이 필요하다
'Software > Maker(Spring & Python & node)' 카테고리의 다른 글
| 남의 기록 시스템을 보며, 나도 내 삶을 기록할 구조를 고민하게 되었다 (0) | 2026.04.07 |
|---|---|
| 비개발자가 Claude Code로 구축한 AI 생산성 환경을 보고 느낀 엔지니어의 반성 (0) | 2026.04.07 |
| Java LTS vs Kotlin 롤링 릴리스: 오라클과 젯브레인의 언어 지원 정책, 뭐가 다른가 (0) | 2026.04.06 |
| Reverse Proxy vs API Gateway vs Load Balancer 비교 분석 (0) | 2026.04.06 |
| [혁신] 채팅은 끝났다, 이제는 '코워크(Cowork)' 시대! Claude Cowork 완벽 가이드 (0) | 2026.04.06 |
