[Spring Security] JWT를 이용한 토큰 기반 인증 시스템 완벽 가이드
안녕하세요! 이번 스터디에서는 Spring Boot 환경에서 Spring Security와 JWT(JSON Web Token)를 사용하여 현대적인 토큰 기반 인증 시스템을 구축하는 방법을 처음부터 끝까지 알아보겠습니다. 전통적인 세션 방식과 토큰 방식의 차이점부터 시작해, 각 컴포넌트의 역할과 코드 구현까지 상세히 다룰 예정입니다.
1. 왜 토큰 방식(JWT)을 사용할까?
본격적인 구현에 앞서, 왜 세션이 아닌 토큰 방식을 선택하는지 이해하는 것이 중요합니다.
- 세션(Session) 방식: 서버가 사용자의 인증 상태를 서버 메모리나 DB에 저장합니다. 서버는 클라이언트에게 세션 ID를 발급하고, 클라이언트는 요청마다 이 ID를 보내 인증을 유지합니다.
- 장점: 구현이 간단하고, 서버에서 세션을 직접 제어할 수 있습니다.
- 단점: 서버가 상태를 유지해야 하므로(Stateful), 서버 확장(Scale-out) 시 세션 불일치 문제가 발생할 수 있습니다.
- 토큰(Token/JWT) 방식: 서버는 인증된 사용자에게 암호화된 토큰을 발급하고, 클라이언트는 이 토큰을 저장했다가 요청 시 헤더에 담아 보냅니다. 서버는 상태를 저장하지 않고 토큰의 유효성만 검증합니다.
- 장점: 서버가 **무상태(Stateless)**를 유지하여 확장성이 뛰어납니다. 웹, 모바일 등 다양한 클라이언트 환경을 지원하기에 용이합니다.
- 단점: 세션보다 구현이 복잡하며, 토큰 자체에 정보를 담고 있어 탈취 시 보안에 유의해야 합니다.
현대의 MSA(Microservice Architecture)나 SPA(Single Page Application) 환경에서는 토큰 방식이 표준처럼 사용되고 있습니다.
2. 프로젝트 설정: 의존성 추가
먼저 build.gradle (또는 pom.xml)에 필요한 라이브러리를 추가합니다.
- spring-boot-starter-security: Spring Security 핵심 라이브러리
- jjwt: JWT 생성 및 검증을 위한 라이브러리
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
// JWT Library
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// ... 기타 의존성
}
3. JWT의 핵심: JwtTokenProvider 구현하기
이 클래스는 JWT의 생성, 검증, 정보 추출 등 토큰과 관련된 모든 로직을 담당하는 '전문가'입니다.
application.yml에 시크릿 키 추가
먼저, 토큰을 암호화할 우리만의 비밀 키를 application.yml에 추가합니다. 이 키는 절대 외부에 노출되어서는 안 됩니다.
jwt:
secret: 'your-super-super-long-secret-key-that-is-secure-enough-for-hs256-algorithm' # 64자 이상의 무작위 문자열 권장
Tip! 안전한 시크릿 키 생성하기
터미널에서 openssl rand -base64 64 명령어로 쉽게 생성할 수 있습니다. 운영 환경에서는 환경 변수로 관리하는 것이 가장 안전합니다.
JwtTokenProvider.java
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = secretKey.getBytes();
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 1. 유저 정보를 가지고 AccessToken을 생성하는 메서드
public String generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + 86400000); // 예: 1일
return Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 2. JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 여기서 User는 우리가 만든 User 엔티티가 아닌, Spring Security가 제공하는 UserDetails 구현체
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 3. 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
4. 요청의 최전선: JwtAuthenticationFilter 구현하기
이 필터는 모든 요청의 입구에서 '인증 심사관' 역할을 합니다. 요청 헤더에 담긴 JWT가 유효한지 검사하고, 유효하다면 해당 사용자를 인증된 상태로 만들어줍니다.
JwtAuthenticationFilter.java
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가져와 SecurityContext에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

UsernamePasswordAuthentication Token 의 경우는 form인증시 사용되는 기본 security 내용입니다.(즉 , form 을 disable 해도 해당 filter는 동작하지 않지만 진행됨.) 이 filter 전에 커스텀 filter를 적용하여 커스텀 필터 후 -> 해당 fillter로 넘겨 사용하곤 합니다.
5. 보안 시스템 설계: SecurityConfig 설정하기
이제 모든 컴포넌트를 조립하여 보안 시스템의 전체적인 동작 규칙을 정의합니다.
SecurityConfig.java
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. 기본 설정 비활성화
.httpBasic(httpBasic -> httpBasic.disable())
.csrf(csrf -> csrf.disable())
.formLogin(formLogin -> formLogin.disable())
// 2. 세션 정책을 STATELESS로 설정
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 3. URL별 인가 규칙 설정
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/members/login", "/members/signup").permitAll() // 로그인, 회원가입은 누구나 접근 가능
.anyRequest().authenticated() // 그 외 모든 요청은 인증 필요
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
// 4. 우리가 만든 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
return http.build();
}
// 비밀번호 암호화를 위한 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
addFilterBefore의 의미
formLogin을 비활성화했기 때문에 UsernamePasswordAuthenticationFilter는 동작하지 않습니다. 하지만 이 필터를 **'기준점'**으로 삼아, 그 앞에 우리의 JWT 필터를 삽입함으로써 "기존 로그인 처리 로직을 우리의 토큰 처리 로직으로 대체한다"는 의미를 명확히 할 수 있습니다.
6. 로그인 API 구현
마지막으로, 사용자가 ID/PW를 보내 토큰을 발급받을 수 있는 로그인 API를 구현합니다.
MemberService.java (일부)
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
public String login(String username, String password) {
// 1. ID/PW 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
// 2. 실제 검증 (비밀번호 비교 등)
// authenticate() 메서드가 실행될 때 CustomUserDetailsService의 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
String token = jwtTokenProvider.generateToken(authentication);
return token;
}
}
마무리하며
이번 글에서는 Spring Security와 JWT를 이용한 토큰 기반 인증 시스템을 구축했습니다. 핵심은 각 컴포넌트의 명확한 역할 분담입니다.
- SecurityConfig: 보안 시스템의 전체적인 설계도를 그립니다.
- JwtAuthenticationFilter: 모든 요청의 입구에서 JWT를 검증하는 실행자 역할을 합니다.
- JwtTokenProvider: JWT 생성, 검증, 정보 추출 등 복잡한 기술 로직을 전담하는 전문가입니다.
이 구조를 이해하신다면, 앞으로 OAuth2 소셜 로그인, Refresh Token 도입 등 더 복잡한 보안 요구사항에도 유연하게 대처하실 수 있을 겁니다.
'개발일지 > SPRINGBOOT' 카테고리의 다른 글
[테스트코드-1] 제대로 이해하는 단위 테스트(Unit Test)의 모든 것 (1) | 2025.06.14 |
---|---|
[SpringBoot] 전략 패턴으로 카카오, 네이버, 구글 소셜 로그인 유연하게 구현하기 (2) | 2025.06.14 |
Builder 패턴을 적용한 엔터티 (2) | 2025.06.08 |
JPA , 값 객체는 무엇이고 어떻게 활용하는가? (1) | 2025.06.04 |
JPA 에서 @Entity 간 연관 관계 (1) | 2025.06.04 |