Contents
see List왜 트랜잭션 충돌을 별도 설계로 다뤄야 하는가
데이터베이스 성능 문제는 항상 느린 SELECT에서만 생기지 않습니다. 주문 확정, 재고 차감, 포인트 적립, 정산 마감처럼 여러 사용자가 같은 데이터를 동시에 바꾸는 업무에서는 트랜잭션 충돌이 더 직접적인 장애 원인이 됩니다. PostgreSQL은 MVCC를 기반으로 읽기와 쓰기를 최대한 분리하지만, 같은 행을 동시에 수정하거나 서로 다른 순서로 여러 행을 잠그면 대기, 타임아웃, 데드락이 발생할 수 있습니다. 이 문제는 단순히 서버 사양을 올린다고 사라지지 않습니다. 업무 흐름, SQL 순서, 트랜잭션 길이, 재시도 정책을 함께 맞춰야 안정적으로 줄일 수 있습니다.
실무에서 중요한 기준은 모든 충돌을 없애는 것이 아니라 예측 가능한 충돌로 바꾸는 것입니다. 어떤 작업이 먼저 잠금을 잡는지, 얼마나 오래 잡는지, 실패했을 때 애플리케이션이 어떻게 다시 시도하는지를 정해 두면 장애는 줄고 운영자는 로그만 보고도 원인을 좁힐 수 있습니다.
잠금이 길어지는 대표 패턴
- 트랜잭션을 시작한 뒤 외부 API 호출, 파일 처리, 긴 계산을 수행해 행 잠금을 오래 붙잡는 경우
- 여러 테이블을 매번 다른 순서로 수정해 요청끼리 서로 상대방의 잠금을 기다리는 경우
- 사용자 목록, 정산 대상, 발송 큐를 큰 범위로 한 번에 업데이트해 불필요한 행까지 잠그는 경우
- 인덱스가 부족해 수정 대상 행을 찾기 전에 많은 행을 스캔하고, 그 과정에서 대기 시간이 커지는 경우
- 데드락 발생 시 같은 요청을 즉시 무한 재시도해 오히려 데이터베이스 부하를 키우는 경우
이런 패턴은 개발 환경에서는 잘 보이지 않습니다. 동시 요청 수가 적고 데이터도 작기 때문입니다. 따라서 결제, 예약, 재고, 정산처럼 쓰기 경합이 예상되는 기능은 처음부터 트랜잭션 규칙을 코드 리뷰 항목으로 다루는 편이 좋습니다.
잠금 순서를 고정하는 기본 설계
데드락을 줄이는 가장 효과적인 방법 중 하나는 여러 행이나 테이블을 수정할 때 항상 같은 순서로 잠그는 것입니다. 예를 들어 계좌 이체나 포인트 이동처럼 두 계정의 잔액을 동시에 바꾸는 작업은 요청 방향에 따라 A를 먼저 잠그기도 하고 B를 먼저 잠그기도 쉽습니다. 이때 ID가 작은 행부터 잠그도록 고정하면 두 요청이 같은 순서로 대기하므로 데드락 가능성이 크게 줄어듭니다.
BEGIN;
-- 두 계정을 항상 같은 순서로 잠근다.
SELECT id, balance
FROM account
WHERE id IN (:from_account_id, :to_account_id)
ORDER BY id
FOR UPDATE;
UPDATE account
SET balance = balance - :amount
WHERE id = :from_account_id
AND balance >= :amount;
UPDATE account
SET balance = balance + :amount
WHERE id = :to_account_id;
INSERT INTO account_ledger(from_account_id, to_account_id, amount, created_at)
VALUES (:from_account_id, :to_account_id, :amount, now());
COMMIT;
핵심은 SELECT FOR UPDATE 자체가 아니라 순서입니다. 같은 업무에서 어떤 API는 계정 테이블을 먼저 수정하고, 다른 배치 작업은 원장 테이블을 먼저 수정한다면 여전히 충돌이 생길 수 있습니다. 따라서 업무 단위로 “잠금 획득 순서”를 문서화하고, 서비스 코드와 배치 코드가 같은 규칙을 따르게 해야 합니다.
트랜잭션 안에서 하지 말아야 할 작업
트랜잭션은 짧을수록 좋습니다. 사용자 검증, 권한 체크, 외부 결제 승인, 알림 메시지 생성 같은 작업을 모두 트랜잭션 안에 넣으면 데이터베이스 잠금 시간이 길어집니다. 특히 외부 API는 응답 시간이 들쭉날쭉하므로 행 잠금을 잡기 전에 호출하거나, 먼저 별도 상태로 요청을 기록한 뒤 후속 단계에서 짧게 확정하는 구조가 안전합니다.
- 트랜잭션 전에 입력값 검증과 조회 가능한 사전 검사를 끝냅니다.
- 트랜잭션 안에서는 반드시 함께 성공하거나 실패해야 하는 데이터 변경만 수행합니다.
- 알림 발송, 검색 인덱스 갱신, 이메일 발송은 outbox 테이블에 기록한 뒤 별도 워커가 처리합니다.
- 대량 업데이트는 작은 배치 단위로 나누고, 각 배치마다 커밋합니다.
운영 환경에서 필요한 타임아웃 설정
무한정 기다리는 트랜잭션은 장애를 키웁니다. PostgreSQL에서는 업무 성격에 맞게 lock_timeout, statement_timeout, idle_in_transaction_session_timeout을 설정해 대기 시간을 제한할 수 있습니다. 모든 값을 전역으로 과하게 짧게 잡으면 정상 배치도 실패할 수 있으므로, 애플리케이션 연결 풀이나 작업 세션 단위로 구분하는 방식이 좋습니다.
-- 온라인 API 요청에서 사용할 예시
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '10s';
SET LOCAL idle_in_transaction_session_timeout = '15s';
lock_timeout은 잠금을 얻지 못하고 기다리는 시간을 제한합니다. statement_timeout은 SQL 한 문장의 최대 실행 시간을 제한합니다. idle_in_transaction_session_timeout은 트랜잭션을 열어 둔 채 아무 작업도 하지 않는 세션을 정리하는 데 도움이 됩니다. 이 값들은 실패를 없애는 설정이 아니라 실패를 빠르게 드러내는 장치입니다. 애플리케이션은 이 실패를 사용자에게 적절히 안내하거나 제한된 횟수로 재시도해야 합니다.
재시도는 짧고 제한적으로 설계한다
데드락이나 직렬화 실패는 정상적인 동시성 상황에서도 발생할 수 있습니다. PostgreSQL에서는 데드락이 감지되면 한 트랜잭션을 중단시키고 오류를 반환합니다. 이때 같은 작업을 다시 실행하면 성공할 수 있지만, 모든 오류를 무조건 재시도하면 중복 처리와 부하 폭증이 생깁니다. 재시도 대상 오류를 구분하고, 멱등키를 둔 뒤, 짧은 지연을 넣어 제한된 횟수만 반복해야 합니다.
// Node.js 예시: SQLSTATE 기준으로 제한 재시도
const retryableCodes = new Set(['40P01', '40001']); // deadlock_detected, serialization_failure
async function runWithTransactionRetry(work, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL lock_timeout = '2s'");
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
if (!retryableCodes.has(error.code) || attempt === maxAttempts) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 80 * attempt));
} finally {
client.release();
}
}
}
재시도 가능한 오류와 재시도하면 안 되는 오류를 나누는 것이 중요합니다. 잔액 부족, 권한 없음, 유효하지 않은 상태 전이는 재시도해도 해결되지 않는 업무 오류입니다. 반면 데드락, 직렬화 실패처럼 동시성 타이밍 때문에 생긴 오류는 제한 재시도의 대상이 될 수 있습니다.
충돌 원인을 찾는 점검 SQL
운영 중에는 잠금을 기다리는 세션과 오래 열린 트랜잭션을 빠르게 확인할 수 있어야 합니다. 다음 SQL은 현재 실행 중인 쿼리, 대기 이벤트, 트랜잭션 지속 시간을 함께 보여 줍니다. 정기 대시보드나 장애 대응 문서에 넣어 두면 원인 파악 시간을 줄일 수 있습니다.
SELECT
pid,
usename,
application_name,
state,
wait_event_type,
wait_event,
now() - xact_start AS transaction_age,
now() - query_start AS query_age,
left(query, 300) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND state <> 'idle'
ORDER BY transaction_age DESC NULLS LAST,
query_age DESC;
이 결과에서 transaction_age가 길고 wait_event_type이 Lock으로 표시되는 세션은 우선 확인 대상입니다. application_name을 연결 풀 또는 서비스 이름별로 명확히 넣어 두면 어떤 API, 배치, 관리자 기능에서 문제가 생겼는지 빠르게 구분할 수 있습니다.
실전 체크리스트
- 같은 업무에서 여러 행을 수정할 때 잠금 순서를 ID나 생성일 기준으로 고정했는가
- 외부 API 호출, 알림 발송, 긴 계산을 트랜잭션 밖으로 뺐는가
- 온라인 요청과 배치 작업의 lock_timeout, statement_timeout 기준을 분리했는가
- 데드락과 직렬화 실패만 제한적으로 재시도하고, 업무 오류는 재시도하지 않는가
- 중복 처리를 막기 위한 요청 ID, 멱등키, 원장 테이블이 준비되어 있는가
- pg_stat_activity를 통해 오래 열린 트랜잭션과 잠금 대기를 확인할 수 있는가
트랜잭션 충돌 대응의 핵심은 데이터베이스 기능 하나를 더 쓰는 것이 아니라 업무 규칙과 SQL 실행 순서를 일관되게 만드는 것입니다. 잠금 순서를 고정하고, 트랜잭션을 짧게 유지하고, 실패를 빠르게 감지하며, 제한적으로 재시도하면 동시 사용자가 늘어도 예측 가능한 데이터 변경 흐름을 유지할 수 있습니다.