SSAFY 핀테크 프로젝트를 시작하면서 나는 인프라 전체와 백엔드 일부 기능을 맡게 되었다.
나는 인증 부분을 맡게 되었는데, 결제 시스템이다 보니 인증이 허술하면 아무 의미가 없다고 생각했다.
회원가입, 로그인 등의 기능부터 구현을 시작하였는데, JWT를 선택했다.

JWT의 가장 큰 단점은 한번 발급하면 서버에서 강제로 무효화하기 어렵다는 건데, 이건 나중에 Redis 블랙리스트로 해결했다 (다음 글에서 다룸).
global/
├── security/
│ ├── SecurityConfig.java # Spring Security 설정
│ ├── JwtTokenProvider.java # JWT 토큰 생성/검증
│ ├── JwtAuthenticationFilter.java # 요청마다 토큰 검증하는 필터
│ └── CustomUserDetailsService.java # DB에서 사용자 조회
└── config/
└── RedisConfig.java # Redis 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/health"
).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
하나씩 설명해보겠다.
그리고 BCryptPasswordEncoder를 Bean으로 등록했다. 비밀번호는 당연히 평문 그대로 저장하면 안되기 때문이다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
JWT 관련 모든 로직을 모아둔 클래스이다. jjwt 0.12.6 버전을 사용했다.
@Slf4j
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiry}")
private long accessTokenExpiry; // 30분
@Value("${jwt.refresh-token-expiry}")
private long refreshTokenExpiry; // 7일
private SecretKey key;
@PostConstruct
protected void init() {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
}
@PostConstruct는 secret 문자열을 SecretKey 객체로 변환한다. 이걸 init()에서 한번만 하는 이유는, 매번 토큰을 생성할 때 마다 키를 만들면 낭비이기 때문이다.
토큰 생성은 :
private String createToken(String userId, String userKey, long expiry, String tokenType) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiry);
return Jwts.builder()
.subject(userId)
.claim("type", tokenType) // "access" 또는 "refresh"
.claim("userKey", userKey) // SSAFY 금융망 userKey
.issuedAt(now)
.expiration(expiryDate)
.signWith(key)
.compact();
}
여기서 주목할 점은 userKey를 claim에 넣은 것이다. 우리 프로젝트는 SSAFY 금융 API를 호출할 때 userKey가 필요한데, 매번 DB를 조회하는 건 비효율적이라고 생각했다.
JWT에 넣어두면, 토큰을 파싱하는 것만으로도 userKey를 꺼낼 수 있다.
검증로직 :
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("만료된 JWT 토큰입니다.");
} catch (JwtException e) {
log.warn("유효하지 않은 JWT 토큰입니다: {}", e.getMessage());
}
return false;
}
getClaims(token); 을 호출하면 jjwt가 서명 검증과 만료 체크를 알아서 해준다. 예외가 터지면 유효하지 않은 토큰인거다.
OncePerRequestFilter를 상속해서 모든 HTTP 요청마다 한 번씩 실행되는 필터를 만들었다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
private final RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
// 블랙리스트 체크 (로그아웃된 토큰인지 확인)
if (Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token))) {
filterChain.doFilter(request, response);
return;
}
String userId = jwtTokenProvider.getUserId(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
흐름을 정리하자면
1. Authorization : Bearer {token} 헤더에서 토큰 추출
2. JWT 서명과 만료 검증
3. Redis 블랙리스트에 존재하는 토큰인지 확인(로그아웃한 토큰 차단)
4. 유효하면 SecurityContextHolder에 인증 정보 세팅
5. 이후 컨트롤러에서 @AuthenticationPrincipal 등으로 사용자 정보 접근 가능
처음에는 블랙리스트 체크 없이 만들었는데, "로그아웃 했는데 토큰이 아직 유효하면 어떻게하지?"라는 생각이 들어서 추가했다. JWT의 한계를 Redis로 보완하였다.
지금까지는 JWT를 발급하고 검증하는 기본 구조를 만들었다. 그런데 Access Token이 만료되면? 사용자가 로그아웃하면? 이런 문제를 해결하려면 Refresh Token과 Redis가 필요하다. 다음 글에서 Redis를 이용한 Refresh Token 관리와 토큰 블랙리스트를 다룬다.