← 문제로

3. 캐시(Redis), 캐시 일관성, 비동기 영속화, 멱등성, 분산락

난이도 상
내 답안
모범답안

모범답안 — 캐시(Redis), 캐시 일관성, 비동기 영속화, 멱등성, 분산락

난이도: 상

핵심 답변

  • 캐시를 두는 이유: DB는 디스크 기반이라 느리고 부하 한계가 있다. 자주 읽는 데이터(플레이어 상태, 랭킹, 세션)를 인메모리(Redis)에 두면 응답이 수십 µs~ms로 빨라지고 DB 부하가 급감한다. 또 Redis는 서버 간 공유 상태/원자적 연산/분산락에도 쓰인다.
  • 캐싱 패턴
    • Look-Aside(Cache-Aside): 앱이 캐시를 먼저 보고, 없으면(miss) DB에서 읽어 캐시에 채운다. 쓰기는 DB에 하고 캐시는 무효화(invalidate)/갱신. 단순하지만 캐시-DB 일관성 관리가 앱 책임.
    • Write-Through: 쓰기를 캐시와 DB에 동기로 함께 반영. 캐시는 항상 최신이지만 쓰기 지연이 DB에 묶임.
    • Write-Behind(Write-Back): 캐시에만 먼저 쓰고 DB는 나중에 비동기로 반영. 가장 빠르지만 flush 전 장애 시 손실 위험, 일관성 약함.
  • 멱등성: 같은 요청을 여러 번 보내도 결과(부수효과)가 한 번 보낸 것과 동일한 성질. 재전송이 흔한 결제·보상에서 중복 지급을 막는 핵심.
  • 분산락: 여러 서버가 공유 자원을 동시에 수정할 때 직렬화하기 위한 락. Redis SET NX + 만료(TTL), 또는 전용 코디네이터로 구현. 만료/소유권/클럭 문제를 반드시 고려.

깊이 있는 설명 (왜, 트레이드오프)

캐시 일관성의 근본 문제. 캐시와 DB라는 두 저장소가 생기는 순간 "둘이 어긋날 수 있다"는 문제가 따라온다. Look-Aside에서 흔한 함정은 "DB 업데이트 후 캐시 삭제" 사이의 경쟁 조건이다. 예: A가 DB를 쓰고 캐시를 지우기 전에, B가 옛 DB 값을 읽어 캐시에 채우면 stale 값이 박힌다. 그래서 보통 DB 먼저 쓰고 캐시 무효화(write 후 delete) 순서를 쓰고, 그래도 남는 레이스는 짧은 TTL로 자가치유하게 둔다. 캐시는 "진실의 사본"일 뿐 진실이 아니라는 점을 설계 전제로 둬야 한다.

멱등성 구현의 핵심은 "요청 식별자". 클라이언트가 요청마다 고유 키(idempotency key / request id)를 보내고, 서버는 그 키의 처리 결과를 저장해둔다. 같은 키가 다시 오면 다시 실행하지 않고 이전 결과를 그대로 돌려준다. 게임 보상에서는 더 강하게 "보상 종류 + 날짜/이벤트ID"를 받은 기록 테이블(예: claimed_rewards(user_id, reward_id) UNIQUE)에 유니크 제약을 걸어, 중복 삽입을 DB가 거부하게 만드는 방식이 견고하다. 핵심은 "지급"과 "지급 기록"이 같은 원자적 트랜잭션 안에 있어야 한다는 점.

분산락의 위험. Redis 분산락은 보통 SET key value NX PX ttl로 잡고 value에 소유자 토큰을 넣는다. 위험은 (1) 작업이 TTL보다 오래 걸리면 락이 자동 만료되어 다른 노드가 동시에 들어옴, (2) 해제 시 남의 락을 풀면 안 되므로 토큰을 검사한 뒤 원자적으로 삭제(Lua 스크립트)해야 함, (3) GC 멈춤/네트워크 지연으로 "내가 락을 가졌다고 믿는데 실제로는 만료됨"인 상황. 그래서 결제 같은 진짜 critical한 영역은 분산락만 믿지 말고, **최종 일관성 가드(DB 유니크 제약, 펜싱 토큰, 단일 처리 주체로의 직렬화)**를 병행한다. 락은 경합 완화 수단, 정합성의 최종 방어선은 DB 제약/멱등 키다.

