[Spring] @Transactional의 동작 원리와 핵심 속성(전파, 격리)

트랜잭션

트랜잭션은 데이터베이스의 하나의 논리적 작업 단위를 의미한다. 여러 개의 SQL 문이 모여 하나의 의미 있는 작업을 이루는데, 이 작업은 전부 성공하거나, 전부 실패해야 한다. 부분적으로 SQL이 성공한 채로 남아있다면 데이터의 정합성이 깨지기 때문이다.

대표적인 예가 계좌 이체다. A 계좌에서 1만원을 빼고, B 계좌에 1만원을 더하는 두 개의 UPDATE 쿼리가 있을 때, 첫 번째 쿼리만 성공하고 두 번째 쿼리가 실패하면 1만원이 사라져버린다. 트랜잭션은 이런 상황을 막기 위해 두 쿼리를 하나의 묶음으로 처리한다.

트랜잭션은 ACID 4가지 속성을 보장한다.

  • Atomicity (원자성): 트랜잭션 내 작업은 모두 성공하거나 모두 실패한다. 중간 상태는 없다.
  • Consistency (일관성): 트랜잭션 전후로 DB는 항상 일관된 상태(제약 조건, 무결성)를 유지한다.
  • Isolation (격리성): 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않는다.
  • Durability (지속성): 커밋된 트랜잭션의 결과는 시스템 장애가 나도 영구히 반영된다.

DB 관점에서 트랜잭션은 BEGIN → 여러 쿼리 → COMMIT 또는 ROLLBACK 으로 끝나는 일련의 흐름이고, JDBC 관점에서는 하나의 Connection에서 이 흐름이 일어난다.

스프링에서의 트랜잭션

JDBC를 이용한 트랜잭션 구성

public void transfer(Long fromId, Long toId, int amount) throws SQLException {
    Connection con = dataSource.getConnection();
    try {
        con.setAutoCommit(false); // ① 트랜잭션 시작
 
        // ② 비즈니스 로직
        accountRepository.withdraw(con, fromId, amount);
        accountRepository.deposit(con, toId, amount);
 
        con.commit();             // ③ 성공 시 커밋
    } catch (Exception e) {
        con.rollback();           // ④ 실패 시 롤백
        throw e;
    } finally {
        con.setAutoCommit(true);  // ⑤ 커넥션 풀에 돌려주기 전 원복
        con.close();              // ⑥ 커넥션 반환
    }
}
  1. autoCommit을 false로 바꿔야 트랜잭션이 시작된다. JDBC는 기본적으로 매 쿼리마다 자동 커밋이 켜져 있다.
  2. 같은 Connection으로 모든 쿼리를 실행해야 같은 트랜잭션이다. Repository에 Connection을 매번 인자로 넘겨야 한다.
  3. 반드시 close 해야 한다. 안 그러면 커넥션 풀이 고갈된다.

이 코드의 문제는 비즈니스 로직(withdraw, deposit)을 제외한 모든 줄이 트랜잭션 인프라 코드라는 점이다. 그리고 Repository 시그니처에 Connection이 포함되면서 DB 기술이 비즈니스 계층까지 의존성이 생긴다.

트랜잭션 보일러 플레이트 코드

위 코드의 문제를 해결하기 위해 스프링은 단계적으로 추상화를 제공한다.

1단계: DataSourceUtils로 Connection 동기화

스프링은 DataSourceUtils.getConnection(dataSource)를 통해 현재 스레드에 묶인 Connection이 있으면 그것을 반환한다. 즉 트랜잭션이 진행 중이라면 같은 Connection을 재사용하고, 아니면 새로 만든다. 이 메커니즘이 TransactionSynchronizationManager(ThreadLocal 기반)이다.

public class AccountRepository {
    private final DataSource dataSource;
 
    public void withdraw(Long id, int amount) {
        // 트랜잭션이 진행 중이면 ThreadLocal에 묶인 Connection을 그대로 사용
        // 진행 중이 아니면 새 Connection을 받음
        Connection con = DataSourceUtils.getConnection(dataSource);
        try (PreparedStatement ps = con.prepareStatement(
                "UPDATE account SET balance = balance - ? WHERE id = ?")) {
            ps.setInt(1, amount);
            ps.setLong(2, id);
            ps.executeUpdate();
        } finally {
            // 트랜잭션 진행 중이면 닫지 않고, 아니면 닫음 (스프링이 알아서 판단)
            DataSourceUtils.releaseConnection(con, dataSource);
        }
    }
}

