Notice
Recent Posts
Recent Comments
Link
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

josolha

동시성 · 비동기 처리 진화 스토리 본문

카테고리 없음

동시성 · 비동기 처리 진화 스토리

josolha 2026. 1. 17. 15:18

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));
    }
}

구조는 반드시 이 순서여야 한다:

  1. Redis 락 획득
  2. DB 트랜잭션 시작
  3. 검증/업데이트
  4. DB 커밋
  5. 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 + Outbox

11. 최종 요약

전체 진화 흐름 한 줄 정리

단일 서버에서는 JVM 락으로 시작했지만 멀티 서버로 확장되며 DB 락과 Redis 분산락을 거쳤고, 락의 경쟁·순서·병목 한계를 만나 원자성 UPDATE와 큐 기반 처리로 이동했으며, Redis BLPOP과 @Async의 내구성 한계를 넘기 위해 Kafka로 전환했고, Kafka의 offset 기반 처리로 Scheduler 없이 재처리가 가능해졌으며, 실패 메시지는 DLQ로 격리하고 DB-Kafka 정합성은 Outbox로 보완해 최종 구조에 도달했다.

핵심 설계 원칙

  1. 단순한 것부터 시작 - 모든 문제를 처음부터 Kafka로 해결하지 않음
  2. 락은 최소화 - 원자적 연산으로 해결 가능하면 락 사용 안 함
  3. 실패는 정상 - DLQ와 재시도로 실패를 관리 대상으로 다룸
  4. 정합성 우선 - Outbox로 DB-Kafka 원자성 보장
  5. 멱등성 필수 - 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 (실패 격리)

이 문서가 동시성 제어와 비동기 처리의 전체 진화 과정을 이해하는 데 도움이 되기를 바랍니다.