2026년 07월 02일 | DBMS Error 가이드
이 글에서 다루는 내용
40000 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
40000 transaction rollback 는?
PostgreSQL 에러 코드 40000은 트랜잭션이 롤백되었음을 나타내는 일반적인 에러입니다. 이 에러는 데이터베이스가 트랜잭션을 정상적으로 완료하지 못하고 모든 변경 사항을 되돌렸을 때 발생하며, 주로 동시성 충돌, 제약 조건 위반, 또는 명시적인 롤백 명령에 의해 트리거됩니다. 에러 코드 40000은 부모 클래스 코드로서 40001(직렬화 실패), 40002(트랜잭션 무결성 제약 위반), 40003(구문 완료 알 수 없음), 40P01(교착 상태)과 같은 더 구체적인 하위 에러 코드들의 상위 분류에 해당합니다.
주요 발생 원인
1. 교착 상태(Deadlock) 발생
교착 상태는 두 개 이상의 트랜잭션이 서로 상대방이 보유한 잠금을 기다리면서 무한정 대기 상태에 빠지는 현상입니다. PostgreSQL은 교착 상태를 감지하면 관련 트랜잭션 중 하나를 강제로 롤백시켜 상황을 해소하며, 이때 40000(또는 더 구체적으로 40P01) 에러가 발생합니다. 예를 들어 세션 A가 테이블 1을 잠그고 테이블 2를 기다리는 동시에, 세션 B가 테이블 2를 잠그고 테이블 1을 기다리는 상황이 전형적인 사례입니다.
2. 직렬화 실패(Serialization Failure)
SERIALIZABLE 또는 REPEATABLE READ 격리 수준을 사용하는 트랜잭션에서 동시에 같은 데이터를 수정하려고 할 때 직렬화 실패가 발생합니다. PostgreSQL의 SSI(Serializable Snapshot Isolation) 메커니즘은 트랜잭션들이 순차적으로 실행된 것처럼 보장해야 하는데, 이 조건을 만족할 수 없을 때 트랜잭션을 롤백시킵니다. 이 경우 애플리케이션 레벨에서 재시도(retry) 로직을 반드시 구현해야 합니다.
3. 명시적 또는 암묵적 롤백 명령
애플리케이션 코드 내에서 ROLLBACK 명령이 명시적으로 호출되거나, 트랜잭션 블록 내에서 처리되지 않은 예외(unhandled exception)가 발생하면 트랜잭션이 자동으로 롤백됩니다. PL/pgSQL 함수나 프로시저에서 예외 처리 없이 에러가 발생한 경우에도 동일하게 40000 계열의 에러가 반환될 수 있습니다. 이는 특히 대용량 배치 작업에서 한 건의 오류가 전체 트랜잭션을 무효화하는 심각한 문제로 이어질 수 있습니다.
해결 방법
교착 상태 해결
교착 상태를 해결하려면 우선 어떤 쿼리들이 충돌하고 있는지 파악해야 합니다. pg_stat_activity와 pg_locks 뷰를 조합하면 현재 잠금 상태를 진단할 수 있습니다.
-- 현재 잠금 대기 중인 세션 확인
SELECT
a.pid,
a.usename,
a.application_name,
a.query,
a.wait_event_type,
a.wait_event,
a.state,
now() - a.query_start AS query_duration
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY query_duration DESC;
-- 교착 상태 관련 잠금 상세 조회
SELECT
l.pid,
l.relation::regclass AS table_name,
l.mode,
l.granted,
a.query
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted
ORDER BY l.pid;
-- 교착 상태를 유발하는 프로세스 종료 (주의해서 사용)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid = <문제_PID>;
교착 상태를 근본적으로 해결하려면 모든 트랜잭션에서 테이블 접근 순서를 일관되게 유지해야 합니다.
-- 나쁜 예: 서로 다른 순서로 테이블 접근 (교착 상태 위험)
-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 트랜잭션 B (동시 실행 시 교착 상태 위험)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
-- 좋은 예: 항상 id 순서대로 접근
-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 낮은 id 먼저
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 트랜잭션 B
BEGIN;
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 낮은 id 먼저
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
COMMIT;
직렬화 실패 해결 (재시도 로직)
-- PL/pgSQL에서 재시도 로직 구현 예제
CREATE OR REPLACE FUNCTION transfer_with_retry(
p_from_account INT,
p_to_account INT,
p_amount NUMERIC,
p_max_retries INT DEFAULT 5
)
RETURNS BOOLEAN AS $$
DECLARE
v_retry_count INT := 0;
v_success BOOLEAN := FALSE;
BEGIN
WHILE v_retry_count < p_max_retries AND NOT v_success LOOP
BEGIN
-- SERIALIZABLE 격리 수준 트랜잭션
UPDATE accounts
SET balance = balance - p_amount
WHERE id = p_from_account
AND balance >= p_amount;
IF NOT FOUND THEN
RAISE EXCEPTION '잔액 부족 또는 계좌 없음';
END IF;
UPDATE accounts
SET balance = balance + p_amount
WHERE id = p_to_account;
v_success := TRUE;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN
-- 40001 또는 40P01 에러 시 재시도
v_retry_count := v_retry_count + 1;
RAISE NOTICE '재시도 중... (시도 횟수: %)', v_retry_count;
PERFORM pg_sleep(0.1 * v_retry_count); -- 지수 백오프
WHEN OTHERS THEN
-- 다른 에러는 즉시 전파
RAISE;
END;
END LOOP;
RETURN v_success;
END;
$$ LANGUAGE plpgsql;
-- 함수 사용 예
SELECT transfer_with_retry(1, 2, 500.00, 3);
명시적 롤백 및 SAVEPOINT 활용
대용량 배치 작업에서 일부 실패가 전체 트랜잭션을 무효화하지 않도록 SAVEPOINT를 활용합니다.
-- SAVEPOINT를 이용한 부분 롤백 패턴
BEGIN;
-- 첫 번째 작업
INSERT INTO orders (customer_id, amount) VALUES (101, 1500.00);
SAVEPOINT after_first_insert;
-- 두 번째 작업 시도
BEGIN
INSERT INTO order_items (order_id, product_id, qty)
VALUES (currval('orders_id_seq'), 999, 5);
EXCEPTION
WHEN foreign_key_violation THEN
-- 두 번째 작업만 롤백, 첫 번째 작업은 유지
ROLLBACK TO SAVEPOINT after_first_insert;
RAISE NOTICE '상품 ID 999가 존재하지 않아 해당 항목은 건너뜁니다.';
END;
-- 나머지 작업 계속 진행
INSERT INTO order_items (order_id, product_id, qty)
VALUES (currval('orders_id_seq'), 100, 3);
COMMIT;
예방 방법
1. 잠금 타임아웃 및 구문 타임아웃 설정
무한정 대기로 인한 연쇄 롤백을 방지하려면 세션 또는 데이터베이스 수준에서 타임아웃 값을 반드시 설정해야 합니다. lock_timeout은 잠금을 획득하기 위해 기다리는 최대 시간을 제한하고, statement_timeout은 단일 쿼리의 최대 실행 시간을 제한합니다. 이를 통해 문제가 발생하더라도 빠르게 감지하고 자원을 해제할 수 있습니다.
-- 데이터베이스 전체에 기본값 설정
ALTER DATABASE mydb SET lock_timeout = '5s';
ALTER DATABASE mydb SET statement_timeout = '30s';
ALTER DATABASE mydb SET deadlock_timeout = '1s';
-- 세션 단위로 설정 (애플리케이션 연결 시 권장)
SET lock_timeout = '3s';
SET statement_timeout = '10s';
-- 현재 설정 확인
SHOW lock_timeout;
SHOW statement_timeout;
SHOW deadlock_timeout;
2. 트랜잭션을 짧고 명확하게 유지 (Short Transaction 원칙)
트랜잭션의 범위를 최소화하는 것이 교착 상태와 직렬화 실패를 예방하는 가장 효과적인 방법입니다. 트랜잭션 내에서 외부 API 호출, 파일 I/O, 사용자 입력 대기 등 불필요한 작업을 포함시키지 말고, 데이터베이스 작업만 트랜잭션 블록 안에 배치해야 합니다. 또한 자주 업데이트되는 행에 대해서는 SELECT ... FOR UPDATE SKIP LOCKED 패턴을 활용하여 잠금 경합을 줄일 수 있습니다.
-- SKIP LOCKED를 활용한 큐 처리 패턴 (잠금 경합 최소화)
BEGIN;
SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 가져온 작업 처리 후 상태 업데이트
UPDATE job_queue
SET status = 'processing', started_at = NOW()
WHERE id = ANY(ARRAY[/* 위에서 가져온 id 목록 */]);
COMMIT;
관련 에러
- 40001 (serialization_failure): 직렬화 가능 격리 수준에서 동시 트랜잭션 충돌 시 발생. 40000의 가장 흔한 하위 에러입니다.
- 40P01 (deadlock_detected): 교착 상태 감지 시 발생하는 PostgreSQL 전용 에러 코드. 즉각적인 재시도 로직이 필요합니다.
- 40002 (transaction_integrity_constraint_violation): 트랜잭션 무결성 제약 위반 시 발생하며, 주로
DEFERRABLE제약 조건과 관련이 있습니다. - 25006 (read_only_sql_transaction): 읽기 전용 트랜잭션에서 쓰기 작업 시도 시 발생하는 에러로, 잘못된 트랜잭션 설정과 관련이 있습니다.
- 55P03 (lock_not_available):
NOWAIT옵션 사용 시 즉시 잠금을 획득할 수 없을 때 발생하며, 트랜잭션 롤백으로 이어집니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.