본문 바로가기

Project/기록

비동기 아키텍처 전환

700 RPS 환경에서 응답 시간 96% 개선 및 MySQL 부하 해소

서비스 초기에 트래픽이 적을 땐 동기 처리 기반의 단순한 구조만으로도 충분히 운영이 가능했습니다. 하지만 서비스가 성장하고, 초당 700건 이상의 요청이 발생하면서 이전 구조에서는 한계를 드러내기 시작했습니다. 특히 Like 이벤트 처리 시 두 가지 주요 문제가 발생했습니다: MySQL Connection 고갈Row-Level Lock 경합입니다.

문제가 발생한 시점의 성능 지표는 다음과 같았습니다.
평균 응답 시간이 580ms, 95퍼센타일 기준으로는 무려 988ms까지 치솟았습니다. 트래픽은 순간적으로 몰리는 burst 환경에서 더 극단적인 지연을 야기했으며, 이는 사용자 경험에 직접적인 악영향을 주었습니다.

단순한 max_connections 증가로 해결될까?

문제를 처음 마주했을 때, 가장 먼저 떠오른 해결책은 MySQL의 max_connections 값을 늘리는 것이었습니다. 기본값인 151에서 500으로 확장하면 동시에 더 많은 요청을 수용할 수 있고, 일시적인 고갈 문제는 완화될 수 있을 것이라 판단했습니다.

하지만 이는 단기적인 “진통제”일 뿐, 구조적인 한계를 고려하지 않은 위험한 접근이었습니다.

Thread-Per-Connection 모델의 구조적 문제

MySQL은 기본적으로 Thread-Per-Connection 방식을 채택하고 있습니다. 즉, 하나의 연결이 생성될 때마다 하나의 OS 스레드가 함께 생성됩니다. max_connections를 500으로 설정한다는 것은 500개의 스레드가 동시에 운영된다는 뜻이며, 이로 인해 Context Switching이 폭증하게 됩니다.

Context Switching이란, CPU가 여러 스레드를 빠르게 전환하며 실행하는 과정인데, 스레드 수가 많아질수록 전환 비용이 커지고 결국 Thrashing이라는 현상이 발생합니다. 이 상태에선 CPU가 실질적인 작업보다는 스케줄링에 더 많은 자원을 소모하게 되어, 오히려 성능은 하락하게 됩니다.

MySQL Document

메모리 고갈과 스왑

스레드 하나는 약 256KB 이상의 스택 메모리를 사용합니다. 500개의 스레드는 단순 계산만으로도 수백 MB의 메모리를 점유하게 되며, 시스템의 가용 메모리를 크게 압박합니다. 결과적으로 시스템은 스왑 공간을 사용하게 되고, 이는 디스크 I/O 부하 증가, 그리고 MySQL 성능 급락으로 이어집니다. InnoDB의 버퍼 풀 같은 중요한 캐시 영역이 줄어들게 되면서, 쿼리 성능까지 영향을 받습니다.

커넥션 풀 설정과의 충돌 가능성

WAS(예: Node.js, Spring 등)와 DB 클라이언트는 보통 자체적으로 커넥션 풀을 가지고 있습니다. max_connections를 500으로 설정했더라도, 애플리케이션에서 적절한 커넥션 풀 설정이 병행되지 않으면, 초과 접속 시도가 발생하게 됩니다. 이때 MySQL은 Too many connections 에러를 발생시키고, 일부 요청은 큐에 대기하지 못한 채 즉시 실패하게 됩니다.

MySQL Document

Lock 경합: 트래픽 증가가 곧 병렬성 향상은 아니다

동시 연결 수가 늘어난다고 해서 DB의 병렬성이 향상되는 것은 아닙니다. 오히려 동일한 데이터에 대해 여러 트랜잭션이 동시에 접근하면서 Row-Level Lock 경합이 심화되고, InnoDB의 트랜잭션 대기, Lock wait timeout, Deadlock 발생률 증가 등의 부작용이 발생하게 됩니다. 151개의 max_connection 부터 Row-Level Lock이 발생했습니다. 500이상의 max_connection을 증가해도 똑같은 문제가 발생할 것입니다. 실제 아래 Default Value를 보면 1년동안 wait을 걸어두기 때문에 시간이 지나면 해결이 된다. 하지만 근본적으로 우리는 빠른 응답을 요구하는 시스템을 개발하기 때문에 이런 timeout 시간은 말이 되지 않습니다.

MySQL Document

구조적 개선 없이는 문제는 반복된다

이러한 이유들로 인해, max_connections를 단순히 증가시키는 것은 치유제(cure)가 아닌, 일시적인 진통제(painkiller)에 불과합니다. 구조적인 문제를 해결하지 못하면, 비슷한 문제가 반복적으로 발생할 수밖에 없습니다.

궁극적으로 이 문제를 해결하기 위해서는 비동기 처리 기반 아키텍처로의 전환이 필요하다는 판단을 내렸고, 저는 이를 위해 BullMQ 기반의 이벤트 큐 시스템을 도입하여 병목을 해결했습니다.

비동기 아키텍처로의 전환, 그리고 설계 철학

