← all posts
DEV 2026.05.02 · 13 min read Intermediate

Spring Security OAuth2는 어떻게 소셜 로그인을 완성하는가

Grant Type 선택부터 PKCE 방어, Authorization Code 10단계 흐름, JWT Resource Server 검증까지 — Spring Security OAuth2의 설계 철학을 추적한다.


Spring Security의 OAuth2 지원은 단순한 소셜 로그인 편의 기능이 아니다. Grant Type 선택, PKCE 연산, Token 교환, UserInfo 조회, JWT 서명 검증까지 — 각 단계는 하나의 보안 결정이다. 왜 이 흐름이 이렇게 설계됐는가?

위임의 문제: OAuth2가 풀려는 것

OAuth2 이전에는 제3자 앱이 사용자 데이터에 접근하려면 사용자의 비밀번호를 직접 받아야 했다. 이 구조는 치명적이다 — 앱이 비밀번호를 알게 되고, 비밀번호 변경으로 접근을 차단할 수도 없다.

OAuth2는 이 문제를 위임(delegation) 으로 해결한다. 사용자는 인가 서버에서 직접 인증하고, 앱에는 제한된 권한의 Access Token만 전달된다. 앱은 비밀번호를 절대 알 수 없고, Token 취소로 접근을 즉시 차단할 수 있다.

이 위임 구조가 Grant Type이라는 4가지 시나리오로 구체화된다.

Grant Type — 시나리오별 위임 방식

4가지 Grant Type은 “누가 인증하고, 어디서 Token을 교환하는가”에 따라 갈린다.

Authorization Code + PKCE 는 현재의 표준이다. 브라우저 앱, 모바일, 서버사이드 웹 앱 모두 이 방식을 쓴다. 핵심은 Token이 브라우저에 직접 노출되지 않는다는 점이다 — 브라우저는 단기 Code만 받고, Token 교환은 서버 간 통신으로 이루어진다.

Client Credentials 는 사용자가 없는 서버 간 통신을 위한 방식이다. 마이크로서비스 A가 마이크로서비스 B를 호출할 때, Resource Owner 없이 client_id + client_secret 만으로 Token을 발급받는다.

ImplicitResource Owner Password 는 deprecated다. Implicit은 Token을 URL Fragment에 노출했고, Resource Owner Password는 클라이언트가 사용자 비밀번호를 직접 받는다 — 둘 다 OAuth2가 없애려던 문제를 다시 만든다.

Grant Type 선택 가이드:
  웹 앱 / SPA / 모바일  →  Authorization Code + PKCE
  서버 간 M2M 통신      →  Client Credentials
  Implicit              →  금지 (deprecated)
  Resource Owner Pass   →  금지 (OAuth2.1 제거)

PKCE — 수학으로 Code 탈취를 막는 방법

Authorization Code는 짧은 수명의 1회용 코드다. 그런데 모바일 앱 환경에서는 악성 앱이 같은 redirect_uri를 등록해 이 Code를 가로챌 수 있다. PKCE(Proof Key for Code Exchange)는 이 공격을 수학적으로 차단한다.

클라이언트는 인가 요청 전에 랜덤 code_verifier를 생성하고, 그 SHA-256 해시를 code_challenge로 인가 서버에 전송한다.

code_challenge=BASE64URL(SHA256(ASCII(code_verifier)))\text{code\_challenge} = \text{BASE64URL}(\text{SHA256}(\text{ASCII}(\text{code\_verifier})))

Token 교환 시에는 원본 code_verifier를 전송한다. 인가 서버는 SHA256(code_verifier)=code_challenge\text{SHA256}(\text{code\_verifier}) = \text{code\_challenge} 를 검증한다. 공격자가 Code를 가로채도 code_verifier를 모르면 Token 교환이 불가능하다. SHA-256은 단방향이라 code_challenge에서 code_verifier를 역산할 수 없기 때문이다.

Spring Security 6.x는 Public Client에 PKCE를 자동 적용한다.

Authorization Code Flow 10단계 내부

/oauth2/authorization/google 요청 하나가 어떤 경로를 거치는지 추적해보면 Spring Security의 설계를 읽을 수 있다.

① OAuth2AuthorizationRequestRedirectFilter
   state(CSRF 방어), code_verifier/challenge(PKCE) 생성
   → HttpSession에 저장
   → 인가 서버 URI로 302 Redirect

② 사용자: 인가 서버에서 직접 로그인 + 동의

③ 인가 서버 → redirect_uri?code=XXX&state=YYY

④ OAuth2LoginAuthenticationFilter
   세션의 state == 파라미터 state 검증 (CSRF 방어)

⑤ OAuth2AuthorizationCodeAuthenticationProvider
   POST {token_uri} (서버 → 서버, 브라우저 미개입)
   code + code_verifier 전송

