30. 길드 가입 신청 수락의 동시 처리 (이중 소속 / 정원 초과)
난이도 하내 리뷰 · C#
내 리뷰 · 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_count에CHECK/조건부 UPDATE). - 성공하면 그 플레이어의 모든 대기 신청을 한 트랜잭션에서 정리.
더 나은 설계
- 불변식의 주체로 잠금 단위를 정한다: "플레이어는 한 길드" → 플레이어 행/키를 잠가야지, 길드별 잠금만으론 길드 간 경합을 못 막는다.
- 단일 노드라면
playerId단위 락(또는 액터/메일박스)으로 한 플레이어의 가입 요청을 순차 처리. - 분산이라면 DB 유니크 제약을 진실원천으로, 애플리케이션 검사는 빠른 거절용 1차 필터로만.
면접 포인트
- 검사-후-실행(TOCTOU)은 검사와 변경을 같은 임계영역/트랜잭션으로 묶거나, 변경을 멱등 차지(unique 제약)로 바꿔 해결한다.
- 잠금 단위(granularity) 는 깨지면 안 되는 불변식의 주체를 따라간다 — 여기선 길드가 아니라 플레이어.
- 성공 시 부수 상태(다른 신청)까지 같은 트랜잭션에서 정리해야 "나중에 또 수락" 같은 2차 결함을 막는다.
해설 · C++
해설 — 길드 가입 신청 수락의 동시 처리 (이중 소속 / 잘못된 잠금 단위)
난이도: 하
요약
acceptApplication 은 길드별 뮤텍스 g.mtx 로 그 길드의 멤버 목록만 보호한다. 하지만 깨지면 안 되는 불변식은 "한 플레이어는 한 길드"로 플레이어 단위다. 신청자 A 를 G1·G2 가 동시에 수락하면 두 스레드는 서로 다른 뮤텍스(G1.mtx, G2.mtx)를 잡으므로 상호 배제가 전혀 안 되고, 둘 다 (A)에서 "소속 없음"을 통과해 A 가 두 길드에 가입된다. 또한 공유 맵 playerGuild_ 를 (E)에서 락 없이 동시 수정해 자료구조 자체가 깨진다(데이터 레이스, UB).
문제점
1. (잠금 단위 오류) 보호 대상과 불변식의 주체가 다름 — (A)(B)(E)
- 증상: G1.mtx 와 G2.mtx 는 별개 락이라 동시 진입 가능. A 가 두 길드 members 에 모두 들어간다.
- 근본원인: 불변식("플레이어 1 → 길드 1")의 주체는 플레이어인데, 잠금은 길드별이다. 길드 락은 길드 간 경합을 직렬화하지 못한다.
2. (데이터 레이스) 공유 맵 무보호 접근 — (A read)(E write)
getPlayerGuild의find(읽기)와 (E)의playerGuild_[...] =(쓰기/리해시)가 락 밖에서 동시 실행 →std::unordered_map의 동시 read/write 는 정의되지 않은 동작(반복자 무효화/노드 손상/크래시).
3. (TOCTOU) 소속 검사~반영이 분리 — (A)→(E)
- (A) 통과 후 (E) 까지 사이에 다른 스레드가 끼어들 수 있다. 검사와 반영이 한 임계영역이 아니다.
4. (정합성) 부분 신청 정리 — (F)
- 수락 시 그 길드 신청만 지워, A 의 다른 신청이 남아 이후 또 수락될 수 있다.
수정안
잠금 단위를 플레이어로 올리거나, 전역 소속 맵을 하나의 락으로 보호해 "검사+차지"를 원자화한다.
class GuildService {
public:
bool acceptApplication(Guild& g, long playerId) {
std::scoped_lock lk(globalMtx_); // 소속 맵 + 정원 판단을 함께 보호
if (playerGuild_.count(playerId)) return false; // 이미 소속
if ((int)g.members.size() >= g.capacity) return false;
g.members.push_back(playerId);
playerGuild_[playerId] = g.id; // 차지(원자적으로 검사와 한 임계영역)
removeAllApplications(playerId); // 이 플레이어의 모든 신청 무효화
return true;
}
private:
std::mutex globalMtx_; // 소속 맵 보호 (플레이어 불변식의 주인)
std::unordered_map<long,long> playerGuild_;
};
- 단일 전역 락이 부담되면 플레이어 id 로 샤딩한 락(
lockFor(playerId))을 써서 같은 플레이어 요청만 직렬화하고 길드 간 병렬성은 유지한다. - 멤버 목록과 소속 맵이 함께 갱신돼야 하므로, 둘을 같은 임계영역에서 수정한다(불변식: members 와 playerGuild_ 가 항상 일치).
더 나은 설계
- 불변식 → 잠금 단위 매핑: 깨지면 안 되는 규칙의 주체(플레이어)로 잠금을 잡는다. 길드별 락은 "그 길드 내부" 불변식(정원)만 책임진다.
- 영속 계층이 있으면
player_idPK/유니크 제약으로 DB가 한 소속을 보증(C# 해설의INSERT ... ON CONFLICT와 동형). - 한 플레이어의 가입/탈퇴를 액터/직렬 큐로 처리하면 락 없이도 직렬화된다.
면접 포인트
- "무엇을 보호하는 락인가" 와 "무엇을 지켜야 하는 불변식인가" 가 어긋나면 락이 있어도 경합이 난다 — 잠금 단위는 불변식의 주체를 따른다.
std::unordered_map등 표준 컨테이너는 동시 read/write 가 UB. 공유 컨테이너는 반드시 동일 락 아래에서만 접근.- 검사+상태변경을 한 임계영역(또는 unique 제약의 원자 차지)으로 묶어 TOCTOU 를 없앤다.