이벤트 파이프라인 성능 개선 : 처리량 향상과 리소스 과부하 대비 본문
배경
나는 IoT 서비스를 운영하는 백엔드 개발자이다.
회사 기기는 삼성의 Smartthings, LG의 ThinQ, Naver Clova, KT, Kakao 등 국내 IoT 플랫폼 연동을 지원한다.
나는 플랫폼의 기기 제어 요청을 처리하고, 반대로 기기의 이벤트를 플랫폼으로 전달하는 중간 다리의 역할을 한다.
이벤트 파이프라인은 기기 상태 변화를 수신하고, 기기가 연동된 플랫폼으로 그 이벤트를 전달하는 역할을 한다.
화재 감지, 강제 문 열림 알림, 동작 감지 등, 이벤트는 단순히 앱 상 UI 변경만이 아닌, 사용자 안전 문제와 직결된다.
그렇기에 지연 시간과 유실률은 서비스 신뢰도 문제이며, 안정적인 리소스 관리, 꼼꼼한 처리 증적 관리가 특히 더 중요했다.
처리량 증가
서비스가 성장하며 이벤트가 크게 늘었다.
2년 전까지만 해도 초당 2,000건이었던 기기 이벤트가, 이제는 초당 6,000건을 훌쩍 넘는다.
아래는 한 달 동안의 이벤트 수 변화이다.
그 수위가 점점 높아지는 것을 볼 수 있다.
기존 인프라와 로직으로는 늘어난 유입량을 못 따라갔고, 이는 금방 지연과 유실로 이어졌다.