2단계: PlatformTransactionManager

트랜잭션 시작/커밋/롤백을 추상화한 인터페이스. JDBC라면 DataSourceTransactionManager, JPA라면 JpaTransactionManager 가 구현한다.

TransactionStatus status = txManager.getTransaction(new DefaultTransactionDefinition());
try {
    // 비즈니스 로직
    txManager.commit(status);
} catch (Exception e) {
    txManager.rollback(status);
    throw e;
}

3단계: TransactionTemplate

콜백으로 try-catch를 감춰주는 헬퍼 클래스이다.

transactionTemplate.execute(status -> {
    accountRepository.withdraw(fromId, amount);
    accountRepository.deposit(toId, amount);
    return null;
});

여전히 비즈니스 로직 주변에 인프라 코드(execute 호출, 콜백 등)가 남는다. 이걸 완전히 걷어내는 게 @Transactional이다.

트랜잭션 어노테이션과 프록시

@Transactional 어노테이션 하나만 붙이면 위의 모든 보일러플레이트가 사라진다.

@Service
public class TransferService {
 
    @Transactional
    public void transfer(Long fromId, Long toId, int amount) {
        accountRepository.withdraw(fromId, amount);
        accountRepository.deposit(toId, amount);
    }
}

이게 가능한 이유는 스프링이 AOP 프록시로 메서드를 감싸기 때문이다.

  1. Client가 userService.createUser()를 호출한다. 이때 호출 대상은 실제 UserService가 아니라 스프링이 만들어 둔 ProxyInstance 이다.
  2. ProxyInstance는 메서드 진입 직전에 TransactionInterceptor.invoke() 를 호출한다.
  3. TransactionInterceptorTransactionAspectSupport.invokeWithinTransaction() 으로 위임한다. 여기서 PlatformTransactionManager를 통해 트랜잭션을 시작하고, 만들어진 Connection을 TransactionSynchronizationManager에 ThreadLocal로 묶어 둔다.
  4. 그 후 실제 UserService.createUser()가 호출된다. 내부에서 사용하는 Repository는 DataSourceUtils를 통해 ThreadLocal에 묶여 있는 같은 Connection을 꺼내 쓴다.
  5. 메서드가 정상 리턴하면 commit(), RuntimeException이 던져지면 rollback() 한다. 마지막으로 Connection을 풀에 반환한다.

코드로 보면 사용자가 호출하는 시점에 실제로는 이런 객체가 실행된다.

// 우리가 작성한 빈
@Service
public class UserService {
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
}
 
// 스프링이 런타임에 만들어 주입하는 프록시 (개념적 표현)
public class UserService$$EnhancerByCGLIB extends UserService {
    private final UserService target;
    private final TransactionInterceptor interceptor;
 
    @Override
    public void createUser(User user) {
        interceptor.invoke(new MethodInvocation() {
            @Override public Object proceed() {
                target.createUser(user); // 진짜 메서드 호출
                return null;
            }
        });
    }
}

프록시 방식 때문에 생기는 주요 제약 두 가지가 있다.

  • public 메서드에만 적용된다. (CGLIB 기준으로는 protected/package-private도 가능하긴 하지만 기본 정책은 public)
  • 같은 클래스 내부의 self-invocation에는 적용되지 않는다. this.method()로 호출하면 프록시를 거치지 않기 때문에 @Transactional이 무시된다. 이 경우 별도 빈으로 분리하거나 AopContext.currentProxy()를 써야 한다.

트랜잭션 전파

트랜잭션 전파(Propagation)는 이미 트랜잭션이 진행 중일 때, 새 @Transactional 메서드가 호출되면 어떻게 동작할지를 정의한다. @Transactional(propagation = ...) 로 설정한다.

