백엔드 개발을 공부하다 보면 로그인 기능을 구현하는 것에서 끝나지 않고,
로그인 상태를 어떻게 유지할지,
인증된 사용자를 어떻게 식별할지,
소셜 로그인은 어떤 흐름으로 동작하는지까지 자연스럽게 궁금해지게 됩니다.
처음에는 저도 로그인이라고 하면 그냥 이메일과 비밀번호를 확인해서 통과시키는 기능 정도로만 생각했습니다.
그런데 스프링 시큐리티를 공부하다 보니,
로그인 이후의 인증 상태를 서버가 기억하는 방식도 있고,
토큰을 이용해서 stateless하게 처리하는 방식도 있다는 점이 꽤 중요하게 느껴졌습니다.
특히 실제 서비스에서는
모바일 앱, 프론트엔드와 백엔드 분리 구조, REST API 기반 환경이 많다 보니
세션 기반 로그인보다 JWT 같은 토큰 기반 인증을 더 자주 보게 됩니다.
그래서 이번 글에서는 노션 내용을 바탕으로
세션 기반 인증과 토큰 기반 인증의 차이, JWT를 스프링 시큐리티에서 어떻게 적용하는지,
그리고 OAuth를 이용한 카카오 로그인은 어떤 흐름으로 동작하는지를
조금 더 구체적인 코드와 함께 정리해보려고 합니다.
이번 글에서 다룰 내용은 다음과 같습니다.
- 세션 기반 인증과 토큰 기반 인증의 차이
- JWT란 무엇인가
- JwtUtil은 어떤 역할을 하는가
- JwtAuthFilter는 어떻게 인증을 처리하는가
- SecurityConfig에 JWT 필터를 어떻게 등록하는가
- @AuthenticationPrincipal로 현재 로그인 사용자를 어떻게 꺼내는가
- OAuth와 카카오 로그인은 어떤 흐름으로 동작하는가
- CustomOAuthService, OAuthSuccessHandler는 왜 필요한가
세션 기반 인증과 토큰 기반 인증은 뭐가 다를까?
로그인 방식을 이해할 때 가장 먼저 정리해야 하는 것이
서버가 사용자의 로그인 상태를 어디에 저장하느냐입니다.

세션 기반 인증
세션 기반 인증은 사용자가 로그인하면
서버가 세션을 만들고, 그 세션에 사용자 정보를 저장하는 방식입니다.
클라이언트는 보통 쿠키에 JSESSIONID를 저장하고, 이후 요청마다 해당 쿠키를 함께 보냅니다.
서버는 이 세션 ID를 보고 “이 요청은 로그인한 사용자 요청이구나”라고 판단합니다.
즉, 핵심은 서버가 로그인 상태를 기억하고 있는 방식이라는 점입니다.
토큰 기반 인증
반면 토큰 기반 인증은 로그인 시 서버가 토큰을 발급하고, 클라이언트가 이 토큰을 저장해두었다가
이후 요청마다 Authorization 헤더에 담아 보내는 방식입니다.
대표적으로 많이 사용하는 방식이 JWT입니다.
즉, 토큰 기반 인증의 핵심은 서버가 사용자 상태를 따로 저장하지 않고, 토큰 자체를 검증해서 인증하는 방식이라는 점입니다.
왜 최근에는 JWT를 많이 사용할까?
세션 기반 인증은 서버가 상태를 기억하는 구조라서 제어가 명확합니다.
반면 JWT는 stateless하게 동작하기 때문에
서버가 여러 대인 환경이나 프론트엔드/백엔드 분리 구조에서 더 유연하게 동작할 수 있습니다.
특히 REST API 기반 서비스에서는 브라우저가 아닌 앱이나 외부 클라이언트도 함께 쓰는 경우가 많기 때문에
세션보다 JWT 방식이 더 잘 어울리는 경우가 많습니다.
그래서 이번에는 토큰 기반 인증 중에서도 가장 자주 보게 되는 JWT 인증 방식을 중심으로 정리해보려고 합니다.
JWT란?
JWT는 Json Web Token의 약자입니다.
쉽게 말하면 사용자 인증 정보를 담아서 전달하는 토큰 형식입니다.