병렬 처리
이벤트 수가 꾸준히 증가했지만, 파티션 수를 유연하게 늘리기 어려웠다.
또 많은 양의 이벤트 수신을 위해 CPU 성능이 중요했고, 인프라 비용이 많이 나가 컨슈머를 마구 늘릴 수도 없는 노릇이다.
처리 순서 역시 중요하기에 메시지를 단순히 병렬 처리하는 것은 방법이 되지 않았다.
한 파티션에 수많은 기기의 이벤트가 적재되었고, 기기별 순서 보장을 위해서 파티션별 순차 처리해야 하는 비효율에 집중했다.
배치 수신한 이벤트를 기기 ID로 묶고, 같은 기기의 이벤트끼리는 순차 처리, 서로 다른 기기의 이벤트끼리는 병렬 처리를 구현했다.
'파티션 단위의 단일 스레드 순차 처리 모델에서 벗어나, 애플리케이션 레벨에서의 병렬 처리를 구현했습니다.'
LLM이 멋진 표현으로 바꿔주었다.
기기별 이벤트 순서 보장은 보장하면서도, 초당 처리량을 기존 약 4,200개에서 18,000개까지 높일 수 있었다.
대신 '그렇게 늘어난 처리량을 기존 리소스로 버틸 수 있는가' 를 풀어야 했다.
단순히 비용을 들인 스케일 아웃으로, 언젠가는 만날 근본적인 문제를 넘기고 싶지 않았다.
쿼리, 인덱스 튜닝
처리량은 크게 올랐지만, 정작 플랫폼으로 전달되는 이벤트 단건의 처리 시간은 늘었다.
들쭉 날쭉한 DB 쿼리 시간과 CP Pending 수를 모니터링하여 DB 커넥션 풀의 커넥션 부족을 확인했다.
동시 처리를 늘린 만큼, DB 액세스가 늘어 CP가 부족해졌고 커넥션 대기에 시간이 필요했던 것이다.
커넥션 풀을 조정을 위해 처리량과 함께 DB 서버의 CPU 사용률과 커넥션 수를 모니터링해야 했다.
우리 팀의 경우엔 지금 다루는 애플리케이션의 감당 수준보다, 데이터베이스 자체의 가용률 체크가 더 중요했다.
커넥션을 오래 잡고 있는 경우는 없을까 락과 놓친 인덱스를 점검했고,
처리 후 해당 서비스에서 2ms 이상 걸리는 쿼리는 없도록 했다.
빈도는 줄었지만 그럼에도 커넥션 부족과 CP Pending을 완전히 피할 순 없었다.
캐시 처리
일부 기기의 이벤트는 처리를 위해 다른 서비스와의 통신이 필요했다.
당장 응답 시간만큼의 블록킹이 발생했고, 외부 서비스의 상태에 직접 의존되었다.
DB 쿼리와 API 요청으로 확인하는 데이터를 캐싱하기 시작했다.
Look Aside로 캐시 조회 후 DB를 확인했고,
데이터 변경 시엔 Evict Only로 캐시 업데이트 없이 무효화했다.
트랜잭션 커밋 후 캐시 업데이트 강제는 @TransactionalEventListener를 사용했다.
쓰기와 조회가 서로 다른 서비스에서 발생하기에, 데이터 변경 후 재조회되는 경우가 많지 않다.
또 조회 성능이 중요한 서비스이기 때문에, 캐시 업데이트 처리에 다른 조회 요청들이 받는 영향을 최소화하고 싶었다.
실제 조회가 발생했을 때 캐싱하여, 읽지 않을 가능성이 높은 데이터를 미리 캐싱하느라 레디스 시간과 공간을 뺏고 싶지 않았다.
캐시는 잘못 적용하면 오히려 직접 요청하는 것보다 비효율적이다.
이 서비스에선 히트율이 약 60% 일 때, 캐시를 적용하지 않을 때와 같은 처리량을 갖는 것을 테스트했다.
실제 운영에선 DB 쿼리에서 90%, API 요청에선 95% 이상의 히트율을 갖으며 캐시 적용에 의의를 확인할 수 있었다.
DB, API 블록킹 시간이 제거되어, 이벤트 단건 평균 처리 시간이 87.2% 감소했다. (86ms → 2ms)
또 그만큼 DB, 네트워크 커넥션 사용이 줄어 커넥션 풀 부족 문제가 해결되었다.
배치 처리
이벤트 종류에 따라 기록이 필요한 데이터도 존재했다.
예를 들어 센서 기기의 이벤트는 DB에 저장되어, 특정 기간 동안의 상태 이력 조회 기능에 사용된다.
처리량을 높이면서 이 데이터 삽입을 위한 병목도 늘었다.
처음엔 쿼리 응답 대기를 제거해 보자는 생각으로 Reactive MongoDB 도입, 논-블록킹 삽입으로 처리량을 개선했다.
대신 DB CPU 사용률이 증가해, 사용 중인 DB 인스턴스 타입을 늘려야 하는 경계에 도달했다.
이에 배치 삽입을 도입했다.
이벤트를 모아 Bulk insert를 사용하면 네트워크 통신 비용, DB의 연산량, 커넥션 점유 경합에 개선이 있을 것이라 생각했다.
DB에 저장해야 하는 이벤트를 즉시 삽입하지 않고, Thread-safe 한 큐에 저장한다.
일정한 시간 간격으로 큐를 비우고, 배치 사이즈만큼씩 bulk insert를 처리하는 꼴로 구현했다.
만약 배치 삽입이 실패하면 재시도가 아닌, DLT로 전달, 빠르게 알림 받고 삽입 실패 이유를 분석하는 방법으로 예외 처리하였다.
Full-managed DB를 사용하고 있어, 문제는 DB 보단 데이터 자체나 로직에 문제가 있는 경우일 확률이 높고,
빠른 재시도의 실시간성보다는 조금 늦게 적재되더라도, 유실에 안전한, 언젠가는 기록됨이 더 중요해서였다.
다른 로직들을 제거하고, 단순 이벤트 저장 흐름으로 처리량을 테스트했을 때,
초당 3.5천 건 → 2만 건으로 처리량 4배 이상 개선하면서도, 이전 Reactive 방식의 DB CPU 사용률 문제 안정화할 수 있었다.
버퍼와 백 프레셔
스케줄링 기능이 있는 서비스 특성상, 매 정각마다 일시적인 이벤트 급증이 발생한다.
일시적인 이벤트 몰림에 맞춰 리소스를 급히 늘린다면, 과부하와 리소스 부족으로 인한 서비스 장애로 전파될 수 있다.
카프카 메시지 수신 스레드 풀과 별개로, 메시지 처리를 위한 워커 스레드 풀을 구성했다.
스레드 수를 미리 지정하여, 처리할 스레드 개수를 제한하고 리소스 과부하를 막았다.
그렇다고 평소 스레드 수로 처리되지 못한 메시지를 쉽게 버리지 않는다.
스레드 풀의 대기열을 버퍼로 사용하여, 처리되지 못한 메시지를 담을 임시 공간을 만들었다.
이때 대기열 사이즈를 지정하지 않으면, 처리되지 못한 태스크가 쌓였을 때 메모리를 모두 차지, OOM으로 이어질 수 있다.
사이즈를 명확하게 하되, 가득 차면 메시지 수신 스레드에서 직접 메시지를 처리하여, 공간이 생길 때까지 다음 메시지 수신을 대기한다.
이렇게 처리량에 따라 메시지 수신 양을 수신처에서 조절하는 구조를 '백프레셔'라고 한다.
미리 설정한 수로 사용할 리소스를 제한하고, 대기열 상태에 따라 다음 메시지 수신을 조절할 수 있는 시스템을 만들 수 있었다.

