donghakim.dev — zsh
← ls ../blog

모듈 싱글톤 커넥션 풀이 서버리스에서 DB 커넥션을 고갈시킬 때

Amplify SSR 런타임은 배포당 하나가 아니라 인스턴스마다 풀을 새로 만든다

모듈 싱글톤 커넥션 풀이 서버리스에서 DB 커넥션을 고갈시킬 때 · cover

운영 admin 의 RDS 가 간헐적으로 커넥션 부족을 토했다. 두 writer 모두 한도(max_connections = 90) 근처까지 차 있었다.

DB writer A   max_connections=90   Threads_connected=41   Max_used=100
DB writer B   max_connections=90   Threads_connected=49   Max_used= 99

SHOW PROCESSLIST 를 떠 보니 실행 중인 쿼리보다 Sleep 상태의 연결이 대부분이었다. 특정 쿼리가 커넥션을 오래 물고 안 놓는 누수가 아니라, 여러 곳이 idle 연결을 조금씩 쥐고 있어서 총량이 차오르는 형태다. 누수를 찾으려고 쿼리를 뒤지면 헛걸음하는 종류의 증상이다.

커넥션 풀 코드 자체는 평범했다. 모듈 레벨에서 풀을 한 번 만들어 재사용하는, 흔한 패턴이다.

// 모듈 싱글톤, 한 번 만들어 재사용
const pool = mysql.createPool({
  waitForConnections: true,
  connectionLimit: 5,
  maxIdle: 2,
  idleTimeout: 60000,
  enableKeepAlive: true,
});

단일 서버 프로세스라면 이 풀은 정말 하나다. 그런데 Amplify Hosting 의 compute(Next.js SSR / API routes) 는 요청을 관리형 런타임 인스턴스 위에서 돌린다. 트래픽이 늘면 인스턴스가 fan-out 되고, 모듈 싱글톤은 그 인스턴스 경계를 넘지 못한다. 즉 풀은 "배포당 하나" 가 아니라 "warm 인스턴스마다 하나" 다.

그러면 곱셈이 시작된다. DB 가 세 개(admin / user / prod-admin) 면 인스턴스마다 풀 3개, 풀마다 connectionLimit: 5 에 idle 에도 maxIdle: 2, warm 인스턴스가 N 개면 idle 연결만 N × 3 × 2 다. max_connections = 90 인 RDS 앞에서 인스턴스가 10개만 떠도 idle 연결이 한도를 위협한다. 로컬에서 5짜리 풀은 작아 보이지만, 서버리스에서는 그 5가 인스턴스 수만큼 복제된다.

여기에 한 겹이 더 있었다. 일부 핫 경로(로그인, 배너 조회) 가 풀을 안 쓰고 raw 커넥션을 직접 만들고 있었다.

// ❌ 풀의 connectionLimit 상한을 우회. 요청마다 새 TCP 커넥션
const conn = await mysql.createConnection({ /* ... */ });

end() 로 닫긴 하지만 이건 풀 상한 바깥에서 즉석 연결을 만든다. 로그인이 몰리는 순간 RDS 에 추가 연결이 그대로 꽂힌다. 풀로 상한을 잡아 놔도 우회로가 하나 있으면 상한은 의미가 없다.

어떻게 고쳤나

두 방향으로 잡았다. 먼저 raw createConnection() 을 없애고 모든 쿼리를 풀 경유로 돌렸다.

// before: 풀 우회
const conn = await mysql.createConnection({ /* ... */ });

// after: 공용 풀 사용
const [rows] = await getPool().execute(sql, params);

그리고 인스턴스당 풀 점유를 줄였다.

mysql.createPool({
  connectionLimit: 2,   // 5 → 2
  maxIdle: 1,           // 2 → 1
  // ...
});

connectionLimitmaxIdle 을 줄이면 인스턴스 하나가 쥐는 연결이 작아지고 곱셈의 계수가 내려간다. 인스턴스 수(N) 는 우리가 못 정하니, 곱해지는 쪽을 줄이는 게 현실적인 1차 완화다.

근본적으로는 RDS Proxy 를 앞단에 둬서 인스턴스들의 fan-out 연결을 DB 앞에서 흡수하게 하는 게 정석이다. 서버리스 + 관계형 DB 조합에서 커넥션 폭발은 구조적 문제라, 애플리케이션 설정만으로는 상한을 못 없애고 미루기만 한다.


증상만 보면 헷갈리기 쉽다. DB 커넥션이 한도 근처인데 SHOW PROCESSLISTSleep 위주면, 쿼리 누수가 아니라 idle 풀이 총량을 잠식하는 거다. 앱이 서버리스(Amplify SSR / Lambda) 이고 트래픽 스파이크 때 악화되거나, Max_used_connectionsconnectionLimit × DB수 의 정수배에 가깝게 찍히면 인스턴스 fan-out 을 의심하면 된다.

이 문제의 뿌리는 "모듈 싱글톤은 전역" 이라는, 단일 서버 시절엔 옳았던 직관이다. 서버리스에서 모듈 스코프는 하나의 런타임 인스턴스 수명만큼만 전역이다. 인스턴스가 늘면 싱글톤도 그만큼 늘고, 풀·캐시·카운터처럼 "하나여야 의미가 있는" 것들이 조용히 복제된다. Amplify SSR 에서 server-only 환경변수가 런타임에 안 들어오던 문제 도 결국 "빌드 시점" 과 "런타임 인스턴스" 가 다른 세계라는 같은 뿌리다. 서버리스를 쓸 땐 "이 코드가 인스턴스마다 한 번씩 도는가, 배포에 한 번 도는가" 를 항상 물어야 한다.

#amplify#aurora#connection-pool#lambda#mysql2#nextjs#rds#serverless#ssr#troubleshooting