처음 인증을 공부할 때는 단순하게 생각했습니다. “JWT 발급해서 헤더로 받고, 서명만 검증하면 끝 아닌가?” 그런데 인증/인가 자료를 조금만 읽어보면 갈림길이 많습니다. 토큰을 헤더에 둘지 쿠키에 둘지, 회수는 블랙리스트로 할지 화이트리스트로 할지, Refresh Token을 회전시킬지 슬라이딩으로 갈지, 인증 프로토콜은 자체로 짤지 OAuth2/OIDC를 쓸지, 인가는 역할로 할지 속성으로 할지. 각 갈림길마다 보안 권고, 운영 비용, 사용자 경험이 달라집니다.

PeekCart 코드를 읽기 전에 이 갈림길들을 먼저 정리하고 싶었습니다. 그래야 PeekCart의 선택이 여러 가능성 중 어디에 있는지 볼 수 있습니다.

이 글에서 사용하는 용어는 다음 뜻으로 읽으면 됩니다.

용어이 글에서의 의미
인증 (Authentication, AuthN)“당신이 누구인가”를 확인하는 과정. 로그인이 대표 예
인가 (Authorization, AuthZ)“당신이 무엇을 할 수 있는가”를 결정하는 과정. 권한 체크가 대표 예
액세스 토큰 (Access Token)한 요청에 대한 인증을 증명하는 짧은 수명의 토큰
리프레시 토큰 (Refresh Token)액세스 토큰을 다시 발급받기 위한 긴 수명의 토큰
토큰 회수 (Revocation)발급된 토큰을 강제로 무효화하는 동작. 로그아웃, 탈취 대응이 대표 예

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이번 학습에서는 다음 질문을 확인하려고 합니다.

  1. 인증과 인가는 어떻게 구분해서 봐야 할까?
  2. 비밀번호는 어떻게 저장하고 검증해야 할까: bcrypt, Argon2, salt, pepper?
  3. 사용자 상태를 어디에 둘 것일까: 서버, 클라이언트, 둘 다?
  4. 토큰을 어디에 담아 보낼 것일까: Authorization 헤더, 쿠키, 그 외?
  5. 토큰을 어떻게 서명/암호화할 것일까: HS256, RS256, ES256, JWE?
  6. 토큰을 어떻게 회수할 것일까: 블랙리스트, 화이트리스트, 짧은 만료?
  7. 재발급 전략과 토큰 수명은 무엇이 있을까: Rotation, Sliding, Reuse Detection, Grace Period?
  8. 인증 프로토콜은 자체로 짤 것일까, OAuth2/OIDC/Passkey 같은 표준을 쓸 것일까?
  9. 인가는 어떤 모델로 표현할 것일까: RBAC, ABAC, ReBAC, Scope?
  10. 서비스 간 신뢰는 어떻게 세울 것일까: mTLS, API Key, Service Token, Gateway?
  11. 이 갈림길들에서 PeekCart는 어떤 선택을 했고 어떤 한계를 남겼을까?

인증과 인가는 어떻게 다른가

가장 먼저 분리해서 봐야 할 두 개념입니다. 종종 같은 흐름 안에 묶여서 다뤄지기 때문에 헷갈리기 쉽습니다.

  • 인증(Authentication, AuthN): 요청을 보낸 주체가 누구인지 확인하는 과정. “이 사람이 정말 user 42인가”에 답합니다.
  • 인가(Authorization, AuthZ): 그 주체가 특정 동작을 할 수 있는지 결정하는 과정. “user 42가 order 7을 취소할 수 있는가”에 답합니다.

같은 시스템에서 두 동작이 거의 동시에 일어납니다. JWT를 예로 들면 서명 검증은 인증에 가깝고 클레임에 담긴 role을 보고 권한을 확인하는 부분은 인가에 가깝습니다. Spring Security의 Authentication과 AccessDecisionManager도 비슷한 방식으로 둘을 분리합니다.

이 구분은 단지 용어 문제가 아닙니다. 인증과 인가는 언제, 누가 책임지는가가 다르게 설계될 수 있습니다.

  • 인증은 보통 시스템 입구(Gateway, Filter) 한 곳에 모읍니다.
  • 인가는 도메인 가까이에서, 때로는 데이터 레벨에서 결정해야 합니다.

예를 들어 “주문은 본인만 취소할 수 있다”는 규칙은 인증이 끝난 뒤 도메인 로직에서 한 번 더 확인해야 합니다. 토큰이 유효하다는 것이 곧 “이 주문에 접근할 권리”를 뜻하지는 않기 때문입니다. OWASP의 Authorization Cheat Sheet는 이 차이를 명시적으로 강조합니다.

이번 글에서 다루는 비교는 대부분 인증 쪽입니다. 인가는 PeekCart 범위에서 단순(ROLE_USER, ROLE_ADMIN)하기 때문에 후반부에 잠깐만 다룹니다.


갈림길 1. 비밀번호를 어떻게 저장하고 검증할 것인가

인증의 가장 첫 갈림길은 자격증명을 어떻게 다룰 것인가입니다. 토큰이 발급되기 전에 비밀번호가 먼저 검증되어야 하고 그 비밀번호는 데이터베이스에 어떤 형태로든 저장됩니다. 평문 저장이 안 된다는 것은 누구나 압니다. 그러면 어떻게 저장할 것인가에서 또 갈림길이 나뉩니다.

방식핵심 아이디어적합한 상황 / 문제
평문 저장그대로 저장사고 시 즉시 전체 유출, 적합한 상황은 없다
단순 해시 (MD5, SHA-1)일방향 해시레인보우 테이블, GPU 무차별 대입에 무력
Salted Hash (SHA-256 + salt)사용자별 salt 추가레인보우 테이블 방어, GPU 무차별 대입은 여전
PBKDF2해시 반복 (key stretching)NIST 권고, FIPS 환경에서 자주 채택
bcrypt적응형 해시, work factor 조정 가능검증된 알고리즘, 메모리는 적게 사용
scrypt메모리 강성 추가GPU/ASIC 공격에 더 강함
Argon2id메모리, 시간, 병렬성 모두 조정OWASP 권고, 새로 짤 때의 기본값

모든 알고리즘은 다음 두 원리를 따릅니다.

Salt는 같은 비밀번호가 사용자마다 다른 해시값을 갖게 만들어, 한 번에 여러 계정을 깨지 못하게 합니다. bcrypt와 Argon2는 salt를 해시 문자열 자체에 내장합니다(예: $2a$10$<salt><hash> 형식).

Work factor(cost) 는 해시 계산을 일부러 느리게 만들어 무차별 대입 비용을 키웁니다. 하드웨어 성능이 좋아질수록 work factor를 올려야 하므로 알고리즘 자체가 “조정 가능”해야 합니다. bcrypt의 cost, Argon2의 memory/time/parallelism이 그 파라미터입니다.

함께 알아둘 개념이 하나 더 있습니다. Pepper는 모든 비밀번호에 공통으로 적용되는 서버 측 시크릿입니다. DB가 유출되더라도 pepper를 모르면 해시를 깰 수 없습니다. salt는 DB와 함께 유출되지만 pepper는 애플리케이션 시크릿 보관소(예: HSM, KMS)에 둡니다. OWASP의 Password Storage Cheat Sheet는 Argon2id + pepper(HSM 보관)를 가장 강한 조합으로 권고합니다.

Spring Security는 이 영역에서 흥미로운 패턴을 제공합니다. DelegatingPasswordEncoder 는 해시 문자열 앞에 {bcrypt}, {argon2} 같은 프리픽스를 붙여 알고리즘을 식별합니다. 한 DB 안에 여러 알고리즘의 해시가 공존할 수 있고 사용자가 다음 로그인할 때 자연스럽게 새 알고리즘으로 마이그레이션됩니다. 이 방식으로 알고리즘 회전을 지원합니다.

PeekCart의 선택은 bcrypt입니다.

// SecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

BCryptPasswordEncoder는 기본 cost=10으로 동작합니다(2^10 라운드). 호출부는 Application 레이어인 AuthService에 있지만 비밀번호 검증의 도메인 의미는 User 엔티티에 모여 있습니다.

// User.java
public boolean matchesPassword(String rawPassword, PasswordEncoder encoder) {
    return encoder.matches(rawPassword, this.passwordHash);
}

User 엔티티는 PasswordEncoder 인터페이스에만 의존하고 bcrypt 구현체는 모릅니다. 엔티티가 인코더를 필드로 들고 있지도 않고 메서드 매개변수로만 받습니다. 비밀번호 비교는 도메인 규칙이지만 알고리즘은 Infrastructure 책임이라는 분리가 자연스럽게 표현됩니다.

PeekCart가 의도적으로 안 한 것은 다음과 같습니다

  • Argon2id로 가지 않음: bcrypt가 검증된 알고리즘이고 Spring 기본 인코더라서 학습 범위에서는 충분합니다. OWASP 권고대로 Argon2id를 사용할 수도 있겠지만 Argon2id의 메모리 사용량이 성능에 주는 영향을 무시할 수 없습니다.
  • DelegatingPasswordEncoder 미사용: 현재는 알고리즘 회전을 고려하지 않고 BCryptPasswordEncoder를 직접 빈으로 등록했습니다. 운영 시스템이라면 마이그레이션 여지를 위해 delegating encoder가 더 안전한 선택입니다. 현재로서는 기술부채로 두고 추후 개선합니다.
  • Pepper 미적용: 시크릿 보관소가 분리되지 않아 일단 salt만 의지합니다. JWT secret과 함께 KMS로 옮길 때 같이 도입할 후보입니다.
  • 비밀번호 정책: 최소 길이, 유출된 비밀번호 차단(HIBP API)이 없습니다. OWASP 권고는 “길이만 강제, 복잡도 규칙은 완화, HIBP의 알려진 유출은 차단”입니다.

갈림길 2. 사용자 상태를 어디에 둘 것인가

가장 큰 갈림길입니다. 한 번 로그인한 사용자가 이후 수많은 요청을 보낼 때, 서버는 매번 그가 누구인지를 알아야 합니다. 그 “기억”을 어디에 둘 것인가가 모든 다음 결정을 좌우합니다.

전략핵심 아이디어장점단점
Session ID + 서버 저장서버가 세션 상태를 보관, 클라이언트는 ID만 전달즉시 회수 가능, 상태 변경 즉시 반영매 요청마다 세션 저장소 조회, 수평 확장 시 세션 공유 비용
JWT only (순수 stateless)서명된 토큰이 사용자 정보를 자체 포함무상태, 빠른 검증, 서비스 간 전파 쉬움회수 불가, 강제 로그아웃 불가, 만료까지 무한 유효
JWT + 회수 메커니즘 (하이브리드)평소엔 stateless, 회수 시점만 stateful빠른 검증 + 회수 가능두 메커니즘 동기화 부담
Opaque Token + Introspection토큰은 식별자, 검증은 인증 서버에 위임OAuth2 표준, 토큰 자체에 민감 정보 없음매 요청마다 introspection 호출 (캐싱 필요)
Token Binding / DPoP토큰을 클라이언트 키에 묶음탈취 시에도 사용 불가클라이언트 구현 부담, RFC 9449 채택률 아직 낮음

전통적으로 웹은 Session ID + 쿠키였습니다. JWT가 등장하면서 stateless가 매력적으로 보였지만 실제로는 “회수 불가”라는 단점 때문에 대부분 시스템이 어떤 형태로든 stateful 요소를 끌어들였습니다. Sven Slootweg의 Stop using JWT for sessions는 이 지점을 강하게 비판한 글로 유명한데, 정리하면 “JWT로 세션을 흉내내려고 하지 말고, 회수가 필요하면 세션처럼 다뤄라”는 주장입니다.

PeekCart의 선택은 세 번째입니다. JWT + 회수 메커니즘. 액세스 토큰은 stateless로 두되, 로그아웃과 재발급 같이 “회수가 필요한” 시점에만 저장소를 끌어들입니다. 이게 왜 합리적인지는 다음 갈림길들을 따라가며 보입니다.

참고로 OAuth2가 권고하는 표준 토큰 형식은 RFC 7519 JWT와 RFC 6749의 opaque token 두 가지 모두입니다. 어느 쪽이든 표준 위에 올라가 있습니다.


갈림길 3. 토큰을 어디에 담아 보낼 것인가

토큰을 발급한 다음 그것을 클라이언트가 매 요청에 어떻게 실어 보낼 것인가도 갈림길이 큽니다. 보안 모델과 공격 표면이 여기서 갈립니다.

전송 위치사용 방식장점약점
Authorization: Bearer 헤더클라이언트 JS가 명시적으로 헤더 추가CSRF 무력화 (브라우저가 자동으로 안 붙임)XSS에 취약 (localStorage에 저장 시 JS가 읽을 수 있음)
Cookie (httpOnly)브라우저가 자동 전송XSS로 토큰 탈취 불가 (JS가 못 읽음)CSRF 방어 필요 (SameSite, CSRF 토큰)
Cookie + SameSite=Strict브라우저가 자동 전송, 다른 사이트에선 안 붙음XSS + CSRF 모두 강함OAuth 리다이렉트 등 cross-site 흐름 제약
Custom Header (예: X-Auth-Token)명시적 헤더, CORS preflight 트리거헤더 자체로 cross-site 호출 차단 가능표준이 아니라 SDK/툴 호환성 부족
URL Query Parameter?token=...가장 단순로그/Referer/북마크로 유출, 거의 안티패턴
Body (POST 전용)POST body에 포함로그 노출 회피GET 요청에서는 불가

여기서 핵심 트레이드오프는 XSS와 CSRF 사이의 선택입니다.

  • 헤더(localStorage) 방식은 CSRF는 자연스럽게 방어됩니다. 브라우저가 헤더를 자동으로 붙이지 않기 때문입니다. 그러나 XSS가 발생하면 토큰이 그대로 털립니다.
  • 쿠키(httpOnly) 방식은 XSS에서 토큰이 안전합니다. JS가 쿠키를 읽을 수 없기 때문입니다. 그러나 브라우저가 자동으로 붙이므로 CSRF 공격이 가능해 별도 방어가 필요합니다.

OWASP의 Session Management Cheat Sheet는 일반적으로 httpOnly + Secure + SameSite 쿠키를 권고합니다. Auth0의 Token Storage 가이드도 비슷한 결론에 도달합니다. 다만 SPA + REST API + 모바일 클라이언트가 섞인 환경에서는 Bearer 헤더가 더 흔하게 채택됩니다. 모바일 앱에서 쿠키 처리는 부자연스럽기 때문입니다.

PeekCart는 Authorization: Bearer 헤더 방식입니다.

// JwtFilter.java
private String resolveToken(HttpServletRequest request) {
    String header = request.getHeader("Authorization");
    if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
        return header.substring(7);
    }
    return null;
}

이 선택의 이유는 PeekCart가 SPA + REST API를 가정하는 학습 프로젝트이기 때문입니다. 만약 서버 사이드 렌더링이 섞인 전통적 웹 앱이라면 httpOnly 쿠키 방식이 더 자연스러웠을 것입니다. 토큰이 헤더에 있다는 것은 XSS 대응을 별도 레이어(Content Security Policy, 입력 검증, 출력 인코딩)에서 강하게 해야 한다는 뜻이기도 합니다.

쿠키 + httpOnly로 갔다면 무엇이 달라졌을까요? 대략 이렇습니다.

  • JwtFilter가 Authorization 헤더 대신 쿠키에서 토큰을 꺼냅니다.
  • 로그인 응답이 토큰을 body에 담는 대신 Set-Cookie로 내려줍니다.
  • CSRF 방어로 SameSite=Lax 또는 Strict를 켜고 필요 시 별도 CSRF 토큰을 추가합니다.
  • 토큰 회수 정책은 그대로입니다(쿠키든 헤더든 서버가 토큰 값을 보고 검사하는 건 같습니다).