JWT는 보통 다음 3부분으로 이루어집니다.
- Header
- Payload
- Signature
그리고 이 세 부분이 . 으로 이어진 문자열 형태가 됩니다.
Header
토큰 타입과 서명 알고리즘 정보가 들어갑니다.
Payload
사용자 식별자, 권한, 만료 시간 등이 들어갑니다.
Signature
서버의 시크릿 키로 서명한 값입니다. 이 값을 통해 토큰 위조 여부를 검증합니다.
여기서 중요한 점은 JWT는 암호화된 값이 아니라 인코딩된 값이라는 점입니다.
즉, Payload는 누구나 디코딩해서 볼 수 있기 때문에 비밀번호 같은 민감한 정보는 넣지 않는 것이 원칙입니다.
전체 흐름 한눈에 보기
전체 흐름을 다시보면 다음과 같습니다.

JWT 관련 코드는 어떻게 나눌까?
JWT 관련 코드를 보통 이런 식으로 나누는 편입니다.

이렇게 나누면 역할이 명확해집니다.
- JwtUtil: 토큰 생성, 파싱, 검증 담당
- JwtAuthFilter: 요청에서 JWT를 꺼내 인증 처리 담당
즉, JWT를 다루는 공통 로직은 util, 요청 흐름에 끼어드는 인증 처리는 filter 로 분리한다고 이해하면 편합니다.
JwtUtil은 어떤 역할을 할까?
JWT를 사용할 때 가장 먼저 필요한 것은 토큰을 생성하고, 읽고, 검증하는 클래스입니다.
노션의 흐름대로 보면 JwtUtil은 이런 역할을 합니다.
- Access Token 생성
- 토큰에서 이메일 꺼내기
- 토큰 유효성 검증
- 시크릿 키로 서명 검증
예를 들면 이런 코드입니다.
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final Duration accessExpiration;
public JwtUtil(
@Value("${jwt.token.secretKey}") String secret,
@Value("${jwt.token.expiration.access}") Long accessExpiration
) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessExpiration = Duration.ofMillis(accessExpiration);
}
public String createAccessToken(AuthMember member) {
return createToken(member, accessExpiration);
}
public String getEmail(String token) {
try {
return getClaims(token).getPayload().getSubject();
} catch (JwtException e) {
return null;
}
}
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
private String createToken(AuthMember member, Duration expiration) {
Instant now = Instant.now();
String authorities = member.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.subject(member.getUsername())
.claim("role", authorities)
.claim("email", member.getUsername())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(expiration)))
.signWith(secretKey)
.compact();
}
private Jws<Claims> getClaims(String token) throws JwtException {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
}
}
이 코드는 왜 이렇게 작성할까?
1. 생성자에서 secretKey를 만든다
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
application.yml에 설정한 문자열 시크릿 키를 실제 JWT 서명에 사용할 SecretKey 객체로 바꾸는 부분입니다.
즉, 서버는 이 키를 이용해서 토큰을 서명하고, 나중에 다시 검증하게 됩니다.
2. createAccessToken()은 실제 발급 메서드다
public String createAccessToken(AuthMember member) {
return createToken(member, accessExpiration);
}
외부에서는 이 메서드만 호출하면 됩니다.
실제 구현은 createToken() 내부에서 처리하지만, 외부에는 “이건 Access Token 생성용 메서드다”라고 의도를 분명하게 보여줄 수 있습니다.
3. subject와 claim에 사용자 정보를 담는다
.subject(member.getUsername())
.claim("role", authorities)
.claim("email", member.getUsername())
여기서 subject는 JWT의 대표 식별값입니다.
보통 이메일이나 사용자 ID를 넣는 경우가 많습니다.
그리고 role, email 같은 정보는 claim으로 추가 저장할 수 있습니다.
즉, 토큰 하나만으로도 “누구인지”, “어떤 권한이 있는지”를 표현할 수 있게 됩니다.
4. expiration으로 만료 시간을 둔다
.expiration(Date.from(now.plus(expiration)))
JWT는 탈취되면 위험하기 때문에 만료 시간이 꼭 필요합니다.
이 코드에서는 현재 시간 기준으로 accessExpiration만큼 뒤를 만료 시각으로 지정합니다.
즉, 이 토큰은 영원히 쓰이는 것이 아니라 정해진 시간까지만 유효하게 됩니다.
5. signWith(secretKey)로 위조를 막는다
.signWith(secretKey)
이 부분이 JWT에서 가장 중요합니다.
단순히 JSON 문자열을 토큰처럼 꾸며도, 서명이 올바르지 않으면 서버는 신뢰하지 않습니다.
즉, JWT를 믿는 이유는 Payload 때문이 아니라 서명이 우리 서버의 시크릿 키로 생성되었는지 검증할 수 있기 때문입니다.
6. getClaims()가 실제 검증을 수행한다
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60)
.build()
.parseSignedClaims(token);
이 부분은 토큰 문자열을 파싱하고,
서명과 만료 시간 등을 검증하는 핵심 로직입니다.
여기서 예외가 발생하면
- 토큰이 만료됐거나
- 시그니처가 틀렸거나
- 형식이 잘못됐거나
- 조작된 토큰일 가능성
이 있다고 볼 수 있습니다.
JwtAuthFilter는 어떤 역할을 할까?
JwtUtil이 토큰 자체를 다루는 유틸이라면, JwtAuthFilter는 실제 요청이 들어왔을 때 인증을 처리하는 필터입니다.
즉, 사용자가 API 요청을 보냈을 때
헤더에 JWT가 있는지 확인하고, 유효하다면 인증 객체를 만들어 SecurityContextHolder에 넣는 역할을 합니다.
예를 들면 이런 코드입니다.
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
try {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
token = token.replace("Bearer ", "");
if (jwtUtil.isValid(token)) {
String email = jwtUtil.getEmail(token);
UserDetails user = customUserDetailsService.loadUserByUsername(email);
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
} catch (Exception e) {
ObjectMapper mapper = new ObjectMapper();
BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED;
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());
ApiResponse<Void> errorResponse = ApiResponse.onFailure(code, null);
mapper.writeValue(response.getOutputStream(), errorResponse);
}
}
}
이 필터는 요청을 어떻게 처리할까?
1. Authorization 헤더를 읽는다
String token = request.getHeader("Authorization");
보통 JWT는 아래처럼 전송합니다.
Authorization: Bearer {JWT_TOKEN}
그래서 먼저 헤더에서 Authorization 값을 꺼냅니다.
2. Bearer 형식이 아니면 그냥 통과시킨다
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
이 부분이 중요한 이유는 모든 요청이 JWT를 필요로 하는 것은 아니기 때문입니다.
예를 들어 로그인 API나 회원가입 API는 토큰 없이 접근할 수 있어야 합니다.
그래서 헤더가 없으면 바로 막는 것이 아니라, 그냥 다음 필터나 컨트롤러로 넘깁니다.
즉, JWT가 없는 요청을 무조건 거부하는 것이 아니라
보안 설정상 인증이 필요한 API에서만 나중에 걸러지게 만드는 구조입니다.
3. Bearer 접두어를 제거한다
token = token.replace("Bearer ", "");
실제 JWT 문자열만 남기기 위해 Bearer 부분을 제거합니다.
4. 토큰이 유효하면 이메일을 꺼낸다
if (jwtUtil.isValid(token)) {
String email = jwtUtil.getEmail(token);
i
sValid()에서 서명, 만료 시간 등을 검증하고,
유효하다면 getEmail()로 사용자 식별값을 꺼냅니다.
즉, 토큰이 신뢰 가능한지 먼저 확인하고, 그다음 사용자 정보를 해석하는 흐름입니다.
5. DB에서 사용자 정보를 다시 조회한다
UserDetails user = customUserDetailsService.loadUserByUsername(email);
여기서 “토큰 안에 정보가 있는데 왜 DB를 다시 조회하지?”라는 궁금증이 생길 수 있습니다.
이유는 현재 시점의 사용자 권한이나 상태를 DB 기준으로 다시 확인하기 위해서입니다.
예를 들어 사용자가 탈퇴했거나 권한이 바뀌었을 수도 있으니, 토큰 정보만 100% 믿기보다 DB에서 다시 확인하는 방식이 더 안전합니다.
6. 인증 객체를 만들어 SecurityContext에 저장한다
Authentication auth = new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(auth);
이 부분이 핵심입니다.
스프링 시큐리티는 SecurityContextHolder 안에 인증 객체가 들어 있으면
“이 요청은 인증된 사용자 요청이구나”라고 판단합니다.
즉, 필터에서 JWT를 검증한 뒤 그 결과를 스프링 시큐리티가 이해할 수 있는 Authentication 객체로 바꿔 넣는 것입니다.
예외 처리는 왜 필요할까?
필터에서는 여러 예외가 발생할 수 있습니다.
- JWT 파싱 실패
- JWT 만료
- JWT Signature 검증 실패
- 잘못된 형식의 JWT
그래서 catch 문에서 일관된 JSON 에러 응답을 내려주는 것이 중요합니다.
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());
ApiResponse<Void> errorResponse = ApiResponse.onFailure(code, null);
mapper.writeValue(response.getOutputStream(), errorResponse);
이렇게 하면 토큰 관련 오류가 났을 때
HTML 로그인 페이지가 아니라 우리가 원하는 API 응답 형식으로 401 Unauthorized를 내려줄 수 있습니다.
SecurityConfig에는 JWT 필터를 어떻게 등록할까?
JWT 필터를 만들었다면
이제 실제 스프링 시큐리티 필터 체인에 등록해야 합니다.
예를 들면 이런 식입니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
여기서 중요한 부분은 뭘까?
1. JwtAuthFilter를 Bean으로 등록한다
@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}
필터도 결국 의존성이 필요합니다.
JwtUtil, CustomUserDetailsService를 주입해서 스프링이 관리하는 Bean 형태로 등록하는 것입니다.
2. addFilterBefore()로 필터 체인에 끼워 넣는다
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
이 부분이 핵심입니다.
우리가 만든 JWT 필터를 기존 스프링 시큐리티 필터보다 앞에 두어서,
컨트롤러로 가기 전에 먼저 JWT 인증을 시도하게 만드는 것입니다.
즉, JWT 인증은 컨트롤러에서 처리하는 것이 아니라 필터 체인에서 처리한다는 점이 중요합니다.
로그인 API는 어떻게 JWT를 발급할까?
JWT 기반 인증에서는 로그인 성공 후 서버가 JWT를 발급해서 응답으로 내려줘야 합니다.
예를 들면 이런 식으로 구성할 수 있습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
AuthMember authMember = (AuthMember) userDetails;
String accessToken = jwtUtil.createAccessToken(authMember);
return ApiResponse.onSuccess(
MemberSuccessCode.OK,
new LoginResponse(accessToken)
);
}
}
이 흐름은 이렇게 이해하면 됩니다.
- 사용자가 이메일과 비밀번호를 보낸다
- AuthenticationManager가 인증을 수행한다
- 인증 성공 시 UserDetails를 얻는다
- 그 사용자 정보를 바탕으로 JWT를 발급한다
- 토큰을 응답으로 내려준다
즉, 세션 로그인에서는 세션이 생겼다면
JWT 로그인에서는 토큰을 발급하는 것이 로그인 성공의 핵심 결과가 됩니다.
마이페이지 API는 어떻게 개선할 수 있을까?
기존에는 마이페이지 조회 API에서
사용자 ID를 직접 Request Body나 Path Variable로 받는 방식을 생각할 수 있습니다.
그런데 JWT 기반 인증에서는 굳이 클라이언트가 사용자 ID를 따로 보내지 않아도 됩니다.
왜냐하면 이미 JWT를 통해 인증이 끝났고, 현재 로그인한 사용자 정보가 SecurityContextHolder 안에 들어 있기 때문입니다.
그래서 컨트롤러에서는 @AuthenticationPrincipal을 사용할 수 있습니다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
@GetMapping("/mypage")
public ApiResponse<MemberResponse> getMyPage(
@AuthenticationPrincipal AuthMember authMember
) {
return ApiResponse.onSuccess(
MemberSuccessCode.OK,
memberService.getMyPage(authMember)
);
}
}
서비스는 이런 식이 될 수 있습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
public MemberResponse getMyPage(AuthMember authMember) {
Member member = authMember.getMember();
return new MemberResponse(
member.getId(),
member.getEmail(),
member.getName()
);
}
}
왜 @AuthenticationPrincipal이 편할까?
@AuthenticationPrincipal AuthMember authMember
이 한 줄로 스프링이 SecurityContextHolder 안에 들어 있는 인증 객체의 principal을 꺼내서 넣어줍니다.
즉, 컨트롤러가 직접 아래 코드를 작성하지 않아도 됩니다.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
AuthMember authMember = (AuthMember) authentication.getPrincipal();
결과적으로 마이페이지 API는
“누구의 마이페이지인지”를 클라이언트에게 다시 묻지 않고,
현재 로그인한 사용자 기준으로 자연스럽게 처리할 수 있게 됩니다.
OAuth는 무엇일까?
JWT를 이해했다면 그다음으로 자연스럽게 보게 되는 것이 OAuth입니다.
OAuth는
비밀번호를 직접 공유하지 않고, 제3자 애플리케이션이 사용자의 리소스에 접근할 수 있도록 권한을 위임하는 표준 프로토콜입니다.
쉽게 말하면 우리가 서비스에서
“카카오로 로그인”, “구글로 로그인” 같은 버튼을 눌렀을 때
우리 서버가 카카오 비밀번호를 직접 받는 것이 아니라, 카카오가 인증을 대신 수행하고 결과만 넘겨주는 구조입니다.
즉, 인증을 외부 제공자에게 맡기는 방식이라고 볼 수 있습니다.

