Skip to content
메모장
Go back

Spring @Async vs Kotlin 코루틴 — 회원탈퇴 소셜 unlink 처리로 보는 선택 기준

개요

회원탈퇴를 처리할 때 카카오/애플의 unlink(연결 끊기) API를 호출해야 했다.

후보는 두 가지였다.

둘 다 “호출자를 안 기다리게 한다”는 목적은 같지만 메커니즘이 완전히 다르다

결과적으로 기존 아키텍처(JPA/Redis 블로킹 호출이 대부분이고, WebClient는 외부 API 어댑터 내부에서만 쓰는 구조)에 뭘 끼워 넣어야 하는지에 따라 답이 갈렸다.

WithdrawService.execute()
  ├─ MarkUserWithdrawUseCase.execute()   ← 상태 전이, 독립 트랜잭션(REQUIRES_NEW)
  ├─ SessionManager.removeAllSessions()  ← 블로킹, Redis
  ├─ SocialUnlinkRunnerPort.unlinkAsync() ← 이번 글의 주제
  └─ UserRepository.delete()             ← 블로킹, JPA

1. @Async / @EnableAsync 동작 원리

@Async는 그 자체로는 아무 일도 하지 않는다.

@EnableAsync가 있어야 Spring이 @Async가 붙은 메서드를 가진 Bean에 AOP 프록시를 씌운다

@Configuration
@EnableAsync
class AsyncConfig

프록시를 통해(= 다른 Bean에서) @Async 메서드가 호출되면, 호출한 스레드에서 실행하는 대신 TaskExecutor에 작업을 던지고 즉시 리턴한다.

반환 타입이 void/Unit이면 결과를 아예 안 기다리는 fire-and-forget이 되고, Future/CompletableFuture를 반환하도록 선언하면 그 핸들을 돌려받을 수 있다.


2. 기본 Executor — 순수 Spring과 Spring Boot는 다르다

@AsyncExecutor Bean을 따로 등록하지 않았을 때 뭐가 기본값으로 잡히는지가 순수 Spring Framework와 Spring Boot에서 다르다.

protected @Nullable Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
    if (beanFactory != null) {
        try {
            // Search for TaskExecutor bean... not plain Executor since that would
            // match with ScheduledExecutorService as well, which is unusable for
            // our purposes here. TaskExecutor is more clearly designed for it.
            return beanFactory.getBean(TaskExecutor.class);
        }
        catch (NoUniqueBeanDefinitionException ex) {
            // 이름이 "taskExecutor"인 Executor Bean을 찾아본다
            ...
        }
        catch (NoSuchBeanDefinitionException ex) {
            // 여기까지 못 찾으면 최종적으로 SimpleAsyncTaskExecutor로 폴백
        }
    }
    ...
}

컨텍스트에서 TaskExecutor 타입 Bean(또는 이름이 taskExecutorExecutor Bean)을 찾고, 그마저 없을 때만 SimpleAsyncTaskExecutor로 떨어진다.

Spring Boot의 TaskExecutionAutoConfigurationapplicationTaskExecutor라는 이름으로 ThreadPoolTaskExecutor를 미리 등록해두기 때문에, Boot 프로젝트에서는 이 조회가 대부분 성공해서 풀링된 Executor를 쓰게 되는 것이다.

환경기본 Executor특징
순수 Spring (Boot 없이)SimpleAsyncTaskExecutor스레드를 재사용하지 않음 — 호출마다 새 스레드 생성
Spring BootThreadPoolTaskExecutor (applicationTaskExecutor)진짜 풀링, spring.task.execution.*로 설정 가능

SimpleAsyncTaskExecutor는 이름 그대로 “Simple”해서 스레드 풀링을 안 한다

TaskExecutionAutoConfiguration에서는 자동으로 진짜 스레드풀을 등록해준다.

한 가지 예외: Java 21+ 가상 스레드(spring.threads.virtual.enabled=true)를 켜면 Boot도 다시 SimpleAsyncTaskExecutor(가상 스레드 기반)를 쓴다.

가상 스레드는 생성 비용이 사실상 무시할 수준이라 이 경우엔 풀링이 없어도 문제가 안 된다.

이 프로젝트에는 실제로 뭐가 있나

iam 모듈의 AsyncConfig는 딱 이만큼이다.

// iam/infra/configuration/AsyncConfig.kt
@Configuration
@EnableAsync
class AsyncConfig

