[강의] Spring 비동기 처리하기

Jerry·2025년 11월 27일

Spring 비동기 처리 개요 및 @Async 소개

1. Spring 비동기 처리의 필요성

1-1. 대용량 트래픽 환경에서의 성능 개선

Spring 비동기 처리는 요청 처리 중 오래 걸리는 작업을 별도 스레드로 분리하여 메인 스레드의 점유 시간을 최소화한다.
이로 인해 동시 요청이 많은 환경에서도 스레드 고갈을 방지하고 서버 처리량(throughput)을 높일 수 있다.

결과적으로 응답 지연 감소, 안정성 향상, 확장성 확보라는 핵심 이점을 제공한다.


1-2. 사용자 경험 향상을 위한 응답 시간(Latency) 최적화

비동기 처리로 오래 걸리는 작업을 즉시 분리하면 사용자는 빠른 응답을 먼저 받고,
실제 작업은 백그라운드에서 진행되므로 UI 지연·멈춤 없이 체감 성능이 크게 향상된다.

서비스 지연으로 인한 사용자 이탈률을 낮추는 데 효과적이다.


1-3. Java 비동기 처리와 Spring 비동기 처리의 차이점

구분Java 비동기 처리Spring 비동기 처리
구현 방식Thread / Executor / CompletableFuture 직접 사용@Async 기반 선언적 비동기 처리
설정 방식스레드풀·큐 크기 수동 설정ThreadPoolTaskExecutor를 Bean으로 설정
결과 처리Future/CompletableFuture 직접 제어반환 타입에 따른 공통 패턴 제공
예외 처리try-catch, callback 직접 구성Spring 예외 처리 체계와 자연스럽게 연계
스프링 연동성컨텍스트 전파 로직 직접 구현 필요AOP 기반으로 동작하나, 트랜잭션 분리 및 SecurityContext 미전파 등 주의 필요
코드 복잡도인프라 코드 혼재비즈니스 로직 중심, 구조 단순

2. Spring에서의 비동기 처리 방식 소개

2-1. Spring 비동기 처리 아키텍처 개요

Spring 비동기 처리는 다음과 같은 구조로 구성된다:

  • 웹 요청 스레드와 비동기 작업 스레드 분리
  • @Async 사용 시 Spring AOP 프록시가 메서드를 Executor에게 위임
  • 작업은 ThreadPoolTaskExecutor 내 전용 스레드풀에서 처리
  • 웹 요청은 즉시 반환 → 결과는 백그라운드에서 실행

2-2. @EnableAsync 활성화

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(5);
        exec.setMaxPoolSize(20);
        exec.setQueueCapacity(100);
        exec.setThreadNamePrefix("async-");
        exec.initialize();
        return exec;
    }
}
@Service
public class MailService {

    @Async
    public void sendMail(String to) {
        System.out.println("Sending mail to " + to);
    }
}

2-3. 비동기 실행을 위한 스레드 관리 방식

Spring은 ThreadPoolTaskExecutor를 이용하여 비동기 작업 실행용 전용 스레드 풀을 구성한다.

주요 구성 요소:

  • CorePoolSize
  • MaxPoolSize
  • QueueCapacity
  • ThreadNamePrefix

2-4. 비동기 처리 방식 설계 예시

비동기로 분리하기 좋은 작업:

  • 이메일 발송
  • 로그 기록
  • 알림 전송
  • 외부 API 호출
  • DB 읽기 기반 후처리

동기로 유지해야 하는 작업:

  • 요청 응답에 반드시 포함되어야 하는 핵심 로직

2-5. 동기 방식 vs 비동기 방식 비교

동기 방식

  • Task 1 → Task 2 완료 대기 → 응답 반환

비동기 방식(@Async)

  • Task 1 → Task 2 실행 요청만 하고 즉시 반환
  • 별도 스레드에서 병렬 처리됨

3. @Async 어노테이션 활용

3-1. @Async 동작 원리

Spring AOP 프록시가 메서드를 가로채서:

  1. 메서드를 즉시 반환
  2. 실제 로직은 Executor 스레드풀에서 백그라운드 실행

3-2. 사용 방식

(1) 메서드 레벨 적용

@Service
public class MailService {

    @Async
    public void sendMail(String to) {
        System.out.println("Sending mail to " + to);
    }
}

(2) 클래스 레벨 적용

@Service
@Async
public class NotificationService {

    public void sendEmail(String email) { }
    public void sendSms(String phoneNumber) { }
}

4. @Async 지원 반환 타입

