Spring Security + JWT
스프링 숙련 강의 1강을 수강하고 정리했다.
필터를 Bean으로 등록해서 로그인 인증/인가 구현
Spring Security에서 JWT의 역할 등을 정리했다.
Spring Security와 JWT
기본적으로 Spring Security는 세션방식으로 동작한다.
로컬환경에서는 사용자의 세션 정보가 서버 메모리, 즉 내 컴퓨터의 RAM에 저장된다.
그래서 서버가 꺼지면 모든 사용자가 강제 로그아웃되고
사용자가 많아질수록 서버 메모리에 그만큼 저장해야 한다.
JWT를 사용한다면?
사용자가 로그인하면 서버가 JWT를 발급하고, 클라이언트(브라우저)에 쿠키로 저장한다.
이후 요청 시 브라우저가 JWT를 요청 헤더에 포함해서 서버로 전송한다.
서버에서는 토큰의 유효성검사만 하면 되고, 별도의 서버 메모리나 세션을 통해 관리할 필요가 없다.
즉, JWT를 사용하면 Stateless 구조로 서버 확장성이 좋아지고 세션 관리 부담이 사라진다.

JWT를 사용하면 왜 서버 확장성이 좋아지는가?
1. 여러 대의 서버가 있어도, 인증 정보를 각 서버의 RAM에 저장하지 않아 서버 하드웨어에 얽매이지 않는다.
2. 서버를 증설해도 JWT 검증만 하면 되므로 서버간의 세션 공유나 동기화가 필요 없다.

JWT는 Header / Payload / Signature의 3 구조로 되어 있다.
Header에는 사용한 알고리즘의 종류, 토큰 타입(JWT)
Payload는 사용자 정보, 권한, 만료시간 등의 Claim
Signature는 Header와 Payload를 합쳐 비밀키로 서명(인코딩)한 값 ( 변조 방지용 )
이때 인코딩시 Base64를 사용한다.
즉, Payload에 실제 유저의 정보
Header와 Signature는 암호와 관련 정보
Spring Security + JWT 프로젝트 적용법
먼저 JWT를 생성/저장/검증하고 생성된 토큰에서 유저 정보를 가져오기 위해 JwtUtil 객체를 만들어야 한다.
JwtUtil 클래스
JwtUtil에서 사용된 메서드들의 역할을 간단히 정리하자면
1. 토큰 생성
public String createToken(String username, UserRoleEnum role) {}
2. 토큰 저장
public void addJwtToCookie(String token, HttpServletResponse res) {}
3. 브라우저에 쿠키로 저장
public void addJwtToCookie(String token, HttpServletResponse res) {}
4. 토큰 전처리 ( 토큰 앞에 있는 "Bearer " 문자열 제거 )
public String substringToken(String tokenValue) {}
5. 토큰 검증
public boolean validateToken(String token) {}
6. 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {}
- JwtUtil 클래스 전체 코드 : 이 후 JWT 인증/인가 필터에서 JwtUtil에 정의된 메서드들을 사용한다.
@Component
public class JwtUtil {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
// JWT 생성
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
}
로그인 로직은 다 필터를 통해서 동작하기에 컨트롤러에서는 회원 가입만 구현하면 된다.
회원가입은 DB의 유저 정보를 조회하고 새로 생성하는 작업이기에 인증이 필요가 없기 때문이다.
JWT과 Spring Security는 인증 및 인가를 위해 Filter를 사용할 수 있다.
필터 사용 시 WebSecurityConfig에 아래와 같이 Spring Security에서 기본적으로 제공하는 기본 Form 인증을 사용할 수도 있지만
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// ... csrf, authorizeHttpRequests 설정 등
http.formLogin(Customizer.withDefaults());
return http.build();
}
커스텀 로그인 페이지를 사용하고, 권한 지정 등의 작업을
아래와 같이 SecurityFilterChain을 커스텀해서 사용할 수 있다.

UserNamePasswordAuthenticationFilter에서는 인증 시도 메서드와
상속한 AbstractAuthenticationProcessingFilter의 인증 성공 후 실행 메서드, 인증 실패 후 실행 메서드 등을 재정의해서 사용할 수 있다. 그리고 AuthenticationManager에 대한 set 메서드도 사용할 수 있다.
사용자가 로그인 시 제출한 username과 password를 UserNamePasswordAuthenticationFilter에서 받아
AuthenticationManager에게 전달해 인증을 시도한다.
성공하면 SecurityContextHolder에 유저 정보를 담고 실패하면 비운다.

