쿠폰 코드와 동시 요청
예를 들어 어드민에서 쿠폰을 만들 때마다 00001, 00002처럼 5자리 숫자 코드가 하나씩 붙는다고 하자. 사용자는 결제할 때 이 코드를 입력해 할인을 받는다.
그런데 관리자 두 명이 거의 같은 순간에 쿠폰 생성 버튼을 누르면 어떻게 될까? 두 쿠폰은 당연히 서로 다른 코드를 받아야 한다. 같은 코드가 두 개 생기면 사용자가 입력한 코드가 어느 쿠폰인지 알 수 없기 때문이다.
쿠폰 관리 API를 구현하면서 코드는 PostgreSQL 시퀀스로 발급했다. 기능은 문제없이 돌아갔지만, '두 요청이 동시에 들어오면 정말 안 겹칠까'라는 질문을 스스로 떠올려 보니 근거를 들어 설명하지는 못했다. 그래서 이번 글에서는 가장 단순한 방법부터 차례로 따라가며, 시퀀스가 이 문제를 어떻게 피하는지 공식 문서와 PostgreSQL 소스 코드로 살펴보도록 하자.
MAX + 1 방식의 문제
번호를 만드는 가장 단순한 방법은 지금 가장 큰 코드를 읽고 1을 더하는 것이다.
-- 지금까지 발급된 쿠폰 코드 중 숫자가 가장 큰 값을 가져옴; 1을 더해 INSERT
SELECT MAX(code) FROM coupons;이 방식은 번호를 읽는 단계와 저장하는 단계가 나뉘어 있다. 그래서 두 요청이 거의 동시에 들어오면, 둘 다 저장하기 전에 같은 최댓값을 읽게 된다.
코드 컬럼에 UNIQUE 제약이 걸려 있다면 같은 코드가 저장되지는 않는다. 대신 두 번째 요청은 실패하고, 실패한 요청을 다시 시도하는 로직을 따로 만들어야 한다. 만약 제약이 없었다면 같은 코드의 쿠폰이 두 개 생겼을 것이다.
락으로 막는 방법의 한계
그렇다면 한 요청이 최댓값을 읽는 동안 다른 요청은 기다리게 잠그면 되지 않을까? 먼저 떠오르는 것은 읽은 행을 잠그는 SELECT ... FOR UPDATE이다. 하지만 PostgreSQL에서는 MAX 같은 집계 결과에 이 잠금을 걸 수 없다. 집계 결과는 테이블의 특정 행 하나와 대응하지 않기 때문이다.
SELECT MAX(code) FROM coupons FOR UPDATE;
-- ERROR: FOR UPDATE is not allowed with aggregate functions가장 큰 코드를 가진 00041 행을 직접 찾아 잠가도 문제는 남는다. FOR UPDATE는 이미 있는 행만 잠그기 때문에, 우리가 지켜야 하는 아직 없는 00042는 잠금 범위 밖에 있다. 실제로 실험해 보면 기다린 요청이 여전히 00041을 읽고 중복 키 에러로 실패한다.
테이블 전체를 잠그면(LOCK TABLE) 번호는 겹치지 않는다. 하지만 잠금 모드에 따라서는 결제처럼 같은 테이블을 건드리는 다른 쓰기까지 멈춘다. 쓰기를 막지 않는 모드를 골라도, 쿠폰 생성 요청끼리는 트랜잭션이 끝날 때까지 줄을 서야 한다. 두 방법을 PostgreSQL과 MySQL에서 직접 실험한 과정은 FOR UPDATE로 MAX + 1 번호 발급을 막을 수 없는 이유와 테이블 락의 한계에서 자세히 다루었다.
즉, 다른 요청을 거의 막지 않으면서 겹치지 않는 번호를 주는 장치가 필요하다.
시퀀스(Sequence)와 nextval
PostgreSQL은 이런 용도로 시퀀스를 제공한다. 시퀀스는 '마지막으로 나눠 준 번호'를 저장하는 한 행짜리 특수한 테이블이고, nextval 함수를 호출하면 다음 번호를 받을 수 있다.
-- 1부터 시작하는 시퀀스를 만듦
CREATE SEQUENCE coupon_code_seq START 1;
SELECT nextval('coupon_code_seq'); -- 1
SELECT nextval('coupon_code_seq'); -- 2공식 문서는 여러 세션이 동시에 nextval을 호출하는 경우를 다음처럼 설명한다.
"This is done atomically: even if multiple sessions execute nextval concurrently, each will safely receive a distinct sequence value."
PostgreSQL 문서, Sequence Manipulation Functions
nextval의 동작 방식
그렇다면 nextval은 MAX + 1과 무엇이 다를까? 차이는 번호를 읽고, 더하고, 저장하는 세 단계를 누가 하느냐에 있다. MAX + 1은 애플리케이션이 이 세 단계를 나눠서 하기 때문에 그 사이에 다른 요청이 끼어들 수 있었다. 반면 nextval은 세 단계를 DB 안에서 한 번에 처리한다. PostgreSQL 소스의 nextval_internal 함수를 따라가 보면 그 과정은 다음과 같다.
- 시퀀스 데이터가 들어 있는 페이지에 배타 잠금을 건다
- 마지막으로 나눠 준 값을 읽고, 증가분을 더해 다음 값을 계산한다
- 계산한 값을 같은 자리에 덮어쓴다
- 페이지 잠금을 바로 푼다
예를 들어 마지막으로 나눠 준 값이 41일 때 두 관리자가 동시에 nextval을 호출하면, 이 네 단계는 다음 순서로 진행된다.
1번의 잠금은 시퀀스의 데이터를 읽는 read_seq_tuple 함수에서 건다.
// src/backend/commands/sequence.c (REL_17_STABLE), read_seq_tuple
// 시퀀스의 0번 페이지를 읽어 오고, 다른 세션이 동시에 읽거나 쓰지 못하게 배타 잠금 수행
*buf = ReadBuffer(rel, 0);
LockBuffer(*buf, BUFFER_LOCK_EXCLUSIVE);여기서 테이블 락과 결정적으로 다른 부분은 4번이다. nextval_internal은 값을 계산하고 저장한 직후 UnlockReleaseBuffer로 페이지 잠금을 푼다. 트랜잭션이 끝날 때까지 기다리지 않는다. 테이블 락은 INSERT 후 커밋할 때까지 다른 요청을 기다리게 하지만, nextval이 다른 요청을 막는 시간은 숫자 하나를 더하고 쓰는 동안뿐이다.
두 방식에서 B의 요청이 기다리는 구간을 나란히 놓으면 다음과 같다.
시퀀스를 활용한 쿠폰 코드 발급
이제 쿠폰 코드를 시퀀스로 발급해 보자. 마이그레이션에서 쿠폰 테이블과 함께 쿠폰 코드 전용 시퀀스를 만들고, 혹시 모를 중복을 막는 마지막 방어선으로 코드 컬럼에 UNIQUE 제약도 건다.
-- 쿠폰 코드 전용 시퀀스; 1부터 시작
CREATE SEQUENCE IF NOT EXISTS coupon_code_seq START 1;
-- 같은 코드가 두 번 저장되면 DB가 거부
ALTER TABLE coupons ADD CONSTRAINT coupons_code_unique UNIQUE (code);쿠폰을 만들 때는 시퀀스에서 다음 값을 받아 앞을 0으로 채운다. 아래는 node-postgres로 작성한 예시이다.
import { Pool } from "pg";
const pool = new Pool();
// 시퀀스에서 다음 번호를 받아 5자리 문자열 생성 (7 -> "00007")
async function generateCouponCode(): Promise<string> {
const { rows } = await pool.query<{ next_val: string }>(
"SELECT nextval('coupon_code_seq') AS next_val",
);
return rows[0].next_val.padStart(5, "0");
}nextval은 bigint를 반환하는데, node-postgres는 bigint를 JavaScript 숫자의 정밀도를 넘을 수 있다는 이유로 문자열로 돌려준다. 그래서 next_val의 타입을 string으로 두고 바로 padStart를 호출했다. padStart(5, "0")는 문자열이 5자리보다 짧으면 앞을 0으로 채워, 42를 '00042'로 만든다.
시퀀스의 번호 구멍
시퀀스가 잠금을 빨리 푸는 대신 포기한 것이 있는데, 그것은 트랜잭션이 롤백되어도 이미 나눠 준 번호를 되돌리지 않는다는 것이다.
"To avoid blocking concurrent transactions that obtain numbers from the same sequence, the value obtained by nextval is not reclaimed for re-use if the calling transaction later aborts."
PostgreSQL 문서, Sequence Manipulation Functions
생각해 보면 자연스러운 선택이다. 번호를 되돌리려면 그 번호를 받은 트랜잭션이 커밋할지 롤백할지 알 때까지 다음 요청이 기다려야 한다. 이는 결국 테이블 락과 같은 줄 세우기다. 그래서 쿠폰을 만드는 도중 INSERT가 실패하면 그 번호는 비게 되고, 00041 다음에 00043이 오게 된다.
롤백 외에도 번호가 건너뛰는 경우가 2가지 더 있다.
- CACHE 옵션을 1보다 크게 준 경우: 세션마다 번호를 여러 개 미리 받아 두고, 다음 호출부터는 시퀀스를 건드리지 않고 받아 둔 번호를 쓴다. 세션이 끝나면 쓰지 않은 번호는 사라지고, 세션 간 번호 순서도 섞일 수 있다. 기본값은 1이다. (PostgreSQL 문서, CREATE SEQUENCE)
- 서버가 비정상 종료된 경우:
nextval은 호출할 때마다 WAL(장애 복구를 위해 변경 내용을 먼저 남기는 로그)을 쓰지 않고, 32개(SEQ_LOG_VALS) 앞선 값까지 한 번에 기록해 둔다. 그래서 비정상 종료 후 복구되면 최대 32개의 번호를 건너뛸 수 있다.
즉, 시퀀스가 보장하는 것은 '겹치지 않는다는 것'이다. 쿠폰 코드는 겹치지만 않으면 되므로 번호 구멍은 문제가 되지 않는다. 하지만 중간에 빠진 번호가 없어야 하는 시스템에서는 시퀀스만으로 부족할 수 있다.
순차 코드의 한계
시퀀스로 겹치지 않는 코드는 얻었지만, 다시 보면 순차 코드 자체에 다음의 2가지 한계가 있다.
- 코드를 추측할 수 있다: 00001부터 차례로 올라가니, 코드 하나를 알면 앞뒤 번호도 유효한 쿠폰일 가능성이 높다. 사용자가 코드를 직접 입력하는 구조라면, 자기 코드의 앞뒤 번호를 넣어 남의 쿠폰을 쓰려는 시도가 가능하다. 코드를 순서 없는 랜덤 문자열로 만들면 다른 코드를 짐작할 수 없다. 다만 랜덤 값은 드물게 이미 있는 코드와 겹칠 수 있으므로, 코드 컬럼에 UNIQUE 제약을 걸어 두고 중복 에러가 나면 새로 만들어 다시 저장한다.
- 5자리를 넘으면 자릿수가 바뀐다:
padStart는 5자리보다 짧을 때만 0을 채우고, 길면 자르지 않는다. 그래서 100000번째 쿠폰부터는 6자리가 된다. 컬럼이varchar(20)이라 저장은 되지만, 화면이나 출력물이 5자리를 가정하고 있다면 깨질 수 있다.
정리
- MAX + 1은 읽기와 저장 사이에 틈이 있어서, 동시 요청이 같은 번호를 받을 수 있다.
FOR UPDATE는 아직 없는 다음 번호를 잠그지 못하고, 테이블 락은 커밋할 때까지 다른 요청을 줄 세운다.nextval은 시퀀스 페이지에 배타 잠금을 건 채 읽고 더하고 쓴 뒤 바로 잠금을 푼다. 그래서 겹치지 않으면서도 오래 기다리지 않는다.- 대신 롤백, CACHE, 비정상 종료로 번호 구멍이 생긴다. 쿠폰 코드는 겹치지만 않으면 되므로 문제가 되지 않지만, 중간에 빠진 번호가 없어야 하는 시스템이라면 시퀀스 외에 다른 방법을 함께 고려해야 한다.