RSS듀오랩스
데이터베이스 설계

동시 수정 처리: 트랜잭션 격리 수준과 낙관적 잠금

작성자
듀오랩스 대표·12분 읽기

두 사람이 같은 거래처 화면을 열었다고 해 보겠습니다. 한 사람은 전화번호를 고치고 저장합니다. 1분 뒤 다른 사람이 주소를 고치고 저장합니다. 두 번째 저장이 끝나면 전화번호는 옛 번호로 돌아가 있습니다.

두 번째 사람의 화면에는 처음 열었을 때의 전화번호가 그대로 들어 있었고, 저장 버튼은 화면의 모든 칸을 보냈습니다. 데이터베이스는 오류를 내지 않았습니다. 두 저장 모두 정상적인 UPDATE 였기 때문입니다.

트랜잭션이 동시 수정을 막아 준다는 생각

동시 수정 이야기가 나오면 흔히 트랜잭션을 떠올립니다. 데이터베이스는 ACID 를 지키고, 그중 I(격리성)가 동시에 실행되는 작업끼리 서로 간섭하지 않게 해 준다고 배웁니다. 그렇다면 격리 수준을 가장 높은 Serializable 로 올리면 위의 덮어쓰기도 막혀야 할 것 같습니다.

막히지 않습니다. 격리 수준을 올려도 이 장면은 그대로 일어납니다. 이유를 이해하려면 동시 수정 문제를 한 덩어리로 보지 말고, 충돌이 트랜잭션 안에서 일어나는지 밖에서 일어나는지로 나눠 봐야 합니다.

동시 수정 문제의 세 갈래

업무 시스템에서 만나는 동시 수정 문제는 대개 셋 중 하나입니다.

갈래 장면 어디서 풀리나
숫자 누적 두 출하가 같은 주문의 출하량을 동시에 올린다 데이터베이스
확인 후 실행 두 사람이 동시에 「수금 처리」를 누르고, 둘 다 「미수」를 확인한 뒤 진행한다 데이터베이스
덮어쓰기 화면을 연 뒤 몇 분 동안 고치고 저장하는 사이 다른 사람이 먼저 저장한다 애플리케이션

앞의 둘은 읽고 쓰는 사이가 몇 밀리초라서 한 트랜잭션, 심지어 한 SQL 문장 안에 담을 수 있습니다. 마지막은 읽고 쓰는 사이에 사람이 있습니다. 이 차이가 해법을 가릅니다.

숫자 누적: 읽지 말고 데이터베이스에서 더하기

출하량을 올리는 코드를 이렇게 짜면 문제가 생깁니다.

const line = await db.orderLine.findUnique({ where: { id } });
await db.orderLine.update({ where: { id }, data: { shippedQty: line.shippedQty + 40 } });

두 요청이 둘 다 shippedQty = 0 을 읽고, 하나는 40 을 다른 하나는 60 을 씁니다. 결과는 100 이 아니라 나중에 쓴 값 하나입니다. 교과서에서 lost update 라고 부르는 이상 현상입니다.

해법은 읽은 값을 앱에서 계산하지 않는 것입니다.

UPDATE "OrderLine" SET "shippedQty" = "shippedQty" + 40 WHERE id = $1;

PostgreSQL 의 격리 수준 문서에 따르면 기본값인 Read Committed 에서 두 번째 UPDATE 는 첫 번째가 커밋하기를 기다렸다가, 첫 번째가 바꾼 새 버전의 행에 자기 연산을 적용합니다. 그래서 shippedQty + 40shippedQty + 60 이 차례로 반영되어 100 이 됩니다. Prisma 라면 { increment: 40 } 이 이 SQL 을 만듭니다.

확인 후 실행: 조건을 UPDATE 안으로 넣기

수금 처리는 「미수인지 확인하고, 미수면 수금으로 바꾼다」입니다.

const v = await db.voucher.findUnique({ where: { id } });
if (v.settled) throw new Error("이미 수금됐습니다");
await db.voucher.update({ where: { id }, data: { settled: true } });
await db.voucher.create({ data: { kind: "RECEIPT", /* ... */ } });

확인과 변경 사이에 틈이 있습니다. 두 요청이 둘 다 settled = false 를 읽으면 둘 다 통과하고, 수금 전표가 두 장 생깁니다. 숫자 누적과 달리 이번에는 값을 덮어쓰는 것이 아니라 한 번만 일어나야 할 일이 두 번 일어납니다.

해법은 확인을 WHERE 로 옮기고, 바뀐 행 수로 누가 이겼는지 판단하는 것입니다.

