← 문제로

55. 캐릭터 이름 변경의 유일성 검사-예약 비원자(중복 이름 발생) · C#

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

해설 — 캐릭터 이름 변경의 유일성 검사-예약 비원자(중복 이름 발생) · C#

난이도: 하

요약

이름 변경권 처리는 "이 이름을 아무도 안 쓰는가?"를 검사하는 지점(A)과, 실제로 그 이름을 예약해 캐릭터에 반영하는 지점(B)이 분리되어 있고, 그 사이에 아무 상호배제가 유지되지 않는다. 게다가 _taken 컬렉션 자체가 락 없이 여러 스레드에서 동시 변형된다. 같은 미사용 이름으로 두 요청이 겹치면 둘 다 (A)를 통과해 둘 다 예약에 성공하고, 월드 유일성 불변식이 깨진다.

문제점

TOCTOU(check-then-act) / 유일성 불변식 붕괴 · 무락 공유 컬렉션 동시 변형

  • 증상: 서로 다른 두 캐릭터가 정규화 기준 동일한 이름을 갖게 된다. 이름으로 대상을 찾는 기능(귓속말, 거래, 신고, 친구추가)이 엉뚱한 대상을 지목하거나 예외를 던진다. 드물게 HashSet 동시 변형으로 내부 상태가 손상되어 Contains가 잘못된 값을 돌려주거나 예외가 난다.
  • 재현 조건:
    1. 캐릭터 X, Y가 각각 미사용 이름 "Aria" 로 동시에 TryRename 호출.
    2. 스레드 T1(X): (A) _taken.Contains("aria") == false → 통과.
    3. 스레드 T2(Y): (A) _taken.Contains("aria") == false → 통과. (아직 아무도 Add 안 함)
    4. T1: (B) _taken.Add("aria"), X.Name="Aria", 티켓 차감.
    5. T2: (B) _taken.Add("aria")(집합이라 무시됨), Y.Name="Aria", 티켓 차감.
    6. 결과: X, Y 둘 다 이름이 "Aria". _taken 에는 "aria" 하나뿐이라 정합성도 깨진다.
  • 근본 원인: 유일성 검사와 예약은 하나의 원자 구간이어야 하는데 (A)와 (B)가 두 개의 짧은 임계구역으로 쪼개졌다. "확인했으니 아직 비어 있을 것"이라는 가정은 동시성 하에서 성립하지 않는다. _chars/_taken 을 보호하는 락도 전혀 없다.

수정안

검사와 예약을 하나의 임계구역으로 합친다. 컬렉션 접근 전체를 같은 락으로 감싼다.

private readonly object _gate = new object();

public bool TryRename(long charId, string newName)
{
    string key = Normalize(newName);
    lock (_gate)
    {
        Character c = _chars[charId];
        if (c.NameChangeTickets <= 0) return false;

        // 검사와 예약이 하나의 원자 구간
        if (_taken.Contains(key)) return false;

        _taken.Remove(Normalize(c.Name));
        _taken.Add(key);
        c.Name = newName;
        c.NameChangeTickets -= 1;
        return true;
    }
}

락을 넓게 잡는 게 부담이면 예약을 원자적 "요청"으로 표현한다. _taken 을 HashSet 대신 ConcurrentDictionary<string,long> 로 두고 TryAdd(key, charId) 의 반환값으로 예약 성공/실패를 원자적으로 판정한다.

private readonly ConcurrentDictionary<string, long> _taken = new();

public bool TryRename(long charId, string newName)
{
    var c = _chars[charId];               // 캐릭터 접근은 별도 동기화 필요
    if (c.NameChangeTickets <= 0) return false;
    string key = Normalize(newName);

    if (!_taken.TryAdd(key, charId))      // 원자적 검사+예약
        return false;                     // 이미 사용 중
    _taken.TryRemove(Normalize(c.Name), out _);
    c.Name = newName;
    c.NameChangeTickets -= 1;
    return true;
}

DB 까지 신뢰 경계를 넓히려면 characters.name 에 대소문자 무시 UNIQUE 제약을 걸고, 변경 UPDATE 의 유니크 위반을 "이미 사용 중"으로 매핑한다. 인메모리 집합은 캐시일 뿐이고 진실의 원천은 DB 제약이어야 한다.

더 나은 설계

  • 원자 예약 우선: "검사 후 행동" 대신 "예약을 시도하고 실패를 받는다"로 뒤집으면 TOCTOU 자체가 사라진다. TryAdd/INSERT ... ON CONFLICT/UNIQUE 제약이 모두 이 패턴이다.
  • 티켓 소모의 원자성: 유니크 예약과 티켓 차감·이름 반영은 한 트랜잭션(또는 한 임계구역)이어야 한다. 예약만 성공하고 크래시로 반영이 누락되면 "유령 예약"이 남으니, 실패 경로에서 예약을 되돌리는 보상도 필요하다.
  • 트레이드오프: 전역 락은 단순하지만 이름 변경 처리량이 락으로 직렬화된다. 이름 변경은 드문 이벤트라 보통 문제되지 않지만, 대규모라면 이름 키를 샤딩(정규화 문자열 해시 기반 스트라이핑)해 경합을 분산할 수 있다.

면접 포인트

  • "먼저 확인하고 나중에 넣는" 모든 코드는 확인과 삽입 사이 창을 갖는다. 유일성·정원·잔액처럼 불변식이 걸린 자원은 검사와 반영을 하나의 원자 연산으로 만들어야 한다.
  • 인메모리 유일성 집합은 다중 프로세스/샤드로 확장하면 깨진다. 최종 방어선은 DB UNIQUE 제약이며, 인메모리 구조는 그 앞단 최적화로만 취급한다.