인증(Authentication)과 인가(Authorization)의 차이
주요 용어 정리
간단 흐름도
[사용자] → [Authorization Server 로그인 + 동의]
→ [Client에게 Access Token 전달]
→ [Client는 Resource Server에 API 요청]
→ [Access Token 검증 후 리소스 응답]
Gradle 의존성 설정
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
Authorization Code Grant 방식 예시
1.사용자 로그인 요청
흐름 다이어그램
[사용자] ──▶ [클라이언트] ──▶ [인가 서버: 로그인 + 동의]
⬇︎
Authorization Code 발급
⬇︎
[클라이언트] ──▶ [인가 서버] (code + secret 전송)
⬇︎
Access Token 수신
⬇︎
[클라이언트] ──▶ [리소스 서버] (API 요청)
전체 플로우 다이어그램

Spring Security에서 Authorization Code Grant 흐름
Spring Boot에서는
spring-boot-starter-oauth2-client모듈을 사용하면, Authorization Code 방식이 자동으로 구성된다.
별도의 컨트롤러 없이도로그인 요청 -> 인가 코드 교환 -> 사용자 정보 요청까지 내부 필터 체인에서 알아서 처리된다.
Redirect URI, Authorization Code, Access Token 개념 설명
https://www.cchaksa.com/oauth/callback?code=abc123xyz <- 에서 'abc123xyz'가 Authorization CodeAuthroization로 포함관계 요약 흐름
1. [사용자 → 로그인/동의]
2. [인가 서버 → Redirect URI로 code 전달]
3. [서버 → code로 access token 요청]
4. [access token으로 API 요청 → 사용자 정보 획득]
Spring Security에서 OAuth 2.0을 어떻게 지원하는가?
spring-security-oauth2-client을 중심으로 OAuth 2.0을 클라이언트 관점에서 완전히 자동화된 방식으로 처리할 수 있게 해준다.
핵심 구성 요소
내부 필터 흐름을 왜 알아야 할까?
Spring Security는 '자동으로 다 해준다'는 장점이 있지만,
이 흐름을 모르면 원하는 로직을 삽입하거나 수정하는 데 벽에 부딪히게 돼요.
- 필터 흐름을 이해하면 JWT 발급, DB 저장, 권한 설정 등 커스터마이징이 쉽다
- 로그인 이후의 다양한 상황에 정확하게 대응할 수 있게 된다.