⑥ Access Token 수신

⑦ DefaultOAuth2UserService
   GET {userinfo_uri} (Bearer Token 첨부)

⑧ OAuth2User → SecurityContext 저장
   OAuth2AuthorizedClient → HttpSession 저장

⑨ 성공 핸들러 → 리다이렉트

Token이 브라우저에 노출되지 않는 이유가 여기 있다. 단계 ⑤⑥은 Spring 앱 서버가 인가 서버와 직접 통신한다. 브라우저는 Code만 보고, Token은 서버만 수신한다.

CustomOAuth2UserService — 소셜 계정을 DB와 연결하는 법

DefaultOAuth2UserService는 UserInfo를 가져와 DefaultOAuth2User를 반환하는 것까지만 처리한다. DB 저장, 회원가입, 계정 연결은 직접 구현해야 한다.

실무 패턴은 세 가지 흐름으로 구성된다. 소셜 제공자(providerId + provider)로 기존 연결을 조회하고, 있으면 프로필을 업데이트한다. 없으면 이메일로 기존 회원을 찾고, 그것도 없으면 신규 생성한다.

제공자별 응답 구조가 다른 것도 처리해야 한다. Google은 평탄한 구조(email, name 직접 접근)이고, Kakao는 kakao_account.profile.nickname처럼 중첩되어 있다. OAuthAttributes 정규화 객체로 이 차이를 흡수하면 UserService 본문 로직을 단순하게 유지할 수 있다.

이메일 기반 자동 연결의 위험

이메일만으로 기존 계정에 자동 연결하면 보안 취약점이 생긴다. 일부 소셜 제공자는 이메일 인증 없이 계정을 생성할 수 있다. 반드시 email_verified: true 를 확인한 경우에만 연결해야 한다.

OIDC 제공자(Google)와 비OIDC 제공자(Kakao, Naver)는 서로 다른 UserService를 설정해야 한다. OidcUserService는 ID Token의 JWK 서명 검증과 클레임 추출을 처리한다. 이를 DefaultOAuth2UserService로 대체하면 ID Token이 무시된다.

JWT Resource Server — 로컬에서 서명을 검증한다

소셜 로그인이 아닌, 자체 인가 서버의 JWT Bearer Token을 검증하는 Resource Server 역할이 필요할 때 oauth2ResourceServer(jwt()) DSL을 사용한다.

BearerTokenAuthenticationFilterAuthorization: Bearer 헤더를 추출하고, NimbusJwtDecoder가 JWK Set URI에서 공개키를 가져와 서명을 검증한다. 클레임 검증(exp, iss, aud)도 자동으로 처리된다.

핵심 장점은 Key Rotation 지원이다. JWT Header의 kid가 캐시에 없으면 JWKS URI를 재조회해 새 키를 가져온다. 인가 서버가 키를 교체해도 리소스 서버는 재배포 없이 자동으로 적응한다.

scope 클레임은 JwtGrantedAuthoritiesConverter를 통해 SCOPE_orders:read 형태의 GrantedAuthority로 변환된다. roles 클레임을 쓰는 경우라면 setAuthoritiesClaimName("roles")setAuthorityPrefix("ROLE_")로 변환 규칙을 바꾸면 된다.

트레이드오프: JWT vs Opaque Token

JWT 로컬 검증은 인가 서버 없이 빠르게 동작하고 확장성이 높다. 대신 즉시 무효화가 불가능하다 — 만료 전까지 탈취된 토큰도 유효하다. 즉시 무효화가 중요한 경우 Opaque Token + Introspection을 선택해야 한다. 두 방식은 성능과 안전성 사이의 트레이드오프다.

정리

  • OAuth2의 모든 Grant Type은 “클라이언트가 사용자 비밀번호를 알지 못하게 한다”는 하나의 원칙에서 나온다.
  • PKCE는 SHA-256 단방향 해시로 Code 탈취 공격을 수학적으로 차단한다. code_verifier 없이는 Token 교환이 불가능하다.
  • Authorization Code Flow의 Token 교환은 서버 간 통신이다. 브라우저는 Code만 보고, Token은 절대 노출되지 않는다.
  • CustomOAuth2UserService는 소셜 로그인과 DB 계층의 연결점이다. 제공자별 속성 차이는 정규화 객체로 흡수하고, 계정 연결은 email_verified 확인 후에만 수행한다.
  • JWT Resource Server는 JWKS 공개키로 로컬 검증하며, Key Rotation을 무중단으로 지원한다.

다음 글에서는 Spring Authorization Server로 자체 인가 서버를 구성하고, Access Token 커스터마이징과 Refresh Token 전략을 추적한다.