fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 디지털 채널 면접: 슬롯 도메인 경험을 커머스 도메인 설계 능력으로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] 커머스/F&B 면접 답변집 — 슬롯 도메인 경험을 주문·결제·쿠폰·매장 설계로 매핑하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/security/[초안] Spring Security 6.x…
system

[초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃

> 본 문서는 OAuth2 + JWT 기반 인증 시스템 설계에 초점을 둔 deep-dive다. > 보안 일반(OWASP, XSS, SQL Injection, 암호화, Rate limiting, 비밀 관리)은 security-auth.md hub 문서를 참고한다. > Spring Boot 3.x + Spring Security 6.x 기준이며, 모바일 앱·백...

2026.05.19·14 min read·15 views

본 문서는 OAuth2 + JWT 기반 인증 시스템 설계에 초점을 둔 deep-dive다. 보안 일반(OWASP, XSS, SQL Injection, 암호화, Rate limiting, 비밀 관리)은 security-auth.md hub 문서를 참고한다. Spring Boot 3.x + Spring Security 6.x 기준이며, 모바일 앱·백오피스·외부 API 연동이 공존하는 커머스 환경을 가정한다.

왜 이것이 면접에서 중요한가

"인증 시스템 설계하라"는 시니어 백엔드 면접에서 가장 답이 갈리는 질문이다. 주니어는 "JWT 발급해서 헤더에 넣고 서버에서 검증한다"에서 끝나지만, 시니어는 **"누가 발급하고, 누가 검증하고, 어떤 grant로 받고, 만료/회전/탈취 시 어떻게 무효화하는가"**를 클라이언트 유형별로 분리해 설명한다.

특히 커머스/디지털 채널 환경은 한 백엔드가 (A) 모바일 앱, (B) 사내 백오피스 SPA, (C) 외부 파트너 API 연동을 동시에 책임진다. 세 클라이언트는 신뢰 수준·세션 모델·토큰 저장 가능성·로그아웃 즉시성 요구가 전부 다르다. "전부 JWT 던지면 된다"는 답은 시니어 면접에서 즉시 탈락 신호다.

이 문서는 그 세 클라이언트를 각각 어떤 grant·어떤 토큰 저장·어떤 검증 경로로 설계해야 하는지, 그리고 Spring Security 6.x DSL로 어떻게 구현하는지를 다룬다.


1. OAuth2 핵심 용어 다시 정리

OAuth2는 인가 위임 프로토콜이다. "사용자가 비밀번호를 클라이언트에게 넘기지 않고도 클라이언트가 사용자 자원에 접근하게 한다"가 본질이다.

역할의미예시
Resource Owner자원 소유자실 사용자
Client자원에 접근하려는 애플리케이션모바일 앱, SPA, 백오피스, 파트너 시스템
Authorization Server토큰 발급 서버Keycloak, Auth0, 사내 OAuth2 서버
Resource Server토큰을 검증하고 API를 제공하는 서버우리 백엔드

OIDC(OpenID Connect)는 OAuth2 위에 인증 레이어를 얹은 표준이다. id_token(JWT)을 추가로 발급해 "사용자가 누구인지"를 표준 클레임(sub, email, name)으로 전달한다. SSO를 직접 구현하려면 OAuth2가 아니라 OIDC가 정답이다.


2. Grant Type — 클라이언트 유형별 선택 기준

Spring Security 6.x가 지원하는 OAuth2 grant 중 실무에서 쓰이는 것은 4가지다.

2.1 Authorization Code + PKCE — 사용자가 직접 로그인하는 모든 경우

text
모바일 앱 / SPA / 백오피스 / 일반 웹앱  →  Authorization Code + PKCE

흐름:

text
[User] → [Client]: 로그인 버튼 클릭
[Client]: code_verifier 랜덤 생성, SHA256(code_verifier) = code_challenge
[Client] → [AuthServer]: /authorize?response_type=code&client_id=...&code_challenge=...&redirect_uri=...
[User] ↔ [AuthServer]: 로그인 + 동의 화면
[AuthServer] → [Client]: redirect_uri?code=abc
[Client] → [AuthServer]: /token (code + code_verifier)
[AuthServer] → [Client]: access_token + refresh_token (+ id_token if OIDC)

PKCE는 원래 client_secret 을 숨길 수 없는 퍼블릭 클라이언트(SPA/모바일)를 위해 추가됐지만, RFC 9700(2024)부터는 모든 클라이언트에 권장된다. 컨피덴셜 클라이언트(서버사이드 백오피스)도 PKCE를 함께 쓰면 code injection 공격 방어선이 한 겹 늘어난다.

2.2 Client Credentials — 서버 to 서버, 사용자 없음

text
파트너 시스템 → 우리 백엔드 (배송 상태 조회, 정산 API 등)  →  Client Credentials

흐름:

text
[PartnerSystem] → [AuthServer]: /token (grant_type=client_credentials, client_id, client_secret, scope)
[AuthServer] → [PartnerSystem]: access_token (refresh_token 없음)
[PartnerSystem] → [OurAPI]: Authorization: Bearer <token>

핵심: refresh_token이 없다. Client Credentials는 클라이언트 자체가 자격증명이라 만료되면 다시 client_secret으로 발급받으면 된다. 토큰 수명은 짧게(15분~1시간), scope로 권한을 엄격히 제한한다(scope=order:read 만 허용 등).

흔한 실수: 사용자 토큰을 백엔드끼리 그대로 토스해서 쓰는 것. 사용자 컨텍스트가 필요 없는 시스템 간 호출이면 Client Credentials로 분리해야 한다. 그래야 사용자가 로그아웃해도 시스템 간 배치는 계속 돌고, 권한 범위도 좁힐 수 있다.

2.3 Refresh Token Grant — 무한 로그인 유지

Access Token이 짧게(10~15분) 만료되는 게 보안 기본이라, 자주 만료된다. 사용자가 매번 로그인하지 않게 하려면 Refresh Token으로 새 Access Token을 받는다.

text
[Client] → [AuthServer]: /token (grant_type=refresh_token, refresh_token=...)
[AuthServer]: 유효성 검증 + 기존 refresh_token 무효화 + 새 access_token + 새 refresh_token 발급(rotation)
[AuthServer] → [Client]: 새 토큰 쌍

회전(rotation)은 4번 섹션에서 자세히 다룬다.

2.4 Resource Owner Password Credentials (ROPC) — 쓰지 마라

grant_type=password 로 사용자 ID/PW를 직접 넘기는 grant. RFC 9700(2024)에서 공식적으로 deprecated. 사용자 비밀번호를 클라이언트가 보게 되는 OAuth2 본래 의도 위반. 신규 시스템에서는 채택 금지.

2.5 결정 표 — 멀티 클라이언트 환경

클라이언트Grant토큰 저장비고
모바일 앱 (iOS/Android)Authorization Code + PKCEAccess: 메모리, Refresh: OS Secure Storage (Keychain/Keystore)PKCE 필수, custom URL scheme + Universal/App Links
사내 백오피스 SPAAuthorization Code + PKCE + BFFAccess: 메모리, Refresh: BFF가 보유, 브라우저는 HttpOnly 세션 쿠키XSS 노출면 차단을 위해 BFF 패턴 권장
외부 파트너 시스템Client Credentials파트너 시스템 내부 비밀 저장소scope로 권한 좁히기, IP allowlist 추가
일반 웹앱 (서버 렌더)Authorization Code (+ PKCE)세션 쿠키(HttpOnly)Spring oauth2-client
내부 마이크로서비스 간Client Credentials 또는 mTLS + Service Token서비스 내 메모리 캐시토큰 캐싱으로 인가 서버 부하 회피

3. Spring Security 6.x — Resource Server 설정

우리 백엔드는 대부분 Resource Server다. 토큰을 발급하지 않고 검증만 한다. Spring Security 5.x에서 6.x로 넘어오며 DSL이 람다 중심으로 바뀌었다.

3.1 의존성

text
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

3.2 가장 단순한 JWT 검증 (OIDC 발급자)

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/commerce

이 한 줄이면 Spring이 issuer-uri 의 /.well-known/openid-configuration 에서 JWKS URL을 자동으로 찾아 공개키를 로드하고, RS256 서명 검증·iss 검증·exp 검증을 켠다.

3.3 SecurityFilterChain — 6.x DSL

java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ApiSecurityConfig {
 
    @Bean
    SecurityFilterChain api(HttpSecurity http,
                            JwtAuthenticationConverter converter) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_order:read")
                .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_order:write")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(converter)))
            .exceptionHandling(eh -> eh
                .authenticationEntryPoint((req, res, ex) -> {
                    res.setStatus(HttpStatus.UNAUTHORIZED.value());
                    res.setContentType("application/json");
                    res.getWriter().write("{\"error\":\"unauthorized\"}");
                }))
            .build();
    }
}

