# [스프링] 실전 패턴과 면접 대비
시리즈 마지막 편이다. 지금까지 배운 내용을 실전에 적용하는 판단 기준과, 운영에서 마주치는 트러블슈팅 패턴, 그리고 면접에서 나올 수 있는 질문들을 정리한다.
비동기 적용 판단 기준
비동기가 만능은 아니다. 잘못 쓰면 오히려 코드 복잡도만 올라가고 디버깅은 어려워진다. "이 작업을 비동기로 해야 하나?"를 판단하는 기준이 필요하다.
| 기준 | 비동기 적합 | 비동기 부적합 |
|---|---|---|
| 응답 필수 여부 | 결과가 응답에 불필요 | 결과를 즉시 반환해야 함 |
| 실패 허용 | 일시적 실패 허용, 재시도 가능 | 실패 시 즉시 롤백 필요 |
| 작업 시간 | 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편에 걸쳐 다룬 내용을 한눈에 정리한다.
마스터 가이드)) 핵심 원리 AOP 프록시 기반 위임 내부 호출 우회 주의 @EnableAsync 필수 스레드 관리 (ThreadPool) Core / Max / Queue 최적화 CPU-bound vs I/O-bound 설정 RejectPolicy 장애 대비 비동기 반환 타입 void (단방향 방치) Future (블로킹 단점) CompletableFuture (콜백/조합) 예외와 컨텍스트 AsyncUncaughtExceptionHandler ThreadLocal 유실 주의 TaskDecorator 로 컨텍스트 전파 트랜잭션 연동 패턴 일반 @EventListener 의 커밋 시점 불일치 @TransactionalEventListener 안전한 연계 결합도 낮추는 이벤트 주도 아키텍처