1. 왜 JWT와 Spring Security를 선택했는가?
프로젝트에서 인증 기능이 필요했고, 다음 한 가지 조건이 있었다.
- 프론트엔드 없이 백엔드 단독으로도 테스트 가능한 인증 방식이 필요했다.
그래서 내가 선택한 방법은 Spring Security + JWT를 직접 구현하는 구조였다.
세션을 서버에 저장하지 않아도 되니 구조가 단순해졌다.
2. 세션이나 쿠키를 사용하지 않은 이유는?
이유1 : 백엔드 단독 실행 및 테스트의 편의성
- 프론트 없이 Postman, Swagger 등에서 직접 요청을 보낼 수 있어야 했다.
- 세션 기반 인증은 보통 쿠키 기반 요청을 전제로 하므로 브라우저 기반에서 테스트가 편하지만, Postman에서 매번 JSESSIONID를 수동 저장하거나 쿠키를 관리해야 함.
이유2 : 서버가 상태를 기억하지 않기 떄문에 확장에 유리
- 세션 기반 인증은 서버가 유저의 상태를 기억해야 한다 -> 서버 메모리에 저장 or Redis 사용
- 하지만 JWT는 서버가 상태를 기억하지 않음(Stateless)
- 클라이언트가 매 요청마다 토큰을 가지고 다님
- 그래서 나중에 서버가 여러 대로 늘어나도 수평확장에 부담이 없다.
2. 회원가입과 로그인 흐름 요약
- 회원가입 /api/auth/singup -> 유저 정보 저장
- 로그인 /api/auth/login -> 이메일/비밀번호 확인 -> JWT 발급
- 이후 모든 요청은 Authorization 헤더에 JWT 포함
- Spring Security가 JWT를 필터링하고, 인증객체를 만들어준다.
3. 핵심 코드 설명
JwtUtil
public class JwtUtil {
private static final String PREFIX = "Bearer ";
private final long TOKEN_TIME = 60 * 60 * 1000L * 24;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.setSigningKey(key) // 비밀 키를 사용하여 서명 검증
.parseClaimsJws(token)
.getBody();
}
public String generateToken(Long userId) {
Date date = new Date();
String token = Jwts.builder()
.setSubject(userId.toString())
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 설정
.setIssuedAt(date) // 발급 시간 설정
.signWith(key, signatureAlgorithm) // 비밀 키와 알고리즘으로 서명
.compact(); // JWT 토큰 생성
return PREFIX + token;
}
public Long extractUserId(String token) {
return Long.parseLong(extractAllClaims(token).getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key) // 비밀 키 설정
.build() // 파서 빌더 빌드
.parseClaimsJws(token); // 토큰 파싱 및 검증
return true; // 토큰이 유효한 경우
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
}
return false;
}
}
- generateToken(userId) : 유저 ID를 기반으로 JWT 생성
- extractUserId(token) : 토큰에서 subject(=userId) 추출
- validateToekn(token) : 서명 및 만료시간 검증
JwtFilter
@RequiredArgsConstructor
@Configuration
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
Long userId = jwtUtil.extractUserId(token);
//인증 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer")) {
return bearer.substring(7);
}
return null;
}
}
- 모든 요청을 가로채 JWT가 있는지 확인
- 유효한 경우 UsernamePasswordAuthenticationToken 생성
- SecurityContextHolder에 등록 -> 이후 컨트롤러에서 인증 정보 사용 가능
➡️ 토큰이 유효하지 않으면 인증 없이 요청 처리
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private JwtUtil jwtUtil;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/signup").permitAll()
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated()
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtFilter jwtFilter() {
return new JwtFilter(jwtUtil);
}
}
- addFilterBefore()로 JwtFilter를 Spring Security 필터 앞에 추가
- /signup, /login은 permitAll() -> 로그인 없이 접근 가능
- 나머지 요청은 모두 authenticatited() -> 토큰 없으면 401 오류
➡️ JWT 필터는 항상 작동하지만, Security는 설정에 따라 접근을 허용하거나 거부
❓ JwtFilter에서 토큰이 없으면 Security는 어떻게 401을 던지는가?
➡️ 기본 원칙 : SpringSecurity는 SecurityContext가 비어있으면 인증되지 않은 요청이라고 판단한다.
✳️ 요청 흐름을 다시 보면
- 클라이언트가 GET /protected-api 요청을 보낸다.
- SecurityFilterChain이 작동
- JwtFilter 실행 -> 토큰 없음 -> SecurityContextHolder에 인증 객체를 넣지 않음
- 다음 필터로 넘어감(현재 프로젝트에서는 SecurityConfig에 있는 필터)
- 이 시점에서SecurityContextHolder.getContext().getAuthentication()은 null
- FilterSecurityInterceptor가 요청을 막고 Spring이 401을 응답한다.
✳️ 시점별 인증 상태
| 시점 | SecurityContext 상태 | 결과 |
| JwtFilter 들어가기 전 | 비어 있음 | 아직 인증 안 됨 |
| JwtFilter에서 유효한 JWT 있으면 | 인증 객체 등록됨 | 통과 가능 |
| JwtFilter에서 토큰 없거나 실패 | 그대로 비어 있음 | FilterSecurityInterceptor가 401 |
4. 전체 요청 흐름 요약
- 클라이언트 -> /api/auth/signup(회원가입)
- 클라이언트 -> /api/auth/login(이베일/비번 -> JWT 응답)
- 클라이언트 -> 보호된 API 호출 시 Authorization 헤더에 JWT 포함
- 서버의 JwtFilter가 토큰 검증 후 인증 객체 생성
- 인증된 상태로 요청 처리
'Project' 카테고리의 다른 글
| Kakao API를 사용하여 키워드로 장소 검색하기 (0) | 2025.04.09 |
|---|---|
| 복합 유니크 제약 조건의 필요성 (0) | 2025.04.04 |
| 스프링 프로젝트에서 공통 응답 + 페이징 + 전역 예외처리 (0) | 2025.03.30 |
| 결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(2) (0) | 2025.03.17 |
| 결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(1) (0) | 2025.03.17 |