9. SELECT-후-INSERT 중복 생성 레이스(고유 제약/ON CONFLICT 부재) (C#)
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — SELECT-후-INSERT 중복 생성 레이스(고유 제약/ON CONFLICT 부재) (C#)
난이도: 하
요약
유일성을 애플리케이션 레벨 "확인 후 삽입" 으로만 보장한다. ExistsAsync(SELECT)와 ExecAsync(INSERT) 사이에 다른 요청이 끼면 둘 다 "없음"을 보고 둘 다 INSERT 해 같은 이름의 길드가 2개 생긴다. DB에 UNIQUE 제약이 없어 최종 방어선도 없다.
문제점
- [TOCTOU / check-then-act] (A)
if (await _db.ExistsAsync(...))- 증상: 같은 이름의 두 요청 T1, T2 가 동시에 오면 둘 다 SELECT 에서 행이 없어 통과 → 둘 다 INSERT.
- 재현 인터리빙:
T1.SELECT(없음) → T2.SELECT(없음) → T1.INSERT → T2.INSERT.await경계마다 다른 요청이 끼어들 수 있어 단일 프로세스에서도 발생. - 근본원인: 확인과 삽입이 원자 단위가 아님. READ COMMITTED 에서 SELECT 는 그 시점만 보장하고, 없는 행은 잠글 수도 없다.
- [DB 제약 부재] (B)
await _db.ExecAsync("INSERT ...")- 증상:
nameUNIQUE 인덱스가 없어 중복 INSERT 성공. 앱 검사를 우회하는 경로도 중복 생성 가능. - 근본원인: 불변식(이름 유일성)을 코드에만 의존 — 레이스·다중 인스턴스에서 깨짐.
- 증상:
수정안
DB가 유일성을 강제(UNIQUE)하고, INSERT 의 충돌을 원자적으로 흡수하거나 중복키 예외를 "이미 존재"로 변환한다.
ALTER TABLE guild ADD CONSTRAINT uq_guild_name UNIQUE (name);
-- 정규화가 필요하면: CREATE UNIQUE INDEX uq_guild_name_norm ON guild (lower(name));
public async Task<bool> CreateGuildAsync(string name, ulong ownerId)
{
// 충돌을 원자적으로 흡수: 1행 INSERT 성공 시 true, 충돌(이미 존재)이면 0행 → false
int rows = await _db.ExecAsync(
"INSERT INTO guild(name, owner_id) VALUES(@n, @o) " +
"ON CONFLICT (name) DO NOTHING", // MySQL: INSERT IGNORE
name, ownerId);
return rows == 1;
}
// ON CONFLICT 를 못 쓰면 중복키 예외를 잡아 변환:
// try { await _db.ExecAsync("INSERT ..."); return true; }
// catch (DbException ex) when (IsUniqueViolation(ex)) { return false; }
// (PostgreSQL SqlState "23505", SQL Server 2601/2627, MySQL 1062)
- 사전
ExistsAsync는 유지해도 좋으나 UX 안내용일 뿐, 정확성은 UNIQUE 제약이 보장.
더 나은 설계
- 불변식은 데이터 계층에: 유일/외래키/체크는 앱이 아니라 DB 제약으로. 코드 검사는 레이스·다중 인스턴스에서 항상 뚫린다.
- 정규화 키: 대소문자·공백·동형문자 정규화 후 UNIQUE 라야 실효.
- 예약 패턴: 생성이 2단계면 이름을 짧은 TTL 로 먼저 예약 INSERT → 확정/취소.
persistence_data/problem3(멱등성)·problem8(lost update)과 한 묶음: "확인 후 행위"는 원자화하거나 제약으로 강제. async의 함정:await경계마다 다른 요청이 끼어들 수 있어, 단일 프로세스라도 "확인 후 삽입"은 비원자. 락이 아니라 DB 제약으로 해결.
면접 포인트
- "확인 후 삽입"이 동시성에서 깨지는 이유(TOCTOU)와 없는 행을 잠글 수 없는 점.
- 유일성을 코드가 아니라 UNIQUE 제약으로 강제해야 하는 이유, 사전 SELECT 의 역할.
ON CONFLICT/INSERT IGNORE/중복키 예외 변환, 영향 행 수로 "생성 성공"을 판정하는 법.
해설 · C++
해설 — SELECT-후-INSERT 중복 생성 레이스(고유 제약/ON CONFLICT 부재) (C++)
난이도: 하
요약
유일성을 애플리케이션 레벨 "확인 후 삽입" 으로만 보장한다. SELECT(존재 확인)와 INSERT 사이에 다른 트랜잭션이 끼어들면 둘 다 "없음"을 보고 둘 다 INSERT 해 같은 이름의 길드가 2개 생긴다. DB에 고유 제약(UNIQUE)이 없어 최종 방어선도 없다.
문제점
- [TOCTOU / check-then-act] (A)
if (db_.exists("SELECT 1 ... name=?"))- 증상: 두 요청 T1, T2 가 같은 이름으로 거의 동시에 들어오면, 둘 다 SELECT 시점엔 행이 없어
false→ 둘 다 INSERT 통과. - 재현 인터리빙:
T1.SELECT(없음) → T2.SELECT(없음) → T1.INSERT → T2.INSERT→ 중복. - 근본원인: 확인과 삽입이 하나의 원자 단위가 아님. 일반 격리수준(READ COMMITTED)에서 SELECT 는 "그 시점"만 보장하고, 존재하지 않는 행은 잠글 수도 없다(잠글 행이 없음).
- 증상: 두 요청 T1, T2 가 같은 이름으로 거의 동시에 들어오면, 둘 다 SELECT 시점엔 행이 없어
- [DB 제약 부재] (B)
INSERT ... VALUES(?, ?)- 증상: 스키마에
nameUNIQUE 인덱스가 없어 중복 INSERT 가 그대로 성공. 앱 검사를 우회하는 어떤 경로(다른 서비스/수동 작업)도 중복을 만들 수 있다. - 근본원인: 불변식(이름 유일성)을 데이터에 강제하지 않고 코드에만 의존. 코드 검사는 레이스·다중 인스턴스에서 깨진다.
- 증상: 스키마에
수정안
DB가 유일성을 강제하게 한다(UNIQUE 제약). 삽입은 충돌을 흡수(ON CONFLICT/INSERT IGNORE)하거나 중복키 오류를 잡아 "이미 존재"로 변환한다. 앱 SELECT 는 UX용 사전 안내일 뿐, 정답은 제약이다.
-- 1) 스키마: 최종 방어선
ALTER TABLE guild ADD CONSTRAINT uq_guild_name UNIQUE (name);
-- 대소문자/공백 정규화가 필요하면 정규화 컬럼에 UNIQUE 또는 함수 인덱스:
-- CREATE UNIQUE INDEX uq_guild_name_norm ON guild (lower(name));
// 2) 삽입: 충돌을 원자적으로 흡수하고 결과로 판정
bool createGuild(Db& db, const std::string& name, uint64_t ownerId) {
// INSERT 가 유일하게 성공한 1건만 true. 충돌이면 0행 → false.
int rows = db.execAffected(
"INSERT INTO guild(name, owner_id) VALUES(?, ?) "
"ON CONFLICT (name) DO NOTHING", // MySQL: INSERT IGNORE / ON DUPLICATE KEY
name, ownerId);
return rows == 1; // 1=내가 만든 길드, 0=이미 존재
}
- MySQL 이면
INSERT IGNORE또는INSERT ... ON DUPLICATE KEY UPDATE+ 영향행 수 판정. 드라이버가 ON CONFLICT 를 못 주면 중복키 예외(SQLSTATE 23505) 를 catch 해false로 변환. - 사전 SELECT 는 유지해도 좋다 — 단지 "사용 중인 이름입니다" UX 안내용이고, 정확성은 INSERT 의 제약이 보장한다.
더 나은 설계
- 불변식은 데이터 계층에: "유일/외래키/체크" 같은 불변식은 앱 코드가 아니라 DB 제약으로. 코드 검사는 레이스·다중 인스턴스·우회 경로에서 항상 뚫린다.
- 정규화 키: 이름 유일성은 보통 대소문자·공백·동형문자(homoglyph) 정규화 후 비교해야 실효. 정규화 결과에 UNIQUE.
- 예약(reservation) 패턴: 생성에 시간이 걸리면(2단계) 이름을 먼저 짧은 TTL 로 예약 INSERT 했다가 확정/취소.
persistence_data/problem3(멱등성)·problem8(lost update)과 결: "확인 후 행위"는 항상 원자화하거나 제약으로 강제. - 분산 환경: 여러 인스턴스가 같은 DB 를 쓰면 UNIQUE 가 그대로 동작. DB 가 분리/샤딩되면 이름 공간을 한 곳(중앙 레지스트리)에 두거나 분산 락.
면접 포인트
- "확인 후 삽입"이 왜 동시성에서 깨지는가(TOCTOU), 그리고 존재하지 않는 행은 왜 잠글 수 없는가.
- 유일성 불변식을 코드가 아니라 UNIQUE 제약으로 강제해야 하는 이유 — 앱 검사는 사전 안내·UX 용.
ON CONFLICT/INSERT IGNORE/중복키 예외 변환의 차이와 영향행 수로 "내가 만든 것"을 판정하는 법.