← 문제로

26. 기간제 아이템 TTL 만료 정리 잡과 연장의 경합 (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 기간제 아이템 TTL 만료 정리 잡과 연장의 경합 (C#)

난이도: 중상

요약

정리 잡이 "만료 후보 조회(SELECT)"와 "삭제(DELETE)"를 두 개의 분리된 문으로 수행하고, DELETE 를 expire_at 조건 없이 id 로만 실행한다. 조회와 삭제 사이에 플레이어가 연장(UPDATE)하면, 이제 유효해진 아이템이 옛 조회 결과에 근거해 삭제된다 (TOCTOU). 즉 연장했는데 아이템이 사라진다.

문제점

  • (A) 검사(조회) 시점의 스냅샷: expiredIds 는 조회 순간 만료였던 id 목록이다. 이후 상태 변화(연장)를 반영하지 못하는 과거 스냅샷이다.
  • (B) 조건 없는 id 삭제: DELETE 가 WHERE item_id IN (...) 만 본다. 조회~삭제 사이에 Renew 가 expire_at 을 미래로 바꿔도, 삭제는 여전히 그 id 를 지운다.
    • 재현: Sweeperの 조회 직후, 커밋 전에 플레이어가 만료 임박 아이템을 연장 → 정리 잡이 유효 아이템을 삭제. 반대로 삭제가 연장을 덮으면 "연장 성공 응답을 받았는데 아이템 없음"이라는 사용자 신뢰 붕괴.
    • 근본 원인: 삭제 조건이 "정말 지금도 만료인가"를 재확인하지 않음(검사-실행 비원자).

수정안

핵심은 삭제 문 자체에 만료 조건을 넣어 원자적으로 재확인하는 것이다. 별도 SELECT 없이 조건부 DELETE 한 방이면 경합이 사라진다.

public void SweepOnce()
{
    var now = DateTime.UtcNow;
    // 삭제 순간에 여전히 만료인 행만 지운다(원자적 재검사).
    _db.Execute(
        "DELETE FROM rented_items WHERE expire_at < @now",
        new { now });
}

배치 크기를 제한하려면 여전히 조건을 유지한다:

DELETE FROM rented_items
WHERE item_id IN (
    SELECT item_id FROM rented_items WHERE expire_at < @now LIMIT @batch
) AND expire_at < @now;   -- 서브쿼리+본조건 모두 만료 확인

연장 경로도 만료된 것을 되살리지 않도록 조건을 건다(정책에 따라):

UPDATE rented_items SET expire_at = @newExpire
WHERE item_id = @itemId AND expire_at >= @now;  -- 이미 만료면 연장 불가

더 나은 설계

  • 삭제 대신 소프트 만료: 조회 쿼리가 항상 expire_at >= now 로 필터링하면 물리 삭제는 한가할 때 배치로 하고, 경합의 상당수가 사라진다.
  • 단일 문/트랜잭션 원칙: "검사 후 변경"을 두 문으로 나누지 말고 조건부 UPDATE/DELETE 한 문으로. 부득이 나눌 땐 동일 트랜잭션 + 적절한 격리/락(FOR UPDATE).
  • 감사 로그로 삭제 사유(만료 시각)를 남겨 오삭제를 추적.

면접 포인트

  • TOCTOU 는 DB 에서도 발생 — 검사와 변경을 한 문의 WHERE 조건으로 합쳐 원자화.
  • id 목록을 캐시해 나중에 변경하는 패턴의 위험(상태가 그새 바뀐다).
  • 소프트 만료(read-time filter) + 지연 물리삭제로 경합을 근본적으로 줄이는 설계.