스레드와 비동기 구조 정리

seongwop·2026년 6월 1일

Spring

목록 보기
21/21

서버 구조를 보면 싱글스레드, 멀티스레드, 동기, 비동기, 블로킹, 논블로킹이라는 말을 계속 만나게 된다. 이 개념들이 헷갈리는 이유는 서로 같은 기준의 단어가 아니기 때문이다.

싱글스레드와 멀티스레드는 작업을 실행하는 스레드가 몇 개인가의 문제다.
동기와 비동기는 작업 결과를 어떤 흐름으로 받는가의 문제다.
블로킹과 논블로킹은 현재 스레드가 멈추는가의 문제다.

정리하면 다음과 같다.

싱글스레드 / 멀티스레드
= 실행 흐름이 하나인가, 여러 개인가

동기 / 비동기
= 결과를 직접 기다리는가, 나중에 받는가

블로킹 / 논블로킹
= 현재 스레드가 멈추는가, 멈추지 않는가

1. 싱글스레드와 멀티스레드

싱글스레드는 하나의 스레드가 작업을 처리하는 구조다. 한 번에 하나의 실행 흐름만 존재하므로 공유 자원에 대한 동시성 문제가 적다. 락이나 컨텍스트 스위칭 비용도 상대적으로 적다. 대신 하나의 작업이 오래 걸리면 뒤의 작업이 기다려야 한다.

멀티스레드는 여러 스레드가 작업을 나누어 처리하는 구조다. 여러 요청을 동시에 처리할 수 있지만, 스레드가 많아질수록 컨텍스트 스위칭, 메모리 사용량, 락 경합 같은 비용이 생긴다.

즉, 싱글스레드와 멀티스레드는 “작업을 실행하는 흐름이 하나인가, 여러 개인가”를 구분하는 개념이다.

2. 동기와 비동기

동기는 작업을 요청한 뒤 결과가 나올 때까지 흐름상 기다리는 방식이다.

예를 들어 DB에서 사용자를 조회한 뒤 응답을 만든다고 하자.

User user = userRepository.findById(id);
return UserResponse.from(user);

이 경우 findById가 끝나야 다음 줄이 실행된다. 작업의 흐름이 위에서 아래로 이어지기 때문에 코드가 직관적이고 디버깅하기 쉽다. 대신 DB 조회나 외부 API 호출처럼 오래 걸리는 작업이 있으면 그동안 현재 스레드가 기다리게 된다.

비동기는 작업을 요청한 뒤 결과를 즉시 기다리지 않고, 결과가 준비되었을 때 나중에 이어서 처리하는 방식이다.

비동기 방식에서는 작업을 요청해두고, 완료 이벤트가 발생하거나 콜백이 호출되거나, Future, Promise, Mono, Flux 같은 객체를 통해 후속 처리를 등록한다.

// Spring Data R2DBC / WebFlux
Mono<UserResponse> response = userRepository.findById(id)
        .map(UserResponse::from);

이 코드는 결과를 바로 꺼내서 다음 줄로 넘기는 방식이 아니다. 사용자를 찾는 작업을 등록하고, 결과가 준비되면 map에 등록한 로직이 이어서 실행되는 방식이다.

이벤트 루프 기반 서버에서는 이런 흐름이 더 분명하게 보인다.

요청 수신
→ I/O 작업 등록
→ 스레드는 다른 이벤트 처리
→ I/O 완료 이벤트 발생
→ 등록된 콜백 또는 후속 로직 실행

즉, 비동기의 핵심은 “작업이 끝날 때까지 기다리는 것”이 아니라, “작업이 끝났을 때 실행할 로직을 미리 등록해두는 것”이다.

3. 동기/비동기와 블로킹/논블로킹 조합

동기/비동기와 블로킹/논블로킹은 다른 개념이므로 조합이 가능하다.

조합의미예시
동기 + 블로킹결과가 올 때까지 현재 스레드가 멈춘다Spring MVC + JDBC/JPA
동기 + 논블로킹결과는 직접 확인하지만 호출은 바로 반환된다non-blocking socket polling, tryLock 반복 확인
비동기 + 블로킹 (안티 패턴)호출자는 기다리지 않지만 작업 스레드는 멈춘다Async 내부에서 JDBC 호출
비동기 + 논블로킹호출자도 기다리지 않고 스레드도 멈추지 않는다WebFlux + Netty + R2DBC