전파 옵션동작
REQUIRED (기본값)진행 중인 트랜잭션이 있으면 참여, 없으면 새로 시작. 가장 많이 쓰임.
REQUIRES_NEW항상 새 트랜잭션을 시작. 기존 트랜잭션은 일시 정지(suspend). 별도 Connection을 새로 받는다.
SUPPORTS트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행.
NOT_SUPPORTED트랜잭션 없이 실행. 진행 중이면 일시 정지.
MANDATORY진행 중인 트랜잭션이 반드시 있어야 함. 없으면 예외.
NEVER트랜잭션이 있으면 예외. 트랜잭션 밖에서만 동작.
NESTEDJDBC SAVEPOINT 기반의 중첩 트랜잭션. 외부가 롤백되면 같이 롤백되지만, 내부만 롤백할 수도 있음.

REQUIRED vs REQUIRES_NEW 의 차이

  • REQUIRED는 같은 Connection을 공유하므로 외부에서 롤백하면 중첩된 작업도 같이 롤백된다.
  • REQUIRES_NEW는 별도 Connection을 새로 받는다. 중첩 트랜잭션이 먼저 커밋되고 외부가 롤백돼도 내부 결과는 살아남는다. 감사 로그(audit log)처럼 성공/실패와 무관하게 반드시 기록되어야 하는 로직에 사용한다.
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final AuditLogService auditLogService;
 
    @Transactional // 외부 트랜잭션 (REQUIRED)
    public void placeOrder(Order order) {
        orderRepository.save(order);
 
        // REQUIRES_NEW → 별도 트랜잭션. 즉시 커밋되어 외부가 롤백돼도 로그는 남음
        auditLogService.writeLog("order placed: " + order.getId());
 
        if (order.getAmount() <= 0) {
            throw new IllegalArgumentException("invalid amount"); // 주문은 롤백, 로그는 남음
        }
    }
}
 
@Service
@RequiredArgsConstructor
public class AuditLogService {
    private final AuditLogRepository auditLogRepository;
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeLog(String message) {
        auditLogRepository.save(new AuditLog(message, LocalDateTime.now()));
    }
}

위 예제에서 placeOrder가 예외를 던지면 주문은 롤백되지만 감사 로그는 살아남는다. REQUIRES_NEW 가 외부 트랜잭션을 일시 정지(suspend)하고 새 Connection으로 즉시 커밋하기 때문이다. 다만 REQUIRES_NEW를 남용하면 한 요청에서 커넥션을 2개 점유하므로, 트래픽이 몰리면 커넥션 풀 고갈 위험이 있다.

NESTED는 SAVEPOINT를 활용하므로 부분 롤백이 가능하다.

@Transactional // 외부 트랜잭션
public void importBulk(List<Order> orders) {
    for (Order order : orders) {
        try {
            saveOneItem(order); // NESTED → SAVEPOINT 기준으로 이 건만 롤백 가능
        } catch (Exception e) {
            log.warn("skip invalid order: {}", order.getId(), e);
            // 외부 트랜잭션은 계속 진행
        }
    }
}
 
@Transactional(propagation = Propagation.NESTED)
public void saveOneItem(Order order) {
    orderRepository.save(order);
}

트랜잭션 격리 수준

격리 수준(Isolation Level)은 동시 트랜잭션 간 데이터를 어느 수준으로 격리할지 결정한다. 격리가 강할수록 안전하지만 성능이 떨어진다.

수준Dirty ReadNon-repeatable ReadPhantom Read
READ_UNCOMMITTEDOOO
READ_COMMITTEDXOO
REPEATABLE_READXXO (DB에 따라 X)
SERIALIZABLEXXX
  • Dirty Read: 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상.
  • Non-repeatable Read: 같은 트랜잭션에서 같은 행을 두 번 읽었는데 값이 달라지는 현상 (다른 트랜잭션이 UPDATE 커밋).
  • Phantom Read: 같은 조건으로 범위 조회를 두 번 했는데 결과 행 개수가 달라지는 현상 (다른 트랜잭션이 INSERT 커밋).

Dirty Read (READ_UNCOMMITTED 에서만 발생)

sequenceDiagram
    autonumber
    participant T1
    participant DB as account(id=1)<br/>balance=100
    participant T2

    T1->>DB: BEGIN
    T1->>DB: UPDATE balance = 0 (아직 커밋 X)
    Note over DB: balance = 0 (미커밋)
    T2->>DB: BEGIN
    T2->>DB: SELECT balance WHERE id=1
    DB-->>T2: 0  ← 미커밋 값을 읽음 (Dirty)
    T1->>DB: ROLLBACK
    Note over DB: balance = 100 으로 복구
    Note over T2: T2가 본 0은 실제로 존재한 적 없는 유령 값

