공공부하자개발 · 영어 학습 노트
자바
인증·로그인JWT · OAuth2 · 2FA 로 로그인 구현0/3 완료
  • 011단계: 비밀번호 해시와 JWT 로그인
  • 022단계: Access/Refresh 토큰과 OAuth2 인가 코드
  • 033단계: 2FA(TOTP) 와 로그인 흐름 통합
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 인증·로그인 › 02 / 3

2단계: Access/Refresh 토큰과 OAuth2 인가 코드

섹션 7진행 0 / 3
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 토큰을 둘로 나눈다 — Access 는 짧고 무상태, Refresh 는 길고 서버가 기억한다

Access 토큰 Refresh 토큰
형식 JWT (내용 있음, 서명으로 검증) 불투명 난수 (내용 없음, 서버 저장소의 키)
수명 5~15분 며칠~몇 주
검증 서명·exp·aud 만 보고 통과 → DB 조회 없음 서버 저장소에서 찾아야 함
보내는 곳 매 API 요청, Authorization: Bearer 갱신 요청 한 곳에만
유출 시 피해 남은 몇 분 회전·재사용 감지로 제한

핵심은 역할 분리입니다. 리소스 서버(API)는 Access 만 봅니다. 서명이 맞으면 통과시키니 빠르고, DB 를 안 보니 수평 확장이 쉽습니다. "취소할 수 없다" 는 약점은 그대로지만, 수명이 5분이면 그 약점의 크기도 5분입니다.

Refresh 토큰은 인가 서버만 봅니다. 서버 저장소에 있으니 언제든 지울 수 있고(로그아웃, 계정 정지), 내용이 없는 난수이니 훔쳐서 열어 봐야 얻을 게 없습니다. JWT 로 만들 이유가 없습니다. 만들면 "서버가 기억하지 않는 긴 토큰" 이 되어 1단계의 약점을 키운 꼴이 됩니다.

2.2 회전과 재사용 감지 — 한 번 쓴 Refresh 는 다시 못 쓴다

Refresh 토큰을 쓸 때마다 새 Refresh 로 교체하고 구 토큰은 "사용됨" 으로 표시합니다. 이것이 회전(rotation)입니다. 회전이 없으면 유출된 Refresh 하나로 공격자가 몇 주 동안 Access 를 무한히 뽑아 갑니다.

회전에 family 개념을 붙이면 탈취를 감지할 수 있습니다. 로그인 한 번 = family 하나. 회전으로 만들어지는 모든 후속 토큰은 같은 family 입니다.

text
로그인 → R1 (family F)
R1 사용 → R2 발급, R1 = 사용됨
R2 사용 → R3 발급, R2 = 사용됨
...
누군가 R1 을 다시 보냄 → "사용된 토큰의 재사용" → family F 전체 폐기 (R3 도 죽는다)

정상 클라이언트는 사용한 토큰을 다시 보내지 않습니다. 재사용이 왔다는 것은 토큰이 두 곳에 있다는 뜻입니다. 누가 진짜인지 서버는 모르므로 둘 다 끊고 재로그인을 시킵니다. 정상 사용자는 한 번 더 로그인하는 불편을 겪지만, 공격자는 그 순간 쫓겨나고 "이 계정의 세션이 탈취됐었다" 는 신호가 남습니다.

2.3 로그아웃 — Access 는 jti 거부 목록, Refresh 는 삭제

로그아웃하면 Refresh 는 저장소에서 지우면 끝입니다. 문제는 아직 exp 가 남은 Access 입니다. 완전한 무상태를 포기하고 거부 목록을 둡니다. 토큰마다 고유 ID(jti)를 넣어 두고, 로그아웃 시 그 jti 를 exp 와 함께 목록에 넣습니다. 리소스 서버는 서명 검증 뒤 이 목록을 한 번 확인합니다.

거부 목록이 무한히 커지지 않을까요? 아닙니다. exp 가 지난 항목은 지워도 됩니다(어차피 서명 검증에서 만료로 걸립니다). 그래서 목록 크기는 "Access TTL 동안 로그아웃한 수" 를 넘지 않습니다. TTL 이 5분이면 목록은 늘 최근 5분치입니다. Redis 라면 SET jti 1 EX <남은 초> 한 줄입니다.

거부 목록을 두기 싫으면 "로그아웃 = Refresh 삭제만" 으로 타협하고 Access 의 남은 몇 분은 감수하는 서비스도 많습니다. 어느 쪽이든 의식적으로 선택해야 하고, 그 선택은 Access TTL 과 함께 정해집니다.

2.4 토큰을 어디에 두는가 — Refresh 는 HttpOnly 쿠키, Access 는 메모리

브라우저에는 저장 장소가 셋 있습니다. localStorage, 일반 쿠키, HttpOnly 쿠키. XSS(페이지에 악성 스크립트가 주입되는 공격)가 터지면 앞의 둘은 document.cookie 와 localStorage.getItem 으로 통째로 털립니다. HttpOnly 쿠키만 JS 가 읽을 수 없습니다.