4-1. void

  • 결과 확인 불가
  • 로그, 알림 등 단방향 작업에 적합

4-2. Future / ListenableFuture

@Async
public Future<String> loadData() {
    return new AsyncResult<>("done");
}

4-3. CompletableFuture

@Async
public CompletableFuture<String> fetchData() {
    return CompletableFuture.completedFuture("ok");
}

5. @Async 활용 시 제약사항

제약설명대응
프록시 기반프록시를 통해 호출돼야 동작반드시 외부 빈을 통해 호출
Self-invocation같은 클래스 내부 호출 시 비동기 적용 안됨구조 분리
접근 제한자public만 프록시 적용 가능@Async 메서드는 public
트랜잭션 전파별도 스레드라 트랜잭션 전파 안됨필요 시 @Transactional 별도 선언

5-1. Self-invocation 문제

@Service
public class ReportService {

    public void generateReport() {
        generateReportAsync(); // 비동기 아님
    }

    @Async
    public void generateReportAsync() { }
}

Spring AOP는 프록시 기반으로 동작하므로, 외부에서 빈을 통해 호출해야 프록시가 개입한다. 같은 클래스 내부에서 this.method()로 호출하면 프록시를 거치지 않고 실제 객체의 메서드가 직접 호출되어 @Async가 적용되지 않는다.


5-2. private 메서드 불가

@Service
public class PushAsyncService {

    @Async  // 비동기 적용되지 않음
    private void sendPushAsync(String message) { }
}

5-3. 트랜잭션 전파 문제

@Service
public class OrderService {

    @Transactional
    public void placeOrder() {
        saveOrder();
        sendOrderNotificationAsync();
    }

    @Async
    public void sendOrderNotificationAsync() { }
}

TaskExecutor와 ThreadPoolTaskExecutor 활용

1. TaskExecutor의 이해

1-1. TaskExecutor 인터페이스의 역할

TaskExecutor는 스레드 실행을 추상화하여 비동기 작업을 처리하기 위한 표준 실행 계약을 제공한다.

  • 스레드 생성 방식에 종속되지 않음
  • 일관된 실행 모델 제공
  • ThreadPoolTaskExecutor 등 다양한 구현체 사용 가능
  • 백그라운드 작업 처리에 적합

1-2. Java ExecutorService와의 관계

  • Spring의 TaskExecutor는 ExecutorService를 감싸는 Spring 추상화 개념
  • 내부적으로 ExecutorService를 사용하지만
    스프링 빈 관리 & @Async 처리에 최적화
  • 즉, Spring식으로 포장한 ExecutorService 라고 이해하면 됨

1-3. TaskExecutor 구현체 종류

구현체특징적합한 용도
SimpleAsyncTaskExecutor매 요청마다 스레드 생성, 스레드풀 없음가벼운 테스트, 간단한 작업
ThreadPoolTaskExecutor가장 널리 사용, 풀 기반, 성능·안정성 우수웹 서비스, 대량 요청 처리
WorkManagerTaskExecutorJCA WorkManager 기반, WebLogic 등 WAS용JEE WAS 환경
ConcurrentTaskExecutor기존 ExecutorService 래핑직접 만든 풀을 Spring에서 재사용

2. ThreadPoolTaskExecutor 상세 구성

2-1. 설정 예시

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();

        // I/O 바운드 기준 예시
        exec.setCorePoolSize(8);      
        exec.setMaxPoolSize(32);      
        exec.setQueueCapacity(200);   
        exec.setKeepAliveSeconds(60); 
        exec.setThreadNamePrefix("async-worker-");

        exec.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        exec.initialize();
        return exec;
    }
}

2-2. 적정 스레드풀 크기 산정 방법

(1) CPU-bound 작업

  • 예: 암호화, 압축, 빅 연산
  • 권장: 코어 수 또는 코어 + 1
  • 이유: 스레드를 많이 늘려도 의미 없음(문맥 전환 비용 증가)

(2) I/O-bound 작업

  • 예: DB 쿼리, API 호출, 파일 I/O
  • 공식: 코어 × (1 + W/C)
    (W=대기시간, C=계산시간)
  • 일반적으로 코어 × 2~4 권장

(3) 실무 튜닝 절차

  • 예상 동시 요청 파악
  • 초기값 설정 후 모니터링
  • CPU 사용률·큐 길이·응답 시간 기반 조정
  • 코어 수 × 노드 수로 전체 스레드풀 고려

