무료 플랜을 의심했는데, 함수가 지구 반대편에서 돌고 있었다

경험을 토대로 AI를 활용하여 작성한 글 입니다.

취업 준비 플래너 앱을 Vercel에 올려두고 쓰다 보니 계속 걸리는 게 있었다. 배포된 주소로 들어가면 첫 화면이 뜨기까지 체감상 확실히 느리다. 로컬에서는 이런 느낌이 없다.

가장 먼저 의심한 건 Supabase 무료 플랜이었다. 공용 컴퓨트에 500MB 메모리, 일주일 놀면 프로젝트가 정지된다는 것도 알고 있었다. 테스트 계정에 태스크가 9천 건 넘게 쌓여 있으니 무료 티어 성능으로는 버거운 게 아닐까 싶었다. 업그레이드 결제 페이지까지 열어봤다.

그런데 돈을 쓰기 전에 재보기로 했다. 이게 이 글의 전부다.

일을 하나도 안 하는 응답을 재봤다

무엇을 잴지 정하는 게 먼저였다. DB가 느린 게 맞다면, DB를 건드리지 않는 요청은 빨라야 한다. 그래서 인증 없이 API를 때렸다. 세션 쿠키가 없으면 withAuth가 Supabase에 닿기도 전에 401로 끊는 경로다.

$ curl -o /dev/null -w "code=%{http_code} ttfb=%{time_starttransfer}\n" \
    https://chuksung.vercel.app/api/tasks
code=401 ttfb=1.047436   # 첫 요청 (cold)
code=401 ttfb=0.343396   # 두 번째
 
$ curl -o /dev/null -w "code=%{http_code} ttfb=%{time_starttransfer}\n" \
    https://chuksung.vercel.app/favicon.ico
code=404 ttfb=0.070677

Supabase를 한 번도 건드리지 않고 "너 누구야, 401" 한 줄 뱉는 데 343ms. 같은 도메인의 정적 파일은 70ms. 273ms 차이가 순수하게 어딘가로 새고 있었다.

이 숫자를 보는 순간 무료 플랜 가설은 죽었다. DB가 아무리 느려도 DB를 호출하지 않는 응답을 느리게 만들 수는 없다. 문제는 DB가 아니라 함수가 있는 위치였다.

헤더에 답이 적혀 있었다

Vercel 응답 헤더를 열어봤다.

$ curl -sD - -o /dev/null https://chuksung.vercel.app/login | grep -i x-vercel-id
x-vercel-id: icn1::iad1::hz2cf-1786335640223-841cb3564a9d

x-vercel-id::로 구분된 경로 기록이다. 앞이 요청이 들어온 엣지, 뒤가 함수가 실제로 실행된 리전이다.

  • icn1 — 서울. 여기까지는 좋다.
  • iad1 — 워싱턴 D.C.

Vercel 문서를 확인하니 명시돼 있었다. 신규 프로젝트의 기본 함수 리전은 iad1이고, "대부분의 외부 데이터 소스가 미국 동부에 있기 때문"이라는 설명이 붙어 있다. 합리적인 기본값이다. 내 데이터 소스가 미국 동부에 있었다면.

$ supabase projects list
{"ref":"ywwsdezbttlwhiikasjq","name":"chuksung","region":"ap-northeast-2", ...}

서울이었다.

그래서 요청 하나가 이렇게 흘렀다

서울 사용자 → icn1 엣지 → iad1 함수 → 서울 Supabase → iad1 → 서울

한 번 왕복하는 것도 아니고 두 겹이다.

  1. 사용자 ↔ 함수 — 모든 SSR 페이지와 API 호출이 태평양을 건넌다. 아까 본 343ms짜리 401이 이 비용이다.
  2. 함수 ↔ Supabase — 여기가 진짜다.

2번이 왜 심각한지는 supabase.auth.getUser()의 동작을 알아야 한다. 이 함수는 쿠키에 든 JWT를 로컬에서 까보지 않는다. 매번 Auth 서버에 검증 요청을 보낸다. 그래서 Supabase 문서도 서버에서는 getSession() 대신 getUser()를 쓰라고 권한다. 쿠키는 위조될 수 있으니 맞는 조언이다.

문제는 그 신뢰의 대가가 네트워크 왕복이고, 내 앱에서 그게 한 요청에 여러 번 발생한다는 것이었다. 로그인한 사용자가 /로 들어올 때 직렬로 벌어지는 일을 세어봤다.

순서어디서무엇을
1proxy.tsupdateSessiongetUser
2app/page.tsxgetUser → 307 리다이렉트
3/daily 재요청, 다시 프록시getUser
4(dashboard)/layout.tsxgetUser + 프로필 조회
5클라이언트 → /api/taskswithAuthgetUser + 태스크 조회

각각이 태평양 왕복이었다. 로컬에서는 전부 같은 머신 안이라 공짜였던 것들이다.

고치는 데는 세 줄이 걸렸다

// vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "regions": ["icn1"]
}