이 차이를 명확히 짚어두는 이유는, 클라이언트 환경이 바뀌면 같은 인증 백엔드도 토큰 전송 방식만 따로 갈아끼울 수 있어야 하기 때문입니다. 그래서 PeekCart는 JwtFilter에 토큰 추출 책임만 분리해두고 검증 로직은 그대로 재사용할 수 있게 짜여 있습니다.

마지막으로, 어떤 전송 위치를 고르든 HTTPS는 전제조건입니다. 평문 HTTP로 토큰을 보내면 위의 모든 결정이 무의미해집니다. 중간자(MITM)가 토큰을 그대로 가로채기 때문입니다. 그래서 운영 환경에서는 HSTS로 HTTPS 강제, 쿠키에 Secure 플래그 부여, 모바일 앱에서는 인증서 핀닝 같은 추가 장치가 따라옵니다. PeekCart는 학습 범위에서 HTTP 로컬 테스트를 허용하지만 Phase 3 이후 Ingress/Gateway 진입 시 HTTPS만 받도록 정렬됩니다.


갈림길 4. 토큰을 어떻게 서명/암호화할 것인가

지금까지는 “토큰이 서버에서 발급되고 클라이언트가 가져온다”는 전제로 이야기했지만 그 전제 안에는 또 하나의 결정이 숨어 있습니다. 이 토큰이 위조되지 않았다는 것을 어떻게 보장할 것인가, 그리고 토큰 안의 내용을 누가 읽을 수 있게 할 것인가입니다.

JWT를 예로 들면 두 가지 표준이 갈라집니다 (RFC 7515 JWS, RFC 7516 JWE).

  • JWS (JSON Web Signature): 페이로드를 평문으로 두고 서명만 붙입니다. 위조는 막을 수 있지만 내용은 누구나 base64 디코딩으로 읽을 수 있습니다.
  • JWE (JSON Web Encryption): 페이로드 자체를 암호화합니다. 위조와 열람을 동시에 막지만 검증 비용이 늘고 디버깅이 어렵습니다.

대부분의 JWT 시스템은 JWS를 쓰고 그 안에서 다시 어떤 서명 알고리즘을 쓸지 결정합니다.

알고리즘키 모델특징적합한 상황
HS256 (HMAC-SHA256)대칭키secret 하나를 발급자와 검증자가 공유단일 서비스, 발급자=검증자
HS384 / HS512대칭키HS256의 해시 길이 변형더 강한 무결성이 필요할 때
RS256 (RSA-SHA256)비대칭 (RSA 2048+)발급자만 개인키 보유, 누구나 공개키로 검증MSA, 외부 IdP, JWKS 공개
ES256 (ECDSA P-256)비대칭 (타원곡선)RS256보다 키/서명 크기 작고 빠름모바일, 트래픽 비용 민감한 환경
EdDSA (Ed25519)비대칭최신 권고, 빠르고 사이드채널에 강함새로 짤 때 권고되는 기본값
alg: none없음서명 자체를 생략절대 사용 금지 (역사적 취약점의 원인)
JWE대칭/비대칭페이로드 자체를 암호화클레임에 민감 정보를 담아야 할 때

여기서 가장 큰 갈림길은 대칭(HS_)이냐 비대칭(RS_, ES*, EdDSA)이냐입니다.

대칭키는 단순합니다. 하나의 secret을 들고 발급도 하고 검증도 합니다. 단일 서비스에서는 가장 자연스럽습니다. 그러나 검증자가 곧 발급자라는 뜻이기도 합니다. 검증을 위해 secret을 공유해야 한다면, 공유받은 쪽이 토큰을 위조할 수도 있습니다.

비대칭키는 이 문제를 분리합니다. 발급자는 개인키로 서명하고 검증자는 공개키로만 검증합니다. 검증자가 공개키를 알아도 토큰을 만들 수는 없습니다. 그래서 여러 서비스가 같은 토큰을 검증해야 하는 환경(MSA, OIDC IdP 분리)에서는 비대칭이 자연스럽다. OIDC의 JWKS 엔드포인트도 이 모델 위에 서 있습니다. IdP가 공개키를 JWKS로 노출하면 모든 서비스가 그것으로 토큰을 검증할 수 있습니다.

추가로 고려할 위협이 하나 있습니다. 알고리즘 confusion 공격입니다. 검증 측이 토큰 헤더의 alg 값을 무비판적으로 신뢰하면, 공격자가 RS256으로 발급된 토큰을 HS256으로 위조해 검증을 통과시킬 수 있습니다(검증자가 공개키를 HMAC secret으로 잘못 쓰면 가능합니다). 그래서 검증 라이브러리는 허용 알고리즘을 코드에서 명시적으로 고정해야 합니다. 2015년경 다수의 JWT 라이브러리가 이 문제로 패치됐고 alg: none을 허용하던 버그도 같은 시기에 정리됐습니다. Auth0의 Critical Vulnerabilities in JSON Web Token Libraries가 그 이력을 잘 정리합니다.

마지막으로 클레임에 무엇을 담을 것인가의 문제가 있습니다. JWS는 페이로드가 평문이므로 누구든 디코딩하면 읽힙니다. 그래서 비밀번호 해시, 결제 정보 같은 민감 정보를 절대 넣으면 안 됩니다. 사용자 식별자와 권한 정도만 담는 게 일반적입니다. 페이로드 자체를 숨겨야 한다면 JWE를 써야 하는데, 검증 비용과 디버깅 부담이 큽니다.

PeekCart의 선택은 HS256(HMAC-SHA256) + JWS, 단일 secret입니다.

// JwtProvider.java
@PostConstruct
private void init() {
    this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}

private String createAccessToken(Long userId, String role) {
    return Jwts.builder()
            .subject(String.valueOf(userId))
            .claim("role", role)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + accessTokenExpiry))
            .signWith(key)
            .compact();
}

Keys.hmacShaKeyFor(...)가 HS256/384/512 중 하나를 secret 길이에 따라 자동 선택합니다. secret은 환경변수 app.jwt.secret으로 주입되며 검증 측인 JwtFilter도 같은 secret으로 만든 같은 key 객체를 씁니다.

이 선택의 합리성은 Phase 1의 맥락에 있습니다. 발급자(JwtProvider)와 검증자(JwtFilter)가 같은 애플리케이션 안에 있고 secret을 공유한다는 사실이 곧 “같은 프로세스가 발급과 검증을 모두 한다”는 자연스러운 사실입니다. 대칭키의 약점인 “공유 시 위조 가능”이 여기서는 문제가 되지 않습니다.

문제는 Phase 4입니다. Gateway가 매 요청을 검증하고 User Service가 발급하는 구조로 가면, 두 서비스가 secret을 공유해야 합니다. Gateway가 secret을 들고 있다는 것은 Gateway가 토큰을 위조할 수도 있다는 뜻입니다. 신뢰 경계가 깨집니다. 그래서 Phase 4 진입 시 자연스러운 전환은 HS256에서 RS256 또는 ES256으로 바꾸는 것입니다. User Service가 개인키로 서명하고 Gateway는 공개키로만 검증합니다. 키 회전을 위해 kid(key ID) 헤더와 JWKS 엔드포인트를 도입합니다. 이 전환은 이미 OIDC IdP가 표준화한 패턴이라 구현 부담이 크지 않습니다.

여기서 PeekCart의 또 한 가지 의식적인 결정이 보입니다. Refresh Token은 서명 없는 UUID(opaque token)다.

// JwtProvider.java
String refreshTokenValue = UUID.randomUUID().toString();

