💡 MySQL Lock 경합 → Redis + BullMQ 기반 비동기 아키텍처로 해결
좋아요 처리 시스템에서 단순 증가 연산만 수행하던 Like 기능이 TPS(초당 처리량)가 증가하면서 MySQL Row-Level Lock 경합을 유발해 시스템 성능 저하가 심화됐습니다.
Redis 캐시 도입, Pipeline 최적화, 인덱스 튜닝 등 다양한 성능 개선 시도를 거쳤으나, 결국 DB Connectoin Pool 고갈 및 Lock 경합 현상이 반복 발생되었습니다. 단순 캐시 레벨에서의 해결의 한계가 명확했습니다.
BullMQ 기반 비동기 Queue 아키텍처로 전환으로 Lock 경합을 근본적으로 제거했고, 그 결과는 아래와 같습니다.
| 성능 지표 | 전환 전 | 전환 후 | 개선율 |
| 평균 응답 속도 | 580 ms | 25 ms | 약 96% 감소 |
| 95% 응답 속도 | 988 ms | 88 ms | 약 91% 감소 |
| 안정적 TPS 처리량 | 100 이하 (불안정) | 387 이상 | 안정화 |
단순 캐시가 아닌 근본적인 Lock 제거와 구조적 아키텍처 전환의 필요성을 직접 경험한 사례입니다. 아래에는 좀 더 자세한 내용이 작성되어 있습니다.
좋아요 처리 시스템을 개발하던 중, 단순한 기능처럼 보였던 Like 요청이 MySQL의 row-level lock 경합을 발생시키며 시스템 성능을 크게 저하시켰습니다. 이 글은 문제의 발생부터 시도한 다양한 해결책, 그리고 아키텍처 개선까지의 여정을 담았습니다.
🚨 TPS 증가와 함께 나타난 Lock 경합

처음에는 단순히 특정 게시글에 대한 좋아요 요청을 처리하는 기능이었습니다. 하지만 TPS 100 수준에서 700까지 부하를 증가시키자 다음과 같은 문제가 발생했습니다.
- MySQL의 row-level-lock 경합
- Lock wait timeout 에러 발생(Connection 고갈)
- 전체 시스템 평균 응답 시간이 580ms, 95 percentile이 988ms로 치솟는 문제
row level lock과 Lock wait timeout이 DB 병목지점이라고 판단했습니다. 병목 지점을 발견했고 문제를 해결하고자 Cache Layer를 도입했습니다.
💡 병목을 줄이기 위한 첫 시도 : Redis 캐시 레이어 도입

처음에는 Redis의 INCR/DECR 명령어를 사용해서 좋아요 수를 빠르게 증가시키고, 이를 바로 DB에 반영하지 않고 Redis에 임시 저장하는 방식을 생각했죠. 이후 일정 시간마다 Bulk Update 하는 형태로 처리하려고 했습니다.
하지만, Redis 자체에서도 병목 현상이 발생했습니다. Redis는 싱글 스레드로 동작하기 때문에, 일부 명령어가 전체 Redis 성능에 큰 영향을 미치는데요, 특히 KEYS 명령어가 문제였습니다.
이 문제를 해결하기 위해, KEYS 명령어 대신 게시글 ID를 기반으로 한 SET 자료구조로 Redis 키 관리를 전환했습니다. 이렇게 하면 전체 키를 탐색하지 않고도 필요한 범위 내에서 효율적으로 데이터를 조회할 수 있어 Redis 병목 현상이 크게 완화됩니다.

- Redis는 싱글 스레드 구조이므로 KEYS와 같은 명령어 사용시 전체 서비스 응답 지연 발생합니다.
- KEYS 명령어는 전체 키를 조회하는 O(n) 복잡도의 명령어로 대규모 환경에서 위험 부담이 큽니다.
- SET 자료구조 기반 키 관리로 전환해 부분 조회가 가능합니다. 성능 향상 및 병목 현상을 완화합니다.
- Redis에서는 KEYS 대신 SCAN 명령어, SET, Sorted Set 등 자료구조 활용을 권장하고 있습니다.
Redis의 삭제 정책에 대해서만 고려를 했었는데, 내부 동작까지 해당 장애를 겪으면서 학습할 수 있었습니다.
💡 RTT(Round Trip Time) 최소화 : Pipeline 도입
Redis에 관한 학습을 진행하면서, Redis의 Pipeline을 도입하면 RTT 시간도 줄일 수 있으니 부하 테스트에서도 이점을 낼 수 있을것이란 판단으로 Pipeline 기법도 도입했습니다.

- 하나의 게시글에 대한 Redis 접근이 많았고, 요청 수만큼 RTT가 발생
- Redis Pipeline을 통해 N회에 걸친 RTT를 2회로 단축
- System call도 기존 대비 1/1000 수준으로 절감
이를 기반으로 주기적 cron job을 통해 Redis의 데이터를 DB에 저장하는 Eventually Consistent 구조를 구현했습니다.
🚨 그래도 사라지지 않은 row-level-lock: MySQL 쿼리 분석과 튜닝
Redis를 사용해 일시적인 성능 개선이 있었지만, MySQL의 row-level lock은 부하를 증가시키면서 여전히 발생했습니다.
- 요청량이 많아지면 Redis에 임시 저장된 데이터를 DB에 저장하는 과정에서 문제가 반복
- root cause는 MySQL 쿼리의 where 절이 index를 타지 않는 구조라는 점을 발견
- UPDATE 쿼리에서 index를 사용하지 않아, Full Table Scan이 발생
- 전체 Lock을 걸어버려 경합이 발생
row-level lock은 적절한 인덱스를 타지 않으면 전체 테이블 스캔 과정에서 모든 row에 Lock을 걸 수 있습니다.



