Backend Developer

Spring에서 비동기 처리와 프록시 객체, 그리고 스레드의 이해

문제 상황

비동기 로직을 @Async로 작성했는데, 분명 @Async가 붙은 메서드 내부에서 수행한 트랜잭션이 예상대로 동작하지 않았다.
또한 컨트롤러에서 호출하자마자 응답이 오길 기대했지만, 실제로는 동작 방식이 애매하게 느껴졌다.

이 상황을 정확히 이해하기 위해, 스프링에서 프록시 객체, 비동기 처리, 스레드 모델, 트랜잭션 처리 방식이 어떻게 연관되어 있는지 하나하나 분석해봤다.


프록시 객체란?

스프링에서 @Transactional, @Async 등 여러 기능은 내부적으로 프록시 객체를 사용해서 구현된다.
프록시 객체는 진짜 객체를 감싸는 껍데기로서, 공통 로직(AOP)을 실행한 뒤 실제 메서드를 호출하는 구조다.

예를 들어 @Transactional 메서드를 호출하면:

  1. 프록시가 트랜잭션 시작
  2. 진짜 객체의 메서드 실행
  3. 예외 여부에 따라 트랜잭션 커밋/롤백 결정

비동기 메서드와 프록시

@Async도 마찬가지다. 프록시가 메서드를 감싼 뒤, 새로운 스레드에 해당 작업을 위임하고 바로 반환한다.
진짜 객체의 메서드는 그때 생성된 스레드에서 동작하게 된다.

중요한 점은:

  • @Async 메서드는 반드시 다른 빈에서 호출되어야 동작한다
  • 자기 자신(this)의 메서드에서 @Async 메서드를 호출하면 프록시를 거치지 않음 → 비동기 안 됨

스레드와 스레드 풀

비동기를 처리할 때 매번 새 스레드를 만드는 건 비용이 크기 때문에, 스프링은 기본적으로 TaskExecutor라는 스레드 풀을 사용한다.

스레드 풀의 특징:

  • 스레드는 미리 만들어두고, 유휴 상태로 대기
  • 작업이 들어오면 기존 스레드에 할당
  • 병렬 처리 수는 코어 수 + 스레드 풀 설정에 따라 결정

스프링의 요청 흐름과 스레드

스프링 MVC에서 HTTP 요청이 들어오면, 하나의 서블릿 스레드가 컨트롤러 → 서비스 → 리포지토리까지 그대로 흐른다.

즉, 요청 단위로 하나의 스레드가 끝까지 책임을 지며:

  • 컨트롤러 → 서비스 → 트랜잭션까지 같은 스레드에서 수행
  • @Transactional 범위도 이 흐름에서 시작되고 끝남

동기 호출에서 WebClient 사용 시 스레드

만약 WebClient.block()처럼 동기로 외부 요청을 보낸다면, 그동안 해당 스레드는 블로킹 상태가 된다.
따라서 스레드가 대기만 하게 되어 리소스 낭비가 심하다.
이럴 땐 .subscribe()Mono.defer() 등 리액티브 방식이 효율적이다.


결론

스프링의 구조는 다음을 포함한다.:

  • 프록시 객체는 AOP 기반 기능의 핵심
  • @Async는 프록시 + 스레드 풀 기반 비동기 처리
  • 트랜잭션은 호출 스레드와 관련
  • 동기식 외부 요청은 스레드 블로킹시킴
  • 비동기를 활용할 땐 스레드 풀 설정, 트랜잭션 경계, 호출 구조를 신중히 고려해야 함