← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-13-stateless-function-serverless.md

BACKEND9 min read

상태를 잊은 함수 — 서버리스 안티패턴

스케일 아웃된 함수가 DB 커넥션을 고갈시키는 이유, 콜드 스타트의 네 단계, Lambda Pinball, 그리고 서버리스가 오히려 정답인 경우.

트래픽이 늘자 애플리케이션이 아니라 데이터베이스가 먼저 거절한다

프로모션이 시작되고 요청량이 평소의 다섯 배가 됐다. 함수는 자동으로 스케일 아웃됐다. 그런데 응답의 상당수가 500을 뱉고, 로그에는 애플리케이션 예외가 아니라 이 줄이 찍힌다.

text
error: sorry, too many clients already

함수 코드에는 버그가 없다. 오토스케일링도 정상 작동했다. 정확히 말하면 오토스케일링이 정상 작동했기 때문에 장애가 났다. 문제의 코드는 이렇게 생겼다.

ts
// api/orders.ts — Before: 핸들러 안에서 풀을 만든다
import { Pool } from "pg"
 
export async function handler(event: APIGatewayProxyEvent) {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL })
  const { rows } = await pool.query("select id, status from orders where id = $1", [
    event.pathParameters!.id,
  ])
  await pool.end()
  return { statusCode: 200, body: JSON.stringify(rows[0] ?? null) }
}

Express 앱에서라면 이 코드는 명백히 이상해 보인다. 커넥션 풀을 요청마다 만들고 버리는 건 누구나 지적한다. 그런데 서버리스로 옮겨오면 오히려 "정석"처럼 보인다. 함수는 매번 새로 시작하고 상태를 들고 있으면 안 된다고 배웠기 때문이다.

서버를 지웠더니 서버가 주던 것들도 같이 지워졌다

3막(9~12편)에서 다룬 것은 경계를 프로세스 경계로 만든 대가였다. 서비스마다 배포 파이프라인·스케일링 정책·패치 주기·모니터링 설정이 필요해졌고, 서비스 12개면 이 운영 표면이 12벌이다. 마이크로서비스가 약속한 팀 자율성의 상당 부분이 여기서 잠식됐다.

서버리스는 이 문제에 대한 정직한 답이었다. 배포 단위를 함수로 줄이고 인스턴스 수명과 스케일링을 플랫폼에 넘긴다. 요청이 없으면 0으로 줄고 몰리면 알아서 늘어난다. 운영 부담 제거라는 목표에서 이건 실제로 작동했다.

문제는 제거된 것이 운영 부담만이 아니었다는 데 있다. 서버가 제공하던 것에는 관리 부담뿐 아니라 긴 수명을 전제로 한 자원도 들어 있었다.

프로세스가 오래 산다는 전제 위에 커넥션 풀, 인메모리 캐시, 워밍업된 JIT, 열려 있는 소켓이 얹혀 있었다. 프로세스를 지우면 그 위에 얹힌 것들이 같이 사라진다.

여기서 안티패턴이 자란다. 서버 시절 코드를 그대로 들고 오면 수명 전제가 깨지고, 전제를 버리고 매 요청마다 새로 만들면 자원 생성 비용이 요청 수만큼 곱해진다. 두 선택지가 다 틀린 것처럼 보이는 상태가 이 편의 출발점이다.

콜드 스타트는 하나의 사건이 아니라 네 단계다

"처음 호출이 느리다" 정도로 뭉뚱그리면 최적화할 지점을 찾지 못한다. 실제로는 네 단계가 순서대로 일어난다.

  1. 컨테이너 확보 및 초기화 — 플랫폼 영역. 개발자가 줄일 수 없다.
  2. 런타임 부트 — Node.js 프로세스 시작.
  3. 모듈 로드 — 번들의 require/import 실행.
  4. 전역 초기화 — 모듈 스코프 클라이언트 생성, 설정 파싱, 연결 수립.

개발자가 통제할 수 있는 건 3번과 4번이다. 그리고 이 둘은 Node.js에서 특히 비싸다. require는 동기적으로 파일을 읽고 파싱하고 실행하므로, 실제 로드되는 모듈이 수천 개면 이 단계가 곧 파일 시스템 접근 수천 회다. 트리 셰이킹과 서브패스 임포트(@aws-sdk/client-s3만 가져오기)가 콜드 스타트 대책으로 거론되는 이유가 여기 있다.