핵심 변경점:

  • antMatchers → requestMatchers (5.8부터 deprecated, 6.x 강제)
  • .and() 체이닝 → 람다 DSL
  • WebSecurityConfigurerAdapter 폐기 → @Bean SecurityFilterChain 등록
  • @PreAuthorize 활성화는 @EnableMethodSecurity (구 @EnableGlobalMethodSecurity 폐기)

3.4 JwtAuthenticationConverter — 클레임을 권한으로

JWT의 scope 클레임은 기본적으로 SCOPE_ 접두사로, realm_access.roles 같은 커스텀 클레임은 직접 매핑해야 한다.

java
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
    scopes.setAuthoritiesClaimName("scope");
    scopes.setAuthorityPrefix("SCOPE_");
 
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(jwt -> {
        Collection<GrantedAuthority> auth = new ArrayList<>(scopes.convert(jwt));
        Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
        if (realm != null && realm.get("roles") instanceof List<?> roles) {
            for (Object r : roles) {
                auth.add(new SimpleGrantedAuthority("ROLE_" + r));
            }
        }
        return auth;
    });
    return converter;
}

3.5 컨트롤러에서 토큰 정보 꺼내기

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
 
    @GetMapping("/{id}")
    public OrderDto get(@PathVariable Long id,
                        @AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();
        String tenant = jwt.getClaimAsString("tenant_id");
        return orderService.findOwned(id, userId, tenant);
    }
 
    @PreAuthorize("hasAuthority('SCOPE_order:write') and #req.userId == authentication.name")
    @PostMapping
    public OrderDto create(@RequestBody OrderRequest req) {
        return orderService.create(req);
    }
}

