@Async는 어떻게 동작하나요?

2025. 10. 6. 16:18·CS
📝 면접 답변 보기

@Async는 스프링 AOP를 통해 동작합니다.

스프링이 @Async가 붙은 메소드를 발견하면, 해당 클래스의 프록시 객체를 생성해서 빈으로 등록합니다.

다른 빈에서 이 서비스를 주입받으면 실제로는 프록시 객체가 주입되고, 메소드를 호출하면 프록시 안에 있는 MethodInterceptor가 AsyncExecutionInterceptor를 실행합니다.

AsyncExecutionInterceptor는 TaskExecutor를 사용해서 새로운 스레드에서 실제 메소드를 실행하고, 호출한 쪽에는 즉시 리턴해서 비동기 처리가 가능해집니다.

중요한 점은, 같은 클래스 내부에서 @Async 메소드를 직접 호출하면 프록시를 거치지 않기 때문에 비동기로 동작하지 않습니다.

 

면접 질문 정리용 레포지토리

https://github.com/unifolio0/backend-interview-study.git

 

GitHub - unifolio0/backend-interview-study

Contribute to unifolio0/backend-interview-study development by creating an account on GitHub.

github.com

핵심 개념

1. 스프링 빈과 프록시

빈(Bean)이란?

빈은 스프링 컨테이너가 관리하는 객체를 의미합니다. @Component, @Service, @Repository 등의 어노테이션이나 @Bean 메서드로 등록된 모든 객체가 빈입니다.

@Service
public class EmailService {
    public void sendEmail(String to) {
        // 이메일 전송 로직
    }
}

위 코드에서 EmailService는 스프링 빈으로 등록됩니다.

프록시(Proxy)란?

프록시는 실제 객체를 감싸는 대리 객체입니다. 스프링은 특정 기능을 제공하기 위해 원본 빈 대신 프록시 객체를 생성해서 주입합니다.

2. AOP (Aspect-Oriented Programming)

AOP란 무엇인가?

AOP는 관점 지향 프로그래밍으로, 여러 곳에서 공통으로 사용되는 기능을 모듈화하는 프로그래밍 기법입니다.

예를 들어, 여러 서비스 메소드에 로깅을 추가하고 싶을 때

AOP 없이 구현한다면 모든 메소드에 아래와 같은 로직을 붙여줘야 합니다.

public void methodA() {
    log.info("methodA 시작");
    // 실제 로직
    log.info("methodA 종료");
}

public void methodB() {
    log.info("methodB 시작");
    // 실제 로직
    log.info("methodB 종료");
}

 

하지만 AOP를 활용하게 된다면 아래와 같이 Aspect를 구현해주면 모든 메소드에 로깅이 적용이 됩니다.

@Aspect
@Component
public class LoggingAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info(joinPoint.getSignature().getName() + " 시작");
        Object result = joinPoint.proceed();
        log.info(joinPoint.getSignature().getName() + " 종료");
        return result;
    }
}

AOP 용어 정리

 

  • Aspect: Advice + Pointcut을 담고 있는 모듈 (위 예시의 LoggingAspect 클래스)
  • Advice: 실제로 실행될 부가 기능 코드 (logAround 메서드)
  • Pointcut: Advice를 어디에 적용할지 정의하는 표현식 (execution(...))
  • JoinPoint: Advice가 적용될 수 있는 지점 (메서드 호출 시점 등)
  • Proxy: Aspect를 실제로 적용하기 위해 스프링이 생성하는 대리 객체

스프링 AOP의 동작 방식

 

  1. 스프링이 빈을 생성할 때 AOP 적용 대상인지 확인
  2. 적용 대상이면 프록시 객체 생성
  3. Advice 로직을 프록시에 포함
  4. 프록시 객체를 빈으로 등록
  5. 메서드 호출 시 프록시가 Advice를 실행한 후 실제 메서드 호출

3. CGLIB 프록시

JDK Dynamic Proxy vs CGLIB Proxy

스프링 AOP는 두 가지 방식으로 프록시를 생성할 수 있습니다:

JDK Dynamic Proxy:

  • 인터페이스 기반
  • 대상 클래스가 인터페이스를 구현해야 함
  • Java 리플렉션 API 사용

