문제 상황
비동기 로직을 @Async로 작성했는데, 분명 @Async가 붙은 메서드 내부에서 수행한 트랜잭션이 예상대로 동작하지 않았다.
또한 컨트롤러에서 호출하자마자 응답이 오길 기대했지만, 실제로는 동작 방식이 애매하게 느껴졌다.
이 상황을 정확히 이해하기 위해, 스프링에서 프록시 객체, 비동기 처리, 스레드 모델, 트랜잭션 처리 방식이 어떻게 연관되어 있는지 하나하나 분석해봤다.
프록시 객체란?
스프링에서 @Transactional, @Async 등 여러 기능은 내부적으로 프록시 객체를 사용해서 구현된다.
프록시 객체는 진짜 객체를 감싸는 껍데기로서, 공통 로직(AOP)을 실행한 뒤 실제 메서드를 호출하는 구조다.
예를 들어 @Transactional 메서드를 호출하면:
- 프록시가 트랜잭션 시작
- 진짜 객체의 메서드 실행
- 예외 여부에 따라 트랜잭션 커밋/롤백 결정
비동기 메서드와 프록시
@Async도 마찬가지다. 프록시가 메서드를 감싼 뒤, 새로운 스레드에 해당 작업을 위임하고 바로 반환한다.
진짜 객체의 메서드는 그때 생성된 스레드에서 동작하게 된다.
중요한 점은:
@Async메서드는 반드시 다른 빈에서 호출되어야 동작한다- 자기 자신(this)의 메서드에서
@Async메서드를 호출하면 프록시를 거치지 않음 → 비동기 안 됨
스레드와 스레드 풀
비동기를 처리할 때 매번 새 스레드를 만드는 건 비용이 크기 때문에, 스프링은 기본적으로 TaskExecutor라는 스레드 풀을 사용한다.
스레드 풀의 특징:
- 스레드는 미리 만들어두고, 유휴 상태로 대기
- 작업이 들어오면 기존 스레드에 할당
- 병렬 처리 수는 코어 수 + 스레드 풀 설정에 따라 결정
스프링의 요청 흐름과 스레드
스프링 MVC에서 HTTP 요청이 들어오면, 하나의 서블릿 스레드가 컨트롤러 → 서비스 → 리포지토리까지 그대로 흐른다.
즉, 요청 단위로 하나의 스레드가 끝까지 책임을 지며:
- 컨트롤러 → 서비스 → 트랜잭션까지 같은 스레드에서 수행
@Transactional범위도 이 흐름에서 시작되고 끝남
동기 호출에서 WebClient 사용 시 스레드
만약 WebClient.block()처럼 동기로 외부 요청을 보낸다면, 그동안 해당 스레드는 블로킹 상태가 된다.
따라서 스레드가 대기만 하게 되어 리소스 낭비가 심하다.
이럴 땐 .subscribe()나 Mono.defer() 등 리액티브 방식이 효율적이다.
결론
스프링의 구조는 다음을 포함한다.:
- 프록시 객체는 AOP 기반 기능의 핵심
- @Async는 프록시 + 스레드 풀 기반 비동기 처리
- 트랜잭션은 호출 스레드와 관련
- 동기식 외부 요청은 스레드 블로킹시킴
- 비동기를 활용할 땐 스레드 풀 설정, 트랜잭션 경계, 호출 구조를 신중히 고려해야 함