이 문제를 해결하기 위해 저는 Like 요청 처리 구조를 비동기 아키텍처 기반으로 전환했습니다. BullMQ 기반의 큐 시스템을 도입하여, 유저의 Like 요청이 발생하면 즉시 DB에 처리하는 것이 아니라 큐에 메시지를 적재하고, 별도의 Worker가 해당 작업을 순차적으로 처리하는 구조로 개선했습니다.

더보기

BullMQ 기반을 선택한 이유

현재 Redis를 사용중이고, Redis와 궁합이 잘 맞는 BullMQ를 선택했습니다. 또한, Job 상태 추적 및 이벤트 기반 처리가 가능했습니다. 비동기 아키텍처로 전환하게 되면서 가장 중요한 것은 결국 성공 또는 실패라고 생각했습니다. 그런 부분을 이벤트 리스너를 사용해 Job 상태에 따른 세밀한 처리 로직을 구현할 수 있기 때문에 선택했습니다.

이 구조는 Eventually Consistency(최종적 일관성)를 수용할 수 있는 비즈니스 로직이라는 점에서 매우 적합했습니다. Like 이벤트는 실시간성이 중요한 작업은 아니며, 사용자가 누른 Like가 수 밀리초 ~ 수 초의 딜레이 후 반영되더라도 전체적인 사용자 경험에 큰 영향을 주지 않기 때문에, 이러한 트레이드오프를 통해 시스템 안정성과 성능을 동시에 확보할 수 있었습니다.

또한, 이 구조는 단순히 응답 시간을 줄이기 위한 목적만이 아니라, 스레드 자원의 효율적 활용이라는 측면에서도 중요합니다. 기존 Thread-per-Connection 모델에서는 순간적인 요청 폭증 시 커넥션 수 증가 → 스레드 수 증가 → CPU Thrashing → 성능 저하라는 악순환이 발생했지만, 비동기 큐 기반의 처리 구조에서는 요청을 Queue에 적재하는 순간부터 스레드 자원은 즉시 반환됩니다. 실제 처리는 Worker가 순차적으로 맡기 때문에, 트래픽의 순간 폭증(TPS Burst)을 Queue가 흡수(buffering)하고, 결국 시스템의 안정성과 처리율을 동시에 확보할 수 있게 됩니다.

그 외 고려할 수 있는 설계 방향

비동기 큐 기반 구조는 매우 효과적인 방식이지만, 그 외에도 다음과 같은 추가적인 아키텍처 전략들도 함께 고려하거나 병행할 수 있습니다.

CQRS (Command Query Responsibility Segregation)
Like 요청을 커맨드(Command)로 분리하고, 실제 반영된 상태는 별도의 Read Model로 조회하게 되면, 쓰기와 읽기의 병목을 각각 독립적으로 튜닝할 수 있습니다.

Event Sourcing 기반 아키텍처
모든 Like 이벤트를 Event로 저장하고, 이를 Replay 하거나 Materialized View를 재생성하는 방식으로 DB 부하를 효과적으로 분산시킬 수 있습니다.

Batch Processing 도입
트래픽이 매우 높지만 실시간 반영이 필요 없는 경우, Like 요청을 일정 시간 단위로 배치 처리함으로써 DB의 락 경합을 완화할 수 있습니다.

비관적 락 대신 낙관적 락 사용 고려
업데이트 시점에서 동시성 충돌이 자주 발생하지 않는다면, 낙관적 락을 통해 Lock Wait, Deadlock 문제를 줄일 수 있습니다.

DB 수평 분산 (Sharding)
Like 테이블 자체가 너무 커지고 쓰기 부하가 많아질 경우, 특정 기준(예: 사용자 ID)으로 테이블을 분할하여 Row-level Lock 경합을 줄일 수 있습니다.


요약: max_connections 증가의 단점과 한계

  • Thread-Per-Connection 구조로 인해 연결 수 증가 = 스레드 수 증가
  • CPU Context Switching 증가 → Thrashing 발생 가능성
  • 각 스레드가 메모리(stack memory) 사용 → RAM 고갈 및 SWAP 유발
  • InnoDB Buffer Pool 등 중요한 캐시 자원 축소 → 쿼리 성능 저하
  • 커넥션 풀 설정이 부적절할 경우 → 즉각적인 접속 오류 발생
  • Lock 경합 심화 → 성능 저하 및 Deadlock 위험 증가
  • 근본적인 병목 해결을 위해선 구조 개선이 필수

요약 : 비동기로 전환하면서

  • 비동기 아키텍처(BullMQ 기반)를 도입하여 요청을 Queue에 적재하고, Worker가 순차 처리하도록 개선.
  • 평균 응답 시간: 580ms → 25ms (96% 개선), 95퍼센타일: 988ms → 88ms (91% 개선).
  • Eventually Consistency를 수용할 수 있는 도메인이기에 비동기 처리 방식이 적절함.
  • 큐는 TPS의 순간 폭증을 완충(Buffering)하는 역할을 하며, 스레드 자원을 절약하고 시스템 안정성 확보.

'Project > 기록' 카테고리의 다른 글

Bullmq Document + CS 관점으로 다시 생각  (1) 2025.06.05
멱등성(Idempotency)이란?  (0) 2025.06.02
내 API는 실패할 수 있다  (0) 2025.05.26
객체지향의 사실과 오해 - 2  (0) 2025.05.10
객체지향의 사실과 오해 - 1  (0) 2025.05.10