Search

스타트업 백엔드 로그 (2) RDS CPU 99%를 낮추기까지: Lock, Index, Connection을 따라간 기록

Tags
Infrastructure
Database
Last edited time
2026/05/31 08:42
2 more properties

1. 서론: 왜 RDS 최적화를 시작했나

운영 중인 RDS의 CPU가 99%까지 올라가는 상황을 보면 가장 먼저 떠오르는 선택지는 스케일업이다. 인스턴스 크기를 키우면 당장 그래프는 내려갈 수 있다. 그리고 때로는 그게 맞는 선택일 수도 있다.
하지만 이번에는 바로 스케일업부터 하고 싶지 않았다. CPU가 높다는 것은 결과에 가까웠고, 정작 궁금한 것은 그 CPU를 누가, 왜 쓰고 있는지였다.
이 작업을 시작하게 된 배경도 단순한 호기심은 아니었다. 피크 시간대마다 DB CPU Max는 99%까지 치솟았고, 이 패턴은 하루 이틀 보인 것이 아니라 오래 누적된 기술부채에 가까웠다. 동시에 유저들로부터 “서비스가 많이 느리다”는 피드백도 계속 들어오고 있었다.
입사한 지 3개월 정도 되었을 때, 이 문제는 더 이상 미뤄둘 수 없다고 판단했다. 다음 분기 OKR의 주요 개선 과제로 가져가야 할 만큼 영향도가 컸고, 빠르게 원인을 좁혀 안정화해야 했다. 그래서 단순히 인스턴스를 키우는 대신, 먼저 RDS가 실제로 어디에서 시간을 쓰고 있는지 끝까지 쪼개보기로 했다.
처음 관찰한 지표는 단순했다.
Aurora MySQL Writer의 CPU가 피크 시간대마다 99%까지 상승했다.
DB Load가 인스턴스의 vCPU 수를 넘는 구간이 있었다.
유저들이 서비스 지연을 체감하고 있었고, 운영 리스크도 커지고 있었다.
참고로 이 글의 테이블명과 일부 도메인 표현은 공개 블로그에 맞게 일반화했다. 실제 내부 테이블명을 그대로 옮기기보다, <usage_counter_table>, <large_order_item_table>, <import_job_item_table>처럼 문제의 성격만 드러나는 placeholder로 바꿔 설명한다.
그런데 Performance Insights를 열어보니 이야기가 조금 달랐다. 순수하게 CPU 연산만 많이 하고 있는 것이 아니었다. 주요 wait event는 크게 두 가지로 갈라졌다.
wait/synch/cond/innodb/row_lock_wait
wait/io/table/sql/handler
둘 다 “DB가 힘들다”는 같은 증상으로 보이지만, 원인은 완전히 다르다. 하나는 row lock contention에 가깝고, 다른 하나는 쿼리가 너무 많은 row를 읽고 있다는 신호에 가깝다.
이 글은 그 두 축을 따라 RDS 병목을 좁혀간 기록이다. 중간에 connection 최적화도 함께 진행했기 때문에, CPU와 connection을 어떻게 분리해서 봤는지도 같이 정리해보려고 한다.
구조는 크게 세 부분이다. 먼저 왜 이 작업을 시작했는지와 어떤 지표를 봤는지 설명하고, 본론에서는 lock, row scan, connection이라는 세 가지 축으로 병목을 나누어 정리한다. 마지막으로 비슷한 상황에서 다시 사용할 수 있는 체크리스트와 교훈을 남긴다.

1.1. CPU가 높다는 말만으로는 부족했다

