Skip to content

필사 모드: JWT와 세션, 무엇을 언제 — 상태를 어디에 둘 것인가로 정리하는 인증 선택

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — JWT가 확장성이 좋다는 말의 실제 의미

인증 방식을 정할 때 가장 자주 등장하는 문장이 있습니다. "세션은 서버에 상태를 두니까 확장이 안 되고, JWT는 무상태라서 확장이 잘된다." 이 문장은 절반만 맞습니다. 그리고 틀린 절반이 실무에서 훨씬 비싼 대가를 치릅니다.

우선 사실 확인부터 합니다. JWT는 암호화되지 않습니다. base64url로 인코딩됐을 뿐이라 누구나 읽습니다.

echo 'eyJzdWIiOiJ1c2VyXzg4MTIiLCJyb2xlIjoiYWRtaW4iLCJlbWFpbCI6ImtpbUBleGFtcGxlLmNvbSIsImV4cCI6MTc4NDA1NjAwMH0' \
  | base64 -d
{"sub":"user_8812","role":"admin","email":"kim@example.com","exp":1784056000}

서명이 보장하는 것은 위조 불가이지 비밀 유지가 아닙니다. 그래서 페이로드에 이메일, 전화번호, 내부 식별자를 넣는 것은 브라우저 로컬 스토리지에 평문으로 개인정보를 저장하는 것과 같습니다. JWE로 암호화하지 않는 한 페이로드는 공개 데이터라고 생각해야 합니다.

이 글은 두 방식 중 하나를 고르는 기준을 다룹니다. 결론을 미리 말하면, 브라우저를 상대하는 대부분의 웹 애플리케이션에는 세션 쿠키가 더 낫고, JWT는 서비스 간 통신과 무상태가 정말로 필요한 자리에 씁니다.

근본 차이는 하나다 — 상태를 어디에 두는가

세션 방식에서 서버는 세션 저장소에 인증 상태를 들고 있고, 클라이언트는 의미 없는 식별자만 쿠키로 들고 있습니다. 요청이 오면 서버는 그 식별자로 저장소를 조회합니다. 조회 결과가 없으면 인증 실패입니다.

JWT 방식에서 서버는 아무것도 들고 있지 않고, 클라이언트가 서명된 클레임 자체를 들고 있습니다. 요청이 오면 서버는 서명을 검증하고 만료 시각을 확인합니다. 저장소 조회가 없습니다.

여기서 모든 것이 갈라집니다. 세션은 인증 판단의 근거가 서버에 있으니 서버가 언제든 바꿀 수 있습니다. JWT는 근거가 이미 클라이언트 손에 나가 있으니 서버가 회수할 수 없습니다. 성능도 저장 위치도 이 차이의 결과일 뿐입니다.

항목세션 쿠키JWT (무상태)짧은 액세스 토큰 + 리프레시
상태 위치서버 저장소클라이언트클라이언트 + 리프레시용 서버 저장소
요청당 비용저장소 조회 1회 (Redis 기준 1ms 미만)서명 검증 (HMAC은 마이크로초 단위)서명 검증. 갱신 주기마다만 저장소 조회
즉시 무효화가능. 레코드 삭제불가능. 만료까지 유효액세스 토큰 수명만큼 지연 (보통 5분에서 15분)
권한 변경 반영즉시만료 후갱신 시점
크기32바이트 내외의 식별자300에서 1000바이트. 매 요청 전송액세스 토큰 크기 동일
주요 노출 경로CSRFXSS (저장 위치에 따라)저장 설계에 따라 달라짐
다중 노드 확장공유 저장소 필요공유 저장소 불필요갱신 경로에만 공유 저장소 필요
검증 주체서버 본인비밀키나 공개키를 가진 누구나액세스 토큰은 누구나, 갱신은 발급자만

표의 마지막 줄이 JWT의 진짜 존재 이유입니다. 세션 식별자는 저장소에 접근할 수 있는 주체만 해석할 수 있지만, JWT는 공개키만 가지면 누구나 검증할 수 있습니다. 발급자와 검증자가 다른 조직이거나 다른 시스템일 때, 이 성질은 대체 불가능합니다.

JWT의 최대 약점 — 즉시 무효화가 불가능하다

계정이 탈취되어 사용자가 비밀번호를 바꿨다고 합시다. 세션이면 그 사용자의 세션 레코드를 전부 지우면 끝입니다. 다음 요청부터 즉시 로그아웃됩니다.

