[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(); // ⑥ 커넥션 반환
}
}- autoCommit을 false로 바꿔야 트랜잭션이 시작된다. JDBC는 기본적으로 매 쿼리마다 자동 커밋이 켜져 있다.
- 같은 Connection으로 모든 쿼리를 실행해야 같은 트랜잭션이다. Repository에 Connection을 매번 인자로 넘겨야 한다.
- 반드시 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 프록시로 메서드를 감싸기 때문이다.
- Client가
userService.createUser()를 호출한다. 이때 호출 대상은 실제UserService가 아니라 스프링이 만들어 둔 ProxyInstance 이다. - ProxyInstance는 메서드 진입 직전에
TransactionInterceptor.invoke()를 호출한다. TransactionInterceptor는TransactionAspectSupport.invokeWithinTransaction()으로 위임한다. 여기서PlatformTransactionManager를 통해 트랜잭션을 시작하고, 만들어진 Connection을TransactionSynchronizationManager에 ThreadLocal로 묶어 둔다.- 그 후 실제 UserService.createUser()가 호출된다. 내부에서 사용하는 Repository는
DataSourceUtils를 통해 ThreadLocal에 묶여 있는 같은 Connection을 꺼내 쓴다. - 메서드가 정상 리턴하면
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 | 트랜잭션이 있으면 예외. 트랜잭션 밖에서만 동작. |
| NESTED | JDBC 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 Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ_UNCOMMITTED | O | O | O |
| READ_COMMITTED | X | O | O |
| REPEATABLE_READ | X | X | O (DB에 따라 X) |
| SERIALIZABLE | X | X | X |
- 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에서 다시 로드
}
});
}