논 블록킹 처리 : 스레드 사용 효율 개선 경험과 주의할 점 본문
처리량 개선이 필요하다.
회사가 성장하며 기기 이벤트가 크게 늘었다. 8개월 전까지만 해도 초당 1500건이었던 기기 이벤트가, 이제는 3500건을 넘는다. 한 달 동안의 이벤트 수 변화량만 보더라도, 그 수위가 점점 높아지는 것을 볼 수 있다. 기존 인프라와 로직으로는 늘어난 유입량을 못 따라가고, 곧 지연과 유실로 이어진다.
이벤트 컨슘부터 Ack까지의 흐름 안에는 다양한 로직들이 있다. 그래서 '어떤 구간이 문제다!'라기보다는, 주변 영향을 최소화하면서도 효과적인 개선 방향을 고민한다. 물론 어떤 경우에는 단순히 인프라만 늘리면 해결되는 경우도 많다. 프로세스를 더 띄우고, 스레드 수를 늘리는 게 제일 빠를 수도 있겠지만, 최대한 주어진 자원 안에서 개선하는 방법을 고민하는 게 요즘 우리 팀의 방향이다.
지난번엔 캐시 적용이었고, 이번에는 리액티브 프로그래밍이다. 반응형 라이브러리를 사용한 논-블록킹으로 I/O 대기를 피하여, DB 적재를 처리하는 로직의 처리량을 개선하거나 외부 API 호출에 사용하는 스레드 개수를 줄인 경험을 소개한다.

리액티브 프로그래밍과 논 블록킹
리액티브 프로그래밍은 데이터 변화나 이벤트 처리에 반응하며 로직을 전개하는 꼴을 말한다. 예를 들어 DB 저장이나 외부 API 호출을 하는 상황에서 요청 후 처리 완료 이벤트가 도착했을 때, 이에 반응해 그 결과를 처리하는 식으로 로직을 진행한다. 이를 비동기로 한다면, 요청 후 이후 흐름은 이어가고, 처리 완료 후 결과를 처리하는 식으로 I/O 대기를 피할 수 있게 되는 것이다.
이때 반응형 프로그래밍(리액티브 프로그래밍)과 논 블록킹을 구분해야겠다. 반응형은 이벤트 변화에 반응하여 로직을 전개해 나가는 코딩 패러다임이지, 논-블록킹을 사용해야 하는 것은 아닌 것도 같다. 단지 자바/Spring 진영에서 사용하는 반응형 프로그래밍이 논-블록킹과 함께 쓰이는 경우가 많아 익숙해진 것은 아닌지 생각해본다.
I/O 멀티 플렉싱
처음 이 '대기 없이 이어감'을 들었을 때는, 새로운 스레드를 사용하는 것 외에는 방법이 떠오르지 않았다. 여러 방식이 있겠지만, 이번 개선에서 사용한 Reactive MongoDB나 WebClient의 방식은 멀티플렉싱에 답이 있었다. WebClient는 Netty, Reactive MongoDB는 MongoDB 드라이버 위에서 동작하고, 이들은 커널 단의 I/O 멀티플렉싱 커멘드를 사용하여 논 블록킹을 구현한다. Api 요청, DB 쿼리 등 응답에 대기가 필요한 작업을 전달하고, 처리 완료 이벤트를 미리 만들어둔 이벤트 루프 스레드를 사용하여 처리한다.
플랫폼 비종속적인 Java이기에, 코드 단에서는 어떤 멀티플랙싱 커멘드를 사용할 수 있는지 모른다는 점도 재밌다. Netty의 경우 코드 단에서 운영체제 정보와 사용할 수 있는 멀티플렉싱 커멘드를 찾는 코드가 포함되어 있다. 그 분기 로직은 바이트 코드로 컴파일되고, JVM 어셈블러/JIT에 의해 바이너리 코드로 변환될 때까지 결정되지 못한다. 코드가 실행되면서, OS 정보와 사용할 수 있는 멀티플렉싱 커멘드를 확인하고, 이를 포함한 네이티브 함수가 JNI에 의해 수행되며 실제 시스템 콜이 발생하게 된다.
만약 사용할 수 있는 최적의 커멘드(ex, Linux의 epoll, Mac의 kqueue)를 찾지 못하는 경우, Fallback으로 Java NIO 라이브러리를 사용한다. NIO는 poll/select처럼 유닉스 계열에선 범용적인, 그렇지만 epoll, kqueue 보다는 성능이 부족한, 멀티플렉싱 커멘드를 사용하는 네이티브 함수를 호출한다.

