Dev/Etc

blocking & non-blocking

kimyeonjae 2026. 1. 31. 02:52

들어가기전 용어 정리

- 동기 (Synchronous) : 작업 완료를 기다린 후 다음 작업 수행

- 비동기 (Ayynchronous) : 작업 완료를 기다리지 않고 다음 작업 수행



- 블로킹 (Blocking) : 스레드가 I/O 완료까지 멈춰서 대기

- 논블로킹 (Non-blocking) : 스레드가 I/O 요청 후 즉시 다른 일 수행

Spring MVC + RestTemplate

최근 진행한 프로젝트에서 사용한 전통적인 방식입니다.
Spring MVC는 서블릿 기반으로 하나의 요청에 하나의 스레드를 할당합니다. DB 조회나 API 호출 같은 I/O 작업이 필요하다면 해당 작업이 끝날 때까지 해당 스레드는 Blocking 상태로 대기합니다.

즉 Tomcat(서블릿 컨테이너)가 Thread Pool을 여러개 가지고 있고, 이를 요청마다 하나씩 할당 해줍니다.
전체적인 과정은 아래와 같습니다

1. 요청 
2. 스레드 할당 
3. Controller 
4. Service 
5. DB 나 외부 API 호출등 작업(현재 스레드 블로킹) 
6. 응답 받음(블로킹 해제)
7. 응답 전송
8. 현재 스레드 반환

I/O 대기중에 스레드가 그냥 대기하고 있습니다.
또한 동시 처리량이 스레드 풀 크기에 제한되기에 만약 스레드 풀을 넘는 동시요청이 들어오면 앞의 요청을 처리하고 스레드가 반환될 때 까지 대기해야합니다.

현재의 고민

최근 프로젝트에 Google API 호출하는 부분이 여러개 있습니다.
Google Geocoding API, Google Place API등 외부 API를 이용하여 장소명 및 위치 데이터를 검색하고 받아오거나, 장소 이름 자동 완성 기능을 사용합니다.
또한 서버에서 처리하는데 시간이 걸리는 작업들 HEIC -> JPG 변환, 썸네일 생성, 로컬이나 S3에 파일 업로드등이 있습니다.

사실 성능 테스트를 진행하며 목표한 성능까지는 달성하였습니다만, 외부 API를 쓸꺼면 비동기로 처리하는게 효과적이라는 얘기에 조금 더 욕심을 내서 적용해보려했습니다.

비동기로 전환하는 방법

MVC에서 적용

MVC에서도 제한적으로 비동기를 사용할 수는 있습니다.

  • @Async : 이게 붙은 메서드는 별도의 스레드에서 실행되며, 호출한 스레드는 블로킹 되지 않음 (같은 클래스 내에서 호출되면 비동기적으로 실행되지 않을 수 도 있다고 합니다)
  • Callable 이나 DeferredReuslt 사용 : Tomcat 스레드는 즉시 풀에 반환되고, 외부 API 응답이 오게되면 다른 스레드가 응답을 완료함

WebFlux

아니면 Spring WebFlux를 사용하여 비동기적으로 구현할 수 있습니다.
구조는 Netty(Non-blocking Server) 안에 Event Loop가 기본적으로 cpu 코어 수만큼 생성되어 있습니다.
이 Event Loop가 이벤트(요청)를 감지하게되면, I/O는 맡겨두고 다음 이벤트를 처리하러 갑니다.
I/O응답이 준비가 되면 다시 이벤트로 넣고, 응답을 전송하게 됩니다.


1.  요청
2.  EventLoop가 이벤트 감지
3.  Handler가 우선 Mono / Flux만 반환
4.  I/O는 처리하는 애한테 맡겨두고 다음 이벤트 처리하러 감
5.  결과가 준비되면 다시 이벤트 호출
6.  응답 전송

추후 계획

처음에는 외부 API를 별도의 서버로 빼서 처리하는게 더 나은가 생각했으나, 아직까지는 서비스가 예상한 스트레스 환경에서 목표한 성능을 나타내고 있습니다.

우선 현재 프로젝트 구조 안에서 @Async를 활용하면 현재 서비스 환경에서는 오히려 더 좋은 성능 RPS를 나타낼 수 있을 것이라 예상합니다.

추후 서비스가 더 커지게 된다면, 메세지 큐 기반 비동기 처리를 하거나, 외부 API를 마이크로서비스로 분리하여 Netty에 WebFlux 구조 적용한 서비스를 따로 운영하는 것이 좋다 생각합니다.

노트

https://oneny.tistory.com/37
여기 블로그 글을 보고 위 개념 학습에 도움이 되어 여기서 간단하게 요약 정리

I/O가 처리되는 과정

1. 사용자 프로세스는 현재 열고 있는 파일의 파일 디스크립터를 통해 blocking 함수인 read()를 호출 한다.
2. 커널 내부의 시스템 콜 로드에서 정확성을 위해 파라미터들을 체크한다
    1.  입력값인 경우 해당 데이터가 이미 버퍼 캐시에  존재하면, 그 데이터는 사용자 프로세스로 반환되고 I/O 요청은 완료
    2. 없으면 물리적 I/O가 수행되어야 한다. 시스템 콜을 요청한 사용자 프로세스는 실행 큐(run queue)에서 삭제되고 대기 큐(wait queue)에 추가된다.
3. 디바이스 하드웨어 에서 처리 과정이 완료 된 후, 커널은 디바이스 드라이버로부터 전달받은 데이터를 전송하고 사용자 프로세스의 주소 공간에 코드를 반환한다. 그리고나서 사용자 프로세스는 대기 큐(wait queue)에서 준비 큐(ready queue)로 이동하게 된다.
4. 사용자 프로세스를 준비 큐(ready queue)로 이동시키는 작업은 해당 사용자 프로세스의 blocking 상태를 풀겠다는 의미이다. 스케줄러는 사용자 프로세스를 다시 CPU에 할당되고, 시스템 콜이 완료되는 시점에 사용자 프로세스는 실행 상태로 재개된다.

socket blocking I/O

socket마다 send_buffer와 recv_buffer 두 개의 buffer가 존재한다.

socket에서는 blocking I/O가 read system call일 때와 write system call일 때 각각 recv_buffer와 send_buffer의 상태가 block이 된다.

socket non-blocking I/O

socket에서 받는 측이면, 데이터가 아직 없는 경우 이전에는 read system call을 호출 한 스레드가 block되었지만, non-blocking 인 경우에는 recv_buffer에 데이터가 없다면 그냥 없다고 알려주고, read system call에 대한 호출을 바로 종료한다.

마찬가지로 보내는 측의 socket이 write system call을 할 때도 만약에 send_buffer에 데이터가 다차있어 더 이상 write를 할 수 없다면 이 write system call을 호출한 스레드를 블락시키지 않고, 적절한 에러코드와 함께 write system call이 반환되도록 한다

non-blocking I/O 결과 처리 방식

1. 완료 되었는지 read non-blocking system call을 반복적으로 호출
    - 완료 되었는지 반복적으로 확인하는 것은 CPU 낭비 발생
    - time gap 또한 발생
2. I/O multiplexing(다중 입출력)
    - 관심있는 I/O 작업들을 동시에 모니터링하고 그 중에 완료된 I/O 작업들을 한 번에 알려주는 방식
3. callback/signal 사용
    - 잘 안쓰임

잘못된 부분이 있다면 알려주시면 수정하겠습니다!


Reference

https://bubobubo003.tistory.com/85
https://oneny.tistory.com/37