Non-repeatable Read (READ_COMMITTED 에서 발생)

sequenceDiagram
    autonumber
    participant T1
    participant DB as account(id=1)<br/>balance=100
    participant T2

    T1->>DB: BEGIN
    T1->>DB: SELECT balance WHERE id=1
    DB-->>T1: 100
    T2->>DB: BEGIN
    T2->>DB: UPDATE balance = 200
    T2->>DB: COMMIT
    Note over DB: balance = 200
    T1->>DB: SELECT balance WHERE id=1
    DB-->>T1: 200  ← 같은 트랜잭션인데 값이 바뀜

Phantom Read (REPEATABLE_READ 에서 발생 가능)

sequenceDiagram
    autonumber
    participant T1
    participant DB as orders<br/>WHERE user_id=1
    participant T2

    T1->>DB: BEGIN
    T1->>DB: SELECT COUNT(*) WHERE user_id=1
    DB-->>T1: 3건
    T2->>DB: BEGIN
    T2->>DB: INSERT INTO orders (user_id=1, ...)
    T2->>DB: COMMIT
    Note over DB: 4번째 행이 추가됨
    T1->>DB: SELECT COUNT(*) WHERE user_id=1
    DB-->>T1: 4건  ← 유령 행 등장

스프링에서는 @Transactional(isolation = Isolation.REPEATABLE_READ) 처럼 지정한다. 기본값 Isolation.DEFAULT는 DB의 기본 격리 수준을 그대로 따른다.

// 통계 집계: 같은 트랜잭션에서 여러 번 조회해도 값이 변하지 않아야 함
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public StatsReport calculateMonthlyStats(Long userId) {
    int totalOrders = orderRepository.countByUserId(userId);
    int totalAmount = orderRepository.sumAmountByUserId(userId);
    // 두 쿼리 사이에 다른 트랜잭션이 INSERT 해도 영향받지 않음
    return new StatsReport(totalOrders, totalAmount);
}
 
// 동시성 이슈가 크리티컬한 영역: 직렬화 강제
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferWithLimitCheck(Long fromId, Long toId, int amount) {
    // 동시 이체로 인한 잔액 초과 차감 등을 원천 차단
}
  • MySQL (InnoDB) 기본값: REPEATABLE_READ (단, 갭락 덕분에 Phantom Read도 막힘)
  • PostgreSQL 기본값: READ_COMMITTED
  • Oracle 기본값: READ_COMMITTED

대부분 DB 기본값(DEFAULT)을 쓰고, 강한 정합성이 필요한 특정 메서드만 SERIALIZABLE로 올린다.

유의사항

1. self-invocation 미적용

@Service
public class OrderService {
    public void outer() {
        this.inner(); // 프록시를 거치지 않아 @Transactional 무시됨
    }
    @Transactional
    public void inner() { ... }
}

같은 클래스 안에서 @Transactional 메서드를 호출해도 트랜잭션이 시작되지 않는다. AOP는 프록시 객체를 통해 들어오는 호출만 인터셉트할 수 있기 때문이다.

// 방법 A: 다른 빈으로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderTxService orderTxService;
    public void outer() {
        orderTxService.inner(); // 다른 빈의 프록시를 통과
    }
}
 
@Service
public class OrderTxService {
    @Transactional
    public void inner() { ... }
}
 
// 방법 B: 자기 자신의 프록시를 주입받기
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationContext context;
    public void outer() {
        context.getBean(OrderService.class).inner();
    }
    @Transactional
    public void inner() { ... }
}

2. checked exception은 기본적으로 롤백하지 않는다

@Transactional은 기본적으로 RuntimeException과 Error만 롤백한다. IOException 같은 checked exception에서도 롤백하려면 명시해야 한다.

// IOException 발생해도 커밋된다
@Transactional
public void save(Order order) throws IOException {
    orderRepository.save(order);
    fileExporter.export(order); // IOException 가능
}
 
// rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void save(Order order) throws IOException {
    orderRepository.save(order);
    fileExporter.export(order);
}

3. private 메서드에는 적용되지 않는다

