26. 기간제 아이템 TTL 만료 정리 잡과 연장의 경합 (C#)
난이도 중내 리뷰 · 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) + 지연 물리삭제로 경합을 근본적으로 줄이는 설계.
해설 · C++
해설 — 기간제 아이템 TTL 만료 정리 잡과 연장의 경합 (C++)
난이도: 중상
요약
정리 잡이 "만료 후보 조회(SELECT)"와 "삭제(DELETE)"를 분리된 두 문으로 하고, DELETE
를 expire_at 조건 없이 id 로만 수행한다. 조회~삭제 사이에 연장(UPDATE)이 끼면 유효해진
아이템이 옛 조회 결과에 근거해 삭제된다(TOCTOU).
문제점
- (A) 과거 스냅샷:
expiredIds는 조회 순간의 만료 목록으로, 이후 연장을 반영하지 못한다. - (B) 조건 없는 삭제: DELETE 가
WHERE item_id IN (...)만 봐서, 조회 후expire_at이 미래로 바뀐 행도 지운다. 근본 원인은 삭제가 "지금도 만료인가"를 재확인하지 않는 것 (검사-실행 비원자).- 재현: 조회 직후 플레이어 연장 → 정리 잡이 유효 아이템 삭제(연장 성공 응답 후 소멸).
수정안
삭제 문에 만료 조건을 넣어 원자적으로 재확인한다. 별도 SELECT 없이 조건부 DELETE 한 문이면 경합이 사라진다.
void sweepOnce() {
int64_t now = db_->nowUnix();
// 삭제 순간 여전히 만료인 행만 삭제(원자적 재검사).
db_->executeCond("DELETE FROM rented_items WHERE expire_at < ?", now);
}
배치 크기 제한이 필요하면 서브쿼리로 대상을 뽑되 본 삭제 조건에도 expire_at < now
를 유지한다:
DELETE FROM rented_items
WHERE item_id IN (SELECT item_id FROM rented_items WHERE expire_at < ? LIMIT ?)
AND expire_at < ?;
연장 경로도 만료된 것을 되살리지 않게(정책에 따라):
UPDATE rented_items SET expire_at = ? WHERE item_id = ? AND expire_at >= ?;
더 나은 설계
- 소프트 만료: 조회를 항상
expire_at >= now로 필터 → 물리 삭제는 배치로 미뤄 경합 축소. - 검사-변경을 한 문으로: 조건부 UPDATE/DELETE 로 원자화. 부득이 분리 시 동일 트랜잭션 + 행 락(FOR UPDATE)/적절한 격리.
- 삭제 감사 로그로 오삭제 추적.
면접 포인트
- DB 에서도 TOCTOU — WHERE 조건으로 검사·변경을 합쳐 원자화.
- id 스냅샷을 캐시해 나중에 쓰는 패턴의 위험성.
- 소프트 만료 + 지연 물리삭제 설계의 이점.