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