JWT면 지울 대상이 없습니다. 공격자가 들고 있는 토큰은 만료 시각까지 유효합니다. 만료를 24시간으로 잡았다면 최대 24시간 동안 공격자는 계속 로그인 상태입니다. 관리자 권한을 회수해도, 계정을 정지시켜도, 결제를 막아도 마찬가지입니다. 서버가 검증하는 것은 서명과 exp뿐이기 때문입니다.

현실적인 대응은 세 가지가 조합됩니다.

첫째, 액세스 토큰 수명을 짧게 잡습니다. 5분에서 15분이 일반적입니다. 노출 창을 그만큼 줄이는 방법입니다.

둘째, 리프레시 토큰으로 갱신합니다. 리프레시 토큰은 서버 저장소에 기록되어 있고, 갱신 시점에 유효성을 확인합니다. 회전을 함께 씁니다. 한 번 쓴 리프레시 토큰은 즉시 폐기하고 새것을 발급하며, 이미 쓴 토큰이 다시 들어오면 탈취로 간주해 그 계열 전체를 폐기합니다.

async function rotateRefreshToken(presented) {
  const record = await db.refreshTokens.findByHash(sha256(presented))
  if (!record) throw new AuthError('unknown token')

  if (record.usedAt) {
    // 이미 사용된 토큰이 다시 왔다 = 사본이 돌아다닌다
    await db.refreshTokens.revokeFamily(record.familyId)
    throw new AuthError('reuse detected, family revoked')
  }

  await db.refreshTokens.markUsed(record.id)
  return issuePair({ userId: record.userId, familyId: record.familyId })
}

셋째, 블랙리스트를 둡니다. 무효화된 토큰의 jti를 저장해 두고 매 요청마다 대조합니다.

그런데 여기서 정직하게 짚어야 할 지점이 있습니다. 블랙리스트를 두는 순간 모든 요청이 공유 저장소를 조회하게 되고, 그것은 세션과 정확히 같은 구조입니다. 저장소 장애가 인증 장애가 되는 것도, 노드마다 저장소에 붙어야 하는 것도 같습니다. 다른 점은 세션보다 부품이 많고 토큰이 크다는 것뿐입니다. 무상태의 이점을 없애면서 복잡도만 남기는 선택이 됩니다.

그래서 실제로 쓸 만한 중간 지점은 짧은 액세스 토큰과 저장된 리프레시 토큰의 조합입니다. 저장소 조회를 매 요청이 아니라 갱신 주기마다 한 번으로 줄이면서, 무효화 지연을 액세스 토큰 수명 이내로 묶습니다. 이 설계를 고를 때는 그 지연이 비즈니스적으로 감당 가능한지를 먼저 답해야 합니다. 금융 거래 권한처럼 초 단위 회수가 필요한 자리라면 감당할 수 없습니다.

토큰을 어디에 저장할 것인가

이 질문은 XSS와 CSRF 중 어느 쪽을 감수할 것인가로 요약되곤 하는데, 정확한 비교는 조금 다릅니다.

localStorage에 넣으면 같은 오리진의 모든 스크립트가 읽습니다. XSS가 한 번 발생하면 토큰이 통째로 공격자 서버로 전송되고, 공격자는 그 토큰으로 브라우저 밖에서 자유롭게 API를 호출합니다. 만료까지 유효하고, 사용자가 로그아웃해도 소용없습니다. XSS 하나가 곧 자격 증명 유출입니다.

httpOnly 쿠키에 넣으면 스크립트가 값을 읽을 수 없습니다. XSS가 나도 토큰 자체는 빠져나가지 않습니다. 다만 공격자 스크립트가 그 페이지 안에서 요청을 날리면 쿠키가 자동으로 실리므로 사용자 대신 행동할 수는 있습니다. 이것은 분명한 피해지만, 자격 증명이 유출되어 임의의 위치에서 무기한 사용되는 것과는 피해 규모가 다릅니다. 공격 창이 그 세션과 그 페이지로 제한됩니다.

쿠키를 쓰면 CSRF를 처리해야 합니다. 속성 설정이 1차 방어입니다.

Set-Cookie: sid=8f3c...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600

SameSite=Lax는 교차 사이트 POST에 쿠키를 싣지 않고, 최상위 내비게이션 GET에만 싣습니다. 크로미움이 기본값으로 채택하면서 폼 기반 CSRF의 상당 부분이 자동으로 막혔습니다. Strict는 외부 링크를 타고 들어온 GET에도 쿠키를 안 싣기 때문에 로그인 상태가 풀린 것처럼 보입니다. None은 교차 사이트 전송을 허용하며 Secure가 필수인데, 이 경우 CSRF 토큰이 반드시 필요합니다.

