Skip to main content
Summary

트랜잭션은 데이터 변경의 안전 단위다

트랜잭션은 여러 데이터 변경 작업을 하나의 논리적 단위로 묶는 방법이다. 핵심은 간단하다. 함께 성공해야 하는 작업은 함께 커밋하고, 하나라도 실패하면 함께 롤백한다.

예를 들어 사용자가 상품을 주문할 때 애플리케이션은 보통 여러 일을 한다.

  • 주문 row 생성

  • 주문 상품 row 생성

  • 재고 차감

  • 결제 상태 기록

  • 쿠폰 사용 처리

  • 포인트 적립 또는 차감

이 작업 중 주문만 생성되고 재고 차감이 실패하면 시스템은 이상한 상태가 된다. 사용자는 주문했다고 보지만 실제 재고는 줄지 않았다. 반대로 재고는 줄었는데 주문 생성이 실패하면 재고가 사라진다. 이런 오류는 서버가 정상 응답을 주지 않았을 때만 생기지 않는다. 네트워크 끊김, 애플리케이션 예외, 타임아웃, 배포 중 프로세스 종료, 데이터베이스 제약 조건 위반처럼 흔한 상황에서도 생긴다.

트랜잭션은 이런 실패를 전제로 한다. 개발자가 생각해야 할 질문은 이것이다.

이 기능에서 절반만 성공하면 안 되는 데이터 변경은 어디까지인가?

이 질문의 답이 트랜잭션 범위가 된다.

트랜잭션과 데이터 정합성AI 생성 이미지

ACID를 장애와 버그의 언어로 이해하기

트랜잭션은 보통 ACID라는 네 가지 속성으로 설명한다. 용어만 외우면 추상적이지만, 실제로는 애플리케이션 버그를 막는 기준이다.

원자성(Atomicity)은 트랜잭션 안의 작업이 모두 반영되거나 모두 취소되어야 한다는 뜻이다. 계좌 이체에서 A 계좌 잔액을 10만 원 줄이고 B 계좌 잔액을 10만 원 늘리는 작업은 하나의 단위다. 출금만 성공하고 입금이 실패하면 돈이 사라진다. 원자성은 이런 중간 상태를 데이터베이스에 남기지 않는다.

일관성(Consistency)은 트랜잭션 전후에 데이터가 정해진 규칙을 만족해야 한다는 뜻이다. 잔액은 음수가 될 수 없다, 주문은 존재하는 사용자에게만 속해야 한다, 결제 금액은 0보다 커야 한다 같은 규칙이다. 일부 규칙은 NOT NULL, CHECK, UNIQUE, 외래 키 같은 제약 조건으로 지킬 수 있고, 일부는 애플리케이션 로직으로 검사해야 한다. 중요한 점은 트랜잭션이 성공한 뒤에도 데이터가 업무 규칙을 깨면 안 된다는 것이다.

격리성(Isolation)은 동시에 실행되는 트랜잭션이 서로를 어지럽히지 않도록 보장하는 성질이다. 두 사용자가 마지막 남은 재고 1개를 동시에 주문할 때, 둘 다 재고가 있다고 보고 주문을 성공시키면 문제가 된다. 격리성은 이런 동시성 문제를 다룬다. 격리 수준에 따라 보장 범위와 성능 비용이 달라지며, 자세한 내용은 다음 장에서 다룬다.

지속성(Durability)은 커밋된 데이터가 장애 이후에도 남아야 한다는 뜻이다. 주문 생성 트랜잭션이 성공 응답을 반환했는데 데이터베이스 재시작 후 주문이 사라지면 사용자는 시스템을 신뢰할 수 없다. 데이터베이스는 로그, 체크포인트, 복구 절차를 통해 커밋된 변경을 보존하려고 한다.

ACID는 완벽한 마법이 아니다. 트랜잭션 범위를 잘못 잡거나, 데이터베이스 밖의 작업을 섞거나, 동시성 충돌을 고려하지 않으면 여전히 오류가 생긴다. ACID는 데이터베이스가 제공하는 안전장치이고, 개발자는 그 안전장치를 올바른 단위에 적용해야 한다.