CGLIB Proxy:

  • 클래스 기반 (상속)
  • 인터페이스 없이도 프록시 생성 가능
  • 바이트코드 조작으로 서브클래스 생성
  • 스프링 부트의 기본 방식

CGLIB로 프록시를 생성하게 되면 아래와 같은 형태로 프록시 객체가 생성됩니다.

원본 클래스
프록시 객체 정의 및 필드
프록시 객체 메소드 형태

@Async의 동작 과정

Step 1 : 스프링 애플리케이션 시작 시

1. @EnableAsync 설정 확인

먼저 스프링 부트 애플리케이션에서 비동기 기능을 활성화해야 합니다. @EnableAsync가 있어야 AsyncConfigurationSelector를 임포트하여 @Async가 붙은 빈을 프록시로 만드는 AsyncAnnotationBeanPostProcessor를 빈으로 등록합니다.

@SpringBootApplication
@EnableAsync  // ← 이 어노테이션이 필수!
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2. @Async가 붙은 메서드를 가진 클래스 발견

스프링이 빈을 스캔하면서 @Async 어노테이션을 찾습니다.

@Service
public class AsyncService {

    @Async
    public void callAsync(String name) {
        System.out.println("callAsync: " + name);
    }

    public void callNotAsync(String name) {
        System.out.println("callAsync: " + name);
    }
}

 

 

3. CGLIB으로 프록시 클래스 생성

스프링은 AsyncService를 상속하는 프록시 클래스를 동적으로 생성합니다.

AsyncService$$SpringCGLIB$$0 (프록시)
    ↓ extends
AsyncService (원본)

4. AsyncExecutionInterceptor를 MethodInterceptor로 등록

프록시 객체에 비동기 처리를 담당하는 AsyncExecutionInterceptor를 등록합니다. 이 인터셉터가 실제로 새로운 스레드를 생성하고 메서드를 실행하는 역할을 합니다.

// 스프링 내부 동작 (의사 코드)
AsyncService$$SpringCGLIB$$0 proxy = new AsyncService$$SpringCGLIB$$0();

// 비동기 처리를 위한 인터셉터 등록
AsyncExecutionInterceptor asyncInterceptor = new AsyncExecutionInterceptor();
asyncInterceptor.setExecutor(taskExecutor); // 스레드 풀 설정
proxy.setCallback(0, asyncInterceptor);

5. 프록시 객체를 빈으로 등록

최종적으로 원본 AsyncService 대신 프록시 객체를 스프링 컨테이너에 빈으로 등록합니다.

스프링 컨테이너
├── "asyncService" → AsyncService$$SpringCGLIB$$0 (프록시)
└── (원본 AsyncService는 프록시 내부에만 존재)

Step 2: 메서드 호출 시

1. 외부에서 asyncService.callAsync() 호출

@RestController
public class AsyncController {
    
    private final AsyncService asyncService; // 프록시 객체가 주입됨
    
    public AsyncController(AsyncService asyncService) {
        this.asyncService = asyncService;
    }
    
    @PostMapping("/async")
    public String async() {
        System.out.println("비동기 시작: " + Thread.currentThread().getName());
        asyncService.callAsync("비동기 처리");
        System.out.println("비동기 완료: " + Thread.currentThread().getName());
        return "완료";
    }
}

2. 실제로는 프록시 객체의 callAsync() 실행

asyncService는 실제로는 AsyncService$$SpringCGLIB$$0 프록시 객체이므로, 프록시의 callAsync 메서드가 호출됩니다. 아래는 실제 프록시 객체의 코드입니다.

public final void callAsync(String var1) {
    try {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if (var10000 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        if (var10000 != null) {
            var10000.intercept(this, CGLIB$callAsync$0$Method, new Object[]{var1}, CGLIB$callAsync$0$Proxy);
        } else {
            super.callAsync(var1);
        }
    } catch (Error | RuntimeException var2) {
        throw var2;
    } catch (Throwable var3) {
        throw new UndeclaredThrowableException(var3);
    }
}

3. MethodInterceptor.intercept() 호출

프록시 내부에서 MethodInterceptor의 intercept가 호출됩니다. 이때 MethodInterceptor의 구현체 중 하나인 DynamicAdvisedInterceptor의 intercept가 호출됩니다. 아래는 DynamicAdvisedInterceptor의 intercept의 동작을 요약해서 적은 코드입니다. 이 코드의 맨 마지막인 new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain).proceed()에서 AsyncExecutionInterceptor의 invoke() 메소드가 실행됩니다.

