다단계 워크플로우에서 Postgres 트랜잭션을 활용하는 방법: 업데이트를 안전하게 묶고, 부분 쓰기를 막고, 재시도를 처리해 데이터 일관성을 유지하는 실용적 패턴을 알아보세요.

대부분의 실제 기능은 한 번의 데이터베이스 업데이트로 끝나지 않습니다. 행을 삽입하고, 잔액을 업데이트하고, 상태를 표시하고, 감사 기록을 쓰고, 작업을 큐에 넣는 식의 짧은 연쇄입니다. 일부 단계만 데이터베이스에 반영될 때 부분 쓰기가 발생합니다.
이 문제는 체인이 중단될 때 나타납니다: 서버 오류, 앱과 Postgres 간 타임아웃, 2단계 이후의 크래시, 혹은 1단계를 다시 실행하는 재시도 등입니다. 각 SQL 문 자체는 괜찮지만 워크플로우가 중간에 멈출 때 문제가 생깁니다.
보통 다음과 같은 증상으로 빠르게 알아챌 수 있습니다:
구체적 예: 플랜 업그레이드는 고객의 플랜을 업데이트하고 결제 기록을 추가하며 사용 가능한 크레딧을 늘립니다. 결제는 저장됐지만 크레딧 추가 전에 앱이 크래시하면, 지원팀은 한 테이블에서는 "paid"를 보고 다른 테이블에서는 "크레딧 없음"을 볼 수 있습니다. 클라이언트가 재시도하면 결제가 두 번 기록될 수도 있습니다.
목표는 단순합니다: 워크플로우를 하나의 스위치처럼 다루세요. 모든 단계가 성공하거나 전혀 저장되지 않아 절반만 완료된 상태가 남지 않도록 합니다.
트랜잭션은 데이터베이스가 여러 단계를 하나의 작업 단위로 다루도록 하는 방법입니다. 모든 변경이 일어나거나 전혀 일어나지 않는다고 보장합니다. 행을 생성하고, 잔액을 업데이트하고, 감사 레코드를 쓰는 등 여러 업데이트가 필요한 경우 중요합니다.
돈을 옮기는 상황을 생각해보세요. 계좌 A에서 빼고 계좌 B에 더해야 합니다. 첫 단계 이후 앱이 크래시하면 빼기만 기억되는 상태가 되어서는 안 됩니다.
커밋하면 Postgres에게 이 트랜잭션에서 한 모든 작업을 유지하라고 말하는 겁니다. 변경사항은 영구화되고 다른 세션에 보입니다.
롤백하면 Postgres에게 이 트랜잭션에서 한 모든 작업을 잊으라고 말하는 겁니다. Postgres는 마치 트랜잭션이 없었던 것처럼 변경사항을 되돌립니다.
트랜잭션 내부에서는 커밋하기 전 다른 세션에 반쯤 완료된 결과가 노출되지 않도록 보장합니다. 실패하고 롤백하면 데이터베이스는 그 트랜잭션의 쓰기를 정리합니다.
트랜잭션이 나쁜 워크플로우 설계를 고쳐주지는 않습니다. 잘못된 금액을 빼거나 잘못된 사용자 ID를 사용하거나 필요한 검사를 생략하면 Postgres는 그 잘못된 결과를 그대로 커밋합니다. 또한 트랜잭션만으로는 적절한 제약, 잠금, 격리 수준을 함께 사용하지 않으면 비즈니스 수준의 모든 충돌(예: 재고 과다 판매)을 자동으로 막아주지 않습니다.
하나의 실제 동작을 완료하기 위해 둘 이상의 테이블(또는 여러 행)을 업데이트할 때마다 트랜잭션 후보가 됩니다. 요점은 동일합니다: 모두 완료되거나 아무 것도 일어나지 않아야 합니다.
주문 흐름이 고전적인 사례입니다. 주문 행을 만들고, 재고를 예약하고, 결제를 받고, 주문을 결제 완료로 표시할 수 있습니다. 결제는 성공했지만 상태 업데이트가 실패하면 돈이 잡혔지만 주문은 미결제로 남습니다. 주문 행은 만들어졌지만 재고가 예약되지 않으면 없는 상품을 판매할 수 있습니다.
사용자 온보딩도 같은 방식으로 조용히 깨집니다. 사용자를 생성하고 프로필을 삽입하고 역할을 할당하고 환영 이메일을 보낼 기록을 남기는 것은 하나의 논리적 행동입니다. 묶지 않으면 로그인은 가능한데 권한이 없거나 프로필만 있고 사용자가 없는 상태가 될 수 있습니다.
백오피스 작업은 보통 "증빙 + 상태 변경"이 엄격히 함께 일어나야 합니다. 요청 승인, 감사 항목 작성, 잔액 업데이트는 함께 성공해야 합니다. 잔액은 바뀌었지만 감사 로그가 누락되면 누가 무엇을 왜 바꿨는지 증거를 잃습니다.
백그라운드 작업도 이득을 봅니다. 작업 아이템을 처리할 때 여러 단계가 있으면: 아이템을 claim해서 두 워커가 동시에 처리하지 못하게 하고, 비즈니스 업데이트를 적용하고, 리포팅과 재시도를 위한 결과를 기록한 다음 아이템을 완료(또는 실패 사유와 함께 실패로 표시)합니다. 이 단계들이 따로 놀면 재시도와 동시성으로 엉망이 됩니다.
다단계 기능은 독립된 업데이트들의 더미처럼 취급될 때 깨집니다. 데이터베이스 클라이언트를 열기 전에 워크플로우를 한 편의 짧은 이야기로 적고 하나의 분명한 완료 조건을 정하세요: 사용자에게 정확히 무엇이 "완료"로 보이는가?
먼저 단계들을 평범한 언어로 나열한 뒤 단일 성공 조건을 정의하세요. 예: "주문이 생성되고 재고가 예약되며 사용자는 주문 확인 번호를 받는다." 일부 테이블이 업데이트되어도 이것이 충족되지 않으면 성공이 아닙니다.
다음으로 데이터베이스 작업과 외부 작업 사이에 명확한 선을 그으세요. 데이터베이스 단계는 트랜잭션으로 보호할 수 있는 것들입니다. 카드 결제, 이메일 발송, 서드파티 API 호출 같은 외부 콜은 실패가 느리고 예측 불가능하며 보통 롤백할 수 없습니다.
간단한 계획 방법: 단계를 (1) 반드시 모두-혹은-없음으로 묶어야 하는 것, (2) 커밋 이후에 해도 되는 것으로 나누세요.
트랜잭션 내부에는 반드시 함께 일관성을 유지해야 하는 단계만 넣으세요:
사이드 이펙트는 밖으로 옮기세요. 예를 들어 주문을 먼저 커밋한 뒤 아웃박스 레코드를 보고 확인 이메일을 보내세요.
각 단계마다 다음 단계가 실패하면 무엇을 할지 적어두세요. "롤백"은 데이터베이스 롤백일 수도 있고 보상 작업일 수도 있습니다.
예: 결제는 성공했지만 재고 예약이 실패하면 즉시 환불할지, 아니면 주문을 "결제됨, 재고 대기"로 표시하고 비동기로 처리할지 미리 결정하세요.
트랜잭션은 Postgres에게 이 단계들을 하나의 단위로 처리하라고 알려줍니다. 모두 일어나거나 전혀 일어나지 않습니다. 이것이 부분 쓰기를 방지하는 가장 단순한 방법입니다.
시작부터 끝까지 하나의 데이터베이스 연결(하나의 세션)을 사용하세요. 여러 연결에 걸쳐 단계를 나누면 Postgres가 모두-혹은-없음 결과를 보장할 수 없습니다.
순서는 간단합니다: BEGIN, 필요한 읽기와 쓰기 실행, 모든 것이 성공하면 COMMIT, 그렇지 않으면 ROLLBACK하고 명확한 오류를 반환합니다.
간단한 SQL 예시는 다음과 같습니다:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
트랜잭션은 실행되는 동안 잠금을 유지합니다. 오래 열어두면 다른 작업을 차단하고 타임아웃이나 데드락이 발생할 가능성이 커집니다. 트랜잭션 내부에는 필수 작업만 두고 이메일 전송, 결제 기관 호출, PDF 생성 같은 느린 작업은 밖으로 옮기세요.
문제가 발생하면 재현에 충분한 컨텍스트를 기록하되 민감한 데이터는 노출하지 않도록 하세요: 워크플로우 이름, order_id 또는 user_id, 핵심 파라미터(amount, currency), Postgres 오류 코드 등. 전체 페이로드나 카드 데이터, 개인 식별 정보는 로그에 남기지 마세요.
동시성은 단지 두 가지 일이 동시에 일어나는 것입니다. 마지막 콘서트 티켓을 두 명의 고객이 동시에 사려는 상황을 떠올려 보세요. 둘 다 "1개 남음"을 보고 둘 다 결제 버튼을 누르면 누가 가져갈지 결정해야 합니다.
보호 장치가 없으면 두 요청이 같은 오래된 값을 읽고 둘 다 업데이트할 수 있습니다. 그 결과 음수 재고, 중복 예약, 주문 없는 결제 등이 발생합니다.
행 잠금(row lock)은 가장 단순한 안전장치입니다. 변경하려는 특정 행을 잠그고 검사를 수행한 뒤 업데이트하세요. 같은 행을 건드리는 다른 트랜잭션은 당신이 커밋하거나 롤백할 때까지 기다려야 하므로 이중 업데이트를 방지할 수 있습니다.
일반적인 패턴: 트랜잭션을 시작해 FOR UPDATE로 재고 행을 선택하고 재고가 있는지 확인한 뒤 감소시키고 주문을 삽입합니다. 그러면 중요한 단계가 끝날 때까지 "문을 잡아두는" 효과가 있습니다.
격리 수준은 동시 트랜잭션에서 허용할 수 있는 이상 현상의 정도를 조절합니다. 일반적으로 안전성 대 속도의 트레이드오프가 있습니다:
잠금은 짧게 유지하세요. 트랜잭션이 외부 API 호출이나 사용자 입력 대기 등으로 오래 열려 있으면 긴 대기와 타임아웃을 유발합니다. 대신 잠금 타임아웃을 설정하고 오류를 잡아 "다시 시도해 주세요"를 반환하는 명확한 실패 경로를 선호하세요.
외부 작업(예: 카드 결제)을 해야 하면 워크플로우를 분리하세요: 빠르게 예약하고 커밋한 뒤 느린 작업을 수행하고 짧은 트랜잭션으로 다시 마무리하세요.
재시도는 Postgres 기반 앱에서 정상입니다. 요청은 올바른 코드에서도 실패할 수 있습니다: 데드락, 문장 타임아웃, 짧은 네트워크 단절, 높은 격리 수준에서의 직렬화 오류 등. 동일한 핸들러를 단순히 다시 실행하면 두 번째 주문 생성, 이중 결제, 중복 이벤트 행 삽입 위험이 있습니다.
해결책은 멱등성(idempotency)입니다: 동일한 입력으로 두 번 실행해도 안전해야 합니다. 데이터베이스가 "이 요청과 동일하다"를 인식하고 일관된 응답을 반환할 수 있어야 합니다.
실용적 패턴은 각 다단계 워크플로우에 멱등 키(보통 클라이언트 생성 request_id)를 붙여 주요 레코드에 저장하고 그 키에 유니크 제약을 추가하는 것입니다.
예: 체크아웃에서 사용자가 결제 버튼을 클릭할 때 request_id를 생성하고 주문을 그 request_id와 함께 삽입합니다. 재시도가 발생하면 두 번째 시도는 유니크 제약에 걸리고 기존 주문을 반환해 새로운 주문을 생성하지 않습니다.
보통 중요한 점들:
재시도 루프는 트랜잭션 밖에 두세요. 각 시도는 새로운 트랜잭션을 시작하고 전체 작업 단위를 다시 실행해야 합니다. 실패한 트랜잭션 내부에서 재시도하는 것은 도움이 되지 않습니다. Postgres는 해당 트랜잭션을 aborted로 표시합니다.
작은 예: 앱이 주문을 생성하고 재고를 예약했는데 COMMIT 직후 타임아웃이 발생했다고 가정하세요. 클라이언트가 재시도하면 멱등 키가 있으면 두 번째 요청은 이미 생성된 주문을 반환하고 두 번째 예약을 하지 않습니다.
트랜잭션은 다단계 워크플로우를 묶어주지만 데이터가 정확해지게 자동으로 만들어주지는 않습니다. 버그가 끼어들어도 데이터베이스에서 "잘못된" 상태가 되기 어렵게 만드는 것이 부분 쓰기 문제를 피하는 강력한 방법입니다.
기본 안전장치부터 시작하세요. 외래 키는 참조가 실제인지 보장합니다(주문 라인이 존재하지 않는 주문을 가리킬 수 없음). NOT NULL은 절반만 채워진 행을 막습니다. CHECK 제약은 말이 안 되는 값을 잡아냅니다(예: quantity > 0, total_cents >= 0). 이런 규칙은 어떤 서비스나 스크립트가 데이터베이스를 건드리든 모든 쓰기에서 실행됩니다.
길게 걸리는 워크플로우는 상태 전이를 명시적으로 모델링하세요. 여러 불리언 플래그 대신 하나의 상태 컬럼(pending, paid, shipped, canceled)을 사용하고 유효한 전이만 허용하세요. 제약이나 트리거로 불법 전이를 거부하도록 데이터베이스에 강제할 수 있습니다(예: shipped -> pending 같은 점프 금지).
중복 방지를 위해 유니크 제약을 추가하세요. 중복이 워크플로우를 망가뜨린다면 order_number, invoice_number, 혹은 재시도 방지를 위한 idempotency_key 같은 필드에 유니크 제약을 두세요. 그러면 앱이 같은 요청을 재시도해도 Postgres가 두 번째 삽입을 차단하고 "이미 처리됨"을 안전하게 반환할 수 있습니다.
추적성이 필요하면 명시적으로 저장하세요. 누가 언제 무엇을 변경했는지 기록하는 감사(audit) 테이블이나 히스토리 테이블은 사고 조사 시 "미스터리 업데이트"를 쿼리 가능한 사실로 바꿉니다.
대부분의 부분 쓰기는 "나쁜 SQL" 탓이 아니라 워크플로우 설계에서 비롯됩니다. 절반의 이야기를 커밋하기 쉽게 만드는 결정들이 문제를 만듭니다.
accounts를 먼저 업데이트하고 orders를 나중에 업데이트하지만 다른 요청은 반대로 하면, 부하가 걸릴 때 데드락 가능성이 높아집니다.구체적 예: 체크아웃에서 재고를 예약하고 주문을 생성한 뒤 카드를 청구한다고 합시다. 카드 청구를 같은 트랜잭션 안에서 하면 네트워크를 기다리느라 재고 잠금을 오래 유지할 수 있습니다. 청구가 성공했지만 트랜잭션이 나중에 롤백되면 고객에게는 청구만 되고 주문은 생성되지 않을 수 있습니다.
더 안전한 패턴은: 트랜잭션은 데이터베이스 상태에 집중하게 하세요(재고 예약, 주문 생성, 결제 대기 기록), 커밋하고 외부 API를 호출한 뒤 짧은 트랜잭션으로 결과를 다시 기록하세요. 많은 팀이 이를 단순한 pending 상태와 백그라운드 작업으로 구현합니다.
워크플로우에 여러 단계(삽입, 업데이트, 결제, 전송)가 있을 때 목표는 단순합니다: 모든 것이 기록되거나 아무 것도 기록되지 않아야 합니다.
필요한 모든 데이터베이스 쓰기는 하나의 트랜잭션 안에 두세요. 한 단계라도 실패하면 롤백하고 데이터는 이전 상태 그대로 남겨야 합니다.
성공 조건을 명시적으로 정의하세요. 예: "주문이 생성되고 재고가 예약되며 결제 상태가 기록된다." 이 조건이 충족되지 않으면 실패 경로로 처리하고 트랜잭션을 중단합니다.
BEGIN ... COMMIT 블록 안에 있습니다.ROLLBACK이 발생하고 호출자에게 명확한 실패 결과를 반환합니다.같은 요청이 재시도될 수 있음을 가정하세요. 데이터베이스가 한 번만 수행되어야 할 작업을 도와줘야 합니다.
트랜잭션 내부에서 최소 작업만 하고 잠금 중 네트워크 호출을 피하세요.
어디서 깨지는지 보지 못하면 계속 추측만 하게 됩니다.
체크아웃은 주문 생성, 재고 예약, 결제 시도 기록, 주문 상태 표시 같은 여러 단계가 함께 움직여야 합니다.
사용자가 1개 품목을 구매한다고 합시다.
하나의 트랜잭션 내부에서는 데이터베이스 변경만 하세요:
orders 행을 pending_payment 상태로 삽입합니다.inventory.available 감소 또는 reservations 행 생성).idempotency_key(유니크)를 가진 payment_intents 행을 삽입합니다.order_created 같은 outbox 행을 삽입합니다.어떤 문장이라도 실패하면(재고 부족, 제약 위반, 크래시) Postgres가 전체 트랜잭션을 롤백합니다. 주문은 있지만 예약이 없는 상태나 예약만 있고 주문이 없는 상태가 남지 않습니다.
결제 제공자는 데이터베이스 밖에 있으니 별도의 단계로 다루세요.
제공자 호출이 커밋 전에 실패하면 트랜잭션을 중단하고 아무 것도 쓰지 마세요. 제공자 호출이 커밋 이후 실패하면 새 트랜잭션을 실행해 결제 시도를 실패로 표시하고 예약을 해제하며 주문 상태를 취소로 변경하세요.
클라이언트가 체크아웃 시 idempotency_key를 보내도록 하세요. payment_intents(idempotency_key)(또는 원하는 경우 orders)에 유니크 인덱스를 걸어 재시도 시 기존 행을 조회해 계속 진행하고 새 주문 삽입을 피하세요.
이메일을 트랜잭션 내부에서 보내지 마세요. 같은 트랜잭션에서 아웃박스 레코드를 쓰고 커밋 후에 백그라운드 워커가 이메일을 전송하게 하세요. 이렇게 하면 롤백된 주문에 대해 이메일이 발송되는 일을 막을 수 있습니다.
두 개 이상의 테이블을 건드리는 워크플로우 하나를 골라보세요: 가입 + 환영 이메일 큐잉, 체크아웃 + 재고, 인보이스 + 원장 항목, 프로젝트 생성 + 기본 설정 생성 등.
먼저 단계들을 쓰고 항상 참이어야 할 규칙(불변 조건)을 적으세요. 예: "주문은 완전히 결제되고 예약되었거나 결제되지 않고 예약되지 않은 상태여야 한다. 절반만 예약된 상태는 없어야 한다." 그런 규칙을 모두-혹은-없음 단위로 만드세요.
간단한 계획:
그 다음 일부러 끔찍한 경우들을 테스트하세요. 2단계 후에 크래시를 시뮬레이션하고, 커밋 직전 타임아웃을 유도하고, UI에서 이중 제출을 시도해 보세요. 목표는 지루한 결과입니다: 고아 행 없음, 이중 결제 없음, 영원히 pending 상태 없음.
빠르게 프로토타이핑한다면 워크플로우를 계획 우선 도구에서 스케치한 뒤 핸들러와 스키마를 생성하는 것이 도움이 됩니다. 예를 들어 Koder.ai (koder.ai)는 Planning Mode와 스냅샷 및 롤백을 지원해 트랜잭션 경계와 제약을 반복적으로 다듬을 때 유용할 수 있습니다.
이번 주에 한 워크플로우를 적용해 보세요. 두 번째는 훨씬 빠를 것입니다.