55. 캐릭터 이름 변경의 유일성 검사-예약 비원자(중복 이름 발생) · C#
난이도 하해설 — 캐릭터 이름 변경의 유일성 검사-예약 비원자(중복 이름 발생) · C#
난이도: 하
요약
이름 변경권 처리는 "이 이름을 아무도 안 쓰는가?"를 검사하는 지점(A)과, 실제로 그 이름을 예약해 캐릭터에 반영하는 지점(B)이 분리되어 있고, 그 사이에 아무 상호배제가 유지되지 않는다. 게다가 _taken 컬렉션 자체가 락 없이 여러 스레드에서 동시 변형된다. 같은 미사용 이름으로 두 요청이 겹치면 둘 다 (A)를 통과해 둘 다 예약에 성공하고, 월드 유일성 불변식이 깨진다.
문제점
TOCTOU(check-then-act) / 유일성 불변식 붕괴 · 무락 공유 컬렉션 동시 변형
- 증상: 서로 다른 두 캐릭터가 정규화 기준 동일한 이름을 갖게 된다. 이름으로 대상을 찾는 기능(귓속말, 거래, 신고, 친구추가)이 엉뚱한 대상을 지목하거나 예외를 던진다. 드물게
HashSet동시 변형으로 내부 상태가 손상되어Contains가 잘못된 값을 돌려주거나 예외가 난다. - 재현 조건:
- 캐릭터 X, Y가 각각 미사용 이름
"Aria"로 동시에TryRename호출. - 스레드 T1(X): (A)
_taken.Contains("aria")== false → 통과. - 스레드 T2(Y): (A)
_taken.Contains("aria")== false → 통과. (아직 아무도 Add 안 함) - T1: (B)
_taken.Add("aria"),X.Name="Aria", 티켓 차감. - T2: (B)
_taken.Add("aria")(집합이라 무시됨),Y.Name="Aria", 티켓 차감. - 결과: X, Y 둘 다 이름이 "Aria".
_taken에는 "aria" 하나뿐이라 정합성도 깨진다.
- 캐릭터 X, Y가 각각 미사용 이름
- 근본 원인: 유일성 검사와 예약은 하나의 원자 구간이어야 하는데 (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 제약이며, 인메모리 구조는 그 앞단 최적화로만 취급한다.
해설 — 캐릭터 이름 변경의 유일성 검사-예약 비원자(중복 이름 발생) · C++
난이도: 하
요약
tryRename 은 유일성 검사(A)와 예약·반영(B)을 두 개의 분리된 임계구역으로 나눈다. 각 블록은 뮤텍스를 잡지만, (A)에서 락을 풀고 (B)에서 다시 잡기 때문에 그 사이에 다른 스레드가 끼어들 수 있다. 같은 미사용 이름으로 두 요청이 겹치면 둘 다 (A)를 통과하고 둘 다 (B)에서 예약에 성공해 유일성 불변식이 깨진다.
문제점
TOCTOU(check-then-act) / 락 범위 분할로 인한 원자성 상실
- 증상: 서로 다른 두 캐릭터가 정규화 기준 같은 이름을 갖는다.
taken_에는 키가 하나만 남아 반납 시 정합성이 어긋난다(다른 캐릭터가 여전히 그 이름을 쓰는데erase로 풀려버려 세 번째 사용자가 중복 획득 가능). - 재현 조건:
- 캐릭터 X(워커 T1), Y(워커 T2)가 같은 이름
"Aria"(key"aria")로 동시 요청. - T1: (A) 락 획득 →
taken_.count("aria")==0→ 락 해제, 통과. - T2: (A) 락 획득 → 여전히
count==0(T1이 아직 insert 전) → 락 해제, 통과. - T1: (B) 락 획득 →
insert("aria"),X.name="Aria", 티켓 차감 → 해제. - T2: (B) 락 획득 →
insert("aria")(중복이라 무시),Y.name="Aria", 티켓 차감 → 해제. - X, Y 둘 다 "Aria".
- 캐릭터 X(워커 T1), Y(워커 T2)가 같은 이름
- 근본 원인: 뮤텍스를 잡았다는 사실이 아니라 하나의 락 구간이 검사와 반영을 함께 덮는가가 중요하다. 락을 중간에 놓으면 그 순간 다른 스레드가 관찰·변경할 수 있어 원자성이 깨진다.
수정안
검사와 예약을 하나의 lock_guard 구간으로 합친다.
bool tryRename(long charId, const std::string& newName) {
std::string key = normalize(newName);
std::lock_guard<std::mutex> lk(mtx_); // 검사~반영을 하나의 임계구역으로
Character& c = chars_.at(charId);
if (c.nameChangeTickets <= 0) return false;
if (taken_.count(key)) return false; // 검사
taken_.erase(normalize(c.name));
taken_.insert(key); // 예약
c.name = newName;
c.nameChangeTickets -= 1;
return true;
}
chars_.at() 도 같은 락 안으로 들어와야 한다. 문제 코드는 캐릭터 접근을 락 밖에서 했는데, chars_ 도 다른 경로에서 변형될 수 있으므로 함께 보호해야 한다.
예약을 원자 "시도"로 표현하려면 insert 의 반환값을 검사로 쓴다.
std::lock_guard<std::mutex> lk(mtx_);
auto [it, inserted] = taken_.insert(key); // 검사+예약이 한 연산
if (!inserted) return false; // 이미 사용 중
taken_.erase(normalize(c.name));
c.name = newName;
c.nameChangeTickets -= 1;
return true;
더 나은 설계
- 락 구간은 불변식 단위로 잡는다: "확인"과 "그 확인에 근거한 변경"은 반드시 같은 임계구역이어야 한다. 락을 잠깐 풀어 성능을 얻으려다 정확성을 잃는 전형적 함정이다.
- 예약 시도 패턴:
insert().second,try_emplace, DBINSERT ... ON CONFLICT, UNIQUE 제약은 모두 검사와 반영을 융합해 TOCTOU를 없앤다. - 트레이드오프: 단일 뮤텍스는 이름 변경을 직렬화하지만 빈도가 낮아 실무상 무해하다. 경합이 크면 정규화 키 해시로 뮤텍스를 스트라이핑한다. 다중 프로세스라면 최종 권위는 DB UNIQUE 제약에 둔다.
면접 포인트
- "뮤텍스를 썼는데도 레이스가 난다"의 대표 사례: 락을 여러 조각으로 나눠 원자성을 잃는 경우. 임계구역의 경계는 자료구조가 아니라 지켜야 할 불변식으로 정해야 한다.
unordered_set::insert는pair<iterator,bool>을 돌려주며bool이 신규 삽입 여부다. 이 원자적 신호를 활용하면 별도count검사가 필요 없다.