Refresh Token은 자체적으로 진위를 증명하지 않습니다. 진위 확인은 DB의 refresh_tokens 테이블이 합니다. 그래서 서명 알고리즘 결정과 무관합니다. Phase 4에서 액세스 토큰을 RS256으로 갈아끼워도, Refresh Token은 그대로 UUID + DB 검증을 유지하면 됩니다. 두 토큰의 신뢰 모델이 다르게 설계된 덕분에 변경 비용이 한쪽에 격리됩니다.

키 관리에서 의도적으로 안 한 것도 있습니다.

  • 키 회전(Key Rotation): 현재는 단일 secret이라 secret이 유출되면 모든 토큰을 한 번에 무효화해야 합니다. kid + JWKS로 가면 키를 점진적으로 회전시킬 수 있습니다.
  • 키 보관소(KMS, Vault): 현재 secret은 환경변수에 있습니다. 운영 환경에서는 AWS KMS / HashiCorp Vault 같은 시크릿 저장소로 옮기는 게 권고됩니다.
  • JWE 적용: 페이로드 평문 노출이 신경 쓰이는 클레임은 아예 안 담는 방식으로 회피했습니다. 클레임에 민감 정보가 들어가야 한다면 JWE를 검토해야 합니다.

갈림길 5. 토큰을 어떻게 회수할 것인가

stateless 토큰의 가장 어려운 부분이 회수입니다. 사용자가 로그아웃하거나, 토큰이 탈취되거나, 관리자가 강제 로그아웃을 시켜야 하는 순간이 옵니다. 그때 발급된 토큰을 어떻게 막을 것인가가 또 하나의 갈림길입니다.

전략핵심 아이디어장점단점
짧은 만료 시간만토큰 수명을 짧게(예: 5분) 잡고 회수 자체를 포기매우 단순, 저장소 없음만료까지 강제 차단 불가
블랙리스트 (Deny List)회수된 토큰만 별도 저장소에 기록저장하는 키가 적음, 조회는 키 하나 확인매 요청마다 저장소 조회, 저장소가 SPOF가 됨
화이트리스트 (Allow List)발급된 토큰을 모두 저장하고 매 검증 시 조회즉시 회수, 정밀한 추적매 요청마다 저장소 조회, stateless의 장점 상실
만료 + 짧은 토큰의 조합액세스 토큰 매우 짧게, Refresh 시점에만 검사합리적 절충만료 사이의 짧은 윈도우 동안 차단 불가
Version/Generation 기반사용자 단위 토큰 버전을 두고 토큰의 버전과 비교한 사용자 단위 일괄 회수 쉬움사용자 정보 매 검증 시 조회 필요

이 표를 보면 트레이드오프가 명확해집니다. 회수를 정밀하게 할수록 매 요청 비용이 커집니다.

블랙리스트도 요청마다 저장소를 봅니다. PeekCart의 JwtFilter는 서명 검증을 통과한 토큰마다 isBlacklisted로 Redis에 키가 있는지 확인합니다. 다만 저장하는 것은 회수된 토큰뿐이라 저장소가 작고, 조회는 단일 키 확인(O(1))이라 가볍습니다. 다만 저장소(보통 Redis)가 SPOF가 되며 장애 시 회수된 토큰이 일시적으로 다시 유효해질 수 있습니다.

화이트리스트는 정반대입니다. 모든 발급된 토큰을 보유해야 하므로 저장소 부담이 크고 매 요청마다 조회가 필요합니다. 하지만 토큰을 잃어버린 사용자, 의심스러운 디바이스만 골라서 정밀하게 회수할 수 있습니다. 금융 같은 고보안 환경에서 종종 선택됩니다. Curity의 Phantom Token Pattern이 이 방향의 변형으로, Gateway가 opaque 토큰을 받아 내부에서 JWT로 교환하면서 사실상 화이트리스트 검증을 합니다.

만료 + 조합 전략은 OAuth2 권고에 가깝습니다. 액세스 토큰을 5~15분으로 짧게 잡고 회수는 Refresh Token 회전 시점에만 적용합니다. 액세스 토큰의 짧은 윈도우 동안 차단되지 않는 것은 받아들이고 대신 매 요청 비용을 없앱니다. Auth0의 Refresh Token Rotation 가이드가 이 모델을 자세히 다룹니다.

PeekCart의 선택은 블랙리스트입니다. Redis에 회수된 액세스 토큰만 키로 두고 TTL을 토큰 잔여 유효 시간과 정확히 맞춥니다.

@Repository
public class TokenBlacklistRepository implements TokenBlacklistPort {

    private static final String BLACKLIST_PREFIX = "bl:";

    public void addToBlacklist(String token, long ttlSeconds) {
        redisTemplate.opsForValue()
                .set(BLACKLIST_PREFIX + token, "1", ttlSeconds, TimeUnit.SECONDS);
    }

    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token));
    }
}
// AuthService.logout()
long ttlSeconds = (claims.expiration().toEpochMilli() - System.currentTimeMillis()) / 1000;
if (ttlSeconds > 0) {
    tokenBlacklistPort.addToBlacklist(accessToken, ttlSeconds);
}

여기서는 TTL을 토큰 잔여 시간과 정확히 정렬한 것입니다. 토큰이 자연 만료되는 순간 Redis 키도 함께 사라지므로 별도 정리 배치가 필요 없습니다. 도메인의 시간 의미와 Redis TTL을 그대로 매핑한 사례입니다.

화이트리스트였어도 요청마다 한 번 조회하는 것은 같습니다. 차이는 저장하는 대상입니다. 화이트리스트는 발급한 토큰을 모두 저장하고 관리해야 하고, 블랙리스트는 로그아웃 등으로 회수된 토큰만 저장합니다.


갈림길 6. 재발급 전략은 무엇이 있는가

액세스 토큰을 짧게 잡으면 자주 만료됩니다. 사용자에게 다시 로그인하라고 강요하지 않으려면 어떻게든 조용히 갱신해야 합니다. 여기에도 갈림길이 여럿입니다.

재발급 전략을 정하기 전에 더 작은 질문이 하나 있습니다. 액세스 토큰과 리프레시 토큰의 만료 시간을 각각 얼마로 잡을 것인가입니다.

토큰너무 짧으면너무 길면일반적 권고
Access Token재발급 빈도 증가, 사용자 끊김 위험탈취 시 노출 시간 증가, 회수 어려움5~15분
Refresh Token자주 재로그인 강제탈취 시 영구 사용 위험1~30일 + Rotation

여기에 또 한 가지 갈림길이 있습니다. Sliding vs Absolute expiry입니다.

  • Absolute: 발급 시점부터 고정 만료. 30일이 지나면 무조건 재로그인. 보안은 강하지만 활성 사용자에게도 주기적 재로그인을 강제합니다.
  • Sliding: 사용할 때마다 만료 시점이 연장됩니다. 활성 사용자는 계속 로그인 유지. UX는 좋지만 탈취된 토큰이 계속 활성 상태로 남을 위험이 있습니다.

대부분의 시스템은 둘을 섞습니다. Refresh Token에 sliding을 적용하되, “최초 발급 후 N일”이라는 absolute upper bound를 함께 둡니다. PeekCart의 액세스 토큰은 30분, 리프레시 토큰은 7일입니다. 재발급할 때마다 리프레시 토큰을 새로 만들고 만료 시점도 그때부터 7일로 다시 잡습니다. 그래서 7일 안에 한 번이라도 재발급하는 사용자는 계속 로그인 상태로 남고, 결과적으로 sliding처럼 동작합니다. 최초 발급 기준의 absolute 상한은 두지 않았습니다.

