스프링 비동기 (7/7)

이전 편: [Spring] 6. WebClient를 활용한 비동기 HTTP 통신

이 문서가 시리즈의 마지막 편입니다.

# [스프링] 실전 패턴과 면접 대비

시리즈 마지막 편이다. 지금까지 배운 내용을 실전에 적용하는 판단 기준과, 운영에서 마주치는 트러블슈팅 패턴, 그리고 면접에서 나올 수 있는 질문들을 정리한다.

비동기 적용 판단 기준

비동기가 만능은 아니다. 잘못 쓰면 오히려 코드 복잡도만 올라가고 디버깅은 어려워진다. "이 작업을 비동기로 해야 하나?"를 판단하는 기준이 필요하다.

기준비동기 적합비동기 부적합
응답 필수 여부결과가 응답에 불필요결과를 즉시 반환해야 함
실패 허용일시적 실패 허용, 재시도 가능실패 시 즉시 롤백 필요
작업 시간I/O 대기가 긴 작업CPU 위주의 짧은 계산
트랜잭션독립적 트랜잭션 OK호출자와 같은 트랜잭션 필요
순서 보장순서 무관순서가 중요한 작업

적합한 예시

  • 이메일/SMS/푸시 알림 발송
  • 로그 수집, 감사 기록
  • 외부 API 연동 (결제 알림, 웹훅 등)
  • 대용량 데이터 가공, 보고서 생성
  • 캐시 갱신, 검색 인덱스 업데이트

부적합한 예시

  • 결제 처리 (결과를 즉시 사용자에게 보여줘야 함)
  • 재고 차감 후 주문 확정 (원자성 필요)
  • 순차적 비즈니스 검증 단계
  • 간단한 DB CRUD (비동기 오버헤드가 더 큼)

비동기 테스트 작성법

비동기 코드를 테스트할 때 가장 흔한 실수는 결과가 나오기 전에 검증하는 것이다.

void 메서드 테스트

@Async void 메서드는 완료 시점을 알 수 없으므로, 테스트에서는 비동기를 끄거나 대기 로직이 필요하다.

방법 1 — 테스트에서 동기 Executor 사용

@TestConfiguration
public class SyncAsyncConfig {

    @Bean
    @Primary
    public TaskExecutor taskExecutor() {
        return new SyncTaskExecutor();
    }
}
  • SyncTaskExecutor는 작업을 호출 스레드에서 동기로 실행한다
  • 비동기 로직의 비즈니스 로직만 검증할 때 유용하다

방법 2 — Awaitility 사용

비동기 동작 자체를 검증하고 싶다면 Awaitility 라이브러리를 쓴다.

@Test
void 비동기_이메일_발송_테스트() {
    orderService.completeOrder(orderId);

    await().atMost(5, SECONDS).untilAsserted(() -> {
        verify(emailSender).send(eq("user@test.com"), anyString());
    });
}
  • await().atMost(5, SECONDS) : 최대 5초까지 기다린다
  • untilAsserted : 조건이 만족될 때까지 반복 확인한다

CompletableFuture 메서드 테스트

CompletableFuture를 반환하면 join()으로 결과를 기다릴 수 있어서 테스트가 더 간단하다.

@Test
void 프로필_조회_테스트() {
    CompletableFuture<UserProfile> future = userService.fetchProfile("user123");

    UserProfile result = future.join();

    assertThat(result.getName()).isEqualTo("홍길동");
}
  • join()이 결과가 나올 때까지 블로킹하므로 별도의 대기 로직이 필요 없다
테스트에서의 타임아웃

CompletableFuture.get(3, TimeUnit.SECONDS)처럼 타임아웃을 설정하면, 비동기 작업이 무한 대기하는 상황을 방지할 수 있다. CI 환경에서 테스트가 멈추는 것을 막아준다.

트러블슈팅

스레드 풀 고갈

비동기 작업이 쌓이는 속도가 처리 속도보다 빠르면 스레드 풀이 고갈된다.

  • 증상 : 비동기 작업이 점점 느려지다가 RejectedExecutionException 발생
  • 원인 : 스레드 풀 사이즈가 부족하거나, 비동기 작업 자체가 너무 느림
  • 해결 :
    • 스레드 풀 모니터링으로 queue 사이즈를 확인
    • 작업 내부의 I/O 병목 제거 (타임아웃 설정, 연결 풀 확인)
    • corePoolSize, maxPoolSize 조정
    • 용도별 Executor 분리로 영향 범위 격리

비동기 메서드가 동기로 실행됨

코드상 @Async를 붙였는데 같은 스레드에서 실행되는 경우.

  • 확인 방법 : 로그에서 스레드 이름을 확인한다. async-1 같은 이름이 아니라 http-nio-8080-exec-1이면 동기 실행이다.
  • 체크리스트 :
    • @EnableAsync 선언 여부
    • 같은 클래스 내부에서 호출하고 있지 않은지
    • 메서드가 public인지
    • 빈으로 등록된 객체를 통해 호출하고 있는지 (new로 생성한 객체는 프록시가 아니다)
// 로그 패턴에 스레드 이름을 포함시키면 디버깅이 쉬워진다
logging.pattern.console=%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n

비동기 작업 간 데드락

비동기 작업 A가 비동기 작업 B의 완료를 기다리는데, 스레드 풀이 꽉 차서 B가 실행되지 못하는 상황이다.