쿼리를 튜닝하여 Index Scan으로 변경해, MySQL의 동시성을 높이기 위한 row-level lock 방식을 사용할 수 있게 개선하였습니다.
🚀 튜닝 결과( Redis + MySQL Index 최적화 )
- 평균 응답 시간: 580ms → 42ms (93% 개선)
- 95 Percentile: 988ms → 102ms (89% 개선)
- Redis Pipeline 적용: RTT 1/1000 수준 절감
- MySQL: Full Table Scan → Index Scan 전환
- RPS 700 환경에서도 안정적인 처리 가능
🚨 TPS 700 이상 : Connection 고갈과 Lock 문제
시스템 부하가 TPS 700 이상으로 증가하자, 이전에 해결한 것 같던 문제가 다시 나타났습니다. 동시 접속 요청이 급증하면서 MySQL Connection이 고갈되는 상황이 발생했습니다.
이로 인해 다수의 요청이 Connection 대기열에 쌓여 지연이 커졌고, 결국 시스템 성능이 크게 저하되었습니다.
- 대량 트래픽으로 Connection Pool이 포화되어 더 이상 새로운 DB 연결을 할 수 없는 상태였습니다.
- 이전에 도입한 Redis로 버퍼 처리를 하는 구조도, 폭발적인 동시 데이터 폭증 앞에서는 한계가 존재합니다.
- DB Lock 경합과 Connection 부족 현상이 복합적으로 발생합니다.
이 문제를 해결하기 위해서 Async Queue를 도입하기로 결정했습니다.
💡 아키텍처 변경 : Async Queue 도입 (BullMQ)
시슴테에서 발생한 Connection 고갈과 Lock 경합 문제를 해결하기 위해, Redis 친화적인 BullMQ를 선택했습니다. Redis에 대한 학습을 장애를 해결하면서 했고, Redis의 주요한 부분을 알고 있었기에 도입에 부담이 없었습니다.

TPS가 급격히 증가하면서 DB Connection Pool이 고갈되어, 새로운 연결을 받지 못하는 상태가 지속되었습니다. 다수 요청이 한꺼번에 DB에 들어가면서 MySQL Lock 경합이 심해지고 전체 처리 지연이 발생했습니다. 단순히 Redis에서 Bulk 사이즈를 줄이면 되지 않을까? 생각을 했습니다. 하지만 이는 근본적인 문제 해결방식도 아니고 추가적으로 더 많은 양의 데이터가 모인다면? 사이즈를 계속해서 줄여야합니다. 이런 구조는 잠시 버틸 뿐 끝까지 유지를 할 수 없다는 판단에 Async Queue 도입을 시도했습니다.
따라서, Connection Pool의 고갈 방지와 DB Lock 경합 완화를 최우선으로 두고 안정적인 시스템 운영을 목표로 잡았습니다.
Async Queue인 BullMQ을 적용하면 아래와 같은 방식으로 사용됩니다.
- Redis에 저장된 Like Count를 바로 DB에 반영하지 않습니다.
- BullMQ 기반의 비동기 큐에 작업을 적재해 요청을 분산 처리 합니다.
- 큐 Consumer가 순차적으로 DB에 반영을 수행하며, DB Connection Pool이 과부하되지 않도록 관리합니다.
🚀 최종 성능 결과 ( BullMQ 도입 )
- 평균 응답 시간: 580ms → 25ms (96% 개선)
- 95 Percentile: 988ms → 88ms (91% 개선)
- TPS: 초당 387건 이상에서도 안정 동작
- Connection 고갈 현상 해소
- Lock 경합 사라짐
비교
| 개선 단계 | 평균 응답 시간(ms) | 95퍼센타일(ms) | TPS 안정성 |
| 초기 | 580 | 988 | 불안정 |
| Redis 도입 | ~200 | ~400 | 불안정 |
| 인덱스 튜닝 | 42 | 102 | 부분 개선 |
| BullMQ | 25 | 88 | 안정적 |
배운점
- 단순한 캐시 레이어 추가는 근본적인 Lock 문제를 해결할 수 없다.
- Redis 사용 시에도 구조적 병목이 발생할 수 있으므로, 명령어의 특성과 성능을 이해하고 써야 한다.
- MySQL에서 Lock 경합을 해결하려면 인덱스 설계와 쿼리 구조 분석이 필수입니다.
- TPS가 높아지면 RDB의 connection 구조적 한계에 도달할 수 있으므로, 비동기 처리 아키텍처로의 전환이 필요합니다.
- Eventually Consistency 모델은 실시간성이 중요한 요구사항이 아니라면 강력한 해결책이 될 수 있다.
'Project > 기록' 카테고리의 다른 글
| 앞으로 작성해야 하는 내용들 (1) | 2026.01.25 |
|---|---|
| Log 영속성을 위한 MQ 도입 (1) | 2025.06.10 |
| Bullmq Document + CS 관점으로 다시 생각 (1) | 2025.06.05 |
| 멱등성(Idempotency)이란? (0) | 2025.06.02 |
| 비동기 아키텍처 전환 (0) | 2025.05.28 |