@AuthenticationPrincipal Jwt 가 가장 깔끔하다. URL 기반 인가는 1차, 메서드 레벨 @PreAuthorize 는 도메인 규칙에 맞춘 2차 방어다.


4. JWT Access + Refresh Token 구조와 회전

4.1 Access Token 클레임 설계

json
{
  "iss": "https://auth.example.com/realms/commerce",
  "aud": "commerce-api",
  "sub": "u-7821",
  "iat": 1735900000,
  "exp": 1735900900,
  "jti": "8e2a-...",
  "scope": "order:read order:write profile",
  "tenant_id": "store-42",
  "ver": 3
}

원칙:

  • 만료(exp) 짧게 — 10~15분. 탈취 시 피해 윈도우를 좁힌다.
  • aud 를 반드시 검증해 다른 서비스용 토큰이 들어오는 것을 거부한다.
  • jti 로 토큰 단위 추적 가능(블랙리스트, 감사로그).
  • ver(token version) 같은 즉시 무효화용 클레임을 미리 둔다(섹션 5에서 활용).
  • 민감정보(email, phone, password_hash)는 절대 넣지 않는다. JWT는 Base64URL일 뿐 암호화가 아니다.

4.2 Refresh Token — 불투명 vs JWT

Refresh Token은 불투명 난수가 정답이다. JWT로 만들면 검증은 가벼워도 즉시 폐기·회전 감지가 어렵다.

java
String refreshRaw = randomBase64Url(32);     // 클라이언트에게 전달
String refreshHash = sha256Hex(refreshRaw);  // 서버 DB에는 해시만

DB 유출 시에도 해시만 있으면 재사용 불가. 평문 저장은 데이터 사고 1순위 후회 항목이다.

4.3 Refresh Rotation + 재사용 탐지

java
@Service
public class TokenService {
 