전략핵심 아이디어보안UX
Refresh Token 없음액세스 토큰 만료 시 재로그인 강제가장 강함가장 불편
Refresh Token 영구Refresh 만료 없음, 무한 재발급약함 (탈취 시 영구)가장 편함
Sliding SessionRefresh 사용 시마다 만료 시점 연장활성 사용자만 유지됨자연스러움
Refresh RotationRefresh 사용 시 새 토큰으로 교체, 구 토큰 무효화강함 (탈취 감지 가능)동시 요청 시 race condition
Rotation + Grace Period회전 직후 짧은 유예 윈도우강함 (윈도우만큼만 약화)동시 요청 허용
Rotation + Reuse Detection회전된 토큰이 다시 쓰이면 패밀리 전체 무효화가장 강함 (탈취 즉시 감지)동시 요청을 재사용으로 보면 전체 로그아웃

OAuth2 Working Group이 발행한 Best Current Practice (BCP)는 Public Client(SPA, 모바일)에 대해 Refresh Token Rotation + Reuse Detection을 명시적으로 권고합니다. Auth0, Okta, IdentityServer 같은 상용 ID 서비스도 모두 이 조합을 기본값으로 둡니다.

그러나 Rotation을 엄격히 적용하면 정상 사용자 환경에서 race condition이 생깁니다.

사용자가 같은 사이트를 탭 두 개로 열어둠
    ↓
탭 A: 액세스 토큰 만료 → 재발급 요청 (Refresh Token X)
탭 B: 액세스 토큰 만료 → 재발급 요청 (Refresh Token X)
    ↓
거의 동시에 같은 X로 두 요청이 서버에 도착
sequenceDiagram
    participant A as 탭 A
    participant B as 탭 B
    participant S as AuthService
    participant DB as refresh_tokens

    A->>S: refresh(X)
    B->>S: refresh(X)
    S->>DB: findByToken(X) [A]
    S->>DB: findByToken(X) [B]
    DB-->>S: 토큰 발견 [A]
    DB-->>S: 토큰 발견 [B]
    S->>DB: deleteByToken(X) [A] → 1건 삭제
    S->>DB: deleteByToken(X) [B] → 0건 삭제
    Note over S: A는 새 토큰 발급<br/>B는 USR-004로 거절
    Note over A,B: 정상 사용자인데<br/>탭 B에서 강제 로그아웃 발생

두 요청이 모두 A의 삭제보다 먼저 토큰을 읽는 이 순서는, 뒤에서 볼 grace period를 넣은 코드에서도 같은 결과가 됩니다.

비슷한 시나리오는 네트워크 재시도, 모바일 앱의 백그라운드/포그라운드 전환, SPA의 동시 API 호출에서도 생깁니다. 즉, “Rotation을 엄격히 지키면 정상 사용자가 튕긴다”는 문제가 보안 정책과 UX 사이에서 생깁니다.

PeekCart는 Rotation + Grace Period를 선택했습니다. Reuse Detection까지는 가지 않았습니다.

public TokenResult refresh(String oldRefreshToken) {
    return refreshTokenRepository.findByToken(oldRefreshToken)
            .map(token -> rotateToken(token, oldRefreshToken))
            .orElseGet(() -> refreshViaGracePeriod(oldRefreshToken));
}

private TokenResult rotateToken(RefreshToken token, String oldRefreshToken) {
    if (token.isExpired()) {
        refreshTokenRepository.deleteByToken(oldRefreshToken);
        throw new UserException(ErrorCode.USR_005);
    }
    User user = userRepository.findById(token.getUserId())
            .orElseThrow(() -> new UserException(ErrorCode.USR_003));
    tokenBlacklistPort.addGracePeriod(oldRefreshToken, user.getId(), 10);
    boolean deleted = refreshTokenRepository.deleteByToken(oldRefreshToken);
    if (!deleted) {
        // 동시 요청이 먼저 처리됨: 이중 발급 방지
        throw new UserException(ErrorCode.USR_004);
    }
    return issueTokens(user);
}

private TokenResult refreshViaGracePeriod(String oldRefreshToken) {
    Long userId = tokenBlacklistPort.consumeGracePeriod(oldRefreshToken)
            .orElseThrow(() -> new UserException(ErrorCode.USR_004));
    User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserException(ErrorCode.USR_003));
    refreshTokenRepository.deleteByUserId(userId);
    return issueTokens(user);
}

흐름은 이렇습니다.

  1. refresh()는 두 갈래입니다. DB에 토큰이 있으면 회전 경로(rotateToken), 없으면 grace period 경로(refreshViaGracePeriod).
  2. 회전 경로는 (a) Redis에 grace period 키를 먼저 저장하고 (b) DB에서 토큰을 삭제합니다. 삭제 결과가 0건이면 다른 요청이 먼저 처리한 것이므로 거절합니다.
  3. 첫 요청의 삭제가 커밋된 뒤에 도착한 요청은 DB 조회에서 빈 결과를 받고 grace period 경로로 진행됩니다. 거기서 Redis GETDEL로 userId를 가져와 새 토큰을 발급합니다.

grace period가 구제하는 것은 이 세 번째 경우입니다. 앞의 다이어그램처럼 두 요청이 모두 삭제 전에 토큰을 읽으면, 늦은 쪽은 회전 경로에서 삭제 0건을 받아 그대로 USR-004로 거절됩니다. 즉 이 구현이 흡수하는 것은 앞 요청이 끝난 뒤 10초 안에 온 요청입니다. 네트워크 재시도나 조금 늦게 깨어난 탭이 여기에 해당합니다.

grace period 경로에도 대가가 있습니다. refreshViaGracePeriod는 새 토큰을 발급하기 전에 deleteByUserId로 그 사용자의 리프레시 토큰을 모두 지웁니다. 첫 요청이 방금 받은 새 리프레시 토큰도 여기서 지워지므로, 탭 A는 다음 재발급 때 USR-004를 받고 다시 로그인해야 합니다. 튕기는 시점이 액세스 토큰 만료(30분) 뒤로 미뤄질 뿐, 두 탭이 모두 살아남지는 않습니다.

여기에 두 겹의 동시성 안전망이 있습니다.

// TokenBlacklistRepository.java
public Optional<Long> consumeGracePeriod(String token) {
    String value = redisTemplate.opsForValue().getAndDelete(GRACE_PERIOD_PREFIX + token);
    return Optional.ofNullable(value).map(Long::parseLong);
}

첫째는 DB의 deleteByToken 반환값입니다. UK가 걸린 같은 토큰을 두 트랜잭션이 동시에 삭제하려고 하면 한쪽은 1건, 다른 쪽은 0건이 됩니다. 0건을 받은 쪽은 자기가 “졌다”는 것을 압니다. 이게 회전 경로의 이중 발급을 막습니다.

둘째는 Redis GETDEL의 원자성입니다. 만약 두 요청이 거의 동시에 grace period 경로로 진행됐다면, GETDEL은 처음 호출한 쪽에만 값을 반환하고 즉시 키를 지웁니다. 두 번째 호출은 빈 결과를 받고 USR-004로 거절됩니다. 이게 grace period 경로의 이중 발급을 막습니다.

대안과 비교해서 grace period의 위치를 다시 보면 이렇습니다.

전략동시 요청에서의 동작PeekCart 적용 가능성
즉시 무효화만두 번째 요청이 실패해 사용자가 튕김보안은 강하나 UX 손상
Rotation + Grace Period (채택)첫 요청이 끝난 뒤 10초 안에 온 요청은 새 토큰 발급. 완전히 겹치면 늦은 쪽 거절, 구제된 경우에도 먼저 받은 쪽 토큰이 지워짐짧은 유예 윈도우(10s)만 보안 약화
Rotation + Reuse Detection두 번째 요청을 “재사용”으로 보고 패밀리 전체 무효화가장 강하지만 토큰 패밀리 트래킹 필요
Sliding Session만료 시점 연장만 함, 토큰 자체는 안 바꿈UX 좋으나 탈취 시 영구 사용 위험

