← 문제로

9. SELECT-후-INSERT 중복 생성 레이스(고유 제약/ON CONFLICT 부재) (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 ...")
    • 증상: name UNIQUE 인덱스가 없어 중복 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/중복키 예외 변환, 영향 행 수로 "생성 성공"을 판정하는 법.