← 문제로

22. 길드 임원 강등의 Write Skew와 재시도/캐시 결함 (C#)

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

해설 — 길드 임원 강등의 Write Skew와 재시도/캐시 결함 (C#)

난이도: 상

요약

DemoteSelfAsync 는 "임원이 2명 이상이면 나를 강등한다"를 스냅샷 격리 트랜잭션 안에서 수행한다. 문제는 읽은 행(길드의 모든 임원 행)과 쓴 행(내 행)이 다르다는 것이다. 스냅샷/RR 격리는 같은 행에 대한 쓰기 충돌만 감지하므로, 두 임원이 동시에 사임하면 둘 다 "임원 2명"인 스냅샷을 보고 통과하고, 서로 다른 행을 갱신하므로 충돌 없이 둘 다 커밋된다. 결과는 임원 0명 길드다(I1 붕괴). 여기에 ① 직렬화 실패/데드락을 일반 오류로 뭉개는 예외 처리, ② 커밋 전에 갱신하는 인메모리 캐시, ③ 캐시된 count 를 재검증 없이 재사용하는 재시도가 겹친다.

문제점

(A) Write Skew — 읽은 행과 쓴 행이 달라 충돌이 감지되지 않는다

  • 분류: 격리 수준 오해 · 불변식 붕괴(write skew)
  • 증상: 임원 2명뿐인 길드에서 두 사람이 동시에 사임하면 둘 다 성공하고 임원이 0명이 된다. 길드 창고와 가입 승인 큐가 잠겨 GM 이 수동으로 role 을 복구해야 한다. 반대로 PromoteWithRetryAsync 는 동시 임명으로 임원이 6~7명이 되어 I2 가 깨진다.
  • 재현 조건: 길드 해체 직전이나 대규모 길드 이동 시. 두 요청이 서로 다른 게임서버 프로세스(또는 다른 스레드)에서 거의 동시에 도착해 두 트랜잭션의 스냅샷이 같은 시점을 보는 경우. 부하가 없어도 사람이 "동시에 누르는" 수백 ms 창이면 충분하다.
  • 근본 원인:
    1. 스냅샷/RR 이 막아 주는 것은 lost update(같은 행 동시 갱신)뿐이다. 이 코드의 검증은 "임원 집합"을 읽고 갱신은 "내 행"에만 한다. 두 트랜잭션의 write set 이 겹치지 않으므로 SQL Server 의 스냅샷 갱신 충돌(3960)도 발생하지 않고 둘 다 정상 커밋된다. 이것이 write skew 의 교과서적 형태다.
    2. 잠글 대상 행이 존재하지 않는다. "임원 수"라는 술어(predicate)에 대한 제약인데 술어를 잠그는 수단(범위 락, 술어 락)이 없다. SELECT COUNT(*) 는 스냅샷에서 읽으므로 잠금을 걸지도 않는다.
    3. UPDATE ... AND role='officer' 조건은 자기 행에 대한 이중 강등만 막는다. 다른 사람이 동시에 강등되는 것은 이 조건으로 알 수 없다. 즉 행 수준 방어는 있으나 집합 수준 불변식 방어가 없다.
    4. PromoteWithRetryAsync 는 한 술 더 떠서 트랜잭션 안에서 count 를 아예 읽지 않는다. 프로세스 로컬 캐시 값으로 I2 를 판단하므로, 다른 프로세스가 임명한 결과는 보이지도 않는다. 게임서버가 2대 이상이면 즉시 깨진다.

(B) 재시도·예외·연결·캐시 처리 결함

  • 분류: 트랜잭션 오류 처리 오류 · 리소스 누수 · 캐시 일관성 위반
  • 증상:
    1. 커밋 실패(충돌/데드락)로 롤백됐는데 캐시에는 이미 감소한 임원 수가 남는다. DB 는 3명인데 캐시는 2명이라, 캐시를 믿는 권한 체크가 잘못된 판정을 내리고 프로세스를 재시작해야 복구된다.
    2. 예외가 나면 DemoteSelfAsync 는 연결을 절대 닫지 않는다. catch 블록에 conn.Close() 가 없고 using 도 없다. 강등 요청이 몰려 오류가 반복되면 커넥션 풀이 고갈되어 길드 기능 전체가 타임아웃으로 죽는다. 정상 경로에서도 Commit() 이 던지면 같은 일이 생긴다.
    3. tx.Rollback() 자체가 예외를 던질 수 있다(연결이 이미 끊긴 경우). 그러면 catch 안에서 새 예외가 나 로깅조차 안 되고 호출자에게 엉뚱한 예외가 전파된다.
    4. PromoteWithRetryAsync 는 재시도를 하지만 캐시된 cachedOfficerCount 를 갱신하지 않고 그대로 재사용한다. 재시도의 목적은 "최신 상태로 다시 판단"인데 판단 근거가 그대로면 같은 잘못된 결정을 3번 반복할 뿐이다.
  • 재현 조건: DB 부하로 데드락(1205)이나 스냅샷 갱신 충돌(3960)이 발생하는 상황, 또는 DB 재시작·네트워크 순단.
  • 근본 원인:
    1. 재시도 가능한 오류와 그렇지 않은 오류를 구분하지 않는다. SqlException.Number 로 1205(데드락 희생자), 3960(스냅샷 갱신 충돌), 1222(락 타임아웃)를 식별해 재시도해야 하는데, DemoteSelfAsync 는 전부 return false 로 뭉개 "사임 실패"로 사용자에게 보낸다. 반대로 PromoteWithRetryAsync 는 catch (Exception) 으로 재시도하면 안 되는 오류(제약 위반, 문법 오류)까지 3번 반복한다.
    2. 커밋 전 캐시 갱신은 원칙 위반이다. 외부에 관측되는 상태 변경은 트랜잭션이 커밋에 성공한 뒤에만 해야 한다. 롤백 경로에서 캐시를 되돌리는 보상 코드도 없다.
    3. SqlConnection / SqlTransaction 이 using 없이 지역 변수로만 잡혀 있다. 비동기 경로에서 예외가 나면 GC 파이널라이저에 의존하게 되는데, 풀 반납이 늦어지면 사실상 누수다.

수정안

핵심은 넷이다. ① 술어 불변식을 DB 가 강제하게 하고, ② 격리 수준을 올리고 재시도를 필수로 삼고, ③ 캐시는 커밋 후에만 갱신하고, ④ 리소스는 await using 으로 확실히 정리한다.

가장 견고한 해법은 조건부 UPDATE 한 문장으로 검증과 갱신을 원자화하는 것이다. 읽기와 쓰기를 하나로 합치면 write skew 가 성립할 여지가 사라진다.

public async Task<bool> DemoteSelfAsync(long guildId, long memberId)
{
    const string Sql = @"
UPDATE guild_members
   SET role = 'member'
 WHERE guild_id = @g
   AND member_id = @m
   AND role = 'officer'
   AND (SELECT COUNT(*) FROM guild_members WITH (UPDLOCK, HOLDLOCK)
         WHERE guild_id = @g AND role = 'officer') >= 2;";

    return await ExecuteWithRetryAsync(async (conn, tx) =>
    {
        await using var cmd = new SqlCommand(Sql, conn, tx);
        cmd.Parameters.Add("@g", SqlDbType.BigInt).Value = guildId;
        cmd.Parameters.Add("@m", SqlDbType.BigInt).Value = memberId;
        return await cmd.ExecuteNonQueryAsync() == 1;   // 0이면 조건 불충족 → 정상 거절
    }, guildId);
}

UPDLOCK, HOLDLOCK 이 술어에 범위 락을 걸어 다른 트랜잭션의 동시 강등을 직렬화한다. (SQL Server 에서 스냅샷 격리를 쓰더라도 이 힌트가 붙은 읽기는 잠금 기반으로 동작한다.)

재시도·정리·캐시 갱신은 공통 래퍼로 뽑아 실수를 원천 차단한다.

private static bool IsTransient(SqlException ex) =>
    ex.Number == 1205    // deadlock victim
 || ex.Number == 3960    // snapshot isolation update conflict
 || ex.Number == 1222    // lock request timeout
 || ex.Number == -2;     // command timeout

private async Task<bool> ExecuteWithRetryAsync(
    Func<SqlConnection, SqlTransaction, Task<bool>> body, long guildId)
{
    for (int attempt = 0; attempt < 3; attempt++)
    {
        await using var conn = new SqlConnection(ConnStr);
        await conn.OpenAsync();
        var tx = (SqlTransaction)await conn.BeginTransactionAsync(IsolationLevel.Snapshot);
        try
        {
            bool ok = await body(conn, tx);   // 검증은 매 시도마다 트랜잭션 안에서 새로
            await tx.CommitAsync();
            if (ok) await RefreshCacheAsync(guildId);   // 커밋 성공 이후에만
            return ok;
        }
        catch (SqlException ex) when (IsTransient(ex))
        {
            try { await tx.RollbackAsync(); } catch { /* 연결 단절 시 무시 */ }
            await Task.Delay(TimeSpan.FromMilliseconds(20 * (1 << attempt) + Random.Shared.Next(10)));
        }
        catch
        {
            try { await tx.RollbackAsync(); } catch { }
            throw;    // 재시도 불가 오류는 그대로 전파
        }
        finally { await tx.DisposeAsync(); }
    }
    _log.Warn($"demote exhausted retries guild={guildId}");
    return false;
}

PromoteWithRetryAsync 도 캐시 값을 버리고 같은 방식의 조건부 UPDATE(... < 5)로 바꾼다. 재시도의 각 시도는 반드시 트랜잭션 안에서 검증을 다시 수행해야 한다.

더 나은 설계

(1) DB 제약으로 불변식을 강제한다 — 애플리케이션 검증에 의존하지 않는다. guild_officers(guild_id, slot) 처럼 임원 자리를 행으로 모델링하고 slot 에 1..5 체크 제약과 UNIQUE(guild_id, slot) 를 걸면, I2 는 유니크 제약이 자동으로 지킨다. I1 은 guild(guild_id, officer_count) 요약 행을 두고 CHECK (officer_count >= 1) + 강등 시 UPDATE guild SET officer_count = officer_count - 1 로 처리하면, 모든 트랜잭션이 같은 행(요약 행)을 갱신하므로 write skew 가 lost update 로 환원되어 격리 수준이 잡아낸다.

  • 트레이드오프: 요약 행이 길드별 직렬화 지점이 되어 초대형 길드의 임원 변경이 줄을 선다. 다만 임원 변경은 초당 수 건 수준이라 실무상 문제가 없고, "불변식을 코드가 아니라 스키마가 지킨다"는 이득이 압도적이다. 대신 요약 행과 실제 행의 동기화 책임이 생긴다.

(2) IsolationLevel.Serializable 로 올린다. Serializable 은 범위 락(SQL Server) 또는 SSI(PostgreSQL)로 write skew 를 실제로 막는 유일한 표준 격리 수준이다.

  • 트레이드오프: 락 경합과 데드락이 늘어나므로 재시도 로직이 선택이 아니라 필수 전제가 된다. 길드 임원 변경처럼 저빈도·고위험 연산에는 적합하지만, 인벤토리 갱신 같은 초고빈도 경로에 일괄 적용하면 처리량이 급락한다. 격리 수준은 연산 단위로 고르는 것이지 서버 전역 설정으로 정할 것이 아니다.

(3) 길드 단위 직렬화 — 애플리케이션 락 또는 액터. sp_getapplock @Resource = 'guild:12345' 로 길드 단위 뮤텍스를 잡거나, 길드 ID 해시로 샤딩된 단일 워커가 해당 길드의 모든 변경을 순차 처리한다. 임원 관리처럼 규칙이 복잡한 도메인은 이쪽이 읽기 쉽다.

  • 트레이드오프: 애플리케이션 락은 DB 를 락 서비스로 쓰는 것이라 연결 수명 관리가 까다롭고, 액터 방식은 게임서버가 여러 대일 때 "어느 서버가 이 길드의 주인인가"라는 소유권 라우팅이 필요하다. 그 대신 캐시 갱신도 액터 안에서 안전하게 처리할 수 있다.

(4) 캐시는 커밋 후 무효화(invalidate)만. 값을 직접 써 넣지 말고 커밋 성공 후 해당 길드 엔트리를 지워 다음 조회가 DB 에서 다시 읽게 한다. 분산 환경이면 커밋 후 이벤트를 발행해 다른 서버의 캐시도 무효화한다.

  • 트레이드오프: 무효화 직후 조회가 DB 로 몰려 순간 부하가 생기지만, 롤백·다중 서버 상황에서 캐시가 영구히 어긋나는 위험보다 낫다. "쓰기 후 캐시 갱신"은 항상 커밋 성공 이후, 그리고 값 주입보다 무효화가 안전하다.

면접 포인트

  • "REPEATABLE READ / 스냅샷 격리가 막아 주는 이상 현상은 무엇이고, write skew 는 왜 못 막는가?" — 이들은 자신이 읽은 행이 바뀌었는지(또는 자신이 쓴 행이 충돌했는지)를 본다. write skew 는 읽은 집합과 쓴 행이 달라서 어떤 행 충돌도 발생하지 않는다. 막으려면 Serializable, 술어 락(UPDLOCK/HOLDLOCK), 또는 물리적으로 같은 행을 만들어 충돌을 유도해야 한다.
  • "트랜잭션 재시도에서 가장 흔한 실수는?" — 검증을 재시도 밖에 두는 것. 재시도는 "최신 상태에서 다시 판단"이어야 하는데 이전 시도의 계산 결과를 재사용하면 같은 오판을 반복한다. 그리고 재시도 가능한 오류(데드락·직렬화 실패)와 불가능한 오류(제약 위반)를 반드시 구분해야 하며, 지수 백오프 + 지터가 없으면 재시도끼리 다시 충돌한다.
  • "DB 트랜잭션과 인메모리 캐시의 갱신 순서는?" — 캐시는 항상 커밋 성공 이후, 그리고 값 주입보다 무효화. 커밋 전에 캐시를 고치면 롤백 시 되돌릴 방법이 없고, 그 사이 다른 요청이 존재하지 않는 상태를 관측한다. 같은 이유로 커밋 전에 클라이언트에 성공 패킷을 보내는 것도 금물이다.