앞서 프록시 방법에선 UserService를 상속받아 public 메서드에 트랜잭션 로직이 생성되었다. 하지만 private 접근 제어자는 UserService를 상속받더라도 override가 불가능하기 때문에 트랜잭션 로직이 생성될 수 없다.

4. readOnly 옵션의 의미

JPA에서는 flush를 생략(FlushMode.MANUAL) 해서 dirty checking 비용을 없애고, 일부 DB/드라이버는 read-only Connection을 별도로 라우팅한다 (Read Replica).

@Service
public class OrderQueryService {
    @Transactional(readOnly = true) // 조회 전용 — 영속성 컨텍스트 변경 감지 X
    public List<OrderResponse> findAll() {
        return orderRepository.findAll().stream()
                .map(OrderResponse::from)
                .toList();
    }
}

5. 트랜잭션 안에서 외부 API 호출 금지

@Transactional 메서드 안에서 외부 HTTP 호출을 하면, 그 시간 동안 Connection을 잡고 있다. 외부 시스템 지연이 그대로 DB 커넥션 풀 고갈로 이어진다.

// 외부 API 응답이 30초 걸리면 그 동안 Connection 점유
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    paymentApiClient.charge(order); // 외부 호출 — 트랜잭션 안에 두면 안 됨
}
 
// 트랜잭션 범위를 좁히고, 외부 호출은 트랜잭션 밖으로
public void placeOrder(Order order) {
    saveOrderTx(order);             // 트랜잭션 빠르게 끝냄
    paymentApiClient.charge(order); // 트랜잭션 밖에서 호출
}
 
@Transactional
public void saveOrderTx(Order order) {
    orderRepository.save(order);
}

6. 트랜잭션 안에서 비동기 호출 시 컨텍스트 분리

@Async 메서드는 별도 스레드에서 실행되므로 ThreadLocal에 묶인 트랜잭션을 공유하지 못한다.

@Transactional
public void register(User user) {
    userRepository.save(user);
    notificationService.sendWelcomeAsync(user.getEmail());
    // 이 시점에 user가 아직 커밋되지 않았을 수 있음
    // 비동기 스레드에서 user를 DB에서 조회하면 못 찾을 수 있음
}
 
@Async
public void sendWelcomeAsync(String email) {
    // 새 스레드 → 새 트랜잭션 (또는 트랜잭션 없음)
    User user = userRepository.findByEmail(email); // null 가능
}

비동기 스레드를 같은 트랜잭션으로 묶고 싶다면, TransactionSynchronizationManager를 이용할 수 있다. TransactionSynchronizationManager.registerSynchronization() 으로 커밋 후 콜백을 걸어 비동기 호출을 트리거하는 것이다.

@Transactional
public void register(User user) {
    userRepository.save(user);
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override public void afterCommit() {
                notificationService.sendWelcomeAsync(user.getEmail());
            }
        });
}

7. 예외를 try-catch로 삼키면 롤백되지 않는다

프록시가 롤백 여부를 판단하는 기준은 “메서드 밖으로 나간 예외”이다. 안에서 catch 해버리면 트랜잭션은 정상 커밋된다.

// 예외 삼킴 → 커밋됨
@Transactional
public void process(Order order) {
    orderRepository.save(order);
    try {
        externalService.call(order);
    } catch (Exception e) {
        log.error("failed", e);
        // 트랜잭션은 정상 커밋된다
    }
}
 
