무료 티어로 옮기기 전, 코드에서 막힌 것 10가지

잘 돌아가던 개인 프로젝트를 Vercel과 Supabase 무료 티어로 옮길 수 있을지 검토했다. 요금제 한도표부터 보는 대신 코드를 먼저 읽었고, 진짜 제약은 숫자가 아니라 실행 모델 쪽에 있었다.

배경

개인 이력서 사이트를 하나 운영하고 있다. Next.js App Router에 Prisma, PostgreSQL을 쓰고, 단일 서버에 컨테이너 두 개(앱 + 리버스 프록시)로 올려 터널을 통해 외부에 노출하는 구성이다. 개인 프로젝트라 트래픽도 적고, 배포는 push 한 번으로 끝난다.

이걸 관리형 PaaS로 옮기면 어떨까 싶었다. 서버 관리에서 손을 떼고, 무료 티어 안에서 끝낼 수 있다면 나쁘지 않은 거래다.

그래서 검토를 시작했는데, 흔히 하는 방식(요금제 페이지의 한도표를 놓고 내 사용량과 비교하는 것)으로는 답이 나오지 않았다. 용량은 넉넉했다. 문제는 다른 데 있었다.

검토 방법: 한도표가 아니라 코드

무료 티어 검토를 "내 사용량이 한도 안에 들어가나"로 접근하면 중요한 걸 놓친다. 컨테이너에서 잘 돌던 코드가 서버리스에서 깨지는 이유는 용량이 아니라 실행 모델의 전제가 달라지기 때문이다.

그래서 질문을 바꿨다. 이 코드는 어떤 실행 환경을 암묵적으로 가정하고 있나?

그 관점으로 읽으니 문제가 줄줄이 나왔다.

블로커: 헤드리스 브라우저로 만드는 PDF

이력서를 PDF로 내려주는 기능이 있다. 헤드리스 브라우저를 띄워 HTML을 렌더해 PDF로 뽑는 방식이다.

서버리스 함수에는 두 가지 문제가 된다. 첫째, 브라우저 바이너리가 크다. 함수 번들 용량 제한을 그냥 넘긴다. 둘째, 콜드 스타트에 브라우저 실행까지 얹히면 함수 실행 시간 제한에 걸릴 여지가 있다.

그런데 더 근본적인 문제는 코드 자체였다.

let browserPromise = null;
let renderLock = Promise.resolve();
// 동시 렌더 1개로 직렬화 → 메모리 폭발 방지

브라우저 인스턴스를 하나만 띄우고, 렌더를 직렬화해서 메모리 사용을 묶어두는 코드다. 단일 프로세스에서는 완벽하게 동작한다.

서버리스에서는 요청마다 별개의 인스턴스가 뜰 수 있다. 프로세스가 여러 개면 각자 자기 락을 들고 자기 브라우저를 띄운다. 직렬화가 안 먹힌다. 즉 이 코드의 메모리 폭발 방어 장치가 조용히 무력화된다.

번들 용량은 라이브러리를 바꿔서 해결할 수 있다. 하지만 "동시 실행 1개"라는 전제가 깨지는 건 라이브러리 교체로 해결되지 않는다.

in-memory rate limiter: 주석에 이미 답이 있었다

API에 IP 기반 rate limit이 걸려 있다. 구현을 열어보니 첫 줄에 이렇게 써 있었다.

매우 단순한 in-memory IP rate limiter.
단일 인스턴스 가정. 서버 재시작 시 리셋.
분산 환경에서는 Redis 등으로 교체 필요.

과거의 내가 이미 답을 적어놨다. 카운터가 프로세스 메모리의 Map에 있으니, 인스턴스가 N개면 실효 한도도 N배가 된다.

이게 왜 중요한가. 이 rate limit이 지키고 있던 건 두 가지다. 외부 LLM API를 호출하는 챗봇 엔드포인트(호출당 과금), 그리고 위의 PDF 렌더(비싼 연산). 둘 다 "느슨해져도 괜찮은" 종류가 아니다.

신뢰 경계가 사라진다

같은 파일에서 클라이언트 IP를 뽑는 코드를 봤다.

const cf = headers.get("cf-connecting-ip");
if (cf) return cf.trim();

터널 뒤에 있을 때는 이 헤더를 터널 제공자가 항상 덮어쓰므로 신뢰할 수 있다. 그래서 내 감사 문서에는 이 항목이 "헤더 위조 가능하지만 폐쇄망 한정이라 수용" 으로 적혀 있었다.

옮기면 그 전제가 사라진다. 앞단에 그 터널이 없으면, 클라이언트가 요청에 이 헤더를 직접 넣을 수 있다. 매 요청 다른 값을 넣으면 rate limit을 무한히 우회한다.

여기서 배운 게 있다. 감사 문서의 "수용된 리스크"는 배포 환경에 종속된다. 환경을 바꾸는 순간 수용 근거가 사라지고, 수용됐던 항목이 실제 취약점으로 승격된다. 마이그레이션 체크리스트에 "수용된 리스크 재검토"가 반드시 들어가야 한다.