처음에는 “CPU가 99%다”라는 숫자만 눈에 들어왔다. 하지만 CPU Utilization 하나만 보고는 다음 액션을 결정하기 어렵다.
예를 들어 같은 CPU 99%라도 원인은 다를 수 있다.
관찰 항목
의미
이때 봐야 할 것
CPU Utilization
인스턴스 CPU 사용률
결과 지표에 가깝다
DB Load / AAS
DB가 동시에 처리하거나 기다리는 작업량
vCPU 수와 비교해야 한다
row_lock_wait
row lock을 기다리는 시간
트랜잭션, 동시성, lock 범위
table handler wait
테이블 row를 읽고 처리하는 비용
full scan, index, Rows Examined
DatabaseConnections
DB 연결 수
pool 설정, direct connection, proxy 설정
이번 경우 Performance Insights에서 눈에 띈 것은 순수 CPU wait가 아니라 row_lock_waittable handler wait였다.
이 시점에서 문제를 두 개로 나눴다.
1.
같은 row를 여러 요청이 동시에 갱신하면서 lock contention이 생기는가?
2.
쿼리가 필요한 row보다 훨씬 많은 row를 읽으면서 CPU와 I/O를 쓰고 있는가?
결과적으로 둘 다 맞았다. 다만 두 문제가 만든 영향은 달랐다.

2. 본론: RDS 병목을 세 가지 축으로 나누어 보기

이후의 분석은 세 가지 축으로 나누어 진행했다. 첫 번째는 같은 row를 두고 요청들이 기다리던 lock contention, 두 번째는 필요한 row보다 훨씬 많은 row를 읽던 쿼리 패턴, 세 번째는 Aurora에 유지되는 connection 수와 RDS Proxy 설정이었다.

2.1. 첫 번째 축: lock contention 줄이기

피크 CPU를 설명하기 위해 가장 먼저 눈에 들어온 것은 lock wait였다. 이 파트에서는 같은 row를 여러 요청이 동시에 갱신하면서 생긴 row lock contention과, 이를 atomic update로 줄인 과정을 정리한다.

2.1.1. 첫 번째 병목: SELECT FOR UPDATE가 만든 row lock contention

먼저 눈에 띈 것은 티켓 사용량을 갱신하는 쿼리였다.
이미지 번역 같은 기능에서 사용자의 티켓 사용량을 증가시켜야 했고, 기존 흐름은 대략 이런 방식이었다.
SELECT ... FROM <usage_counter_table> WHERE id = ? FOR UPDATE;
SQL
복사
애플리케이션에서는 row를 lock으로 잡고, 현재 사용량을 읽고, quota를 넘는지 확인한 뒤, amount를 증가시키고 저장했다.
흐름을 단순화하면 이렇다.
이미지 번역 요청 -> 티켓 사용량 row 조회 -> SELECT ... FOR UPDATE -> quota 검증 -> amount 증가 -> save
Plain Text
복사
처음 보면 자연스러운 구현이다. 같은 티켓 사용량 row를 동시에 수정하면 안 되니 pessimistic lock을 잡고 안전하게 처리한다.
문제는 요청이 동시에 몰릴 때였다.
한 사용자가 여러 이미지 번역 요청을 동시에 보내면 같은 <usage_counter_table> row에 접근한다. Consumer 동시성과 맞물리면 같은 row에 대한 X-lock 대기가 길어진다. useTicket뿐 아니라 rollback 흐름도 비슷한 패턴을 사용하고 있어서 경합은 더 커질 수 있었다.
당시 관찰된 지표도 이 방향을 가리키고 있었다.
Top SQL 중 FOR UPDATE 계열 쿼리 비중이 높았다.
Row Lock Wait가 피크 시간대에 크게 튀었다.
일부 시간대에는 deadlock도 관찰됐다.
DB Load에서 row lock wait가 유의미한 비중을 차지했다.
여기서 중요한 점은, 이 문제가 “CPU를 직접 태우는 문제”라기보다 “DB가 lock을 기다리며 처리량과 안정성을 잃는 문제”에 가깝다는 것이다. 그래도 반드시 줄여야 하는 병목이었다.

2.1.2. 첫 번째 개선: lock을 잡고 읽는 대신, 조건부 UPDATE로 바꾸기