외부 장애 대응
잠깐의 캐시 비정상 동작, 외부 API 응답 속도 저하에도 크게 영향을 받는다.
이에 꼼꼼한 예외처리, 장애 지속 판별과 회로 차단이 중요했다.
먼저 캐시 서버에 예외가 발생하면 DB를 직접 호출한다.
캐시에 반영하지 못한 더러워진 데이터는 따로 큐에 보관하여, 복구 시 캐시 무효화를 처리할 수 있도록 했다.
캐시 업데이트에 실패한 데이터가 일정 개수 이상이면, 관련 캐시 엔트리 전체를 제거하는 꼴로 구현했다.
서버 종료 시 큐에 남은 더러워진 데이터가 유실되지 않도록, Graceful shutdown으로 일정 시간 동안 캐시 업데이트를 반복 시도한다.
API 호출, 레디스 장애 상황에선 그 호출 자체도 비용이다.
특히 느린 응답의 반복은 전체 처리 지연으로 이어질 수 있다.
API 서버와 캐시 요청에 서킷 브레이커를 적용해서, 장애 지속을 판단하고 일정 시간 접근 시도를 피한다.
테스트를 반복하며 외부 API 응답 시간에 따른 리소스 변화, 처리량 변화를 미리 파악하였고, 장애 판단의 기준을 잡을 수 있었다.
테스트 환경 구성
외부 API 응답 속도에 따른 리소스 변화를 확인하기 위한 테스트 환경 구성이 필요했다.
외부 서비스를 Wiremock으로 대체, 서버 응답 속도와 값을 제어하여 상황별 리소스 변화와 장애 판단 기준을 만들 수 있었다.
실제 운영보다 많은 수의 기기 이벤트와 DB 로우를 가정하여 환경을 구성했다.
아래는 외부 API 응답 속도에 따른 변화를 테스트했을 때의 대시보드이다.
Scrap 시간 단위마다 Prometheus에 직접 요청하여 실시간 값을 반영하는 꼴로 만들었다.
그라파나 대시보드는 평소 전체 상태 확인을 위한다면, 이는 원하는 매트릭만 골라 값 변화를 명확하게 확인하기 좋았다.
아래는 동일한 DB CP, 네트워크 커넥션 풀, 워커 스레드 풀 설정에, 외부 API 속도만 변경했을 때이다.
200ms까지 단건 처리 시간이 약간 늘긴 했지만, 처리량이 줄거나 워커 스레드 풀에서 대기가 발생하는 경우가 없었다.
반면 250ms에서 워커 스레드 풀에서 대기(버퍼링)가 발생하면서 단건 처리 시간이 늘고, 처리량이 급격히 줄은 것을 볼 수 있다.
이렇게 외부 변수에 따른 처리량, 서버 리소스, 처리 속도 변화를 확인했다.
그 지표는 리소스를 조정하거나 회로 차단의 재난 기준을 설정하는 근거가 되었다.

모니터링 환경
실시간 리소스 값 확인과는 또 다른, 특정 기간 동안의 변화를 확인할 수 있는 방법 역시 필요했다.
이에 주요 매트릭을 정의하고, 이를 애플리케이션에서 계산, 수집할 수 있도록 개발하고 그라파나 대시보드를 재구성하였다.
아래는 구성한 그라파나 대시보드 중 처리량과 관련된 매트릭을 모은 그룹의 예시 이미지이다.
OpenTelemetry + Jaeger로 APM을 구성하여, 이벤트 처리 구간별 지연 시간을 확인했지만,
이벤트 양이 워낙 많아 Jaeger가 수집할 수 있는 샘플링 비율이 매우 적었다.
또 단 건의 구간별 병목보다는 전체 이벤트 처리를 진행하며 발생하는 리소스 변화, '튐'을 보는 것이 더 좋은 지표가 되었다.
기본 JVM 매트릭 만이 처리량, 유실률, 캐시 히트율과 API 요청과 응답 시간, 처리 지연 시간 등 파이프라인의 성능을 주로 보고 있다.
이 데이터들은 단순히 '애플리케이션이 정상임'을 넘어, 다음 개선 포인트를 확인할 수 있는 배경이 되었다.
특히 '해당 시간대엔 몇 건의 유실이 발생했고, 평균 Nms로 처리되었다.'를 보이는 외부 플랫폼과의 소통을 위한 증적으로도 활용된다.