그래서 표준 배치는 이렇습니다.

  • Refresh → Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/refresh
    • HttpOnly JS 접근 차단. Secure HTTPS 에서만. Path=/refresh 갱신 요청에만 실리므로 다른 요청에는 노출조차 안 됨. SameSite=Strict 다른 사이트에서 시작된 요청에는 안 실림 → CSRF 차단.
  • Access → 응답 본문으로 받아 JS 변수(메모리)에 둠. 새로고침하면 사라지지만 Refresh 쿠키로 조용히 다시 받으면 됩니다. localStorage 에 두지 않는 이유는 XSS 한 방에 TTL 동안 털리기 때문입니다.

쿠키의 약점은 CSRF 입니다. 쿠키는 브라우저가 자동으로 붙이므로, 공격자 사이트가 우리 서버로 <form> 을 제출하면 쿠키가 딸려 갑니다. SameSite=Strict 가 이를 막고, 갱신 응답에 담긴 Access 는 공격자 페이지가 읽을 수 없으니(동일 출처 정책) 갱신 CSRF 로 얻는 것도 없습니다.

한 걸음 더 간 것이 BFF(Backend for Frontend) 입니다. 브라우저에는 토큰을 아예 주지 않고 세션 ID 쿠키만 줍니다. 토큰은 우리 백엔드가 세션에 보관하고, 브라우저 대신 API 를 호출합니다. 이 레슨의 클라이언트 앱이 이 구조입니다. Spring Security 의 oauth2Login() 도 기본이 이 형태입니다.

2.5 aud — 이 토큰은 어느 API 용인가

인가 서버 하나가 여러 API(주문, 결제, 관리자)에 토큰을 발급하면, 주문 API 용 토큰을 결제 API 에 들이밀어도 서명은 맞습니다. aud(audience) 클레임에 대상 API 이름을 넣고, 각 리소스 서버가 자기 이름이 맞는지 확인해야 이 재활용을 막습니다. Spring Resource Server 의 JwtValidators 에 aud 검사를 추가하는 것이 정확히 이 부분이며, 빠뜨리기 쉬운 항목 1순위입니다.

2.6 OAuth2 인가 코드 흐름 — 세 역할과 두 채널

OAuth2 에는 역할이 넷 있습니다. 리소스 소유자(사용자), 클라이언트(우리 앱), 인가 서버(구글 로그인 서버), 리소스 서버(구글 프로필 API). 인가 코드 흐름은 이렇게 돕니다.

text
브라우저                클라이언트 앱              인가 서버                리소스 서버
  │ GET /login              │                          │                        │
  │────────────────────────▶│ state, verifier 생성·세션 저장                     │
  │ 302 → /authorize?client_id&redirect_uri&scope&state&code_challenge          │
  │◀────────────────────────│                          │                        │
  │ GET /authorize ...      (앞 채널: 브라우저 주소창을 지나간다)                    │
  │───────────────────────────────────────────────────▶│ 로그인 화면, 동의 화면    │
  │ 302 → redirect_uri?code=XXX&state=...              │                        │
  │◀───────────────────────────────────────────────────│                        │
  │ GET /callback?code&state│                          │                        │
  │────────────────────────▶│ state 대조               │                        │
  │                         │ POST /token code + code_verifier  (뒷 채널: 서버↔서버) │
  │                         │─────────────────────────▶│ 코드·PKCE 검증          │
  │                         │ {access_token, refresh_token}                     │
  │                         │◀─────────────────────────│                        │
  │                         │ GET /api/profile  Bearer access                   │
  │                         │──────────────────────────────────────────────────▶│
  │ 302 → /profile          │◀──────────────────────────────────────────────────│

왜 토큰을 바로 안 주고 코드를 거칠까요? 앞 채널(브라우저 리다이렉트)은 주소창·히스토리·Referer·프록시 로그에 남습니다. 거기에 토큰을 실으면 유출 경로가 너무 많습니다.

그래서 앞 채널에는 60초짜리 1회용 코드만 흘리고, 진짜 토큰은 클라이언트 서버가 인가 서버에 직접(뒷 채널) 요청해서 받습니다. 브라우저는 토큰을 보지 못합니다. 예전의 Implicit 흐름(토큰을 URL 조각으로 직접 반환)은 이 이유로 폐기됐습니다.

2.7 PKCE 와 state — 코드 가로채기와 로그인 CSRF 를 막는다

코드가 앞 채널을 지나는 이상, 코드를 가로챌 가능성은 남습니다. 가로챈 코드로 /token 을 부르면 토큰이 나옵니다. 이를 막는 것이 PKCE(Proof Key for Code Exchange, "픽시")입니다.

  1. 클라이언트가 난수 code_verifier 를 만들어 자기만 보관합니다.
  2. /authorize 에는 code_challenge = BASE64URL(SHA-256(code_verifier)) 를 보냅니다. 인가 서버는 이 값을 코드와 함께 저장합니다.
  3. /token 에는 원본 code_verifier 를 보냅니다. 인가 서버가 다시 SHA-256 해서 저장값과 대조합니다.