2-3. ThreadPoolTaskExecutor vs Tomcat Thread 설정 비교

Tomcat 설정 예:

server:
  tomcat:
    threads:
      max: 200
      min-spare: 20
    max-connections: 10000
    connection-timeout: 20000
    keep-alive-timeout: 20000
    max-keep-alive-requests: 100
    accept-count: 100
  • Tomcat 스레드: 웹 요청 처리
  • ThreadPoolTaskExecutor: @Async 비동기 작업 처리

→ 둘은 완전히 독립적으로 동작


3. TaskExecutor 빈 등록 및 활용

3-1. 커스텀 Executor 생성

@EnableAsync
public class AsyncConfig {

    @Bean(name = "customExecutor")
    public TaskExecutor customExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(4);
        exec.setMaxPoolSize(8);
        exec.setQueueCapacity(100);
        exec.setThreadNamePrefix("custom-");
        exec.initialize();
        return exec;
    }
}

3-2. 특정 Executor 지정

@Service
public class MailService {

    @Async("customExecutor")
    public void sendMail() {
        System.out.println("메일 전송: " + Thread.currentThread().getName());
    }
}

3-3. 여러 Executor 운영

@Configuration
@EnableAsync
public class AsyncMultiConfig {

    @Bean("fastExecutor")
    public TaskExecutor fastExecutor() {
        return new ThreadPoolTaskExecutor() {{
            setCorePoolSize(2);
            setMaxPoolSize(4);
            setThreadNamePrefix("fast-");
            initialize();
        }};
    }

    @Bean("ioExecutor")
    public TaskExecutor ioExecutor() {
        return new ThreadPoolTaskExecutor() {{
            setCorePoolSize(8);
            setMaxPoolSize(16);
            setThreadNamePrefix("io-");
            initialize();
        }};
    }
}

4. 모니터링 및 관리

4-1. Spring Actuator 활용

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus'
}
  • ThreadPoolTaskExecutor는 Micrometer에 의해 자동 메트릭 수집
  • /actuator/metrics에서 확인 가능

4-2. 스레드 풀 메트릭 수동 등록

@Configuration
@EnableAsync
@RequiredArgsConstructor
public class AsyncConfig {

    private final MeterRegistry meterRegistry;

    @Bean("monitoredExecutor")
    public ThreadPoolTaskExecutor monitoredExecutor() {

        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.initialize();

        ExecutorServiceMetrics.monitor(
            meterRegistry,
            executor.getThreadPoolExecutor(),
            "async-executor",
            Collections.emptyList()
        );

        return executor;
    }
}

4-3. 스레드 풀 포화 감지

@Bean("ioExecutor")
public ThreadPoolTaskExecutor ioExecutor() {

    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("io-");

    executor.setRejectedExecutionHandler((r, exec) -> {
        int poolSize = exec.getPoolSize();
        int active = exec.getActiveCount();
        int queueSize = exec.getQueue().size();

        log.warn("스레드풀 포화 발생 poolSize={}, active={}, queueSize={}", 
                 poolSize, active, queueSize);

        throw new RejectedExecutionException("스레드풀 포화 상태");
    });

    executor.initialize();
    return executor;
}

4-4. 커스텀 모니터링 API 구축

@RestController
@RequiredArgsConstructor
public class ExecutorMonitorController {

    private final ThreadPoolTaskExecutor monitoredExecutor;

    @GetMapping("/monitor/executor")
    public Map<String, Object> status() {
        ThreadPoolExecutor tp = monitoredExecutor.getThreadPoolExecutor();

        return Map.of(
            "poolSize", tp.getPoolSize(),
            "activeCount", tp.getActiveCount(),
            "queueSize", tp.getQueue().size(),
            "corePoolSize", tp.getCorePoolSize(),
            "maxPoolSize", tp.getMaximumPoolSize()
        );
    }
}

Spring Event 기반 비동기 처리

1. Spring Event 시스템 개요

1-1. 개념

Spring Event 시스템은 다음과 같은 구조를 제공한다:

  • 애플리케이션 내부 사건을 이벤트 객체로 발행
  • 리스너가 이를 동기/비동기 방식으로 처리
  • 발행자-소비자 간 느슨한 결합

활용 분야:

  • 도메인 이벤트
  • 알림
  • 로깅
  • 후처리 작업

1-2. ApplicationEvent & ApplicationListener

  • ApplicationEvent: 발생한 사건을 표현하는 객체
  • ApplicationListener: 해당 이벤트를 구독하는 컴포넌트