@Async
public CompletableFuture<String> taskA() {
    // taskB의 결과를 기다림
    CompletableFuture<String> result = taskB();
    return result.thenApply(b -> "A + " + b);
}

@Async
public CompletableFuture<String> taskB() {
    return CompletableFuture.completedFuture("B");
}
  • 스레드 풀에 스레드가 1개뿐이라면 : taskA가 스레드를 점유 → taskB는 스레드를 받지 못함 → 교착 상태
  • 해결 : 비동기 작업이 다른 비동기 작업을 기다리지 않도록 설계하거나, 별도 Executor를 사용한다

면접 Q&A

스프링 비동기 면접 질문

Q. @Async는 내부적으로 어떻게 동작하나요?

AOP 프록시 기반이다. 스프링이 @Async가 붙은 빈을 감싸는 프록시를 생성하고, 메서드 호출을 가로채서 TaskExecutor에 Runnable로 제출한다. 호출자에게는 즉시 반환(void) 또는 CompletableFuture를 돌려준다.

---

Q. @Async와 CompletableFuture.supplyAsync()의 차이는?

@Async는 스프링 AOP 프록시가 비동기 전환을 처리한다. 스프링 빈의 메서드에 선언적으로 사용하며, TaskExecutor를 통해 스레드를 관리한다. supplyAsync()는 Java 표준 API로, 스프링 의존 없이 사용할 수 있고 ForkJoinPool.commonPool()이 기본 Executor다. 스프링 환경에서는 @Async가 스레드 풀 관리, TaskDecorator 등과 자연스럽게 통합되므로 더 편리하다.

---

Q. 같은 클래스에서 @Async 메서드를 호출하면 어떻게 되나요?

동기로 실행된다. this.method()는 프록시가 아닌 실제 객체의 메서드를 호출하기 때문에 AOP가 개입하지 않는다. 비동기 메서드를 별도 빈으로 분리하면 해결된다.

---

Q. 비동기 메서드에서 트랜잭션은 어떻게 동작하나요?

비동기 메서드는 호출자의 트랜잭션에 참여하지 않는다. 트랜잭션 컨텍스트가 ThreadLocal에 바인딩되어 있어서, 다른 스레드에서는 새로운 트랜잭션이 시작된다. 호출자의 트랜잭션이 롤백되어도 비동기 메서드의 트랜잭션에는 영향이 없다.

---

Q. 스레드 풀 사이즈는 어떻게 결정하나요?

작업 성격에 따라 다르다. CPU-bound 작업은 코어 수와 비슷하게, I/O-bound 작업은 코어 × (1 + 대기시간/실행시간) 공식을 참고한다. 하지만 공식은 출발점일 뿐이고, 실제로는 부하 테스트로 최적값을 찾아야 한다. 스레드 풀 모니터링도 반드시 필요하다.

---

Q. TaskDecorator는 왜 필요한가요?

MDC, SecurityContext 같은 ThreadLocal 기반 컨텍스트는 비동기 스레드로 자동 전파되지 않는다. TaskDecorator는 작업이 스레드 풀에 제출되기 직전에 가로채서, 원래 스레드의 컨텍스트를 캡처하고 비동기 스레드에서 복원하는 역할을 한다.

---

Q. (함정) SimpleAsyncTaskExecutor는 왜 기본 Executor인데 문제가 되나요?

SimpleAsyncTaskExecutor는 스레드 풀이 아니다. 요청마다 새 스레드를 생성하고, 재사용하지 않는다. 트래픽이 몰리면 스레드가 무제한으로 생성되어 OutOfMemoryError가 발생할 수 있다. 운영 환경에서는 반드시 ThreadPoolTaskExecutor로 교체해야 한다.

---

Q. (함정) @Async void 메서드에서 예외가 발생하면?

호출자에게 전파되지 않는다. 기본적으로 로그만 남기고 사라진다. AsyncUncaughtExceptionHandler를 등록해서 예외를 감지해야 한다. 예외 처리가 중요하다면 CompletableFuture를 반환하는 방식이 더 안전하다.

---

Q. (함정) @TransactionalEventListener에 @Async를 붙이지 않으면 어떻게 되나요?

동기로 실행된다. 트랜잭션 커밋 후에 같은 스레드에서 리스너가 실행되므로, 리스너의 처리 시간이 호출자의 응답 시간에 포함된다. 또한 이 시점에서는 트랜잭션이 이미 커밋되었으므로, 리스너에서 예외가 발생해도 원래 트랜잭션을 롤백할 수 없다.

시리즈 정리

7편에 걸쳐 다룬 내용을 한눈에 정리한다.

mindmap root((Spring Async
마스터 가이드)) 핵심 원리 AOP 프록시 기반 위임 내부 호출 우회 주의 @EnableAsync 필수 스레드 관리 (ThreadPool) Core / Max / Queue 최적화 CPU-bound vs I/O-bound 설정 RejectPolicy 장애 대비 비동기 반환 타입 void (단방향 방치) Future (블로킹 단점) CompletableFuture (콜백/조합) 예외와 컨텍스트 AsyncUncaughtExceptionHandler ThreadLocal 유실 주의 TaskDecorator 로 컨텍스트 전파 트랜잭션 연동 패턴 일반 @EventListener 의 커밋 시점 불일치 @TransactionalEventListener 안전한 연계 결합도 낮추는 이벤트 주도 아키텍처