엣지에서 JWT를 로컬 검증하기 — JWKS 캐시와 리전 고정
인증이 2초를 잡아먹고 있었어요. 매 요청마다 지구 반대편에 "이 사람 맞나요?"를 물어보고 있었거든요. 서명 검증을 엣지로 내리고 캐시를 붙인 4단계와, 병목이 순서대로만 보인다는 교훈이에요.

관리자 화면이 뜨는 데 2초가 걸렸어요. 그런데 코드에는 느릴 만한 게 없었습니다.
쿼리도 가볍고, 렌더링도 단순하고, 데이터도 작았어요. 그런데 느렸죠.
범인은 인증이었습니다. 정확히는 매 요청마다 인증 서버에 "이 사람 맞나요?"를 물어보고 있었던 것이요.
토큰은 이미 답을 갖고 있었어요
로그인 세션은 JWT로 오고 있었습니다. JWT의 핵심은 서명이에요. 발급자가 개인키로 서명했고, 우리는 공개키로 그 서명을 검증할 수 있죠.
그러니까 원래는 물어볼 필요가 없는 구조였어요. 그런데 편해서 물어보고 있었습니다.
// 요청이 올 때마다
const res = await fetch(AUTH_SERVER + "/user", {
headers: { Authorization: "Bearer " + token },
});
const user = await res.json(); // ← 여기서 왕복 한 번이게 왜 2초까지 가느냐면, 인증 서버가 지구 반대편에 있었거든요. 그리고 이 왕복이 화면 렌더링을 막고 서 있었어요.
| 그때 한 요청에서 벌어진 일 | 비용 |
|---|---|
| 엣지에서 인증 서버로 왕복 | 원거리 네트워크 1회 |
| 그 응답을 기다리는 동안 | 다른 조회 전부 대기 |
| 화면 렌더링 | 그제서야 시작 |
처음 떠오른 답은 틀렸습니다
제일 먼저 든 생각은 "캐시 시간을 늘리자"였어요. 확인 결과를 오래 들고 있으면 왕복이 줄잖아요.
그런데 이건 문제를 미루는 거지 없애는 게 아니에요. 캐시가 식는 순간 그 사용자는 다시 2초를 만납니다.
그리고 더 나쁜 성질이 있어요. 느려지는 시점을 예측할 수 없게 됩니다. 대부분 빠른데 가끔 느린 화면이 제일 고치기 어려운 종류거든요.
(평균을 좋게 만드는 최적화와, 최악을 없애는 최적화는 다른 일이에요.)
그래서 검증을 엣지로 내렸습니다
서명 검증은 수학이에요. 공개키만 있으면 어디서든 할 수 있죠. 그래서 4단계로 내려갔습니다.
1단계 — 토큰 헤더부터 본다
서명을 확인하기 전에 헤더를 먼저 읽어요. 어떤 알고리즘인지, 어떤 키로 서명했는지.
const supportedAlgorithms = new Set(["ES256", "RS256"]);
const header = decodeProtectedHeader(token);
if (!supportedAlgorithms.has(header.alg) || typeof header.kid !== "string") {
return { kind: "unsupported" }; // 검증 시도조차 안 함
}허용 목록에 없는 알고리즘이면 여기서 끝냅니다. 이건 성능이 아니라 보안이에요 — 알고리즘을 토큰이 정하게 두면 안 되거든요.
2단계 — 공개키를 캐시에서 꺼낸다
공개키는 발급자가 공개 주소로 게시합니다. 매번 받아오면 왕복이 그대로 남으니 캐시를 뒀어요.
const maximumCacheMilliseconds = 10 * 60 * 1_000; // 10분여기서 신경 쓴 게 하나 있어요. 모르는 키 id가 오면 매번 공개키를 다시 받으러 갈까요? 그러면 아무 토큰이나 던져서 우리를 반복 요청하게 만들 수 있어요.
그래서 "이 키 id는 없더라"는 사실도 같이 캐시합니다. 없는 키를 계속 물어보러 가지 않게요.
3단계 — 로컬에서 서명을 검증한다
이제 네트워크 없이 검증합니다. 그리고 서명만 보는 게 아니라 클레임까지 같이 봐요.
const result = await jwtVerify(token, key, {
algorithms: [header.algorithm], // 헤더가 아니라 우리가 정한 것
audience: "authenticated",
issuer, // 우리 발급자가 맞는지
currentDate: new Date(clock.now()),
requiredClaims: ["exp", "sub"], // 없으면 실패
});`algorithms`를 다시 넘기는 게 포인트예요. 토큰이 주장하는 알고리즘을 그대로 믿지 않고, 1단계에서 검증한 값으로 고정합니다.
4단계 — 실패를 종류별로 나눈다
여기가 실제로 제일 까다로웠어요. 검증 실패에는 성격이 다른 두 가지가 섞여 있거든요.
| 결과 | 의미 | 해야 할 일 |
|---|---|---|
| unsupported | 우리가 다룰 수 있는 토큰이 아님 | 거절 |
| invalid | 서명이 틀렸거나 만료됨 | 거절 — 재시도 무의미 |
| unavailable | 공개키를 지금 못 가져옴 | 거절하면 안 됨 — 우리 잘못 |
| verified | 통과 | 세션 발급 |
세 번째가 중요해요. 공개키 서버가 잠깐 죽었을 때 사용자를 로그아웃시키면 안 되잖아요. 토큰이 틀린 것과 우리가 확인할 수 없는 것은 다른 사건입니다.
그래서 jose가 던지는 에러 코드를 목록으로 만들어 "이건 토큰 잘못"만 골라냈어요.
const invalidTokenErrorCodes = new Set([
"ERR_JOSE_ALG_NOT_ALLOWED",
"ERR_JWS_INVALID",
"ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
"ERR_JWT_CLAIM_VALIDATION_FAILED",
"ERR_JWT_EXPIRED",
"ERR_JWT_INVALID",
"ERR_JWKS_NO_MATCHING_KEY",
]);
// 이 목록에 없는 에러는 삼키지 않고 그대로 던진다목록에 없는 에러를 조용히 "invalid"로 처리하지 않는 것도 규칙이에요. 모르는 실패를 로그인 실패로 바꾸면 진짜 장애가 사용자 탓처럼 보입니다.
그럼 세션을 서버에 저장하면 안 되나
여기서 갈림길이 하나 있었어요. 아예 JWT를 버리고 세션을 서버에 저장하는 방식으로 갈 수도 있었거든요.
| 서버 세션 | 로컬 검증 JWT | |
|---|---|---|
| 검증 방법 | 저장소 조회 | 수학 계산 |
| 네트워크 | 매번 필요 | 필요 없음 |
| 즉시 무효화 | 가능 | 만료까지 유효 |
| 엣지 적합성 | 저장소가 멀면 무의미 | 어디서든 동일 |
세 번째 줄이 JWT의 진짜 약점이에요. 로그아웃시켜도 토큰은 만료 전까지 살아 있습니다.
그런데 이 프로젝트에서는 그 약점이 감당 가능했어요. 토큰 수명이 짧고, 다루는 게 가족 블로그의 저작 권한이거든요. 은행이었다면 반대로 갔을 겁니다.
중요한 건 이 판단을 조건을 적고 나서 했다는 거예요. "JWT가 요즘 방식이니까"로 고르면 나중에 왜 골랐는지 설명을 못 합니다.
한 가지 더 — 만료를 누가 다루나
검증이 통과해도 끝이 아니에요. 이 토큰이 언제 죽는지를 호출한 쪽이 알아야 합니다.
return {
kind: "verified",
session: { actor: { userId: subject }, expiresAt: new Date(tokenExpiresAt).toISOString() },
tokenExpiresAt, // ← 만료 시각을 같이 돌려준다
};그래서 클레임 검사도 깐깐하게 했어요. exp가 숫자인지, 안전한 정수 범위인지, 밀리초로 바꿨을 때 넘치지 않는지까지 봅니다.
이게 과해 보일 수 있는데, 여기서 이상한 값이 통과하면 "만료가 100만 년 뒤인 세션"이 만들어져요. 검증의 마지막 한 걸음을 빼먹으면 앞의 아홉 걸음이 무의미해집니다.
(요즘은 이런 계산을 AI가 잘 놓쳐요. 형태는 맞는데 경계값을 안 보는 코드가 나오거든요.)
그리고 위치를 옮겼습니다
검증을 로컬로 내려도 남는 왕복이 있었어요. DB 조회요.
엣지는 사용자 가까이 있는 게 이득인데, DB를 여러 번 보는 요청은 반대예요. DB 옆에 있는 게 이득입니다.
그래서 API를 DB가 있는 도쿄 리전에 고정했어요. 사용자와 멀어지는 대신 DB와 가까워지는 거래죠.
| 무엇을 어디에 | 이유 |
|---|---|
| 정적 파일 — 엣지 | 사용자와 가까울수록 빠름 |
| DB를 여러 번 보는 API — DB 리전 | 왕복 횟수 × 거리가 지배적 |
| 서명 검증 — 어디서나 | 네트워크가 필요 없는 계산 |
결과, 그리고 진짜 교훈
인증 캐시가 식은 직후에 2초를 넘던 경로가 체감 가능한 수준으로 내려왔어요. 그런데 숫자보다 중요한 게 있었습니다.
"캐시가 식으면 느려진다"는 비결정성이 사라진 거예요. 이제 느릴 수 있는 조건이 없습니다.
그리고 이 작업의 진짜 교훈은 인증이 아니라 순서에 있었어요.
병목은 순서대로만 보입니다. 이 2초를 지우기 전까지 그 뒤에 있던 문제들 — 요청마다 DB 연결을 따로 맺고 있던 것, 공개 페이지를 그리는 쪽이 아직 먼 곳에 있던 것 — 은 보이지도 않았어요.
한참 뒤에 공개 페이지 성능을 잡을 때 그 문제들을 만났고, 그때 이 문장을 다시 떠올렸습니다.
(그 이야기는 "빠르게 느껴지게" 시리즈에 있어요.)
다음 편은 이 Worker가 왜 세 개로 나뉘었는지예요. 조건은 셋이었어요 — 비용 0원, 저트래픽, 재배포 금지.