UPDATE "Voucher" SET settled = true WHERE id = $1 AND settled = false;
-- 바뀐 행이 1이면 내가 처리했고, 0이면 이미 누가 처리했다

이것이 동작하는 근거도 같은 문서에 있습니다. Read Committed 에서 기다렸던 UPDATE 는 새 버전의 행에 대해 WHERE 조건을 다시 평가합니다. 첫 번째 요청이 settledtrue 로 커밋했다면, 두 번째 요청의 settled = false 조건은 더 이상 맞지 않아 바뀐 행이 0 이 됩니다. 수금 전표를 만드는 일은 바뀐 행이 1 일 때만 진행합니다.

조건이 한 문장에 담기지 않을 때는 행 잠금을 씁니다.

BEGIN;
SELECT * FROM "Voucher" WHERE id = $1 FOR UPDATE;  -- 이 행을 잠근다
-- 여러 조건을 확인하고 여러 테이블을 바꾼다
COMMIT;                                             -- 잠금이 풀린다

FOR UPDATE 로 읽은 행은 트랜잭션이 끝날 때까지 다른 트랜잭션이 바꾸거나 같은 방식으로 잠그지 못합니다. 두 번째 요청은 기다렸다가 첫 번째가 끝난 뒤의 값을 읽습니다. 이것이 비관적 잠금입니다. 몇 밀리초 동안만 쥐는 잠금이라 비용이 크지 않습니다.

격리 수준을 올리면 달라지는 것

격리 수준을 Repeatable Read 로 올리면 앞의 두 갈래를 다르게 막습니다. PostgreSQL 에서 이 수준의 트랜잭션은 자기가 시작한 뒤 다른 트랜잭션이 바꾼 행을 고치려 하면 기다렸다가 새 값에 적용하지 않고 오류를 냅니다.

ERROR: could not serialize access due to concurrent update

문서는 이 오류를 받으면 트랜잭션을 처음부터 다시 실행하라고 안내합니다. Serializable 은 여기서 더 나아가, 트랜잭션들을 차례로 하나씩 실행한 것과 같은 결과가 아니면 오류를 냅니다.

두 방식의 차이를 정리하면 이렇습니다. Read Committed 에서는 내가 SQL 을 잘 짜서 경쟁을 피합니다. 높은 격리 수준에서는 데이터베이스가 경쟁을 감지해 오류를 내고, 앱이 재시도합니다. 어느 쪽이든 경쟁은 트랜잭션 안에서 일어나고 데이터베이스가 그것을 볼 수 있습니다.

덮어쓰기가 트랜잭션 밖의 문제인 이유

처음의 거래처 장면으로 돌아가겠습니다. 요청의 흐름은 이렇습니다.

요청 1  거래처 읽기 → 화면에 표시 → 트랜잭션 끝
        (사용자가 3분 동안 고친다)
요청 2  거래처 저장 → 새 트랜잭션 → 커밋

화면을 여는 요청과 저장하는 요청은 서로 다른 HTTP 요청이고, 서로 다른 트랜잭션입니다. 두 번째 사람의 저장 요청이 시작될 때 첫 번째 사람의 저장은 이미 커밋이 끝났습니다. 두 번째 트랜잭션 입장에서는 커밋된 최신 행을 고치는 평범한 UPDATE 이고, 겹치는 트랜잭션이 없으니 Serializable 에서도 충돌이 아닙니다.

데이터베이스는 두 번째 사람이 3분 전에 무엇을 읽었는지 모릅니다. 그 정보는 브라우저의 화면에만 있습니다.

그렇다고 화면을 열 때 트랜잭션을 시작하고 저장할 때 커밋하는 방법은 쓸 수 없습니다. 사람이 화면을 열어 둔 채 자리를 비우면 그동안 행 잠금과 데이터베이스 연결을 계속 쥐고 있게 됩니다. 웹 요청은 요청이 끝나면 연결을 돌려주는 구조라 애초에 맞지 않습니다.

낙관적 잠금: 읽은 버전을 들고 다니기

데이터베이스가 모르는 「3분 전에 무엇을 읽었나」를 앱이 대신 기억하는 방법이 낙관적 잠금입니다.

행에 버전 칸을 둡니다.

ALTER TABLE "Partner" ADD COLUMN version integer NOT NULL DEFAULT 0;

화면을 열 때 버전을 같이 읽어 폼에 숨겨 둡니다. 저장할 때 그 버전을 조건에 넣고, 저장하면서 버전을 올립니다.