리버스 프록시와 함께 사라지는 것들

보안 헤더를 리버스 프록시 설정에 넣어뒀다. CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, 그리고 요청 본문 크기 제한까지.

관리형 PaaS로 가면 그 프록시가 없다. 설정 파일에만 있던 방어가 통째로 사라진다.

이건 난이도가 낮은 작업이다. 프레임워크의 헤더 설정으로 옮기고 본문 크기는 핸들러에서 검사하면 된다. 문제는 누가 알려주지 않는다는 것. 빌드도 통과하고 테스트도 통과하고 사이트도 잘 뜬다. 다만 CSP가 없다.

인프라 설정 파일에 들어 있는 보안 정책은 코드 리뷰에도, 타입 체크에도, 테스트에도 걸리지 않는다.

DB 커넥션 모델

Prisma를 node-postgres 어댑터로 쓰고 있었다. 직접 TCP 커넥션을 맺는 방식이다.

컨테이너에서는 프로세스가 하나라 커넥션 풀도 하나다. 서버리스에서는 인스턴스가 늘어나면 커넥션도 같이 늘어난다. 무료 티어 DB의 커넥션 한도는 넉넉하지 않다.

그래서 서버리스에서는 트랜잭션 모드 커넥션 풀러를 경유하는 게 정석이고, 연결 문자열과 어댑터 설정을 그에 맞게 바꿔야 한다. 코드 몇 줄이지만, 모르고 올리면 트래픽이 조금 늘었을 때 커넥션 고갈로 죽는다.

캐시가 캐시가 아니게 된다

모듈 스코프에 캐시를 여러 개 두고 있었다. 생성된 PDF 캐시, 서비스 상태 점검 결과 30초 캐시, DB 클라이언트 싱글톤.

인스턴스가 여러 개면 이들은 공유되지 않는다. 히트율이 떨어지고, 그만큼 DB 쿼리와 외부 API 호출이 늘어난다.

이게 무료 티어에서 특히 아픈 이유는, 무료 한도가 호출 수와 대역폭으로 측정되기 때문이다. 캐시 히트율 하락이 곧 한도 소모 가속으로 이어진다. 성능 문제가 요금 문제로 번역되는 구조다.

용량과 운영

여기까지가 실행 모델 문제였고, 아래는 평범한 용량 문제다.

방문 로그를 DB 테이블에 쌓고 있는데 정리 로직이 없다. 무료 DB 용량 안에서 당장 터지진 않지만, 크롤러 트래픽을 받으면 계속 누적된다. 인덱스도 같이 커진다.

무료 DB는 일정 기간 비활성이면 일시정지된다. 실제 트래픽이 있는 사이트면 문제없고, 오히려 상태 점검 엔드포인트가 주기적으로 DB를 깨워준다.

자동 백업은 무료 티어에서 제공되지 않는다. 지금은 스케줄러가 주기적으로 덤프를 떠서 보관하고 있는데, 옮기면 그 스케줄러가 의미를 잃는다. 백업 전략을 새로 짜야 한다.

마지막으로, 회원가입 API가 환경변수로 잠겨 있다.

const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === "true";

폐쇄망에서는 위험이 낮았다. 공개 인터넷에서는 이 플래그가 켜진 순간 누구나 관리자 계정을 만든다. 환경이 바뀌면 같은 플래그의 위험도가 바뀐다.

정리

항목 | 성격 | 요금제로 해결되나
헤드리스 브라우저 PDF | 번들 용량 + 단일 프로세스 전제 | 일부만
in-memory rate limiter | 멀티 인스턴스 | 아니오
헤더 위조로 rate limit 우회 | 신뢰 경계 변경 | 아니오
회원가입 플래그 노출 | 신뢰 경계 변경 | 아니오
보안 헤더 유실 | 설정 이전 | 아니오
DB 커넥션 모델 | 커넥션 한도 | 일부만
모듈 스코프 캐시 | 멀티 인스턴스 | 아니오
DB 용량 / 로그 누적 | 용량 | 예
비활성 일시정지 | 정책 | 예
자동 백업 부재 | 기능 | 예

세어보면 요금제로 풀리는 건 아래쪽 세 개뿐이다. 위쪽은 전부 서버리스라는 실행 모델에서 나오는 문제다.

결론

무료 티어의 진짜 제약은 500MB나 100GB 같은 숫자가 아니었다. "내 코드가 단일 프로세스를 가정하고 있다" 는 사실이었다.

그리고 그 가정은 코드 어디에도 명시되어 있지 않다. let renderLock, 모듈 스코프 Map, 프록시 설정 파일에 흩어져 있을 뿐이다. 한도표를 아무리 열심히 봐도 보이지 않는다.

그렇다면 자연스러운 다음 질문. Pro 요금제를 쓰면 몇 개가 사라질까? 실행 시간 제한도 풀리고 메모리도 늘어나고 DB 용량도 커지는데, 위 목록이 얼마나 줄어들까.

세어봤더니 결과가 예상과 달랐다. 다음 글에서 다룬다.