← 문제로

21. SELECT MAX(id)+1 방식 순번 발급의 경합 (C#)

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

해설 — SELECT MAX(id)+1 방식 순번 발급의 경합 (C#)

난이도: 하

요약

번호를 "최대값 조회 (A) → +1 → 삽입 (B)" 로 발급하는데, 조회와 삽입이 하나의 원자 단위가 아니다. 동시 발송이 겹치면 둘 다 같은 maxNo 를 읽어 같은 newNo 로 INSERT → PK/UNIQUE 충돌로 한쪽 발송이 실패하거나, 제약이 없다면 번호가 중복된다.

문제점

  • [분류: check-then-act / TOCTOU · 애플리케이션 레벨 시퀀스 생성 경합]
    • 증상: 동시 우편 발송 중 일부가 "중복 키" 예외로 실패, 재시도해도 같은 경합 반복. 제약이 없으면 서로 다른 우편이 같은 mail_no 를 갖는 정합성 붕괴.
    • 재현: T1·T2 가 거의 동시에 (A) 실행 → 둘 다 MAX=100 → 둘 다 newNo=101(B) 에서 둘 다 101 INSERT → 하나는 성공, 하나는 UNIQUE 위반.
    • 근본 원인: 시퀀스 생성 로직을 DB의 원자적 기능이 아니라 앱에서 "읽고-계산하고-쓰는" 비원자 절차로 구현했다. 기본 격리수준으로는 두 트랜잭션이 같은 MAX 를 읽는 것을 못 막는다(둘 다 아직 미커밋 상태를 못 봄).

수정안

번호 발급을 DB의 원자적 시퀀스/IDENTITY에 위임한다.

-- PostgreSQL: 시퀀스
CREATE SEQUENCE mail_no_seq;
INSERT INTO mail(mail_no, receiver_id, payload)
VALUES (nextval('mail_no_seq'), @r, @p)
RETURNING mail_no;

-- 또는 IDENTITY/AUTO_INCREMENT 컬럼
CREATE TABLE mail (mail_no BIGINT GENERATED ALWAYS AS IDENTITY, ...);
public async Task<long> CreateMail(long receiverId, MailPayload payload)
{
    return await _db.QueryScalar<long>(
        "INSERT INTO mail(receiver_id, payload) VALUES(@r, @p) RETURNING mail_no",
        new { r = receiverId, p = payload });   // 번호는 DB가 원자적으로 발급
}

시퀀스/IDENTITY 는 트랜잭션과 무관하게 원자적으로 다음 값을 발급하므로 동시성에도 중복이 없다(대신 롤백 시 갭이 생길 수 있음 — 순번의 "연속성"에 의존하지 말 것). 연속 번호가 꼭 필요하면 전용 카운터 행에 UPDATE ... SET v=v+1 RETURNING v(원자 증가) 또는 INSERT ... ON CONFLICT 재시도를 쓴다.

더 나은 설계

  • 전역 유일성만 필요하면 UUID/Snowflake ID 로 DB 왕복 없이 분산 발급.
  • 사람이 읽는 연속 번호가 필요하면 원자 카운터 행 1개로 병목을 감수하거나, 발급을 배치(범위 예약: 한 번에 100개 받아 로컬 소비)로 완충.
  • 트레이드오프: 시퀀스는 갭 허용, Snowflake 는 시계 의존, 카운터 행은 경합 병목.

면접 포인트

  • MAX+1 이 왜 동시성에서 깨지는가, 기본 격리수준이 왜 못 막는가.
  • 시퀀스/IDENTITY 는 롤백 시 갭이 생긴다 — "연속성 가정" 금지.
  • 전역 유일 ID(UUID/Snowflake) vs 사람이 읽는 연속 번호의 트레이드오프.