1-3. EventPublisher의 역할

EventPublisher는 발행된 이벤트를 ApplicationEventMulticaster로 전달하여,
리스너들에게 알맞게 분배하는 출발점 역할을 한다.


1-4. 이벤트 기반 아키텍처 장점

장점설명
느슨한 결합발행자·소비자 간 의존도 감소
확장성소비자 개별 확장 가능
비동기 처리메인 로직 처리 속도 향상
유연성리스너 추가만으로 기능 확장
관심사 분리핵심 로직과 후처리 분리

2. 이벤트 처리 방식

2-1. 이벤트 클래스 정의

public class UserCreatedEvent {
    private final Long userId;
    public UserCreatedEvent(Long userId) {
        this.userId = userId;
    }
    public Long getUserId() {
        return userId;
    }
}

2-2. 이벤트 퍼블리셔

@Service
@RequiredArgsConstructor
public class UserService {

    private final ApplicationEventPublisher publisher;

    public void createUser(Long userId) {
        publisher.publishEvent(new UserCreatedEvent(userId));
    }
}

2-3. 이벤트 리스너

@Component
public class UserCreatedListener 
        implements ApplicationListener<UserCreatedEvent> {

    @Override
    public void onApplicationEvent(UserCreatedEvent event) {
        System.out.println("신규 사용자 생성 이벤트: " + event.getUserId());
    }
}

3. @EventListener 활용

3-1. 메서드 기반 이벤트 리스너

@Component
public class UserEventHandler {

    @EventListener
    public void handle(UserCreatedEvent event) {
        System.out.println("사용자 생성됨: " + event.getUserId());
    }
}

3-2. 조건부 처리

@EventListener(condition = "#event.userId > 100")
public void handleOnlyBigId(UserCreatedEvent event) {
    System.out.println("조건 충족: " + event.getUserId());
}

3-3. SpEL 필터링

@EventListener(condition = "#event.username == 'admin'")
public void handleAdmin(UserLoginEvent event) {
    System.out.println("관리자 로그인");
}

4. 비동기 이벤트 처리

@Async + @EventListener 조합

@Component
public class UserCreatedEventListener {

    @Async("eventExecutor")
    @EventListener
    public void onUserCreated(UserCreatedEvent event) {
        System.out.println("비동기 스레드: " + Thread.currentThread().getName());
        System.out.println("신규 사용자 생성 처리");
    }
}
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("eventExecutor")
    public ThreadPoolTaskExecutor eventExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(4);
        exec.setMaxPoolSize(8);
        exec.setThreadNamePrefix("event-");
        exec.initialize();
        return exec;
    }
}

TransactionalEventListener 이해 및 활용

1. 개념

TransactionalEventListener는 트랜잭션의 단계에 따라 이벤트 실행 시점을 제어한다.

Phase설명활용
BEFORE_COMMIT커밋 직전 실행정합성 체크
AFTER_COMMIT커밋 성공 이후알림, 외부 API
AFTER_ROLLBACK롤백 시에만 실행보상 처리
AFTER_COMPLETION성공·실패 관계없이 종료 시정리 작업

2. 사용 예시

(1) 이벤트 클래스

public class OrderCreatedEvent {
    private final Long orderId;
    public OrderCreatedEvent(Long orderId) {
        this.orderId = orderId;
    }
    public Long getOrderId() {
        return orderId;
    }
}

(2) 서비스에서 이벤트 발행

@Service
@RequiredArgsConstructor
public class OrderService {

    private final ApplicationEventPublisher publisher;
    private final OrderRepository orderRepository;

    @Transactional
    public void createOrder() {
        Order order = new Order();
        orderRepository.save(order);

        publisher.publishEvent(new OrderCreatedEvent(order.getId()));
    }
}

(3) 트랜잭션 단계에 따라 처리