동기 + 블로킹은 가장 익숙한 방식이다. 작업을 요청한 뒤 결과가 올 때까지 현재 스레드가 기다린다. Spring MVC에서 JDBC나 JPA로 DB를 조회하는 방식이 여기에 가깝다.

동기 + 논블로킹은 조금 생소하다. 결과를 직접 확인한다는 점에서는 동기적이지만, 확인하는 호출 자체는 바로 반환된다.

예를 들어 tryLock을 생각해볼 수 있다.

while (!lock.tryLock()) {
    // 락을 얻을 때까지 직접 확인
}

tryLock은 락을 얻지 못해도 스레드를 멈추지 않고 바로 반환한다. 그래서 논블로킹이다. 하지만 호출한 쪽이 락을 얻었는지 계속 직접 확인하므로 동기적이다.

non-blocking socket polling도 비슷하다.

read 호출
→ 아직 데이터 없음
→ 바로 반환
→ 다른 작업 수행
→ 다시 read 호출
→ 데이터가 있으면 처리

이 방식은 계속 확인해야 하므로 비효율적으로 보일 수 있다. 하지만 블로킹 방식처럼 스레드가 완전히 멈춰 있는 것보다는 효율적일 수 있다. 확인 중간중간 다른 작업을 처리할 수 있기 때문이다.

그렇다고 모든 상황을 비동기로 바꾸는 것이 항상 좋은 것도 아니다. 비동기로 만들려면 이벤트 리스너, 콜백, 상태 관리, 후속 작업 등록 같은 구조가 필요하다. 이 구조 자체도 복잡도와 자원 소모를 만든다.

따라서 작업이 단순하거나, 결과를 직접 확인하는 편이 명확하거나, 비동기 이벤트 구조를 도입하는 비용이 더 클 때는 동기 + 논블로킹 방식을 사용할 수 있다.

비동기 + 블로킹도 가능하다. 예를 들어 Async로 작업을 다른 스레드에 넘겼지만, 그 내부에서 JDBC를 호출하면 호출자는 기다리지 않지만 작업 스레드는 DB 응답을 기다리며 멈춘다. 사실상 안티패턴이다.

비동기 + 논블로킹은 WebFlux, Netty, R2DBC가 목표로 하는 구조에 가깝다. 호출자는 결과를 즉시 기다리지 않고, 스레드도 I/O 완료를 기다리며 멈추지 않는다. 결과가 준비되면 이벤트나 콜백, reactive chain을 통해 후속 로직이 실행된다.


4가지 서버 조합

1. 싱글스레드 + 동기

대표적인 예시는 Redis의 핵심 명령 실행이다.

Redis는 대부분 싱글스레드 구조로 동작한다. 하나의 프로세스가 multiplexing 기법으로 여러 클라이언트 요청을 받지만, 실제 요청은 단일 스레드에서 순차적으로 처리된다.

이 구조의 장점은 단순함이다. 여러 스레드가 동시에 같은 데이터를 수정하지 않으므로 락 비용이 적다. INCR 같은 명령이 원자적으로 처리될 수 있는 이유도 여기에 있다.

다만 느린 명령이 실행되면 뒤의 요청들이 기다린다. 느린 요청 하나가 다른 클라이언트 요청을 기다리게 만들 수 있다.

실제로 적용되는 기술과 작업은 다음과 같다.

기술실제 작업
Redis 명령 실행GET, SET, INCR, LPUSH, ZADD 같은 메모리 데이터 조작
Redis Lua Script여러 Redis 명령을 하나의 원자적 로직처럼 실행
Node.js 이벤트 루프 내부의 JS 실행 구간콜백 함수 내부의 일반 JavaScript 코드 실행

2. 싱글스레드 + 비동기

싱글스레드 + 비동기는 하나의 메인 스레드가 모든 I/O를 직접 기다리지 않고 이벤트 루프를 통해 처리하는 방식이다.

여러 소켓 등록
→ 이벤트 루프가 감시
→ 준비된 요청만 처리
→ 다시 이벤트 감시

Redis의 네트워크 I/O multiplexing이나 Node.js 이벤트 루프가 이 방식에 가깝다. Redis는 싱글스레드라고 하지만, 클라이언트 연결을 하나씩 블로킹 방식으로 기다리는 구조는 아니다. Redis 공식 문서는 Redis가 소켓 읽기/쓰기 같은 시스템 콜에서 블로킹되지 않도록 설계되었다고 설명한다.