커밋, 롤백, 자동 커밋을 코드 구조로 보기

트랜잭션의 기본 흐름은 시작, 작업, 커밋 또는 롤백이다.

BEGIN;

UPDATE accounts
SET balance = balance - 100000
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100000
WHERE id = 2;

COMMIT;

중간에 오류가 나면 COMMIT 대신 ROLLBACK을 실행해 변경을 취소한다.

ROLLBACK;

많은 데이터베이스 클라이언트와 ORM은 기본적으로 자동 커밋 모드로 동작한다. 자동 커밋은 SQL 한 문장이 실행될 때마다 즉시 커밋하는 방식이다. 단순 조회나 한 row 수정에는 편하지만, 여러 문장이 하나의 업무 단위일 때는 위험하다.

예를 들어 다음 두 작업이 각각 자동 커밋되면 원자성이 깨진다.

1. 주문 생성 INSERT 성공 후 자동 커밋
2. 재고 차감 UPDATE 실패

이 경우 주문은 남고 재고는 줄지 않는다. 따라서 여러 변경을 하나로 묶어야 할 때는 명시적으로 트랜잭션을 시작해야 한다.

애플리케이션 코드에서는 보통 다음 구조가 된다.

transaction 시작
try
  필요한 데이터 조회
  검증
  INSERT, UPDATE, DELETE 실행
  transaction 커밋
catch
  transaction 롤백
  예외 처리

여기서 중요한 것은 같은 트랜잭션을 사용하는 모든 쿼리가 같은 데이터베이스 연결 또는 같은 트랜잭션 컨텍스트에서 실행되어야 한다는 점이다. 서비스 메서드 내부에서 트랜잭션을 열었는데, 하위 함수가 별도의 연결로 쿼리를 실행하면 같은 트랜잭션에 묶이지 않을 수 있다. ORM을 사용할 때도 트랜잭션 객체나 세션을 함수 경계에 어떻게 전달하는지 확인해야 한다.

트랜잭션 범위는 너무 좁아도, 너무 넓어도 문제다. 너무 좁으면 함께 실패해야 할 작업이 분리된다. 너무 넓으면 잠금 시간이 길어지고, 다른 요청의 대기 시간이 늘어나며, 장애 시 롤백 비용도 커진다. 실무에서는 다음 기준으로 범위를 잡는다.

  • 하나의 업무 불변식을 지키는 변경은 같은 트랜잭션에 넣는다.

  • 사용자 입력 대기, 외부 API 호출, 긴 계산은 가능하면 트랜잭션 밖으로 뺀다.

  • 트랜잭션 안에서는 필요한 데이터베이스 작업만 짧게 수행한다.

  • 실패했을 때 재시도 가능한 단위인지 확인한다.

원자성이 깨지는 대표 사례

계좌 이체는 트랜잭션 설명에서 자주 쓰이는 예지만, 실제 서비스에서도 같은 패턴은 계속 등장한다.

첫 번째는 계좌 이체다.

A 계좌 출금
B 계좌 입금
이체 내역 기록

출금과 입금은 반드시 함께 성공해야 한다. 이체 내역도 중요하다. 잔액은 바뀌었는데 이체 내역이 없으면 고객 문의나 정산에서 원인을 추적하기 어렵다. 따라서 잔액 변경과 내역 기록은 보통 같은 트랜잭션에 넣는다.

두 번째는 주문 생성이다.

주문 생성
주문 상품 생성
쿠폰 사용 처리
결제 시도 상태 기록

주문 row만 있고 주문 상품이 없으면 의미 없는 주문이 된다. 쿠폰이 사용 처리되었는데 주문이 실패하면 사용자는 쿠폰을 잃는다. 주문 생성 과정에서는 어떤 상태를 최종 성공으로 볼지 명확히 나눠야 한다. 결제 승인처럼 외부 시스템 결과가 필요한 경우에는 더 조심해야 한다. 결제 API 호출 자체를 데이터베이스 트랜잭션 안에 넣는 것이 항상 좋은 답은 아니다. 이 문제는 뒤에서 다시 다룬다.