여기서 흔히 등장하는 해법이 워밍 해킹이다. 5분마다 더미 요청을 보내 인스턴스를 살려둔다. 비용은 명확하다. 하루 288회, 월 약 8,640회의 무의미한 호출이 함수마다 붙는다.

더 큰 문제는 이 해법이 정작 필요한 순간에 무력하다는 것이다. 워밍 핑 하나는 인스턴스 하나만 따뜻하게 유지한다. 동시 요청 50개가 도착하면 플랫폼은 인스턴스 50개를 띄우고, 그중 49개는 콜드다. 콜드 스타트가 문제가 되는 상황은 정의상 트래픽이 급증하는 순간인데, 워밍은 트래픽이 없는 순간에만 효과가 있다. 비용은 상시로 내고 효과는 필요 없을 때만 받는 구조다.

커넥션 폭발: 동시성 100이 한계선이다

이 편에서 가장 명확하게 계산되는 붕괴가 커넥션이다. 구조적 차이는 한 문장으로 정리된다.

서버 기반 앱은 프로세스당 풀을 공유하고, 서버리스는 인스턴스당 풀을 갖는다.

Express 앱을 컨테이너 4대로 돌리고 각 프로세스가 max: 10인 풀을 쓰면 DB가 보는 커넥션은 최대 40개다. 반면 서버리스 함수는 동시 실행 하나가 곧 프로세스 하나이고, 각자 자기 풀을 갖는다.

동시 실행 1,000개 × 함수당 커넥션 1개 = 커넥션 1,000개. PostgreSQL의 기본 max_connections는 100이다. 동시성이 100을 넘는 순간 그 이상의 인스턴스는 전부 연결 거절을 받는다. 앞의 에러가 정확히 이것이다.

max_connections를 1,000으로 올리면 되지 않느냐는 반문이 나온다. PostgreSQL은 커넥션마다 별도 프로세스를 띄우고 작업 메모리를 따로 잡는다. 커넥션당 상주 메모리를 어림잡아 5MB로 놓아도 1,000개면 5GB가 연결 유지에만 쓰이고, 대부분은 쿼리 없이 유휴 상태로 자리만 차지한다. 연결 수를 늘리는 건 문제를 DB 메모리 압박으로 옮기는 것이지 푸는 게 아니다.

핸들러마다 new Pool을 하는 앞의 코드는 한 겹을 더 얹는다. TCP 핸드셰이크와 TLS 협상과 인증을 요청마다 반복하므로, 쿼리가 1ms여도 연결 수립에 수십 ms가 붙는다.

Lambda Pinball: 함수가 함수를 부르기 시작할 때

함수 단위로 쪼개다 보면 함수가 다른 함수를 동기 호출하는 사슬이 생긴다. Thoughtworks Technology Radar가 Lambda Pinball(요청이 함수 사이를 핀볼처럼 튕겨 다니는 구조)이라는 이름으로 지목한 형태다. 세 가지가 동시에 망가진다.

관측. 함수 경계마다 트레이스 컨텍스트를 명시적으로 넘기지 않으면 트레이스가 끊긴다. 함수 5개를 지나는 요청은 끊길 자리를 4군데 갖는다(15편).

비용. 실행 시간에 과금되는 모델에서 동기 대기는 이중 과금이다. A→B→C→D 사슬에서 D가 100ms 걸리고 나머지가 각각 자기 작업 20ms를 한다고 하자. D는 100ms, C는 20+100=120ms, B는 140ms, A는 160ms 동안 살아 있다. 과금 합계는 100+120+140+160 = 520ms, 실제 계산은 100+20+20+20 = 160ms다. 3.25배를 낸다.

지연과 가용성. 12편의 곱셈이 그대로 적용된다. 함수 4개가 각각 99.9%면 사슬 전체는 0.999⁴ = 99.6%, 월 43,200분 기준 43,200 × 0.004 = 약 173분이다. 꼬리 지연도 같다. 각 단계의 p99가 100ms일 때 네 단계 중 최소 하나가 p99 구간에 걸릴 확률은 1 − 0.99⁴ = 3.9%다. 여기에 각 마디가 독립적으로 콜드일 가능성이 겹친다.

