josolha
동시성 · 비동기 처리 진화 스토리 본문
0. 출발점 - "트랜잭션이면 되겠지?"
처음엔 서비스 메서드에 @Transactional을 붙이면 주문 생성, 재고 차감, 쿠폰 발급이 하나의 작업 단위로 묶이니까 실패 시 롤백도 되고 안전할 거라 생각했다.
@Service
public class OrderService {
@Transactional
public void createOrder(Long productId, Long userId) {
Product product = productRepository.findById(productId);
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
Order order = new Order(userId, productId);
orderRepository.save(order);
}
}
하지만 곧 깨달았다.
트랜잭션은 내 작업의 원자성만 보장한다. 동시에 시작되는 다른 요청을 막아주지는 않는다.
Lost Update (갱신 분실) 문제
초기 재고: 1개
[시간순]
요청 A: SELECT stock FROM product WHERE id = 1; -- 1개 읽음
요청 B: SELECT stock FROM product WHERE id = 1; -- 1개 읽음 (동시!)
요청 A: UPDATE product SET stock = 0 WHERE id = 1;
요청 A: COMMIT; -- 주문 성공
요청 B: UPDATE product SET stock = 0 WHERE id = 1;
요청 B: COMMIT; -- 주문 성공 (문제!)
결과: 재고 1개인데 주문 2건 성공1. 1차 해결 - 단일 서버에서는 JVM 락으로도 막을 수 있었다
synchronized
단일 서버 환경에서는 synchronized로 임계 구역을 만들면 한 번에 하나의 스레드만 들어올 수 있어 갱신 분실을 막을 수 있다.
@Service
public class OrderService {
@Transactional
public synchronized void createOrder(Long productId, Long userId) {
Product product = productRepository.findById(productId);
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
orderRepository.save(new Order(userId, productId));
}
}
하지만:
- 서버가 여러 대가 되면 JVM이 달라져 락이 공유되지 않는다
- 메서드 단위로 잠그기 쉬워 락 범위가 커지면 TPS가 급격히 떨어진다
ConcurrentHashMap<Long, ReentrantLock>
더 세밀하게 키 단위 락을 만들기 위해 ConcurrentHashMap<id, ReentrantLock> 방식도 사용 가능하다.
@Component
public class LockManager {
private final ConcurrentHashMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
public ReentrantLock getLock(Long key) {
return locks.computeIfAbsent(key, k -> new ReentrantLock());
}
public void removeLock(Long key) {
locks.remove(key);
}
}
@Service
public class OrderService {
private final LockManager lockManager;
@Transactional
public void createOrder(Long productId, Long userId) {
ReentrantLock lock = lockManager.getLock(productId);
lock.lock();
try {
Product product = productRepository.findById(productId);
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
orderRepository.save(new Order(userId, productId));
} finally {
lock.unlock();
}
}
}
이게 가능한 이유는:
- 같은 JVM 안에서 모든 스레드가 같은 Map과 Lock 객체를 공유하기 때문
- 특정 ID에 대해서만 직렬화가 가능해 병렬성이 더 좋아진다
하지만 이 역시:
- 멀티 서버에서는 무력
- Lock 객체 관리(메모리, 제거 시점)가 어렵고
- 서버 재시작 시 상태가 모두 날아간다
결론: JVM 락은 "단일 서버"까지만 유효하다.
2. 2차 해결 - DB 비관락으로 정합성은 잡았다
멀티 서버에서도 확실히 막으려면 모든 서버가 공유하는 자원인 DB가 필요했다.
그래서 SELECT ... FOR UPDATE 같은 DB 비관락을 사용했다.
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
}
@Service
public class OrderService {
@Transactional
public void createOrder(Long productId, Long userId) {
Product product = productRepository.findByIdWithLock(productId)
.orElseThrow();
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
orderRepository.save(new Order(userId, productId));
}
}
생성되는 SQL:
SELECT * FROM product WHERE id = ? FOR UPDATE;
장점:
- 같은 row에 대해 동시에 수정 불가
- 갱신 분실 완벽 차단
하지만 곧 다른 문제가 드러났다:
- 요청이 몰리면 락 대기열이 길어짐
- DB가 병목 지점이 됨
- 락은 "하나씩 처리"는 해도 순서(FIFO)는 보장하지 않음
- 데드락 가능성
결론: DB 락은 강력하지만 너무 비싸다. DB에 들어오기 전에 경쟁을 줄여야 했다.
2.5. 낙관락 시도 - 충돌이 많으면 재시도 폭증
비관락을 도입하기 전, 낙관락(@Version)도 시도해봤다.
@Entity
public class Product {
@Id
private Long id;
@Version
private Long version;
private int stock;
public void decreaseStock(int quantity) {
if (this.stock < quantity) {
throw new OutOfStockException();
}
this.stock -= quantity;
}
}
@Service
public class OrderService {
@Transactional
public void createOrder(Long productId, Long userId) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.decreaseStock(1);
// UPDATE product SET stock = ?, version = version + 1
// WHERE id = ? AND version = ?
orderRepository.save(new Order(userId, productId));
}
}
장점:
- 락을 걸지 않아 읽기 성능 좋음
- 충돌이 드문 경우 효과적
하지만 주문/쿠폰 시스템에서는:
- 충돌이 많은 환경에서는
OptimisticLockException재시도 폭증 - 재시도 로직 복잡도 증가
- 사용자 입장에서는 "실패 → 재시도 → 실패"로 느껴짐
// 재시도 로직 예시
@Service
public class OrderService {
public void createOrderWithRetry(Long productId, Long userId) {
int maxRetries = 3;
int attempt = 0;
while (attempt < maxRetries) {
try {
createOrder(productId, userId);
return;
} catch (OptimisticLockException e) {
attempt++;
if (attempt >= maxRetries) {
throw new RuntimeException("재시도 횟수 초과", e);
}
// 백오프
Thread.sleep(100 * attempt);
}
}
}
}
결론:
- 낙관락은 충돌이 드문 경우(조회 많음, 수정 적음)에만 유효
- 주문/쿠폰은 충돌 빈도가 높아 비관락/분산락이 더 명확했다
3. 3차 해결 - Redis 분산락으로 멀티 서버 동시 진입을 막았다
분산락의 원리
Redis는 단일 스레드로 명령을 처리한다. 그래서 다음 명령은 원자적이다.
SET lock:product:123 token NX PX 3000- NX: 없을 때만 생성
- PX: TTL로 자동 해제 (3초)
이로써 멀티 서버 환경에서도 동시에 하나의 요청만 임계 구역에 진입할 수 있다.
Redisson을 사용한 이유
Redis 락을 직접 구현하면 함정이 많다:
- 락 해제 시 "내 락만 풀기" 문제 (다른 요청의 락을 실수로 해제)
- 스핀락 vs pub/sub 대기 방식
- TTL 만료/연장 문제
- 예외 상황에서 락 유실 문제
Redisson은 이를 라이브러리로 제공한다.
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
return Redisson.create(config);
}
}
@Service
public class OrderService {
private final RedissonClient redissonClient;
@Transactional
public void createOrder(Long productId, Long userId) {
String lockKey = "lock:product:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 최대 10초 대기, 3초 후 자동 해제
boolean acquired = lock.tryLock(10, 3, TimeUnit.SECONDS);
if (!acquired) {
throw new LockAcquisitionException("락 획득 실패");
}
Product product = productRepository.findById(productId)
.orElseThrow();
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
orderRepository.save(new Order(userId, productId));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 획득 중 인터럽트", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Redisson이 제공하는 것:
- 안전한 unlock (토큰 비교 + Lua 스크립트)
- 효율적인 대기 방식 (pub/sub)
- TTL/lease time/자동 연장 (watchdog)
분산락이 보장하는 것 vs 보장하지 못하는 것
보장:
- 동시 진입 차단
보장 불가:
- 요청 순서(FIFO)
- 공정성
그래서 주문/재고에는 충분히 쓸 수 있었지만 선착순 쿠폰에서는 체감 문제가 남았다.
4. 분산락을 써도 반드시 필요한 것 - DB 트랜잭션 결합
Redis 락은 "문을 잠그는 역할"만 한다. DB 상태의 원자성은 여전히 DB 트랜잭션이 책임져야 한다.
잘못된 구조 예시
// Bad: 락이 트랜잭션 밖에 있음
public void createOrder(Long productId, Long userId) {
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
lock.lock();
orderService.createOrderInTransaction(productId, userId);
// 트랜잭션 커밋 전에 락이 풀릴 수 있음!
} finally {
lock.unlock();
}
}
@Transactional
public void createOrderInTransaction(Long productId, Long userId) {
// DB 작업
}
올바른 구조
// Good: 락 획득 → 트랜잭션 시작 → 커밋 → 락 해제
@Service
public class OrderService {
public void createOrder(Long productId, Long userId) {
String lockKey = "lock:product:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(3, TimeUnit.SECONDS);
// 트랜잭션 시작
processOrderInTransaction(productId, userId);
// 트랜잭션 커밋
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@Transactional
public void processOrderInTransaction(Long productId, Long userId) {
Product product = productRepository.findById(productId)
.orElseThrow();
if (product.getStock() <= 0) {
throw new OutOfStockException();
}
product.decreaseStock(1);
orderRepository.save(new Order(userId, productId));
}
}
구조는 반드시 이 순서여야 한다:
- Redis 락 획득
- DB 트랜잭션 시작
- 검증/업데이트
- DB 커밋
- Redis 락 해제
그리고 락을 잡은 메서드와 DB 작업은 같은 트랜잭션 컨텍스트(REQUIRED) 안에 있어야 한다.
5. 전환점 - "모든 걸 락으로 풀 필요는 없었다"
여기서 한 단계 성숙해진 판단이 나온다.
"이건 사실 락이 아니라 DB의 원자적 UPDATE로 끝낼 수 있지 않나?"
전환 계기: 조회수 증가
처음에는 조회수 증가도 분산락으로 처리했다.
// Before: 락 사용
public void increaseViewCount(Long postId) {
RLock lock = redissonClient.getLock("lock:post:" + postId);
try {
lock.lock();
Post post = postRepository.findById(postId).orElseThrow();
post.increaseViewCount(); // views++
postRepository.save(post);
} finally {
lock.unlock();
}
}
하지만 이건 과한 설계였다. 단순 카운트 증가는 DB의 원자적 연산만으로도 충분했다.
// After: 원자적 UPDATE
@Modifying
@Query("UPDATE Post p SET p.views = p.views + 1 WHERE p.id = :id")
void increaseViewCount(@Param("id") Long id);
UPDATE post SET views = views + 1 WHERE id = ?;
재고 차감에도 적용
마찬가지로 재고 차감도 원자적 UPDATE로 전환했다.
// Before: 락 사용
public void decreaseStock(Long productId, int quantity) {
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
lock.lock();
Product product = productRepository.findById(productId).orElseThrow();
if (product.getStock() < quantity) {
throw new OutOfStockException();
}
product.decreaseStock(quantity);
} finally {
lock.unlock();
}
}
// After: 원자적 UPDATE
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :quantity " +
"WHERE p.id = :id AND p.stock >= :quantity")
int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);
public void decreaseStock(Long productId, int quantity) {
int updatedRows = productRepository.decreaseStock(productId, quantity);
if (updatedRows == 0) {
throw new OutOfStockException("재고 부족 또는 상품 없음");
}
}
왜 이 방식이 좋은가
락 방식:
- 락 획득 대기
- SELECT → 검증 → UPDATE
- 락 해제 대기
- 순차 처리로 병목
원자적 UPDATE:
- DB가 한 번의 UPDATE로 처리
- 락 대기 구조를 만들지 않음
- 실패는 즉시 반환 (영향 행 0)
- 병렬 처리 가능
결론:
- 락은 대기를 만들고 부하를 키우지만
- 원자성 UPDATE는 빠르게 실패/성공을 결정한다
- 단순 카운트성 자원은 이 방식으로 전환했다
언제 락이 필요한가?
// 복잡한 검증 로직이 있을 때만 락 사용
@Transactional
public void createOrder(Long productId, Long userId, Long couponId) {
RLock lock = redissonClient.getLock("lock:order:" + userId);
try {
lock.lock();
// 여러 테이블 조회 및 검증
Product product = productRepository.findById(productId).orElseThrow();
Coupon coupon = couponRepository.findById(couponId).orElseThrow();
User user = userRepository.findById(userId).orElseThrow();
// 복잡한 비즈니스 로직
if (!coupon.isUsableBy(user)) {
throw new InvalidCouponException();
}
if (product.getStock() < 1) {
throw new OutOfStockException();
}
// 여러 테이블 업데이트
product.decreaseStock(1);
coupon.use();
orderRepository.save(new Order(userId, productId, couponId));
} finally {
lock.unlock();
}
}
6. "선착순" 문제 - 경쟁이 아니라 줄 세우기
선착순 쿠폰은 요구사항이 다르다:
- 정합성
- 순서 체감 (먼저 온 사람이 먼저 받아야 함)
- 폭주 트래픽
락은 경쟁 구조라 순서를 예쁘게 보장하지 못한다.
그래서 발상을 바꾼다:
"경쟁시키지 말고 줄을 세우자."
7. Redis Queue(BLPOP)로 줄 세우기 시도
Redis 리스트 + BLPOP으로 큐를 만들었다.
Producer (요청 접수)
@RestController
public class CouponController {
private final StringRedisTemplate redisTemplate;
@PostMapping("/coupon/issue")
public ResponseEntity<String> issueCoupon(@RequestBody CouponRequest request) {
String queueKey = "queue:coupon:" + request.getCouponId();
// 큐에 사용자 ID 추가 (FIFO)
redisTemplate.opsForList().rightPush(queueKey, request.getUserId().toString());
return ResponseEntity.ok("대기열에 추가되었습니다");
}
}
Consumer (처리 워커)
@Component
public class CouponWorker {
private final StringRedisTemplate redisTemplate;
private final CouponService couponService;
@PostConstruct
public void startWorker() {
new Thread(() -> {
String queueKey = "queue:coupon:123";
while (true) {
try {
// BLPOP: 데이터가 있을 때까지 블로킹 대기 (폴링 X)
List<String> result = redisTemplate.opsForList()
.leftPop(queueKey, 1, TimeUnit.SECONDS);
if (result != null && !result.isEmpty()) {
Long userId = Long.parseLong(result.get(0));
couponService.issueCoupon(userId);
}
} catch (Exception e) {
log.error("쿠폰 발급 실패", e);
// 실패 처리 필요
}
}
}).start();
}
}
장점:
- 요청은 큐에 적재 (빠른 응답)
- 워커가 BLPOP으로 꺼내 처리
- 불필요한 폴링 감소 (블로킹 대기)
하지만 문제가 생긴다:
- 처리 중 장애 시 메시지 유실 가능
- ack/retry 개념이 약함
- 스케일 아웃/리밸런싱 어려움 (워커 여러 대 띄우면 중복 처리)
실패 처리 추가
@Component
public class CouponWorker {
public void processWithRetry() {
String queueKey = "queue:coupon:123";
String processingKey = "processing:coupon:123";
String failedKey = "failed:coupon:123";
while (true) {
try {
// 1. 큐에서 꺼내서 처리중 목록으로 이동
String userId = redisTemplate.opsForList()
.rightPopAndLeftPush(queueKey, processingKey);
if (userId == null) {
Thread.sleep(100);
continue;
}
// 2. 처리
couponService.issueCoupon(Long.parseLong(userId));
// 3. 성공 시 처리중 목록에서 제거
redisTemplate.opsForList().remove(processingKey, 1, userId);
} catch (Exception e) {
// 4. 실패 시 실패 목록으로 이동
String userId = redisTemplate.opsForList()
.rightPop(processingKey);
if (userId != null) {
redisTemplate.opsForList().rightPush(failedKey, userId);
}
log.error("쿠폰 발급 실패: {}", userId, e);
}
}
}
}
// 실패 메시지 재처리 Scheduler
@Scheduled(fixedDelay = 60000) // 1분마다
public void retryFailedMessages() {
String failedKey = "failed:coupon:123";
String queueKey = "queue:coupon:123";
while (true) {
String userId = redisTemplate.opsForList().rightPop(failedKey);
if (userId == null) {
break;
}
// 재시도를 위해 다시 큐에 넣기
redisTemplate.opsForList().rightPush(queueKey, userId);
}
}
이 시점부터 "큐 시스템을 직접 운영"하는 상태가 된다.
8. @Async 비동기 이벤트 시도
다음 시도는 @Async였다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
@Service
public class OrderService {
@Transactional
public void createOrder(Long userId, Long productId) {
// 주문 처리 (빠르게)
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(1);
Order order = orderRepository.save(new Order(userId, productId));
// 후처리는 비동기로
notificationService.sendOrderConfirmation(order.getId());
}
}
@Service
public class NotificationService {
@Async
public void sendOrderConfirmation(Long orderId) {
// 알림 발송 (비동기)
Order order = orderRepository.findById(orderId).orElseThrow();
emailService.send(order.getUserEmail(), "주문 완료");
}
}
장점:
- 트랜잭션은 빠르게 끝냄
- 후처리는 비동기로 분리
하지만 @Async는:
- 같은 프로세스 안의 스레드일 뿐
- 서버가 죽으면 작업 유실
- 재처리/리플레이 불가
- 확장성 한계 (스레드풀 크기 제한)
결론: 비동기처럼 보이지만 메시징 시스템은 아니다.
9. 최종 진화 - Kafka + Outbox + DLQ
Kafka는 왜 Scheduler가 필요 없는가
Redis BLPOP:
- 워커가 주기적으로 큐 확인 필요
- 워커 다운 시 재시작 필요
Kafka Consumer:
- 토픽을 구독하고 항상 실행 중이며
- poll-loop로 메시지를 즉시 가져온다
즉:
- "주기적으로 꺼내는" 모델이 아니라
- "계속 받고 처리하는" 모델
그래서 Scheduler가 필요 없다.
Offset이 곧 처리 상태
Kafka Consumer 동작:
1. poll()로 메시지 배치 가져오기
2. 비즈니스 로직 처리
3. 성공 시 offset commit (처리 완료 표시)
4. 실패 시 commit 안 함 → 다시 받음 (재처리)이게 Kafka가 자연스럽게 재처리를 지원하는 이유다.
Kafka Producer (주문 완료 이벤트 발행)
@Service
public class OrderService {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Transactional
public void createOrder(Long userId, Long productId) {
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(1);
Order order = orderRepository.save(new Order(userId, productId));
// Kafka 이벤트 발행
OrderEvent event = new OrderEvent(order.getId(), userId, productId);
kafkaTemplate.send("order-created", event);
}
}
Kafka Consumer (알림 발송)
@Service
public class NotificationConsumer {
@KafkaListener(topics = "order-created", groupId = "notification-group")
public void handleOrderCreated(OrderEvent event) {
try {
// 알림 발송
emailService.send(event.getUserEmail(), "주문 완료");
// 성공 시 자동으로 offset commit
} catch (Exception e) {
log.error("알림 발송 실패: {}", event, e);
throw e; // 재처리를 위해 예외 던지기
}
}
}
설정:
# application.yml
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: notification-group
enable-auto-commit: false # 수동 커밋 (처리 성공 후에만 커밋)
auto-offset-reset: earliest # 처음부터 읽기
listener:
ack-mode: record # 레코드 단위로 커밋
9.1. DLQ(Dead Letter Queue)는 왜 필요해졌나
Kafka는 실패 시 재처리가 쉽다. 하지만 계속 실패하는 메시지가 생긴다:
- 데이터 자체가 잘못됨 (null, 형식 오류)
- 외부 시스템 오류 (이메일 서버 다운)
- 코드 버그
이런 메시지를 계속 재시도하면:
- 전체 파티션이 막힌다 (offset이 진행되지 않음)
- 정상 메시지까지 처리 못 한다
그래서 DLQ가 등장한다.
DLQ는 "더 이상 정상 처리 경로에 두면 안 되는 메시지의 격리소"다.
DLQ 설정
@Configuration
public class KafkaConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderEvent> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
// DLQ로 보내는 에러 핸들러
factory.setCommonErrorHandler(errorHandler());
return factory;
}
@Bean
public DefaultErrorHandler errorHandler() {
// DLQ 토픽으로 재발행
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
kafkaTemplate(),
(record, ex) -> new TopicPartition("order-created.DLQ", record.partition())
);
// 1초 간격으로 3회 재시도 후 DLQ로
return new DefaultErrorHandler(
recoverer,
new FixedBackOff(1000L, 3L)
);
}
}
DLQ Consumer (모니터링 및 알림)
@Service
public class DLQConsumer {
@KafkaListener(topics = "order-created.DLQ", groupId = "dlq-monitor")
public void handleDLQ(OrderEvent event,
@Header(KafkaHeaders.EXCEPTION_MESSAGE) String errorMessage) {
log.error("DLQ 메시지 수신: event={}, error={}", event, errorMessage);
// Slack 알림
slackService.sendAlert("주문 처리 실패: " + event.getOrderId());
// DB에 실패 이력 저장
failedEventRepository.save(new FailedEvent(event, errorMessage));
}
}
DLQ 이후 처리 흐름
일반 흐름:
1. Consumer에서 처리 실패
2. 1초 후 재시도 (1회)
3. 1초 후 재시도 (2회)
4. 1초 후 재시도 (3회)
5. 여전히 실패 → DLQ 토픽으로 이동
6. Slack 알림 발송
7. 관리자가 수동 확인
8. 데이터 수정 후 재발행 or 무시DLQ는 장애를 숨기기 위한 게 아니라 정상 흐름을 보호하기 위한 장치다.
9.2. Outbox는 왜 필요했나
Kafka가 있어도 이 문제는 남는다:
- DB는 커밋됐는데 Kafka 발행 실패?
- Kafka는 발행됐는데 DB 롤백?
문제 시나리오
@Transactional
public void createOrder(Long userId, Long productId) {
// 1. DB 저장
Order order = orderRepository.save(new Order(userId, productId));
// 2. Kafka 발행
kafkaTemplate.send("order-created", new OrderEvent(order.getId()));
// 만약 여기서 네트워크 오류로 발행 실패하면?
// → DB는 커밋됐지만 이벤트는 발행 안 됨
}
Outbox 패턴 적용
@Entity
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String topic;
private String payload; // JSON
private boolean published;
private LocalDateTime createdAt;
}
@Service
public class OrderService {
private final OutboxEventRepository outboxRepository;
@Transactional
public void createOrder(Long userId, Long productId) {
// 1. DB 저장
Order order = orderRepository.save(new Order(userId, productId));
// 2. 이벤트를 DB에 저장 (같은 트랜잭션)
OutboxEvent outboxEvent = new OutboxEvent(
"order-created",
objectMapper.writeValueAsString(new OrderEvent(order.getId())),
false
);
outboxRepository.save(outboxEvent);
// DB 커밋 시 함께 커밋됨 (원자성 보장)
}
}
Outbox Relay (발행 담당)
@Component
public class OutboxRelay {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 100) // 100ms마다 폴링
@Transactional
public void publishEvents() {
List<OutboxEvent> events = outboxRepository
.findTop100ByPublishedFalseOrderByCreatedAtAsc();
for (OutboxEvent event : events) {
try {
// Kafka 발행
kafkaTemplate.send(event.getTopic(), event.getPayload()).get();
// 발행 성공 표시
event.setPublished(true);
outboxRepository.save(event);
} catch (Exception e) {
log.error("Outbox 발행 실패: {}", event.getId(), e);
// 다음 폴링에서 재시도
}
}
}
}
CDC 방식 (Debezium)
Polling 대신 DB 변경 로그를 직접 스트리밍하는 방법도 있다.
# Debezium Connector 설정
name: outbox-connector
connector.class: io.debezium.connector.mysql.MySqlConnector
database.hostname: localhost
database.port: 3306
database.user: debezium
database.password: dbz
database.server.id: 1
database.server.name: order-db
table.include.list: order_db.outbox_event
transforms: outbox
transforms.outbox.type: io.debezium.transforms.outbox.EventRouter
장점:
- 실시간 스트리밍 (폴링보다 빠름)
- 애플리케이션 부하 없음
단점:
- 인프라 복잡도 증가
- CDC 도구 운영 필요
9.3. Consumer의 멱등성 처리
Outbox를 사용해도 중복 발행 가능성은 남는다:
- Kafka 발행 성공했지만 DB 업데이트 실패
- 네트워크 재전송
그래서 Consumer는 멱등(Idempotent) 처리를 해야 한다.
@Service
public class NotificationConsumer {
@KafkaListener(topics = "order-created")
@Transactional
public void handleOrderCreated(OrderEvent event) {
// 멱등키로 중복 체크
String idempotencyKey = "order-notification:" + event.getOrderId();
boolean alreadyProcessed = redisTemplate
.opsForValue()
.setIfAbsent(idempotencyKey, "processed", 1, TimeUnit.HOURS);
if (!alreadyProcessed) {
log.info("이미 처리된 이벤트: {}", event.getOrderId());
return; // 중복 처리 방지
}
// 실제 처리
emailService.send(event.getUserEmail(), "주문 완료");
}
}
10. 전략 매핑 - 어디에 무엇을 사용했는가
| 기능 | 전략 | 이유 |
|---|---|---|
| 주문 생성 | Redis 분산락 + 원자적 UPDATE | 여러 테이블 정합성 필요 + 재고 차감 |
| 재고 차감 | 원자적 UPDATE | 단일 row, 빠른 실패, 락 불필요 |
| 선착순 쿠폰 | Kafka | 순서 보장(파티션) + 내구성 + 스케일 아웃 |
| 조회수 증가 | 원자적 UPDATE | 단순 카운트, 락 오버헤드 제거 |
| 주문 완료 후 알림 | Kafka + Outbox + DLQ | DB-Kafka 정합성 + 재처리 + 실패 격리 |
| 재고 복구 (취소 시) | 원자적 UPDATE | stock = stock + 1 |
| 쿠폰 발급 이력 | DB 트랜잭션 | 정확한 발급 기록 필요 |
의사결정 트리
동시성 문제 발생?
├─ 단순 카운트 증감?
│ └─ 원자적 UPDATE (락 불필요)
│
├─ 여러 테이블 정합성 필요?
│ └─ Redis 분산락 + 트랜잭션
│
├─ 순서 보장 필요?
│ └─ Kafka (파티션 순서 보장)
│
└─ 비동기 후처리?
├─ 유실 가능 → @Async
└─ 내구성 필요 → Kafka + Outbox11. 최종 요약
전체 진화 흐름 한 줄 정리
단일 서버에서는 JVM 락으로 시작했지만 멀티 서버로 확장되며 DB 락과 Redis 분산락을 거쳤고, 락의 경쟁·순서·병목 한계를 만나 원자성 UPDATE와 큐 기반 처리로 이동했으며, Redis BLPOP과 @Async의 내구성 한계를 넘기 위해 Kafka로 전환했고, Kafka의 offset 기반 처리로 Scheduler 없이 재처리가 가능해졌으며, 실패 메시지는 DLQ로 격리하고 DB-Kafka 정합성은 Outbox로 보완해 최종 구조에 도달했다.
핵심 설계 원칙
- 단순한 것부터 시작 - 모든 문제를 처음부터 Kafka로 해결하지 않음
- 락은 최소화 - 원자적 연산으로 해결 가능하면 락 사용 안 함
- 실패는 정상 - DLQ와 재시도로 실패를 관리 대상으로 다룸
- 정합성 우선 - Outbox로 DB-Kafka 원자성 보장
- 멱등성 필수 - Consumer는 중복 메시지를 안전하게 처리
각 단계의 한계와 극복
| 단계 | 한계 | 극복 방법 |
|---|---|---|
| JVM 락 | 멀티 서버 불가 | DB 락, Redis 분산락 |
| DB 락 | 병목, 순서 미보장 | 원자적 UPDATE, Redis Queue |
| 낙관락 | 충돌 시 재시도 폭증 | 비관락/분산락으로 전환 |
| Redis 분산락 | 순서 미보장, 공정성 X | Kafka (파티션 순서 보장) |
| Redis Queue | 유실 가능, 스케일 어려움 | Kafka (offset, consumer group) |
| @Async | 서버 다운 시 유실 | Kafka (영속성) |
| Kafka | DB-Kafka 불일치 | Outbox 패턴 |
| Kafka 재시도 | 무한 재시도 시 파티션 막힘 | DLQ (실패 격리) |
이 문서가 동시성 제어와 비동기 처리의 전체 진화 과정을 이해하는 데 도움이 되기를 바랍니다.