전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

엣지에서 JWT를 로컬 검증하기 — JWKS 캐시와 리전 고정

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

2026. 07. 14. 13:13:246분 읽기프로젝트/arcaC/성능 개선
#Supabase#Cloudflare Workers#성능#JWT#JWKS
조회 38
엣지 JWT 검증 썸네일

관리자 화면이 뜨는 데 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원, 저트래픽, 재배포 금지.

시리즈 이어읽기

엣지에서 벌어진 일

1 / 2

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.