이 문제는 SELECT FOR UPDATE + save 패턴을 atomic update로 바꾸는 방향으로 개선했다.
기존 방식은 다음과 같았다.
1. SELECT ... FOR UPDATE 2. 현재 amount 확인 3. quota 초과 여부 검증 4. amount 증가 5. save
Plain Text
복사
개선 후에는 검증 조건을 UPDATE 문 안으로 넣었다.
UPDATE <usage_counter_table> SET amount = amount + :useAmount WHERE id = :id AND amount + :useAmount <= :quota;
SQL
복사
rollback도 같은 방향으로 바꿨다.
UPDATE <usage_counter_table> SET amount = amount - :rollbackAmount WHERE id = :id AND amount >= :rollbackAmount;
SQL
복사
이렇게 하면 애플리케이션에서 row를 먼저 읽고 오래 잡고 있을 필요가 줄어든다. 물론 UPDATE 자체도 row lock을 잡는다. 하지만 SELECT FOR UPDATE -> 애플리케이션 검증 -> save로 이어지는 read-modify-write 흐름을 여러 번의 왕복으로 나누지 않고, 하나의 UPDATE 안에서 조건 확인과 변경을 끝내기 때문에 lock을 잡고 있는 시간과 round-trip을 줄일 수 있다.
처리 결과는 affected row count로 판단할 수 있다.
affected rows = 1: 정상적으로 사용량 증가
affected rows = 0: quota 초과 또는 rollback 불가 상태
즉, 동시성 안전성을 포기하지 않으면서 lock hold time과 round-trip을 줄이는 방식이다.
배포 후 지표는 의도한 방향으로 움직였다.
지표
변경 전
변경 후
변화
Row Lock Wait avg
2.15 AAS
0.94 AAS
-56.3%
Row Lock Wait peak
7.08 AAS
4.04 AAS
-43.0%
DB Load avg
7.84 AAS
6.04 AAS
-23.0%
Top SQL 내 FOR UPDATE
높음
제거
개선
CPU Max
99%대
99%대
거의 변화 없음
결과는 절반의 성공에 가까웠다. Row lock contention은 줄었고, DB Load도 낮아졌으며, FOR UPDATE 쿼리도 Top SQL에서 사라졌다. 하지만 CPU 99%는 그대로였다. 이 지점에서 우리가 푼 문제와 아직 남아 있는 문제가 다르다는 것을 알게 됐다.

2.1.3. 중요한 반전: CPU 99%를 설명하는 병목은 lock만이 아니었다

처음에는 SELECT FOR UPDATE가 너무 눈에 띄었기 때문에 이것이 CPU 99%의 주범처럼 보였다. 하지만 lock 개선 이후에도 CPU가 내려가지 않았고, 이건 실패라기보다 힌트에 가까웠다.
row lock wait는 DB Load와 지연을 만들지만, CPU를 직접 태우는 성격의 병목은 아니다. CPU 99%를 설명하려면 여전히 남아 있는 wait/io/table/sql/handler를 봐야 했다. 그래서 slow query와 Performance Insights를 다시 파고들었고, 그 과정에서 훨씬 직접적인 원인이 보이기 시작했다.

2.2. 두 번째 축: 과도한 row scan 줄이기

Lock wait를 줄였는데도 CPU 피크가 그대로였다는 사실은, 아직 더 큰 병목이 남아 있다는 신호였다. 두 번째 축은 wait/io/table/sql/handler와 slow query를 따라가며 과도한 row scan을 줄인 과정이다.

2.2.1. 두 번째 병목: 300만 행을 매번 읽던 쿼리

가장 큰 문제는 주문 상품 테이블을 조회하는 쿼리였다. 단순화하면 이런 형태였다.
SELECT ... FROM <large_order_item_table> WHERE owner_id = ? AND market_order_id IN (?, ?, ...);
SQL
복사
이 쿼리는 결과로 몇 개에서 수십 개 정도의 row만 반환했다. 그런데 실제로는 매번 약 300만 행 규모의 테이블을 훑고 있었다.
관찰된 특징은 다음과 같았다.
<large_order_item_table>은 약 300만 행 규모였다.
반환 row는 대략 1~40행 수준이었다.
하지만 Rows Examined는 약 300만 행에 가까웠다.
한 시간에 1,600회 이상 호출되는 구간이 있었다.
평균 query time은 약 9.8초로 기록됐다.
조건에 사용되는 owner_id, market_order_id에 맞는 복합 인덱스가 없었다.
결과는 명확했다. DB는 아주 적은 결과를 반환하기 위해 매번 거대한 테이블을 읽고 있었다. 이 패턴이 반복되면 wait/io/table/sql/handler가 높아지고, CPU도 같이 밀려 올라간다. 비슷한 문제는 상품 수집 요청 테이블에서도 보였다.
SELECT ... FROM <import_job_item_table> WHERE collection_mode = ? AND status IN (?, ?);
SQL
복사
이 쿼리도 조건에 맞는 인덱스가 없어 많은 row를 scan하고 있었다. 물론 table handler wait가 항상 full table scan만을 뜻하는 것은 아니다. 하지만 이 경우에는 Rows Examined와 실제 반환 row 수의 차이가 컸고, slow query의 호출 빈도도 높았다.
그래서 단순한 CPU 연산 병목보다는 인덱스 누락으로 인한 과도한 row scan 가능성이 훨씬 높다고 판단했다. 처음에 잡은 row lock 문제도 분명한 병목이었지만, CPU 99%를 크게 낮추려면 full table scan을 줄이는 쪽을 봐야 했다.

