<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>하니의 개발새발  </title>
    <link>https://pyh-dotcom.tistory.com/</link>
    <description>한 번 공부를 해보겠습니다</description>
    <language>ko</language>
    <pubDate>Mon, 12 Oct 2026 21:29:02 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>pyh-dotcom</managingEditor>
    <image>
      <title>하니의 개발새발  </title>
      <url>https://tistory1.daumcdn.net/tistory/9042449/attach/91c121c1e63a4e548558cd2dade846ca</url>
      <link>https://pyh-dotcom.tistory.com</link>
    </image>
    <item>
      <title>[비동기] Async</title>
      <link>https://pyh-dotcom.tistory.com/1</link>
      <description>&lt;h1&gt;@Async&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async 어노테이션 하나만 붙이면 spring에서 비동기로 수행할 수 있습니다. 하지만 기본 설정 그대로 운영에 올렸다가는 트래픽이 몰리는 순간 서버가 스레드를 무한정 생성하며 죽어버릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 @Async의 기본 사용법부터, 왜 기본 설정이 위험한지, 스레드 풀을 어떻게 관리해야 하는지, 그리고 내부적으로 어떻게 동작하는지까지 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 기본 사용법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async를 쓰려면 먼저 @EnableAsync를 붙여야 합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        return new SimpleAsyncTaskExecutor(&quot;SimpleAsyncTaskExecutor-&quot;);
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;getAsyncExecutor()는 @Async 메서드를 실행할 때 사용할 Executor를 지정합니다. 이걸 사용하지 않으면 Spring Boot는 TaskExecutionAutoConfiguration이 자동 등록한 ThreadPoolTaskExecutor를 사용하므로 오버라이드하였습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 비동기로 실행하고 싶은 메서드에 @Async를 붙입니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Service
public class EmailService {

