레이스 컨디션 취약점이란? 포인트 이중 사용의 원인과 방어법
레이스 컨디션 취약점으로 포인트 이중 사용과 선착순 초과가 생기는 원리, 트랜잭션만으로 부족한 이유, 원자적 업데이트·행 잠금·유니크 제약 선택 기준과 점검법을 코드로 정리했습니다.
레이스 컨디션 취약점은 같은 데이터를 다루는 요청들이 동시에 실행될 때, 처리 순서에 따라 결과가 달라지면서 업무 규칙이 깨지는 결함입니다. 잔액 1,000포인트로 1,000포인트 상품을 두 번 사거나, 100명 한정 쿠폰이 103장 나가는 일이 대표적입니다. 두 요청이 모두 "아직 잔액이 있다"는 같은 값을 읽고, 그 값을 믿은 채로 각자 수정하기 때문에 생깁니다.
막는 방법은 검사와 수정 사이의 틈을 없애는 것입니다. 포인트처럼 숫자 하나를 조건부로 바꾸는 작업은 원자적 업데이트, 읽은 값으로 복잡한 판단을 해야 하는 작업은 행 잠금, "한 번만"을 보장해야 하는 작업은 유니크 제약이 기본 선택입니다. 트랜잭션을 거는 것만으로는 대부분 막히지 않습니다.
이 결함은 CWE-362(부적절한 동기화를 동반한 공유 자원 동시 실행)로 분류되고, 검사와 사용 사이가 벌어지는 형태는 TOCTOU(CWE-367)라고 부릅니다. 아래에서는 포인트 차감과 선착순 이벤트 코드를 예로, 왜 뚫리고 무엇을 골라야 하는지 살펴봅니다.
트랜잭션만 걸어도 뚫리는 이유
가장 흔한 코드는 다음과 같은 "조회 후 수정" 패턴입니다. 트랜잭션 안에 있어서 안전해 보입니다.
// 취약한 코드: 트랜잭션 안의 조회 후 수정
await db.transaction(async (tx) => {
const { rows } = await tx.query(
'SELECT balance FROM point_accounts WHERE user_id = $1',
[userId],
);
if (rows[0].balance < amount) throw new Error('포인트 부족');
await tx.query(
'UPDATE point_accounts SET balance = $1 WHERE user_id = $2',
[rows[0].balance - amount, userId],
);
await tx.query(
'INSERT INTO orders (user_id, product_id) VALUES ($1, $2)',
[userId, productId],
);
});
잔액 1,000에 1,000포인트짜리 주문 요청이 동시에 두 건 들어오면 이렇게 진행됩니다.
시점 트랜잭션 A 트랜잭션 B
t1 SELECT → 1000
t2 SELECT → 1000
t3 검사 통과 검사 통과
t4 UPDATE balance = 0
t5 COMMIT
t6 UPDATE balance = 0
t7 COMMIT
결과 주문 2건, 차감은 1,000포인트 한 번 분량
트랜잭션의 기본 역할은 여러 작업을 함께 성공하거나 함께 취소하게 묶는 것입니다. 기본 설정의 트랜잭션은 다른 요청이 같은 행을 읽는 것을 막아 주지 않습니다.
- PostgreSQL 기본값(Read Committed): 일반
SELECT는 잠금을 잡지 않습니다. 두 트랜잭션 모두 1,000을 읽습니다. 뒤따르는UPDATE는 앞선 트랜잭션이 끝나길 기다리지만, 애플리케이션이 계산해 넘긴 값0은 다시 계산되지 않습니다. - MySQL InnoDB 기본값(Repeatable Read): 일반
SELECT는 스냅샷을 읽습니다. MySQL 공식 문서도 같은 트랜잭션에서 조회 후 수정할 때 일반SELECT로는 보호가 충분하지 않다고 설명합니다.
SET balance = balance - $1로 바꾸면 차감 누락은 사라지지만 문제가 끝나지는 않습니다. 검사는 여전히 낡은 값으로 통과했으므로 잔액이 -1,000이 됩니다. 값을 덮어쓰는 문제가 초과 사용 문제로 바뀐 것입니다.
트랜잭션 밖의 방어도 비슷하게 새는 경우가 많습니다. 애플리케이션 메모리의 뮤텍스나 Java synchronized는 서버가 두 대가 되면 효과가 없습니다. 레이트 리밋은 요청 수를 줄일 뿐입니다. 이중 사용에는 동시 요청 두 건이면 충분하고, PortSwigger는 HTTP/2의 단일 패킷 기법으로 20~30개 요청을 사실상 동시에 완료시킬 수 있다고 설명합니다. 세션에 "사용함" 표시를 남기는 방식도 DB 작업의 한도를 지키는 수단으로는 부적합합니다.
포인트 차감: 원자적 업데이트로 틈을 없앤다
검사와 차감을 SQL 한 문장에 넣으면 틈 자체가 없어집니다. 영향받은 행 수가 곧 검사 결과입니다.
// 고친 코드: 조건부 원자적 업데이트
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error('잘못된 금액');
}
await db.transaction(async (tx) => {
const result = await tx.query(
`UPDATE point_accounts
SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1`,
[amount, userId],
);
if (result.rowCount === 0) throw new Error('포인트 부족');
await tx.query(
`INSERT INTO point_ledger (user_id, delta, order_id)
VALUES ($1, $2, $3)`,
[userId, -amount, orderId],
);
});
PostgreSQL 문서에 따르면 Read Committed에서 두 번째 UPDATE는 첫 번째 트랜잭션이 끝나길 기다린 뒤, 갱신된 행을 기준으로 WHERE 조건을 다시 평가합니다. 그래서 두 번째 요청은 잔액 0을 보고 조건에 걸려 0행을 수정합니다.
여기서 트랜잭션의 쓸모가 제대로 드러납니다. 틈을 막는 일은 조건부 UPDATE가 하고, 트랜잭션은 차감과 원장 기록이 함께 반영되도록 묶습니다.
놓치기 쉬운 부분도 있습니다. 영향받은 행 수를 확인하지 않으면 실패한 차감 뒤에 주문이 생성됩니다. 금액 양수 검증이 없으면 음수 차감으로 포인트가 늘어납니다. 안전망으로 CHECK (balance >= 0) 제약을 함께 두면 다른 코드 경로의 실수도 잡을 수 있습니다. 다만 쓰는 DB 버전에서 CHECK가 실제로 강제되는지는 확인해야 합니다.
행 잠금: 읽은 값으로 판단해야 할 때
회원 등급별 차감 한도, 쿠폰과 포인트의 혼합 계산처럼 조건을 SQL 한 문장에 담기 어려운 경우가 있습니다. 이때는 SELECT ... FOR UPDATE로 읽는 순간부터 다른 수정을 줄 세웁니다.
// 고친 코드: 행 잠금 후 판단
await db.transaction(async (tx) => {
const { rows } = await tx.query(
`SELECT balance, grade FROM point_accounts
WHERE user_id = $1 FOR UPDATE`,
[userId],
);
const usable = calcUsablePoints(rows[0], order);
if (usable < amount) throw new Error('사용 한도 초과');
await tx.query(
`UPDATE point_accounts SET balance = balance - $1
WHERE user_id = $2`,
[amount, userId],
);
});
행 잠금을 쓸 때는 네 가지를 지켜야 합니다.
- 트랜잭션 안에서 호출합니다. MySQL 문서는 잠금 읽기가 autocommit이 꺼진 상태에서만 가능하다고 명시합니다. 잠금은 트랜잭션이 끝날 때 풀리므로, 자동 커밋으로 실행하면 다음 문장 전에 보호가 사라집니다.
- 잠금 중에 외부 API를 호출하지 않습니다. PG 응답을 기다리는 동안 같은 사용자의 요청이 줄줄이 대기합니다. 외부 호출은 트랜잭션 밖으로 빼고 상태 컬럼으로 진행 단계를 관리합니다. 결제 흐름의 서버 검증은 결제 금액 변조 취약점 글에서 단계별로 다뤘습니다.
- 여러 행은 항상 같은 순서로 잠급니다. 사용자 간 포인트 이체라면 ID 오름차순처럼 순서를 고정해 교착 상태를 줄입니다.
- 없는 행은 잠글 수 없습니다. "참여 기록이 없으면 생성"은 조회 결과가 0건이면 PostgreSQL에서 잠글 대상이 없습니다. 이 경우는 유니크 제약의 몫입니다.
선착순 이벤트: 유니크 제약과 조건부 업데이트를 함께
선착순 쿠폰에는 서로 다른 규칙이 두 개 있습니다. "한 사람당 한 장"과 "전체 100장"입니다. 규칙마다 막는 도구가 다릅니다.
// 취약한 코드: 두 규칙 모두 조회 후 처리
const event = await tx.query('SELECT issued, capacity FROM events WHERE id = $1', [eventId]);
const entry = await tx.query(
'SELECT 1 FROM event_entries WHERE event_id = $1 AND user_id = $2',
[eventId, userId],
);
if (entry.rowCount === 0 && event.rows[0].issued < event.rows[0].capacity) {
await tx.query('INSERT INTO event_entries (event_id, user_id) VALUES ($1, $2)', [eventId, userId]);
await tx.query('UPDATE events SET issued = issued + 1 WHERE id = $1', [eventId]);
}
고친 코드는 판단을 모두 DB에 맡깁니다.
-- 스키마: 한 사람당 한 장은 제약으로 고정
ALTER TABLE event_entries
ADD CONSTRAINT uq_event_user UNIQUE (event_id, user_id);
// 고친 코드: 유니크 제약 + 조건부 업데이트
await db.transaction(async (tx) => {
const entry = await tx.query(
`INSERT INTO event_entries (event_id, user_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[eventId, userId],
);
if (entry.rowCount === 0) throw new Error('이미 참여함');
const seat = await tx.query(
`UPDATE events SET issued = issued + 1
WHERE id = $1 AND issued < capacity`,
[eventId],
);
if (seat.rowCount === 0) throw new Error('마감'); // 참여 기록도 함께 롤백
});
같은 사용자의 요청이 겹치면 뒤의 INSERT는 앞선 트랜잭션의 커밋 또는 롤백을 기다린 뒤 충돌 여부가 결정됩니다. 애플리케이션의 존재 여부 검사와 달리 틈이 없습니다. 조건을 넣은 고유 제약이 필요하면 PostgreSQL의 부분 유니크 인덱스도 쓸 수 있습니다. 쿠폰 코드, 결제 승인번호, 멱등성 키처럼 한 번만 쓰여야 하는 값은 모두 같은 방식으로 지킵니다.
인기 이벤트에서는 events 한 행에 쓰기가 몰려 대기가 길어집니다. 이때는 쿠폰을 수량만큼 미리 행으로 만들고 FOR UPDATE SKIP LOCKED로 비어 있는 행을 하나씩 가져가는 구조를 검토합니다. MySQL 문서가 안내하듯 이 옵션은 일관되지 않은 뷰를 반환하므로 일반 트랜잭션용이 아니라 큐 형태의 테이블에 적합합니다.
무엇을 고를지: 판단 기준
먼저 "절대 깨지면 안 되는 규칙"을 한 문장으로 적어 보면 도구가 거의 정해집니다.
| 지켜야 할 규칙 | 기본 선택 | 이유 | 주의할 점 |
|---|---|---|---|
| 숫자가 한도를 넘지 않음 (잔액, 재고) | 원자적 업데이트 | 대기 시간이 짧고 코드가 단순 | 영향받은 행 수 확인 |
| 값이 중복되지 않음 (1인 1매, 쿠폰 1회) | 유니크 제약 | 모든 코드 경로에 적용되고 없는 행도 보호 | 충돌을 정상 응답으로 처리 |
| 읽은 값으로 복잡한 판단 후 수정 | 행 잠금 | 조건을 SQL에 담기 어려울 때 유효 | 짧게 유지, 잠금 순서 고정 |
| 충돌이 드물고 긴 편집 흐름 | 버전 컬럼 낙관적 잠금 | 평소 대기가 없음 | 실패 시 재시도나 안내 필요 |
세 가지 질문으로 정리할 수 있습니다.
- 조건을
UPDATE의WHERE한 줄로 쓸 수 있는가? 그렇다면 원자적 업데이트입니다. - 규칙이 "한 번만" 또는 "중복 없음"인가? 그렇다면 유니크 제약입니다. 이 제약은 다른 방어를 쓰더라도 안전망으로 추가할 가치가 있습니다.
- 둘 다 아니고 읽은 값으로 판단해야 하는가? 그렇다면 행 잠금입니다.
격리 수준을 올리는 방법도 있습니다. PostgreSQL의 Repeatable Read는 동시 수정이 커밋된 행을 고치려 하면 트랜잭션을 실패시키고, Serializable은 더 넓은 이상 현상을 막습니다. 다만 공식 문서가 강조하듯 애플리케이션이 직렬화 실패(SQLSTATE 40001)를 받아 전체 트랜잭션을 재시도하도록 준비되어 있어야 합니다. 재시도 공통 처리 없이 설정만 올리면 오류가 사용자에게 그대로 노출됩니다. 또한 MySQL의 Repeatable Read는 이름이 같아도 이런 방식으로 조회 후 수정을 막아 주지 않으니, DB별 동작을 확인해야 합니다.
내 코드에서 찾고 검증하는 법
웹 스캐너는 "이 포인트가 두 번 쓰이면 안 된다"는 업무 규칙을 모릅니다. 그래서 이런 결함은 스캐너 결과가 깨끗해도 남아 있기 쉽고, 코드 리뷰가 가장 효율적입니다.
코드 점검 체크리스트
- 돈·포인트·재고·쿠폰·횟수 제한을 바꾸는 API를 전부 목록으로 만들었다
-
find후if후save또는update로 이어지는 흐름이 없다 - JPA·Prisma 등 ORM에서 엔티티를 읽어 필드를 고치고 저장하는 코드를 확인했다
- 조건부
UPDATE의 영향받은 행 수를 코드에서 확인한다 - "한 번만" 규칙마다 DB 유니크 제약이 있다
- 잔액·재고에 음수 방지 제약이 있다
- 판단용 조회가 읽기 복제본이나 캐시를 보지 않는다
- 잠금을 잡은 채 외부 API를 호출하지 않는다
- 환불·취소·재발급 같은 역방향 API도 같은 기준으로 봤다
- 클라이언트 재시도를 대비한 멱등성 키가 유니크 제약으로 보호된다
동시성 테스트
코드 리뷰와 함께, 자기 테스트 환경에서 불변 조건을 검증하는 테스트를 CI에 넣습니다.
test('잔액 1000으로 1000포인트 주문은 한 건만 성공한다', async () => {
await seedBalance(userId, 1000);
const results = await Promise.allSettled(
Array.from({ length: 20 }, () =>
fetch('https://staging.example.com/api/orders', orderRequest),
),
);
const success = await countOrders(userId);
expect(success).toBe(1);
expect(await getBalance(userId)).toBe(0);
});
성공 여부가 타이밍에 좌우되므로 여러 번 반복해 돌립니다. 인메모리 DB나 커넥션 1개짜리 풀에서는 요청이 줄을 서서 재현되지 않을 수 있으니, 운영과 같은 DB 엔진과 복수의 커넥션, 가능하면 복수의 인스턴스로 구성합니다. 이 테스트는 자기 서비스의 테스트 환경에서만 실행해야 합니다.
운영 중에는 대사(정합성 대조) 쿼리가 탐지 수단이 됩니다. 원장 합계와 잔액이 다른 계정, capacity보다 많은 참여 기록, 음수 잔액을 주기적으로 찾아 알림을 보내면 코드가 놓친 경로를 발견할 수 있습니다.
자주 묻는 질문
Redis 분산 락을 쓰면 DB 처리는 안 해도 되나요?
권장하지 않습니다. 락 만료나 장애 시 보호가 풀릴 수 있고, 저장소가 둘로 나뉘면 정합성 문제가 하나 더 생깁니다. Redis는 트래픽을 앞단에서 걸러 내는 용도로 쓰고, 최종 판단은 데이터가 있는 DB의 조건부 업데이트와 제약으로 하는 편이 안전합니다.
저희는 Node.js 단일 프로세스인데도 레이스 컨디션이 생기나요?
생깁니다. 코드가 한 스레드에서 돌아도 await로 DB 응답을 기다리는 사이 다른 요청이 실행됩니다. DB에서 읽고 판단하고 쓰는 구조라면 조회 후 수정의 틈은 똑같이 존재합니다.
프런트엔드 버튼 비활성화로는 막을 수 없나요?
사용자의 실수로 인한 중복 클릭은 줄지만 보안 통제는 아닙니다. 요청은 화면을 거치지 않고도 서버로 직접 보낼 수 있으므로 규칙은 서버와 DB에서 강제해야 합니다.
정리
레이스 컨디션 취약점의 뿌리는 검사와 수정이 분리된 조회 후 수정 패턴이며, 기본 설정의 트랜잭션만으로는 이 틈이 닫히지 않습니다. 숫자 한도는 원자적 업데이트, 중복 금지는 유니크 제약, 복잡한 판단은 행 잠금을 기본으로 고릅니다. 영향받은 행 수 확인과 동시성 테스트, 대사 쿼리까지 갖춰야 방어가 유지됩니다. 직접 점검이 어렵다면 무료 분석 신청으로 분석을 받아볼 수 있습니다.
참고 자료
- PostgreSQL Documentation: Transaction Isolation
- PostgreSQL Documentation: Index Uniqueness Checks
- PostgreSQL Documentation: Constraints
- MySQL 8.4 Reference Manual: Locking Reads
- MySQL Reference Manual: Consistent Nonlocking Reads
- PortSwigger Web Security Academy: Race conditions
- PortSwigger Research: The single-packet attack
- OWASP Community: Race Conditions
- MITRE CWE-362: Race Condition
함께 읽으면 좋은 글
개인정보 파기 방법: 회원 탈퇴 후 DB·백업·로그 처리법
개인정보 파기 시점과 방법, 법정 보존 데이터의 분리 보관, 백업·로그·외부 서비스 사본 처리, 파기 기록까지 회원 탈퇴 흐름을 구현 단위로 정리했습니다.
Django 보안 설정 가이드: check --deploy가 못 잡는 빈틈
Django 보안 설정을 배포 전에 점검하는 가이드다. check --deploy가 잡는 항목과 놓치는 DRF 기본 권한, 객체 권한, 프록시 HTTPS, Admin 노출을 고친 코드로 확인한다.
웹서비스 출시 전 보안 점검표: 배포 환경에서 놓치는 10가지
출시 전 보안 점검을 코드 밖 배포 환경에 집중해 정리했습니다. 디버그 모드, .git, 백업 파일, 스테이징, 기본 계정 등 10가지를 curl로 확인하고 고치는 방법을 얻을 수 있습니다.