2.2.2. 두 번째 개선: 정확한 조건에 맞는 인덱스 추가

개선 방향은 단순했다. 다만 단순한 만큼 정확해야 했기 때문에, 실제로 자주 사용되는 WHERE 조건에 맞춰 복합 인덱스를 추가했다.
CREATE INDEX idx_large_order_item_owner_market_order ON <large_order_item_table> (owner_id, market_order_id); CREATE INDEX idx_import_job_item_collection_mode_status ON <import_job_item_table> (collection_mode, status);
SQL
복사
중요한 것은 “인덱스를 많이 추가했다”가 아니다.
어떤 쿼리가 자주 호출되는지
어떤 조건으로 조회하는지
얼마나 많은 row를 읽고 얼마나 적은 row를 반환하는지
Top SQL과 slow query에서 실제로 병목인지
이걸 확인한 뒤 필요한 위치에 인덱스를 추가했다는 점이 중요했다.
적용 후 지표는 확실히 달라졌다. 아래 수치는 특정 관찰 구간 기준의 전후 비교다.
지표
인덱스 적용 전
인덱스 적용 후
변화
CPU Avg
55.4%
8.7%
-84.3%
CPU Max
99.7%
15.9%
-84.1%
DB Load avg
4.73 AAS
0.47 AAS
-90.1%
table handler wait
3.673 AAS
0.167 AAS
-95.5%
물론 운영 지표 비교는 항상 조심해야 한다. 요일, 시간대, 트래픽, 배치 작업 여부에 따라 숫자는 달라질 수 있다. 그래서 이 수치를 “영구적으로 항상 이만큼 개선됐다”고 단정해서는 안 된다.
하지만 Top SQL에서 문제 쿼리가 사라지고, handler wait가 크게 줄고, CPU 피크가 내려간 것은 인덱스 효과를 보여주는 강한 근거였다.
이번 CPU 최적화에서 가장 인상적이었던 점은 이것이다.
CPU 99%는 단순한 CPU 연산 부족이라기보다, 필요한 row를 찾기 위해 너무 많은 row를 반복해서 읽고 있던 쿼리 패턴으로 가장 잘 설명됐다.

2.3. 세 번째 축: connection 구조 정리하기

CPU와 쿼리 병목을 줄이는 것과 별개로, connection 수가 max_connections에 가까워지는 문제도 운영 리스크였다. 세 번째 축은 RDS Proxy 적용 범위와 idle connection 설정을 정리한 과정이다.

2.3.1. 다른 축의 문제: connection 수 줄이기

CPU 피크는 인덱스 개선으로 크게 안정화됐지만, 이것만으로 RDS 운영 리스크가 모두 사라진 것은 아니었다. 별도의 축으로 connection 수가 max_connections에 가까워지는 문제가 남아 있었다.
Aurora의 max_connections는 약 3,000 수준이었고, 피크 시간대에는 Threads_connected가 2,300~2,500 수준까지 올라가는 구간이 있었다. 여유가 아주 없는 것은 아니었지만, 운영 관점에서는 불편한 숫자였다.
처음에는 “RDS Proxy를 쓰고 있으니 괜찮지 않을까?”라고 생각할 수 있다. 하지만 실제 구조를 보면 그렇지 않았다.
문제는 크게 두 가지였다.
1.
일부 서비스가 RDS Proxy를 거치지 않고 Aurora endpoint에 직접 연결하고 있었다.
2.
RDS Proxy 자체도 idle connection을 꽤 많이 유지하고 있었다.
구조를 단순화하면 이렇다.
구분
문제
개선 방향
직접 연결 서비스
인스턴스 수 x pool 크기만큼 DB 연결 증가
RDS Proxy 경유로 변경
Proxy idle pool
필요 이상으로 backend connection 유지
MaxIdleConnectionsPercent 조정
메트릭 해석
CloudWatch와 processlist 숫자가 다르게 보임
각 metric의 의미를 분리해서 해석
여기서 중요한 점은 RDS Proxy가 “켜져 있는지”가 아니라, 실제로 모든 주요 트래픽이 Proxy를 경유하는지, 그리고 Proxy 설정이 워크로드에 맞는지였다.

