트랜잭션 경계는 어디서 나눠야 할까?
출하 등록 버튼 하나가 서버에서 하는 일을 적어 보면 이렇습니다.
const shipment = await db.shipment.create({ data: { ... } }); // 1. 출하 저장
await db.stockMove.createMany({ data: moves }); // 2. 재고 이동
await db.orderLine.update({ where: { id }, data: { shippedQty: { increment: qty } } }); // 3. 출하량
await db.voucher.create({ data: { kind: "SALES", ... } }); // 4. 매출 전표4번에서 오류가 나면 무엇이 남을까요. 출하는 있고, 재고는 빠졌고, 수주의 출하량도 올라갔는데 매출 전표만 없습니다. 사용자는 오류 화면을 보고 다시 누릅니다. 이번에는 네 단계가 다 성공합니다. 출하는 두 건, 재고는 두 번 빠졌고, 전표는 한 장입니다.
데이터베이스가 알아서 한 덩어리로 처리한다는 생각
코드에는 트랜잭션이라는 말이 한 번도 나오지 않습니다. 그래도 흔히 한 요청 안의 저장은 데이터베이스가 한 덩어리로 다뤄 줄 거라고 기대합니다. 데이터베이스는 원자성을 보장한다고 배웠기 때문입니다.
원자성은 보장됩니다. 다만 그 단위가 기대와 다릅니다. PostgreSQL 튜토리얼은 이렇게 설명합니다.
PostgreSQL actually treats every SQL statement as being executed within a transaction. If you do not issue a BEGIN command, then each individual statement has an implicit BEGIN and (if successful) COMMIT wrapped around it.
BEGIN 이 없으면 문장 하나가 트랜잭션 하나입니다. 위 코드의 네 줄은 네 번 따로 커밋됩니다. 데이터베이스는 이 네 문장이 한 버튼에서 나왔다는 것을 모릅니다. 원자성은 데이터베이스가 지키지만, 무엇을 한 단위로 볼지는 앱이 BEGIN 과 COMMIT 으로 알려 줘야 합니다.
업무 동작 하나가 트랜잭션 하나
트랜잭션 경계를 정하는 기준은 사용자가 누른 버튼, 즉 업무 동작 하나가 맞다고 봅니다. 출하 등록이라는 동작은 출하, 재고, 출하량, 전표가 모두 바뀌었을 때만 의미가 있습니다. 그중 일부만 반영된 상태는 업무에 존재하지 않는 상태입니다.
Prisma 에서는 콜백 안의 조회와 저장을 한 트랜잭션으로 묶습니다.
await db.$transaction(async (tx) => {
const shipment = await tx.shipment.create({ data: { ... } });
await tx.stockMove.createMany({ data: moves });
await tx.orderLine.update({ where: { id }, data: { shippedQty: { increment: qty } } });
await tx.voucher.create({ data: { kind: "SALES", ... } });
});콜백 안에서 오류가 나면 네 단계가 모두 되돌려집니다. 중요한 것은 콜백 안에서 db 가 아니라 tx 를 써야 한다는 점입니다. 실수로 db 를 쓴 줄은 트랜잭션 밖에서 따로 커밋되고, 오류가 나도 되돌려지지 않습니다. 겉보기에는 멀쩡히 동작해서 찾기 어렵습니다.
너무 넓게 묶었을 때 생기는 일
그렇다면 요청 전체를 트랜잭션으로 감싸면 될까요. 경계가 넓어지면 다른 비용이 생깁니다.
트랜잭션 안에서 바꾼 행은 커밋할 때까지 잠겨 있습니다. 같은 행을 바꾸려는 다른 요청은 그동안 기다립니다. 트랜잭션이 길어질수록 기다리는 시간이 늘고, 두 트랜잭션이 서로의 잠금을 기다리는 교착 상태가 생길 확률도 올라갑니다.
그래서 트랜잭션 안에는 데이터베이스 작업만 두는 것이 원칙입니다. 입력 검증, 계산, 화면용 데이터 조회는 가능하면 트랜잭션 전에 끝냅니다. Prisma 의 트랜잭션 문서에 따르면 이 방식의 트랜잭션은 기본 제한 시간이 5초이고, 넘기면 되돌려집니다. 제한 시간을 늘려야 할 만큼 긴 트랜잭션이라면 경계를 다시 보라는 신호라고 생각합니다.
트랜잭션이 되돌리지 못하는 일
트랜잭션이 되돌릴 수 있는 것은 그 데이터베이스 안의 변경뿐입니다. 전표를 저장하면서 메일을 보냈다면, 전표 저장이 롤백돼도 메일은 이미 나갔습니다. 외부 API 호출, 파일 저장, 알림 발송이 모두 같습니다.
await db.$transaction(async (tx) => {
const invoice = await tx.taxInvoice.create({ data: { ... } });
await asp.issue(invoice); // 외부 발행 대행사 호출
await tx.voucher.update({ ... }); // 여기서 실패하면?
});마지막 줄에서 실패하면 우리 데이터베이스에는 계산서가 없는데, 발행 대행사에는 발행된 계산서가 있습니다. 이런 일은 트랜잭션 경계를 어떻게 잡아도 한 트랜잭션으로 묶이지 않습니다.
제가 보기에 선택지는 순서를 바꾸는 것입니다. 데이터베이스에는 「발행 요청됨」 상태로 먼저 커밋하고, 외부 호출은 커밋 뒤에 합니다. 외부 호출 결과는 다시 별도의 트랜잭션으로 상태에 반영합니다. 외부 호출이 실패하면 「발행 요청됨」에 머문 건을 다시 시도하거나 사람이 볼 수 있습니다. 반쯤 된 상태를 없애는 것이 아니라, 반쯤 된 상태에 이름을 붙여 데이터로 남기는 방식입니다. 이 방향을 체계화한 것이 아웃박스 패턴입니다.
경계 밖에 남는 동시 수정
트랜잭션으로 묶으면 반쯤 저장되는 문제는 사라지지만, 동시에 들어온 두 요청의 경쟁은 따로 남습니다. 두 사람이 같은 전표를 동시에 수금 처리하면, 두 트랜잭션이 각자 완결된 채로 둘 다 성공할 수 있습니다. 트랜잭션 경계는 한 요청 안의 일관성을, 격리 수준과 조건부 UPDATE 는 요청 사이의 경쟁을 다룹니다. 둘은 다른 문제이고, 뒤쪽은 동시 수정과 트랜잭션에서 다뤘습니다.
경계를 정할 때 묻는 질문
코드 한 덩어리를 두고 이렇게 물으면 경계가 정해진다고 봅니다.
| 질문 | 그렇다면 |
|---|---|
| 이 중 일부만 저장된 상태가 업무에서 말이 되는가 | 말이 안 되면 한 트랜잭션 |
| 트랜잭션 안에 사람을 기다리거나 외부를 부르는 줄이 있는가 | 밖으로 빼고, 필요하면 중간 상태를 데이터로 남김 |
콜백 안의 모든 저장이 tx 를 쓰는가 |
db 를 쓴 줄은 트랜잭션 밖이다 |
| 트랜잭션을 여러 곳에서 여는가 | 업무 동작을 구현하는 함수 한 곳에서 연다 |
마지막 줄은 구조의 문제입니다. 작은 함수들이 각자 트랜잭션을 열면, 그 함수들을 모아 부르는 쪽에서 전체를 한 단위로 묶기 어렵습니다. 저라면 저장 함수는 트랜잭션 클라이언트를 인자로 받게 하고, 트랜잭션은 버튼 하나에 대응하는 가장 바깥 함수에서만 열겠습니다.
여기까지가 확실한 부분
문장마다 따로 커밋되는 동작은 PostgreSQL 기준입니다. 다른 데이터베이스나 드라이버는 자동 커밋 기본값이 다를 수 있고, 튜토리얼도 일부 클라이언트 라이브러리가 BEGIN 과 COMMIT 을 알아서 보낸다고 주의를 줍니다. 쓰는 드라이버가 어떻게 하는지는 그 문서에서 확인해야 합니다. 제한 시간 5초는 Prisma 의 대화형 트랜잭션 기본값이고 설정으로 바꿀 수 있습니다.
함께 읽기
- 감사 로그와 변경 이력 설계거래처의 결제 조건이 「월말 마감 30일」에서 「월말 마감 60일」로 바뀌어 있습니다. 거래처 테이블에는 updatedAt 이 있어서 어제 오후 3시에 바뀌었다는 것은 압니다.
- 금액·수량 자료형 선택: 정수, numeric, 부가세 끝수 처리견적서의 품목 세 줄에 부가세를 줄마다 계산해서 더한다고 해 보겠습니다. 같은 견적을 합계에서 부가세를 한 번 계산한 화면과 나란히 놓으면 부가세가 1원 다릅니다.
- 소프트 삭제(deletedAt)의 비용과 대안품목 코드 RM-001 을 잘못 만들어서 지웠다고 해 보겠습니다. 소프트 삭제라 행은 남고 deletedAt 에 시각이 찍힙니다. 이제 같은 코드로 품목을 다시 만들려고 하면 오류가 납니다.
- 복합 인덱스 칸 순서 정하는 법여러 회사가 함께 쓰는 업무 시스템에 전표 테이블이 있고, 인덱스가 이렇게 걸려 있다고 해 보겠습니다.
- 동시 수정 처리: 트랜잭션 격리 수준과 낙관적 잠금두 사람이 같은 거래처 화면을 열었다고 해 보겠습니다. 한 사람은 전화번호를 고치고 저장합니다. 1분 뒤 다른 사람이 주소를 고치고 저장합니다. 두 번째 저장이 끝나면 전화번호는 옛 번호로 돌아가 있습니다.