워커 스레드 고갈에 주의
이런 반응형 프로그래밍과 논 블록킹 조합이, '스레드를 전혀 사용하지 않는다'는 것은 아니다. Spring 생태계에서 Netty를 이용한 논-블록킹은, I/O 요청이 후 완료까지 스레드 대기가 불필요하다는 것이지, 완료 후 I/O 이벤트가 발생했을 때부터의 후처리는 마찬가지로 스레드가 필요하다. 이때 Netty는 EventLoopGroup 스레드 풀이 사용된다.
그렇기에 이 스레드 풀을 사용하는 후처리가 길어지면, 스레드 풀이 마르는 현상이 발생할 수 있다. 특히 Netty의 기본 스레드 풀 사이즈는 코어 수 * 2로, 톰캣의 기본 수나 보통 애플리케이션에서 설정하는 스레드 풀 수보다 작기 때문에, 이를 고려하지 않은 리액티브 프로그래밍은 오히려 병목을 만들 수 있다. ( Github - Netty, EventLoopGroup의 스레드 수 결정 방법 )
public abstract class MultithreadEventLoopGroup extends MultithreadEventExecutorGroup implements EventLoopGroup {
private static final int DEFAULT_EVENT_LOOP_THREADS;
static {
DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt(
"io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
}
}
그래서 아래와 같은 꼴은 위험하다. 논 블록킹이기에 100개의 로그가 한번에 찍히고, 3초 후에 100개의 요청이 한번에 전달될 것 같지만 사실은 그렇지 않다. RestTemplate의 응답 대기가 워커 스레드 풀 고갈을 만들기 때문이다. 당장 워커 스레드 풀에 스레드가 10개라면, 3초 후 10개의 요청이 수행되고, 그 응답을 받은 이후에나 다음 요청을 수행할 스레드가 시작된다. 그렇기에 100번째 요청은 3초 타이머가 한참 지난 뒤에나 전달되게 될 것이다.
for(int i =0; i< 100; i++) {
Mono.delay(Duration.ofSeconds(3))
.doOnNext(l -> restTemplate.get("https://google.com"))
.subscribe();
print(i);
}
의도대로 하려면 WebClient를 써서 응답 대기를 피해야 한다. 아래처럼 작성하면, 3초 후 Mono.delay의 워커 스레드는 WebClient 요청을 수행하고 응답 대기 없이 반환되기에, 바로 다음 요청을 수행할 스레드가 시작된다. 워커 스레드 풀이 고갈되지 않기에, 다른 대기없이 100개의 요청을 전달할 수 있다. 대신 WebClient의 커넥션 풀이 고갈될 수 있으니, 무한한 요청을 동시에 보낼 수 있다고 생각해선 안된다.
for(int i =0; i< 100; i++) {
Mono.delay(Duration.ofSeconds(3))
.flatMap(l -> webClient.get().uri("https://google.com"))
.subscribe();
print(i);
}
결국 하고 싶은 말은, 논 블록킹으로 처리하는 후처리가 동기 작업으로 길어지면 워커 스레드 풀이 고갈될 수 있으니, 가급적 그 처리 역시 논 블록킹으로 이어가거나 최대한 짧게 해야 하다는 것이다.
블록킹 작업이 뒤따라야 하는 경우
그럼 이런 논 블록킹 작업에 대한 체인으로, 동기처리가 필수적인 경우는 어떻게 처리하면 좋을까. 예를 들어 3초 후 요청을 보내고, 응답을 받으면, 이를 블록킹으로 DB에 넣어야 할 때를 요구 사항으로 해보자.
그런 경우에는 블록킹 작업과 논 블록킹 작업의 스레드 풀을 분리하는 것이 좋다. 논 블록킹 동작은 적은 수의 스레드를 효율적으로 쓸 수 있는 스레드 풀을 사용하고, 블록킹 동작은 스레드가 부족하면 새로 만들어 사용하거나 사용되지 않는 스레드는 정리할 수 있는 스레드 풀을 사용하는 것이다. 전자의 개념을 갖고 있는 스레드 풀을 Parallel, 후자의 개념으로 사용되는 스레드 풀을 bounded elastic이라고 한다.
아래처럼 publishOn()을 사용하면, 체인에 포함된 블록킹 동작을 bounded elastic 스레드 풀로 넘길 수 있다. Mono.delay로 대기없이 3초를 기다리고, parallel 워커 스레드에서 WebClient를 논 블록킹 요청, 분리된 스레드 풀(bounded elastic)을 사용하여 parallel 워커 스레드 풀을 침범하지 않고, 동기식 작업을 수행하게 된다.
Mono.delay(Duration.ofSeconds(3))
.flatMap(l -> webClient.get().uri("https://google.com"))
.publishOn(Schedulers.boundedElastic())
.map(res -> resHistoryRepository.save(res))
.subscribe();
그렇다고 bounded elastic 스레드 풀이 기본 워커 스레드 풀과 다른 형태의 스레드를 갖는 건 아니다. 그보다는 빠른 후처리에 사용할 스레드 풀과 느린 후처리에 사용될 스레드 풀을 분리해서, 빠른 후처리의 병목을 피하는 것이 첫 번째 의의이고, 각각의 사용처에 맞게 스레드 풀이 스레드를 할당하고, 생성하고, 제거하는 규칙을 다르게 한다는 것이 두 번째 의의이다.
Spring MVC와 함께 사용할 때
이런 리액티브 프로그래밍을 서버 애플리케이션 전체에 적용하면, 웹 요청도 논 블로킹 방식으로 처리할 수 있게 된다. Spring 생태계에서는 WebFlux가 대표적이다. 기존의 Spring MVC는 톰캣을 기반으로 요청마다 하나의 스레드를 할당하지만, Netty 기반의 WebFlux는 이벤트 루프를 이용해 스레드 대기를 최소화하고, 동시에 더 많은 요청을 처리할 수 있다.
그렇다고 WebFlux가 언제나 정답은 아닌 것 같다. 리액티브 프로그래밍은 익숙한 블록킹 방식보다 코드 작성이 어렵고, 기존 방식에서의 전환도 쉽지 않다. 또 그렇다고 트래픽이 많지 않은 서비스가 아니라면 기존 블록킹 방식과의 처리량 차이도 미미하다. 요청 수가 많아질수록 WebFlux와 MVC의 격차가 커진다.
그렇기에 기존 Spring MVC로 요청당 스레드 사용을 유지하면서, Reactive MongoDB나 WebClient와 같은 리액티브 라이브러리를 사용하는 것도 방법이다. 하지만 이 경우에는 전체 요청 처리 흐름에서 완전한 논-블록킹 효과를 얻을 수 없다. 아래와 같은 컨트롤러라고 하면, WebFlux에서는 Mono가 구독되고 요청 스레드가 대기 없이 먼저 반환된 후에, 외부 요청에 대한 응답 이벤트가 도착하면, 이를 이벤트 루프의 스레드가 후처리 하여 응답하는 꼴이다.
반면, Spring MVC에서는 컨트롤러가 Mono <String> 같은 리액티브 타입을 반환해도, 컨트롤러가 리턴하는 시점까지 톰캣 스레드는 반환되지 않는다. 즉, 사용자 요청 처리 흐름 안에서 이벤트 루프로 논-블록킹으로 처리한다고 해도, 결국 사용자 요청 처리 스레드는 그 논-블록킹 처리 완료를 대기해야 하는 것이다.
@GetMapping("/example")
public Mono<String> callApi() {
return webClient.get()
.uri("http://external-api.com")
.retrieve()
.bodyToMono(String.class);
}
처리 순서 주의
처리 완료를 기다리지 않은 흐름이기에, 반대로 순서 처리에 주의해야 한다. 네트워크 상태, 워커 스레드 풀 상태에 따라 먼저 수신한 메시지가 그 후에 처리한 메시지보다 늦게 처리될 수도 있게 된다. 예를 들어 카프카를 통해 메시지를 수신하고 이를 DB에 저장하는 로직을 가정하자. 카프카 수신부터 DB 적재 요청, 카프카 ACK까지의 한 흐름을 처리하는 데는 빠르지만, 네트워크 이슈로 먼저 DB 적재가 요청던 이벤트 A보다, 그 후에 요청된 B가 더 빠르게 적재될 수 있는 것이다.
개선 결과 1. DB 저장 서비스, 처리량 47% 증가
이벤트 파이프라인에서 전달되는 이벤트는 여러 BE 서비스에서 본인의 역할에 맞게 처리된다. 그중 History 서비스는 기기의 상태 이벤트를 저장하거나, 그 내역을 조회할 수 있도록 한다. DB는 MongoDB를 사용하고 있고, 글을 쓰는 시점에서, 해당 서비스로 초당 2500개의 메시지가 전달되고 있다.
기존에는 DB에 Insert를 전달하고 대기하였다가, 저장을 마치면 다음 메시지를 수신하는 흐름이었다. Reactive MongoDB를 도입하여 DB에 Insert를 전달하고 대기 없이 바로 다음 메시지를 수신하는 꼴로 로직을 변경했다. 기기 상태 이력이야 조회하는 시점에서 정렬하니 저장 순서가 완벽히 보장되지 않아도 되었고, 이벤트 수신부터 Ack 전달까지의 사이클에 드는 시간을 최소화하는 것이 중요한 로직이었기에, 리액티브 도입에 적합할 것이라고 생각했다.

적용 결과는 매우 효과적이었다. 메시지 처리 사이클이 매우 짧아졌고, DB에 데이터 적재에도 지연이 없었다. MongoDB 쪽의 매트릭도 이상 없었다. 아래는 개선 전후의 처리량을 확인하기 위한 로그이다. 명확한 계산을 위해 테스트를 위한 컨슈머 스레드는 단일로 구성했다.
우선 개선 전의 처리량이다. 위는 카프카에 유입되는 이벤트 개수이고, 아래는 이를 수신하여 블록킹으로 DB에 적재한 개수이다. 유입 속도를 따라가지 못하고, 대략 80%의 처리량을 갖는 것을 볼 수 있다. 한 스레드에서 초당 약 1700개의 이벤트를 적재할 수 있었다.


이번엔 개선 후의 처리량이다. 위는 카프카로 유입되는 이벤트 개수, 아래는 이를 수신하여 논-블록킹으로 DB에 적재한 개수를 표현한다. 당연하게도 같은 시간대 유입 수와 처리 수가 완전히 동일한 값을 갖진 못하지만, 처리량 자체는 100%로 모든 메시지를 커버하는 것을 볼 수 있다.


해당 시점의 카프카 대시보드에도 특이사항은 없다. Lag에 쌓이는, 즉 지연이 발생한 경우는 없었고, 이벤트 유입과 수신 속도가 일치했다. 논-블록킹으로 전환 전에는 여러 파티션으로 처리하던 메시지량을, 전환 후에는 단일 파티션만으로 커버할 수 있었다.
초당 처리량이 1,700건에서 2,500건으로 약 47% 향상된 것을 확인할 수 있었다. 처리해야 할 이벤트 수가 더 많아질 수 록 개선 효과는 더욱 커질 것으로 예상된다. (일부러 데이터를 쌓고 실험했을 때, 초당 1만 5천 건이 처리되는 것을 확인할 수 있었다.)

개선 결과 2 - API 다중 호출, 단일 스레드로
기기의 상태를 조회하기 위해선 여러 Api를 호출하고 그 결과를 수집해야만 하는 제품이 있다. 이 경우 각 Api 호출을 순차적으로 처리하게 된다면 사용자 응답이 너무 늦기에, 멀티 스레드로 동시에 Api를 호출하고 그 결과를 조합한다. 지금까지는 그저 이전에 해오던 방식으로, 스레드 풀을 남발하며 응답 시간을 줄이고 있었다.
리액티브를 공부하고 도입하면서, 이를 스레드 풀 없이 단일 스레드로 처리할 수 있음이 떠올랐다. WebClient로 여러 요청을 논-블록킹으로 요청하고, 응답 결과만 블록킹으로 조합하면 된다면, 굳이 매 요청별로 스레드를 따로 사용하지 않을 수 있겠다는 것이었다.
리액티브 방식으로 전환 후, 예상대로 스레드 사용량이 줄었는지, 요청 처리량이 낮아지진 않았는지 테스트가 필요했다. 테스트 환경을 만들고, 해당 서비스의 현재 초당 유입량만큼의 부하테스트를 진행한다. 문제가 되었던 로직에서 사용하는 스레드 풀을 분리하여, 다른 처리에 스레드 풀이 오염되는 상황을 피한다.


위는 5분 동안 70개의 가상 사용자가 문제 Api에 동시 요청을 진행한 결과이다. 1초에 한번 스레드 풀의 활성 스레드 개수와 큐 개수를 집계하고 이를 출력했다. 5분을 마치고는 그 기간 동안 처리한 요청 수와 평균값을 출력한다.
전환 전에는 평균 287개의 활성 스레드가 필요했으나, 전환 후에는 요청 스레드 1개로만 처리되었다. 특히 지정된 스레드 풀 안에서 즉시 처리가 불가능하여 스레드 대기가 필요했던 멀티 스레드 방식에 비해, 전환 후에는 스레드 자원이 부족하여 대기하는 경우가 없었다. 그러면서도 기존 16500개의 요청 처리보다 많은 18000개를 처리할 수 있었기에, 혹여나 의심했던 요청 처리량이 정상인지에 대한 걱정도 피할 수 있었다.
그렇다고 WebClient로 무한히 많은 요청을 동시에 전달할 수 있는 것은 아니다.
WebClient 내부의 커넥션 풀 사이즈가 정해져 있고, 사용할 수 있는 FD, 즉 소켓 수도 한정적이다.
정리
작업 : 리액티브 프로그래밍과 논 블록킹 라이브러리 도입
처리 :
- Reactive MongoDB 도입하여, DB 처리 대기 시간 제거
- Api를 멀티스레드로 다중 호출하던 로직에서, WebClient를 사용한 단일 스레드 로직으로 전환
결과 :
- DB 저장 서비스의 처리량 개선, 초당 처리량이 1,700건에서 2,500건으로 약 47% 향상
- 스레드 사용 효율 증가, 전환 전 287개의 평균 활성 스레드가 필요했던 로직을 단일 스레드로 처리
'KimJinHwan > Project' 카테고리의 다른 글
| OOM 문제 해결 : 스레드 풀, 요청량이 처리량을 넘어설 때 (0) | 2025.10.09 |
|---|---|
| 프로젝트 경험 소개 : 레거시 서비스 인프라 개선 (0) | 2025.10.06 |
| 캐시 도입 : 이벤트 파이프라인 처리량 높이기 (1) | 2025.08.28 |
| 프로젝트 경험 소개 : 코어 플랫폼 확장 (1) | 2025.06.29 |
| 클라우드 비용 절감기 : 월 천만 원을 절약한 스케일 다운 (0) | 2025.04.05 |