목록으로

@Async를 붙였는데 왜 비동기로 안 돌아가지?

5

@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를 조회해야 한다면, 호출자의 트랜잭션이 커밋된 이후에 실행되도록 타이밍을 맞춰야 한다.
@Async를 붙였는데 왜 비동기로 안 돌아가지? | KYUDORI