Spring Boot의 멀티 스레드
Spring Boot 환경에서 멀티 스레드 구현 방법
만약 Spring Boot 프로젝트에서 특정 로직을 비동기적으로, 즉 별도의 스레드에서 실행해야 하는 요구사항이 있다면 어떤 방법들을 고려해볼 수 있을까요? 그리고 각 방법의 장단점은?
Spring Boot 환경에서 멀티스레딩을 구현하는 방법은 크게 두 가지로 나눌 수 있습니다.
1. Java 표준 스펙을 직접 사용하는 방법
Java ExecutorService: Java에서 제공하는 표준 스레드 풀 라이브러리입니다. Executors 팩토리 클래스를 통해 다양한 종류의 스레드 풀을 직접 생성하고 관리할 수 있습니다.
CompletableFuture (Java 8+): 비동기 작업의 결과를 조합하거나, 여러 비동기 작업들을 체이닝(Chaining)하는 등 복잡한 비동기 흐름을 제어할 때 매우 유용합니다. @Async와 함께 사용하면 강력한 시너지를 냅니다.
2. Spring이 제공하는 추상화된 기능을 사용하는 방법
Spring @Async: Spring이 제공하는 가장 대표적인 비동기 처리 방법입니다. 메소드에 어노테이션 하나만 붙이면 간단하게 해당 로직을 비동기적으로 실행할 수 있도록 해줍니다. 내부적으로는 ThreadPoolTaskExecutor를 사용합니다.
Spring ThreadPoolTaskExecutor: Spring이 ExecutorService를 한번 더 감싸서 빈(Bean)으로 등록하고 관리하기 쉽게 만들어 놓은 스레드 풀 구현체입니다. @Async의 기본 스레드 풀로 사용되며, 직접 빈으로 등록하여 세밀한 커스터마이징이 가능합니다.
3. 특징
| 기술 | 장점 | 단점 | 주로 사용하는 경우 |
|---|---|---|---|
| Java ExecutorService | Java 표준 스펙이라 유연하고 자유도가 높음 | 개발자가 직접 스레드 풀의 생명주기를 관리해야 함. Spring 컨테이너의 관리를 받지 못해 관리가 번거로움. | Spring을 사용하지 않는 환경이거나, 매우 특수한 요구사항의 스레드 풀이 필요할 때 제한적으로 사용. |
| Spring @Async | 사용이 매우 간편함 (어노테이션 기반). 코드 가독성이 좋음. 별도 설정 없이도 기본 스레드 풀로 동작. | 세밀한 제어가 어려움. 내부 동작 원리(AOP 프록시)를 모르면 실수하기 쉬움 (e.g., self-invocation). | "Fire-and-forget" 방식의 간단한 알림(메일, SMS) 발송, 로그 기록 등 메인 스레드의 응답 시간에 영향을 주지 않아야 하는 간단한 작업에 주로 사용합니다. |
| Spring ThreadPoolTaskExecutor | 스레드 풀의 상세 설정(Core/Max size, Queue capacity 등)을 직접 제어 가능. 빈으로 등록하여 관리하므로 재사용성과 테스트가 용이. | @Async에 비해 초기 설정 코드가 필요함. | 작업의 성격에 따라 스레드 풀을 분리하고 싶을 때 (e.g., I/O-intensive 작업용, CPU-intensive 작업용), 또는 시스템 리소스를 정밀하게 튜닝해야 하는 핵심 비동기 서비스에 사용합니다. |
@Async
@Async를 사용할 때 발생할 수 있는 잠재적인 문제점
@Async는 Spring AOP(관점 지향 프로그래밍)를 기반으로 동작합니다. Spring 컨테이너는 @Async가 붙은 메소드를 포함하는 빈(Bean)에 대해 프록시(Proxy) 객체를 생성합니다. 외부에서 이 메소드를 호출하면, 프록시가 호출을 가로채 실제 로직을 스레드 풀에 위임하고 즉시 리턴하는 방식입니다. 이 '프록시' 방식 때문에 몇 가지 제약사항이 발생합니다.
@Async 제약사항
- Self-Invocation (내부 호출) 문제: 가장 흔히 겪는 문제입니다. @Async가 붙은 메소드를 같은 클래스 내의 다른 메소드에서 this.asyncMethod()와 같이 직접 호출하면 비동기로 동작하지 않습니다. 그 이유는 프록시를 거치지 않고 실제 객체(target)의 메소드가 바로 호출되기 때문입니다. 이를 해결하려면 해당 메소드를 별도의 클래스로 분리하여 빈으로 주입받아 호출해야 합니다.
- private 메소드 호출 불가: AOP 프록시는 기본적으로 public 메소드에 대해서만 적용됩니다. 따라서 @Async는 반드시 public 메소드에 붙여야 합니다. private이나 protected 메소드에 붙이면 아무런 효과가 없습니다.
- 반환 타입에 따른 예외 처리 방식의 차이:
- void 반환 타입: 메소드 내부에서 발생한 예외는 호출부(caller)로 전파되지 않고 그대로 스레드 내에서 종료됩니다. 기본적으로는 에러 로그만 남고, 호출한 쪽에서는 예외 발생 여부를 알 수 없습니다. 이를 처리하려면 별도의 AsyncUncaughtExceptionHandler를 구현해야 합니다.
- Future 또는 CompletableFuture 반환 타입: 예외가 발생하면 Future 객체 내에 캡슐화됩니다. 나중에 future.get()을 호출하는 시점에 ExecutionException으로 래핑되어 예외를 받을 수 있습니다. 따라서 비동기 작업의 성공/실패 여부를 확인해야 한다면 반드시 Future 타입을 사용해야 합니다."
ThreadPoolTaskExecutor
스레드 풀의 핵심 파라미터인 CorePoolSize, MaxPoolSize, QueueCapacity는 어떤 관계를 가지고 동작하며, 어떤 기준으로 이 값들을 튜닝하시겠습니까?
ThreadPoolTaskExecutor 핵심 파라미터
- CorePoolSize: 스레드 풀에 기본적으로 유지되는 스레드의 수. 이 스레드들은 유휴 상태이더라도 제거되지 않고 계속 대기합니다.
- MaxPoolSize: 스레드 풀이 최대로 가질 수 있는 스레드의 수.
- QueueCapacity: CorePoolSize의 스레드가 모두 일하고 있을 때, 들어오는 작업을 대기시키는 큐(Queue)의 크기.
ThreadPoolTaskExecutor 동작 순서
- 새로운 작업 요청이 들어온다.
- CorePoolSize 만큼의 스레드가 아직 다 차지 않았다면, 새 스레드를 생성하여 작업을 처리한다.
- CorePoolSize가 꽉 찼다면, Queue에 작업을 넣는다.
- Queue마저 꽉 찼다면, MaxPoolSize에 도달할 때까지 새 스레드를 추가로 생성하여 작업을 처리한다.
- MaxPoolSize까지 스레드가 꽉 찼고, Queue도 꽉 찼다면, 설정된 RejectedExecutionHandler 정책에 따라 작업을 거부한다.
ThreadPoolTaskExecutor 튜닝시 확인 사항
이 값들을 튜닝할 때는 작업의 성격(I/O-bound vs CPU-bound)과 시스템의 리소스(CPU 코어 수, 메모리)를 종합적으로 고려해야 합니다.
- CPU-bound 작업 (e.g., 암호화, 복잡한 계산): 스레드가 많다고 성능이 무조건 좋아지지 않습니다. CPU 코어 수에 맞춰 CorePoolSize와 MaxPoolSize를 비슷하게 설정하는 것이 좋습니다. 너무 많은 스레드는 잦은 컨텍스트 스위칭으로 오히려 성능을 저하시킬 수 있습니다. 예를 들어, 8코어 CPU라면 size를 8~16 정도로 설정하고, Queue는 비교적 넉넉하게 잡아 CPU가 낭비되지 않도록 합니다.
- I/O-bound 작업 (e.g., DB 조회, 외부 API 호출): 스레드가 I/O를 기다리며 대기하는 시간이 길기 때문에, 스레드를 CPU 코어 수보다 훨씬 많이 설정하는 것이 효율적입니다. CorePoolSize와 MaxPoolSize를 비교적 크게 잡고, 요청이 급증하는 경우를 대비해 QueueCapacity도 충분히 확보합니다. 예를 들어, MaxPoolSize를 50~100과 같이 설정하여 동시에 많은 I/O 작업을 처리할 수 있도록 합니다.
RejectedExecutionHandler의 정책
그렇다면 RejectedExecutionHandler에는 어떤 종류의 정책들이 있으며, 각 정책은 어떤 상황에서 사용하는 것이 적합할까요? 만약 외부 API 호출처럼 실패해서는 안 되는 매우 중요한 비동기 작업을 처리하는 스레드 풀을 설계한다면, 어떤 거부 정책을 선택하시겠습니까? 그리고 그 이유는 무엇인가요?
RejectedExecutionHandler는 스레드 풀이 더 이상 새로운 작업을 수용할 수 없을 때(큐가 꽉 차고, 최대 스레드 수도 도달했을 때) 어떻게 대처할지를 정의하는 인터페이스입니다. Spring의 ThreadPoolTaskExecutor는 기본적으로 Java의 ThreadPoolExecutor를 사용하므로, Java에서 제공하는 표준 정책들을 그대로 사용할 수 있습니다.
AbortPolicy (기본값): 가장 기본 정책입니다. RejectedExecutionException 예외를 발생시키고 작업을 거부합니다. 호출한 쪽에서 이 예외를 try-catch로 잡아서 처리해야 합니다.
DiscardPolicy: 가장 조용한 정책입니다. 아무런 예외 없이 작업을 그냥 버립니다. 어떤 작업이 버려졌는지 알 수 없으므로, 데이터 유실이 발생할 수 있습니다.
DiscardOldestPolicy: 큐에서 가장 오래된(먼저 들어온) 작업을 버리고, 새로 들어온 작업을 큐에 넣으려고 시도합니다. 이 역시 데이터 유실의 위험이 있습니다.
CallerRunsPolicy: 작업을 스레드 풀에 위임한 스레드(보통 메인 스레드 또는 HTTP 요청 스레드)가 직접 그 작업을 실행하도록 만듭니다. 즉, 비동기 호출이 동기 호출처럼 동작하게 됩니다.
CallerRunsPolicy
실패해서는 안 되는 중요한 비동기 작업(예: 결제 후 주문 완료 처리 알림)을 처리하는 스레드 풀을 설계한다면 CallerRunsPolicy를 선택
- 작업 유실 방지: CallerRunsPolicy는 작업을 버리거나 예외를 던지는 대신, 작업을 요청한 스레드가 직접 그 일을 처리하도록 합니다. 이렇게 하면 스레드 풀이 바쁘더라도 해당 작업은 결코 유실되지 않고 반드시 실행됨을 보장할 수 있습니다.
- 자연스러운 백 프레셔(Back-pressure) 역할: 이 정책의 가장 큰 장점은 시스템에 자연스러운 제동을 걸어준다는 점입니다. 작업을 요청한 스레드(예: 웹 요청을 처리하는 Tomcat 스레드)가 직접 비동기 작업을 처리하게 되면, 그 스레드는 해당 작업이 끝날 때까지 블로킹됩니다. 결과적으로 새로운 웹 요청을 받지 못하게 되어 시스템으로 들어오는 요청의 속도를 자연스럽게 늦추는 효과를 냅니다. 이는 스레드 풀이 과부하 상태에서 회복할 시간을 벌어주고, 시스템 전체가 다운되는 것을 막아주는 안전장치 역할을 합니다."
CallerRunsPolicy에 대한 보안 전략
만약 CallerRunsPolicy를 사용했음에도 불구하고, 특정 상황(예: 외부 API 응답이 극도로 느려져 동기화된 요청 스레드들이 모두 장시간 대기하는 상황) 때문에 시스템 전체의 응답 속도가 현저히 저하되는 문제가 발생했습니다. 이때 작업 유실은 막으면서 시스템 전체의 장애로 번지는 것을 막기 위해 CallerRunsPolicy를 대체하거나 보완할 다른 전략이 있다면 무엇을 고려해볼 수 있을까요?
Custom RejectedExecutionHandler 구현: 직접 RejectedExecutionHandler를 구현하여, 특정 조건에서는 작업을 재시도 큐(e.g., Redis나 RabbitMQ 같은 외부 메시지 큐)에 넣는 로직을 추가할 수 있습니다.
외부 메시지 큐(MQ) 도입: 애초에 스레드 풀의 메모리 큐가 아닌, RabbitMQ, Kafka, AWS SQS 같은 외부 메시지 큐를 버퍼로 사용하는 아키텍처를 고려할 수 있습니다.
서킷 브레이커(Circuit Breaker) 패턴 적용: 비동기 작업 자체(e.g., 외부 API 호출)에 서킷 브레이커 패턴을 적용하여, 실패가 일정 횟수 이상 누적되면 더 이상 스레드 풀에 작업을 넣지 않고 바로 실패 처리하여 시스템 부하를 줄입니다.
데드 레터 큐 (Dead Letter Queue, DLQ) 패턴: 재시도 큐에서조차 여러 번 실패한 작업들을 별도의 DLQ로 보내 나중에 수동으로 분석하고 처리할 수 있도록 하는 패턴입니다.
"매우 현실적이고 어려운 문제입니다. CallerRunsPolicy가 동기화되면서 메인 스레드들이 모두 장시간 블로킹되는 상황은 시스템 전체의 가용성을 위협할 수 있습니다. 이 경우, '모든 작업을 즉시 처리한다'는 원칙과 '시스템 전체의 안정성을 지킨다'는 원칙 사이에서 트레이드오프를 해야 합니다."
1단계: 비동기 작업 자체에 타임아웃과 서킷 브레이커 적용
먼저, 문제의 근본 원인인 '외부 API의 지연'에 대응해야 합니다. CompletableFuture에 .orTimeout()을 적용하거나, Resilience4j와 같은 라이브러리를 사용해 서킷 브레이커 패턴을 도입하겠습니다. 외부 API 호출이 일정 시간(예: 3초) 이상 걸리면 강제로 타임아웃시키고, 실패율이 임계치를 넘으면 서킷을 열어 잠시 동안은 API 호출 시도 자체를 막고 바로 실패 응답을 주도록 하겠습니다. 이렇게 하면 CallerRunsPolicy로 인해 메인 스레드가 무한정 대기하는 최악의 상황을 막을 수 있습니다.
**2단계: Custom Handler와 외부 메시지 큐를 이용한 재처리 아키텍처 도입
"여기서 더 나아가, 단순히 실패시키는 것이 아니라 나중에라도 반드시 처리해야 하는 작업이라면, RejectedExecutionHandler를 직접 구현하는 방식을 고려하겠습니다."
"이 커스텀 핸들러는 다음과 같이 동작합니다.
- 작업이 거부되면, 즉시 실행하는 대신 해당 작업의 메타데이터(e.g., 주문 ID, 사용자 ID)를 Redis나 RabbitMQ 같은 외부 메시지 큐(MQ)에 저장합니다.
- 그리고 클라이언트에게는 '주문은 정상적으로 접수되었으며, 처리가 완료되면 알려드리겠습니다'와 같은 응답을 즉시 반환하여 메인 스레드를 해제합니다.
- 별도의 배치 작업이나 워커(Worker) 프로세스가 이 MQ를 주기적으로 폴링(polling)하면서, 실패했던 작업들을 재시도합니다."
외부 메시지 큐 장점
- 시스템 분리: 웹 서버의 스레드 풀과 실제 작업을 처리하는 시스템이 분리되어, 한쪽의 장애가 다른 쪽에 직접적인 영향을 주지 않습니다.
내구성(Durability): 작업 요청이 외부 큐에 안전하게 저장되므로, 애플리케이션이 재시작되더라도 작업이 유실되지 않습니다.
탄력성(Resilience): 외부 시스템이 일시적으로 불안정하더라도, 나중에 복구되었을 때 재처리할 수 있는 기회를 가질 수 있습니다."
결론적으로, CallerRunsPolicy의 한계를 극복하기 위해 서킷 브레이커로 빠른 실패(Fail-Fast)를 유도하고, 외부 메시지 큐를 이용한 비동기 재처리 아키텍처를 도입하여 시스템의 안정성과 데이터의 정합성을 모두 확보하는 방향으로 설계를 개선.
'SpringBoot' 카테고리의 다른 글
| [SpringBoot] Thymeleaf 사용 (1) | 2021.04.19 |
|---|---|
| [SpringBoot] Spring Data JPA (0) | 2021.02.22 |
| [SpringBoot] DataBase 연동 (0) | 2021.02.20 |
| [SpringBoot] 프로젝트 구조 (0) | 2021.02.20 |
| [SpringBoot] 프로젝트 생성 및 실행 (0) | 2021.02.20 |