UPDATE "Partner"
SET phone = $2, address = $3, version = version + 1
WHERE id = $1 AND version = $4;   -- $4 는 화면을 열 때 읽은 버전

바뀐 행이 0 이면 그사이 누군가 먼저 저장해서 버전이 올라갔다는 뜻입니다. 저장을 거부하고 「다른 사람이 먼저 수정했습니다」라고 알립니다.

구조를 보면 앞의 「확인 후 실행」과 같은 모양입니다. 조건을 WHERE 에 넣고 바뀐 행 수를 봅니다. 다른 것은 조건의 값이 어디서 오는가입니다. 확인 후 실행에서는 조건이 업무 규칙(settled = false)이고, 낙관적 잠금에서는 사용자가 보고 있던 화면의 버전입니다. 그 값을 브라우저가 들고 왔다가 돌려주기 때문에 트랜잭션 밖의 시간을 건널 수 있습니다.

이름이 낙관적인 이유는 충돌이 드물다고 보고 평소에는 아무것도 잠그지 않기 때문입니다. 충돌이 실제로 났을 때만 비용을 치릅니다.

버전 칸 대신 updatedAt 을 비교하는 방식도 쓰입니다. 칸을 새로 두지 않아도 되는 대신, 시각의 정밀도가 낮거나 앱 서버마다 시계가 다르면 같은 값이 두 번 나올 수 있습니다. 저는 정수 버전 칸 쪽이 비교가 확실해서 낫다고 봅니다.

편집 중 잠금을 기본값으로 두지 않는 이유

덮어쓰기를 막는 다른 방법으로 「홍길동 님이 편집 중입니다」 같은 화면 잠금이 있습니다. 화면을 열 때 잠금 표시를 남기고, 다른 사람은 읽기만 하게 합니다.

이 방법은 트랜잭션 잠금이 아니라 앱이 표에 적어 두는 표시라서, 누가 풀지를 앱이 책임져야 합니다. 편집하던 사람이 창을 닫지 않고 퇴근하거나 브라우저가 멈추면 잠금이 남습니다. 그래서 만료 시간과 강제 해제 기능이 따라 붙고, 만료 시간이 짧으면 오래 편집하던 사람이 잠금을 잃습니다.

저는 이 방식이 두 사람이 같은 문서를 오래 붙잡고 고치는 일이 흔하고, 충돌했을 때 다시 입력하는 비용이 큰 곳에만 맞다고 봅니다. 긴 계약서 본문이나 결재 중인 문서가 그런 예입니다. 거래처 연락처처럼 몇 초면 다시 입력할 수 있는 화면에는 낙관적 잠금이 맞습니다.

어느 방법을 쓸지 고르는 순서

화면이나 동작 하나를 두고 이렇게 물으면 대부분 정해집니다.

질문 그렇다면
기존 값에 더하거나 빼는가 데이터베이스에서 계산 (SET qty = qty + n)
한 번만 일어나야 하는 상태 전환인가 조건부 UPDATE 와 바뀐 행 수
확인할 조건이 여러 테이블에 걸치는가 트랜잭션 안에서 SELECT ... FOR UPDATE
사람이 화면에서 고친 뒤 저장하는가 낙관적 잠금 (버전 칸)
오래 붙잡는 문서이고 충돌 시 다시 쓰기가 비싼가 편집 중 잠금을 추가로 검토

한 화면에 여러 개가 같이 필요한 경우도 많습니다. 수주 수정 화면은 낙관적 잠금으로 덮어쓰기를 막고, 같은 화면의 「출하 확정」 버튼은 조건부 UPDATE 로 두 번 확정되는 것을 막습니다.

여기까지가 확실한 부분

Read Committed 에서 UPDATE 가 새 버전의 행에 대해 조건을 다시 평가하는 동작, Repeatable Read 의 직렬화 오류, Serializable 의 정의는 PostgreSQL 문서 기준입니다. MySQL 이나 SQL Server 는 기본 격리 수준이나 이 경우의 동작이 다를 수 있으므로 같은 코드를 옮길 때 그 데이터베이스의 문서를 따로 확인해야 합니다.

낙관적 잠금은 데이터베이스 기능이 아니라 앱이 지키는 약속입니다. 버전 칸을 조건에 넣지 않는 저장 경로가 하나라도 있으면 그 경로에서는 덮어쓰기가 그대로 일어납니다. 그래서 저장 경로를 한 함수로 모아 두는 것이 이 방법의 일부라고 생각합니다. 읽고 더하는 틈을 SQL 한 문장으로 없애는 같은 원리를 문서 번호에 적용한 예는 문서 번호 채번 글에 있습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.