정리
작업 : 이벤트 파이프라인 성능 개선
배경 :
- 이벤트 파이프라인은 기기 상태 변경 이벤트를 수신하여, 기기가 연동된 플랫폼 서버에 이를 전달
- 서비스가 성장하며 이벤트 증가. 초당 2,000건에서 6,000건
- 기존 리소스와 로직으로는 처리량이 부족하여 OOM, 처리 지연 발생, 이에 개선 필요
처리 :
- 병렬 처리
- 이벤트 브로커에서 수신한 이벤트를 기존 파티션별 순차처리에서 기기별 순차 처리로 변경
- 배치 수신한 이벤트를 기기 ID로 묶고, 같은 기기의 이벤트끼리는 순차 처리, 서로 다른 이벤트끼리는 병렬 처리 구현
- 초당 4200건에서 18000건으로, 초당 처리량 약 4.2배 증가
- 쿼리, 인덱스 튜닝
- DB 동시 액세스가 늘며 CP 커넥션 부족
- 커넥션 풀 조정, 쿼리, 인덱스 재점검 등 커넥션 점유 시간 감소
- 캐시 처리
- DB 쿼리, API 조회 데이터 캐싱
- Look aside, Evit Only, DB 트랜잭션 커밋 이후 캐시 업데이트 처리
- 배치 처리
- 이벤트 내용 DB 삽입 과정에서 병목 발생
- 이벤트 배치 수신 그룹별 Bulk insert 구현
- 단순 DB 삽입에 초당 3.5천 건 → 2만 건으로 처리량 4배 이상 개선, CPU 사용률 정상화
- 버퍼, 백 프레셔
- 이벤트 수신 스레드와 분리된 워커 스레드 구성
- 이벤트 처리에 사용할 스레드 수 고정, 일시적 트래픽 급증에도 동일한 리소스 사용 제한
- 스레드 풀 대기열을 버퍼로 사용, 평소 처리량으로 처리되지 않은 메시지 임시 보관
- 대기열이 가득차면 메시지 수신을 대기, 처리량에 따라 메시지 수신을 조절하는 백-프레셔 구조 구성
- 외부 장애 대응
- 캐시 : 캐시 서버 장애 시 DB 직접 접근. Dirties 데이터 관리
- 외부 서비스 호출 : 서킷 브레이커를 사용하여, 장애 지속 시 요청 제한. DLT를 사용하여 유실 대비
- 테스트 환경 구성
- 외부 서비스를 WireMock으로 대체
- 응답 속도에 따른 리소스 사용, 처리량 변화 테스트
- 리소스를 조정하거나 회로 차단의 재난 기준을 설정하는 근거 마련
- 모니터링 대시보드 구성
- 처리량, 유실률, 캐시 히트율과 API 요청과 응답 시간, 이벤트 단건 처리 시간 계산 및 수집
- 외부 플랫폼과의 소통을 위한 증적 강화
'KimJinHwan > Project' 카테고리의 다른 글
| 데이터 레이크 구축 : 초당 6천 개의 이벤트를 담는 똑똑한 호수 (0) | 2026.03.29 |
|---|---|
| 인증 서버 리팩토링 : OAuth 인증 시나리오와 PKCE (0) | 2026.03.21 |
| 웹 소켓 서버 구조 개선 : Api Gateway 를 사용한 상태 리스 전환 (0) | 2025.12.21 |
| WebRTC 시그널링 서버 개발 : P2P 통신 원리와 데이터 중개 (0) | 2025.11.28 |
| 레거시 서비스 인프라 개선 : IaC, 모니터링, CI/CD, 로깅 등 (0) | 2025.10.06 |