본문 바로가기

Programming/Java & Spring 관련 내용 정리

[Spring] @Async는 왜 제대로 동작하지 못했는가악!

 

@Async를 붙였는데 비동기로 동작하지 않는 코드를 발견했다.

원인을 찾아보니 두 가지 문제가 있었다.

 

 


🔍 상황

 

자사 서비스에서 여러 제휴사로 API를 호출하는 구조다.

BenzService, BmwService, FerrariService 등 제휴사별 서비스가 있고,

각 서비스에서 callExternalApi()를 호출할 때 비동기로 실행하고자 했다.

 

1
제휴사별 서비스에서 callExternalApi() 호출 → DB에 관리번호 INSERT
2
관리번호와 함께 제휴사에 API 호출
3
제휴사가 처리 후 콜백으로 상태값 응답 → 관리번호 기준 UPDATE

 

문제의 코드

 

public class BenzService extends CarService { public void init(CarDto carDto) { // ... callExternalApi(carDto, apiKey); // ⚠️ 같은 클래스 내부 호출 } @Async // 비동기로 실행하고 싶었지만... public void callExternalApi(CarDto carDto, String apiKey) { // 제휴사 API 호출 + DB INSERT } }

 

@Async를 붙였지만, 10개 제휴사 호출이 순차적으로 실행되고 있었다.

같은 요청 흐름에서 순차 실행되니 처리 시간이 길어지고,

트랜잭션 범위가 넓게 잡혀 있으면 커넥션 점유 시간도 길어질 수 있었다.

 

 


💥 원인 1: Self-invocation — 프록시를 타지 않는다

 

@Async는 Spring AOP 기반으로, 프록시 객체를 통해 동작한다.

 

✅ 외부에서 호출
다른 빈이 호출
프록시가 가로챔
다른 스레드에서 실행
❌ 내부에서 호출 (self-invocation)
같은 클래스 내부 호출
프록시를 거치지 않음
같은 스레드에서 동기 실행

 

init() 안에서 callExternalApi()를 호출하면 같은 객체 내부 호출이라 프록시를 거치지 않는다.

@Async가 붙어있어도 실제로는 동기로 실행된다.

 

💡 이건 @Async만의 문제가 아니다.
@Transactional, @Cacheable프록시 기반 AOP 어노테이션은 전부 같은 제약이 있다.
같은 클래스 내부 호출은 프록시를 거치지 않으므로 어노테이션이 무시된다.

 

@Async가 동작하기 위한 조건

 

조건 이유
@EnableAsync 설정 필요 이 설정이 없으면 @Async 자체가 활성화되지 않음
public 메서드에만 적용 프록시가 메서드를 가로채려면 외부에서 접근 가능해야 함
self-invocation 불가 내부 호출은 프록시를 거치지 않으므로 동기로 실행됨
Spring Bean이어야 함 new로 생성한 객체는 프록시가 적용되지 않음

 

 


💥 원인 2: 트랜잭션 커밋 전에 콜백이 올 수 있다

 

self-invocation을 고쳐서 비동기로 분리하더라도, 또 다른 문제가 있었다.

 

1
관리번호 row를 INSERT (아직 커밋 전)
2
@Async로 제휴사 API 호출
!
제휴사 콜백이 빠르게 도착 → 관리번호 row를 UPDATE하려 하지만 커밋 전이라 보이지 않음

 

@Transactional이 걸려 있으면 INSERT는 했지만 commit 전이라

다른 트랜잭션(콜백 처리)에서 해당 row가 보이지 않을 수 있다.

DB INSERT 커밋 시점과 외부 콜백 도착 시점의 race condition이다.

 

 


✅ 해결: 별도 빈으로 분리 + 트랜잭션 경계 분리

 

1단계: @Async 메서드를 별도 빈으로 분리

 

// 별도 빈으로 분리 — 외부 호출이므로 프록시를 탄다 @Service public class ExternalApiAsyncService { @Async public void callExternalApi(CarDto carDto, String apiKey) { // 제휴사 API 호출 } }

 

// 호출하는 쪽 — 다른 빈의 메서드를 호출하므로 프록시를 거침 public class BenzService extends CarService { private final ExternalApiAsyncService externalApiAsyncService; public void init(CarDto carDto) { // ... externalApiAsyncService.callExternalApi(carDto, apiKey); // ✅ 프록시 경유 } }

 

2단계: 트랜잭션 커밋 후 비동기 호출 (race condition 방지)

 

관리번호 INSERT가 커밋된 후에 외부 API를 호출해야 콜백 race를 막을 수 있다.

@TransactionalEventListener를 사용하면 커밋 이후 시점에 비동기 호출을 걸 수 있다.

 

// 트랜잭션 커밋 후 비동기 실행 @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void callExternalApi(CarCreatedEvent event) { // DB 커밋 확인 후 실행 → 콜백이 와도 row가 보임 }

 

⚠️ @EnableAsync 설정도 필요하다.

@Async는 어노테이션만 붙이면 끝이 아니다.
설정 클래스에 @EnableAsync가 있어야 활성화된다.

 

 


🔧 비동기 동작 확인하는 방법

 

로그에 Thread.currentThread().getName()을 찍어 확인할 수 있다.

 

❌ 동기 (self-invocation)
[http-nio-8080-exec-1] Benz API 호출
[http-nio-8080-exec-1] Bmw API 호출
[http-nio-8080-exec-1] Ferrari API 호출
같은 스레드에서 순차 실행
✅ 비동기 (별도 빈 호출)
[async-pool-1] Benz API 호출
[async-pool-2] Bmw API 호출
[async-pool-3] Ferrari API 호출
각각 다른 스레드에서 실행

 

스레드 이름은 설정한 Executor의 threadNamePrefix에 따라 달라진다.

 

 


📝 정리

 

문제는 두 가지였다.

 

문제 원인 해결
@Async가 동기로 실행됨 같은 클래스 내부 호출
(self-invocation → 프록시 우회)
@Async 메서드를
별도 Spring Bean으로 분리
콜백이 INSERT보다 먼저 도착 트랜잭션 커밋 전에
외부 API 호출 → 콜백 race condition
@TransactionalEventListener로
커밋 후 비동기 호출

 

💡 한 줄 요약: @Async어노테이션만 붙이면 끝이 아니다.
프록시 기반이므로 self-invocation은 불가하고,
외부 시스템 호출이라면 트랜잭션 커밋 시점까지 고려해야 한다.