PeekCart의 grace period는 일종의 “회전과 즉시 무효화 사이의 절충”입니다. 10초라는 윈도우는 앞 요청이 끝난 뒤 조금 늦게 온 재시도를 흡수하기엔 충분하고 실제 탈취 공격이 활용하기엔 좁습니다. 다만 Reuse Detection까지는 가지 않았기 때문에, 유예 시간이 지난 뒤 옛 토큰이 다시 와도 거절만 하고 그것을 공격 신호로 보아 사용자 전체 토큰을 무효화하지는 않습니다. Phase 4에서 추가 후보입니다.


갈림길 7. 인증 프로토콜을 자체로 짤 것인가 표준을 쓸 것인가

여기서 또 다른 큰 갈림길이 있습니다. 토큰을 직접 발급하는 자체 인증 서버를 만들 것인가, 아니면 OAuth2/OIDC 같은 표준을 따를 것인가입니다.

프로토콜핵심 아이디어적합한 상황
자체 인증 (Custom)직접 토큰 발급/검증 로직 구현단일 시스템, 외부 IdP 없음
OAuth 2.0자원 소유자가 클라이언트에게 권한을 위임하는 표준 (RFC 6749)“Login with Google” 같은 위임 흐름
OpenID Connect (OIDC)OAuth2 위에 인증 정보(ID Token) 추가사용자 인증까지 위임 (SSO 등)
SAML 2.0XML 기반 SSO 표준엔터프라이즈 SSO, 레거시 시스템
Passkey / WebAuthn비밀번호 없는 공개키 기반 인증피싱 저항, 사용자 디바이스 인증
Magic Link / OTP이메일/SMS의 일회용 링크/코드비밀번호 없는 간편 인증

OAuth2와 OIDC의 차이를 자주 헷갈리는데, 한 줄로 정리하면 OAuth2는 인가 위임 프로토콜이고 OIDC는 그 위에 인증까지 얹은 확장입니다. “Google 계정으로 PeekCart에 로그인” 같은 흐름이 OIDC입니다.

자체 인증을 짜는 것의 장점은 단순함과 통제력입니다. 외부 의존이 없고 토큰 형식과 정책을 자유롭게 정할 수 있습니다. 단점은 표준의 검증된 보안 권고를 직접 따라가야 한다는 점입니다. OAuth2 BCP, OIDC, RFC 9449(DPoP) 같은 문서가 다루는 위협 모델을 자체 구현에서도 똑같이 고려해야 합니다.

표준을 쓰는 것의 장점은 위협 모델이 잘 정리돼 있고, 클라이언트 SDK가 풍부하다는 점입니다. 단점은 자체 시스템과 매핑하는 데 학습 비용이 들고 단일 시스템에는 과한 구조가 될 수 있다는 점입니다.

PeekCart는 자체 인증입니다. 회원가입과 로그인을 직접 받고 JWT 액세스 토큰과 UUID 리프레시 토큰을 직접 발급합니다.

@Component
public class JwtProvider implements TokenIssuer {

    @Override
    public IssuedTokens issue(Long userId, String role) {
        String accessToken = createAccessToken(userId, role);
        String refreshTokenValue = UUID.randomUUID().toString();
        LocalDateTime refreshTokenExpiresAt =
                LocalDateTime.now().plusSeconds(refreshTokenExpiry / 1000);
        return new IssuedTokens(accessToken, refreshTokenValue, refreshTokenExpiresAt);
    }

    private String createAccessToken(Long userId, String role) {
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiry))
                .signWith(key)
                .compact();
    }
}

여기서 짚어둘 디테일은 액세스 토큰은 JWT지만 리프레시 토큰은 UUID라는 점입니다. 리프레시 토큰은 “DB에 저장된 이 토큰의 소유자가 누구인지를 가져오는 키”로 쓰이고, 스스로를 검증할 필요가 없습니다. 그래서 서명과 페이로드가 필요 없는 식별자(opaque token)면 충분합니다. 굳이 JWT로 만들면 검증 비용만 늘어나고 얻는 것이 없습니다. 이건 OAuth2가 권고하는 “Refresh Token은 opaque로 두라”는 방향과도 일치합니다.

PeekCart가 OIDC로 갔다면 어떻게 됐을까요? Keycloak이나 Authelia 같은 IdP를 띄우고 PeekCart는 Resource Server로만 남았을 것입니다. 토큰 발급/회수 책임이 IdP로 이동하고 PeekCart는 토큰 검증만 합니다. 학습 프로젝트 범위에서는 자체로 짜는 편이 토큰 생명주기를 직접 보고 만지기에 좋습니다. 운영 시스템이라면 IdP를 따로 두는 게 보안 권고에 더 가깝습니다.


갈림길 8. 인가 모델은 무엇으로 표현할 것인가

지금까지는 모두 인증(AuthN) 이야기였습니다. 인가(AuthZ) 쪽에도 갈림길이 있습니다.

모델핵심 아이디어예시
ACL (Access Control List)자원마다 허용된 주체 목록”이 파일은 user 42와 43이 읽을 수 있다”
RBAC (Role-Based)사용자에게 역할 부여, 역할이 권한 보유”관리자 역할은 상품 등록 가능”
ABAC (Attribute-Based)주체/자원/환경의 속성 조합으로 결정”본인 주문이고, 결제 전 상태이고, 24시간 이내면 취소 가능”
ReBAC (Relationship-Based)주체와 자원 사이의 관계로 결정”이 문서의 작성자의 팀원은 편집 가능” (Google Zanzibar 모델)
Scope (OAuth2)토큰이 허용하는 동작 범위를 명시read:orders write:orders
Policy as Code외부 정책 엔진이 결정 (OPA, Cedar 등)Rego/Cedar로 정책 표현, 런타임 평가

여기서도 트레이드오프는 분명합니다. 단순한 모델은 빠르게 구현되지만 복잡한 규칙을 표현하기 어렵고, 풍부한 모델은 유연하지만 도구와 운영 비용이 든다.

RBAC은 가장 자주 채택되는 모델입니다. Spring Security의 @PreAuthorize("hasRole('ADMIN')")이 RBAC을 그대로 표현합니다. 그러나 RBAC만으로는 “본인 주문만 조회 가능” 같은 데이터 레벨 규칙을 표현하기 어렵습니다. 그래서 실무에서는 RBAC + 데이터 레벨 검증(ABAC의 일부)을 함께 쓰는 게 일반적입니다.

PeekCart는 단순한 RBAC + 데이터 레벨 검증 조합입니다.

// JwtFilter.java
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
        claims.userId(), null,
        List.of(new SimpleGrantedAuthority("ROLE_" + claims.role()))
);

JWT 클레임의 role을 그대로 Spring Security의 권한으로 매핑합니다. 역할은 USER, ADMIN 둘뿐입니다. 단순한 권한 검사(예: 관리자만 상품 등록)는 Spring Security의 메서드 어노테이션으로 처리하고 “본인 주문만 취소 가능” 같은 규칙은 도메인 레벨에서 직접 확인합니다.

// OrderRepositoryImpl
Optional<Order> findByIdAndUserId(Long id, Long userId);

여기서 userId를 함께 받는 것이 단순한 RBAC을 넘는 데이터 레벨 검증의 흔적입니다. ROLE만으로는 “이 주문이 이 사용자의 것인가”를 표현할 수 없으므로, 도메인 쿼리에서 직접 본인 여부를 확인합니다.

만약 PeekCart의 권한 규칙이 더 복잡해진다면(예: 판매자 등급별 상품 등록 한도, 시간 정책별 취소 가능 여부) ABAC이나 정책 엔진(OPA 등)으로 이동하는 게 자연스럽습니다. Phase 4 단계에서 결정할 후보입니다.


갈림길 9. 서비스 간 신뢰는 어떻게 세울 것인가

여기는 Phase 1보다는 Phase 4에서 더 무거워지는 갈림길이지만 미리 정리해둘 가치가 있습니다.

