먼저 스프링에서는 하나의 작업을 어떻게 처리하는지 부터 알아야 합니다.
스프링 애플리케이션에서 서비스 로직은 기본적으로 멀티쓰레드 동기 방식으로 실행됩니다. 클라이언트로부터 웹 요청이 들어오면, 톰캣이나 넷티 같은 웹 서버가 요청마다 Thread Pool에서 하나의 쓰레드를 할당하여 처리합니다. 이러한 방식으로 스프링은 다수의 요청을 처리할 수 있는 동시성을 지원하게 됩니다.

멀티 쓰레드가 필요한 경우는 언제일까?
멀티 쓰레드는 애플리케이션의 성능을 높이고 동시성을 관리하기 위해 사용하는 중요한 기술입니다. 하지만 모든 작업에 멀티 쓰레드를 사용하는 것이 적합한 것은 아닙니다. 적절한 상황에서 멀티 쓰레드를 활용하면 작업을 효율적으로 처리할 수 있습니다.
멀티 쓰레드는 단일한 개념이 아니라, 여러 쓰레드를 사용해 성능을 최적화할 수 있는 방법론입니다. 이를 구현하는 다양한 방식이 있으며, 각 구현 방식은 상황에 맞게 적절히 적용되어야 합니다.
아래는 멀티 쓰레드의 방법들과 각 방법에 맞는 활용 상황을 보여드리겠습니다.
-1 비동기 작업 처리
이메일 전송, 로그 저장, 외부 API 호출 등 시간이 오래 걸릴 수 있는 작업의 경우, 메인 요청 쓰레드를 블로킹하지 않기 위해 @Async나 Executor를 사용해 별도의 쓰레드로 실행시킵니다.
여기서 "별도의 쓰레드를 실행한다"는 것은 멀티 쓰레드를 사용해 작업을 분리한다는 뜻으로, 예시로 메인 요청 쓰레드(HTTP 요청을 처리하는 쓰레드 )에서는 클라이언트가 요청한 작업만 처리합니다 즉 진입점의 메서드만을 Async로 하여 오랜 시간이 걸리는 작업을 다른 쓰레드로 넘기는 방식으로 하는 것입니다.
이렇게 될 경우, 메인 요청 쓰레드는 오랜 시간이 필요한 작업이 끝날 때까지 기다리지 않고 즉시 클라이언트에게 응답하거나 다음 작업을 처리합니다. 이메일 전송은 별도의 쓰레드에서 실행되므로, 시간이 오래 걸려도 메인 요청 쓰레드에 영향을 주지 않습니다.
하지만 우리는 이러한 것을 바라지 않을 것입니다. 클라이언트가 어떠한 요청을 보냈는데 그에 따른 결과값이 오지 않고 단순히 응답만 빠르게 온다? 아무 소용도 없는 코드일 것입니다. 따라서 단순히 @Async로 비동기 작업을 실행했으니까 됐다. 이런 것이 아니라 모든 작업이 끝난 뒤 클라이언트에게 결과를 반환하는 방식이 필요합니다.
우선 @ @Async를 간단히 활용한 예제를 보겠습니다.
@Service
public class LogService {
@Async
public void saveLog(String logMessage) {
System.out.println("로그 저장 시작: " + logMessage);
try {
Thread.sleep(2000); // 2초 걸리는 작업 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("로그 저장 완료: " + logMessage);
}
}
요청 쓰레드는 saveLog 작업이 끝날 때까지 기다리지 않고 다른 요청을 처리할 수 있습니다. 이는 멀티 쓰레드 환경에서 이루어지며, 로그 저장 작업이 메인 요청 쓰레드를 블로킹하지 않도록 분리되어 실행됩니다.
중요한 건 @Async를 사용해 로그 저장 작업을 별도의 쓰레드에서 실행하므로, 클라이언트 요청 처리 속도에 영향을 주지 않는다는 것입니다. 사실 이러한 예시를 든 것도 결과가 필요없는 작업의 예시를 갖고 온 것입니다. 어떻게 처리하는 지 뒤에서 추가로 설명하겠습니다.
-2 병렬 처리
병렬 처리란 여러 작업을 동시에 실행하여 처리 속도를 높이는 방식을 의미합니다. 작업을 한 번에 하나씩 처리하는 직렬 처리와는 반대로, 작업을 여러 쓰레드에서 나누어 동시에 실행하여 효율성을 극대화합니다.
잘 와닿지 않을텐데, 예시로 설명해보자면 한 사람이 여러 책을 한권 씩 순서대로 읽어 1개의 독후감을 쓰는 경우가 직렬 처리, 여러 사람이 각각 다른 책을 동시에 읽어 1개의 독후감을 쓰는 경우로 생각하시면 됩니다. 책 할당량 증가 속도는 확연히 차이가 나게 되겠죠?
병렬 처리의 목표는 "대규모 데이터를 빠르게 처리하거나, 오래 걸리는 작업을 단축 시킬 때, CPU 코어를 최대한 활용하여 작업을 나누어 처리함으로써 성능 향상" 을 위하여서 사용이 됩니다.
그러면 병렬처리가 과연 좋은 코드일까?
바로 아래의 예제를 살펴볼 건데, 우선 복잡성이 증가합니다. 병렬 처리는 단순히 작업을 나누는 것처럼 보여도, 내부적으로는 여러 쓰레드가 동시에 실행되므로 동시성 문제나 결과 통합에 관련하여서 추가적인 로직이 동반되어야 하기에 어쩔 수 없이 복잡해지게 됩니다.
하지만 자바의 CompletableFuture 같은 고수준 API를 사용하면 이 복잡성을 많이 숨길 수 있습니다.
import java.util.concurrent.CompletableFuture;
public class ParallelProcessingExample {
public static void main(String[] args) {
CompletableFuture<Void> task1 = CompletableFuture.runAsync(() -> {
System.out.println("작업 1 실행: " + Thread.currentThread().getName());
try {
Thread.sleep(2000); // 작업 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
}
});
CompletableFuture<Void> task2 = CompletableFuture.runAsync(() -> {
System.out.println("작업 2 실행: " + Thread.currentThread().getName());
});
CompletableFuture<Void> allTasks = CompletableFuture.allOf(task1, task2);
allTasks.join(); // 모든 작업이 끝날 때까지 기다림
System.out.println("모든 작업 완료");
}
}
// runAsync: 작업을 별도의 쓰레드에서 실행
// allOf: 모든 작업이 완료될 때까지 대기
작업 1 실행: ForkJoinPool.commonPool-worker-1
작업 2 실행: ForkJoinPool.commonPool-worker-2
모든 작업 완료
결과 값을 보면 알 수 있듯이, CompletableFuture는 기본적으로 자바의 ForkJoinPool을 사용하여 비동기 작업을 처리합니다.
이어서 설명드리겠지만, 별도의 쓰레드 풀을 지정하는 것이 보통이고 단일로 병렬처리를 하여 사용했을 시, 기본적으로 모든 CompletableFuture가 공통 풀을 공유하게 되므로 작업량이 많아질 경우 예상치 못한 병목현상이 발생할 수 있습니다.
-3 Async와 병렬처리의 결합
사실 여기서부터가 본론입니다.
앞서 설명한 대로, @Async는 메인 요청 쓰레드의 블로킹을 방지하고 작업을 백그라운드 쓰레드로 넘기기 위해 사용됩니다. 여기서 블로킹이란, 클라이언트의 요청을 처리하는 쓰레드가 작업이 끝날 때까지 기다리는 상태를 의미하며, 이는 서버 성능 저하의 원인이 될 수 있습니다.
반면, 병렬 처리는 여러 작업을 동시에 실행하여 효율성을 높이는 데 사용됩니다. 하지만 실무에서는 이 두 가지를 결합하여 비동기 작업과 병렬 처리를 함께 구현하는 경우가 많습니다. 단순히 @Async로 빈 응답을 받고 만족하는 것은 실제로 드문 사례입니다. 또한, 병렬 처리는 작업을 나누어 처리하는 데 효과적이지만, 단순히 비동기적으로 실행한다고 해서 항상 효율성이 보장되는 것은 아닙니다.
스프링은 싱글 쓰레드 기반의 요청 처리 방식을 사용하지만, @Async와 병렬 처리 기술은 쓰레드 풀을 통해 멀티 쓰레드 환경에서 작동합니다. 따라서 병렬 작업이 많은 경우에도 적절한 쓰레드 풀 설정을 통해 효율성을 극대화할 수 있습니다.
실제 적용 예시
제가 프로젝트에 적용한 사례를 보여드리자면, 전체적인 서비스는 Chat GPT API 에게 입력값을 Json으로 받으면 해당 Json을 DTO에게 매핑을 하여야 하며, 이 중 응답 Json 필드 중 하루 별 일과 코스를 모은 데이터는 Geocoding API에게 매핑하여 좌표를 반환받고 또 같은 DTO에게 매핑을 해야 하는 것입니다.
우선 API 호출이 내부적으로 2번이나 이루어지면서 동시에 DTO 매핑을 하고 또 이를 캐시에 저장을 해야하는 것인데 해당 요청에 응답하기 까지만 꽤나 오랜 시간이 걸리게 됩니다.
만일 이를 동기적으로 처리하게 된다면 사용자가 100명이 몰렸을 때, 가뜩이나 오래 걸리는 API를 줄 서서 기다리게 되는 꼴이되니 사용자 경험이 매우 안 좋아지게 될 것입니다. 그래서 결국 비동기 처리와 병렬처리를 결합하여, 각각의 매핑을 나누어서 처리를 해 결국 하나의 DTO 값을 나타내게 하고 이를 비동기 처리를 하여서 동시에 수행이 가능토록 하였습니다.
@Async("AI_Executor")
public CompletableFuture<RecommendDTO> createTripPlan(TripRequestDTO tripRequestDTO) {
CompletableFuture<GPTResponse> gptResponseFuture = gptService.getRecommendation(tripRequestDTO)
.thenApply(ResponseEntity::getBody);
CompletableFuture<List<List<LatLngDTO>>> dailyCoordinatesFuture = gptResponseFuture.thenCompose(gptResponse ->
googleMapsService.processDailyRoutes(gptService.extractDailyRoutes(gptResponse)));
return CompletableFuture.allOf(gptResponseFuture, dailyCoordinatesFuture)
.thenApply(v -> {
GPTResponse gptResponse = gptResponseFuture.join();
List<List<LatLngDTO>> dailyCoordinates = dailyCoordinatesFuture.join();
List<RecommendedItemDTO> recommendedItems = gptService.extractRecommendedItems(gptResponse);
JsonNode rootNode = GPTResponseMapper.parseResponseContent(gptResponse);
return TripMapper.toRecommendDTO(rootNode, dailyCoordinates, recommendedItems, tripRequestDTO);
});
}
@Async 메서드 자체가 별도의 쓰레드에서 실행됩니다. 메인 쓰레드가 블로킹되지 않으므로 클라이언트의 요청 처리 속도가 빨라지게 되며,
CompletableFuture로 GPTService에서 AI 응답을 비동기로 받아오고, 이를 기반으로 GoogleMapsService에서 병렬로 좌표를 처리합니다. "allOf"를 사용해 모든 작업이 완료된 후 결과를 통합합니다.
AI 응답과 Google Maps 좌표 처리가 각각 비동기로 실행되며, 병렬로 처리되기에 결국 작업 간 의존성을 최소화하여 효율성을 극대화 하는 것에 성공할 수 있었습니다.
주의점: @Async와 CompletableFuture 사용 시 유의사항
1. 같은 쓰레드 풀을 사용 하여야 한다.
맨 위의 @Async를 보면 괄호안에 이름이 들어가있습니다. 이는 제가 만든 커스텀 쓰레드 풀입니다. 왜냐하면 @Async와 CompletableFuture는 모두 스프링의 쓰레드 풀을 사용하여 비동기 작업을 처리하기 때문입니다.
기본적으로 스프링은 SimpleAsyncTaskExecutor라는 비동기 작업 실행기를 제공을 하지만 아무런 설정 없이 @Async를 사용하면, 해당 기본 설정이 사용이 되게 되는데, 이는쓰레드 풀을 사용하지 않고, 요청이 들어올 때마다 새 쓰레드를 생성합 쓰레드 재사용을 지원하지 않으므로 커스텀 쓰레드 풀을 정의하여 효율적인 작업 처리를 보장해야 합니다.
2. 결과 통합 시 블로킹 주의
CompletableFuture의 allOf를 사용하는 경우, 내부적으로 join을 호출하여 모든 작업의 결과를 기다립니다. 이 과정에서 작업이 완료되지 않으면 쓰레드가 무한 대기 상태에 빠질 수 있으므로, 모든 작업의 흐름을 정확히 설계해야 합니다.
특히 대규모 데이터 처리나 네트워크 작업에서는 블로킹 시간을 최소화하도록 비동기 작업의 크기와 병렬성 수준을 적절히 조정해야 하며 타임아웃과 같은 기능으로 병목 현상을 예방할 필요가 있습니다.
3. 공유 리소스 접근 주의
비동기 작업이 동시에 실행되면서 공유 리소스를 수정하거나 읽는 경우, 동시성 문제가 발생할 수 있습니다. 이를 방지하기 위해 synchronized 블록, ReentrantLock, 또는 Atomic 변수를 활용하여 동기화를 보장해야 합니다. 공유 리소스를 다루는 경우에는 항상 잠재적인 동시성 문제를 염두에 두고 코드를 작성해야 합니다
4. 예외 처리
비동기 작업에서 발생한 예외는 기본적으로 메인 쓰레드로 전달되지 않기 때문에 별도로 처리해야 합니다.
CompletableFuture에서는 exceptionally나 handle을 사용하여 예외를 처리하고, 적절한 기본 값을 반환하거나 에러 로그를 남기는 방식으로 안정성을 보장할 수 있습니다.
CompletableFuture<GPTResponse> gptResponseFuture = gptService.getRecommendation(tripRequestDTO)
.thenApply(ResponseEntity::getBody)
.exceptionally(ex -> {
System.err.println("GPT Service 에러 발생: " + ex.getMessage());
return null; // 적절한 기본 값 설정
});
멀티 쓰레드의 부작용
멀티 쓰레드는 애플리케이션의 성능을 향상시키는 강력한 도구이지만, 무조건 사용한다고 해서 성능이 항상 개선되는 것은 아닙니다. 멀티 쓰레드를 도입하기 전에 부작용을 명확히 이해하고, 현재 상황에서 멀티 쓰레드가 필요한지 신중히 판단해야 합니다.
동시성 문제
멀티 쓰레드 환경에서는 여러 쓰레드가 동시에 공유 자원에 접근할 때 데이터 충돌이 발생할 수 있습니다.
예시: 두 사용자가 동시에 재고를 감소시키는 요청을 보낼 경우, 재고가 중복 차감되거나 잘못된 결과를 초래할 수 있습니다.
해결 방안: 동기화또는 락을 사용해 데이터 접근을 제어해야 하지만, 이는 성능 저하를 유발할 수 있습니다.
복잡성 증가
병렬 처리에서 말했듯이, 멀티 쓰레드는 코드의 복잡성을 크게 증가시킵니다. 왜냐하면 동시 실행되는 작업 간 의존성을 관리하거나, 데이터를 올바르게 공유하기 위해 추가적인 동기화 로직이 필요하기 때문입니다. 이러한 복잡성은 디버깅과 테스트를 어렵게 만들고, 코드의 가독성을 저하시킬 수 있습니다.
예시: 동기화 메커니즘이나 락을 잘못 사용하면 의도치 않은 버그가 발생하거나 성능 병목이 생길 수 있습니다.
자원 소모
쓰레드 생성과 관리는 CPU와 메모리 자원을 소비합니다. 쓰레드 수가 증가하면 컨텍스트 스위칭 비용이 증가하고, 성능이 오히려 저하될 수 있습니다.
- 컨텍스트 스위칭은 CPU가 실행 중인 작업의 상태를 저장하고, 새로운 작업으로 전환하는 과정을 말합니다.
특히, 과도한 쓰레드 생성은 시스템 메모리 부족 또는 OutOfMemoryError를 초래할 수 있습니다.
해결 방안: 쓰레드 풀(Thread Pool)을 사용해 쓰레드 생성을 제한하고, 자원 소모를 최적화할 수 있습니다.
멀티 쓰레드를 피하는 방법
멀티 쓰레드는 성능을 최적화하고 동시성을 관리하는 데 효과적인 도구이지만, 잘못 사용하면 자원 소모, 코드 복잡성 증가, 동시성 문제 등 여러 단점을 초래할 수 있습니다. 이러한 문제를 해결하거나 최소화하기 위해, 멀티 쓰레드를 대체할 수 있는 효율적인 대안들이 존재합니다. 이 대안들은 멀티 쓰레드의 장점을 살리면서 단점은 피할 수 있도록 설계되었습니다.
Spring WebFlux 사용
Spring WebFlux는 비동기 및 논블로킹 IO를 기반으로 하는 스프링 프레임워크의 웹 개발 모듈입니다. 기존 스프링 MVC가 동기적 요청-응답 처리 모델을 사용하는 것과는 달리, WebFlux는 논블로킹 방식으로 동작하여 더 적은 리소스로 많은 요청을 처리할 수 있도록 설계되었습니다.
WebFlux는 요청과 응답을 논블로킹 방식으로 처리하므로, 쓰레드가 IO 작업을 기다리느라 낭비되지 않습니다. 예를 들어, 대규모 클라이언트 요청을 처리하는 REST API 서버에서는 많은 클라이언트의 요청을 처리하기 위해 멀티 쓰레드가 필요할 수 있습니다. 하지만 WebFlux를 사용하면 적은 쓰레드로도 대량의 요청을 효율적으로 처리할 수 있습니다.
@RestController
public class WebFluxController {
@GetMapping("/async-data")
public Mono<String> getAsyncData() {
return Mono.fromSupplier(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "비동기로 처리된 데이터";
});
}
}
이 코드는 Spring WebFlux 방식으로 비동기 처리된 데이터를 반환합니다. 기존 REST API와 달리, Mono를 사용해 요청-응답 과정을 논블로킹으로 처리합니다.
Mono: 최대 1개의 데이터를 비동기적으로 반환하는 리액티브 타입입니다. 데이터가 준비되면 이를 비동기로 클라이언트에 전달합니다.
장점: 쓰레드가 데이터를 기다리며 블로킹되지 않아, 적은 쓰레드로도 대규모 요청을 효율적으로 처리할 수 있습니다. 이는 고성능 실시간 애플리케이션에 적합합니다.
메시지 큐 사용
RabbitMQ나 Kafka 같은 메시지 큐를 사용하면 작업을 비동기로 처리하면서 멀티 쓰레드 사용을 최소화할 수 있습니다. 메시지 큐는 작업을 생산자와 소비자로 나누어 처리하므로, 쓰레드 대신 메시지 단위로 작업을 분산할 수 있습니다. 이를 통해 동시성 문제를 방지하고, 작업을 안전하게 처리할 수 있습니다.
예를 들어, 이메일 전송과 같은 작업을 메시지 큐를 통해 처리한다고 가정해 보겠습니다.
메시지 생산자 코드
@Service
public class EmailProducer {
private final RabbitTemplate rabbitTemplate;
public EmailProducer(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void sendEmail(String email) {
rabbitTemplate.convertAndSend("emailQueue", email);
System.out.println("이메일 전송 요청: " + email);
}
}
메시지 소비자 코드
@Component
public class EmailConsumer {
@RabbitListener(queues = "emailQueue")
public void processEmail(String email) {
try {
Thread.sleep(3000); // 이메일 전송 시뮬레이션
System.out.println("이메일 전송 완료: " + email);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
여기서 생산자는 이메일 요청을 메시지 큐에 보내고, 소비자는 큐에서 메시지를 가져와 비동기로 처리합니다. 생산자와 소비자가 독립적으로 동작하기 때문에 멀티 쓰레드를 사용하지 않아도 비동기 처리와 동시성 관리가 가능합니다.
태스크 스케줄링
멀티 쓰레드를 사용하지 않고도 작업을 순차적으로 스케줄링하여 처리할 수 있습니다. Spring Batch와 같은 프레임워크를 사용하면 대량의 데이터를 나누어 처리하거나, 정기적으로 실행해야 하는 작업을 쉽게 관리할 수 있습니다. 이는 멀티 쓰레드 사용으로 인한 자원 소모와 동시성 문제를 방지하는 데 효과적입니다.
예를 들어, 데이터베이스에서 특정 데이터를 읽어와 배치 처리하는 코드를 작성해 보겠습니다.
@Configuration
public class BatchConfig {
@Bean
public Job dataProcessingJob(JobBuilderFactory jobBuilderFactory, Step dataProcessingStep) {
return jobBuilderFactory.get("dataProcessingJob")
.start(dataProcessingStep)
.build();
}
@Bean
public Step dataProcessingStep(StepBuilderFactory stepBuilderFactory, ItemReader<String> reader,
ItemProcessor<String, String> processor, ItemWriter<String> writer) {
return stepBuilderFactory.get("dataProcessingStep")
.<String, String>chunk(10)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
}
위 코드는 데이터베이스에서 데이터를 읽고 가공한 뒤 저장하는 배치 작업을 순차적으로 처리합니다. 스케줄링된 작업이 순서대로 실행되므로, 쓰레드 간 충돌 없이 안정적으로 처리할 수 있습니다.
'spring' 카테고리의 다른 글
| [JPA] JPA 속 키 매핑 방법과 배치 전략 속 키 매핑 방법 (1) | 2024.12.22 |
|---|---|
| [DB] 낙관적 락 vs 비관적 락 (2) | 2024.11.30 |
| JPA에서의 N+1 문제란? (0) | 2024.09.24 |
| AOP와 스프링 AOP란? (0) | 2024.09.23 |
| 스프링 세션과 Redis 응용 (1) | 2024.09.18 |