1. 세션 기반 vs 토큰 기반
저번 주차에 구현한 폼 로그인은 세션 기반 인증이다. formLogin()으로 세션이 생성되고, 브라우저는 JSESSIONID 쿠키를 전송해 서버가 사용자 상태를 유지하는 Stateful 방식이다.
이번 주차에는 **토큰 기반 인증(JWT)**를 구현한다. 두 방식의 가장 큰 차이는 서버가 사용자 정보를 기억(Stateful)하느냐, 기억 안 함(Stateless)다.
| 구분 | 세션 기반 | 토큰 기반 (JWT) |
|---|---|---|
| 상태 관리 | Stateful (서버가 세션 저장) | Stateless (서버가 상태 저장 X) |
| 인증 방식 | JSESSIONID 쿠키 | Authorization 헤더에 토큰 전송 |
| 확장성 | 서버 여러 대일 때 세션 동기화 필요 | 서버 증설이 자유로움 |
| 보안 | 서버 통제 용이 | 토큰 탈취 시 만료 시점까지 대응 어려움 |
요즘은 모바일 앱, 프론트-백 분리 구조, REST API 기반 서비스가 많아져서 JWT가 범용적으로 더 많이 쓰이는 추세다.
2. JWT란?
JWT(JSON Web Token)는 하나의 거대한 JSON 문자열로, 세 부분으로 구성된다.
Header.Payload.Signature
- Header: 토큰 타입(JWT)과 서명 알고리즘 정보
- Payload: 사용자 식별자, 권한, 만료 시간 등 (민감한 정보는 넣으면 안 됨!)
- Signature: 서버의 시크릿 키로 서명 → 위조 여부 검증
각 파트는 JSON → Base64 URL-safe 인코딩으로 구성되기 때문에 누구나 디코딩 가능하다. 따라서 Payload에 비밀번호 같은 민감 정보는 절대 넣지 말 것.
위조 방지는 Signature로 한다. 헤더와 페이로드 + 서버의 시크릿 키를 함쳐 암호화한 결과로 Signature를 만들기 때문에, 시크릿 키를 모르면 유효한 Signature를 생성할 수 없다.
JWT 토큰 운용 전략
- 만료 기한을 짧게 (30분 정도) 설정 → 탈취되더라도 피해 최소화
- Access Token: 실제 인증에 사용하는 메인 토큰
- Refresh Token: Access Token 재발급만을 위한 보조 토큰 (더 긴 만료)
- 전달 방식: Response Body 또는 쿠키
3. JWT 구현 - JwtUtil
@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);
}
// AccessToken 생성
public String createAccessToken(AuthMember member) {
return createToken(member, accessExpiration);
}
// 토큰 유효성 검증
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException e) {
return false;
}
}
// 토큰에서 이메일 추출
public String getEmail(String token) {
try {
return getClaims(token).getPayload().getSubject();
} catch (JwtException e) {
return null;
}
}
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)
.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);
}
}
4. JWT 커스텀 필터 - JwtAuthFilter
Spring Security는 JWT를 기본적으로 제공하지 않기 때문에 필터를 직접 구현해서 Filter Chain에 끼워 넣어야 한다.
토큰 검증 흐름:
요청 들어옴
↓
Authorization 헤더 확인
없거나 Bearer 아님 → 다음 필터로 넘김
↓
토큰 유효성 검증
유효하지 않음 → 다음 필터로 넘김
↓
토큰에서 이메일 추출 → DB에서 사용자 조회
↓
인증 객체 생성 → 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");
// Bearer 토큰이 없으면 다음 필터로
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) {
// 파싱 실패, 만료, Signature 검증 실패 등 예외 대응
ObjectMapper mapper = new ObjectMapper();
BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED;
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());
mapper.writeValue(response.getOutputStream(), ApiResponse.onFailure(code, null));
}
}
}
SecurityConfig에서 addFilterBefore로 등록한다.
http.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
5. @AuthenticationPrincipal 활용
JWT 인증이 완료되면 SecurityContextHolder에 인증 객체가 등록된다. 컨트롤러에서 이를 꺼낼 때 @AuthenticationPrincipal를 사용하면 된다.
// 기존: Request Body로 id를 받아 조회
@GetMapping("/my")
public ApiResponse<?> getMyInfo(@RequestBody MemberReqDTO.MyPage request) { ... }
// 개선: 토큰에서 사용자 정보를 바로 기말
@GetMapping("/my")
public ApiResponse<?> getMyInfo(@AuthenticationPrincipal AuthMember authMember) {
return ApiResponse.onSuccess(memberService.getMyInfo(authMember));
}
6. OAuth - 소셜 로그인
OAuth란?
OAuth(Open Authorization)는 비밀번호 공유 없이 제3자 앱이 사용자의 리소스에 안전하게 접근할 수 있도록 권한을 위임하는 개방형 표준 프로토콜이다. "카카오로 로그인", "구글로 로그인" 같은 소셜 로그인이 바로 OAuth를 활용한 것이다.
카카오 OAuth 플로우
1. 사용자가 소셜 로그인 버튼 클릭
2. 클라이언트는 우리 서버에 리다이렉트 URI 요청
3. 우리 서버는 카카오 로그인 페이지로 리다이렉트
4. 사용자가 로그인 & 동의
5. 카카오가 일회성 코드를 우리 서버 콜백 URI로 전송
6. 우리 서버가 코드로 사용자 정보 요청
7. 정보 기반으로 비즈니스 로직 처리 (DB 저장/조회) → JWT 발급
Spring OAuth-Client 라이브러리를 사용하면 1~6을 대부분 자동 처리한다. 우리가 커스텀할 건 7에 해당하는 CustomOAuthService와 OAuthSuccessHandler 두 가지다.
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);
// 유저 정보 추출
Map<String, Object> attributes = oAuthMember.getAttribute("kakao_account");
Map<String, Object> profile = (Map<String, Object>) attributes.get("profile");
String socialUid = String.valueOf((Long) oAuthMember.getAttribute("id"));
String email = attributes.get("email").toString();
String name = profile.get("nickname").toString();
// DTO로 매핑
OAuthDTO dto = new KakaoDTO(socialUid, email, name);
// DB 저장 (없으면 신규, 있으면 조회)
Member member = memberRepository
.findBySocialTypeAndSocialUid(SocialType.KAKAO, socialUid)
.orElseGet(() -> memberRepository.save(MemberConverter.toMember(dto)));
return new OAuthMember(member, oAuthMember.getAttributes());
}
}
OAuthSuccessHandler
@RequiredArgsConstructor
public class OAuthSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.OK.value());
// SecurityContextHolder에서 OAuth 인증 객체 가져오기
OAuthMember member = (OAuthMember) SecurityContextHolder
.getContext().getAuthentication().getPrincipal();
// JWT 토큰 생성
String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember()));
objectMapper.writeValue(response.getOutputStream(),
ApiResponse.onSuccess(MemberSuccessCode.OK,
MemberConverter.toLogin(accessToken)));
}
}
SecurityConfig 적용
# 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"
userNameAttribute: id
http.oauth2Login(oauth -> oauth
.authorizationEndpoint(e ->
e.baseUri("/oauth/authorization"))
.redirectionEndpoint(e ->
e.baseUri("/oauth/callback/*"))
.userInfoEndpoint(e ->
e.userService(customOAuthService()))
.successHandler(oAuthSuccessHandler())
);
authorizationEndpoint.baseUri: OAuth 시작 엔드포인트 (기본값:/oauth2/authorization/kakao)redirectionEndpoint.baseUri: 콜백 URIuserInfoEndpoint.userService: 사용자 정보를 가져온 후 실행할 서비스successHandler: 서비스 실행 후 JWT 발급 통하는 핸들러
핵심 키워드
- 세션과 토큰의 차이는?
- 액세스 토큰과 리프레시 토큰이란?
- OAuth 1.0과 OAuth 2.0의 차이는?
- JWT 시크릿 키는 절대 공개되면 안 되는 이유
