Vercel에서 AWS Aurora Postgres 연결할 때 max_connections 에러 막는 법
TuBrief 편집팀
2026년 7월 21일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
서버리스 인프라 튜토리얼은 대부분 연결 버튼 몇 번 누르는 과정만 보여줍니다. 문제는 그다음입니다. 코드를 배포하고 트래픽이 조금만 몰려도 DB 커넥션이 터지고, 정적 Access Key 관리하다 식은땀을 흘리거나, 데이터 이동 비용으로 몇백 달러가 찍힌 인보이스를 받게 됩니다.
Vercel 프론트엔드와 AWS 백엔드를 연동할 때 겪는 실무 병목과 해결책을 정리했습니다.
PostgreSQL은 클라이언트가 연결될 때마다 프로세스를 새로 띄웁니다. 프로세스 하나당 메모리를 2MB에서 8MB 이상 먹습니다. Vercel 서버리스 함수는 요청이 오면 무상태 인스턴스를 무제한으로 늘리기 때문에, Aurora Postgres의 max_connections 한계치(100~300개)를 몇 초 만에 채워버립니다.
결국 FATAL: sorry, too many clients already 에러가 뜨면서 DB CPU 점유율이 100%를 찍고 서비스가 죽습니다.
서버리스 함수와 Aurora 사이에 PgBouncer를 두고 트랜잭션 풀링(Transaction Pooling) 모드를 켜야 합니다. 세션 풀링은 커넥션을 1:1로 잡고 있어서 도움이 안 되고, 구문 풀링은 다중 구문 트랜잭션을 못 씁니다. 트랜잭션 풀링은 딱 쿼리 트랜잭션이 실행되는 동안만 DB 커넥션을 할당하고 COMMIT이 떨어지면 즉시 풀로 반환합니다.
pgbouncer.ini 파일의 설정 기준입니다.
[pgbouncer]
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20
reserve_pool_size = 10
max_db_connections = 80
pool_mode = transaction으로 트랜잭션 단위 재사용을 켭니다.max_client_conn은 10000 정도로 잡고 OS의 ulimit -n 한도를 늘려둡니다.default_pool_size는 DB 사용자 및 DB 쌍당 Aurora로 열어둘 커넥션 수입니다. 10~20 사이로 잡습니다.max_db_connections를 80으로 딱 걸어두면 Aurora 인스턴스로 들어가는 실제 커넥션 상한을 강제로 통제할 수 있습니다.Vercel Fluid Compute 환경에서는 여러 실행 호출이 전역 스코프를 공유합니다. 따라서 DB 드라이버 풀을 모듈 스코프에 선언해야 합니다. @vercel/functions 패키지의 attachDatabasePool을 쓰면 함수 인스턴스가 꺼지기 전에 유휴 커넥션을 깔끔하게 정리합니다.
import { Pool } from 'pg';
import { attachDatabasePool } from '@vercel/functions';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 5000,
});
attachDatabasePool(pool);
export default pool;
연결 문자열도 환경별로 나눠야 합니다. 로컬 개발 환경(.env.local)에서는 Aurora 포트(5432)로 바로 붙거나 로컬 PgBouncer를 바라보게 하고, 프로덕션(.env.production)에서는 PgBouncer 포트(6432)와 pgbouncer=true 파라미터를 붙입니다.
# .env.local (Direct Connection)
DATABASE_URL="postgresql://dbuser:dbpassword@aurora-cluster.us-east-1.rds.amazonaws.com:5432/app_dev?sslmode=require"
# .env.production (PgBouncer)
DATABASE_URL="postgresql://dbuser:dbpassword@pgbouncer-proxy.internal:6432/app_prod?sslmode=require&pgbouncer=true"
Vercel 환경 변수에 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 직접 박아넣는 건 위험합니다. 키가 유출되면 인프라 전체가 뚫립니다. AdministratorAccess 같은 관리자 권한을 부여해뒀다면 상황은 더 심각해집니다.
OIDC(OpenID Connect)를 사용하세요. Vercel이 발급한 서명 JWT를 AWS STS의 AssumeRoleWithWebIdentity API에 던져서 1시간짜리 임시 자격 증명을 받아오는 방식입니다. 하드코딩된 키 자체가 사라집니다.
AWS IAM Role 신뢰 관계 정책에는 최소 권한만 정의해야 합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.vercel.com/my-team-slug"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.vercel.com/my-team-slug:aud": "https://vercel.com/my-team-slug",
"oidc.vercel.com/my-team-slug:sub": "owner:my-team-slug:project:my-ai-app:environment:production"
}
}
}
]
}
[oidc.vercel.com/my-team-slug](https://oidc.vercel.com/my-team-slug)를 등록합니다.aud 클레임을 Vercel 팀 URL로 제한해서 다른 조직이 접근하지 못하게 막습니다.sub 클레임 조건으로 프로젝트명(my-ai-app)과 환경(production)을 정확히 찌릅니다. Confused Deputy 공격을 막는 핵심 설정입니다.권한 정책도 필요한 리소스만 엽니다. 특정 S3 버킷과 Lambda 함수 호출권만 부여하는 예시입니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RestrictedS3BucketAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-production-ai-assets",
"arn:aws:s3:::my-production-ai-assets/*"
]
},
{
"Sid": "RestrictedLambdaInvocation",
"Effect": "Allow",
"Action": [
"lambda:InvokeFunction"
],
"Resource": [
"arn:aws:lambda:us-east-1:123456789012:function:python-ml-inference-service"
]
}
]
}
Node.js 코드에서는 @vercel/oidc-aws-credentials-provider를 이용해 임시 자격 증명을 가져옵니다.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
const credentials = awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN!,
});
const s3Client = new S3Client({ region: 'us-east-1', credentials });
const lambdaClient = new LambdaClient({ region: 'us-east-1', credentials });
export async function POST(req: Request) {
const body = await req.json();
await s3Client.send(new PutObjectCommand({
Bucket: 'my-production-ai-assets',
Key: `inputs/${Date.now()}.json`,
Body: JSON.stringify(body),
}));
const lambdaRes = await lambdaClient.send(new InvokeCommand({
FunctionName: 'python-ml-inference-service',
Payload: Buffer.from(JSON.stringify(body)),
}));
return Response.json({
status: 'success',
result: JSON.parse(Buffer.from(lambdaRes.Payload!).toString()),
});
}
Vercel 프론트엔드가 도쿄 PoP에서 돌고 AWS 백엔드가 미국 동부(us-east-1)에 있다면, 요청 한 번 오갈 때마다 150ms에서 250ms의 물리적 레이턴시가 얹어집니다.
자주 바뀌지 않는 API 응답은 Vercel Edge Middleware 단에서 캐싱해서 백엔드 호출 자체를 안 넘어가게 만드는 게 좋습니다.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: ['/api/v1/ml-models/:path*'],
};
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=120'
);
response.headers.set(
'Vercel-CDN-Cache-Control',
's-maxage=300, stale-while-revalidate=600'
);
return response;
}
s-maxage=60, stale-while-revalidate=120을 주면 60초 동안은 Edge 캐시에서 즉시 응답을 쳐주고, 캐시가 만료된 뒤 120초까지는 일단 구버전 응답을 보여주면서 백그라운드에서 캐시를 갱신합니다.
서비스 간 트레이싱을 연결하려면 OpenTelemetry의 W3C TraceContext 규격(traceparent 헤더)을 전파해야 합니다. Vercel 설정입니다.
// instrumentation.ts
import { registerOTel } from '@vercel/otel';
export function register() {
registerOTel({
serviceName: 'vercel-frontend-service',
instrumentationConfig: {
fetch: {
propagateContextUrls: ['api.my-aws-backend.com', '*.amazonaws.com'],
},
},
});
}
AWS FastAPI 단에서도 똑같이 OpenTelemetry 수신기를 달아주면 프론트엔드부터 백엔드까지 단일 Trace ID로 이어집니다.
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
app = FastAPI(title="Python ML Service")
FastAPIInstrumentor.instrument_app(app)
@app.get("/api/v1/ml-models/predict")
async def predict():
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("ml_inference_execution"):
return {"status": "completed", "prediction": [0.95, 0.05]}
비용 사고는 보통 두 곳에서 터집니다. Vercel의 Fast Data Transfer 초과 요금(1TB 넘어가면 GB당 $0.15)과 AWS의 Data Transfer Out 요금(GB당 $0.09)입니다. 여기에 트래픽이 몰릴 때 서버리스 함수와 Lambda가 동시 스케일링을 시작하면 계산서 단위가 달라집니다.
Vercel 지출 한도 웹훅을 받아서 Slack으로 알림을 쏘는 API Route 예시입니다. HMAC SHA1 서명 검증을 거쳐야 안전합니다.
import crypto from 'crypto';
export async function POST(req: Request) {
const payload = await req.text();
const signature = req.headers.get('x-vercel-signature');
const expectedSignature = crypto
.createHmac('sha1', process.env.VERCEL_SPEND_WEBHOOK_SECRET!)
.update(payload)
.digest('hex');
if (signature !== expectedSignature) {
return new Response('Invalid Signature', { status: 401 });
}
const event = JSON.parse(payload);
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Vercel 지출 경고: 예산 한도(${event.payload.spendAmount} USD) 도달.`,
}),
});
return new Response('OK', { status: 200 });
}
안전장치 두 가지는 꼭 켜두세요.