    public TokenPair rotate(String refreshRaw) {
        String hash = sha256Hex(refreshRaw);
        RefreshToken stored = refreshRepo.findByHash(hash)
            .orElseThrow(() -> new AuthException("invalid refresh"));
 
        if (stored.isUsed()) {
            // 이미 한 번 회전된 토큰이 다시 옴 = 도난으로 간주
            refreshRepo.revokeAllByUserId(stored.getUserId());
            auditLog.recordSuspicious(stored.getUserId(), "refresh_reuse_detected");
            kafka.send("auth.session.revoked", stored.getUserId());
            throw new AuthException("token reuse — all sessions revoked");
        }
        if (stored.getExpiresAt().isBefore(Instant.now())) {
            throw new AuthException("refresh expired");
        }
        stored.markUsed();
        refreshRepo.save(stored);
        return issueNewPair(stored.getUserId());
    }
}

핵심:

  1. 사용한 refresh는 즉시 used=true 플래그. 회전이 끝났다는 의미.
  2. used=true 인 토큰이 다시 오면 도난. 사용자의 모든 refresh를 폐기(revokeAllByUserId).
  3. 폐기 이벤트는 Kafka로 브로드캐스트해 각 서비스의 토큰 캐시도 무효화.

이 패턴은 RFC 6819(OAuth2 Security BCP)와 RFC 9700(2024)에서 권장된다.

4.4 토큰 저장 — XSS/CSRF 노출면

저장 위치XSS 노출CSRF 노출추천
localStorage매우 높음 — JS로 다 읽힘낮음 (자동 전송 안 됨)금지
sessionStorage매우 높음낮음금지
쿠키 (HttpOnly+ Secure + SameSite=Strict)차단토큰 + SameSite 필요권장 (BFF 환경)
메모리(JS 변수, 모바일 RAM)새 탭에서 사라짐자동 전송 없음권장 (Access Token)
OS Secure Storage (Keychain/Keystore)앱별 격리N/A모바일 Refresh 보관

일반 원칙: Access Token은 메모리, Refresh Token은 HttpOnly 쿠키(웹) 또는 Secure Storage(모바일). BFF 패턴에서는 Refresh Token이 브라우저로 내려가지 않게 BFF가 보관한다.


5. JWT 환경의 로그아웃 — "무상태 토큰을 어떻게 무효화하는가"

JWT의 가장 큰 약점은 즉시 무효화가 어렵다는 것이다. Access Token은 만료 전까지 유효하다. 세 가지 전략을 조합한다.

5.1 짧은 수명 + Refresh 폐기 (기본)

  • Access Token 수명을 10~15분으로 짧게 잡고 별도 블랙리스트 없이 자연 만료를 기다린다.
  • 로그아웃 시 서버는 Refresh Token만 DB에서 삭제한다. Access는 만료까지 살아있지만, 새 Access를 받을 수 없다.
  • 대부분의 도메인에서 이 정도면 충분하다.
java
@PostMapping("/auth/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest req,
                                   HttpServletResponse res) {
    refreshRepo.revokeByHash(sha256Hex(req.refreshToken()));
    Cookie clear = new Cookie("refresh_token", "");
    clear.setMaxAge(0);
    clear.setHttpOnly(true);
    clear.setSecure(true);
    clear.setPath("/auth");
    res.addCookie(clear);
    return ResponseEntity.noContent().build();
}

5.2 token_version 클레임 — 즉시 무효화 경로

권한 변경·계정 해킹·관리자 강제 로그아웃처럼 즉시 차단이 필수인 경우.

java
// JWT 발급 시
.withClaim("ver", user.getTokenVersion())
 
// Resource Server 검증 시
@Bean
OAuth2TokenValidator<Jwt> tokenVersionValidator(UserTokenVersionCache cache) {
    return jwt -> {
        String userId = jwt.getSubject();
        Integer claimVer = jwt.getClaim("ver");
        Integer currentVer = cache.get(userId);
        if (claimVer == null || !claimVer.equals(currentVer)) {
            return OAuth2TokenValidatorResult.failure(
                new OAuth2Error("token_revoked"));
        }
        return OAuth2TokenValidatorResult.success();
    };
}

tokenVersion 을 +1 하면 기존에 발급된 JWT는 전부 무효가 된다. UserTokenVersionCache 는 Redis로 캐시해 ms 단위 조회. 적용 비용은 매 요청당 Redis lookup 한 번 — 운영상 충분히 감당 가능.

5.3 jti 블랙리스트 — 특정 토큰만 차단

특정 의심 토큰만 차단하고 싶을 때. Redis SET에 jti 를 토큰 남은 수명만큼 저장.