@Component
public class OrderEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void beforeCommit(OrderCreatedEvent event) {
        System.out.println("BEFORE_COMMIT: 검증");
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void afterCommit(OrderCreatedEvent event) {
        System.out.println("AFTER_COMMIT: 알림 발송");
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void afterRollback(OrderCreatedEvent event) {
        System.out.println("AFTER_ROLLBACK: 보상 처리");
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void afterCompletion(OrderCreatedEvent event) {
        System.out.println("AFTER_COMPLETION: 정리 작업");
    }
}

3. 트랜잭션과 비동기 관계

비동기(@Async)는 스레드가 다르기 때문에 트랜잭션이 전파되지 않는다.

// ❌ 잘못된 사용 - BEFORE_COMMIT과 @Async
@Async
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(OrderCreatedEvent event) {
    // 커밋 전에 비동기로 실행되면 트랜잭션 정합성 보장 불가
}

// ✅ 유효한 사용 - AFTER_COMMIT과 @Async
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
    // 커밋 확정 후 비동기 실행 - 알림, 외부 연동 등에 적합
}

→ 트랜잭션 종속 로직에는 절대 @Async를 사용하면 안 됨


4. 이벤트 기반 비동기 활용 사례

분야설명
사용자 활동 로깅비동기로 기록하여 성능 저하 방지
알림 발송SMS/Email 푸시 등
캐시 갱신데이터 변경 시 자동 무효화
외부 시스템 연동ERP·CRM 연결 작업 분리

5. 분산 환경 확장

5-1. 내부 이벤트 한계

  • 이벤트는 동일 프로세스 내에서만 동작
  • 다중 인스턴스 환경에서는 이벤트가 다른 노드로 전달되지 않음

→ Kafka / RabbitMQ 같은 메시지 브로커 필요


5-2. 도메인 이벤트 vs 통합 이벤트

항목도메인 이벤트통합 이벤트
목적내부 로직 간 결합도 감소시스템 간 통신
범위단일 애플리케이션여러 서비스/시스템
전달Spring EventKafka / RabbitMQ
트랜잭션트랜잭션과 밀접독립적 비동기 처리
OrderPaidEventOrderCreatedIntegrationEvent

5-3. 이벤트 기반 아키텍처

  • 발행자 & 소비자 분리
  • 대규모 트래픽에 강함
  • 마이크로서비스 구조에 적합

비동기 예외 처리와 AsyncUncaughtExceptionHandler

1. 비동기 처리에서 예외 처리의 도전 과제

1-1. 메인 스레드와 분리된 예외 전파

@Async 메서드는 별도의 스레드에서 실행되므로, 예외가 호출자에게 전파되지 않는다.
따라서 호출부에서는 예외를 전혀 감지할 수 없다.

@Async
public void sendEmail() {
    throw new RuntimeException("이메일 전송 실패.");
}

// 호출부
emailService.sendEmail(); // ❌ 예외 감지 불가

1-2. try-catch가 동작하지 않는 한계

비동기 메서드를 호출하는 try-catch는 실행 스레드가 다르기 때문에 예외를 잡지 못한다.

@Async
public void sendEmail() {
    throw new RuntimeException("이메일 전송 실패.");
}

try {
    emailService.sendEmail(); // @Async
} catch (Exception e) {
    System.out.println("이 블록은 실행되지 않음");
}

1-3. void 비동기 작업의 결과 확인 불가

void 반환 메서드는 결과와 실패 여부를 확인할 방법이 없다.

@Async
public void processTask() {
    throw new IllegalStateException("처리 실패");
}

processTask(); // 호출부는 실패 여부 확인 불가
System.out.println("호출부는 에러를 감지할 수 없다");

2. @Async 반환 타입별 예외 처리 방식

2-1. void 반환 메서드: AsyncUncaughtExceptionHandler 필요

void 메서드는 예외가 호출부에 전달되지 않기 때문에,
AsyncUncaughtExceptionHandler를 반드시 사용해야 한다.

@Async
public void sendEmail() {
    throw new RuntimeException("이메일 전송 실패");
}

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            System.out.println("비동기 예외: " + ex.getMessage());
    }
}

2-2. Future 기반 예외 처리 (get() 호출 시 감지 가능)

@Async
public Future<String> loadUser() {
    throw new IllegalStateException("사용자 조회 실패");
}

Future<String> result = loadUser();

try {
    result.get();  // ExecutionException으로 감싸져 던져짐
} catch (Exception e) {
    System.out.println("Future 예외: " + e.getCause().getMessage());
}

2-3. CompletableFuture 기반 예외 처리 (가장 유연)

@Async
public CompletableFuture<String> fetchData() {
    throw new RuntimeException("데이터 조회 실패");
}

fetchData()
    .exceptionally(ex -> {
        System.out.println("CompletableFuture 예외: " + ex.getMessage());
        return "fallback";
    })
    .thenAccept(result -> System.out.println("결과: " + result));

3. AsyncUncaughtExceptionHandler 활용

3-1. 구현 방식

@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        log.error("비동기 예외 발생");
        log.error("예외: {}", ex.getMessage());
        log.error("메서드: {}", method.getName());
        log.error("파라미터: {}", Arrays.toString(params));
    }
}

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
}