여기에 중요한 함정이 있습니다. SameSite는 오리진이 아니라 사이트 단위로 판단합니다. app.example.com과 api.example.com은 다른 오리진이지만 같은 사이트입니다. 서브도메인 하나에 XSS가 생기면 SameSite는 아무 방어도 하지 않습니다. 사용자 콘텐츠를 서브도메인에 호스팅하는 서비스라면 특히 위험합니다.

SPA에서 널리 쓰이는 절충안은 액세스 토큰을 자바스크립트 메모리 변수에만 두고, 리프레시 토큰을 httpOnly 쿠키로 갱신 경로에만 스코프하는 것입니다.

// 액세스 토큰은 모듈 스코프 변수에만. 새로고침하면 사라집니다
let accessToken = null

async function refresh() {
  // 리프레시 쿠키는 Path=/auth/refresh 로 스코프되어 이 요청에만 실립니다
  const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
  if (!res.ok) throw new AuthError('refresh failed')
  accessToken = (await res.json()).access_token
}

새로고침할 때마다 갱신 요청이 한 번 나가는 대신, XSS가 나도 지속 가능한 자격 증명은 스크립트 손에 들어가지 않습니다.

한 가지 덧붙이면, XSS가 발생한 상황에서 완전한 방어는 없습니다. 저장 위치 논쟁은 피해를 줄이는 이야기이지 막는 이야기가 아닙니다. Content-Security-Policy와 출력 이스케이프가 근본 대책이고, 저장 위치는 그다음입니다.

구현 취약점 — 서명은 검증할 때만 의미가 있다

JWT는 스펙이 유연해서 라이브러리 사용법을 조금만 어긋나게 쓰면 서명이 무력화됩니다. 고전이 된 사례들입니다.

alg 헤더를 none으로 바꿔 보내면 서명 없이 통과되던 라이브러리들이 있었습니다. 공격자는 페이로드의 role을 admin으로 바꾸고 서명 부분을 비운 토큰을 만들면 그만이었습니다. 오래된 이야기 같지만, 검증 시 알고리즘을 토큰 헤더에서 읽어 오는 코드는 지금도 나옵니다.

알고리즘 혼동은 더 교묘합니다. RS256으로 발급하는 서버에서 검증 함수가 알고리즘을 고정하지 않으면, 공격자가 HS256으로 헤더를 바꾸고 서버의 공개키를 HMAC 비밀키로 써서 서명한 토큰을 만듭니다. 공개키는 이름 그대로 공개되어 있으므로 누구나 만들 수 있습니다.

가장 흔한 것은 검증을 아예 안 하는 실수입니다.

// 하지 마세요 — decode는 서명을 검증하지 않습니다
const payload = jwt.decode(token)
if (payload.role === 'admin') grantAdmin()
// 검증 시 알고리즘, 발급자, 대상을 서버가 고정합니다
import { jwtVerify } from 'jose'

const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'], // 토큰이 고르게 두지 않습니다
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  clockTolerance: 5,
})

aud를 검증하지 않으면 다른 서비스용으로 발급된 토큰이 우리 API에서 통과합니다. 같은 인증 서버를 쓰는 내부 서비스가 여럿이면 실제로 벌어지는 일입니다. iss 미검증도 같은 성격입니다.

HMAC을 쓸 때 비밀키가 짧으면 오프라인에서 무차별 대입으로 뚫립니다. 사전 단어나 기본값을 그대로 둔 사례가 공개 서비스에서도 발견됩니다. 최소 256비트의 무작위 값을 쓰고, 코드나 저장소가 아니라 시크릿 관리 시스템에 둡니다.

kid 헤더로 키를 찾는 구현은 그 값이 공격자 입력이라는 점을 잊으면 안 됩니다. 파일 경로나 SQL 조회에 그대로 넣으면 경로 조작과 주입이 생깁니다. 알려진 키 식별자 집합과 대조한 뒤에만 사용합니다.

세션이 실제로 확장에 문제가 되는 시점

세션이 확장에 불리하다는 통념을 숫자로 확인해 보겠습니다.

세션 레코드 하나는 사용자 식별자, 발급 시각, 만료, 약간의 메타데이터로 이루어집니다. 넉넉히 잡아 200바이트에서 500바이트입니다. 동시 세션 100만 개면 200MB에서 500MB입니다. 레디스 인스턴스 하나가 여유롭게 감당하는 크기입니다. 1000만 개라도 수 GB이고, 이 규모의 서비스라면 이미 레디스 클러스터를 운영하고 있을 것입니다.

조회 비용은 키 하나에 대한 O(1) 조회입니다. 같은 리전 내 레디스 왕복은 보통 1ms 미만이고, 단일 인스턴스가 초당 수만에서 십만 단위 명령을 처리합니다. 애플리케이션이 요청당 수행하는 다른 DB 쿼리들과 비교하면 무시할 만한 비중입니다.