함수를 상태 저장소처럼 쓰려는 시도

이 자리는 1편에서 이미 예고됐다. 모듈 스코프 캐시가 맥락에 따라 정답도 되고 유령도 된다던 "맥락 C"가 정확히 서버리스 환경이다.

ts
// lib/context.ts
import { S3Client } from "@aws-sdk/client-s3"
 
// 타당하다: 불변이고, 프로세스 수명 내내 유효하며, 생성 비용이 크다
export const s3 = new S3Client({ region: process.env.AWS_REGION })
export const FEATURE_FLAGS = JSON.parse(process.env.FEATURE_FLAGS ?? "{}")
 
// 위험하다: 변하는 값을 인스턴스 메모리에 둔다
const userCache = new Map<string, User>()
 
export async function getUser(id: string) {
  const hit = userCache.get(id)
  if (hit) return hit                 // 언제 갱신됐는지 알 방법이 없다
  const user = await fetchUser(id)
  userCache.set(id, user)
  return user
}

userCache가 나쁜 이유는 캐시라서가 아니라 히트율이 관측 불가능하기 때문이다. 인스턴스마다 내용이 다르고, 콜드 스타트마다 비워지고, 인스턴스 수는 플랫폼이 결정한다. 결과는 "대부분 맞는데 가끔 오래된 값이 나오고 재현이 안 되는" 상태다. 무효화를 보낼 대상조차 없다.

반대로 s3와 FEATURE_FLAGS는 전역에 두는 게 맞다. SDK 클라이언트는 생성 비용이 커서 요청마다 만들면 콜드 스타트가 아니라 모든 호출에 비용이 붙는다. 구분 기준은 하나로 줄어든다.

불변이고 프로세스 수명 내내 유효한가. 그렇다면 전역이 맞고, 아니라면 외부 저장소(Redis 등)로 나가야 한다.

탈출 경로와 그 대가

커넥션은 외부 풀러로 옮긴다. PgBouncer, RDS Proxy, Supabase나 Neon의 풀러가 함수와 DB 사이에 서서 클라이언트 커넥션 1,000개를 서버 커넥션 20개로 다중화한다. 트랜잭션 단위 풀링에서는 트랜잭션이 끝날 때마다 서버 커넥션이 반납되므로, 짧은 쿼리 위주라면 20개로 수백 클라이언트를 감당한다. HTTP 기반 드라이버는 TCP 커넥션 개념 자체를 걷어낸다.

ts
// api/orders.ts — After: 모듈 스코프에서 한 번, 그리고 풀러를 경유한다
import { Pool } from "pg"
 
const pool = new Pool({
  connectionString: process.env.DATABASE_POOLER_URL, // PgBouncer / RDS Proxy 엔드포인트
  max: 1,                          // 인스턴스 1개가 동시에 처리하는 요청은 1개다
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 3_000,  // 풀러 앞에서 무한 대기하지 않는다
})
 
export async function handler(event: APIGatewayProxyEvent) {
  const { rows } = await pool.query("select id, status from orders where id = $1", [
    event.pathParameters!.id,
  ])
  return { statusCode: 200, body: JSON.stringify(rows[0] ?? null) }
}

max: 1이 핵심이다. 함수 인스턴스는 동시에 요청 하나만 처리하므로 커넥션을 2개 이상 쥐는 건 낭비가 아니라 위험이다. 그리고 모듈 스코프 재사용만으로는 부족하다. 재사용은 같은 인스턴스가 살아 있는 동안 연결 수립 비용을 줄일 뿐 인스턴스 수를 줄이지 못한다. 동시성 1,000이면 여전히 커넥션 1,000개다. 재사용은 지연 대책이고 풀러는 커넥션 수 대책이다.

대가. 트랜잭션 단위 풀링에서는 세션 상태에 의존하는 기능이 깨진다. prepared statement, 세션 변수, 어드바이저리 락, 커서가 대표적이다. ORM이 prepared statement를 기본으로 쓰면 설정을 바꿔야 한다. 풀러 자체도 또 하나의 장애 지점이라 가용성 곱셈에 한 항을 더한다.

