RDS Proxy를 썼는데도 왜 느릴까? - 범인은 코드 안에 있었다

RDS Proxy 적용 후에도 여전히 발생하는 타임아웃. 알고 보니 범인은 TypeORM의 기본 설정과 인덱스 부재였다.

Jun Noh

RDS의 Connection Pool은 범인이 아니었다.

지난 글에서 RDS Proxy를 야심 차게 도입했다.

이론대로라면 이제 커넥션 풀 문제는 해결되었고, 내 서버는 수천 명의 동시 접속도 거뜬히 받아내야 했다.

하지만 뭔가 이상했다.

위풍당당하게 Artillery 부하 테스트를 다시 돌렸는데, 받아든 성적표는 처참했다.

“분명 프록시를 달았는데…”

테스트 시작 5분 만에 가상 유저의 절반 이상이 타임아웃(Timeout)으로 떨어져 나갔고, 응답 속도는 여전히 수십 초를 넘나들었다.

"summaries": {
    "http.response_time": {
      "min": 15440,
      "max": 59610,
      "p95": 54738,
      "p99": 55843.8
    },
    // ...

RDS Proxy 모니터링을 확인해봤다. 이상하게도 Proxy는 여유로웠다.

ClientConnections는 치솟는데, 정작 DB로 연결되는 DatabaseConnections는 바닥을 기고 있었다.

도로(Proxy)를 8차선으로 뚫어놨는데, 차들이 1차선으로만 다니는 기분이었다.

도대체 병목은 어디에 있는 걸까?

진짜 범인 검거: TypeORM의 배신

백엔드 코드 설정을 하나하나 뜯어보다가, src/config/database.ts 에서 등잔 밑이 어두웠던 사실을 발견했다.

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST, // 여기가 RDS Proxy 주소
  // ...
  // extra: { max: ??? }  <-- 설정이 없다 (default 사용 중)
});

지금 Node.js 환경에서 TypeORM을 사용하고 있다. 그런데 TypeORM(정확히는 내부의 pg 드라이버)은 커넥션 풀 크기(max)를 명시하지 않으면 기본값 10을 사용한다.

이게 무슨 소리냐면, RDS Proxy가 뒤에서 “나 수천 명도 받을 수 있어!”라고 외치고 있어도, 정작 Node.js 서버는 “아, 난 10명만 보낼게” 하고 문을 걸어 잠그고 있었다는 뜻이다.

11번째 유저부터는 앞선 10명이 볼일을 마칠 때까지 하염없이 기다려야 했고, 그게 쌓여서 타임아웃 대폭발로 이어진 것이다.

설정을 즉시 수정했다.

Proxy를 믿고 Node.js 쪽의 파이프라인도 넓혔다.

extra: { max: 150, // 10 -> 150으로 증설. connectionTimeoutMillis: 10000, },

내친김에 인덱스 수술까지

커넥션 풀을 뚫어주니 이제 트래픽이 시원하게 DB로 밀려들어갈 것이다. 그런데 문득 불안감이 엄습했다.

“과연 DB가 그 많은 쿼리를 감당할 수 있을까? 쿼리 효율은 괜찮은가?”

CloudWatch로 쿼리 실행 계획을 살짝 엿보았더니, 역시나 Full Table Scan 파티가 열리고 있었다. 도로가 뚫리자마자 차들이 거북이 주행을 하고 있는 꼴이었다.

  1. 인덱스 없는 정렬 메인 피드를 조회할 때 ORDER BY created_at DESC를 쓰는데, created_at에 인덱스가 없었다. DB는 매번 전체 데이터를 메모리에 올리고 정렬하느라 비명을 지르고 있었다.

  2. 느려터진 LIKE 검색 검색 기능에 LOWER(title) LIKE ‘%김치%’ 쿼리를 쓰고 있었다. 앞에 %가 붙으면 인덱스를 못 탄다. 데이터가 쌓일수록 검색 속도는 기하급수적으로 느려질 시한폭탄이었다.

인덱스(Index) 설정

  1. 정렬 부하 없애기 (최신순, 인기순)
CREATE INDEX idx_recipe_post_created_at ON recipe_post (created_at DESC);
CREATE INDEX idx_recipe_post_like_count ON recipe_post (like_count DESC);
  1. 검색 속도 10배 빠르게 만들기 (pg_trgm)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_recipe_post_title_trgm ON recipe_post USING gin (title gin_trgm_ops);

특히 검색의 경우, PostgreSQL의 pg_trgm(Trigram) 확장을 적용하여 LIKE 검색도 인덱스를 탈 수 있게 만들었다. 이제 검색어 입력 즉시 결과가 튀어 나온다.

다시, 부하 테스트

모든 최적화를 마치고 다시 Artillery 부하 테스트를 돌렸다. 결과는 경이로웠다.

Before (RDS Proxy 적용 후)

Max Latency: 59,610ms (약 60초)
p95 Latency: 54,738ms
Status: 유저의 50% 이상이 Timeout 발생

After (Connection Pool + Index 최적화 후)

Max Latency: 954ms (0.9초)
Mean Latency: 201.4ms
p95 Latency: 300ms 대 (추정)
Status: Timeout 0%, 모든 요청 성공
"http.response_time": {
  "min": 12,
  "max": 954,
  "count": 348,
  "mean": 201.4,
  "p50": 159.2,
  "median": 159.2,
  "p75": 333.7,
  "p90": 450.4
}

응답 속도가 약 60배 이상 빨라졌다.

서버가 죽어가던 상황에서, 이제는 쾌적하게 날아다니는 수준이 되었다.

이 모든 것이 코드 몇 줄과 인덱스 설정으로 가능했다.

결론: 인프라는 도구일 뿐

RDS Proxy는 훌륭한 도구지만, 도입한다고 해서 마법처럼 모든 성능 문제가 해결되진 않았다. 오히려 인프라 뒤에 숨어있던 애플리케이션 설정(Connection Pool)과 쿼리 비효율(No Index)이라는 진짜 문제들을 마주하게 해 주었다.

이번 삽질을 통해 얻은 교훈은 명확하다.

프레임워크의 기본값(Default)을 맹신하지 말자.

max: 10은 로컬 개발용이지 실서비스용이 아니다.

도로(Connection)를 뚫었으면 차(Query)도 튜닝해야 한다. 인덱스 없는 DB는 스포츠카를 끌고 논두렁을 달리는 것과 같다.

이제 다시 Artillery를 돌리러 가봐야겠다.

이번엔 진짜 초록불만 뜨기를!

마침

다른 글 보기