이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • ACID는 각각 무엇을 보장하는가?
  • ACID를 안다고 해서 왜 실무 트랜잭션 설계가 끝나지 않는가?
  • DB 보장과 애플리케이션 보장의 경계는 어디인가?

개요

Transaction은 여러 DB 작업을 하나의 논리적 작업 단위로 묶는다. ACID는 이 트랜잭션이 가져야 하는 대표 속성이다. 그러나 실무에서는 ACID 단어를 외우는 것보다 “어떤 범위의 작업이 실제로 함께 성공하고 실패하는가”를 이해하는 것이 중요하다.

백엔드 개발자는 트랜잭션을 DB 기능으로만 보면 안 된다. API 경계, 서비스 메서드, 외부 시스템 호출, 메시지 발행, 재시도, 사용자 응답이 모두 트랜잭션 설계와 연결된다.

원리

ACID는 다음을 뜻한다.

  • Atomicity: 모두 성공하거나 모두 실패한다.
  • Consistency: 트랜잭션 전후에 데이터 불변식이 유지된다.
  • Isolation: 동시에 실행되는 트랜잭션이 서로를 얼마나 격리해서 보는지 정한다.
  • Durability: 커밋된 변경은 장애 후에도 보존된다.

예를 들어 주문 생성은 다음 작업을 포함할 수 있다.

INSERT INTO orders (member_id, status, total_price)
VALUES (10, 'CREATED', 30000);
 
INSERT INTO order_items (order_id, product_id, quantity)
VALUES (100, 1, 2);

두 insert 중 하나만 성공하면 주문 데이터가 깨진다. 그래서 하나의 트랜잭션으로 묶는다.

Atomicity

원자성은 트랜잭션 안의 DB 변경이 하나의 성공/실패 단위가 된다는 뜻이다.

@Transactional
public Long createOrder(CreateOrderCommand command) {
    Order order = orderRepository.save(command.toOrder());
    orderItemRepository.saveAll(command.toItems(order.id()));
    return order.id();
}

orderItemRepository.saveAll에서 예외가 발생하면 orders insert도 롤백되어야 한다. Spring에서는 RuntimeException 계열 예외가 기본 롤백 대상이다. Checked exception, 예외 삼키기, 내부 호출 같은 Spring Transaction 특성은 별도로 이해해야 한다.

그래서 중요한 변경 흐름은 “예외가 발생한다”가 아니라 “어떤 예외가 proxy 밖으로 전파되고, Spring이 rollback-only로 표시하는가”까지 확인해야 한다.

@Transactional
public void createOrder(CreateOrderCommand command) throws IOException {
    orderRepository.save(command.toOrder());
    receiptFileStore.write(command.receipt()); // IOException
}

기본 설정에서는 checked exception이 항상 rollback을 유발한다고 단정하면 안 된다. rollbackFor, 예외 변환, 파일 I/O 분리, 보상 작업 중 무엇이 맞는지 기능 성격에 맞춰 정한다.

Consistency

일관성은 데이터가 비즈니스 규칙과 DB 제약을 만족해야 한다는 뜻이다. 그러나 DB가 모든 비즈니스 규칙을 자동으로 아는 것은 아니다.

ALTER TABLE coupon_issues
ADD CONSTRAINT uq_coupon_member UNIQUE (coupon_id, member_id);

중복 쿠폰 발급처럼 DB 제약으로 표현 가능한 규칙은 DB에 내려야 한다. 반면 “VIP는 한 달에 3번 무료 배송 가능” 같은 규칙은 애플리케이션 로직과 제약, 상태 모델이 함께 필요할 수 있다.

Isolation

격리성은 동시에 실행되는 트랜잭션 사이의 보이는 범위를 정한다. 격리 수준이 낮으면 동시성이 높지만 이상 현상이 가능하고, 격리 수준이 높으면 더 강한 일관성을 얻지만 대기와 충돌이 늘 수 있다.

-- PostgreSQL 예시
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
COMMIT;

