다음은 “트랜잭션이란 무엇인가?”와 “데이터 일관성을 어떻게 보존하는가?”를 백엔드 관점(특히 RDB + 스프링 중심)에서 딱 정리한 답이에요.

트랜잭션이란?

여러 개의 데이터 연산을 “한 덩어리(단위 작업, Unit of Work)”로 묶어 전부 성공하거나(Commit) 전부 실패(롤백, Rollback) 하게 보장하는 메커니즘입니다. 고전 예시는 계좌이체: 출금 −100, 입금 +100이 둘 다 성공해야만 실제 반영.

핵심 속성(ACID)

Atomicity: 전부 아니면 전무(부분 성공 없음)

Consistency: 트랜잭션 전후로 **정합 조건(무결성 제약)**이 유지

Isolation: 동시에 실행돼도 서로 간섭 최소화

Durability: 커밋된 결과는 장애에도 보존


일관성을 해치는 현상(동시성 이슈)

Dirty Read: A가 커밋 전 임시값을 B가 읽음

Non-Repeatable Read: 같은 행을 두 번 읽는데 중간에 값이 바뀜

Phantom Read: 같은 조건의 조회인데 중간에 행 수가 바뀜(삽입/삭제)

Lost Update: 둘이 동시에 수정해서 한쪽 변경이 유실


DB 격리 수준 정리 (일반적 보장)

Read Uncommitted: Dirty Read 허용

Read Committed: Dirty Read 방지(기본: Oracle, PostgreSQL)

Repeatable Read: Non-Repeatable Read 방지(기본: MySQL InnoDB)

Serializable: 직렬 실행과 동일(가장 안전, 성능 비용 큼)


> 현대 RDB는 주로 MVCC로 읽기-쓰기 경합을 줄이며 스냅샷을 제공합니다.



일관성 보존 방법 (실무 체크리스트)

1. 스키마/제약으로 1차 방어



PK/UK, FK, CHECK로 불변조건을 DB가 강제

꼭 필요한 곳에만 NULL 허용

적절한 정규화와 인덱싱


2. 트랜잭션 경계 설계



“무엇을 한 번에 커밋해야 하는가?”를 도메인 규칙 기준으로 결정
(계좌이체, 주문→재고차감→결제예약 등)

너무 긴 트랜잭션(사용자 입력 대기, 외부 API 대기) 피하기


3. 격리 수준/잠금 전략



기본은 Read Committed(혹은 MySQL 기본인 Repeatable Read)로 시작
→ 문제 구간에서만 상향 또는 명시적 잠금 사용

행 충돌 우려가 큰 갱신 시:

비관적 잠금: SELECT ... FOR UPDATE

낙관적 잠금: version 컬럼(+ 예외 시 재시도)로 Lost Update 방지



4. 트랜잭션 내 부수효과 금지



같은 트랜잭션 안에서 외부 시스템 호출(결제, 메일, 메시지 발행 등) 지양
→ 커밋 성공과 외부 부수효과의 원자성 깨짐
→ 대안: Outbox 패턴(+CDC) 로 커밋 후 메시지 발행 보장


5. Idempotency & Retry



네트워크/타임아웃로 인해 중복요청 발생 가능

멱등키(Idempotency-Key)로 동일 요청 한 번만 처리

결과 재시도 정책은 멱등성과 함께



6. 에러/데드락 대응



데드락은 피할 수 없을 수 있음 → 짧은 트랜잭션, 일관된 잠금 순서, 재시도 로직 도입


7. 분산(마이크로서비스) 트랜잭션



2PC(이중 커밋)는 복잡/제약 많음

권장: 사가(Saga) 패턴

오케스트레이션 또는 코레오그래피로 단계별 커밋 + 보상 트랜잭션

예: 주문 생성→재고 예약→결제 승인, 중간 실패 시 이전 단계 보상(재고 해제, 주문 취소 등)



스프링에서의 실전 포인트

서비스 계층 메서드에 경계를 잡고 @Transactional 사용

읽기 전용은 readOnly = true로 힌트 부여(플랫폼에 따라 최적화)

필요한 곳만 격리수준/전파 레벨 조정


@Service
public class TransferService {
  private final AccountRepo repo;

  @Transactional(
      isolation = Isolation.REPEATABLE_READ, // 상황에 맞게 조정
      propagation = Propagation.REQUIRED,
      rollbackFor = Exception.class
  )
  public void transfer(long fromId, long toId, BigDecimal amount) {
    Account from = repo.findByIdForUpdate(fromId); // 비관적 잠금 예시
    Account to   = repo.findByIdForUpdate(toId);

    if (from.getBalance().compareTo(amount) < 0) {
      throw new InsufficientBalanceException();
    }
    from.withdraw(amount);
    to.deposit(amount);

    // 무결성은 DB 제약이 2차 방어 (ex. balance >= 0 CHECK)
    // 커밋은 메서드 정상 종료 시점에 수행
  }
}

낙관적 잠금 예시 (JPA @Version):

@Entity
class ProductStock {
  @Id Long id;
  @Version Long version; // 충돌 시 OptimisticLockException
  int quantity;
}

명시적 트랜잭션(SQL):

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 둘 중 하나라도 실패면 ROLLBACK

요약 (바로 적용)

DB 제약으로 1차 방어 → 짧고 명확한 트랜잭션 경계 → 격리 수준/잠금 전략 정확히 → Outbox/사가로 분산 일관성 → 멱등성 + 재시도로 실전 내구성 확보.
이 흐름대로 설계하면 데이터 일관성을 안정적으로 지킬 수 있어요.



LIST

+ Recent posts