이 구조는 연결 수가 많고 각 요청이 짧을 때 효율적이다. 스레드를 많이 만들지 않아도 많은 연결을 다룰 수 있다.

실제로 적용되는 기술과 작업은 다음과 같다.

기술실제 작업
Redis I/O multiplexing여러 클라이언트 소켓 연결 감시, 요청 읽기, 응답 쓰기
Node.js Event LoopHTTP 요청 수신, 타이머, 네트워크 I/O 이벤트 처리
Nginx worker 이벤트 루프다수의 클라이언트 연결을 이벤트 기반으로 처리

단점은 이벤트 루프에서 오래 걸리는 CPU 작업을 하면 전체가 막힌다는 점이다. 그래서 이벤트 루프에는 짧은 작업만 올리고, 오래 걸리는 작업은 별도 워커나 외부 시스템으로 넘기는 것이 좋다.

3. 멀티스레드 + 동기

대표적인 예시는 Spring MVC + Servlet + JDBC/JPA 구조다.

Spring MVC에서는 요청이 들어오면 Tomcat 같은 Servlet 컨테이너의 스레드 풀에서 스레드 하나가 할당된다. 그 스레드는 Controller, Service, Repository, DB 조회를 순서대로 처리한다.

요청 A → 스레드 1 → DB 대기 → 응답
요청 B → 스레드 2 → DB 대기 → 응답
요청 C → 스레드 3 → DB 대기 → 응답

Tomcat HTTP Connector에서 non-async 요청은 처리되는 동안 하나의 스레드를 필요로 한다. 또한 Tomcat의 maxThreads 기본값은 200이며, 이 값은 동시에 처리 가능한 요청 처리 스레드의 최대 개수와 관련된다.

Spring 공식 문서도 Spring MVC와 Servlet 애플리케이션은 현재 스레드가 블로킹될 수 있다고 가정하기 때문에, Servlet 컨테이너가 큰 스레드 풀을 사용한다고 설명한다.

이 방식의 장점은 코드가 쉽다는 것이다.

Controller
→ Service
→ Repository
→ DB
→ Response

흐름이 위에서 아래로 이어지기 때문에 디버깅, 에러 추적, 트랜잭션 관리가 비교적 쉽다.

단점은 DB나 외부 API 응답을 기다리는 동안에도 스레드가 점유된다는 점이다. 요청이 많고 대기 시간이 길어지면 스레드 풀이 고갈될 수 있다. 스레드를 더 늘릴 수는 있지만, 스레드가 많아질수록 컨텍스트 스위칭과 메모리 사용량도 증가한다.

실제로 적용되는 기술과 작업은 다음과 같다.

기술실제 작업
Spring MVC + Tomcat요청마다 스레드를 할당해 Controller, Service, Repository 흐름 처리
JDBC/JPA/HibernateDB 쿼리 실행 후 결과가 올 때까지 요청 스레드 대기
전통적인 REST API 서버회원 조회, 주문 생성, 게시글 작성 같은 CRUD 처리
Java ExecutorService블로킹 파일 처리, 배치 작업, 외부 API 동기 호출

4. 멀티스레드 + 비동기

대표적인 예시는 Spring WebFlux + Reactor Netty다.

WebFlux는 요청마다 스레드 하나를 끝까지 붙잡아두지 않는다. 이벤트 루프 스레드가 요청을 받고, I/O 작업을 등록한 뒤, 결과가 준비되면 후속 로직을 이어서 실행한다.

요청 수신
→ 논블로킹 I/O 등록
→ 스레드 반환
→ 완료 이벤트 발생
→ 후속 로직 실행

Spring 공식 문서는 WebFlux 같은 non-blocking 서버가 애플리케이션이 블로킹하지 않는다고 가정하고, 작은 고정 크기의 이벤트 루프 워커 스레드로 요청을 처리한다고 설명한다. 또한 reactive와 non-blocking의 핵심 기대 효과는 작은 수의 스레드와 적은 메모리로 확장하는 것이라고 설명한다.

Reactor Netty는 기본적으로 Event Loop Group을 사용하고, worker thread 수는 런타임에서 사용 가능한 프로세서 수를 기준으로 하되 최소 4개를 사용한다.

즉, 비동기 논블로킹의 철학은 이것이다.

최소한의 스레드로 최대한 많은 요청을 처리하자

예를 들어 외부 API 3개를 호출해야 한다면, 동기 방식에서는 순서대로 기다릴 수 있다.