    @Async
    public void sendEmail(String to) {
        log(&quot;send email to &quot; + to);
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
public class EmailController {

    private final EmailService emailService;

    @PostMapping(&quot;/email&quot;)
    public void send() {
        emailService.sendEmail(&quot;test@test.com&quot;);
        log(&quot;요청 접수 완료&quot;); // sendEmail이 끝나길 기다리지 않고 바로 리턴됨
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 email 요청을 보내는 스레드의 이름을 확인해 봅시다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;23:31:35.739 [SimpleAsyncTaskExecutor-1] send email to test@test.com
23:31:35.739 [http-nio-8080-exec-1] 요청 접수 완료

23:31:41.528 [http-nio-8080-exec-2] 요청 접수 완료
23:31:41.528 [SimpleAsyncTaskExecutor-2] send email to test@test.com

23:31:48.092 [SimpleAsyncTaskExecutor-3] send email to test@test.com
23:31:48.091 [http-nio-8080-exec-3] 요청 접수 완료
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 이름이 SimpleAsyncTaskExecutor-1, -2, -3으로 모두 다른 거 보이시나요? 스레드가 &lt;b&gt;재사용되지 않고 매번 새로 생성&lt;/b&gt;되고 있다는 뜻입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. SimpleAsyncTaskExecutor의 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableAsync만 설정하고 별도로 Executor를 등록하지 않으면 &lt;b&gt;Spring은 기본값으로 SimpleAsyncTaskExecutor를 사용&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름 때문에 오해하기 쉬운데 이건 &lt;b&gt;스레드 풀이 아닙니다.&lt;/b&gt; 요청이 들어올 때마다 새 스레드를 생성하고 작업이 끝나면 그 스레드를 버립니다. 스레드를 미리 만들어두고 재사용하는 ThreadPoolTaskExecutor와는 완전히 다른 방식입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;직접 재현해 보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 짧은 시간 안에 @Async 메서드를 대량으로 호출하면 어떻게 될까요.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
public class TestController {

    private final EmailService emailService;

    @GetMapping(&quot;/test&quot;)
    public String test() {
        for (int i = 0; i &amp;lt; 1000; i++) {
            emailService.sendEmail(&quot;test&quot; + i + &quot;@test.com&quot;);
        }
        return &quot;done&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SimpleAsyncTaskExecutor를 쓰는 상태에서 이 요청을 실행하면 짧은 시간 안에 스레드가 수천 개 생성됩니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;23:33:30.671 [SimpleAsyncTaskExecutor-11] send email to test10@test.com
23:33:30.675 [SimpleAsyncTaskExecutor-28] send email to test27@test.com
23:33:30.673 [SimpleAsyncTaskExecutor-22] send email to test21@test.com
23:33:30.676 [SimpleAsyncTaskExecutor-38] send email to test37@test.com
23:33:30.676 [SimpleAsyncTaskExecutor-37] send email to test36@test.com

...
23:33:30.789 [SimpleAsyncTaskExecutor-996] send email to test995@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-997] send email to test996@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-998] send email to test997@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-999] send email to test998@test.com
23:33:30.790 [SimpleAsyncTaskExecutor-1000] send email to test999@test.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 위험한가&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모리 고갈&lt;/li&gt;
&lt;li&gt;스레드가 생성될 때마다 OS는 콜스택을 담을 스택 메모리를 미리 예약해 둡니다. 기본 크기는 스레드당 512KB~1MB 정도로 스레드가 1,000개면 그것만으로 수백 MB~GB 단위의 메모리가 사용됩니다.&lt;/li&gt;
&lt;li&gt;콘텍스트 스위칭 비용 증가&lt;/li&gt;
&lt;li&gt;OS 스케쥴러가 관리해야 할 스레드가 늘어날수록 컨텍스트 스위칭 비용이 증가합니다.&lt;/li&gt;
&lt;li&gt;동시성 제어 불가&lt;/li&gt;
&lt;li&gt;SimpleAsyncTaskExecutor는 요청이 들어오는 즉시 그대로 스레드를 생성합니다. 순간적으로 요청이 몰리면 그 순간의 트래픽 양이 곧 그대로 스레드 생성 개수가 됩니다.&lt;/li&gt;
&lt;li&gt;연쇄 장애 가능성
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;스레드 폭증
  &amp;rarr; 메모리 고갈 (OutOfMemoryError)
  &amp;rarr; 비동기 로직뿐만 아니라 요청을 받던 톰캣 스레드(http-nio-*)까지 함께 죽음
  &amp;rarr; 동시에 DB 커넥션 풀(HikariCP 등)・외부 API 커넥션도 스레드 수만큼 고갈
  &amp;rarr; 비동기와 무관한 동기 요청까지 커넥션을 못 구해 타임아웃
&lt;/code&gt;&lt;/pre&gt;
시작은 스레드 하나였지만 결과는 서버 전체의 응답 지연&amp;middot;장애입니다.&lt;/li&gt;
&lt;li&gt;앞의 세 문제가 겹치면 장애가 도미노처럼 번집니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞의 네 가지 문제는 스레드 생성에 아무런 제한이 없다는 것에서 시작됩니다. 그러면 이 문제는 어떻게 해결할 수 있을까요?? 바로 &lt;b&gt;스레드 풀을 관리&lt;/b&gt;하는 것입니다. 스레드를 미리 만들어 두고 만들어 둔 스레드는 버리지 않고 재사용하는 방법에 대해서 살펴보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Thread Pool 등록 및 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AsyncConfigurer를 구현해서 직접 ThreadPoolTaskExecutor를 등록하면 이 문제를 해결할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix(&quot;async-&quot;);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new SimpleAsyncUncaughtExceptionHandler();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 설정값&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 의미&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;corePoolSize&lt;/td&gt;
&lt;td&gt;기본으로 유지하는 스레드 수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;maxPoolSize&lt;/td&gt;
&lt;td&gt;늘어날 수 있는 최대 스레드 수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;queueCapacity&lt;/td&gt;
&lt;td&gt;core 스레드가 모두 사용 중일 때 작업을 대기시키는 큐의 용량&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;threadNamePrefix&lt;/td&gt;
&lt;td&gt;스레드 이름 접두사 (로그 추적에 유용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RejectedExecutionHandler&lt;/td&gt;
&lt;td&gt;큐도 꽉 차고 max까지 다 찼을 때 처리 전략&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 순서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ThreadPoolTaskExecutor는 아래 순서로 동작합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 요청 도착
2. core 스레드에 여유가 있다면 &amp;rarr; 새 스레드 생성해서 즉시 처리
3. core가 꽉 찼다면 &amp;rarr; queue에 대기
4. queue도 꽉 찼다면 &amp;rarr; max까지 스레드를 추가로 생성해서 처리
5. max까지 다 찼고 queue도 꽉 찼다면 &amp;rarr; RejectedExecutionHandler 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RejectedExecutionHandler의 기본 정책은 예외를 던지는 것(AbortPolicy)이지만 상황에 따라 다음과 같은 정책을 선택할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정책 설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AbortPolicy (기본값)&lt;/td&gt;
&lt;td&gt;RejectedExecutionException 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CallerRunsPolicy&lt;/td&gt;
&lt;td&gt;작업을 제출한 스레드가 직접 실행 (호출자가 느려지지만 유실은 안 됨)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DiscardPolicy&lt;/td&gt;
&lt;td&gt;조용히 버림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DiscardOldestPolicy&lt;/td&gt;
&lt;td&gt;큐에서 가장 오래된 작업을 버리고 새 작업을 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 다시 많은 양의 async를 처리하는 api를 수행하고 로그를 확인해 봅시다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;...
15:28:28.130 [ async-10] send email to test59@test.com
15:28:28.133 [  async-5] send email to test8@test.com
15:28:28.133 [  async-3] send email to test5@test.com
15:28:28.132 [  async-2] send email to test7@test.com
15:28:28.132 [  async-4] send email to test6@test.com
15:28:28.131 [http-nio-8080-exec-1] send email to test64@test.com
15:28:28.135 [  async-4] send email to test15@test.com
15:28:28.131 [  async-8] send email to test4@test.com
15:28:28.131 [  async-1] send email to test3@test.com
15:28:28.131 [  async-9] send email to test2@test.com
15:28:28.135 [  async-1] send email to test18@test.com
15:28:28.135 [  async-8] send email to test17@test.com
15:28:28.135 [http-nio-8080-exec-1] send email to test77@test.com
15:28:28.135 [  async-4] send email to test16@test.com
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;async-1~async-10까지만 반복해서 등장합니다. 코드에 설정한 값으로 왜 2개(corePoolSize)가 아니라 10개(maxPoolSize)까지 늘어났는지 계산해 보면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;corePoolSize=2
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청이 들어오자마자 async-1~async-2 2개가 먼저 만들어져 처리를 시작합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;queueCapacity=50
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;core 2개가 모두 바쁘면 다음 50개는 스레드를 더 만들지 않고 큐에서 대기합니다.&lt;/li&gt;
&lt;li&gt;1000개는 2(core) + 50(queue)를 훌쩍 넘기 때문에 큐까지 꽉 찬 시점부터 maxPoolSize=10까지 스레드를 추가로 만들어(async-3~async-10) 처리합니다.&lt;/li&gt;
&lt;li&gt;async-11처럼 10을 넘는 이름은 절대 나오지 않습니다. maxPoolSize가 상한선이기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 더 확인해 볼 점이 있습니다. corePoolSize(2) + queueCapacity(100) + (maxPoolSize(10) - corePoolSize(2)) = 110개까지만 스레드 풀이 흡수할 수 있는데 요청은 1000개입니다. 나머지 약 890개는 어디로 갈까요? RejectedExecutionHandler로 CallerRunsPolicy를 지정해 뒀기 때문에 이 초과분은 버려지지 않고 &lt;b&gt;요청을 보낸 스레드(http-nio-*)가 직접 실행&lt;/b&gt;합니다. 로그에서 async-가 아니라 http-nio-가 찍히면서 sendEmail이 실행되는 줄이 바로 이 경우입니다. 이 구간은 사실상 동기 처리가 되는 셈이라 그만큼 API 응답이 늦어집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실무 팁&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CPU 바운드 작업&lt;/b&gt;: corePoolSize를 CPU 코어 수 근처로, 큐는 작게&lt;/li&gt;
&lt;li&gt;&lt;b&gt;I/O 바운드 작업&lt;/b&gt;(외부 API 호출, DB 조회 등): 스레드가 대기 시간이 기니 corePoolSize를 더 넉넉하게, 큐도 여유 있게&lt;/li&gt;
&lt;li&gt;작업 성격이 다르면 Executor를 여러 개 등록하고 이름으로 구분해서 쓰는 것도 방법입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 동작 방식 - AOP Proxy&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async는 Spring의 AOP 프락시 메커니즘 위에서 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableAsync를 선언하면 Spring은 AsyncAnnotationBeanPostProcessor를 등록합니다. 이 후처리기가 @Async가 붙은 메서드를 가진 빈을 감지하고 그 빈에 대한 프록시 객체를 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 그려보면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[호출자] &amp;rarr; [프록시 객체] &amp;rarr; (원본 메서드 대신) Executor에 작업 제출 
                                                &amp;rarr; [스레드 풀] &amp;rarr; 실제 메서드 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서 @Async 메서드를 호출하면 실제로는 원본 객체가 아니라 프록시가 먼저 호출을 가로챕니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Self-invocation 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프록시 기반 동작 방식 때문에 생기는 가장 흔한 실수가 바로 같은 클래스 내부 호출입니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
public class OrderService {

    public void processOrder() {
        this.sendNotification(); // ❌ @Async가 동작하지 않음!
    }

    @Async
    public void sendNotification() {
        System.out.println(Thread.currentThread().getName());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;processOrder()에서 this.sendNotification()으로 호출하면 이 호출은 프록시를 거치지 않고 &lt;b&gt;원본 객체의 메서드를 직접&lt;/b&gt; 호출하게 됩니다. 프록시는 스프링 컨테이너가 빈을 주입할 때 개입하는 것이지 객체 내부의 this 호출까지 가로챌 수는 없기 때문입니다. 결과적으로 sendNotification()은 동기적으로, 즉 호출한 스레드에서 그대로 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 다른 빈으로 분리하여 호출하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class OrderService {

    private final NotificationService notificationService; // 별도 빈으로 분리

    public void processOrder() {
        notificationService.sendNotification(); // ✅ 프록시를 거쳐 정상 동작
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그 외 제약사항&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. public이어야 함&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 대상 클래스를 상속해서 메서드를 오버라이드하는 방식으로 만들어집니다. private 메서드는 애초에 오버라이드가 불가능해서 @Async를 붙여도 프록시가 가로챌 방법이 없습니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Async
private void sendEmail(String to) { ... } // 프록시가 가로챌 수 없어서 그냥 동기 실행됨
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;protected도 Spring이 공식적으로 지원 범위 밖이라고 명시하고 있습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 반환 타입은 void, Future&amp;lt;T&amp;gt;, CompletableFuture&amp;lt;T&amp;gt;만 의미 있음&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시가 메서드를 호출한 시점엔 실제 로직이 아직 실행되지도 않은 상태입니다. void는 애초에 아무것도 안 돌려주면 되고 Future/CompletableFuture는 &quot;나중에 결과 줄게&quot;라는 약속 객체만 즉시 리턴하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Async
public String sendEmail(String to) {
    ...
    return &quot;발송 성공&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;String result = emailService.sendEmail(&quot;a@test.com&quot;);
// result는 항상 null. 메서드 내부에서 뭘 리턴했든 상관없이 버려짐
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. void 반환 시 예외가 호출자에게 전파되지 않음&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sendEmail()의 실제 코드는 호출자 스레드(http-nio-*)가 아니라 풀 안의 다른 스레드(async-3 같은)에서 실행됩니다. 예외는 그 예외가 터진 스레드의 콜스택을 타고만 전파되는데 호출자 스레드는 이미 리턴 받고 다음 코드로 넘어간 뒤라 예외를 받아줄 콜스택 연결고리가 없습니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Async
    public void sendEmail(String to) {
        if (to == null || to.isEmpty()) {
            throw new NullPointerException(&quot;to is null or empty&quot;);
        }
        log(&quot;send email to &quot; + to);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;public void send() {
    emailService.sendEmail(null);
    log(&quot;요청 접수 완료&quot;); // 예외와 상관없이 무조건 찍힘
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;send() 수행 결과는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;21:45:21.507 [http-nio-8080-exec-8] 요청 접수 완료

2026-08-18T21:45:21.508+09:00 ERROR 67704 --- [spring] [        async-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.spring.async.EmailService.sendEmail(java.lang.String)

java.lang.NullPointerException: to is null or empty
	at com.example.spring.async.EmailService.sendEmail(EmailService.java:14) ~[classes/:na]
	...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;send()는 실패 여부를 알 방법이 없고 try-catch로도 잡을 수 없습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 세 줄 정리&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;✅ @Async를 사용할 때는 Thread Pool 을 등록하여 사용할 것
✅ corePoolSize / maxPoolSize / queueCapacity를 작업 특성에 맞게 조정
✅&amp;nbsp;프록시로 동작
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Async는 짧은 어노테이션 하나로 비동기 처리를 가능하게 해주는 편리한 기능이지만 그 이면에는 AOP 프록시와 스레드 풀이라는 명확한 동작 원리가 있습니다. 이걸 이해하지 못한 채 기본 설정 그대로 사용하면 트래픽이 몰리는 순간 예상치 못한 장애로 이어질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 @Async의 반환 타입으로 쓰이는 Future와 CompletableFuture의 차이, 그리고 여러 비동기 작업을 조합하는 패턴을 다뤄보겠습니다.&lt;/p&gt;</description>
      <category>Spring</category>
      <category>async</category>
      <category>비동기</category>
      <author>pyh-dotcom</author>
      <guid isPermaLink="true">https://pyh-dotcom.tistory.com/1</guid>
      <comments>https://pyh-dotcom.tistory.com/1#entry1comment</comments>
      <pubDate>Tue, 22 Sep 2026 22:13:51 +0900</pubDate>
    </item>
  </channel>
</rss>