트랜잭션은 데이터 변경의 안전벨트다
데이터베이스를 사용하는 코드는 대부분 간단해 보인다. 주문을 만들고, 결제 금액을 저장하고, 재고를 줄이고, 포인트를 차감한다. 문제는 이 작업들이 혼자 실행되지 않는다는 점이다. 실제 서비스에서는 여러 사용자의 요청, 배치 작업, 관리자 수정, 재시도 로직이 동시에 같은 데이터를 바꾼다.
트랜잭션은 여러 데이터 작업을 하나의 논리적 작업 단위로 묶는 장치다. 예를 들어 상품 주문은 보통 다음 작업으로 이루어진다.
BEGIN;
INSERT INTO orders (...) VALUES (...);
UPDATE products SET stock = stock - 1 WHERE id = 10;
INSERT INTO payments (...) VALUES (...);
COMMIT;이 셋 중 하나만 성공하면 시스템은 이상한 상태가 된다. 주문은 있는데 결제가 없거나, 결제는 됐는데 재고가 줄지 않거나, 재고만 줄고 주문이 없는 상태가 생긴다. 트랜잭션은 이런 중간 상태가 외부에 남지 않게 한다.
개발자가 트랜잭션을 이해해야 하는 이유는 단순하다. 결제, 재고, 쿠폰, 포인트, 정산처럼 돈이나 권리와 연결된 데이터는 조금 틀리는 것이 아니라 치명적으로 틀린다. 로그 한 줄 누락과 포인트 중복 지급은 장애의 무게가 다르다.
AI 생성 이미지
ACID가 막아 주는 문제
트랜잭션의 성질은 보통 ACID로 설명한다. 각각 원자성, 일관성, 격리성, 지속성을 뜻한다.
원자성은 트랜잭션 안의 작업이 모두 성공하거나 모두 실패해야 한다는 뜻이다. 중간까지만 반영되는 상태를 막는다. 포인트 차감 후 주문 생성에 실패했다면 포인트 차감도 되돌아가야 한다. 데이터베이스에서는 COMMIT 전 오류가 나면 ROLLBACK으로 이전 상태로 되돌린다.
일관성은 트랜잭션 전후로 데이터가 정해진 규칙을 만족해야 한다는 뜻이다. 여기서 규칙은 기본키, 외래키, NOT NULL, UNIQUE, CHECK 같은 데이터베이스 제약 조건뿐 아니라 "재고는 음수가 될 수 없다", "결제 금액은 주문 금액과 같아야 한다" 같은 도메인 규칙도 포함한다. 다만 데이터베이스가 모든 도메인 규칙을 자동으로 이해해 주는 것은 아니다. 제약 조건으로 표현할 수 없는 규칙은 애플리케이션 코드와 트랜잭션 설계로 함께 지켜야 한다.
격리성은 동시에 실행되는 트랜잭션들이 서로의 중간 상태를 함부로 보지 못하게 하는 성질이다. 두 사용자가 마지막 남은 상품 1개를 동시에 구매할 때, 둘 다 재고가 있다고 판단하면 초과 판매가 발생한다. 격리성은 이런 동시 실행의 충돌을 제어한다.
지속성은 커밋된 데이터가 장애 이후에도 남아야 한다는 뜻이다. 데이터베이스가 "저장 완료"라고 응답한 뒤 서버가 재시작되어도 결과가 유지되어야 한다. 이를 위해 데이터베이스는 로그, 체크포인트, 디스크 동기화 같은 내부 메커니즘을 사용한다.
ACID는 이론적인 표어가 아니다. 원자성이 깨지면 일부만 반영된 주문이 남고, 일관성이 깨지면 말이 안 되는 데이터가 저장되며, 격리성이 약하면 동시 요청에서 값이 꼬이고, 지속성이 약하면 성공 응답을 받은 사용자의 데이터가 사라진다.
격리 수준은 성능과 정확성의 조절 장치다
격리성을 가장 강하게 보장하려면 모든 트랜잭션을 하나씩 순서대로 실행하면 된다. 그러면 결과는 안전하지만 성능은 크게 떨어진다. 데이터베이스는 그래서 여러 격리 수준을 제공한다. 격리 수준은 "동시에 실행할 때 어떤 이상 현상을 허용할 것인가"에 대한 선택이다.
대표적인 이상 현상은 세 가지다.
더티 리드는 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상이다. A 트랜잭션이 사용자 잔액을 10만 원에서 5만 원으로 바꿨지만 아직 커밋하지 않았다. 이때 B 트랜잭션이 5만 원을 읽었다. 그런데 A가 실패해 롤백하면 실제 잔액은 다시 10만 원이다. B는 존재한 적 없는 값을 기준으로 판단한 셈이다.
반복 불가능 읽기는 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 달라지는 현상이다. A가 주문 상태를 읽었을 때는 READY였다. 그 사이 B가 주문 상태를 CANCELED로 커밋했다. A가 같은 주문을 다시 읽으면 값이 달라진다. 보고서 생성이나 정산처럼 한 시점의 일관된 값을 기대하는 작업에서는 문제가 된다.
팬텀 리드는 같은 조건으로 조회했는데 두 번째 조회에서 없던 행이 나타나거나 사라지는 현상이다. A가 "오늘 생성된 주문 수"를 조회했을 때 100건이었다. 그 사이 B가 새 주문을 추가했다. A가 같은 조건으로 다시 조회하면 101건이 된다. 값 하나가 바뀐 것이 아니라 조건에 맞는 행의 집합이 바뀐 것이다.
실무에서는 여기에 갱신 손실도 함께 떠올려야 한다. 두 트랜잭션이 같은 값을 읽고 각자 계산한 뒤 저장하면, 먼저 저장된 변경이 나중 저장에 덮여 사라질 수 있다. 예를 들어 둘 다 재고 10개를 읽고 각각 1개씩 차감해 9개를 저장하면, 실제로는 2개가 팔렸는데 재고가 9개로 남을 수 있다. 이런 문제는 격리 수준만 믿기보다 원자적 UPDATE, 락, 버전 체크 같은 방법으로 명확히 막아야 한다.
일반적인 격리 수준은 다음처럼 이해할 수 있다.
| 격리 수준 | 막는 문제 | 남을 수 있는 문제 | 특징 |
|---|---|---|---|
| Read Uncommitted | 거의 없음 | 더티 리드, 반복 불가능 읽기, 팬텀 리드 | 가장 약함. 실무 핵심 데이터에는 거의 부적합 |
| Read Committed | 더티 리드 | 반복 불가능 읽기, 팬텀 리드 | 커밋된 데이터만 읽음. 많은 DB의 기본값 |
| Repeatable Read | 더티 리드, 반복 불가능 읽기 | 구현에 따라 팬텀 리드 가능 | 트랜잭션 내 같은 행 읽기를 안정화 |
| Serializable | 직렬 실행과 같은 결과 | 성능 저하, 대기 또는 직렬화 실패 증가 | 가장 강한 격리 수준 |
주의할 점은 데이터베이스마다 구현이 다르다는 것이다. 같은 Repeatable Read라도 어떤 데이터베이스는 MVCC 스냅샷으로 동작하고, 어떤 데이터베이스는 특정 락 전략으로 팬텀 리드를 더 강하게 막기도 한다. Serializable도 내부적으로 모든 요청을 실제로 한 줄로 세워 실행한다기보다, 직렬로 실행한 것과 같은 결과가 나오도록 락이나 검증을 사용하는 경우가 많다. 따라서 이름만 외우면 안 된다. 사용 중인 데이터베이스에서 해당 격리 수준이 실제로 어떤 읽기 결과와 락 동작을 만드는지 확인해야 한다.
락은 충돌을 기다리게 만드는 방식이다
동시성 제어의 가장 직관적인 방법은 락이다. 락은 어떤 데이터에 대해 "지금은 내가 사용 중이니 다른 트랜잭션은 기다리라"고 표시하는 장치다.
읽기 락은 여러 트랜잭션이 동시에 가질 수 있지만, 쓰기 락과는 충돌한다. 쓰기 락은 데이터를 변경하는 동안 다른 변경이나 일부 읽기를 막는다. 계좌 이체에서 한 계좌의 잔액을 수정하는 동안 다른 트랜잭션이 같은 잔액을 동시에 수정하지 못하게 하는 식이다.
예를 들어 재고 차감을 안전하게 처리하려면 다음처럼 행을 잠그고 확인할 수 있다.
BEGIN;
SELECT stock
FROM products
WHERE id = 10
FOR UPDATE;
-- 애플리케이션에서 stock > 0 확인
UPDATE products
SET stock = stock - 1
WHERE id = 10;
COMMIT;FOR UPDATE는 선택한 행을 변경 대상으로 잠그겠다는 뜻이다. 다른 트랜잭션이 같은 상품의 재고를 수정하려 하면 먼저 잡은 트랜잭션이 끝날 때까지 기다린다. 이런 방식은 안전하지만 트래픽이 몰리면 대기 시간이 길어진다. 실제 코드에서는 stock > 0이 아닐 때 업데이트하지 않고 트랜잭션을 종료하는 분기도 반드시 필요하다.
경우에 따라서는 조건을 포함한 하나의 UPDATE로 더 단순하게 처리할 수도 있다.
UPDATE products
SET stock = stock - 1
WHERE id = 10
AND stock > 0;영향받은 행 수가 1이면 재고 차감에 성공한 것이고, 0이면 차감할 재고가 없다는 뜻이다. 이렇게 데이터베이스가 한 문장 안에서 조건 확인과 변경을 처리하게 하면, 애플리케이션에서 값을 읽고 다시 쓰는 사이에 생기는 경쟁 조건을 줄일 수 있다.
락은 범위가 중요하다. 행 하나만 잠그면 비용이 작지만, 테이블 전체를 잠그면 관련 없는 요청까지 멈출 수 있다. 또한 락을 오래 잡을수록 병목이 커진다. 트랜잭션 안에서 외부 API 호출, 긴 계산, 사용자 입력 대기를 하면 안 되는 이유가 여기에 있다. 트랜잭션은 가능한 짧게 유지해야 한다.
락을 잘못 잡으면 데드락도 생긴다. A는 주문 행을 잠근 뒤 결제 행을 기다리고, B는 결제 행을 잠근 뒤 주문 행을 기다리는 상황이다. 데이터베이스는 보통 데드락을 감지해 한쪽 트랜잭션을 실패시킨다. 애플리케이션은 이 실패를 장애로만 볼 것이 아니라 재시도 가능한 충돌로 다룰 수 있어야 한다.
MVCC는 읽기와 쓰기의 충돌을 줄인다
많은 현대 데이터베이스는 MVCC를 사용한다. MVCC는 Multi-Version Concurrency Control의 약자로, 하나의 데이터에 여러 버전을 두고 트랜잭션마다 적절한 버전을 읽게 하는 방식이다.
락이 "기다리게 해서 안전하게 만든다"에 가깝다면, MVCC는 "읽는 사람에게 그 시점의 사진을 보여 준다"에 가깝다. 어떤 트랜잭션이 상품 가격을 10,000원에서 12,000원으로 바꾸고 있어도, 다른 트랜잭션은 커밋된 이전 버전인 10,000원을 읽을 수 있다. 그래서 읽기 작업이 쓰기 작업에 막히는 일이 줄어든다.
MVCC는 읽기 성능과 동시성을 높이지만 공짜는 아니다. 오래 열린 트랜잭션이 있으면 데이터베이스는 그 트랜잭션이 볼 수도 있는 예전 버전을 쉽게 지우지 못한다. 그 결과 저장 공간이 늘거나 정리 작업이 밀리고, 쿼리 성능이 나빠질 수 있다. "조회만 하는 트랜잭션이니까 오래 열어도 괜찮다"는 생각은 위험하다.
실무에서 긴 트랜잭션은 여러 문제를 만든다. 관리자 화면에서 대량 데이터를 조회하며 트랜잭션을 오래 유지하거나, 배치가 커서를 열어 둔 채 천천히 처리하면 MVCC 버전 정리가 지연될 수 있다. 트랜잭션은 읽기든 쓰기든 짧고 명확해야 한다.
낙관적 락과 비관적 락은 충돌 가능성에 대한 판단이다
동시성 제어 전략은 크게 비관적 락과 낙관적 락으로 나눌 수 있다.
비관적 락은 충돌이 날 가능성이 높다고 보고 미리 잠그는 방식이다. 앞의 SELECT ... FOR UPDATE가 대표적이다. 재고가 1개 남은 인기 상품, 같은 계좌의 잦은 출금, 좌석 예약처럼 같은 자원에 요청이 몰리는 경우에 적합하다. 장점은 충돌을 강하게 막는 것이고, 단점은 대기와 데드락 가능성이 커진다는 것이다.
낙관적 락은 충돌이 드물다고 보고 먼저 작업한 뒤, 저장 시점에 그 사이 값이 바뀌었는지 확인하는 방식이다. 보통 version 컬럼이나 updated_at 값을 사용한다.
UPDATE coupons
SET used = true,
version = version + 1
WHERE id = 100
AND used = false
AND version = 3;영향받은 행 수가 1이면 성공이다. 0이면 누군가 먼저 수정했거나 이미 사용된 쿠폰이라는 뜻이다. 이때 애플리케이션은 다시 읽고 재시도하거나, 사용자에게 "이미 사용된 쿠폰입니다"라고 알려야 한다.
낙관적 락은 락 대기를 줄일 수 있어 충돌이 낮은 환경에서 효율적이다. 하지만 충돌이 잦으면 재시도가 폭증한다. 특히 결제나 재고처럼 재시도 자체가 외부 부작용을 만들 수 있는 작업에서는 멱등성도 함께 설계해야 한다. 멱등성은 같은 요청을 여러 번 보내도 결과가 한 번 처리된 것과 같게 만드는 성질이다.
결국 선택 기준은 기술 이름이 아니라 충돌 패턴이다. 같은 행에 요청이 자주 몰리면 비관적 락을 고려하고, 대부분의 사용자가 서로 다른 데이터를 수정한다면 낙관적 락이 단순하고 빠를 수 있다.
분산 환경에서는 하나의 트랜잭션으로 묶기 어렵다
단일 데이터베이스 안에서는 트랜잭션이 비교적 명확하다. 문제는 마이크로서비스 환경이다. 주문 서비스는 주문 DB를 쓰고, 결제 서비스는 결제 DB를 쓰고, 포인트 서비스는 포인트 DB를 쓴다. 각 서비스가 자기 데이터베이스를 소유하면 하나의 BEGIN과 COMMIT으로 전체 작업을 묶기 어렵다.
분산 트랜잭션은 여러 시스템의 작업을 하나의 트랜잭션처럼 맞추려는 방식이다. 대표적으로 2단계 커밋이 있다. 먼저 모든 참여자에게 "커밋 가능하냐"고 묻고, 모두 가능하다고 답하면 실제 커밋을 지시한다. 이 방식은 강한 일관성을 줄 수 있지만 느리고 복잡하다. 참여자 중 하나가 응답하지 않으면 전체가 대기하거나 복구 절차가 필요하다. 서비스 간 결합도도 높아진다.
그래서 많은 마이크로서비스 시스템은 최종 일관성을 선택한다. 최종 일관성은 모든 순간에 데이터가 완벽히 같지는 않더라도, 시간이 지나면 올바른 상태로 수렴하게 만드는 방식이다. 주문 생성 직후 결제 상태가 잠시 PENDING일 수 있지만, 결제 이벤트가 처리되면 PAID 또는 FAILED로 바뀐다.
최종 일관성을 사용하려면 실패를 정상 흐름으로 설계해야 한다. 메시지가 늦게 도착할 수 있고, 중복 처리될 수 있고, 일부 서비스만 성공할 수 있다. 따라서 이벤트 처리에는 멱등성이 필요하고, 처리 상태를 기록해야 하며, 실패한 메시지를 재시도하거나 별도 큐로 보내는 전략이 필요하다.
여기서 중요한 것은 "나중에 맞으면 된다"가 "아무렇게나 처리해도 된다"는 뜻이 아니라는 점이다. 최종적으로 어떤 상태가 올바른지, 중간 상태를 사용자에게 어떻게 보여 줄지, 실패한 작업을 누가 언제 복구할지를 미리 정해야 한다. 최종 일관성은 느슨한 설계가 아니라 실패와 지연을 포함한 설계다.
보상 트랜잭션은 되돌리기 위한 별도 작업이다
분산 환경에서는 이미 커밋된 작업을 단순히 롤백할 수 없다. 결제 서비스가 결제를 승인했고, 주문 서비스가 그 뒤 실패했다고 해서 데이터베이스 롤백처럼 결제 승인 기록이 자동으로 사라지지 않는다. 이때 필요한 것이 보상 트랜잭션이다.
보상 트랜잭션은 앞선 작업을 논리적으로 취소하는 별도 작업이다. 결제가 승인된 뒤 주문 생성이 실패했다면 결제 취소를 요청한다. 포인트를 차감한 뒤 배송 접수가 실패했다면 포인트를 다시 적립한다. 중요한 점은 "없던 일로 만든다"가 아니라 "취소라는 새로운 사실을 기록한다"는 것이다.
이런 흐름은 보통 사가 패턴으로 구현한다. 긴 비즈니스 작업을 여러 로컬 트랜잭션으로 나누고, 각 단계가 실패하면 앞 단계에 대한 보상 작업을 실행한다. 예를 들면 다음과 같다.
주문 생성
재고 예약
결제 승인
배송 요청
배송 요청 실패 시 결제 취소
결제 취소 후 재고 예약 해제
주문 상태를 실패로 변경
이 방식은 단일 트랜잭션보다 복잡하다. 대신 서비스 간 독립성을 유지하면서 장애를 견딜 수 있다. 개발자는 성공 경로만 설계해서는 안 된다. 어느 단계에서 실패해도 데이터가 설명 가능한 상태로 남아야 한다.
개발자가 판단해야 할 것
트랜잭션과 일관성 설계에서 가장 중요한 질문은 "얼마나 정확해야 하는가"와 "얼마나 동시에 처리해야 하는가"다.
정산, 결제, 잔액, 재고처럼 틀리면 안 되는 데이터는 강한 일관성과 명확한 동시성 제어가 필요하다. 반대로 조회수, 추천 점수, 로그 집계처럼 잠깐 틀려도 되는 데이터는 비동기 처리와 최종 일관성으로 성능을 얻을 수 있다.
또한 트랜잭션 범위를 작게 유지해야 한다. 데이터베이스 안에서 꼭 함께 성공해야 하는 작업만 묶고, 외부 API 호출이나 긴 계산은 분리하는 것이 좋다. 락을 오래 잡는 트랜잭션은 다른 요청의 대기 시간을 늘리고 장애를 확산시킨다.
마지막으로 동시성 버그는 테스트 환경에서 잘 드러나지 않는다. 개발자 한 명이 버튼을 눌러서는 문제가 없어 보인다. 하지만 같은 요청이 동시에 수십 개 들어오면 갱신 손실, 초과 판매, 중복 사용, 순서 꼬임이 나타난다. 핵심 도메인에서는 동시 요청 테스트, 재시도 테스트, 장애 주입 테스트를 통해 실제 충돌 상황을 확인해야 한다.
트랜잭션은 데이터베이스 기능 하나가 아니다. 시스템이 현실의 약속을 지키게 만드는 안전장치다. 어떤 데이터는 즉시 정확해야 하고, 어떤 데이터는 나중에 맞아도 된다. 이 차이를 구분하는 능력이 저장소 설계의 핵심이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.