@Async를 붙였는데 왜 비동기로 안 돌아가지?
@Async를 붙였는데 왜 비동기로 안 돌아가지?
2025-11-20 ~ 2026-02-09
문제 상황
블록체인 송금이 완료되면, 보낸 사람과 받는 사람 모두에게 알림을 보내야 한다. 그런데 알림 발송이 느리면 API 응답도 느려진다. 사용자 입장에서는 "송금 완료"가 떴는데 2~3초 더 기다리는 셈이다.
그래서 알림 발송을 비동기로 돌리기로 했다. Spring의 @Async를 쓰면 간단할 줄 알았다.
@Service
class TransactionService(
private val notificationService: NotificationService
) {
fun commitTransaction(txId: String): Response {
// 1. 블록체인 커밋 (동기)
val result = blockchain.commit(txId)
// 2. 알림 발송 (비동기로 하고 싶음)
sendNotifications(txId)
return Response(result)
}
@Async // ← 이거 붙이면 비동기 되는 거 아닌가?
fun sendNotifications(txId: String) {
// DB 조회 → 알림 발송
}
}
테스트해보면 여전히 동기로 실행된다. @Async가 무시되고 있었다.
원인: Spring의 프록시 기반 AOP
Spring의 @Async, @Transactional, @Cacheable 같은 어노테이션은 프록시(Proxy)를 통해 동작한다.
→ 프록시란 원래 객체를 감싸는 "대리인" 객체다. Spring은 Bean을 생성할 때 프록시 객체로 감싸서, 메서드 호출 전후에 부가 기능(트랜잭션 관리, 비동기 실행 등)을 자동으로 끼워넣는다.
외부에서 호출: Controller → [Proxy] → Service.method()
↑ 프록시가 @Async를 감지하고 별도 스레드에서 실행
자기 자신 호출: Service.commitTransaction() → this.sendNotifications()
↑ 프록시를 거치지 않음!
같은 클래스 안에서 자기 자신의 메서드를 호출하면 프록시를 거치지 않는다. 이걸 Self-Invocation(자기 호출) 문제라고 한다. → Self-Invocation은 Spring에서 가장 흔한 함정 중 하나다. 외부에서 호출하면 프록시를 통과하지만, 같은 클래스 안에서 this로 호출하면 프록시를 건너뛰어 어노테이션이 무시된다.
this.sendNotifications()는 프록시가 아니라 실제 객체를 직접 호출하기 때문에, @Async가 적용되지 않는다.
해결: 별도 서비스로 분리
가장 깔끔한 해결법은 비동기 메서드를 별도 클래스로 분리하는 것이다.
// 비동기 알림 전담 서비스
@Service
class TransferNotificationService(
private val sessionRepository: SessionRepository,
private val walletRepository: WalletRepository,
private val pushService: PushService
) {
@Async // ← 외부에서 호출하므로 프록시가 정상 작동
fun sendTransferNotifications(txId: String) {
runCatching {
val session = sessionRepository.findByTxId(txId) ?: return@runCatching
// 보낸 사람에게 알림
findUsersByAddress(session.fromAddress).forEach { mycode ->
pushService.sendTransferPush(mycode, amount, isDeposit = false)
}
// 받는 사람에게 알림
findUsersByAddress(session.toAddress).forEach { mycode ->
pushService.sendTransferPush(mycode, amount, isDeposit = true)
}
}.onFailure { e ->
logger.error("알림 발송 실패: txId={}", txId, e)
}
}
}
// 기존 서비스에서는 주입받아서 호출
@Service
class TransactionService(
private val transferNotificationService: TransferNotificationService // 주입
) {
fun commitTransaction(txId: String): Response {
val result = blockchain.commit(txId)
// 외부 서비스 호출 → 프록시 경유 → @Async 정상 동작
transferNotificationService.sendTransferNotifications(txId)
return Response(result) // 알림 발송 기다리지 않고 바로 응답
}
}
Self-Invocation이 문제되는 다른 경우
@Async만의 문제가 아니다. 프록시 기반 어노테이션은 전부 같은 함정이 있다.
@Service
class SomeService {
@Transactional
fun methodA() {
// ... DB 작업
methodB() // ← @Transactional 무시됨!
}
@Transactional(propagation = REQUIRES_NEW) // REQUIRES_NEW는 기존 트랜잭션과 별개로 새 트랜잭션을 시작하라는 설정이다
fun methodB() {
// 새 트랜잭션을 원했지만, self-invocation이라 기존 트랜잭션에서 실행됨
}
}
methodA()에서 methodB()를 호출하면, REQUIRES_NEW가 무시되고 같은 트랜잭션에서 실행된다.
@Async 메서드에서 주의할 점
비동기 메서드는 호출한 쪽의 트랜잭션 컨텍스트 밖에서 실행된다. 그래서 몇 가지 주의사항이 있다.
1. 예외가 호출자에게 전파되지 않는다
@Async
fun doSomething() {
throw RuntimeException("에러!") // 호출자는 이 예외를 모른다
}
runCatching이나 try-catch로 반드시 감싸야 한다. 안 그러면 에러가 조용히 사라진다.
2. 영속성 컨텍스트가 공유되지 않는다
→ 영속성 컨텍스트란 JPA가 관리하는 엔티티 객체의 임시 저장소다. 같은 트랜잭션 안에서는 DB에서 조회한 엔티티를 캐시처럼 보관하고 변경을 추적하는데, 다른 스레드에서는 이 저장소가 공유되지 않는다.
@Async
fun sendNotification(txId: String) {
// 새로운 스레드 → 새로운 DB 커넥션
// 호출자의 트랜잭션에서 아직 커밋 안 된 데이터는 조회 불가
val session = repository.findByTxId(txId) // null일 수 있음!
}
호출자 트랜잭션이 커밋된 후에 비동기 메서드가 실행되어야 데이터가 보인다.
배운 것
- Spring의
@Async,@Transactional은 프록시 기반이다. 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 어노테이션이 무시된다. - 해결법은 단순하다: 별도 서비스(클래스)로 분리하고, 주입받아서 호출하면 된다.
@Async메서드는 별도 스레드에서 실행되므로, 예외 처리와 데이터 가시성에 주의해야 한다.- 비동기 작업에서 DB를 조회해야 한다면, 호출자의 트랜잭션이 커밋된 이후에 실행되도록 타이밍을 맞춰야 한다.