Executor Bean을 직접 등록하지도, spring.task.execution.*(풀 크기, 큐 용량 등)을 application.yml에 튜닝해두지도 않았다


3. 자기호출(self-invocation) 문제

@Transactional과 동일한 함정이 그대로 있다. 같은 클래스 안에서 this.xxx()@Async 메서드를 호출하면 프록시를 안 거쳐서 그냥 동기 실행된다.

// app/port/SocialUnlinkRunnerPort.kt
interface SocialUnlinkRunnerPort {
    fun unlinkAsync(provider: OAuthProvider, providerId: String)
}
// infra/external/SocialUnlinkRunnerAdapter.kt
@Component
class SocialUnlinkRunnerAdapter(
    private val resolver: SocialUnlinkResolver,
) : SocialUnlinkRunnerPort {

    private val log = KotlinLogging.logger {}

    @Async
    override fun unlinkAsync(provider: OAuthProvider, providerId: String) {
        runCatching {
            resolver[provider].unlink(providerId)
        }.onFailure {
            log.error(it) { "Failed to unlink social account, provider=$provider, providerId=$providerId" }
        }
    }
}

runCatching을 여기서 감싸는 이유도 자기호출 문제와 무관하지 않다

  • @Async void 메서드에서 던진 예외는 기본적으로 호출자에게 전파되지 않고 별도 핸들러로 흘러가는데, 그 흐름에 기대기보다는 명시적으로 잡아서 로깅하는 편이 추적하기 쉽다.

4. 그럼 코루틴은 왜 안 썼나

이미 NaverMapController.mapSearch()suspend fun + WebClient.awaitBody<T>()로 완전히 코루틴을 사용하여 논 블로킹 처리를 하고 있다.

다만 위에서 사용하지 않은 이유는 아래와 같다.

@Async (스레드풀)코루틴(suspend)
실행 단위실제 OS 스레드경량 실행 단위, 스레드와 1:1 아님
대기 중 자원 점유스레드를 통째로 점유스레드를 놓아주고 다른 코루틴이 씀
강점기존 블로킹 코드를 그대로 감싸서 별도 스레드로 던지기만 하면 됨진짜 논블로킹 I/O(HTTP 응답 대기 등)에서 매우 적은 스레드로 대량 동시성

WithdrawService.execute()가 부르는 나머지 작업들(MarkUserWithdrawUseCase의 JPA, SessionManager의 Redis, 마지막 UserRepository.delete)은 전부 블로킹이다.

만약 unlink() 호출부만 suspend fun으로 바꾸고 컨트롤러까지 suspend로 끌어올렸다면

즉 컨트롤러 하나만 suspend로 바꾼다고 되는 게 아니라, 그 밑에 걸린 블로킹 체인 전체를 디스패처 인지형으로 다시 짜야 코루틴의 이점을 실제로 볼 수 있다.

지금 필요한 건 “unlink 실패가 탈퇴 응답을 막지 않게 하는 것” 하나뿐이라, 이미 검증된 블로킹 스택 위에 @Async 하나만 얹는 쪽이 훨씬 적은 변경으로 같은 목적을 달성한다.

반대로 NaverMapController가 코루틴을 쓰는 건 정확히 코루틴이 잘하는 상황이다


요약

항목선택이유
비동기 메커니즘@Async (스레드풀)호출 체인이 대부분 블로킹(JPA/Redis)이라, 코루틴으로 가려면 체인 전체를 디스패처 인지형으로 바꿔야 함 — 이득 대비 비용이 큼
ExecutorSpring Boot 기본값(applicationTaskExecutor)Boot가 이미 풀링된 ThreadPoolTaskExecutor를 자동 등록 — 순수 Spring의 SimpleAsyncTaskExecutor(비풀링)와 다름
자기호출 회피별도 Bean(SocialUnlinkRunnerAdapter)으로 분리@Async@Transactional과 동일하게 프록시 기반이라 같은 클래스 내부 호출로는 무효화됨
레이어링app/port/SocialUnlinkRunnerPort + infra 구현체비동기 실행이라는 infra 관심사를 app이 직접 알지 않도록 인터페이스로 격리
예외 처리어댑터 내부 runCatching@Async void 메서드의 기본 예외 처리(핸들러로 전파)에 기대지 않고 명시적으로 로깅

Share this post:

Previous Post
@Transactional 함정
Next Post
Redis 비교