세 번째는 재고 차감이다.

현재 재고 확인
재고 차감
주문 생성

단순히 애플리케이션에서 재고 값을 읽고, 0보다 크면 차감하는 방식은 동시 요청에 취약하다. 두 요청이 동시에 재고 1을 읽으면 둘 다 주문 가능하다고 판단할 수 있다. 적어도 재고 차감은 조건부 UPDATE로 처리하는 편이 안전하다.

UPDATE products
SET stock = stock - 1
WHERE id = 10
  AND stock >= 1;

그 다음 영향받은 row 수가 1인지 확인한다. 0이면 재고 부족으로 처리한다. 이 방식은 읽기와 쓰기를 분리해 애플리케이션에서 판단하는 것보다 안전하다. 다만 여러 테이블이 함께 변경된다면 이 UPDATE도 주문 생성 트랜잭션 안에서 실행되어야 한다.

데이터베이스 밖의 작업은 트랜잭션처럼 되돌릴 수 없다

트랜잭션을 사용할 때 가장 많이 실수하는 부분은 외부 세계와의 경계다. 데이터베이스 변경은 롤백할 수 있지만, 이미 호출한 외부 API나 발송한 메시지, 보낸 이메일은 쉽게 되돌릴 수 없다.

예를 들어 주문 트랜잭션 안에서 결제 승인 API를 호출한다고 해보자.

transaction 시작
주문 생성
결제 API 호출 성공
재고 차감 실패
rollback

데이터베이스에는 주문이 남지 않았지만, 결제는 승인되었을 수 있다. 반대로 결제 API 호출은 성공했는데 네트워크 타임아웃 때문에 애플리케이션이 실패로 판단할 수도 있다. 이때 같은 요청을 다시 보내면 중복 결제가 발생할 수 있다.

메시지 발행도 비슷하다.

transaction 시작
주문 상태를 PAID로 변경
메시지 브로커에 주문 완료 이벤트 발행
commit 실패

구독 시스템은 주문 완료 이벤트를 받았지만, 데이터베이스에는 주문 완료 상태가 반영되지 않을 수 있다. 이런 불일치를 막기 위해 실무에서는 보통 다음 방식을 고려한다.

  • 외부 API는 멱등성 키를 사용해 중복 호출을 안전하게 만든다.

  • 트랜잭션 안에서는 외부 호출을 피하고, 필요한 요청 기록만 저장한다.

  • 커밋 후 별도 작업자가 메시지를 발행한다.

  • 메시지 발행 여부를 데이터베이스에 함께 기록하는 아웃박스 패턴을 사용한다.

  • 실패한 외부 작업은 보상 트랜잭션으로 되돌리거나 정정한다.

멱등성은 같은 요청이 여러 번 실행되어도 결과가 한 번 실행한 것과 같도록 만드는 성질이다. 결제 요청에 고유한 요청 키를 붙이면, 네트워크 오류 후 재시도하더라도 결제 시스템이 같은 요청을 중복 승인하지 않게 만들 수 있다.

긴 비즈니스 로직도 트랜잭션 안에 넣지 않는 편이 좋다. 복잡한 할인 계산, 대용량 파일 처리, 외부 추천 시스템 조회처럼 시간이 오래 걸리는 작업이 트랜잭션 안에 있으면 잠금이 오래 유지된다. 그동안 다른 요청은 같은 데이터를 수정하지 못하고 기다릴 수 있다. 트랜잭션은 짧고 명확해야 한다.

낙관적 락과 비관적 락

트랜잭션은 실패 시 원자성을 제공하지만, 동시에 같은 데이터를 수정하는 문제까지 자동으로 모두 해결해주지는 않는다. 이때 자주 사용하는 방식이 낙관적 락과 비관적 락이다.

비관적 락은 충돌이 날 것이라고 보고 먼저 잠그는 방식이다. 예를 들어 계좌 row를 수정하기 전에 해당 row를 잠그면 다른 트랜잭션은 잠금이 풀릴 때까지 기다린다.