응용/실무 연결 (게임서버에서)

(a) 그냥 "+100"의 사고와 멱등 설계: 재전송 또는 두 서버 동시 처리로 다이아가 여러 번 지급된다(double credit). 설계:

  • 보상 기록 테이블 reward_claims(user_id, reward_id)UNIQUE(user_id, reward_id).
  • 트랜잭션: INSERT INTO reward_claims ... (중복이면 실패) → 성공한 경우에만 다이아 +100. 둘을 같은 트랜잭션으로 커밋.
  • 이미 받은 요청이 재전송되면 INSERT가 막혀 "이미 수령" 응답을 멱등하게 반환.

(b) 캐시-DB 순서: 재화는 DB(진실) 먼저 트랜잭션 커밋 → 캐시 갱신/무효화 순서. 즉 재화에는 Write-Behind를 쓰지 않는다(손실 = 분쟁). Look-Aside + 쓰기 시 캐시 무효화가 안전. 좌표·버프처럼 손실 허용 데이터에만 Write-Behind를 제한적으로 쓴다. 트레이드오프: DB-first는 약간 느리지만 손실/중복 위험이 없고, cache-first는 빠르지만 stale·손실 위험.

(c) 분산락 적용: 키 lock:reward:{user_id}SET NX PX 5000. 해당 유저 보상 처리는 락을 잡은 서버에서만 수행. 작업이 끝나면 토큰 검사 Lua로 해제. 락이 걸린 채 서버가 죽으면 TTL 만료로 자동 해제되어 다른 서버가 이어받는다. 단 위 (a)의 DB 유니크 제약이 있으므로, 락이 만료돼 두 서버가 동시에 들어와도 중복 지급은 최종적으로 차단된다(락은 성능, 제약은 정합성).

흔한 오답·함정

  • "캐시 먼저 쓰고 DB는 나중에"를 재화에 적용: 크래시 시 결제·보상이 사라진다.
  • 캐시 갱신을 "update"로: 동시성 레이스로 stale 값이 박히기 쉽다. 무효화(delete) + TTL이 더 안전한 경우가 많다.
  • 멱등성을 "클라이언트가 버튼을 한 번만 누르게"로 해결: 네트워크 재전송/중복 패킷은 막을 수 없다. 서버 측 멱등 키가 필요.
  • 분산락만 믿고 정합성 보장: 락은 만료·소유권 문제로 깨질 수 있어 최종 가드(DB 제약)가 필수.
  • 남의 락을 그냥 DEL로 해제: 토큰 검증 없이 삭제하면 다른 소유자의 락을 푼다.

꼬리질문 대비

  • Q: Redlock은 안전한가요? A: 여러 Redis 노드 과반 획득으로 단일 장애를 보완하지만, 클럭/GC 멈춤 가정에 대한 논쟁이 있다. critical 경로는 펜싱 토큰·DB 제약 같은 추가 안전장치를 권장.

  • Q: 캐시 스탬피드(같은 키 동시 miss로 DB 폭주)는 어떻게 막나요? A: 키 단위 락(단일 리로더만 DB 조회), 약간의 TTL 지터, 또는 만료 임박 시 백그라운드 갱신(early recompute).

  • Q: 멱등 키는 어디에 얼마나 보관하나요? A: 처리 결과와 함께 저장하고 비즈니스 의미에 맞는 기간 동안 유지(영구 보상이면 영구 기록, 일시 요청이면 TTL). 결과까지 저장해야 재전송에 동일 응답을 줄 수 있다.