3-2. 예외 메타데이터 활용 (Method, params 값)

@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
    String methodName = method.getName();
    String paramValues = Arrays.toString(params);
    String errorMessage = ex.getMessage();

    log.error("[Async Error] method={}, params={}, message={}",
            methodName, paramValues, errorMessage);
}

3-3. 로깅 & 알림 시스템 연동

실제 운영 환경에서는 Slack, Sentry, CloudWatch 등과 연계하여
비동기 예외를 실시간 알림으로 받아야 한다.

@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {

    log.error("비동기 예외 감지: {}", ex.getMessage());

    // Sentry 연동 예시
    Sentry.captureException(ex);

    // Slack 전송 예시
    slackNotifier.send(
        "[Async Error] 메서드: " + method.getName() +
        ", 파라미터: " + Arrays.toString(params) +
        ", 메시지: " + ex.getMessage()
    );
}

4. 글로벌 비동기 예외 처리 설정

4-1. AsyncConfigurer를 통한 전역 설정

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(4);
        exec.setMaxPoolSize(20);
        exec.setQueueCapacity(100);
        exec.setThreadNamePrefix("async-");
        exec.initialize();
        return exec;
    }
}

4-2. 전역 예외 핸들러 구현

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    return new AsyncUncaughtExceptionHandler() {
        @Override
        public void handleUncaughtException(Throwable ex, Method method, Object... params) {
            System.out.println("비동기 예외: " + ex.getMessage());
            System.out.println("메서드: " + method.getName());
            System.out.println("파라미터: " + Arrays.toString(params));
        }
    };
}

4-3. 예외 타입별 차등 처리

@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {

    if (ex instanceof IllegalArgumentException) {
        log.warn("[비동기 경고] 잘못된 파라미터: {}", ex.getMessage());
        return;
    }

    if (ex instanceof IllegalStateException) {
        log.error("[비동기 비즈니스 오류] {}", ex.getMessage());
        slackNotifier.send("비즈니스 오류: " + ex.getMessage());
        return;
    }

    // 기타 시스템 오류
    log.error("[비동기 시스템 오류] {}", ex.getMessage(), ex);
    sentry.captureException(ex);
}

5. 비동기 예외 처리 모범 사례

5-1. 재시도(Retry) 전략 (@Retryable)

@Service
public class RetryService {