실무에서는 DB의 기본 격리 수준을 이해하고, 특정 기능에서 더 강한 격리가 필요한지 판단해야 한다.

Durability

지속성은 커밋된 변경이 장애 후에도 보존된다는 뜻이다. DB는 WAL이나 redo log 같은 로그를 사용해 복구 가능성을 확보한다.

하지만 지속성은 “모든 외부 부작용까지 함께 보존된다”는 뜻이 아니다. DB 커밋 후 메시지 발행에 실패하거나, 결제 API는 성공했지만 DB 커밋이 실패할 수 있다. 이런 문제는 outbox, saga, 보상 트랜잭션 같은 설계가 필요하다.

실무에서 자주 보는 실패 조합은 다음이다.

상황결과필요한 설계
DB commit 성공, 메시지 발행 실패데이터는 바뀌었지만 다른 서비스가 모름outbox, 재발행 worker
외부 결제 승인 성공, DB commit 실패외부 결제만 성공결제 조회, 보상 취소, 멱등 키
이메일 발송 성공, DB rollback사용자 알림과 DB 상태 불일치커밋 후 발송, outbox

ACID는 DB 내부의 강한 기반이지만, 서비스 전체의 원자성은 별도 아키텍처 문제다.

실전 팁

  • ACID는 DB 내부 보장이고, 외부 시스템까지 포함한 비즈니스 원자성은 별도 설계가 필요하다.
  • DB 제약으로 표현 가능한 불변식은 코드보다 DB에 둔다.
  • 트랜잭션은 짧고 명확해야 한다.
  • 격리 수준을 높이기 전에 어떤 이상 현상을 막아야 하는지 설명할 수 있어야 한다.
  • 커밋 이후 메시지 발행이 필요한 작업은 outbox 패턴을 검토한다.
  • Spring rollback 규칙은 기능별로 명시한다. checked exception, business exception, validation exception이 각각 commit/rollback 중 어느 쪽인지 팀 규칙을 둔다.
  • 외부 부작용은 DB transaction 안에서 직접 실행하기보다 “DB에 의도 기록 후 커밋, 이후 재시도 가능한 worker”로 분리할 수 있는지 검토한다.

위험 신호!

  • 트랜잭션 안에서 외부 결제 API를 호출한다.
  • 중복 방지를 exists 검사에만 의존한다.
  • Checked exception이 발생했는데 롤백되지 않는 상황을 모른다.
  • 커밋과 메시지 발행 사이 실패를 고려하지 않는다.
  • ACID를 이유로 모든 작업을 하나의 긴 트랜잭션에 넣는다.
  • checked exception이 발생했는데도 commit되는 경우를 테스트하지 않는다.
  • 커밋 직후 메시지 발행 실패를 “희귀한 네트워크 오류”로만 보고 복구 루틴을 두지 않는다.

확인 질문

  • ACID를 알아도 실무 트랜잭션 설계가 끝나지 않는 이유는 무엇인가?
    • DB 내부 변경의 보장과 외부 시스템 호출, 메시지, 재시도, 사용자 응답의 보장은 별도 설계가 필요하기 때문이다.
  • Consistency를 DB가 모두 자동으로 보장하지 않는 이유는 무엇인가?
    • DB는 제약으로 표현된 규칙만 알 수 있고, 복잡한 비즈니스 규칙은 애플리케이션 로직과 함께 설계해야 하기 때문이다.
  • Durability와 외부 API 성공은 왜 다른 문제인가?
    • DB 커밋은 DB 변경의 지속성을 보장하지만, 외부 API 호출이나 메시지 발행의 성공과 원자적으로 묶이지는 않기 때문이다.
  • Spring에서 rollback 여부를 확인할 때 봐야 할 것은 무엇인가?
    • 예외 종류, 예외가 proxy 밖으로 전파되는지, rollbackFor/noRollbackFor 설정, self-invocation 여부, 실제 DB commit/rollback 통합 테스트 결과다.

참고 문서