전체 흐름 요약: Kakao 로그인 시
[1] 사용자 → `/oauth2/authorization/kakao` 요청
↓
[2] OAuth2AuthorizationRequestRedirectFilter가 가로챔
↓
[3] Filter는 Kakao 인가 서버로 리디렉션하면서 client_id, redirect_uri, scope 등을 전달
↓
[4] 사용자 → Kakao 로그인 + 동의 완료
↓
[5] Kakao → redirect_uri + code 리턴
↓
[6] OAuth2LoginAuthenticationFilter → 인가 코드(code) 추출,
- 내부적으로 Access Token을 받기 위한 인증 요청(OAuth2LoginAUthenticationToken) 생성
↓
[7] AuthenticationManager -> OAuth2LoginAuthenticationProvider
- 필터는 인증 요청을 AuthenticationManaget에 위임
↓
[7] AuthenticationProvider → Access Token + 사용자 정보 요청
↓
[8] OAuth2UserService → 사용자 매핑
- 기본 OAuth2UserService or CustomUserService가 사용자 정보를 OAuth2User로 변환
- 이때 DB 저장, 최초 회원가입 처리, 권한 매핑 등을 할 수 있다.
↓
[9] AuthenticationSuccessHandler → 로그인 후 후처리(리다이렉션 or JWT 발급)
↓
[10] 인증 완료 → SecurityContextHolder 저장
- 인증이 완료되면 Spring Security는 인증 객체를 세션(SecurityContext)에 저장
- 이후 요청에서 `@AuthenticationPrincipal` 등으로 사용자 정보에 접근 가능
위치:
패키지: org.springframework.security.oauth2.client.web
전체 동작 흐름 요약
[사용자 로그인 완료 후 Redirect URI로 돌아옴]
↓
[OAuth2LoginAuthenticationFilter가 요청 가로챔]
↓
[인가 코드(code)를 읽고 → access token 요청]
↓
[사용자 정보 받아서 인증 객체 생성 → SecurityContext에 저장]
요청 파라미터 파싱
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
리다이렉트된 요청의 파라미터(code, state, error)를 MultiValueMap으로 파싱
만약 파라미터가 정상적인 OAuth 응답이 아니면 예외 발생
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error("invalid_request");
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
------------------------------------------
static boolean isAuthorizationResponse(MultiValueMap<String, String> request) {
return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request);
}
static boolean isAuthorizationResponseSuccess(MultiValueMap<String, String> request) {
return StringUtils.hasText((String)request.getFirst("code")) && StringUtils.hasText((String)request.getFirst("state"));
}
static boolean isAuthorizationResponseError(MultiValueMap<String, String> request) {
return StringUtils.hasText((String)request.getFirst("error")) && StringUtils.hasText((String)request.getFirst("state"));
}
OAuth 리디렉션 콜백 요청이 인가 응답(authorization response) 으로서 유효한지 판단하는 로직
세션에서 AuthorizationRequest 복원
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
요약: 인가 요청시 생성했던 정보(state, client 등)를 복원한다.
클라이언트 등록 정보 확인
String registrationId = (String)authorizationRequest.getAttribute("registration_id");
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
요약: 어떤 OAuth2 provider(Kakao, Google 등)인지 식별하고, 해당 설정을 불러온다.
Authorization Code -> Access Token 요청 준비
String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)).replaceQuery(null).build().toUriString();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
OAuth2AuthorizationExchange authExchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration, authExchange);
요약: 인가 코드와 클라이언트 정보를 합쳐 인증 요청 객체를 만든다.
인증 처리 위임
OAuth2LoginAuthenticationToken authenticationResult =
(OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);
요약: Access TOken과 사용자 정보를 요청하는 실질적인 인증 수행 단계이다.
최종 인증 객체 생성 및 저장
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter.convert(authenticationResult);
oauth2Authentication.setDetails(authenticationDetails);
요약: Spring Security가 사용하는 최종 사용자 인증 정보를 구성한다.
Access Token 저장
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(...);
this.authorizedClientRepository.saveAuthorizedClient(...);
요약: 토큰을 클라이언트 인증 저장소에 저장해, 이후 요청에서도 사용할 수 있게 한다.
최종 인증 객체 반환
return oauth2Authentication;
요약: 모든 인증이 완료되었음을 알리며, 사용자 세션이 구성된다.
전체 요약
OAuth2LoginAuthenticaitonFilter의 attemptAuthentication() 메서드는 OAuth 2.0 Authorization Code Grant 방식의 핵심 로직을 처리한다.
이 메서드는 인가 서버로부터 리디렉션된 요청에서 인가 코드(code)를 추출하고, 이를 기반으로 Access Token을 요청한다.
이후 사용자 정보를 가져오고, 인증이 완료되면 최종 인증 객체(OAuth2AuthenticationToken)를 생성하여 SecurityContext에 저장한다.
또한, AccessToken과 RefreshToken은 OAuth2AuthorizedClientRepository를 통해 저장되어, 이후 요청에서도 재사용할 수 있도록 관리한다.이 모든 과정은 Spring Security의 필터 체인 내에서 자동으로 처리되며, 개발자는 필요한 부분만 커스터마이징하면 된다.
의존성 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
// JWT 발급 시
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
}
사용자 정보를 처리하는 커스텀 서비스 (OAuth2UserService 구현)
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oauth2User = new DefaultOAuth2UserService().loadUser(userRequest);
Map<String, Object> attributes = oauth2User.getAttributes();
String provider = userRequest.getClientRegistration().getRegistrationId(); // kakao
String oauthId = String.valueOf(attributes.get("id"));
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
String email = (String) kakaoAccount.get("email");
// DB에 유저 없으면 생성
User user = userRepository.findByOauthId(oauthId)
.orElseGet(() -> userRepository.save(User.ofKakao(oauthId, email)));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
"id"
);
}
}
OAuth2User로부터 유저 정보 추출
public class KakaoOAuth2UserInfo {
private final Map<String, Object> attributes;
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public String getOAuthId() {
return String.valueOf(attributes.get("id")); // 고유 ID
}
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return (String) kakaoAccount.get("email");
}
public String getNickname() {
Map<String, Object> profile = (Map<String, Object>) ((Map<String, Object>) attributes.get("kakao_account")).get("profile");
return (String) profile.get("nickname");
}
}
내부 DB 유저와 매핑 처리 (sign in/up)
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oauth2User = new DefaultOAuth2UserService().loadUser(userRequest);
KakaoOAuth2UserInfo userInfo = new KakaoOAuth2UserInfo(oauth2User.getAttributes());
// 1. 고유 OAuth ID 확인
String oauthId = userInfo.getOAuthId();
// 2. DB에서 유저 조회 or 신규 생성
User user = userRepository.findByOauthId(oauthId)
.orElseGet(() -> userRepository.save(
User.ofKakao(oauthId, userInfo.getEmail(), userInfo.getNickname())
));
// 3. 인증 객체 리턴
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
oauth2User.getAttributes(),
"id"
);
}
}
JWT 발급 로직 연결하기
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oauth2User = authToken.getPrincipal();
// 고유 ID를 기준으로 JWT 생성
String userId = oauth2User.getAttribute("id").toString();
String jwt = jwtProvider.createToken(userId);
// JWT를 redirect or cookie 등에 담아 프론트로 전달
response.sendRedirect("domain url?token=" + jwt);
}
}
JWT 생성 클래스 (JwtProvider)
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String secret;
public String createToken(String userId) {
Date now = new Date();
Date expiry = new Date(now.getTime() + 1000 * 60 * 60 * 24); // 1일
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
}
정리
- OAuth2UserService -> 사용자 정보 파싱
- DB에서 유저 매핑 (없으면 가입)
- 성공 핸들러 -> JWT 발급
- 클라이언트에게 리디렉션 또는 토큰 응답
Access Token / Refresh Token 구조 설계
// Access Token (JWT)
{
"sub": "user-id-uuid",
"role": "USER",
"exp": 1713450000
}// Refresh Token (DB에 저장)
{
"userId": "user-id-uuid",
"refreshToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"expiresAt": "2025-05-01T00:00:00Z"
}필터를 통한 JWT 인증 처리 (OncePerRequestFilter 활용)
모든 요청에 대해 JWT가 있는지 검사하고, 유효하면 인증 컨텍스트에 저장
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = jwtProvider.resolveToken(request);
if (token != null && jwtProvider.validateToken(token)) {
String userId = jwtProvider.getUserId(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
필터 등록: Security Config
http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
로그인 후 토큰 전달 방식
1. 리디렉션 + 쿼리 파라미터 방식
2. JSON 응답 방식 (API 기반)
3. HttpOnly 쿠키 방식
정리 흐름
[OAuth2 로그인 완료]
↓
[JWT 발급: Access + Refresh]
↓
[Access Token → Authorization: Bearer 헤더로 API 요청]
↓
[JwtAuthenticationFilter → 사용자 인증 처리]
↓
[Access Token 만료 시 → Refresh Token으로 재발급]
Provider마다 다른 사용자 정보 처리 방식 (Kakao, Google 등)
OAuth2 Provider(Google, Kakao, GitHub 등)는 user-info API의 형식이 서로 다르기 때문에, 사용자 정보를 추출할 때 반드시 provider 별로 구조를 분기하거나 통일된 추상화가 필요합니다.
유연하게 처리하는 방법
OAuth2UserService에서 registrationId로 provider 식별 후 분기
CSRF와 CORS 설정 유의점
CORS policy: No 'Access-Control-Allow-Origin' 오류 발생Redirect URI 허용 정책 관리
대부분의 OAuth Provider(Kakao, Google 등)는 사전 등록된 redirect_uri만 허용 -> 동적 URI 사용 불가, exact match 필요