SELECT id, balance
FROM accounts
WHERE id = 1
FOR UPDATE;

이 방식은 충돌이 자주 발생하고, 잘못된 동시 수정의 비용이 큰 경우에 적합하다. 계좌 잔액 변경, 한정 수량 재고 차감, 좌석 예약처럼 같은 데이터에 여러 요청이 몰리는 기능에서 유용하다. 단점은 대기 시간이 늘고, 잠금을 잡은 트랜잭션이 길어지면 전체 처리량이 떨어질 수 있다는 점이다. 잠금 순서가 꼬이면 교착 상태가 발생할 수도 있다.

낙관적 락은 충돌이 드물다고 보고 일단 작업한 뒤, 저장 시점에 누가 먼저 수정했는지 확인하는 방식이다. 보통 version 컬럼이나 updated_at 값을 사용한다.

UPDATE posts
SET title = '새 제목', version = version + 1
WHERE id = 10
  AND version = 3;

영향받은 row 수가 1이면 성공이다. 0이면 누군가 먼저 수정했다는 뜻이므로 다시 읽고 재시도하거나 사용자에게 충돌을 알려야 한다. 낙관적 락은 충돌이 드문 관리자 설정 변경, 게시글 수정, 프로필 수정 같은 기능에 적합하다. 잠금을 오래 잡지 않기 때문에 읽기와 수정이 많은 시스템에서 부담이 작다. 대신 충돌이 잦으면 재시도가 많아지고 사용자 경험이 나빠질 수 있다.

둘 중 하나가 항상 더 좋은 것은 아니다. 판단 기준은 충돌 빈도와 실패 비용이다.

  • 충돌이 드물고, 재시도나 사용자 확인이 가능하면 낙관적 락이 잘 맞는다.

  • 충돌이 잦고, 반드시 순서대로 처리해야 하면 비관적 락이 안전하다.

  • 잠금 시간이 길어질 가능성이 크면 비관적 락은 신중히 써야 한다.

  • 재시도 로직을 구현하기 어렵거나 중복 실행 비용이 크면 낙관적 락도 위험하다.

락은 트랜잭션과 함께 설계해야 한다. SELECT FOR UPDATE로 row를 잠갔더라도 트랜잭션이 바로 끝나지 않으면 잠금은 계속 유지된다. 반대로 낙관적 락을 사용하면서 버전 조건을 UPDATE에 넣지 않으면 실제로는 아무 보호도 하지 않는 셈이다.

트랜잭션 설계 체크리스트

트랜잭션을 설계할 때는 SQL 문법보다 업무 단위를 먼저 봐야 한다. 다음 질문에 답할 수 있으면 대부분의 위험을 줄일 수 있다.

  • 이 기능에서 함께 성공하거나 함께 실패해야 하는 변경은 무엇인가?

  • 실패하면 롤백해야 하는 데이터와, 롤백할 수 없는 외부 작업은 무엇인가?

  • 자동 커밋 때문에 중간 상태가 남을 가능성은 없는가?

  • 트랜잭션 안에서 외부 API, 메시지 발행, 긴 계산을 하고 있지 않은가?

  • 동시에 같은 데이터를 수정할 때 어떤 충돌이 가능한가?

  • 충돌이 잦은가, 드문가? 실패 비용은 큰가?

  • 재시도해도 안전하도록 멱등성을 갖추었는가?

좋은 트랜잭션 설계는 코드를 복잡하게 만드는 일이 아니다. 오히려 실패 상황에서 시스템이 어떤 상태로 남을지 예측 가능하게 만든다. 결제, 재고, 포인트, 정산처럼 돈이나 권리와 연결된 기능일수록 이 예측 가능성이 중요하다. 데이터베이스는 많은 안전장치를 제공하지만, 어떤 작업을 하나로 묶을지는 애플리케이션을 만드는 개발자가 결정해야 한다.

책 목록

개발자가 알아야 할 데이터베이스 지식

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기