2.3.2. RDS Proxy 설정을 조정하며 배운 것

기존 RDS Proxy 설정은 대략 다음과 같았다.
MaxConnectionsPercent: 100%
MaxIdleConnectionsPercent: 50%
ConnectionBorrowTimeout: 120s
여기서 먼저 조정한 것은 MaxIdleConnectionsPercent였다.
MaxIdleConnectionsPercent: 50% -> 25%
MaxConnectionsPercent: 100% 유지
왜 최대 연결 비율은 그대로 두고 idle 비율만 낮췄을까?
MaxConnectionsPercent를 낮추면 DB를 보호하는 가드레일이 생기지만, 동시에 피크 상황의 여유분도 줄어든다. 반면 MaxIdleConnectionsPercent는 Proxy가 미리 유지하는 idle backend connection의 크기를 조정하는 값이다.
당시 관찰한 workload는 connection 변동성이 크지 않았다.
connection 증가폭이 비교적 안정적이었다.
갑작스러운 burst가 자주 발생하는 패턴은 아니었다.
BorrowLatency도 함께 관찰할 수 있었다.
그래서 최대 용량은 당장 줄이지 않고, idle pool부터 줄이는 보수적인 접근을 택했다.
동시에 Aurora에 직접 연결하던 서비스를 RDS Proxy writer endpoint로 옮겼다. 애플리케이션 설정을 크게 바꾸기보다 DB_HOST를 Proxy endpoint로 바꾸는 방식이었다. 물론 이때도 session variable, prepared statement, temporary table, lock table처럼 RDS Proxy pinning을 유발할 수 있는 요소를 함께 검토했다.
변경 후에는 connection 수가 의미 있게 줄었다. 아래는 문서에 남아 있는 측정 구간 기준의 전후 비교다.
지표
변경 전
변경 후
변화
Aurora Writer Connections
2,416
1,250
-48.3%
Instance DB Connections avg
2,106
1,312
-37.7%
Query Response Latency avg
75.4ms
27.8ms
-63.1%
RDS Proxy DB Connections
153.7
103.9
-32.4%
이 수치는 CPU/index 개선 효과와 섞어 해석하면 안 된다. connection 최적화의 핵심은 CPU를 직접 낮췄다는 주장보다, Aurora에 유지되는 연결 수와 connection 관리 리스크를 줄였다는 데 있었다.
직접 연결하던 서비스의 connection이 Proxy 뒤로 들어갔다.
Query Response Latency와 BorrowLatency도 개선된 구간이 있었다.
최종 리뷰 시점에는 MaxAllowed 대비 DatabaseConnections 사용률에 여유가 있었다.
여기서 배운 것은 두 가지다.
첫째, RDS Proxy를 도입했다고 connection 문제가 자동으로 해결되지는 않는다. 직접 연결이 남아 있으면 그 서비스는 여전히 DB connection을 그대로 소비한다.
둘째, CloudWatch와 DB processlist가 보여주는 connection 숫자는 같은 의미가 아니다. processlist에는 Proxy ENI가 유지하는 물리 연결과 idle warm pool이 보일 수 있고, CloudWatch의 DatabaseConnections, ClientConnections, CurrentlyBorrowed, MaxDatabaseConnectionsAllowed는 각각 의미가 다르다.
connection 최적화는 단순히 숫자를 줄이는 일이 아니라, 그 숫자가 어떤 계층의 connection인지 해석하는 일이었다.