전략핵심 아이디어적합한 상황
토큰 전달 (Token Propagation)사용자 토큰을 서비스 간에 그대로 전달단순, 사용자 컨텍스트 유지
Header 신뢰 (Gateway 검증)Gateway가 검증 후 X-User-Id 헤더로 전달내부 서비스 부담 적음, 네트워크 경계 필수
mTLS서비스끼리 클라이언트 인증서 교환강한 서비스 신뢰, 운영 복잡
Service Account 토큰서비스별 자체 토큰 발급사용자 컨텍스트와 서비스 컨텍스트 분리
Service Mesh (예: Istio)인프라가 mTLS와 정책을 자동 적용K8s 환경, 운영 인력 보유

Phase 1의 PeekCart는 모놀리스라 이 질문이 아직 명시적으로 나오지 않습니다. 그러나 Phase 4에서 Gateway + 여러 Service로 갈라지는 순간 결정해야 합니다.

가장 흔한 패턴은 Gateway가 토큰 검증을 끝내고, 내부 서비스에는 X-User-Id 같은 신뢰된 헤더로 전달하는 것입니다. 그러면 각 서비스는 토큰 검증 비용을 안 들이고 사용자 ID만 신뢰하면 됩니다. 단, 외부에서 직접 서비스를 호출할 수 없도록 네트워크 경계가 확실해야 합니다. 그러지 않으면 누구나 헤더를 위조해 다른 사용자로 변장할 수 있습니다.

mTLS는 더 강한 모델입니다. 서비스끼리 인증서를 교환하므로 네트워크 경계가 뚫리더라도 다른 서비스를 흉내 내려면 인증서를 훔쳐야 합니다. 운영 비용이 크지만 금융이나 헬스케어 같은 환경에서는 종종 필수입니다.

PeekCart Phase 4에서는 Gateway 검증 + 헤더 전달 모델이 일차 후보입니다. 학습 범위에서 mTLS까지 가는 건 과해 보입니다. 다만 mTLS의 위협 모델은 같이 학습하고 필요 시 Service Mesh를 얹는 형태로 단계적으로 강화하는 게 자연스럽습니다.


PeekCart의 선택을 한 번에 보면

지금까지 본 갈림길에서 PeekCart는 다음 위치에 있습니다.

갈림길PeekCart의 선택이유 요약
비밀번호 저장bcrypt(cost=10), User.matchesPassword가 PasswordEncoder만 알게Spring 기본, 검증된 알고리즘, 도메인-인프라 분리
사용자 상태 저장JWT + 회수 메커니즘(하이브리드)stateless 검증 비용 + 회수 가능성 동시 확보
토큰 전송 위치Authorization: Bearer 헤더 (HTTPS 전제)SPA + REST 가정, CSRF 자연 방어
토큰 서명/암호화HS256(HMAC-SHA256) + JWS, 단일 secret발급자=검증자인 단일 서비스에 자연스러움, Phase 4에서 RS256으로 전환 예정
토큰 회수 방식블랙리스트 (Redis)회수된 토큰만 저장, TTL이 토큰 잔여시간과 같음
재발급 전략Rotation + Grace Period (10s), 재발급마다 리프레시 만료 7일 연장보안 정책 유지 + 늦게 온 재시도 흡수
인증 프로토콜자체 인증 (JWT + UUID Refresh)학습 범위, 토큰 생명주기 직접 다룸
인가 모델단순 RBAC + 데이터 레벨 검증역할이 적고 규칙이 단순
서비스 간 신뢰Phase 1은 N/A, Phase 4는 Gateway + 헤더 예정학습 범위

이 선택들은 서로 맞물려 있습니다. 예를 들어 헤더 방식(갈림길 3)을 골랐기 때문에 CSRF 걱정 없이 블랙리스트(갈림길 5)에 집중할 수 있고 블랙리스트와 grace period(갈림길 6)가 같은 Redis를 공유하면서도 prefix(bl:, gp:)로 책임을 나눕니다.

// TokenBlacklistRepository.java
private static final String BLACKLIST_PREFIX = "bl:";
private static final String GRACE_PERIOD_PREFIX = "gp:";

그리고 계층 구조 글에서 본 4-Layered + DDD가 여기에서도 그대로 적용됩니다. Application(AuthService)은 TokenBlacklistPort 인터페이스에만 의존하고 Redis 구현체는 Infrastructure에 격리되어 있습니다.

// AuthService.java
private final TokenBlacklistPort tokenBlacklistPort;
// TokenBlacklistRepository.java (infrastructure/redis)
public class TokenBlacklistRepository implements TokenBlacklistPort {
    ...
}

블랙리스트와 grace period를 한 인터페이스에 함께 둔 것은 두 개념이 모두 “토큰 상태 키-값 저장소”라는 같은 추상에 속하기 때문입니다. 만약 grace period가 다른 저장소(예: 인메모리 캐시)로 옮겨가야 한다면 인터페이스를 나누는 게 자연스럽습니다. 지금은 함께 두는 편이 호출부도 단순하고 구현체 위치도 깔끔합니다.


한계와 트레이드오프

PeekCart의 인증 구조에서 의도적으로 안 한 것, 미해결로 남은 것을 정리합니다.

Redis SPOF. 분산 락은 Redis 장애 시 DB 낙관적 락이 fallback이 되지만 JWT 블랙리스트와 grace period에는 fallback이 없습니다. Redis가 다운되면 로그아웃된 토큰이 만료 전까지 일시적으로 유효해질 수 있고 동시 재발급 요청에서 한쪽이 튕깁니다. Phase 4의 Redis Sentinel/Cluster 도입 동기 중 하나입니다.

DB와 Redis의 정합성 윈도우. rotateToken은 grace period를 먼저 저장하고 DB를 삭제합니다. 그 사이 프로세스가 죽으면 grace period 키만 남는 고아 상태가 됩니다. 다만 이 키도 TTL=10s로 자동 정리되므로 영향이 짧습니다. 트랜잭션으로 두 저장소를 묶을 방법이 없다는 한계를 짧은 TTL로 무력화한 패턴입니다.

Phase 1 범위에서 의도적으로 안 한 것:

  • Refresh Token Reuse Detection: 회전된 토큰이 다시 사용되면 패밀리 전체를 무효화하는 강한 탐지. PeekCart는 grace period 만료 이후 사용은 USR-004로 거절할 뿐, 사용자 단위 강제 로그아웃은 하지 않습니다.
  • 완전히 겹친 동시 재발급: 두 요청이 모두 삭제 전에 토큰을 읽으면 grace period와 상관없이 늦은 쪽이 거절됩니다. grace period로 구제된 경우에도 먼저 받은 쪽 리프레시 토큰이 지워집니다. 두 탭을 모두 살리려면 재발급 결과를 잠시 보관했다가 같은 토큰의 두 번째 요청에 그대로 돌려주는 방식이 필요합니다.
  • 디바이스별 토큰 격리: 현재는 사용자당 한 활성 디바이스만 가정합니다(refreshTokenRepository.deleteByUserId가 매 로그인마다 호출됨). 모바일 + 웹 동시 접속을 지원하려면 (userId, deviceId)로 키를 확장해야 합니다.
  • MFA: 비밀번호 외 이중 인증. WebAuthn/Passkey와도 연결되는 영역.
  • 계정 잠금 / 무차별 대입 방어: 로그인 실패가 누적돼도 잠금이 없습니다. exponential backoff, IP/디바이스 단위 레이트 리미트, 캡차가 모두 비어 있습니다. Gateway 또는 WAF 레벨 책임으로 봅니다.
  • 계정 열거(Account Enumeration) 방지 미흡: 회원가입 시 “이메일 중복(USR-001)“과 로그인 실패 시 “인증 실패(USR-002)“가 응답으로 구분됩니다. 공격자가 이메일 존재 여부를 확인할 수 있는 단서가 됩니다. 운영 시스템이라면 회원가입도 “확인 메일을 보냈습니다”로 통일하는 게 권고됩니다.
  • 레이트 리미트: 같은 IP에서 로그인/재발급을 무한히 시도할 수 있습니다. Gateway 레벨 책임으로 봅니다.
  • PKCE / state / nonce: 자체 인증에서는 직접 영향이 없지만 OIDC IdP로 이행할 때 SPA, 모바일 클라이언트에 대해 PKCE (RFC 7636)와 OIDC nonce가 필수가 됩니다.
  • DPoP / Token Binding: 토큰을 클라이언트 키에 묶어 탈취 시에도 사용 불가하게 만드는 강한 보호 (RFC 9449). 모바일/SPA 클라이언트와 함께 설계해야 하므로 학습 범위에서는 미적용.
  • 보안 감사 로그와 이상 행동 탐지: 발급/회전/실패 이력을 별도 저장. Phase 3에서 일부 Prometheus 메트릭으로 들어왔지만 본격적인 보안 감사 로그와 IP/디바이스/지역 기반 이상 행동 탐지(risk-based authentication)는 없습니다. OWASP ASVS의 인증 관련 요구사항을 체크리스트로 사용하면 어느 위치에 있는지 측정 가능합니다.