오케스트레이션을 함수 밖으로 꺼낸다. 함수가 함수를 동기 호출하는 대신 상태 머신(Step Functions 류)이나 큐가 흐름을 소유한다. 함수 경계의 기준은 하나의 함수 = 하나의 이벤트에 대한 응답이다. A가 B의 결과를 기다려야 한다면 그건 두 함수가 아니라 한 함수이거나, 이벤트로 분리되어야 할 두 사건이다.

대가. 외부 오케스트레이터는 벤더 종속을 한 겹 더한다. 상태 머신 정의는 코드가 아니라 그 플랫폼의 DSL이고 로컬 재현이 어렵다. 동기 호출을 이벤트로 바꾸면 호출 스택이 사라지므로 "이 요청이 왜 여기서 멈췄는가"를 추적하기가 더 어려워진다. 관측 가능성 투자가 선택이 아니라 전제 조건이 된다(15편).

판단 기준 — 서버리스가 오히려 정답인 경우

트래픽이 희박하고 불규칙한 워크로드. 하루 몇 백 번 호출되는 관리자용 API, 월 1회 정산 배치, 웹훅 수신기. 컨테이너를 24시간 띄워두면 사용률이 1% 미만인데도 비용은 100%를 낸다. 여기서는 0으로 줄어드는 것 자체가 압도적인 이득이다.

콜드 스타트가 무관한 비동기 작업과 이벤트 글루. 큐 워커, 이미지 리사이즈, 스토리지 업로드 트리거, 스케줄 작업. 응답을 기다리는 사람이 없으면 콜드 스타트 수백 ms는 비용 항목이 아니다. 짧고, 상태가 없고, 다른 함수를 호출하지 않는 형태가 서버리스의 원래 자리다.

반대로 상시 트래픽이면 컨테이너가 싸다. 인스턴스 1대를 한 달 내내 돌리면 720시간, 2,592,000초다. 평균 실행 100ms인 함수가 이 컴퓨트를 채우려면 2,592,000 ÷ 0.1 = 2,592만 회 호출이 필요하고, 이는 초당 10회다. 즉 초당 10 요청을 넘기면 그 함수는 이미 인스턴스 한 대분의 컴퓨트를 상시 소비하고 있다. 여기에 요청당 과금이 얹히므로 실제 손익분기는 이 지점보다 아래다. 상시로 이 선을 넘으면 컨테이너가 비용·콜드 스타트·커넥션을 한 번에 해결한다.

요약

항목내용
증상스케일 아웃은 성공했는데 DB가 too many clients로 거절한다
초기 매력운영 표면 제거. 0으로 줄고 알아서 늘어나는 스케일링은 실제로 작동했다
붕괴 기전프로세스 수명을 전제한 자원(풀·캐시·소켓)이 인스턴스당으로 쪼개진다
수치동시성 1,000 × 커넥션 1 = 1,000개 vs PostgreSQL 기본 max_connections 100
Lambda Pinball4단 동기 사슬: 과금 520ms / 실작업 160ms = 3.25배, 가용성 0.999⁴ = 99.6%
탈출외부 풀러 + max: 1, 오케스트레이션 외부화, 동기 사슬을 이벤트로
대가풀러는 세션 상태 기능을 깨고, 오케스트레이터는 벤더 종속과 디버깅 난도를 더한다
오히려 정답희박·불규칙 트래픽, 비동기 배치, 이벤트 글루. 초당 10 요청이 컨테이너와의 분기점

다음 편 — 14편. 이벤트 루프를 막는 자 — Node.js 런타임 고유 안티패턴

지금까지 다룬 것이 구조의 문제였다면 다음 편은 실행 모델의 문제다. 함수를 잘 나누고 커넥션을 정리해도, 한 요청이 CPU를 100ms 붙잡으면 그동안 도착한 모든 요청이 같이 멈춘다. 블로킹이 한 요청이 아니라 프로세스 전체에 과금되는 이유를 처리량으로 따지고, async를 붙였는데도 동시성이 생기지 않는 경우를 코드로 구분한다.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..