// catch 후에도 롤백시키려면 명시
@Transactional
public void process(Order order) {
    orderRepository.save(order);
    try {
        externalService.call(order);
    } catch (Exception e) {
        log.error("failed", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

트랜잭션 어노테이션과 멀티 데이터 소스

멀티 데이터 소스에서의 트랜잭션

한 애플리케이션이 다루는 데이터 시스템은 보통 동일한 RDB만이 아니라 이종 시스템의 조합이다.(MySQL + Kafka + Redis + Mongo..)

이종 시스템은 트랜잭션 모델이 전부 다르며, 하나의 ACID 트랜잭션으로 묶을 수 없다. @Transactional은 어디까지나 PlatformTransactionManager 를 통한 추상화이고, Kafka/Redis는 그 추상화 바깥에 있다.

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);                              // MySQL
    kafkaTemplate.send("order-created", toJson(order));        // Kafka ← 이미 발행됨!
    redisTemplate.opsForValue().set("order:" + order.getId(), order); // Redis ← 이미 저장됨!
 
    if (somethingWrong()) {
        throw new RuntimeException(); // MySQL만 롤백. Kafka/Redis는 살아남음
    }
}

@Transactional은 RuntimeException이 나면 MySQL Connection을 rollback 하지만, Kafka에 이미 보낸 메시지는 회수할 수 없고, Redis에 쓴 값도 되돌릴 수 없다.

  • DB에는 주문이 없는데, 컨슈머는 주문 생성됨 이벤트를 받음 → 결제/배송이 진행됨
  • Redis에는 주문이 캐시돼 있는데 DB엔 없음 → 다른 API가 캐시를 읽으면 유령 데이터

통합 방법

1. afterCommit 콜백

MySQL 트랜잭션이 커밋된 후에만 Kafka/Redis를 건드린다. TransactionSynchronizationManager.registerSynchronization() 로 콜백을 건다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final StringRedisTemplate redisTemplate;
 
    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order); // MySQL 트랜잭션 안
 
        TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override public void afterCommit() {
                    // MySQL 커밋이 확정된 이후에만 실행됨
                    kafkaTemplate.send("order-created", toJson(order));
                    redisTemplate.opsForValue().set(
                        "order:" + order.getId(), toJson(order));
                }
            });
    }
}

@TransactionalEventListener(phase = AFTER_COMMIT) 로 더 깔끔하게 작성할 수 있다.

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    eventPublisher.publishEvent(new OrderCreatedEvent(order));
}
 
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
    kafkaTemplate.send("order-created", toJson(event.order()));
    redisTemplate.opsForValue().set("order:" + event.order().getId(), toJson(event.order()));
}

다만, MySQL 커밋 직후 애플리케이션이 죽으면 Kafka/Redis 작업이 누락된다. 이걸 막으려면 다음 패턴(Outbox)이 필요하다.

2. Transactional Outbox

afterCommit 의 문제점은 커밋 직후 프로세스가 죽으면 메시지가 사라진다는 것이다. Outbox는 같은 MySQL 트랜잭션 안에서 outbox 테이블에 메시지를 INSERT 하고, 별도 프로세스가 outbox 를 읽어 Kafka 로 발행한다.

CREATE TABLE outbox (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    aggregate   VARCHAR(50) NOT NULL,   -- "Order"
    event_type  VARCHAR(50) NOT NULL,   -- "OrderCreated"
    payload     JSON NOT NULL,
    created_at  DATETIME NOT NULL,
    published   BOOLEAN NOT NULL DEFAULT FALSE,
    INDEX idx_unpublished (published, created_at)
);
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
 
    @Transactional // MySQL만의 트랜잭션 — 두 INSERT가 원자적
    public void placeOrder(Order order) {
        orderRepository.save(order);
        outboxRepository.save(OutboxEvent.of("Order", "OrderCreated", order));
    }
}
 
@Component
@RequiredArgsConstructor
public class OutboxPublisher {
    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
 
    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        List<OutboxEvent> events = outboxRepository.findTop100ByPublishedFalseOrderByCreatedAtAsc();
        for (OutboxEvent event : events) {
            kafkaTemplate.send(event.getTopic(), event.getPayload())
                .whenComplete((result, ex) -> {
                    if (ex == null) event.markPublished(); // 같은 트랜잭션에서 업데이트
                });
        }
    }
}

이와 비슷한 방법으로 인프라 레벨에서 Debezium같은 CDC(Change Data Capture) 도구로 MySQL binlog 를 읽어 Kafka 로 자동 발행할 수 있다.

3. Cache-Aside 패턴

Redis는 캐시이므로 DB가 원본, Redis는 사본이다.

// 전략 A: Write-through (afterCommit으로 갱신)
@Transactional
public Order updateOrder(Long id, OrderUpdate cmd) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.apply(cmd);
 
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override public void afterCommit() {
                redisTemplate.opsForValue().set(cacheKey(id), toJson(order));
            }
        });
    return order;
}
 
// 전략 B: Cache invalidation (지우기만, 다음 조회 때 다시 채움)
@Transactional
public void updateOrder(Long id, OrderUpdate cmd) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.apply(cmd);
 
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override public void afterCommit() {
                redisTemplate.delete(cacheKey(id)); // 다음 조회 때 DB에서 다시 로드
            }
        });
}