java
public boolean isRevoked(String jti) {
    return Boolean.TRUE.equals(redis.hasKey("revoked:jti:" + jti));
}

5.1 ~ 5.3은 함께 쓴다. 90%는 5.1로 처리하고, 5.2를 비상 경로로 두고, 5.3은 특정 토큰만 잡을 때.


6. 다중 SecurityFilterChain — 외부 API와 사용자 API 분리

같은 백엔드가 사용자 API(JWT 검증)와 파트너 API(Client Credentials 검증)를 모두 제공할 때, SecurityFilterChain 을 둘로 나눈다.

java
@Bean
@Order(1)
SecurityFilterChain partnerApi(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/partner-api/**")
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasAuthority("SCOPE_partner:integrate"))
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .build();
}
 
@Bean
@Order(2)
SecurityFilterChain userApi(HttpSecurity http,
                            JwtAuthenticationConverter converter) throws Exception {
    return http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(converter)))
        .build();
}

@Order 로 매칭 우선순위를 명시한다. 파트너 API는 IP allowlist를 추가로 두는 게 일반적이다(Spring Cloud Gateway 또는 ALB rule).


7. 흔한 함정과 디버깅 포인트

7.1 Algorithm Confusion 공격

java
// 위험: 라이브러리가 알고리즘을 자동 추론
JWT.decode(token);
 
// 안전: 알고리즘 고정
NimbusJwtDecoder.withPublicKey(publicKey)
    .signatureAlgorithm(SignatureAlgorithm.RS256)
    .build();

RS256으로 발급된 토큰의 공개키를 HS256 비밀로 오인해 검증하면 공격자가 공개키만으로 토큰을 위조할 수 있다. 알고리즘은 코드에서 명시적으로 고정.

7.2 aud 미검증

발급자(Auth Server)가 한 명이고 Resource Server가 여러 개일 때, A 서비스용 토큰으로 B 서비스를 호출하면 안 된다. aud 클레임 검증은 자동이 아니라 명시적으로 켠다.

java
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
    JwtValidators.createDefaultWithIssuer("https://auth.example.com/realms/commerce"),
    new JwtClaimValidator<List<String>>("aud",
        aud -> aud != null && aud.contains("commerce-api"))));

7.3 JWKS 캐시 만료

Authorization Server가 키를 회전(kid 교체)했는데 Resource Server가 옛 JWKS를 캐시하고 있으면 모든 검증이 실패한다. Spring Security 기본 캐시는 5분. 회전 빈도가 낮으면 별 문제 없지만, 운영 트래픽이 크면 캐시 무효화 정책을 별도로 둔다.

7.4 SecurityContextHolder 가 비어있는 비동기 컨텍스트

@Async 메서드에서 SecurityContextHolder.getContext().getAuthentication() 이 null. 기본 전략이 ThreadLocal 이라 새 스레드에는 전파 안 됨.

java
@Bean
public DelegatingSecurityContextAsyncTaskExecutor asyncExecutor() {
    ThreadPoolTaskExecutor base = new ThreadPoolTaskExecutor();
    base.initialize();
    return new DelegatingSecurityContextAsyncTaskExecutor(base);
}

또는 SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) 을 부팅 초기에 한 번. 단 후자는 스레드 풀과 함께 쓰면 컨텍스트 누수 위험이 있어 비추천.


8. 로컬 실습 환경

bash
# Keycloak — OAuth2/OIDC Authorization Server
docker run -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:24.0 start-dev
 
# Redis — refresh token store + token version cache
docker run -p 6379:6379 redis:7
 
# 우리 백엔드 (Spring Boot 3.x)
./gradlew bootRun --args='--spring.profiles.active=local'

Keycloak realm 생성:

  1. commerce realm 생성
  2. Client 등록: commerce-mobile (public, Standard Flow + PKCE only)
  3. Client 등록: partner-shipping (confidential, Service accounts roles)
  4. Scope 정의: order:read, order:write, partner:integrate
  5. 테스트 사용자 1명 생성

application.yml:

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/commerce
          audiences: commerce-api

토큰 발급 테스트:

bash
# Authorization Code (PKCE) — 브라우저로 /authorize
# Client Credentials (파트너)
curl -X POST http://localhost:8080/realms/commerce/protocol/openid-connect/token \
  -d "grant_type=client_credentials" \
  -d "client_id=partner-shipping" \
  -d "client_secret=<secret>" \
  -d "scope=partner:integrate"

응답으로 받은 access_token 을 우리 API에 Authorization: Bearer ... 로 보내본다.


9. 면접 답변 프레이밍

Q. "커머스 백엔드 인증을 어떻게 설계하시겠어요?"

답변 뼈대 (3분 분량):

"먼저 클라이언트 유형을 분리합니다. 모바일 앱, 사내 백오피스, 외부 파트너 — 셋이 신뢰 모델이 다릅니다.

모바일과 백오피스는 사용자가 직접 로그인하니까 OIDC + Authorization Code + PKCE를 쓰고, Access Token은 10~15분 짧은 RS256 JWT, Refresh Token은 서버 해시 저장 + 회전 정책을 둡니다. 모바일은 Refresh를 OS Keychain/Keystore에, 백오피스는 BFF 패턴으로 브라우저에 안 내려보냅니다. localStorage는 XSS 한 번에 다 털리니까 절대 안 씁니다.

파트너 시스템 연동은 사용자 컨텍스트가 없으니 Client Credentials grant로 분리합니다. scope=partner:integrate 같이 권한을 좁히고, IP allowlist를 추가로 둡니다.

검증은 Spring Security 6.x Resource Server로 — issuer-uri 하나 잡으면 JWKS 자동 로드해 RS256 검증·iss·exp 자동입니다. aud 검증은 명시적으로 추가합니다. JwtAuthenticationConverter 로 scope 와 realm_access.roles 를 모두 Spring 권한으로 매핑하고, URL 기반 인가 + @PreAuthorize 메서드 인가를 2중으로 둡니다.

로그아웃은 Refresh 폐기 + Access 자연 만료가 기본인데, 권한 변경이나 강제 로그아웃이 필요한 경로용으로 token_version 클레임 + Redis 캐시를 둬서 즉시 무효화 경로를 확보합니다."

Q. "왜 Refresh Token을 JWT로 안 하시나요?"

"즉시 폐기와 회전 감지가 어렵기 때문입니다. JWT는 무상태라 만료 전 폐기를 하려면 별도 블랙리스트가 필요한데, Refresh는 그게 일상 동작이라 차라리 처음부터 불투명 난수 + DB 해시 저장이 깔끔합니다. 회전 감지(used=true 인 토큰이 다시 오면 도난)도 DB 상태 변경으로 자연스럽게 됩니다."

Q. "JWT가 탈취되면요?"

"세 단계입니다. 탐지 — Refresh 회전 재사용 감지, 이상 지역/디바이스 로그인. 즉시 차단 — 해당 사용자의 token_version +1 해서 기존 JWT 전부 무효, Refresh 테이블에서 revokeAll(user_id), Kafka로 auth.session.revoked 브로드캐스트해 각 서비스 캐시 무효화. 복구 — 강제 재로그인 + MFA, 키 노출 가능성 있으면 서명키 kid 회전, XSS·로그 누출·클라이언트 저장소 어디서 샜는지 근본 원인 분석."

Q. "왜 OAuth2가 아니고 OIDC인가요?"

"OAuth2는 인가 위임 프로토콜이지 사용자 신원을 표준화하지 않습니다. 그래서 OAuth2만으로 SSO를 하면 userinfo 엔드포인트 형식·sub 의미·email claim 위치가 공급자마다 달라집니다. OIDC가 id_token(JWT)과 표준 클레임을 정의해서 이걸 해결합니다. 사용자 로그인을 다룬다면 처음부터 OIDC가 정답입니다."


10. 체크리스트

  • 클라이언트 유형별로 grant가 분리되어 있는가 (사용자 → Code+PKCE, 시스템 → Client Credentials)
  • ROPC(grant_type=password) 를 신규 시스템에서 채택하지 않았는가
  • Access Token 수명 ≤ 15분
  • Refresh Token이 평문이 아니라 SHA-256 해시로 DB에 저장되는가
  • Refresh Token Rotation + 재사용 탐지가 구현되어 있는가
  • JWT 알고리즘이 코드에서 고정되어 있는가 (alg: none 거부)
  • iss, aud, exp 검증이 모두 켜져 있는가
  • JWT 페이로드에 비밀번호/PII가 들어가지 않는가
  • token_version 또는 jti 블랙리스트 같은 즉시 무효화 경로가 있는가
  • localStorage/sessionStorage에 토큰을 저장하지 않는가
  • 모바일 Refresh가 Keychain/Keystore에 저장되는가
  • BFF 패턴 또는 HttpOnly+Secure+SameSite 쿠키로 브라우저 토큰을 보호하는가
  • 파트너 API에 scope 제한 + IP allowlist가 적용되어 있는가
  • 사용자 API와 파트너 API의 SecurityFilterChain 이 분리되어 있는가
  • JwtAuthenticationConverter 로 커스텀 클레임이 Spring 권한으로 매핑되는가
  • @PreAuthorize 메서드 인가가 URL 인가의 2차 방어로 사용되는가
  • JWKS 키 회전 시 캐시 무효화 정책이 있는가
  • 비동기/스레드풀 컨텍스트에서 SecurityContextHolder 전파가 보장되는가
  • 인증 실패·refresh 재사용 탐지가 감사 로그와 알림으로 연결되어 있는가
  • 보안 일반 항목(OWASP, 암호화, Rate limiting)은 security-auth.md 체크리스트로 보강했는가
on this page
  • 01왜 이것이 면접에서 중요한가
  • 021. OAuth2 핵심 용어 다시 정리
  • 032. Grant Type — 클라이언트 유형별 선택 기준
  • 2.1 Authorization Code + PKCE — 사용자가 직접 로그인하는 모든 경우
  • 2.2 Client Credentials — 서버 to 서버, 사용자 없음
  • 2.3 Refresh Token Grant — 무한 로그인 유지
  • 2.4 Resource Owner Password Credentials (ROPC) — 쓰지 마라
  • 2.5 결정 표 — 멀티 클라이언트 환경
  • 043. Spring Security 6.x — Resource Server 설정
  • 3.1 의존성
  • 3.2 가장 단순한 JWT 검증 (OIDC 발급자)
  • 3.3 SecurityFilterChain — 6.x DSL
  • 3.4 JwtAuthenticationConverter — 클레임을 권한으로
  • 3.5 컨트롤러에서 토큰 정보 꺼내기
  • 054. JWT Access + Refresh Token 구조와 회전
  • 4.1 Access Token 클레임 설계
  • 4.2 Refresh Token — 불투명 vs JWT
  • 4.3 Refresh Rotation + 재사용 탐지
  • 4.4 토큰 저장 — XSS/CSRF 노출면
  • 065. JWT 환경의 로그아웃 — "무상태 토큰을 어떻게 무효화하는가"
  • 5.1 짧은 수명 + Refresh 폐기 (기본)
  • 5.2 `token_version` 클레임 — 즉시 무효화 경로
  • 5.3 jti 블랙리스트 — 특정 토큰만 차단
  • 076. 다중 SecurityFilterChain — 외부 API와 사용자 API 분리
  • 087. 흔한 함정과 디버깅 포인트
  • 7.1 `Algorithm Confusion` 공격
  • 7.2 `aud` 미검증
  • 7.3 JWKS 캐시 만료
  • 7.4 `SecurityContextHolder` 가 비어있는 비동기 컨텍스트
  • 098. 로컬 실습 환경
  • 109. 면접 답변 프레이밍
  • Q. "커머스 백엔드 인증을 어떻게 설계하시겠어요?"
  • Q. "왜 Refresh Token을 JWT로 안 하시나요?"
  • Q. "JWT가 탈취되면요?"
  • Q. "왜 OAuth2가 아니고 OIDC인가요?"
  • 1110. 체크리스트

이런 글도

  • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    "인증을 어떻게 설계하시나요?"는 시니어 백엔드 면접에서 거의 예외 없이 등장하는 질문이다. 이 질문이 겨냥하는 것은 Spring Security의 설정 문법이 아니라, 자격증명이 어느 경계에서 검증되고 어떤 저장소에 머물며 탈취되었을 때 어떤 경로로 차단되는지를 설명할 수 있느냐이다. 주니어는 "JWT를 쓴다"에서 멈추지만, 시니어는 "왜 JWT인지, R...
    📁 system
    system
    2026.04.18

댓글 (0)