TypeORM 커넥션 풀을 여태 기본값으로 두고 있어서 이번에 손대보기로 했다. 넘기는 옵션에 풀 관련 항목이 한 줄도 없었다.
return { type: 'mysql', host: configService.get('DB_HOST'), // ... 자격증명과 timezone뿐, extra 없음};extra가 없으니 드라이버 기본값이 그대로 쓰인다. 그런데 extra: { connectionLimit: 30 }을 적으려다 30이 어디서 나온 숫자인지 댈 수가 없었다. N+1을 걷어낼 때도 이 기본값을 인용만 하고 재보지는 않았다.
기본값이 각각 무엇을 정하나
mysql2의 풀 설정 기본값은 소스에 있다.
this.waitForConnections = options.waitForConnections === undefined ? true : ...this.connectionLimit = isNaN(options.connectionLimit) ? 10 : ...this.queueLimit = isNaN(options.queueLimit) ? 0 : ...커넥션은 최대 10개, 다 쓰고 있으면 기다리고, 대기 큐에는 상한이 없다. queueLimit: 0이 큐를 안 쓴다는 뜻으로 읽히기 쉬운데 상한이 없다는 뜻이다.
세 값이 합쳐지면 포화의 성격이 정해진다. 커넥션이 모자라면 예외가 나는 게 아니라 큐에 쌓이고, 큐가 무한이라 언제까지고 쌓인다. 그래서 증상이 에러가 아니라 지연이다.
포화시키면 무슨 일이 나나
MySQL 8.0 컨테이너를 띄우고 SELECT SLEEP(0.05)을 동시에 던졌다. 쿼리 하나가 50ms 걸리는 상황을 만들어 두고 동시 요청 수를 올린다.
connectionLimit=10, 쿼리당 50ms, queueLimit=0(무제한)
동시 대기(enqueue) 커넥션 생성 wall(ms) p50 p95 max 5 0 5 87 81 84 84 10 0 10 60 58 59 59 20 10 10 125 117 121 121 50 40 10 316 191 305 306 100 90 10 585 345 570 577 200 190 10 1118 620 1114 1115세 가지가 눈에 걸린다.
대기 수가 정확히 동시 − 10이다. 20이면 10건, 200이면 190건이 큐를 거친다. 커넥션 생성은 10에서 멈추고 더 늘지 않는다.
200건을 한꺼번에 던져도 실패가 0건이다. 풀이 고갈돼 예외가 터지는 그림을 상상하기 쉬운데 기본 설정에서는 그런 일이 안 일어난다. 전부 성공하고, 대신 마지막 요청은 1.1초를 기다린다. 쿼리 자체는 50ms다.
전체 소요가 올림(동시/10) × 50ms에 붙는다. 200이면 20묶음이라 1,000ms이고 실측이 1,118ms다. 풀이 처리량의 상한을 정하고 있다는 게 이 숫자로 보인다.
풀을 키우면 얼마나 좋아지나
같은 부하(동시 100건)에서 connectionLimit만 바꿨다.
풀크기 성공 실패 대기 wall(ms) p50 p95 10 100 0 90 585 358 579 20 100 0 80 309 181 297 40 100 0 60 193 124 179 80 100 0 20 181 125 14610에서 20으로 올리면 절반이 되고, 20에서 40으로 올리면 다시 3분의 2가 된다. 그런데 40에서 80으로는 193ms가 181ms로 거의 안 줄어든다. 커넥션을 두 배 더 열어도 얻는 게 12ms다.
이 지점에서 병목이 풀에서 다른 데로 옮겨간 것이다. 이 실측에서는 컨테이너의 CPU와 MySQL 쪽 처리가 상한이 됐다. 풀 크기를 40 위로 올리면 앱에서 기다리던 요청이 DB에서 기다리게 될 뿐이라, 이 컨테이너에서는 40이 올려서 얻는 게 있는 마지막 값이었다.
대기를 에러로 바꾸고 싶으면
queueLimit을 걸면 성격이 바뀐다. 같은 100건을 풀 10에 던졌다.
queueLimit 성공 실패 wall(ms) 무제한 100 0 565 20 30 70 206 50 60 40 347큐를 20으로 막으면 30건만 성공한다. 커넥션 10개가 처리 중이고 큐에 20개가 대기하고 있으니 그 이후는 전부 즉시 거절이다. 응답은 206ms로 빨라지는데, 빨라진 이유가 70건을 안 했기 때문이다.
그래서 이 값은 성능 설정이 아니라 정책 설정이다. 부하가 몰릴 때 모두를 느리게 받을지, 일부를 빠르게 거절할지를 고르는 값이다.
queueLimit은 무제한 그대로 뒀다. 워커 크론 세 개(연차 적립·구독 이력·반복 업무)가 자정에 한꺼번에 뜨고, 연차 촉진 계산은 7월 1일과 10월 1일 아침 9시에 한 번씩만 돈다. 이런 잡이 커넥션을 못 받아 즉시 거절되면 다음 기회가 하루 뒤이거나 몇 달 뒤라, 몇백 ms 기다리는 쪽이 낫다.
커넥션을 오래 쥐는 작업
지금까지의 실측은 쿼리 하나가 커넥션을 50ms 쓰고 돌려주는 모델이다. 실제 코드에는 훨씬 오래 쥐는 것들이 있고, 그쪽이 풀에 미치는 영향이 다르다.
트랜잭션은 커밋할 때까지 커넥션 하나를 붙잡는다. 그 안에서 쿼리를 다섯 번 하든 외부 계산을 하든 커넥션은 계속 그 트랜잭션 것이다.
그래서 PG 호출을 트랜잭션 밖으로 뺀 것이 정합성 이야기이면서 동시에 풀 이야기이기도 하다. 외부 HTTP가 3초 걸리는 동안 트랜잭션을 열어두면 그 3초 내내 커넥션 하나가 묶인다. 열 개짜리 풀에서 그런 요청이 열 개면 나머지는 전부 대기다.
분산 락은 커넥션을 두 개 쓴다. GET_LOCK은 세션에 묶이는 락이라 락을 쥔 커넥션을 잡 전체 기간 동안 고정해야 하고, 그 잡이 트랜잭션까지 쓰면 하나를 더 쓴다.
가드 경로 1건당 커넥션 2개(락용 +
fn의 트랜잭션용)를 쓴다. 동시 가입 폭주 시 풀 소모가 2배 → 부하 테스트 시extra.connectionLimit상향을 검토.
잡 락 유틸 주석에서 가져온 문장이다. 같은 주석에 적어둔 다른 경로가 더 나쁘다. 커넥션 반납이 실패하면 그 커넥션은 풀로 돌아가지도 닫히지도 않은 채 유실되는데, 풀은 여전히 “사용 중”으로 세므로 반복되면 열 개가 다 그렇게 사라진다. 그래서 반납이 실패하면 그 커넥션을 파기해 회수하도록 했다.
앞의 실측에서 200건을 던져도 실패가 0건이었던 걸 생각하면, 열 개를 정말로 다 잃는 건 부하보다 이 반납 실패 쪽이다.
prod은 지금 어디쯤인가
여기까지가 메커니즘이고, 실제로 그 근처에 가 있는지는 별개다. RDS의 DatabaseConnections를 30일치 뽑아봤다.
prod DB (db.t3.small, MySQL 8.0) 30일 · 1시간 단위 721포인트 최대 동시 커넥션 41 평균 18.9 서버 max_connections 약 170 ({DBInstanceClassMemory/12582880})한계의 24%다. DB 쪽 여유는 충분하다.
앱이 열 수 있는 커넥션 수를 세어보면 이렇다. prod 백엔드 태스크가 2개, 워커가 1개이므로 풀이 세 벌이고 각각 10이라 정상 상태의 상한이 30이다. 그런데 관측된 피크는 41이다.
30을 넘는 이유는 배포다. ECS 서비스의 maximumPercent가 200이라 롤아웃 중에는 구 태스크와 신 태스크가 겹쳐 최대 두 배가 뜬다. 그러면 상한이 60이 된다. 실제로 상위 피크 다섯 개의 시각을 배포 이력과 맞춰보니 전부 그날 배포가 있었다.
41 2026-07-13 12:10 그날 배포 2건39 2026-07-29 10:10 4건39 2026-07-29 09:10 4건37 2026-07-28 13:10 14건34 2026-08-03 10:10배포가 없는 날의 일별 최대는 23~30 사이다.
DatabaseConnections로 답이 안 나오는 질문
여기까지 재고 나서 원래 물으려던 질문에 답을 못 한다는 걸 알았다. 풀 하나가 실제로 한도에 닿아 요청을 큐에 세운 적이 있는가.
DatabaseConnections는 서버가 보는 값이라 모든 태스크의 커넥션을 합쳐 놓은 것이다. 백엔드 A가 10을 다 쓰고 요청을 대기시키는 동안 백엔드 B가 2개만 쓰고 있으면 합계는 12이고, 그 12에는 A가 포화됐다는 사실이 안 들어 있다. 합계가 30 아래라는 것과 어떤 풀도 안 막혔다는 것은 다른 말이다.
앞의 실측 표에서 그 사실을 세는 값이 이미 나왔다. mysql2 풀은 커넥션을 즉시 못 주면 enqueue 이벤트를 낸다. 그 수가 곧 “기다린 요청 수”다.
풀 상태를 재는 게이지
이미 있던 Prometheus 레지스트리에 두 개를 얹었다.
this.dbPoolEnqueueTotal = new Counter({ name: 'db_pool_enqueue_total', help: '커넥션을 즉시 못 받아 대기 큐에 들어간 요청 수(풀 포화 신호)', registers: [this.registry],});
this.dbPoolConnections = new Gauge({ name: 'db_pool_connections', labelNames: ['state'], // limit · open · free · queued registers: [this.registry], collect: () => { /* 스크랩 시점에 풀 내부를 읽는다 */ },});게이지에 폴링 타이머를 두지 않은 건 스크랩 주기와 어긋나기 때문이다. 5초마다 값을 재서 캐시해두면 Prometheus가 물어본 순간의 값이 아니라 최대 5초 전의 값을 주고, 순간적으로 큐가 찼다 빠지는 구간은 그 사이에 사라진다. collect 콜백은 물어보는 그 순간에 실행된다.
풀은 부팅 직후가 아니라 onApplicationBootstrap에서 붙인다. 그 시점에야 TypeORM 드라이버가 풀을 만들어 두기 때문이고, 못 찾으면 경고 로그를 남기고 넘어간다.
읽는 값은 전부 mysql2의 비공개 속성이다. _allConnections·_freeConnections·_connectionQueue인데, 라이브러리를 올렸을 때 이름이 바뀌면 예외가 나는 게 아니라 조용히 0을 보고한다. 0으로 보고되는 계측은 “포화 없음”과 구분이 안 되므로, 0건이 안전을 뜻하지 않는다는 같은 함정이다. 그래서 실 MySQL을 띄우는 통합 테스트를 붙였다.
it('한도를 넘는 동시 쿼리는 enqueue로 잡힌다', async () => { const limit = await read('db_pool_connections', 'limit'); const before = await read('db_pool_enqueue_total');
await Promise.all( Array.from({ length: limit * 3 }, () => db.dataSource.query('SELECT SLEEP(0.05)')), );
expect(await read('db_pool_enqueue_total')).toBeGreaterThan(before);});한도의 세 배를 동시에 던져 카운터가 실제로 올라가는지를 본다. mock으로는 필드 이름이 맞는지를 물어볼 수 없어서 실 풀에 붙여야 하는 종류다.
안 바꾼 설정과 붙인 계측
connectionLimit은 10 그대로 두고, queueLimit도 무제한 그대로 둔다. prod DB 피크가 41이고 서버 한계가 약 170이라 DB 쪽에 여유가 있고, 앱 쪽 상한 30을 넘긴 관측치는 전부 배포 중 태스크 겹침으로 설명된다. 값을 올릴 근거가 아직 없다.
바꾼 건 설정이 아니라 관측이다. db_pool_enqueue_total이 0보다 커지면 어떤 태스크의 풀이 몇 번 막혔는지가 남는다.
재고 나서 알게 된 한계
붙여 놓고 보니 세 가지가 남는다.
워커 풀은 /metrics에 안 실린다. 워커는 NestFactory.createApplicationContext로 뜨는 프로세스여서 HTTP 서버가 아예 없고, /metrics는 HTTP 앱에 등록된 컨트롤러다.
그런데 커넥션을 넓게 쓰는 쪽이 워커다. 워커의 TypeORM 설정에도 extra가 없어 풀이 똑같이 10인데, 아웃박스 발송은 대기 행 20건을 꺼내 한 건씩 도니 커넥션을 하나만 쥐지만 연차 촉진 계산은 직원·잔액·계획·촉진이력 네 쿼리를 Promise.all로 한꺼번에 던진다. 회사마다 따로 조회하던 걸 N+1 때문에 한 번에 로드하도록 바꾼 코드인데, 그 대가로 한 잡이 순간 커넥션 네 개를 쓴다.
잡 락이 세션을 하나 더 붙잡으면 다섯 개이고, 자정에 크론 세 개가 겹치면 열 개짜리 풀의 절반이 잡 두 개에 들어간다.
세지 못한 대기 시간
얼마나 기다렸는지는 안 남는다. db_pool_enqueue_total은 줄을 선 횟수만 센다. 앞의 실측에서 동시 200일 때 마지막 요청이 1.1초를 기다렸는데, 그 1.1초에 해당하는 값이 메트릭에는 없다. 이벤트로 재려면 줄에 선 시각과 커넥션을 받은 시각의 차를 봐야 하는데, mysql2가 이벤트로 내보내는 건 앞쪽뿐이다.
this.emit('enqueue'); // 인자 없음 — 누가 섰는지 모른다return this._connectionQueue.push(cb);
// releaseConnection — 대기하던 요청에 커넥션을 넘기는 곳} else if (this._connectionQueue.length) { cb = this._connectionQueue.shift(); process.nextTick(cb.bind(null, null, connection)); // 여기서는 아무 이벤트도 안 난다}acquire는 자유 커넥션을 바로 집거나 새로 만들 때만 나오고, 큐가 빠지는 경로에서는 안 나온다. 대기 시간을 재려면 이벤트 말고 getConnection 호출 자체를 감싸야 한다.
속성 이름이 바뀌면 조용히 0이 된다. 통합 테스트가 이걸 잡지만 그건 CI에서 도는 일이고, 배포된 prod에서 게이지가 실제로 도는지는 별개다. 판정은 db_pool_connections{state="limit"} 하나로 된다. 이 값이 10이 아니라 0으로 보이면 풀에 못 붙은 것이다.
그래서 지금 남는 건 백엔드 두 태스크의 값뿐이고, 배치가 도는 프로세스는 여전히 안 보인다.
로컬에서 잰 곡선을 prod에 그대로 옮길 수 없다는 것도 재고 나서 알았다. 앞의 표에서 가장 크게 떨어진 구간이 10에서 20으로 올릴 때(585ms → 309ms)였고, 40 위로는 12ms밖에 안 줄었는데 그 구간에서 상한을 만든 건 풀이 아니라 컨테이너의 MySQL 처리였다. prod DB가 어느 크기에서 같은 벽에 닿는지는 안 재봤다.
db_pool_enqueue_total이 0을 넘는 날 connectionLimit을 20으로 올려보고, 그래도 대기가 남으면 40으로 가기 전에 prod DB 쪽을 재봐야겠다. 그 전에 워커에 메트릭만 내보내는 포트를 하나 열어서, 배치가 커넥션을 몇 개나 쥐는지도 보면 좋을 것 같다.