• 포스트
  • 도구
  • 작업
  • 포스트
  • 도구
  • 작업
로그인회원가입

© 2005 kaisa.co.kr. All Rights Reserved.

소개개인정보처리방침7083620@hanmail.net
← 목록으로
2026-03-24

비동기·메시징·Virtual Threads

비동기·메시징·Virtual Threads

요약: Virtual Threads와 이벤트·메시지 큐를 용도에 맞게 고르고, 커밋 이후 발행과 Outbox로 유실을 줄인다.


1. 왜 전부 리액티브가 아닌가

요청-응답 CRUD는 동기 MVC로 충분한 경우가 많다. 팀 숙련도와 블로킹 라이브러리(JPA, JDBC)를 보고 고른다.

요구 선택
단순 REST + JDBC MVC + Virtual Threads
응답은 바로, 후처리는 나중 이벤트 또는 큐
높은 동시 블로킹 I/O Virtual Threads
CPU 집약 플랫폼 스레드 풀 제한
서비스 간 재시도·재생 Kafka / RabbitMQ / SQS

2. Virtual Threads

한 줄 정의: Java 21의 Virtual Thread는 OS 스레드에 1:1로 묶이지 않는 가벼운 스레드다. 블로킹 I/O 대기 중에도 캐리어 스레드를 오래 점유하지 않는다. Boot 3.2+에서 Tomcat 요청에 붙는다.

spring:
  threads:
    virtual:
      enabled: true

CPU 바운드에는 효과가 작다. Java 21에서 오래 잡는 synchronized는 캐리어 pinning이 난다. 스레드 로컬을 크게 쓰는 라이브러리는 확인한다. 가상 스레드를 켜도 Hikari 풀 크기가 DB 동시 접속의 천장이다.


3. @Async

같은 JVM 안에서 “조금 나중에” 실행할 때만 쓴다. 재시도·DLQ·순서 보장이 없고, 프로세스가 죽으면 작업이 사라진다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    TaskExecutor taskExecutor() {
        return task -> Thread.startVirtualThread(task);
    }
}
@Service
public class MailService {
    @Async
    public void sendWelcome(String email) {
        // SMTP
    }
}

중요한 후처리는 브로커로 넘긴다.


4. 애플리케이션 이벤트

같은 앱에서 결합을 낮출 때 쓴다. @TransactionalEventListener(AFTER_COMMIT) 이 핵심이다. 커밋 전에 메일을 보내면 롤백된 주문에 환영 메일이 간다.

public record OrderCreatedEvent(Long orderId, String email) {}

@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;
    private final OrderRepository repository;

    public OrderService(ApplicationEventPublisher publisher, OrderRepository repository) {
        this.publisher = publisher;
        this.repository = repository;
    }

    @Transactional
    public OrderResponse create(CreateOrderRequest request) {
        Order order = repository.save(Order.create(request));
        publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getEmail()));
        return OrderResponse.from(order);
    }
}

@Component
public class OrderCreatedListener {
    private final MailService mailService;

    public OrderCreatedListener(MailService mailService) {
        this.mailService = mailService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCreatedEvent event) {
        mailService.sendWelcome(event.email());
    }
}

기본 이벤트는 발행 스레드에서 돈다. 완전 분리하려면 @Async 또는 큐다. AFTER_COMMIT 리스너 실패는 주문 트랜잭션을 되돌리지 않으므로, 실패를 로그·재시도 없이 삼키지 않는다.


5. Kafka와 Outbox

Kafka는 이벤트 로그—재생·파티션 순서·다중 소비자에 강하다. 메일·썸네일·리포트처럼 “일감 하나”를 경쟁 소비자가 나눠 가지면 RabbitMQ(또는 SQS)가 더 단순하다. 교환기·라우팅 키로 구독 대상을 나누는 작업 큐에 맞다.

spring:
  kafka:
    bootstrap-servers: ${KAFKA_BROKERS}
    consumer:
      group-id: demo-service
      auto-offset-reset: earliest
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
kafkaTemplate.send("order.created", event.orderId().toString(), event);

@KafkaListener(topics = "order.created", groupId = "mail-service")
public void consume(OrderCreatedEvent event) {
    mailService.sendWelcome(event.email());
}

소비자는 멱등해야 한다. 스키마는 호환 규칙을 정하고, 실패는 재시도 후 DLT(Dead Letter Topic) 로 보낸다. 파티션 키가 순서 보장 범위다.

Outbox: DB 커밋 후 Kafka 전송이 실패하면 상태가 갈라진다. 같은 트랜잭션에 비즈니스 행과 outbox 행을 쓰고, 별도 퍼블리셔(또는 Debezium CDC)가 브로커로 보낸 뒤 처리 완료를 표시한다.

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT NOT NULL,
    event_type   VARCHAR(100) NOT NULL,
    payload      JSONB NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL,
    published_at TIMESTAMPTZ
);

published_at IS NULL인 행만 폴링한다. 전송 성공과 마킹을 한 작업으로 두고, 중복 발행에 대비해 소비자는 멱등이어야 한다.


6. WebFlux를 보는 시점

동시 연결이 매우 많고 다운스트림이 모두 non-blocking이거나, SSE/WebSocket이 핵심이면 검토한다. JDBC/JPA 중심이고 블로킹 라이브러리가 많으면 MVC + Virtual Threads가 보통 낫다.

HTTP 클라이언트는 connect/read timeout을 명시한다. 가상 스레드여도 무한 대기는 큐와 풀을 채운다. 카프카 리스너 처리 시간과 큐 적체를 메트릭으로 본다.


7. 흔한 실수

실수 대안
@Async로 결제 확정 브로커 + 멱등 소비자
커밋 전 메일 발송 AFTER_COMMIT 또는 Outbox
Virtual Threads만 켜기 풀·타임아웃 함께
비멱등 consume 키 테이블·조건부 갱신
DB 커밋과 produce를 따로 Outbox

연습

  1. Virtual Threads를 켜고, 대기 있는 API의 처리량을 비교한다.
  2. OrderCreatedEvent + AFTER_COMMIT으로 메일(로그)을 보낸다.
  3. (선택) Kafka Testcontainers로 produce/consume IT를 작성한다.
  4. Outbox 테이블을 스케치한다.

댓글

0

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

이전 글Observability — Actuator, Micrometer, OpenTelemetry다음 글캐시·Resilience·성능 최적화
← 목록으로