AWS Lambda와 Connection Pool 사용 시 발생한 응답 지연 문제 해결기
API 응답이 10초 이상 걸려요!!

배경
개발자님! 화면이 너무 늦게 떠요!!
이번 주에 매출 통계 기능을 개발해서 개발 서버에 올렸는데 테스팅을 하다가 갑자기 긴급 호출이 들어왔다. API 속도가 너무 느리다는데 얼마나 느리길래 그런지 확인해봤더니 무려 10~16초가 걸렸다.
신규 레포지토리를 파서 개발한 거라 설정에 문제가 있었나? VPC 간 통신 때문에 지연되는 건가? 별의별 생각이 다 들어서 각 모듈별로 실행되는 시간, 실제 쿼리하는 시간을 전부 측정해봤는데 10~16초나 걸릴 만한 이유를 찾지 못했다.
실제 동작이 query string 받아서 SELECT * From table WHERE a = ? AND b = ? ORDER BY c DESC 정도만 수행하는 아주 간단한 API 였는데, 너무 이상한 수치였다.
CloudWatch 로그를 아무리 뒤져봐도 에러도 보이지 않았고,
코드의 각 부분이 실행되는 시간을 측정해봐도 2초 이내에 모든 실행이 완료되고 있었다.
이상한 점은,
지정한 동작(쿼리 실행, 결과 가공 등)은 모두 끝났는데도
응답을 바로 반환하지 않고 6초 이상 대기한 뒤에야 응답을 반환하고 있다는 것
이었다.
즉, “로직은 다 끝났는데, 어딘가에서 응답이 붙잡혀 있는 상태” 였다.
Connection Pool과 유휴 연결
결론부터 말하면, 문제의 1차적인 원인은
connection pool의 유휴 연결 때문에 Node.js 이벤트 루프가 완전히 비지 않았고,
그로 인해 Lambda가 응답을 바로 반환하지 못하고 지연이 발생한 것이었다.
사용하고 있던 라이브러리는 pg.Pool이었고, idleTimeoutMillis 기본값은 10초다.
node-postgres 문서의 설명은 다음과 같다:
number of milliseconds a client must sit idle in the pool and not be checked out
before it is disconnected from the backend and discarded
default is 10000 (10 seconds) – set to 0 to disable auto-disconnection of idle clients
즉, 한 번 빌려갔다가 돌려준 커넥션이 최소 10초 동안 idle 상태로 pool 안에 남아 있고, 이 동안에는 소켓/타이머 레퍼런스가 살아 있기 때문에 Node.js 이벤트 루프 입장에서는 “아직 완전히 idle이 아니다” 라고 보는 것이다.
Lambda handler 방식과 이벤트 루프의 관계
여기서 자연스럽게 이어지는 질문은 이것이다.
“idle connection이 이벤트 루프를 잡고 있는 건 알겠는데,
이벤트 루프가 안 비면 왜 응답이 늦게 나가는 걸까?”
그 이유는 Lambda handler 방식 때문이다.
Node.js에서 Lambda handler는 크게 두 가지 방식이 있다.
async/await 기반
exports.handler = async (event, context) => { const result = await doSomething(event); return { statusCode: 200, body: JSON.stringify(result), }; };callback 기반 (공식 문서 기준으로 Node.js 22.x까지만 지원된다)
exports.handler = (event, context, callback) => { doSomething(event, (err, result) => { if (err) { return callback(err); } callback(null, { statusCode: 200, body: JSON.stringify(result), }); }); };
callback 방식에서는 기본적으로
콜백을 호출한 뒤에도
이벤트 루프에 남아 있는 작업(타이머, 소켓 등)이 모두 사라질 때까지,
또는 함수 타임아웃에 도달할 때까지
실행을 계속 유지하려는 동작을 한다.
그래서 callback 방식으로 구현한 상태에서 이벤트 루프를 붙잡고 있는 idle connection이 있으면,
로직은 이미 끝났는데도 응답이 바로 나가지 않고 지연되는 상황이 만들어질 수 있다.
fastify-aws-lambda 내부는 callback 기반이었다
Fastify 앱을 Lambda로 올릴 때 우리는 아래와 같은 형태로 간단하게 래퍼만 호출하고 있었다.
const awsLambdaFastify = require('@fastify/aws-lambda');
const { createApp } = require('./createApp');
const app = createApp();
const proxy = awsLambdaFastify(app);
module.exports.handler = proxy;
코드만 보면 이 핸들러가 async/await 기반인지, callback 기반인지 전혀 알 수 없다.
그런데 내부 구현을 확인해보니, aws-lambda-fastify는 Fastify 앱을 감싸면서 최종적으로 Lambda에 등록되는 handler를 (event, context, callback) 형태의 callback 기반으로 생성하고 있었다.
그 결과,
callback 스타일의 **기본 동작(이벤트 루프가 완전히 빌 때까지 실행을 유지하려는 동작)**이 그대로 적용되고
pg.Pool의 idle connection이 이벤트 루프를 비우지 못하는 상황과 맞물리면서로직은 이미 다 끝났는데도 응답이 수 초 동안 지연되는 현상이 발생했다.
해결방법
1) Lambda context.callbackWaitsForEmptyEventLoop = false
먼저, Node.js Lambda의 Context 객체는
함수 이름, 버전, 메모리 등 실행 환경에 대한 정보와 몇 가지 설정 값을 제공하는데,
그중 하나가 callbackWaitsForEmptyEventLoop다. 이 값은 callback 기반 핸들러에서만 의미가 있는 옵션이다.
문서 설명을 보면:
Node.js 이벤트 루프가 빌 때까지 대기하는 대신, 콜백이 실행될 때 즉시 응답을 보내려면 false로 설정합니다.
이것이 false인 경우, 대기 중인 이벤트는 다음 번 호출 중에 계속 실행됩니다.
즉, 이 값을 false로 설정하면
이벤트 루프가 완전히 비기까지 기다리지 않고
이미 준비된 응답을 바로 반환하고
남아 있는 타이머/소켓 같은 것들은 다음 호출에서 재사용될 수 있도록 놔두는
동작을 하게 된다.
Fastify를 Lambda로 올릴 때는 aws-lambda-fastify를 통해 handler를 만들고 있었기 때문에,
이 옵션을 래퍼에 직접 넘겨서 동작을 바꿔줄 수 있었다.
const proxy = awsLambdaFastify(app, {
callbackWaitsForEmptyEventLoop: false,
});
module.exports.handler = proxy;
이렇게 설정한 이후에는 로직이 끝난 시점과 클라이언트가 응답 받는 시점이 거의 일치하게 바뀌었고,
10초 가까이 붙잡혀 있던 지연 현상도 없어졌다.
2) 유휴 연결시간 줄이기
유휴 연결시간 자체를 줄여서 이벤트 루프에 idle 연결이 더 짧게 남도록 조정하는 방법도 시도했봤다.
const { Pool } = require('pg');
const pool = new Pool({
idleTimeoutMillis: 1000, // 1초로 설정
});
기본값(10초)으로 설정했을 때보다 확실히 응답 시간이 줄어들긴 했지만,
여전히 응답이 준비된 이후에 잠깐의 대기 시간이 생기는 문제가 있었다.
그렇다고 idleTimeoutMillis를 너무 짧게 설정하면,
비용이 큰 데이터베이스 연결을 Lambda가 실행될 때마다 계속 새로 시도해야 하는 문제가 생긴다.
결국,
context.callbackWaitsForEmptyEventLoop = false로 응답을 먼저 보내도록 만들고idleTimeoutMillis는 사용 패턴에 맞는 적절한 값으로 조정하는 것
이 현실적인 타협점이라고 판단했다.
결론
삽질했던 시간보다 훨씬 간단하게 해결한 문제였다.
이번에 겪은 일은, 결국 내가 사용하고 있는 플랫폼에 대한 이해 부족에서 비롯된 문제였다. 요즘은 AWS 서비스들이 워낙 잘 되어 있다 보니 “설마 인프라 쪽에 원인이 있겠어?”라는 생각으로, 코드만 붙잡고 있었던 것도 한몫했다. 앞으로는 코드에만 집중하기보다, 런타임·플랫폼·네트워크까지 포함해서 문제를 좀 더 다각도로 바라보려 한다.
이번에 크게 느낀 점은 두 가지다.
첫째, 라이브러리의 겉 API만 보지 말고, 중요한 경계(Lambda handler, DB 커넥션, 이벤트 루프 등)를 다루는 부분의 구현은 한 번쯤 직접 뜯어보자는 것이다. 이번에도 aws-lambda-fastify 내부가 callback 기반이라는 사실을 알고 나서야 퍼즐이 맞춰졌다.
둘째, 서버리스를 사용할 때는 기본 동작과 내가 적용한 설정이 어떻게 맞물리는지 반드시 이해해야 한다. Lambda의 실행 모델, handler 방식(async/await vs callback), Connection Pool의 동작 방식, idleTimeoutMillis, callbackWaitsForEmptyEventLoop 같은 설정은 실제 성능과 응답 지연에 직접적인 영향을 준다. “서버리스니까 알아서 처리해주겠지”라는 기대보다는, 내 코드가 서버리스 환경에서 어떻게 실행되는지 명확히 이해하고 사용하는 것이 중요하다는 점을 다시 한 번 느꼈다.
이런 것들을 한 번 겪어두면, 다음 비슷한 장애가 왔을 때 “코드만 들여다보는” 시간은 조금 줄어들 거라고 기대하고 있다.


