리전을 서울로 옮겼는데 프록시만 싱가포르에 남아 있었다
경험을 토대로 AI를 활용하여 작성한 글 입니다.
지난 글에서 Vercel 함수 리전을 기본값 iad1(워싱턴 D.C.)에서 icn1(서울)로 옮겼다. Supabase가 서울에 있는데 함수만 미국 동부에 있었고, vercel.json 세 줄로 인증 API가 1,003ms에서 111ms가 됐다.
그 글을 쓰면서 다음 작업은 루트 리다이렉트 체인 정리라고 적어뒀다. 그런데 측정표를 다시 보다가 숫자 하나가 걸렸다.
/api/profile (getUser + DB 1행 조회) 111ms
인증 상태 /login (프록시만, 페이지 렌더 없음) ~450ms
DB까지 다녀오는 쪽이 4배 빠르다. 아무것도 안 하는 쪽이 느리면 그건 "무거워서 느린" 게 아니다.
프록시만 따로 재는 법
먼저 이게 정말 프록시 비용인지 확인해야 했다. 마침 이 앱에는 깔끔한 격리 방법이 있었다 — 로그인한 상태로 /login에 접근하면 프록시가 /daily로 되돌린다. 이때 로그인 페이지는 렌더되지 않는다.
정말 안 렌더되는지는 응답 헤더로 확인할 수 있다.
# 비인증 /daily → 프록시가 막음
$ curl -sD - -o /dev/null https://chuksung.vercel.app/daily | grep -i x-vercel-id
x-vercel-id: icn1::b89sp-… # 세그먼트 1개
# 비인증 / → 프록시 통과, page.tsx 가 렌더되어 리다이렉트
$ curl -sD - -o /dev/null https://chuksung.vercel.app/ | grep -i x-vercel-id
x-vercel-id: icn1::icn1::7ml9r-… # 세그먼트 2개x-vercel-id의 :: 세그먼트는 요청이 거쳐간 홉이다. 앞이 진입 엣지, 뒤가 함수가 실행된 리전. 둘 다 똑같은 307 → /login인데 세그먼트 수가 다르다. 1개면 함수가 아예 안 돌았다는 뜻이다.
그러니 인증 상태 /login의 응답 시간은 순수하게 프록시 비용이다. 재보니 ~450ms였다.
regions 설정이 프록시에는 적용되지 않았다
Vercel 문서를 다시 읽으니 이렇게 적혀 있었다.
Vercel deploys Routing Middleware to all regions by default, regardless of your region settings. On the Hobby plan, Routing Middleware runs in fewer regions.
vercel.json의 regions: ["icn1"]은 페이지·API 함수에만 적용된다. 미들웨어(Next 16부터 Proxy로 이름이 바뀐 그것)는 별도 정책으로 배치된다.
지난 글에서 나는 "리전을 고쳤다"고 썼는데, 정확히는 고쳐진 범위를 확인하지 않았다. 설정 하나가 앱 전체에 적용될 거라고 가정했고, 그 가정이 어긋나도 빌드는 통과하고 테스트도 통과했다.
그런데 프록시가 어디 있는지 알 방법이 없었다
문제는 x-vercel-id가 프록시가 끝낸 응답에서는 진입 엣지만 남기고 실행 리전을 안 알려준다는 것이었다. 세그먼트가 1개라 뒤가 없다.
Vercel CLI로 로그를 보려 했지만 로그인이 안 돼 있었고, 로그인은 대화형이라 막혔다. 그래서 관측 수단을 직접 심었다.
// src/proxy.ts
function withRegion(response: NextResponse) {
const region = process.env.VERCEL_REGION
if (region) response.headers.set('x-proxy-region', region)
return response
}배포하고 다시 쳤다.
x-proxy-region: sin1 ← 프록시: 싱가포르
x-vercel-id: icn1::icn1::mwfmd-… ← 페이지·API 함수: 서울
싱가포르였다. 한국 사용자의 요청이 싱가포르로 갔다가, 거기서 서울의 Supabase Auth 서버까지 갔다 오고, 다시 돌아오고 있었다.
진짜 문제는 위치가 아니었다
여기서 선택지가 갈렸다. 프록시를 서울로 옮기는 건 Hobby 플랜에서 불가능하다. 그럼 왕복 자체를 없애야 한다.
supabase.auth.getUser()는 쿠키의 JWT를 로컬에서 까보지 않는다. 매번 Auth 서버에 검증을 요청한다. 쿠키는 위조될 수 있으니 서버에 확인하는 게 맞고, Supabase 문서도 서버에서는 getSession() 대신 이걸 쓰라고 권한다.
문제는 프록시가 실행되는 빈도였다. Next 문서에 이 문장이 있다.
Since Proxy runs on every route, including prefetched routes, it's important to only read the session from the cookie (optimistic checks), and avoid database checks to prevent performance issues.
including prefetched routes. <Link>는 뷰포트에 들어오면 미리 프리페치하므로, 사용자가 누르지도 않은 경로에 대해서도 프록시가 돌고 태평양... 아니 이 경우엔 남중국해를 왕복하고 있었다.
문서는 더 직설적으로도 적어뒀다. "Proxy … should not be used as a full session management or authorization solution." 내가 딱 그걸 하고 있었다.
getClaims() — 안 물어보고 검증하기
Supabase가 비대칭 JWT 서명 키(ES256/RS256)를 지원하면서 생긴 메서드다. 서명을 공개키로 로컬에서 검증하므로 Auth 서버에 물을 필요가 없다.
쓸 수 있는 조건인지부터 확인했다. 실제 세션 토큰을 발급받아 헤더를 까봤다.
실제 세션 JWT 헤더: {"alg":"ES256","kid":"17e3f91c-91fc-4a2a-b208-a89d19fed7ba","typ":"JWT"}
토큰 수명(exp-iat): 3600 초
JWKS 엔드포인트도 같은 kid의 ES256 키를 내려줬다. 이미 비대칭 키로 전환된 프로젝트였다. (대칭 HS256이었다면 getClaims()도 결국 서버에 물어봐서 getUser()와 다를 게 없다.)
문서에 걸리는 문장이 하나 있었다.
If your environment is ephemeral, such as a Lambda function destroyed after every request, a network request will be sent for each new invocation.
서버리스라 매 요청 JWKS를 받아야 하면 의미가 없다. 그래서 JWKS 엔드포인트도 재봤다.
cf-cache-status: HIT
cache-control: public, max-age=600
cf-ray: a2972f594cd6d1ea-ICN ← Cloudflare 서울 엣지
ttfb: 0.079 / 0.068 / 0.069 / 0.101 / 0.079 s
JWKS는 원본이 아니라 가장 가까운 CDN 엣지에서 온다. 콜드 인스턴스가 매번 받아도 자기 근처에서 ~70ms. 반면 getUser()는 캐시가 불가능한 검증 요청이라 항상 원본까지 간다.
로컬에서 나란히 재보니 차이가 분명했다.
getClaims() 1회차 (JWKS 미캐시): 22ms
getClaims() 2회차 (JWKS 캐시됨): 1.0ms
getUser() 비교군: 52.7ms
고치기 전에 뚫리는지부터 확인했다
로컬 검증으로 바꾼다는 건 서명 확인을 내가 책임진다는 뜻이다. 여기가 이 작업의 유일한 실질 위험이라, 코드를 고치기 전에 실제 토큰을 조작해서 확인했다.
정상 토큰 -> PASS ✅
서명 위조 -> REJECT ⛔ Invalid JWT signature
페이로드 변조 (sub 교체) -> REJECT ⛔ Invalid JWT signature
만료 토큰 (exp 과거) -> REJECT ⛔ JWT has expired
alg:none 공격 -> REJECT ⛔ signing method none is invalid
형식이 아닌 문자열 -> REJECT ⛔ Invalid JWT structure
특히 sub(사용자 ID)를 남의 것으로 바꿔치기하는 게 막히는지 봤다. 서명이 페이로드를 덮으므로 한 글자만 바꿔도 검증이 깨진다.
그다음에야 코드를 고쳤다.
// src/lib/supabase/update-session.ts
- const { data: { user } } = await supabase.auth.getUser()
- return { response, user }
+ const { data } = await supabase.auth.getClaims()
+ return { response, claims: data?.claims ?? null }반환 이름을 user에서 claims로 바꾼 게 의도적이다. 이건 더 이상 권위 있는 사용자 정보가 아니라 낙관적 판단이고, 호출하는 쪽이 그걸 오해하면 안 된다.
감수한 것: 엇갈림이 사고에서 상태로 바뀐다
여기가 이번 작업에서 제일 오래 고민한 부분이다.
프록시 (getClaims) | API (withAuth → getUser) | |
|---|---|---|
| 검증 대상 | 서명과 exp만, 로컬 | Auth 서버의 현재 판단 |
| 세션 무효화 반영 | 토큰 만료(1시간)까지 지연 | 즉시 |
서버가 세션을 무효화해도 프록시는 최대 1시간 통과시킨다. 그 사용자는 /daily를 한 번 거친 뒤 API가 401을 내고서야 로그인 화면으로 튕긴다. 대시보드가 잠깐 보였다가 사라진다.
그런데 며칠 전에 이것과 정확히 같은 모양의 결함을 고친 적이 있다. "쿠키는 유효한데 API만 401을 내는" 상황에서 무한 왕복이 생기는 문제였다. apiClient 인터셉터가 401을 받아 /login으로 보내고, 프록시는 쿠키가 유효하니 /daily로 되돌리고, 다시 401을 받고… 8초에 45회 왕복했다. URL 마커(/login?session=invalid)로 끊었다.
그때 그건 드물게 터지는 사고에 대한 안전망이었다. 원인이 RLS 거부나 인증 서버 일시 장애 같은 것들이라 자주 볼 일이 없었다.
이번 변경으로 그 엇갈림이 설계상 예정된 상태가 됐다. 안전망이 상시 작동하는 필수 부품으로 승격된 것이다. 그래서 문서에 이렇게 적었다.
대시보드가 잠깐 보였다가 튕기는 것은 결함이 아니라 이 구조의 결과다.
되돌리지 않는 이유도 함께 적었다. 이걸 없애려면 프록시가 서버 검증을 해야 하는데, 그게 정확히 제거 대상이던 비용이다. 정상 사용자 모든 요청에 왕복을 부과하고 드문 무효화 케이스의 한 홉을 아끼는 거래는 성립하지 않는다. 인가는 withAuth와 RLS가 그대로 담당하니 보안이 약해지지도 않는다 — 프록시 통과는 화면 이동 허가일 뿐 데이터 접근 허가가 아니다.
조용히 깨질 것에 테스트를 걸었다
getClaims() 문서에 이 문장이 있다.
If the user's access token is about to expire when calling this function, the user's session will first be refreshed before validating the JWT.
즉 이 함수는 쿠키 갱신 책임도 함께 진다. 여기가 빠지면 토큰 수명마다, 그러니까 한 시간마다 전 사용자가 로그아웃된다. 그런데 이건 배포 직후엔 멀쩡해 보인다. 한 시간 뒤에야 드러난다.
그래서 E2E로 못박았다. 만료 처리한 쿠키를 주입하고, 페이지가 열리는지와 access_token이 실제로 바뀌었는지까지 확인한다.
session.expires_at = Math.floor(Date.now() / 1000) - 60 // 만료된 것으로 표시
await page.goto('/daily')
await expect(page).toHaveURL(/\/daily$/) // 로그아웃되지 않는다
const refreshed = (await context.cookies()).find((c) => c.name.endsWith('-auth-token'))
expect(readSessionCookie(refreshed!.value).access_token).not.toBe(before) // 갱신됐다서명 위조 차단 테스트도 넣었는데, 여기엔 함정이 하나 있었다. 위조된 access token을 주면 라이브러리가 refresh token으로 새 세션을 받아와 통과해버릴 수 있다. 그러면 테스트가 통과해도 서명 검증을 확인한 게 아니다. refresh 경로를 같이 막아서 검증 대상을 격리했다.
결과
프록시 비용만 격리한 값이다.
| median | |
|---|---|
getUser() (전) | ~450ms |
getClaims() (후) | 106ms |
전 경로로 보면 이렇다.
| 경로 | 전 | 후 |
|---|---|---|
/daily (SSR 셸) | 349ms | 134ms (-62%) |
/ (인증 리다이렉트) | 317ms | 172ms (-46%) |
/api/profile | 111ms | 136ms |
/api/tasks?scope=weekly | 117ms | 94ms |
API 경로가 안 움직인 게 오히려 이 변경의 검증이다. 이 앱의 matcher는 /api를 제외하므로 API 라우트는 프록시를 타지 않는다. 즉 이번 변경의 영향권 밖이다. 프록시를 타는 경로만 움직였다는 것이, 손댄 곳에만 효과가 났다는 증거다.
/daily의 -62%는 첫 진입만의 이야기가 아니다. 프록시는 클라이언트 내비게이션과 프리페치에서도 돌기 때문에, 앱 안에서 메뉴를 누를 때마다 붙던 세금이 같이 줄었다.
닫힌 게 아니라 막힌 것
남은 106ms는 검증 비용이 아니다. 지리다. 프록시가 sin1에 있는 한 한국 사용자는 서울↔싱가포르 왕복을 낸다. 코드로 더 줄일 수 없고, Hobby 플랜에서는 미들웨어 배치를 지정할 수도 없다.
문서에 "해결됨"이 아니라 이렇게 적었다.
이 항목은 닫힌 게 아니라 막힌 것이다.
나중에 이 숫자를 보고 누군가(아마 나겠지만) 또 파고들 텐데, 그때 "이미 다 해봤고 여기가 한계다"를 알 수 있어야 한다.
돌아보며
이번에 배운 건 성능 기법이 아니라 설정의 적용 범위를 확인하는 습관이었다.
vercel.json에 regions: ["icn1"]을 넣고 나는 "리전을 고쳤다"고 생각했다. 헤더에 icn1::icn1::이 찍히는 것도 확인했다. 그런데 그 헤더는 페이지 함수의 리전이었고, 프록시는 애초에 그 응답에 자기 흔적을 남기지 않는다. 확인했다고 생각한 것이 실은 다른 걸 확인한 것이었다.
이걸 알아챈 계기도 도구가 아니라 위화감이었다. DB까지 다녀오는 게 아무것도 안 하는 것보다 4배 빠르다는 숫자. 지난 글에서 "일을 안 하는 요청을 재보는 게 빨랐다"고 썼는데, 이번엔 그렇게 잰 숫자들끼리 비교했을 때 앞뒤가 안 맞는 지점이 다음 단서였다.
그리고 관측할 수 없는 건 고칠 수 없다. x-proxy-region 한 줄을 심기 전까지 나는 프록시가 어디 있는지 영원히 알 수 없었을 것이다. 그 헤더는 지금도 남겨뒀다. 이 설정과 프록시 배치가 또 어긋나도 빌드와 테스트는 여전히 통과할 테니까.