    @Retryable(
        value = { IOException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public String callExternalApi() throws IOException {
        System.out.println("외부 API 호출");
        throw new IOException("일시적 장애");
    }

    @Recover
    public String recover(IOException e) {
        return "기본 응답 반환";
    }
}

5-2. 폴백(Fallback) 전략

public String getUserProfileFallback() {
    return "{ \"name\": \"Guest\", \"role\": \"Unknown\" }";
}

public String getUserProfile() {
    try {
        return externalApi.getUserProfile();
    } catch (Exception e) {
        return getUserProfileFallback(); // 장애 시 폴백
    }
}

5-3. 회로 차단기(Circuit Breaker) — Resilience4j

CircuitBreaker circuitBreaker =
    CircuitBreaker.ofDefaults("externalApi");

Supplier<String> decoratedSupplier =
    CircuitBreaker.decorateSupplier(circuitBreaker,
        () -> externalApi.getData()
    );

try {
    String result = Try.ofSupplier(decoratedSupplier)
                       .recover(ex -> "fallback-data")
                       .get();
    System.out.println(result);

} catch (Exception e) {
    System.out.println("회로 차단기로 인해 호출 차단");
}

TaskDecorator로 MDC, 트레이싱, 세션 정보를 비동기 스레드로 전달하기

1. TaskDecorator란?

TaskDecorator는 Spring의 비동기 작업(@Async)이 새로운 스레드에서 실행될 때,
원래 웹 요청 스레드가 가지고 있던 다음과 같은 맥락(context)을 그대로 전달하도록 도와주는 기능이다.

  • MDC(Logback/SLF4J의 진단 컨텍스트)
  • 트레이싱 정보(Trace ID, Span ID)
  • SecurityContext (인증 정보)
  • ThreadLocal 기반 사용자 정보
  • 요청 ID(Request-Id)

비동기는 스레드가 다르기 때문에 기존 ThreadLocal이 초기화됨
TaskDecorator가 이를 복사해주는 역할을 한다.


2. TaskDecorator가 필요한 이유

2-1. @Async는 스레드를 바꾸므로 ThreadLocal이 사라진다

예시: MDC에 넣은 requestId가 비동기 로그에서는 사라짐

@Slf4j
@RestController
public class DemoController {

    @GetMapping("/test")
    public String test() {
        MDC.put("requestId", UUID.randomUUID().toString());
        asyncService.runAsync(); // 비동기 실행
        return "ok";
    }
}

비동기 로그에서는 requestId가 찍히지 않음:

[async-thread-1] ---- log ---- requestId=null

2-2. SecurityContextHolder 인증 정보도 사라짐

Spring Security 인증 정보도 기본적으로 ThreadLocal 기반이므로 비동기 스레드에서는 SecurityContext가 비어 있다.
→ 인증된 사용자 기반 처리(알림, 로그 기록 등) 불가능

3. TaskDecorator 구현하기

3-1. MDC 복제용 TaskDecorator

public class MdcTaskDecorator implements TaskDecorator {

@Override
public Runnable decorate(Runnable runnable) {
    Map<String, String> contextMap = MDC.getCopyOfContextMap();

    return () -> {
        try {
            if (contextMap != null) {
                MDC.setContextMap(contextMap); // 기존 스레드 MDC 복사
            }
            runnable.run();
        } finally {
            MDC.clear(); // 메모리 누수 방지
        }
    };
}

}


---

### 3-2. ThreadPoolTaskExecutor에 TaskDecorator 적용

```java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {

        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(8);
        exec.setMaxPoolSize(32);
        exec.setQueueCapacity(200);
        exec.setThreadNamePrefix("async-");

        exec.setTaskDecorator(new MdcTaskDecorator()); // 핵심

        exec.initialize();
        return exec;
    }
}

4. SecurityContext까지 전달하는 TaskDecorator

Spring Security 인증 정보(SecurityContextHolder)를 전달하도록 확장할 수 있다.

public class SecurityContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {

        SecurityContext context = SecurityContextHolder.getContext();

        return () -> {
            try {
                SecurityContextHolder.setContext(context); // 인증 정보 복사
                runnable.run();
            } finally {
                SecurityContextHolder.clearContext();
            }
        };
    }
}

5. MDC + SecurityContext + 사용자 정의 ThreadLocal 통합 버전

public class ContextCopyingDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {

        Map<String, String> mdc = MDC.getCopyOfContextMap();
        SecurityContext securityContext = SecurityContextHolder.getContext();
        String userId = UserContext.getUserId(); // 예: ThreadLocal 기반 사용자 ID

        return () -> {
            try {
                if (mdc != null) MDC.setContextMap(mdc);
                SecurityContextHolder.setContext(securityContext);
                UserContext.setUserId(userId);

                task.run();

            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();
                UserContext.clear();
            }
        };
    }
}

6. TaskDecorator 사용 전/후 로그 비교

6-1. TaskDecorator 미사용

[http-nio-1] requestId=ab12...
[async-1] requestId=null

6-2. TaskDecorator 적용 후

[http-nio-1] requestId=ab12...
[async-1] requestId=ab12...  ← 전달됨

요청 단위 추적성이 좋아지고, 비동기 로그가 전체 흐름과 연결됨


7. 실제 활용 사례

7-1. 요청 ID(Request-Id) 로그 추적

  • WebFilter에서 requestId 생성 → MDC에 저장
  • TaskDecorator로 비동기까지 requestId 전달
  • ELK/CloudWatch/Dynatrace에서 트랜잭션 로그가 하나로 묶임

7-2. SecurityContext 기반 비동기 알림 처리

  • 결제 완료 이벤트 @Async 처리
  • TaskDecorator가 SecurityContext를 전달
  • “결제한 실제 사용자” 정보 기반으로 알림·포인트 적립 가능

7-3. 트레이싱(Trace/Span) 정보 전달

Sleuth, OpenTelemetry 등과 함께 활용하면
비동기 호출에서도 Trace ID가 끊기지 않는다.


8. TaskDecorator 모범 사용 패턴

✔ 모든 ThreadLocal 기반 정보는 명확히 정리
✔ MDC, SecurityContext, 커스텀 Context를 반드시 clear() 호출
✔ 하나의 데코레이터에 여러 맥락을 통합하는 것이 효율적
✔ WebFlux 환경에서도 동일 패턴 사용 가능
✔ 보안 정보(SecurityContext)는 외부 노출 위험이 있으므로 필터링 필요


TaskDecorator로 MDC, 트레이싱, 세션 정보를 비동기 스레드로 전달하기

1. TaskDecorator란?

TaskDecorator는 Spring의 비동기 작업(@Async)이 새로운 스레드에서 실행될 때,
원래 웹 요청 스레드가 가지고 있던 다음 정보를 그대로 전달하도록 도와주는 기능이다.

  • MDC(Logback/SLF4J의 진단 컨텍스트)
  • 트레이싱 정보(Trace ID, Span ID)
  • SecurityContext (인증 정보)
  • ThreadLocal 기반 사용자 정보
  • 요청 ID(Request-Id)

비동기는 스레드가 다르기 때문에 기존 ThreadLocal이 초기화되므로
TaskDecorator가 이를 복사해주어야 한다.


2. TaskDecorator가 필요한 이유

2-1. @Async는 스레드를 바꾸므로 ThreadLocal이 사라진다

예시: MDC에 넣은 requestId가 비동기 로그에서는 사라지는 문제

@Slf4j
@RestController
public class DemoController {

    @GetMapping("/test")
    public String test() {
        MDC.put("requestId", UUID.randomUUID().toString());
        asyncService.runAsync(); // 비동기 실행
        return "ok";
    }
}

비동기 로그 예:

[async-thread-1] ---- log ---- requestId=null

2-2. SecurityContextHolder의 인증 정보도 사라짐

Spring Security는 ThreadLocal 기반 → 비동기 스레드에서는 SecurityContext가 비어 있음
→ 인증 사용자 기반 작업 불가


3. TaskDecorator 구현하기

3-1. MDC 복제용 TaskDecorator

public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();

        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap); // 기존 스레드 MDC 복사
                }
                runnable.run();
            } finally {
                MDC.clear(); // 누수 방지
            }
        };
    }
}

3-2. ThreadPoolTaskExecutor에 TaskDecorator 적용

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {

        ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
        exec.setCorePoolSize(8);
        exec.setMaxPoolSize(32);
        exec.setQueueCapacity(200);
        exec.setThreadNamePrefix("async-");

        exec.setTaskDecorator(new MdcTaskDecorator()); // 핵심

        exec.initialize();
        return exec;
    }
}

4. SecurityContext까지 전달하는 TaskDecorator

public class SecurityContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {

        SecurityContext context = SecurityContextHolder.getContext();

        return () -> {
            try {
                SecurityContextHolder.setContext(context); // 인증 정보 복사
                runnable.run();
            } finally {
                SecurityContextHolder.clearContext();
            }
        };
    }
}

5. MDC + SecurityContext + 커스텀 ThreadLocal 통합 버전

public class ContextCopyingDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {

        Map<String, String> mdc = MDC.getCopyOfContextMap();
        SecurityContext securityContext = SecurityContextHolder.getContext();
        String userId = UserContext.getUserId(); // ThreadLocal 사용자 ID

        return () -> {
            try {
                if (mdc != null) MDC.setContextMap(mdc);
                SecurityContextHolder.setContext(securityContext);
                UserContext.setUserId(userId);

                task.run();

            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();
                UserContext.clear();
            }
        };
    }
}

6. TaskDecorator 적용 전·후 로그 비교

적용 전

[http-nio-1] requestId=ab12...
[async-1] requestId=null

적용 후

[http-nio-1] requestId=ab12...
[async-1] requestId=ab12...

→ 비동기 로그에서도 requestId가 유지되어 추적성이 높아짐


7. 실제 활용 사례

7-1. 요청 ID(Request-Id) 추적 개선

  • WebFilter에서 requestId 생성
  • MDC 저장
  • TaskDecorator로 비동기 전파
  • 로그 관리 도구(ELK, CloudWatch 등)에서 전체 요청 흐름 추적 가능

7-2. 인증 정보 기반 비동기 작업

  • 결제 완료 시 @Async 알림 전송
  • SecurityContext 전달로 “결제한 사용자 ID” 기반 처리 가능

7-3. 트레이싱 연계

  • Sleuth, OpenTelemetry 등과 통합 시
  • 비동기에서도 Trace/Span ID 유지되어 전체 호출 체인이 보존됨

8. 모범 사용 패턴

✔ ThreadLocal 기반 정보는 항상 명확히 정리
✔ MDC, SecurityContext, CustomContext는 반드시 clear() 호출
✔ 하나의 데코레이터에 여러 컨텍스트를 통합 가능
✔ WebFlux에서도 같은 패턴 적용
✔ 보안 민감 정보는 외부로 노출되지 않도록 주의

profile
Backend engineer

0개의 댓글