🚀 대규모 트래픽 시대
백엔드 개발에서 대규모 트래픽을 처리하는 것은 숙명과도 같습니다. 과거에는 서버의 성능을 높이는 스케일 업 방식이 주를 이루었지만, 이제는 마이크로서비스 아키텍처와 함께 여러 서버를 분산시키는 스케일 아웃이 기본이 되었습니다. 이러한 환경에서 서버 간의 통신, 데이터베이스 접근, 외부 API 호출 등 네트워크와 디스크 I/O 작업은 기하급수적으로 늘어납니다.
이때 스레드(Thread)라는 한정된 자원을 얼마나 효율적으로 다루느냐가 시스템의 전체 성능을 좌우합니다. 스프링 부트 기반의 톰캣(Tomcat) 환경에서 1개의 요청당 1개의 스레드를 할당하는 전통적인 방식은, I/O 작업이 길어질 경우 스레드가 대기 상태에 빠지며 심각한 병목 현상을 초래할 수 있습니다.
이를 해결하기 위해 네티(Netty)와 같은 이벤트 루프 기반의 기술이나 스프링 웹플럭스(Spring WebFlux)가 등장했습니다. 하지만 단순히 새로운 기술을 도입한다고 해서 성능이 무조건 좋아지는 것은 아닙니다. 시스템의 근간을 이루는 동기와 비동기, 그리고 블로킹과 논블로킹의 차이를 명확히 이해하고 아키텍처를 설계해야만 진정한 성능 최적화를 이루어낼 수 있습니다.
😅 흔히 하는 실수: 껍데기만 비동기인 코드의 위험성
많은 개발자들이 성능 개선을 위해 스프링의 비동기 처리 기능을 도입하려 합니다. 가장 쉽게 접근하는 방법이 바로 Async 어노테이션을 사용하는 것입니다.
하지만 개념에 대한 정확한 이해 없이 비동기 흉내만 내는 코드는 오히려 시스템을 망가뜨리는 시한폭탄이 될 수 있습니다.
💣 스레드 풀 고갈을 부르는 가짜 비동기
가장 대표적인 시행착오가 비동기 메서드 내부에서 블로킹(Blocking) I/O를 호출하는 경우입니다. 성능을 높이겠다고 별도의 스레드를 생성하여 작업을 맡겼지만, 정작 그 스레드가 데이터베이스 응답이나 외부 API 응답을 기다리며 멈춰버린다면 어떻게 될까요?
아래의 잘못된 코드 예시를 살펴보겠습니다.
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
private final RestTemplate restTemplate;
private final OrderRepository orderRepository; // JPA 기반의 RDBMS 레포지토리
public OrderService(RestTemplate restTemplate, OrderRepository orderRepository) {
this.restTemplate = restTemplate;
this.orderRepository = orderRepository;
}
@Async("customThreadPoolTaskExecutor")
public void processOrderAsync(OrderRequest request) {
// 1. 외부 결제 API 호출 (RestTemplate은 기본적으로 블로킹 방식으로 동작합니다)
String paymentResult = restTemplate.postForObject(
"https://payment-api.com/pay",
request.getPaymentInfo(),
String.class
);
// 2. RDBMS에 데이터 저장 (JPA 역시 기본적으로 블로킹 방식으로 동작합니다)
if ("SUCCESS".equals(paymentResult)) {
orderRepository.save(new OrderEntity(request));
}
}
}
위 코드는 스프링의 비동기 기능을 사용하여 메인 스레드를 해방시킨 것처럼 보입니다. 사용자에게는 즉시 응답을 내려줄 수 있겠죠. 하지만 시스템 내부를 들여다보면 끔찍한 일이 벌어지고 있습니다.
customThreadPoolTaskExecutor 라는 스레드 풀에서 할당받은 스레드가, 외부 API 호출과 JPA 작업에서 결과가 돌아올 때까지 아무 일도 하지 못하고 대기(Blocking) 상태에 빠지게 됩니다. 만약 트래픽이 몰려 초당 수천 건의 요청이 들어온다면 어떻게 될까요?
비동기 처리를 위해 만들어둔 스레드 풀의 스레드들이 순식간에 모두 대기 상태에 빠지게 됩니다. 결국 스레드 풀은 고갈되고 새로운 요청들은 큐에 쌓이다가 타임아웃 오류를 발생시키며 시스템 전체의 장애로 이어집니다.
이것이 바로 동기/비동기와 블로킹/논블로킹을 혼동했을 때 발생하는 가장 치명적인 시행착오입니다. 이를 방지하기 위해서는 이 두 가지 축을 완벽하게 분리해서 이해해야 합니다.
💡 핵심 : 2개의 축으로 나누어 이해하는 완벽 가이드
동기와 비동기, 블로킹과 논블로킹은 종종 혼용되어 사용되지만, 사실 완전히 다른 관점의 개념입니다. 이 두 가지를 명확히 구분하는 기준은 다음과 같습니다.
A. 동기(Synchronous) vs 비동기(Asynchronous)
이 축은 작업의 순서와 완료 여부를 누가 신경 쓰느냐(관심사) 에 대한 이야기입니다. 즉, 요청한 작업의 결과가 반환될 때까지 기다리거나 계속 확인하는지, 아니면 콜백(Callback)을 던져놓고 결과에 신경 쓰지 않는지가 핵심입니다.
B. 블로킹(Blocking) vs 논블로킹(Non-blocking)
이 축은 제어권(Control Flow)과 스레드의 상태에 대한 이야기입니다. 호출된 함수가 자신의 작업을 마칠 때까지 호출한 함수에게 제어권을 돌려주지 않아 호출한 함수를 대기하게 만드는지, 아니면 즉시 제어권을 돌려주어 호출한 함수가 다른 일을 할 수 있게 하는지가 핵심입니다.
이 두 가지 축을 조합하면 4가지의 매트릭스가 완성됩니다. 이해를 돕기 위해 아래의 다이어그램을 참고해 주시기 바랍니다.
이제 이 4가지 조합을 우리가 매일 다루는 자바와 스프링 환경의 코드와 함께 상세히 분석해 보겠습니다.
📌 1. 동기 + 블로킹 (Sync + Blocking)
가장 직관적이고 널리 쓰이는 전통적인 방식입니다. 우리가 스프링 웹 MVC 모델과 RDBMS를 연결하여 짤 때 작성하는 대부분의 코드가 여기에 해당합니다.
- 동기: 함수 A가 함수 B를 호출한 뒤, 함수 B의 리턴 값을 받아야만 다음 로직을 진행합니다. 작업의 순서가 철저하게 지켜집니다.
- 블로킹: 함수 B가 실행되는 동안 함수 A는 제어권을 잃고 아무 작업도 하지 못한 채 멈춰(Block) 있습니다.
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository; // Spring Data JPA
public UserService(UserRepository userRepository) {
this.ర్ణuserRepository = userRepository;
}
public UserProfile getUserProfile(Long userId) {
// 1. 블로킹 호출: DB에서 데이터를 가져올 때까지 이 스레드는 멈춥니다.
// 2. 동기 호출: 리턴 값을 받아야만 다음 줄로 넘어갑니다.
UserEntity user = userRepository.findById(userId).orElseThrow();
// 위 작업이 완전히 끝나야만 아래 로직이 실행됩니다.
return new UserProfile(user.getName(), user.getEmail());
}
}
이 방식은 코드가 순차적으로 실행되므로 직관적이고 디버깅이 쉽다는 장점이 있습니다. 하지만 I/O 작업이 길어지면 해당 스레드는 CPU 자원을 쓰지 않음에도 불구하고 무작정 대기해야 하므로 자원 낭비가 심해집니다.
📌 2. 동기 + 논블로킹 (Sync + Non-blocking)
이 방식은 약간 특이합니다. 호출되는 함수는 제어권을 바로 돌려주어 호출한 함수가 다른 일을 할 수 있게 하지만(논블로킹), 호출한 함수는 호출된 함수의 작업 완료 여부를 계속해서 확인해야 합니다(동기). 주로 폴링(Polling) 이라는 기법으로 구현됩니다.
- 동기: 호출한 함수가 결과에 계속 관심을 가지고 주기적으로 완료되었는지 물어봅니다.
- 논블로킹: 호출된 함수는 아직 완료되지 않았다는 상태 값과 함께 제어권을 즉시 돌려주어, 호출한 스레드가 멈추지 않게 합니다.
import java.util.concurrent.Future;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SyncNonBlockingExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
// 논블로킹 방식으로 작업을 던집니다. 즉시 Future 객체를 반환하고 제어권을 돌려받습니다.
Future<String> futureResult = executor.submit(() -> {
Thread.sleep(2000); // 2초가 걸리는 무거운 작업이라고 가정합니다.
return "작업 완료 데이터";
});
// 동기 방식이므로 작업이 끝났는지 계속 확인해야 합니다. (폴링)
while (!futureResult.isDone()) {
System.out.println("아직 안 끝났나요? 다른 짤막한 일 처리 중...");
Thread.sleep(500); // 0.5초마다 확인합니다.
}
// isDone()이 true가 되면 결과를 가져옵니다.
String result = futureResult.get();
System.out.println("최종 결과: " + result);
executor.shutdown();
}
}
제어권을 돌려받아 스레드가 차단되지 않는다는 장점이 있지만, 지속적으로 상태를 확인(Polling)하는 과정에서 CPU 리소스가 소모된다는 단점이 있습니다.
📌 3. 비동기 + 논블로킹 (Async + Non-blocking)
대규모 트래픽 처리를 위한 백엔드 아키텍처의 핵심이라고 할 수 있습니다. 네티(Netty) 기반의 스프링 웹플럭스, Node.js, 그리고 비동기 이벤트 스트리밍인 카프카(Kafka) 등에서 이 패턴을 적극적으로 사용합니다.
- 비동기: 호출한 함수는 작업의 결과를 기다리지 않고, 작업이 완료되었을 때 실행할 콜백(Callback)이나 이벤트 핸들러를 지정해 둡니다.
- 논블로킹: 호출된 함수는 제어권을 즉시 돌려줍니다. 메인 스레드는 블로킹되지 않고 즉시 다음 코드를 실행하며, I/O 작업은 백그라운드나 OS 레벨에서 처리됩니다.
이 방식을 사용하면 단 몇 개의 스레드만으로도 수만 개의 동시 연결을 처리할 수 있습니다. 스레드가 대기 상태에 빠지지 않고 끊임없이 일하기 때문입니다.
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class AsyncNonBlockingExample {
public static void main(String[] args) {
// 비동기/논블로킹 방식의 HTTP 클라이언트인 WebClient를 사용합니다.
WebClient webClient = WebClient.create("https://api.example.com");
System.out.println("1. API 호출 시작");
// 논블로킹: get().retrieve() 호출 시 스레드는 대기하지 않고 즉시 Mono(퍼블리셔)를 반환합니다.
Mono<String> responseMono = webClient.get()
.uri("/data")
.retrieve()
.bodyToMono(String.class);
// 비동기: 결과가 왔을 때 무엇을 할지 콜백(subscribe)만 지정해 둡니다.
responseMono.subscribe(
result -> System.out.println("3. API 응답 도착! 결과 처리: " + result),
error -> System.err.println("에러 발생: " + error.getMessage())
);
System.out.println("2. 메인 스레드는 멈추지 않고 바로 다음 로직을 실행합니다.");
// 출력 순서는 1 -> 2 -> 3 이 됩니다. 메인 스레드는 네트워크 I/O를 전혀 기다리지 않습니다.
// 예제를 위해 메인 스레드가 종료되지 않도록 잠시 대기합니다.
try { Thread.sleep(3000); } catch (InterruptedException e) {}
}
}
성능과 자원 효율성은 극대화되지만, 코드의 실행 순서가 보장되지 않고 디버깅이 어려워진다는 단점이 있습니다. 개발자의 높은 수준의 설계 능력이 요구되는 영역입니다.
📌 4. 비동기 + 블로킹 (Async + Blocking)
이론적으로는 존재하지만, 실무에서는 거의 쓰이지 않는 비효율적인 안티 패턴입니다. 대게 개발자의 실수나 오래된 데이터베이스 드라이버의 한계로 인해 의도치 않게 발생합니다.
- 비동기: 작업이 끝나면 콜백이나 이벤트를 통해 결과를 받으려 합니다.
- 블로킹: 하지만 호출된 작업 자체가 제어권을 쥐고 놓지 않아, 결국 호출한 스레드가 멈춰버립니다.
앞선 '시행착오' 목차에서 보았던, 스프링의 Async 어노테이션 안에서 동기/블로킹 방식의 JDBC 쿼리를 실행하는 예제가 이 패턴으로 변질된 사례로 볼 수 있습니다. 특별한 이유가 없다면 반드시 피해야 할 아키텍처입니다.
📝 마무리: 3줄 요약
- 동기/비동기는 작업의 완료 결과를 기다리고 신경 쓰는지, 아니면 콜백을 맡기고 관심을 끄는지에 대한 결과 처리 관점입니다.
- 블로킹/논블로킹은 호출된 작업이 호출한 쪽의 스레드를 멈추게 하는지, 아니면 즉시 제어권을 넘겨주어 다른 일을 할 수 있게 하는지에 대한 제어권 관점입니다.
- 대규모 트래픽 아키텍처에서는 무늬만 비동기인 코드를 주의해야 하며, 스레드의 대기 시간을 최소화하는 비동기 + 논블로킹 조합으로 시스템을 설계하는 것이 중요합니다.
'Java' 카테고리의 다른 글
| JVM Heap과 Stack의 상호작용 및 생명주기 관리 (1) | 2026.03.11 |
|---|---|
| Java 뮤텍스와 세마포어 실전 활용 (0) | 2026.03.11 |
| Netty 이벤트 루프와 Non-Blocking I/O 동작 원리 가이드 (0) | 2026.03.10 |
| Call by Value vs Call by Reference (0) | 2026.03.09 |
| CAS 알고리즘으로 이해하는 ConcurrentHashMap의 내부 동작 원리 (0) | 2026.02.24 |
댓글