@Override
@Nullable
public Object intercept(Object proxy, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
	// 1. 타겟 객체 가져오기 (실제 AsyncService 인스턴스)
    target = targetSource.getTarget();
    Class<?> targetClass = (target != null ? target.getClass() : null);
    
    // 2. 이 메서드에 적용할 Interceptor 체인 가져오기
    // AsyncExecutionInterceptor 같은 AOP Alliance MethodInterceptor들이 들어있음
    List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
    Object retVal;
    
    if (chain.isEmpty()) {
        // 3-A. Advice가 없으면 원본 메서드 직접 호출
        Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
        retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
    }
    else {
        // 3-B. Advice가 있으면 ReflectiveMethodInvocation 생성하고 실행
        // 여기서 AsyncExecutionInterceptor.invoke()가 호출
        retVal = new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain).proceed();
    }
    return processReturnType(proxy, target, method, args, retVal);
}

4. AsyncExecutionInterceptor가 TaskExecutor에 작업 제출

TaskExecutor의 스레드 풀에서 사용 가능한 스레드를 할당받아 작업을 실행합니다.

@Override
@Nullable
@SuppressWarnings("NullAway")
public Object invoke(final MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
    final Method userMethod = BridgeMethodResolver.getMostSpecificMethod(invocation.getMethod(), targetClass);

    AsyncTaskExecutor executor = determineAsyncExecutor(userMethod);
    if (executor == null) {
        throw new IllegalStateException(
                "No executor specified and no default executor set on AsyncExecutionInterceptor either");
    }

    Callable<Object> task = () -> {
        try {
            Object result = invocation.proceed();
            if (result instanceof Future<?> future) {
                return future.get();
            }
        }
        catch (ExecutionException ex) {
            handleError(ex.getCause(), userMethod, invocation.getArguments());
        }
        catch (Throwable ex) {
            handleError(ex, userMethod, invocation.getArguments());
        }
        return null;
    };

    return doSubmit(task, executor, invocation.getMethod().getReturnType());
}

6. 호출한 쪽에는 즉시 리턴

실행 결과:
비동기 시작: http-nio-8080-exec-1
비동기 완료: http-nio-8080-exec-1 // ← 즉시 리턴!
callAsync: 비동기 처리 // ← 나중에 비동기로 실행됨

 

'CS' 카테고리의 다른 글

동기/비동기, 블로킹/논블로킹은 무엇이고 각각의 차이점은 무엇인가요?  (0) 2025.10.08
컴퓨터의 병렬 처리 기술  (5) 2025.07.27
[매일메일] 객체 지향 프로그래밍이란 무엇이고, 어떤 특징이 있나요?  (0) 2025.03.31
[매일메일] PRG 패턴에 대해서 설명해 주세요.  (0) 2025.03.24
'CS' 카테고리의 다른 글
  • 동기/비동기, 블로킹/논블로킹은 무엇이고 각각의 차이점은 무엇인가요?
  • 컴퓨터의 병렬 처리 기술
  • [매일메일] 객체 지향 프로그래밍이란 무엇이고, 어떤 특징이 있나요?
  • [매일메일] PRG 패턴에 대해서 설명해 주세요.
ggio
ggio
개발 공부를 하며 배운 내용을 기록합니다.
  • ggio
    기록을 하자
    ggio
  • 전체
    오늘
    어제
    • 분류 전체보기 (41)
      • SW마에스트로 (5)
      • System Architecture (8)
      • Algorithm (15)
      • Side Tech Notes (7)
      • CS (5)
      • 취준 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    비관락
    Algorithm
    메시지 큐
    멀티 코어
    분산락
    리액터 패턴
    객체지향
    프로그래밍
    시스템 설계
    fail back
    토스 NEXT
    소마
    다중화
    Programming
    코딩테스트
    ha 아키텍처
    코테
    소프트웨어 마에스트로
    부트캠프
    리트코드
    at-least-once
    매일메일
    시스템 아키텍쳐
    알고리즘
    leetcode
    지리적 분산
    프로액터 패턴
    SW마에스트로
    3PC
    fail over
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
ggio
@Async는 어떻게 동작하나요?
상단으로

티스토리툴바