[IT 개발자를 위한 필독서 SSAFYdia] JWT 인증 필터부터 Redis 연동까지, 직접 구현한 Spring Boot 인증 시스템
안녕하세요! 이번 기사도 SSAFY 프로젝트 중에 요긴하게 잘 사용했던 기술을 들고 찾아왔습니다.
프로젝트 백엔드 개발을 맡아 JWT 인증 방식을 도입하여 Spring Boot 환경에서 JWT를 직접 적용해 본 경험을 정리해보고자 합니다!
처음에는 생소했던 JWT였지만 프로젝트에 녹여내며 인증 시스템에 대한 이해도를 한층 높일 수 있었습니다.
이 글에서는 JWT란 무엇인지부터 실제 코드까지 실용적인 내용을 정리해 보겠습니다!
인증 시스템에 대해 고민하다
웹 서비스를 만들다 보면 '인증'과 '인가'는 빠질 수 없는 중요한 주제입니다.
예를 들어 사용자가 웹사이트에 로그인할 때를 생각해 봅시다.
- 사용자가 아이디와 비밀번호를 입력해서 본인이 누구인지 증명하는 과정은 인증(Authentication)입니다.
- 인증에 성공한 후 사용자가 마이페이지에 접근하거나 관리자 기능을 사용할 수 있는지를 확인하는 것은 인가(Authorization)입니다.
🔐 인증 | "너 누구야?" → 사용자의 신원을 확인하는 과정 (ex. 로그인) |
🆗 인가 | "이거 해도 돼?" → 인증된 사용자가 무엇을 할 수 있는지 결정하는 권한 부여 |
처음 프로젝트를 설계할 때 로그인 이후 사용자 인증을 어떻게 유지할지 고민이 많았습니다.
처음엔 세션 기반 인증을 고려했지만 Spring Security + JWT 조합이 무상태(Stateless) 서버에 더 적합하다고 판단하여 JWT 기반 인증 방식을 채택하게 되었습니다!
지금부터 Spring Security와 함께 JWT를 어떻게 적용했는지 흐름과 함께 설명드리겠습니다.
JWT란?
JWT(Json Web Token)는 서버와 클라이언트 간 사용자 정보를 안전하게 주고받기 위한 토큰 기반 인증 방식입니다. JSON 형식으로 인코딩 된 인증 정보를 담은 토큰이라고 생각하시면 됩니다!
JWT는 다음과 같은 구조로 이루어져 있습니다:
Header.Payload.Signature
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VySWQiLCJleHAiOjE3MDAwMDAwMDB9.SIGNATURE...
- Header: 사용된 해싱 알고리즘 명시 (ex. HS512)
- Payload: 사용자 정보, 권한, 만료 시간 등
- Signature: 위 내용을 기반으로 비밀 키로 생성된 서명
JWT는 클라이언트에 저장되며 서버는 토큰의 유효성만 검증하면 되므로 상태를 저장할 필요가 없어 편리힌 것 같습니다.
왜 JWT를 사용할까?
JWT는 최근 많은 웹 서비스에서 인증 수단으로 사용되고 있습니다.
그렇다면 왜 굳이 기존의 세션 기반 인증이 아닌 JWT를 선택하고 있는지에 대한 이유를 다음 3가지로 정리해 보았습니다.
1. 서버 확장에 유리한 Stateless 구조
2. 토큰 자체로 인증 가능하여 세션 저장 불필요
3. 다양한 클라이언트(웹, 앱)에서 Cross-platform 지원
Spring Boot에서의 JWT 인증 흐름
저는 이번 프로젝트를 Spring Boot 기반으로 진행했기 때문에
Spring Security + JWT 인증 시스템을 다음과 같이 구성했습니다.
1. 사용자가 로그인 요청
2. 인증 성공 → Access Token + Refresh Token 발급
3. 클라이언트는 이후 모든 요청에 `Authorization: Bearer <Access Token>`을 포함
4. 서버는 JWT 인증 필터에서 토큰을 검증하고 인증 객체(`SecurityContex`t)에 저장
5. Refresh Token은 Access Token 재발급용으로 Redis에 저장
HS512 알고리즘 선택
JWT 서명(Signature)에는 여러 알고리즘을 선택할 수 있는데 저는 HS512 (HMAC SHA-512)를 사용했습니다.
- HS256: 빠르지만 상대적으로 보안 약함
- HS512: 더 긴 해시 → 보안성 ↑
RSA 등 비대칭키 방식도 있지만 HS 방식이 간단하고 프로젝트 규모에 적합하다고 합니다!
String token = Jwts.builder()
.setSubject("userId")
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
JwtTokenProvider - 토큰 발급 및 검증 로직
JWT를 사용하는 핵심은 바로 토큰을 어떻게 만들고 어떻게 검증하느냐입니다.
`JwtTokenProvider`는 JWT 생성과 파싱, 검증 등 JWT 관련 핵심 로직을 담당하는 유틸리티 클래스로 아래와 같은 역할을 수행합니다:
- 로그인 시 사용자 정보를 기반으로 Access Token과 Refresh Token 생성
- 요청이 들어올 때 토큰을 파싱 하여 사용자 ID 및 권한 정보 추출
- 클라이언트가 보낸 토큰이 유효한지 검증하고 만료 여부 판단
- 유효한 토큰이라면 Spring Security에서 사용할 인증 객체(Authentication)를 생성해 줌
해당 클래스는 JWT를 기반으로 사용자 인증 흐름의 중심을 담당하며 인증 필터(`JwtAuthenticationFilter`)에서 실제 인증 처리를 할 수 있도록 지원합니다.
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
private final long accessTokenValidity = 1000L * 60 * 15; // 15분
private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일
// 토큰 생성
public String createToken(String userId, List<String> roles, boolean isRefreshToken) {
Claims claims = Jwts.claims().setSubject(userId);
claims.put("roles", roles);
Date now = new Date();
Date expiry = new Date(now.getTime() + (isRefreshToken ? refreshTokenValidity : accessTokenValidity));
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
// 토큰에서 인증 객체 생성
public Authentication getAuthentication(String token) {
String userId = getUserId(token);
List<String> roles = getRoles(token);
UserDetails userDetails = new User(userId, "", roles.stream().map(SimpleGrantedAuthority::new).toList());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 사용자 ID 추출
public String getUserId(String token) {
return parseClaims(token).getSubject();
}
// 토큰에서 권한 정보 추출
public List<String> getRoles(String token) {
Object roles = parseClaims(token).get("roles");
return roles != null ? (List<String>) roles : List.of();
}
// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// Claims 파싱
private Claims parseClaims(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
}
// Request Header에서 토큰 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
return (bearerToken != null && bearerToken.startsWith("Bearer "))
? bearerToken.substring(7)
: null;
}
}
JwtAuthenticationFilter - 요청마다 토큰 인증
프로젝트에서는 직접 작성한 `JwtAuthenticationFilter`를 통해 모든 요청에 대해 JWT 토큰의 유효성을 검사하고 인증 정보를 설정하도록 구성했습니다.
이 필터는 Spring Security의 `OncePerRequestFilte`를 상속받아 HTTP 요청마다 한 번씩 실행되도록 보장되며 아래와 같은 역할을 수행합니다:
- 요청 헤더에서 Access Token을 추출
- 토큰이 유효하면 사용자 정보를 기반으로 인증 객체를 생성
- 해당 인증 객체를 `SecurityContextHolder`에 등록하여 Spring Security가 이후의 요청을 인증된 사용자로 인식할 수 있게 처리
`OncePerRequestFilter`를 상속받아한 요청에 한 번만 필터링되도록 하고 토큰이 유효하면 `SecurityContext`에 인증 정보를 저장하는 방식입니다.
이렇게 JWT 필터를 직접 구현함으로써 세션 없이도 인증 상태를 유지할 수 있는 무상태 서버 구조를 완성할 수 있습니다!
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
Spring Security 설정 - JWT 인증을 위한 최소 구성
Spring Boot에서 JWT 인증을 적용하려면 Spring Security의 기본 설정을 커스터마이징해야 합니다.
이 단계는 JWT는 세션을 사용하지 않는 무상태 인증 방식이기 때문에 기존 Spring Security의 상태 기반 인증 흐름과는 다르게 설정하는 부분입니다.
또한 저는 프로젝트 초기에 모든 API에 인증을 적용하지 않고 테스트하기 위해 개발 환경에서만 인증을 생략할 수 있도록 조건부 설정 코드를 추가해 사용했습니다!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Refresh Token과 함께 사용
Access Token은 유효 시간이 짧기 때문에 로그인을 유지하려면 Refresh Token이 필요합니다.
저는 Refresh Token을 Redis에 저장하여 탈취 시 무효화할 수 있도록 했습니다.
(Redis는 이전 기사에서 언급했던 대로 Docker 활용해서 구현했습니다!)
Redis를 사용하면 다음과 같은 장점이 있습니다:
1. TTL(Time To Live) 기능을 통해 자동 만료 관리
2. 빠른 읽기/쓰기 → 성능 우수
3. 세션 정보와 유사하게 토큰 관리 가능
Redis 저장 구조 예시
속성 | 내용 |
`key` | 사용자 ID (혹은 `RT:{userId}` 형태) |
`value` | Refresh Token 문자열 |
`TTL` | Refresh Token의 만료 시간 (ex. 7일) |
Redis를 활용한 TokenService 구현
1. Redis 설정 (application.yml)
spring:
data:
redis:
host: localhost
port: 6379
2. TokenService – Refresh Token 저장 및 검증
@Service
@RequiredArgsConstructor
public class TokenService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtTokenProvider jwtTokenProvider;
private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일
// Refresh Token 저장
public void saveRefreshToken(String userId, String refreshToken) {
redisTemplate.opsForValue().set(
getRedisKey(userId),
refreshToken,
refreshTokenValidity,
TimeUnit.MILLISECONDS
);
}
// Refresh Token 검증 및 Access Token 재발급
public String reissueAccessToken(String refreshToken) {
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new UnauthorizedException("유효하지 않은 Refresh Token입니다.");
}
String userId = jwtTokenProvider.getUserId(refreshToken);
String storedToken = redisTemplate.opsForValue().get(getRedisKey(userId));
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("저장된 Refresh Token과 일치하지 않습니다.");
}
return jwtTokenProvider.createToken(userId, List.of("ROLE_USER"), false);
}
// 로그아웃 시 토큰 삭제
public void deleteRefreshToken(String userId) {
redisTemplate.delete(getRedisKey(userId));
}
private String getRedisKey(String userId) {
return "RT:" + userId;
}
}
로그인 시 Refresh Token 저장
1. 로그인 시 저장하고 2. 로그아웃이나 재발급 실패 시 삭제하는 구조로 구성하면 됩니다
public TokenResponse login(String userId, List<String> roles) {
String accessToken = jwtTokenProvider.createToken(userId, roles, false);
String refreshToken = jwtTokenProvider.createToken(userId, roles, true);
tokenService.saveRefreshToken(userId, refreshToken);
return new TokenResponse(accessToken, refreshToken);
}
JWT 사용할 때 주의할 점
🔐 비밀키(secretKey)는 외부에 노출되면 안 됩니다!
→ 노출되지 않도록 환경 변수 또는 application.yml에 암호화된 형태로 보관
🕒 Access Token의 유효 시간 설정은 반드시 필요!
→ 만료 시간 없는 토큰은 큰 보안 리스크이고 또한 유효 시간을 너무 길게 설정하지 않는 것이 좋습니다.
🔁 Refresh Token은 별도 저장/관리 필요
→ Refresh Token을 사용할 경우 Redis나 DB에 저장하여 별도로 저장 및 만료 관리(탈취 시 무효화 처리)를 해줘야 합니다.
JWT 인증은 처음에는 어렵게 느껴졌지만 직접 구현하면서 인증 흐름에 대해서 더 깊이 이해할 수 있었습니다.
특히 Spring Security와 연동해 필터 기반으로 구성하니 확장성과 유지보수성 측면에서 이점이 있었습니다.
이와 같이 보안과 편의성 사이에서 균형을 잡고 싶다면 꼭 한 번 사용해 보시길 추천드립니다!
이번 프로젝트를 통해 인증 시스템을 직접 설계해 보는 좋은 경험을 할 수 있었고 앞으로 OAuth2, 소셜 로그인 등 더 다양한 인증 방식을 학습해나가고 싶습니다.
이번 기사를 통해 프로젝트에서 인증 시스템을 구축하는데 도움이 되셨으면 좋겠습니다!
⭐ SSAFY의 다양한 소식을 확인해보세요!
삼성 청년 SW 아카데미
삼성 청년 SW 아카데미| 소프트웨어 교육, 취업 지원, 코딩 교육
www.ssafy.com