회원 API 500ms
+ 주문 API 700ms
+ 배송 API 600ms
= 총 1800ms

비동기 방식에서는 동시에 요청을 보내고 결과를 조합할 수 있다.

Mono<User> user = userClient.getUser(userId);
Mono<Order> order = orderClient.getOrder(orderId);
Mono<Delivery> delivery = deliveryClient.getDelivery(orderId);

return Mono.zip(user, order, delivery)
        .map(tuple -> makeResponse(tuple));

이 경우 전체 응답 시간은 세 작업의 합이 아니라 가장 오래 걸리는 작업에 가까워질 수 있다.

실제로 적용되는 기술과 작업은 다음과 같다.

기술실제 작업
Spring WebFlux + Reactor Netty논블로킹 HTTP 요청 처리, API Gateway, SSE, WebSocket
WebClient여러 외부 API를 동시에 호출하고 결과 조합
R2DBCDB 쿼리를 논블로킹 방식으로 요청하고 reactive stream으로 결과 처리
Netty여러 EventLoop 스레드가 Channel I/O 이벤트 처리

비동기에서도 스레드를 많이 쓰면 되는 것 아닐까?

비동기 논블로킹의 목적은 스레드를 많이 늘리는 것이 아니다. 핵심은 기다리는 동안 스레드를 점유하지 않는 것이다.

스레드를 많이 늘리면 다음 비용이 생긴다.

스레드 스택 메모리 증가
컨텍스트 스위칭 증가
스케줄링 비용 증가
락 경합 증가
장애 상황에서 스레드 풀 고갈 가능성 증가

멀티스레드 + 동기 방식은 요청이 많아질수록 대기 중인 스레드도 많아지기 쉽다. 반면 비동기 논블로킹 방식은 대기 중인 작업을 이벤트로 관리하고, 실제로 처리할 준비가 된 작업에만 스레드를 사용한다.

그래서 비동기의 핵심은 “스레드를 많이 쓰자”가 아니라 “스레드를 놀리지 말자”에 가깝다.

그런데 왜 WebFlux보다 Spring MVC가 더 많이 쓰일까?

WebFlux가 더 최신 기술이라고 해서 항상 더 좋은 선택은 아니다.

블로킹 라이브러리를 사용해야 할 때는 별도 스레드로 처리할 수는 있지만, 블로킹 API는 WebFlux의 concurrency model에 잘 맞지 않는다.

Spring MVC가 여전히 많이 쓰이는 이유는 명확하다.

첫째, 코드 흐름이 단순하다.

요청 받기
→ DB 조회
→ 비즈니스 로직 처리
→ 응답 반환

둘째, JDBC/JPA 생태계가 매우 성숙하다. 대부분의 Java 백엔드 서비스는 DB 중심이고, JPA, Hibernate, QueryDSL 같은 도구는 MVC 구조와 잘 맞는다.

셋째, 에러 트레이싱과 디버깅이 쉽다. WebFlux는 Mono, Flux, operator chain, scheduler 전환이 섞이면 에러가 어디서 발생했는지 추적하기 어려워질 수 있다.

넷째, WebFlux를 쓰더라도 내부에서 JDBC/JPA를 그대로 호출하면 결국 블로킹이 발생한다. 이 경우 이벤트 루프를 막지 않기 위해 별도 스레드로 우회해야 하고, 그러면 non-blocking 구조의 장점이 줄어든다.

따라서 일반적인 CRUD, 관리자 페이지, 트랜잭션 중심 서비스는 Spring MVC가 더 단순하고 적합한 경우가 많다. WebFlux는 외부 API 호출이 많거나, 스트리밍, WebSocket, 대량 I/O 처리가 많은 경우에 장점이 커진다.


일반적인 CRUD와 트랜잭션 중심 서비스라면 Spring MVC가 충분히 좋은 선택이다.
외부 API 호출, 스트리밍, WebSocket, 대량 I/O 중심 서비스라면 WebFlux가 유리할 수 있다.
Redis처럼 짧고 빠른 메모리 연산은 싱글스레드 구조만으로도 매우 효율적일 수 있다.

핵심은 “무조건 멀티스레드가 빠르다”도 아니고, “무조건 비동기가 좋다”도 아니다. 작업의 성격에 맞게 스레드 모델과 I/O 처리 방식을 선택하는 것이 중요하다.

0개의 댓글