이때 유저의 상세정보인 Authentication에는 3가지 요소가 담긴다.
- Principal : username, password를 담은 UserDetails 인스턴스
- Credentials : 비밀번호, 이 값은 인증에 사용한 후 비운다.
- Authorities : 사용자에게 부여된 권한, GrantedAuthority로 추상화하여 사용한다.
이때 UserDetails은 Spring Security가 사용하는 유저 정보를 담은 객체인데,
이를 커스텀해서 UserDetailsImpl로 사용할 수 있다.
서비스 레이어에서 유저를 조회하고 UserDetails로 반환하는 작업을 UserDetailsServiceImpl에서 할 수 있다.
이렇게 얻어진 UserDetails를 사용자의 요청과 함께 컨트롤러로 전달한다.

아래는 UserDetails 객체를 생성하는 UserDetailsService의 요청 흐름이다.

UserDetailsService의 메서드 또한 SecurityChainFilter에 포함되어 적용된다.
UserDetailsService의 메서드는 JwtAuthorizationFilter에서 인증을 처리할 때
인증을 생성하는 과정에서
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
위처럼 UserDetails를 생성하고 인증토큰을 만드는 데 사용된다.
WebSecurityConfig 전체 코드
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
@EnableMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
http.formLogin((formLogin) ->
formLogin
.loginPage("/api/user/login-page").permitAll()
);
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// 접근 불가 페이지
http.exceptionHandling((exceptionHandling)->
exceptionHandling
.accessDeniedPage("/forbidden.html")
);
return http.build();
}
}
그리고 SecurityChainFilter에 사용된 Jwt인증/인가 필터는 다음과 같다.
1. JwtAuthenticationFilter = 로그인 인증 시도, 성공/실패 처리
2. JwtAuthorizationFilter = JWT 유효성 검사 및 인가 처리
JwtAuthenticationFilter에서는
로그인 인증을 시도하고 성공/실패에 따라 JWT토큰을 발행하거나 에러 페이지로 이동시킨다.
import java.io.IOException;
@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
setFilterProcessesUrl("/api/user/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 시도");
try {
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
requestDto.getUsername(),
requestDto.getPassword(),
null
)
);
} catch (IOException e) {
log.error(e.getMessage());
throw new RuntimeException(e.getMessage());
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String token = jwtUtil.createToken(username, role);
jwtUtil.addJwtToCookie(token, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인 실패");
response.setStatus(401);
}
}
JwtAuthorizationFilter에서는 로그인 한 유저 정보를 불러와
JWT의 유효성 검사를 하고 그에 따른 인증 처리를 한다.
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String tokenValue = jwtUtil.getTokenFromRequest(req);
if (StringUtils.hasText(tokenValue)) {
// JWT 토큰 substring
tokenValue = jwtUtil.substringToken(tokenValue);
log.info(tokenValue);
if (!jwtUtil.validateToken(tokenValue)) {
log.error("Token Error");
return;
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
try {
setAuthentication(info.getSubject());
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
filterChain.doFilter(req, res);
}
// 인증 처리
public void setAuthentication(String username) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(username);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
// 인증 객체 생성
private Authentication createAuthentication(String username) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
유저의 권한을 컨트롤러에서 @Secured(권한)로 검사할 수 있다.
예시) 관리자 권한 지정
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자용
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println(userDetails.getUsername());
System.out.println(userDetails.getAuthorities());
}
이때 WebSecurityConfig에 @EnableMethodSecurity(securedEnabled = true)를 설정해줘야 한다.
@Configuration
@EnableWebSecurity // 스프링 Security 지원을 가능하게 함
@EnableMethodSecurity(securedEnabled = true) // @Secured 애너테이션 활성화
public class WebSecurityConfig {}
만약 관리자 권한이 없는 유저가 해당 메서드에 요청 시
접근이 제한된다.
이때 반환되는 페이지를 WebSecurityConfig에서 지정해 줄 수 있다.
// 접근 불가 페이지
http.exceptionHandling((exceptionHandling)->
exceptionHandling
.accessDeniedPage("/forbidden.html")
);
'내배캠' 카테고리의 다른 글
| [내일배움캠프 본캠프] 7일 차 TIL 지연로딩, cascade, scheduler, 헤더 JWT (0) | 2026.02.16 |
|---|---|
| [내일배움캠프 본캠프]6일 차 TIL RestTemplate, Entity 연관 관계 (0) | 2026.02.16 |
| [내일배움캠프 본캠프] 4일 차 TIL Bean 수동 등록, 쿠키/세션 (0) | 2026.02.14 |
| [내일배움캠프 본캠프] 3일 차 TIL 영속성 컨텍스트 (0) | 2026.02.12 |
| [내일배움캠프 본캠프] 2일 차 TIL - SQL, JdbcTemplate, IoC, DI, Bean (0) | 2026.02.11 |