3. 결론: 스케일업 전에 먼저 나누어 볼 것들

이번 작업의 결론은 특정 기법 하나가 모든 문제를 해결했다는 이야기가 아니다. 같은 “RDS 부하”처럼 보이는 현상을 lock, row scan, connection으로 나누어 봤을 때 각각 다른 해결책이 필요했다는 점이 핵심이다.

3.1. CPU와 Connection을 함께 보며 얻은 교훈

이번 작업을 지나고 나니, RDS 최적화에서 가장 위험한 접근은 하나의 숫자만 보고 결론을 내리는 것이라는 생각이 들었다. CPU가 높다고 무조건 스케일업이 답은 아니었고, connection이 많다고 무조건 pool size만 줄이면 되는 것도 아니었다. RDS Proxy를 쓴다고 connection 문제가 자동으로 끝나는 것도 아니었다. 그래서 이번에 정리한 체크리스트는 다음과 같다.

3.1.1. CPU가 높을 때

CPU Utilization만 보지 말고 Performance Insights의 wait event를 본다.
DB Load / AAS를 vCPU 수와 비교한다.
row_lock_waittable handler wait를 구분한다.
Top SQL의 비중만 보지 말고 실제 실행 횟수, 평균 시간, Rows Examined / Rows Sent를 본다.
lock wait가 높다면 SELECT FOR UPDATE가 정말 필요한지 확인한다.
read-modify-write 패턴이 atomic update로 바뀔 수 있는지 본다.
handler wait가 높다면 Rows Examined / Rows Sent를 함께 보고 full table scan과 인덱스 누락 가능성을 확인한다.

3.1.2. 인덱스를 볼 때

WHERE 조건과 인덱스 컬럼 순서가 맞는지 확인한다.
반환 row 수보다 읽는 row 수가 과도하게 많지 않은지 본다.
자주 호출되는 쿼리인지 확인한다.
인덱스 추가가 write cost와 storage cost를 늘린다는 점도 함께 본다.

3.1.3. connection을 볼 때 (RDS Proxy 사용시)

direct connection이 남아 있는 서비스를 찾는다.
애플리케이션 인스턴스 수와 pool size를 곱해 최악의 connection 수를 계산한다.
RDS Proxy의 ClientConnectionsDatabaseConnections를 구분한다.
processlist의 connection 수와 CloudWatch metric의 connection 수가 다르게 보일 수 있음을 전제한다.
MaxIdleConnectionsPercent를 workload 변동성에 맞게 조정한다.
BorrowLatencyMaxDatabaseConnectionsAllowed 대비 사용률을 함께 본다.
Session pinning은 발생 여부보다 비율과 latency 영향을 함께 본다.

3.2. 마무리

이번 작업은 처음에는 “RDS CPU 99%를 낮추자”로 시작했지만, 실제로는 세 가지 다른 문제를 각각 풀어야 했다.
첫 번째는 row lock contention이었다. SELECT FOR UPDATE를 atomic update로 바꾸면서 lock wait와 DB Load를 줄였다.
두 번째는 과도한 row scan이었다. 적은 row를 찾기 위해 큰 테이블을 매번 읽던 쿼리에 맞는 복합 인덱스를 추가했고, CPU와 handler wait가 크게 내려갔다.
세 번째는 connection 구조였다. RDS Proxy 설정과 direct connection을 정리하면서 Aurora에 유지되는 connection 수를 줄였다. 이 세 가지는 모두 RDS 부하처럼 보였지만, 실제로는 다른 문제였고 해결 방법도 달랐다.
스케일업은 여전히 유효한 선택지다. 하지만 이번에는 그 전에 볼 수 있는 것들이 있었다.
DB는 어디서 기다리고 있는가?
어떤 쿼리가 너무 많은 row를 읽고 있는가?
어떤 트랜잭션이 lock을 오래 잡고 있는가?
connection은 어떤 경로로 DB에 도달하고 있는가?
Proxy는 정말 multiplexing 효과를 내고 있는가?
RDS 최적화는 결국 더 큰 인스턴스를 고르는 일이 아니라, DB가 어디서 시간을 쓰고 있는지 끝까지 해석하는 일에 가까웠다.
결과적으로 아름답게 개선된 CPU Utilization