카카오 로그인 설정은 어떻게 시작할까?
카카오는 스프링이 기본 provider로 제공하는 대상이 아니기 때문에
application.yml에 관련 설정을 직접 적어줘야 합니다.
예를 들면 이런 식입니다.
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_REST_API_KEY}
client-secret: ${KAKAO_REST_API_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "http://localhost:8080/oauth/callback/kakao"
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: "https://kauth.kakao.com/oauth/authorize"
token-uri: "https://kauth.kakao.com/oauth/token"
user-info-uri: "https://kapi.kakao.com/v2/user/me"
user-name-attribute: id
이 설정을 보면
카카오 로그인에 필요한 인증 주소, 토큰 발급 주소, 사용자 정보 조회 주소를 스프링에게 알려주는 역할을 한다고 이해할 수 있습니다.
CustomOAuthService는 왜 필요할까?
OAuth 인증이 끝나면 스프링은 OAuth 제공자에게서 사용자 정보를 가져올 수 있습니다.
그런데 그 정보는 아직 우리 서비스의 회원 객체가 아닙니다.
그래서 제공자에서 받아온 정보를 우리 서비스의 회원 시스템과 연결하는 과정이 필요합니다.
그 역할을 하는 것이 CustomOAuthService입니다.
예를 들면 이런 코드입니다.
@Service
@RequiredArgsConstructor
public class CustomOAuthService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuthMember = super.loadUser(userRequest);
SocialType providerId;
String socialUid;
Map<String, Object> attributes = oAuthMember.getAttribute("kakao_account");
Map<String, Object> profile = (Map<String, Object>) attributes.get("profile");
try {
providerId = SocialType.valueOf(
userRequest.getClientRegistration().getRegistrationId().toUpperCase()
);
socialUid = String.valueOf((Long) oAuthMember.getAttribute("id"));
} catch (IllegalArgumentException e) {
throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER);
}
OAuthDTO dto;
switch (providerId) {
case KAKAO -> {
String email = attributes.get("email").toString();
String name = profile.get("nickname").toString();
dto = new KakaoDTO(socialUid, email, name);
}
default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER);
}
Member member = memberRepository.findBySocialTypeAndSocialUid(providerId, socialUid)
.orElseGet(() -> {
Member newMember = MemberConverter.toMember(dto);
memberRepository.save(newMember);
return newMember;
});
return new OAuthMember(member, oAuthMember.getAttributes());
}
}
이 코드는 어떤 흐름으로 동작할까?
1. super.loadUser()로 제공자 정보 조회
OAuth2User oAuthMember = super.loadUser(userRequest);
이 부분에서 스프링이 카카오 서버에 요청을 보내 실제 사용자 정보를 받아옵니다.
즉, 개발자가 직접 RestTemplate으로 호출하지 않아도 기본 흐름은 스프링이 대신 처리해줍니다.
2. 카카오 응답에서 필요한 값 추출
Map<String, Object> attributes = oAuthMember.getAttribute("kakao_account");
Map<String, Object> profile = (Map<String, Object>) attributes.get("profile");
카카오는 응답 구조가 조금 중첩되어 있기 때문에
이메일, 닉네임 같은 값을 꺼내기 위해 이런 식으로 파싱합니다.
즉, OAuth 제공자마다 응답 구조가 다를 수 있어서 이 과정을 DTO로 한 번 정리해주는 것이 중요합니다.
3. 기존 회원이면 조회, 없으면 저장
Member member = memberRepository.findBySocialTypeAndSocialUid(providerId, socialUid)
.orElseGet(() -> {
Member newMember = MemberConverter.toMember(dto);
memberRepository.save(newMember);
return newMember;
});
이 부분이 핵심입니다.
소셜 로그인이라고 해서 매번 새 회원을 만드는 것이 아니라, 같은 소셜 UID를 가진 사용자가 이미 있으면 기존 회원을 가져옵니다.
즉, OAuth 로그인도 결국 우리 서비스 회원 시스템 안으로 연결하는 작업이 필요합니다.
OAuthSuccessHandler는 왜 필요할까?
OAuth 인증이 성공했다고 해서 그 자체로 우리 서비스 인증이 완전히 끝난 것은 아닙니다.
우리 서비스는 이후 API 요청을 JWT 기준으로 처리하고 싶을 수 있습니다.
그래서 소셜 로그인 성공 후에는 우리 서버 전용 JWT를 다시 발급하는 과정이 필요합니다.
그 역할을 하는 것이 OAuthSuccessHandler입니다.
@RequiredArgsConstructor
public class OAuthSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
BaseSuccessCode code = MemberSuccessCode.OK;
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());
OAuthMember member = (OAuthMember) SecurityContextHolder
.getContext()
.getAuthentication()
.getPrincipal();
String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember()));
ApiResponse<MemberResDTO.Login> responseBody = ApiResponse.onSuccess(
code,
MemberConverter.toLogin(accessToken)
);
objectMapper.writeValue(response.getOutputStream(), responseBody);
}
}
이 핸들러는 무엇을 하는 걸까?
1. OAuth 인증 객체를 가져온다
OAuthMember member = (OAuthMember) SecurityContextHolder
.getContext()
.getAuthentication()
.getPrincipal();
카카오 로그인 성공 후
현재 인증 객체 안에는 OAuthMember가 들어 있습니다.
즉, 이제 이 사용자가 누구인지는 확인된 상태입니다.
2. 우리 서비스용 JWT를 발급한다
String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember()));
여기서 OAuth 사용자 정보를 우리 서비스에서 사용하는 AuthMember 형식으로 바꿔서 JWT를 발급합니다.
즉, 소셜 로그인은 “카카오가 인증해주는 단계”이고,
그 이후 실제 우리 서비스 API 호출은 우리 서버가 발급한 JWT로 처리하는 구조가 됩니다.
3. JSON 응답으로 토큰을 내려준다
ApiResponse<MemberResDTO.Login> responseBody = ApiResponse.onSuccess(
code,
MemberConverter.toLogin(accessToken)
);
이렇게 하면 프론트엔드는
카카오 로그인 성공 후 받은 JWT를 저장해두고, 이후 요청마다 Authorization: Bearer {token} 형식으로 보내면 됩니다.
결국 OAuth와 JWT는 어떻게 연결될까?
처음에는 JWT와 OAuth가 별개처럼 느껴질 수 있습니다.
그런데 실제 서비스에서는 둘이 꽤 자연스럽게 이어집니다.
흐름을 정리하면 이렇습니다.
- 사용자가 카카오 로그인 버튼을 누른다
- 카카오가 사용자 인증을 처리한다
- 우리 서버가 사용자 정보를 받아 회원 조회/저장을 한다
- 우리 서버가 자체 JWT를 발급한다
- 이후 API 요청은 JWT로 인증한다
즉,
- OAuth는 외부 제공자를 통한 로그인 방식
- JWT는 우리 서비스 내부에서 인증 상태를 유지하는 방식
이라고 이해하면 훨씬 정리가 잘 됩니다.
정리해보면
이번 내용을 코드와 함께 정리하면서 느낀 것은
JWT와 OAuth는 단순히 개념만 아는 것으로는 잘 이해되지 않는다는 점이었습니다.
실제로는
- JwtUtil에서 토큰을 생성하고 검증하고
- JwtAuthFilter에서 요청 헤더를 확인하고 인증 객체를 만들고
- SecurityConfig에서 필터 체인에 등록하고
- @AuthenticationPrincipal로 현재 로그인 사용자를 꺼내고
- CustomOAuthService에서 소셜 사용자 정보를 회원 시스템에 연결하고
- OAuthSuccessHandler에서 우리 서비스용 JWT를 발급하는
이 흐름이 한 번에 이어져야 비로소 전체 구조가 보이기 시작했습니다.
즉, 인증은 단순히 로그인 성공 여부만 확인하는 기능이 아니라
요청이 들어오는 순간부터 컨트롤러에 도달하기 전까지 어떤 방식으로 사용자를 식별할지 설계하는 일에 더 가깝다고 느꼈습니다.
마무리
처음에는 저도 세션 로그인, JWT, OAuth가 전부 따로 배우는 주제처럼 느껴졌습니다.
그런데 이번에 코드까지 같이 정리해보니
세션 기반 인증과 토큰 기반 인증의 차이,
JWT 필터가 인증 객체를 만드는 방식,
OAuth 로그인 이후 우리 서버 JWT를 다시 발급하는 이유가 조금 더 자연스럽게 연결됐습니다.
특히 이번 내용을 공부하면서 느낀 것은
JWT는 단순한 문자열이 아니라 서명으로 신뢰를 검증하는 인증 수단이고,
OAuth는 단순한 소셜 로그인 버튼이 아니라
외부 인증 결과를 우리 서비스 인증 체계로 연결하는 흐름이라는 점이었습니다.
앞으로 인증 기능을 구현할 때는 단순히 로그인 성공 여부만 보는 데서 끝나지 않고,
이 토큰은 어디서 생성되고 어디서 검증되는지,
현재 인증 객체는 어떻게 만들어지는지,
소셜 로그인 이후 우리 서비스 인증은 어떻게 이어지는지까지 함께 생각하면서 코드를 봐야겠다고 느꼈습니다.