그러면 세션이 실제로 문제가 되는 지점은 어디인가. 세 가지입니다.

프로세스 메모리에 세션을 두고 스티키 세션으로 붙여 놓은 경우입니다. 배포할 때마다 로그인이 풀리고, 노드 간 부하가 균등해지지 않습니다. 이것은 세션의 문제가 아니라 저장소 선택의 문제이고, 공유 저장소로 옮기면 해결됩니다.

리전이 여러 개인 경우입니다. 유럽 사용자의 요청이 세션 조회를 위해 미국 리전까지 왕복하면 수백 밀리초가 추가됩니다. 리전별 저장소와 복제 전략이 필요해집니다. 이 지점부터는 무상태 토큰이 실질적인 이점을 갖습니다.

검증 주체가 우리가 아닌 경우입니다. 파트너사가 우리 토큰을 검증해야 하는데 우리 세션 저장소를 열어 줄 수는 없습니다. 공개키 검증이 답입니다.

정리하면, 세션이 확장에 불리해지는 시점은 대부분의 팀이 생각하는 것보다 훨씬 늦게 옵니다. 그 시점이 오기 전에 JWT를 선택하면, 얻지 못한 확장성 대신 무효화 불가능이라는 부채만 먼저 떠안습니다.

실무 권고 — 무엇을 언제

같은 도메인에서 동작하는 웹 애플리케이션이라면 세션 쿠키가 기본값입니다. httpOnly, Secure, SameSite=Lax를 붙이고, 저장소는 레디스나 DB를 씁니다. 로그아웃과 권한 회수가 즉시 반영되고, 부품이 적고, 토큰 크기 걱정이 없습니다. 서브도메인 간 공유가 필요하면 Domain 속성으로 해결됩니다.

모바일 앱이나 서드파티 클라이언트가 붙는 자사 API라면 짧은 액세스 토큰과 회전하는 리프레시 토큰을 씁니다. 쿠키를 쓸 수 없는 환경이므로 토큰이 필요하고, 리프레시 경로에 상태를 두어 무효화 수단을 확보합니다. 리프레시 토큰은 OS의 보안 저장소에 넣습니다.

서비스 간 통신에는 JWT가 잘 맞습니다. 호출자마다 인증 서버로 왕복하지 않고 공개키로 검증할 수 있고, 스코프를 좁게 잡아 짧게 발급하면 무효화 문제가 실질적으로 사라집니다. 수명이 수십 초 단위라면 폐기할 이유 자체가 거의 없습니다.

외부 신원 제공자를 쓰는 경우, OIDC의 ID 토큰은 JWT입니다. 다만 ID 토큰은 신원을 증명하는 용도이지 API 접근 자격 증명이 아닙니다. ID 토큰을 그대로 API 인증에 쓰는 것은 흔한 오용입니다.

마지막으로 피해야 할 조합이 있습니다. 만료가 긴 JWT를 localStorage에 넣고 로그아웃은 클라이언트에서 값을 지우는 것으로 처리하는 설계입니다. 로그아웃 버튼이 아무것도 무효화하지 않고, XSS 한 번이면 장기 자격 증명이 통째로 나갑니다. 가장 널리 퍼진 튜토리얼 형태이면서 가장 취약한 구성입니다.

마치며 — 무상태가 공짜인지부터 확인한다

JWT를 고를 때 던져야 할 질문은 "확장이 잘될까"가 아니라 "이 자격 증명을 회수하지 못해도 괜찮은가"입니다. 괜찮다면 무상태의 이점을 온전히 누립니다. 괜찮지 않다면 어떤 형태로든 상태를 다시 들여와야 하고, 그 순간 세션 대비 우위는 대부분 사라집니다.

인증 설계의 첫 결정은 토큰 형식이 아니라 무효화 요구사항이고, 그것이 상태를 어디에 둘지를 결정합니다. 브라우저를 상대하는 평범한 웹 서비스라면 세션 쿠키로 시작하는 편이 거의 항상 옳습니다. 나중에 무상태가 진짜로 필요해졌을 때 옮기는 비용이, 처음부터 JWT로 시작해 무효화 사고를 겪는 비용보다 쌉니다.

현재 단락 (1/90)

인증 방식을 정할 때 가장 자주 등장하는 문장이 있습니다. "세션은 서버에 상태를 두니까 확장이 안 되고, JWT는 무상태라서 확장이 잘된다." 이 문장은 절반만 맞습니다. 그리고...

작성 글자: 0원문 글자: 6,486작성 단락: 0/90