challenge 는 앞 채널에 노출되지만 SHA-256 을 역산할 수 없으니 verifier 는 알 수 없고, 코드만 가로챈 공격자는 3번을 통과하지 못합니다. 원래 모바일 앱(비밀을 숨길 수 없는 공개 클라이언트)용이었지만, 현재는 모든 클라이언트에 PKCE 필수가 권고입니다.

state 는 방향이 반대인 공격을 막습니다. 공격자가 자기 계정으로 로그인해서 받은 콜백 URL(/callback?code=공격자코드)을 피해자에게 클릭시키면, 피해자 브라우저가 공격자 계정으로 로그인됩니다. 피해자가 그 상태로 카드 번호를 저장하면 공격자 계정에 저장됩니다. 이것이 로그인 CSRF 입니다.

클라이언트는 /login 에서 난수 state 를 세션에 저장하고, 콜백에서 세션의 state 와 URL 의 state 가 같은지 확인합니다. 공격자의 콜백 URL 에는 공격자 세션의 state 가 들어 있으니 피해자 세션과 맞지 않습니다.

2.8 인가 서버가 지켜야 할 검사 순서

/authorize 에서 redirect_uri 는 등록값과 문자열 단위로 정확히 일치해야 합니다. 접두어 일치나 와일드카드를 허용하는 순간 https://our.app.evil.com 같은 주소로 코드를 보내게 됩니다.

client_id/redirect_uri 가 틀렸을 때는 리다이렉트 자체를 하면 안 됩니다(400 으로 끝). 그 외 오류(scope, response_type)는 규격대로 redirect_uri?error=...&state=... 로 돌려보냅니다.

/token 에서는 코드 존재 → 미사용 → 미만료(60초) → client_id·redirect_uri 일치 → PKCE 순으로 검사하고, 실패 시 400 에 {"error":"invalid_grant","error_description":"..."} 형식으로 답합니다. 401 이 아닙니다. 코드가 두 번 오면 탈취로 보고, 규격은 그 코드로 이미 발급한 토큰까지 폐기하라고 권고합니다.

2.9 Spring Security 가 대신하는 것

이 레슨 Spring
ClientApp /login, /callback oauth2Login() 이 두 엔드포인트를 자동 제공
설정 파일 application.yml 의 client registration
ResourceServer 검증 oauth2ResourceServer().jwt() + issuer-uri
AuthorizationServer Spring Authorization Server, Keycloak, 소셜 제공자
TokenService 회전·거부 목록 OAuth2AuthorizationService(JDBC) + reuseRefreshTokens(false)
Refresh HttpOnly 쿠키 ResponseCookie 직접 생성. BFF 면 세션 쿠키
SAML SSO saml2Login()

oauth2Login() 은 /oauth2/authorization/{id} 와 /login/oauth2/code/{id} 를 만들어 주고, state·PKCE·세션 저장이 내장돼 있습니다. 설정은 다음 키로 합니다.

yaml
spring.security.oauth2.client:
  registration.google: { client-id, client-secret, scope, redirect-uri }
  provider.google: { authorization-uri, token-uri }

리소스 서버의 aud 검사는 JwtValidators 로 추가합니다. SAML 은 인가 코드 대신 XML Assertion 을 POST 로 받지만 "IdP 로 보냈다가 증표 받아 오기" 구조는 같습니다.

소셜 로그인(구글·카카오·네이버)은 인가 서버를 남이 운영하는 인가 코드 흐름입니다. 우리 앱은 클라이언트 역할만 하고, 받은 토큰으로 프로필 API 를 불러 이메일을 얻은 뒤 자사 계정과 연결하고 자사 토큰을 발급합니다. 구글 토큰을 우리 API 인증에 그대로 쓰지 않습니다.

핵심 원리
  • 2.1 토큰을 둘로 나눈다 — Access 는 짧고 무상태, Refresh 는 길고 서버가 기억한다
  • 2.2 회전과 재사용 감지 — 한 번 쓴 Refresh 는 다시 못 쓴다
  • 2.3 로그아웃 — Access 는 jti 거부 목록, Refresh 는 삭제
  • 2.4 토큰을 어디에 두는가 — Refresh 는 HttpOnly 쿠키, Access 는 메모리
  • 2.5 aud — 이 토큰은 어느 API 용인가
  • 2.6 OAuth2 인가 코드 흐름 — 세 역할과 두 채널
  • 2.7 PKCE 와 state — 코드 가로채기와 로그인 CSRF 를 막는다
  • 2.8 인가 서버가 지켜야 할 검사 순서
  • 2.9 Spring Security 가 대신하는 것
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제