Hobby 플랜은 단일 리전만 허용한다(Pro는 5개, Enterprise는 전체). 하지만 사용자도 DB도 전부 한국이라 애초에 여러 리전이 필요 없다. 함수를 둘 사이에 놓기만 하면 된다.

배포 후 헤더를 다시 봤다.

x-vercel-id: icn1::icn1::qg7rw-1786344659899-50a1da1c7eca

검증

기존 측정 스크립트에 있던 getAuthCookieHeader()(E2E 테스트 계정으로 세션 쿠키를 발급하는 함수)를 재활용해서, 배포 URL을 인증 상태로 재는 스크립트를 짰다. 경로마다 7회, 1회차는 cold로 따로 빼고 나머지 median을 쓴다.

측정 경로를 고를 때 하나 조심했다. /api/tasksdaily 스코프는 client_now 파라미터를 받아 템플릿 시딩(INSERT)을 유발한다. 측정할 때마다 데이터가 늘어나면 이후 비교가 전부 오염된다. 그래서 읽기만 하는 weekly 스코프로 대체했다. 예전에 시딩 때문에 존재하지도 않는 회귀를 쫓은 적이 있어서 이건 조건반사가 됐다.

경로iad1icn1델타
/api/profile (getUser + 1행 조회)1,003ms111ms-89%
/api/tasks?scope=weekly801ms117ms-85%
/ (인증 리다이렉트)1,269ms317ms-75%
/login (Supabase 미접촉)315ms124ms-61%
/daily (SSR 셸)482ms349ms-28%

cold 구간은 더 크게 움직였다. /api/tasks는 2,352ms에서 136ms, /는 2,051ms에서 596ms.

프로필 한 줄 읽는 데 1초를 쓰던 게 111ms가 됐다. 코드는 한 줄도 안 고쳤다.

로컬 측정은 이 문제를 볼 수 없었다

제일 뜨끔한 부분이 남았다. 이 앱에는 pnpm perf라는 Lighthouse 측정 스크립트가 붙어 있고, 측정할 때마다 원장에 델타를 쌓는다. 그 원장의 최신 기록은 이랬다.

| Page   | Perf    | LCP           |
| /daily | 95 🟢+3 | 2.39s 🟢-0.31s |

Perf 95. 아주 건강해 보인다. 같은 시점에 배포 환경에서는 API 하나가 1초를 쓰고 있었는데.

이유는 단순하다. 그 하네스는 로컬 빌드를 로컬 Chrome으로 잰다. 함수와 DB가 같은 머신에 있으니 네트워크 거리가 0으로 고정된다. 리전이 지구 반대편이든 옆방이든 이 측정에는 절대 나타나지 않는다. 커밋을 아무리 이분 탐색해도 못 찾는다. 원인이 코드에 없기 때문이다.

전에도 비슷한 걸 겪었다. Lighthouse 랩 지표로는 실사용 상호작용 지연(INP)을 못 잡는다는 걸 알고 TBT를 대용으로 쓰기로 했던 적이 있는데, 그때는 한 도구 안에서의 경계였다. 이번엔 측정 환경 자체가 프로덕션과 다른 종류라는 더 큰 경계였다. 도구가 조용한 게 문제가 없다는 뜻이 아니라, 그 도구가 볼 수 없는 축이라는 뜻일 수 있다.

그래서 이번엔 배포 URL 기준 측정을 별도 문서로 남기고, 거기에 한계도 같이 적었다. 단일 지점(한국)에서 잰 것이고, TTFB만 보며, 무엇보다 아직 자동화돼 있지 않아서 리전이 다시 어긋나도 아무도 경고해주지 않는다는 점을. vercel.jsonicn1과 Supabase의 ap-northeast-2는 항상 같은 곳을 가리켜야 하는데, 어긋나도 빌드는 통과하고 테스트도 통과한다. 조용히 느려질 뿐이다.

남은 것

/가 여전히 317ms로 가장 느리다. 이건 리전 문제가 아니라 구조 문제다. 프록시가 이미 user를 알고 있는데 app/page.tsxgetUser를 다시 호출한 뒤 307로 넘긴다. 요청 하나와 getUser 두 번이 통째로 낭비되고 있어서, 다음은 이 리다이렉트 체인을 걷어낼 차례다.

무료 플랜 이야기도 결론을 내자면, 초기 로딩과는 무관했다. 다만 하나는 진짜로 남는다. 무료 프로젝트는 일주일간 요청이 없으면 정지되고 재개에 1분 넘게 걸린다. 이직용 포트폴리오라면 면접관이 일주일 만의 첫 방문자가 되는 시나리오가 그대로 최악의 경우다. 돈을 쓴다면 성능 때문이 아니라 이것 때문에 쓰는 게 맞다.

돌아보면 이번 진단의 분기점은 도구도 프로파일러도 아니고, 아무 일도 하지 않는 응답을 재본 것이었다. 느린 걸 잴 때는 보통 제일 무거운 경로부터 열어보게 되는데, 그러면 "무거우니까 느리지"라는 그럴듯한 설명이 항상 따라붙는다. 일을 안 하는 요청이 느리면 변명의 여지가 없다. 빼는 방향으로 재는 게 더 빨랐다.