이 항목들은 모두 “추가하면 더 강해진다”는 게 자명합니다. Phase 1의 목표는 인증 구조의 골격과 동시성 절충안을 구현해보는 것이고 위 항목들은 Phase 4에서 같이 다뤄질 후보입니다.


Phase 4 MSA에서는 어떻게 바뀌는가

Phase 4에서는 MSA로 분리되며 인증/인가 책임도 재배치됩니다.

flowchart LR
    Client["Client"]
    GW["API Gateway<br/>JWT 검증 · 블랙리스트 조회"]
    UserSvc["User Service<br/>로그인 · 토큰 발급<br/>refresh_tokens (DB)"]
    OrderSvc["Order Service"]
    PaymentSvc["Payment Service"]
    Redis["Shared Redis<br/>bl:* · gp:*"]

    Client --> GW
    GW --> UserSvc
    GW --> OrderSvc
    GW --> PaymentSvc
    GW -.-> Redis
    UserSvc -.-> Redis

책임이 이렇게 나뉩니다.

  • API Gateway: 매 요청마다 JWT 서명 검증과 블랙리스트 조회. 현재 JwtFilter가 하던 일을 Gateway가 가져갑니다. 각 서비스는 인증된 요청만 받습니다.
  • User Service: 로그인/회원가입/재발급/로그아웃 같은 토큰 발급 책임. 현재 AuthService가 하던 일을 그대로 가져갑니다. refresh_tokens 테이블의 소유권도 User Service의 DB로 갑니다.
  • Shared Redis: 블랙리스트와 grace period는 Gateway가 매 요청마다 읽어야 하고 User Service가 회전/로그아웃 시점에 써야 합니다. 두 서비스가 같은 Redis를 공유하는 구조가 자연스럽습니다.

여기서 새로 생기는 질문들이 있습니다.

  • 각 서비스가 사용자 ID를 어떻게 신뢰할 것인가: 갈림길 9에서 본 대로 Gateway가 검증한 후 X-User-Id 헤더로 전달하는 게 일반적입니다. 내부 서비스는 그 헤더를 신뢰합니다. 외부에서 직접 서비스를 호출할 수 없도록 네트워크 경계를 막아야 합니다.
  • 블랙리스트를 공유 Redis로 둘 것인가, 서비스별 로컬 캐시로 둘 것인가: 공유 Redis는 정합성은 좋지만 SPOF가 됩니다. 로컬 캐시는 빠르지만 로그아웃 전파에 지연이 생깁니다.
  • Refresh Token Reuse Detection을 도입할 자리: 분산 환경에서 토큰 탈취 시나리오가 더 무서워지므로 이 단계에서 본격적으로 추가하는 게 합리적입니다.
  • 자체 인증을 그대로 둘 것인가, OIDC IdP를 분리할 것인가: 별도 IdP를 두면 User Service의 책임이 더 좁아지고 “Login with Google” 같은 위임 흐름을 추가하기 쉬워집니다.

핵심은, 계층 구조 글에서 본 도메인/레이어 경계가 Phase 4에서 서비스 경계로 옮겨갈 때 TokenBlacklistPort 인터페이스가 그대로 살아남는다는 점입니다. 구현체만 “로컬 Redis 호출”에서 “공유 Redis 호출” 또는 “Gateway가 검증한 결과를 신뢰”로 바뀝니다. Application 코드는 같은 계약에 의존합니다.

4-Layered + DDD 구조 글에서 강조했던 학습 효과가 여기서 드러납니다. 이 구조 덕분에 나중에 서비스를 나눌 때 어떤 책임이 어디로 가야 하는지 추적할 수 있습니다.


자료는 어떤 질문에 연결해서 읽을까

질문같이 읽을 자료이 글에서 연결되는 지점
비밀번호는 어떻게 저장해야 안전한가?OWASP, Password Storage Cheat Sheet, Troy Hunt, Have I Been Pwned: Pwned Passwordsbcrypt/Argon2id 선택과 유출 비밀번호 차단을 볼 때
평문 전송은 왜 모든 결정을 무력화하는가?IETF, RFC 6797: HSTS, OWASP, Transport Layer Security Cheat SheetHTTPS 강제와 인증서 핀닝 정책을 볼 때
JWT는 무엇이고 어떤 한계가 있는가?IETF, RFC 7519: JSON Web Token, Sven Slootweg, Stop using JWT for sessionsstateless의 매력과 회수 불가의 함정을 동시에 보고 싶을 때
JWT 서명과 암호화는 어떻게 동작하는가?IETF, RFC 7515: JWS, RFC 7516: JWE, RFC 7518: JWA, Auth0, Critical Vulnerabilities in JWT LibrariesHS256/RS256 선택과 알고리즘 confusion 위협을 볼 때
비대칭 키와 키 회전은 어떻게 운용하는가?OpenID Foundation, OIDC Discovery (JWKS), IETF, RFC 7517: JWKMSA에서 공개키 배포와 키 회전 흐름을 설계할 때
OAuth2와 OIDC의 차이는?IETF, RFC 6749: OAuth 2.0, OpenID Foundation, OIDC Core자체 인증과 표준 위임의 갈림길을 볼 때
토큰을 어디에 저장해야 하는가?OWASP, Session Management Cheat Sheet, Auth0, Token Storage헤더 vs 쿠키 트레이드오프를 비교할 때
Refresh Token Rotation은 왜 권고되는가?Auth0, Refresh Token Rotation, IETF, OAuth 2.0 Security BCP회전과 재사용 감지의 위협 모델을 이해할 때
토큰을 클라이언트 키에 묶으려면?IETF, RFC 9449: DPoP, Curity, Phantom Token PatternBearer 토큰의 탈취 위험을 줄이는 강한 대안을 볼 때
비밀번호 없는 인증은 어떻게 동작하는가?W3C, Web Authentication API, FIDO Alliance, Passkeys자체 인증의 다음 단계 후보를 볼 때
인가는 어떤 모델로 표현할 수 있는가?OWASP, Authorization Cheat Sheet, Google, Zanzibar, OpenPolicyAgent, RegoRBAC을 넘어 ABAC/ReBAC/Policy as Code를 볼 때
서비스 간 인증은 어떻게 세우는가?Istio, Security, Cloud Native Computing Foundation, Workload IdentitymTLS, SPIFFE, Service Mesh의 위치를 볼 때
운영 보안 요구사항은 어디까지인가?OWASP, Application Security Verification Standard”이 구조에 무엇이 빠져 있는가”를 체크리스트로 점검할 때

이 자료들을 다 읽어야 PeekCart의 선택을 이해할 수 있다는 뜻은 아닙니다. 다만 갈림길마다 정리된 위협 모델이 이미 잘 알려져 있으므로 자체 인증을 짤 때도 이 자료들의 결론을 참고해서 “어디까지 갔고 어디는 안 갔는지”를 분명히 의식하는 게 학습에 도움이 됩니다.