21. SELECT MAX(id)+1 방식 순번 발급의 경합 (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 사람이 읽는 연속 번호의 트레이드오프.
해설 — 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에 위임한다(C++ 코드는 얇게).
CREATE SEQUENCE mail_no_seq;
INSERT INTO mail(mail_no, receiver_id, payload)
VALUES (nextval('mail_no_seq'), :r, :p) RETURNING mail_no;
int64_t CreateMail(int64_t receiverId, const MailPayload& payload) {
// 번호는 DB가 원자적으로 발급하고 RETURNING 으로 회수
return db_->InsertMailReturningNo(receiverId, payload);
}
시퀀스/IDENTITY 는 트랜잭션과 독립적으로 원자 발급되어 동시성에도 중복이 없다.
단 롤백 시 갭이 생기므로 "연속성"에 의존하면 안 된다. 연속 번호가 꼭 필요하면
전용 카운터 행에 원자 증가(UPDATE ... SET v=v+1 RETURNING v)를 쓴다.
더 나은 설계
- 전역 유일성만 필요하면 UUID/Snowflake 로 DB 왕복 없이 발급.
- 연속 번호가 필요하면 카운터 행 병목을 감수하거나 범위 예약(배치)으로 완충.
- 트레이드오프: 시퀀스=갭 허용, Snowflake=시계 의존, 카운터 행=경합 병목.
면접 포인트
- MAX+1 이 왜 동시성에서 깨지나, 기본 격리수준이 왜 못 막나.
- 시퀀스/IDENTITY 의 갭 발생 — 연속성 가정 금지.
- 전역 유일 ID vs 사람이 읽는 연속 번호의 트레이드오프.