← 문제로

30. 길드 가입 신청 수락의 동시 처리 (이중 소속 / 정원 초과)

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

해설 — 길드 가입 신청 수락의 동시 처리 (이중 소속 / 정원 초과)

난이도: 하

요약

AcceptApplication 은 "이미 소속인지 확인(A) → 정원 확인(B) → 가입(C~D)" 을 원자적으로 묶지 않는다. 신청자 A 가 G1·G2 두 곳에 신청해 둔 상태에서 두 간부가 동시에 수락하면, 두 호출이 모두 (A)에서 "소속 없음"을 보고 통과해 A 가 두 길드에 동시 가입된다(이중 소속). 같은 구조가 (B) 정원 검사에도 적용되어 정원 초과가 가능하다. 게다가 (D)는 "이번 길드" 신청만 지워 나머지 신청이 남고, 나중에 또 수락될 수 있다.

문제점

1. (TOCTOU / 원자성) 소속 검사와 가입이 분리됨 — (A)(C)

  • 증상: A 가 G1, G2 양쪽 멤버 목록에 들어가고 GetPlayerGuild 가 가리키는 소속이 마지막 쓴 쪽으로 덮어써져 데이터가 어긋난다(멤버 목록 ≠ 소속 맵).
  • 재현조건: 두 수락 스레드가 모두 (A)를 통과한 뒤 각자 (C)를 실행. 검사~가입 사이에 상호 배제가 없다.
  • 근본원인: "플레이어는 한 길드에만" 이라는 불변식의 보호 단위가 플레이어인데, 그 플레이어 단위로 직렬화하는 잠금/제약이 없다.

2. (TOCTOU) 정원 검사도 경합 — (B)

  • 두 신청자(또는 두 수락)가 동시에 (B)에서 같은 Count 를 읽으면 둘 다 통과해 Capacity + 1 이 된다.

3. (정합성) 부분 정리 — (D)

  • 수락 시 그 길드의 신청만 제거한다. A 의 다른 길드 신청은 그대로 남아, 이후 다른 간부가 또 수락 → 다시 이중 소속. 수락 성공 시 그 플레이어의 모든 대기 신청을 무효화해야 한다.

수정안

플레이어 단위로 직렬화하고, 소속 유무를 단일 원자 연산 + DB 유니크 제약으로 강제한다.

-- 플레이어당 소속은 최대 1행: player_id 를 PK 로
CREATE TABLE guild_member (
  player_id BIGINT PRIMARY KEY,    -- 이미 소속이면 INSERT 자체가 충돌
  guild_id  BIGINT NOT NULL
);
public bool AcceptApplication(long guildId, long playerId)
{
    using var tx = _repo.BeginTransaction();

    var g = _repo.GetForUpdate(tx, guildId);          // 길드 행 잠금
    if (g.MemberCount >= g.Capacity) { tx.Rollback(); return false; }

    // 소속을 '차지'. 이미 다른 길드면 PK 충돌 → 0행 → 실패.
    bool joined = _repo.TryInsertMember(tx, playerId, guildId); // INSERT ... ON CONFLICT DO NOTHING
    if (!joined) { tx.Rollback(); return false; }

    _repo.IncMemberCount(tx, guildId);
    _repo.RemoveAllApplications(tx, playerId);         // 이 플레이어의 모든 신청 무효화
    tx.Commit();
    return true;
}
  • TryInsertMember 의 PK 충돌이 "한 플레이어 한 길드"를 DB가 보증한다 — 동시 수락 중 정확히 하나만 성공.
  • 정원은 길드 행을 FOR UPDATE 로 잠가 검사·증가를 직렬화(또는 member_countCHECK/조건부 UPDATE).
  • 성공하면 그 플레이어의 모든 대기 신청을 한 트랜잭션에서 정리.

더 나은 설계

  • 불변식의 주체로 잠금 단위를 정한다: "플레이어는 한 길드" → 플레이어 행/키를 잠가야지, 길드별 잠금만으론 길드 간 경합을 못 막는다.
  • 단일 노드라면 playerId 단위 락(또는 액터/메일박스)으로 한 플레이어의 가입 요청을 순차 처리.
  • 분산이라면 DB 유니크 제약을 진실원천으로, 애플리케이션 검사는 빠른 거절용 1차 필터로만.

면접 포인트

  1. 검사-후-실행(TOCTOU)은 검사와 변경을 같은 임계영역/트랜잭션으로 묶거나, 변경을 멱등 차지(unique 제약)로 바꿔 해결한다.
  2. 잠금 단위(granularity) 는 깨지면 안 되는 불변식의 주체를 따라간다 — 여기선 길드가 아니라 플레이어.
  3. 성공 시 부수 상태(다른 신청)까지 같은 트랜잭션에서 정리해야 "나중에 또 수락" 같은 2차 결함을 막는다.