모듈 싱글톤 커넥션 풀이 서버리스에서 DB 커넥션을 고갈시킬 때
Amplify SSR 런타임은 배포당 하나가 아니라 인스턴스마다 풀을 새로 생성합니다

운영 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 연결이 60개가 되어 한도에 가까워집니다. 로컬에서 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
// ...
});
connectionLimit 과 maxIdle 을 축소하면 인스턴스 하나가 점유하는 연결 수가 감소해 곱해지는 값이 작아집니다. 인스턴스 수(N) 는 우리가 결정하지 못하니, 곱해지는 쪽을 축소하는 게 현실적인 1차 완화입니다.
근본적으로는 RDS Proxy 를 앞단에 배치해 인스턴스들의 fan-out 연결을 DB 앞에서 모아 처리하게 하는 것이 일반적인 해법입니다. 서버리스 + 관계형 DB 조합에서 커넥션 수 급증은 구조적 문제라, 애플리케이션 설정만으로는 한도에 도달하는 시점을 지연시킬 뿐 문제를 해소하지는 못합니다.
증상만 보면 혼동하기 쉽습니다. DB 커넥션이 한도 근처인데 SHOW PROCESSLIST 가 Sleep 위주면, 쿼리 누수가 아니라 idle 풀이 전체 커넥션 수를 점유하고 있는 것입니다. 앱이 서버리스(Amplify SSR / Lambda) 이고 트래픽 스파이크 때 악화되거나, Max_used_connections 가 connectionLimit × DB수 의 정수배에 가깝게 기록되면 인스턴스 fan-out 을 의심하면 됩니다.
이 문제의 원인은 "모듈 싱글톤은 전역" 이라는, 단일 서버 환경에서는 맞았던 가정입니다. 서버리스에서 모듈 스코프는 하나의 런타임 인스턴스 수명만큼만 전역입니다. 인스턴스가 증가하면 싱글톤도 그만큼 증가하고, 풀·캐시·카운터처럼 "하나여야 의미가 있는" 것들이 인스턴스 수만큼 복제됩니다. Amplify SSR 에서 server-only 환경변수가 런타임에 안 들어오던 문제 도 "빌드 시점" 과 "런타임 인스턴스" 가 서로 다른 실행 맥락이라는 같은 원인에서 발생한 문제입니다.