대부분의 웹 코드는 요청이 한 번에 하나씩 도착한다고 가정하고 작성됩니다. 잔액을 읽고, 충분한지 확인하고, 빼고, 저장합니다. 손으로 테스트하면 매번 잘 동작합니다. 그런데 스무 번을 동시에 보내면 같은 돈을 스무 번 쓸 수도 있습니다.
이런 버그가 경쟁 상태(CWE-362)이며, 가장 흔한 형태는 검사 시점과 사용 시점의 불일치, 즉 TOCTOU(CWE-367)입니다. 애플리케이션이 조건을 확인하고 그에 따라 행동하는 사이에 조건이 바뀌는 것이죠. 각 줄이 그 자체로는 올바르게 보이기 때문에 리뷰에서 놓치기 쉽습니다. 버그는 두 줄 사이의 틈에 있습니다.
Node.js도 예외가 아닌 이유
단일 스레드 런타임에는 경쟁 상태가 있을 수 없다는 믿음이 흔합니다. JavaScript가 한 번에 콜백 하나를 실행하는 것은 맞지만, 모든 await는 다른 요청이 실행될 수 있는 지점입니다. 데이터베이스는 모든 인스턴스와 모든 워커, 모든 요청이 공유합니다. SELECT와 UPDATE 사이에는 무슨 일이든 일어날 수 있습니다.
// 취약: 두 개의 await에 걸친 검사 후 실행
export async function withdraw(userId: string, amount: number) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < amount) {
throw new Error('Insufficient funds');
}
// 이 줄이 실행되기 전에 다른 요청이 같은 검사를 통과할 수 있습니다
await db.account.update({
where: { userId },
data: { balance: account.balance - amount },
});
}두 요청이 잔액 100을 읽고, 둘 다 100 출금 검사를 통과하고, 둘 다 0을 씁니다. 100이 들어 있던 계좌에서 사용자는 200을 가져갔습니다. 두 번째 쓰기가 낡은 데이터로 계산한 값으로 첫 번째를 덮어쓰기 때문에 로그에는 이상한 점이 전혀 남지 않습니다. ORM을 써도 달라지지 않습니다. 레코드를 객체로 불러와 수정하고 저장하는 것은 같은 읽기-수정-쓰기 패턴이며 틈도 똑같습니다.
경쟁이 나타나는 곳
- 잔액과 크레딧, 지갑: 같은 자금을 이중으로 쓰는 경우.
- 쿠폰과 기프트 카드: 1회용 코드를 여러 번 사용하는 경우.
- 플랜과 사용량 한도: 플랜이 허용하는 것보다 많은 프로젝트나 좌석, API 호출을 만드는 경우.
- 가입과 초대: 같은 이메일로 계정 두 개를 만들거나 초대 하나를 두 번 수락하는 경우.
- 투표와 좋아요, 평점: 한 사용자를 두 번 이상 집계하는 경우.
- 결제와 주문 흐름: 웹훅과 리디렉션이 함께 도착해 주문을 두 번 처리하는 경우.
악용하기도 어렵지 않습니다. Promise.all이나 curl, 프록시 도구로 요청을 한꺼번에 보내면 수 밀리초짜리 창도 충분히 노릴 수 있고, 단일 패킷 공격 같은 기법은 요청이 서버에 거의 동시에 도착하게 만듭니다. 경쟁이 존재한다면 누군가는 이길 수 있다고 가정하세요.
해결책 1: 규칙을 데이터베이스가 강제하게 하세요
가장 강력한 해결책은 규칙을 데이터베이스로 옮기는 것입니다. 데이터베이스는 동시 요청을 알아서 직렬화해 줍니다. 대부분의 경우는 두 가지 도구로 충분합니다. 고유 제약 조건과 원자적 조건부 갱신입니다.
-- 사용자당 1회: 타이밍과 무관하게 두 번째 INSERT가 실패합니다
CREATE UNIQUE INDEX coupon_redemptions_once
ON coupon_redemptions (coupon_id, user_id);
-- 전체 사용 상한: 한 구문 안에서 검사와 증가를 함께 수행합니다
UPDATE coupons
SET uses = uses + 1
WHERE id = $1
AND uses < max_uses
RETURNING id;
-- 잔액: 조건은 현재 행을 기준으로 평가됩니다
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2
AND balance >= $1
RETURNING balance;
-- 이중 안전장치: 잔액은 결코 음수가 될 수 없습니다
ALTER TABLE accounts
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);조건부 UPDATE가 동작하는 이유는 데이터베이스가 WHERE 절을 평가하는 동안 행을 잠그기 때문입니다. 두 요청이 경쟁하면 두 번째 요청은 첫 번째가 커밋한 행을 기준으로 조건을 다시 확인합니다. 반환되는 행이 없다면 조건이 실패한 것이므로 오류를 반환하면 됩니다. 검사와 쓰기가 같은 구문이므로 악용할 틈 자체가 없습니다.
애플리케이션 코드에서는 고유 제약 위반을 정상적인 결과로 취급하세요. PostgreSQL에서는 오류 코드 23505로 도착합니다. 500이 아니라 '이미 사용된 쿠폰입니다' 같은 명확한 메시지로 변환하세요.
대부분의 ORM은 조건부 갱신을 표현할 수 있습니다. Prisma에서는 where 절에 잔액 조건을 넣은 updateMany가 건수를 반환하며, 0이면 검사가 실패했다는 뜻입니다. findUnique 뒤에 별도의 update를 두는 방식은 검사를 아무리 신중하게 작성해도 같은 보장을 주지 못합니다.
해결책 2: SELECT ... FOR UPDATE로 행을 잠그세요
때로는 판단에 구문 하나 이상이 필요합니다. 여러 필드를 읽고, 가격 계산 함수를 호출하고, 두 테이블에 쓰는 식이죠. 그럴 때는 트랜잭션 안에서 행 락을 잡으세요. SELECT ... FOR UPDATE는 같은 행을 잠그려는 다른 트랜잭션이 여러분의 트랜잭션이 커밋되거나 롤백될 때까지 기다리게 만듭니다.
트랜잭션만으로는 충분하지 않습니다. PostgreSQL의 기본 격리 수준은 READ COMMITTED이며, 그 수준에서는 앞서 본 취약한 코드를 BEGIN과 COMMIT으로 감싸도 달라지는 것이 없습니다. 두 트랜잭션이 같은 잔액을 읽고, 둘 다 검사를 통과하고, 두 쓰기가 모두 성공합니다. 두 번째 트랜잭션을 기다리게 한 뒤 커밋된 값을 읽게 만드는 것은 락입니다.
import { Pool } from 'pg';
const pool = new Pool();
export async function purchase(userId: string, itemId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[userId],
);
const item = await client.query(
'SELECT price FROM items WHERE id = $1',
[itemId],
);
if (rows.length === 0 || item.rows.length === 0) {
throw new Error('Not found');
}
const price = Number(item.rows[0].price);
if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[price, userId],
);
await client.query(
'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
[userId, itemId, price],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}두 가지 세부 사항이 중요합니다. 락은 잔액을 바꾸는 모든 코드 경로가 함께 잡아야만 효과가 있습니다. 락 없이 갱신하는 경로 하나가 경쟁을 다시 열어 줍니다. 그리고 전체 과정이 같은 클라이언트를 사용해야 합니다. 풀의 한 연결에서 BEGIN을 실행하고 다른 연결에서 SELECT를 실행하면 트랜잭션이 아예 성립하지 않습니다. ORM도 인터랙티브 트랜잭션이나 원시 쿼리를 통해 같은 패턴을 제공합니다.
해결책 3: 여러 행에 걸친 규칙에는 어드바이저리 락을
행 락은 '무료 플랜 사용자는 프로젝트를 최대 세 개까지 가질 수 있다'처럼 아직 존재하지 않는 행에 대한 규칙에는 도움이 되지 않습니다. 두 요청이 각각 프로젝트 두 개를 세고 각각 세 번째를 삽입할 수 있습니다. PostgreSQL의 어드바이저리 락을 쓰면 사용자 ID 같은 임의의 키를 트랜잭션 동안 잠글 수 있습니다.
트랜잭션 안에서 사용자로부터 파생한 키로 pg_advisory_xact_lock을 호출한 뒤 세고 삽입하세요. 이 함수는 64비트 정수 키를 받으므로 사용자 ID에서 안정적인 정수를 도출하세요. 예를 들어 hashtext를 쓸 수 있습니다. 서로 무관한 사용자 사이에 가끔 충돌이 생겨도 약간의 대기 비용이 들 뿐 정확성에는 문제가 없습니다. 락은 커밋이나 롤백 시 자동으로 해제됩니다. 또 다른 선택지는 SERIALIZABLE 격리로, PostgreSQL이 충돌하는 트랜잭션을 감지해 하나를 오류 40001로 중단시킵니다. 중단된 트랜잭션을 코드가 재시도한다는 전제에서 잘 동작합니다.
통하지 않는 것은 인메모리 뮤텍스나 JavaScript Map으로 만든 락입니다. 프로세스 하나만 감당할 뿐입니다. 인스턴스가 두 개가 되거나 서버리스 함수 두 개, 혹은 백그라운드 워커가 돌아가는 순간 락은 사라집니다.
해결책 4: 재시도와 중복 제출에는 멱등성 키를
모든 중복이 공격은 아닙니다. 사용자가 더블 클릭하고, 모바일 클라이언트가 타임아웃 후 재시도하고, 결제 제공자가 웹훅을 다시 보냅니다. 멱등성 키는 '이것을 하라'를 '이것을 한 번만 하라'로 바꿔 줍니다. 클라이언트가 논리적 작업마다 키를 생성하고, 서버는 작업을 하기 전에 고유 제약 조건과 함께 그 키를 기록합니다.
-- 스키마
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 키를 먼저 선점합니다. 반환 행이 없으면 다른 요청이 소유한 것입니다
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;INSERT가 행을 반환하면 같은 트랜잭션에서 작업을 수행하고 응답을 저장하세요. 아무것도 반환하지 않으면 저장된 응답을 조회해 반환하거나, 첫 요청이 아직 진행 중이라면 409를 반환하세요. 한 사용자가 다른 사용자의 키를 재사용하거나 막을 수 없도록 키를 사용자 범위로 한정하고, 적당한 기간이 지나면 만료시키세요. 웹훅이라면 제공자의 이벤트 ID가 자연스러운 키가 됩니다. 요청 본문의 해시를 키와 함께 저장하고 본문이 다른 키 재사용을 거부하는 것도 고려해 보세요. 클라이언트의 버그가 다른 작업의 결과를 조용히 받아 가는 일을 막아 줍니다.
코드에서 경쟁 찾기
- 같은 데이터를 읽은 뒤 사이에 await를 두고 쓰는 곳을 찾으세요.
- 삽입 전에 개수를 한도와 비교하는 곳을 찾으세요.
- 고유 제약 조건 없이 '찾아보고 없으면 생성'하는 곳을 찾으세요.
- 민감한 값에 대한 모든 쓰기가 동일한 락 또는 원자적 경로를 거치는지 확인하세요.
- 같은 요청을 동시에 보내고 불변 조건이 유지되는지 검증하는 테스트를 작성하세요.
동시성 테스트가 가장 설득력 있는 증거입니다. 실제 데이터베이스를 대상으로 Promise.all로 작업을 스무 번 실행한 뒤 잔액이나 사용 횟수, 행 수를 검증하세요. 수정 전에는 실패하고 수정 후에는 통과한다면 경쟁이 닫힌 것입니다. 한 번 이상 실행하세요. 다섯 번에 한 번 실패하는 경쟁도 여전히 경쟁입니다.
경쟁 상태는 한 줄이 아니라 흐름 전체를 읽어야 드러나는 종류의 버그이고, AI 리뷰어가 잘 찾아낼 수 있는 유형입니다. CodeAuditAgent가 이를 표시할 때 발견 사항에는 CWE와 인용한 검사·쓰기 코드, 병렬 요청을 설명하는 공격 시나리오, 그리고 보통 위 해결책 중 하나인 패치가 함께 담깁니다. 어느 쪽이든 일반 규칙은 같습니다. 모든 요청을 보는 유일한 구성 요소인 데이터베이스가 결정하게 하세요.