관련 코드 깃허브
https://github.com/lhoju0158/securityJwt
1. JWT 구조 이해
JWT 웹토큰이 뭐시냐
JSON 객체로 안전하게 어떤 정보를 전송하기 위한 방식
디지털 서명이 되어있어서 신뢰성 보장 가능하다. RSA 혹은 HMAC을 사용한다.
주로 서명의 용도로 사용한다!
구조는
xxxxx.yyyyy.zzzzz
Header - Payload - Signature로 이루어져 있음
- Header
사용한 알고리즘 종류, type이 적혀있다.
{
"alg" : "HS256",
"typ" : "JWT"
}
Based64Url로 인코딩되어 있어서 디코딩 가능하다.
- Payload
엔티티 및 추가 데이터에 대한 설명
클레임은 3가지로 구성
등록 된 클레임, 공개 소유권 주장, 개인 클레임
개인 클레임에 user id같은 프라이머리 키를 넣는다
- Signature
헤더, 페이로드 정보, 키를 HMAC으로 암호화한다.
client가 서버에 아이디, 비밀번호를 보낸다. 서버는 인증이 완료되면 JWT를 만들어준다. client가 로컬 스토리지에 저장한다. 이후 client가 JWT를 서버에게 주면, 서버가 검증을 한다. 검증이 완료되면 payload에 있는 user 정보로 db에서 찾은 후 보내준다.

2. JWT 프로젝트 세팅
새로운 프로젝트를 생성한다.
다음 사이트로 간다. 자바 repository가 있는 사이트다.

JWT 라이브러리를 사용하려 한다. 라이브러리는 JWT를 생성해주는 역할을 한다.
implementation 'com.auth0:java-jwt:4.5.0'
해당 dependency를 build.gradle에 넣고 저장한다. 이후 간단한 controller도 생성한다.
@RestController
public class RestApiController {
@GetMapping("/home")
public String home(){
return "<h1>home</h1>";
}
}
3. JWT를 위한 Security 설정
user model를 만든다.
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String username;
private String password;
private String roles; // USER, ADMIN
public List<String> getRoleList(){
if(this.roles.length()>0){
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
}
@GeneratedValue(strategy = GenerationType.IDENTITY)로 하게 되면 mysql을 쓰고 있으면 auto_increment
-> 특정 DB 벤더에 의존적이라는 뜻, Id가 null일 경우 객체 DB를 auto_increment를 가져와 할당한다.
roles의 경우는 여러개다.
이제 securityConfig를 만들자
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // session을 사용하지 않겠다. (stateless server)
.formLogin(AbstractHttpConfigurer::disable) // jwt 쓸거니깐 formLogin 안함
.httpBasic(AbstractHttpConfigurer::disable) // 기본적인 Http 형식도 사용하지 않는다.
.addFilter(corsFilter) // 인증이 필요한 경우는 filer에 등록을 해야한다.
.authorizeHttpRequests(auth -> auth // role에 따른 접근 권한 주기
.requestMatchers("/api/v1/user/**").hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manager/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
}
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 내 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있게 할 지 설정
config.addAllowedOrigin("*"); // 모든 ip의 응답을 허용하겠다.
config.addAllowedHeader("*"); // 모든 header의 응답을 허용하겠다.
config.addAllowedMethod("*"); // 모든 메소드 허용하겠다
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
설명은 주석과 같다.
이전 security와 다른 점은 다음과 같다.
1. stateless로 사용한다.
2. CrossOrigin 정책에서 벗어나 사용하지 않을 것이다. 모든 요청을 허용한다.
3. formLogin도 사용하지 않는다.
4. JWT Bearer 인증 방식
client가 server에 최초의 로그인 요청을 하면 서버는 session 메모리에 세션 id를 만든다. client가 홍길동이라면, session안에 홍길동을 위한 영역을 할당한다. 홍길동 user object를 저장한 후 세션 id를 return한다. 홍길동은 웹브라우저로 접속을 했고, 웹브라우저 프로그램의 쿠키 영역에 세션 id를 저장한다. 그 이후 요청 시, 쿠키에 저장된 세션 id를 들고 서버에게 요청한다.
해당 방식의 단점: 서버가 여러 개인 경우
서버마다 세션 메모리가 따로 존재한다.
또한 클라이언트가 자바스크립트로 요청하게 되면, AJAX를 사용한다 하면,
쿠키의 기본적인 정책은 동일 도메인에서 요청이 올 때 발동한다.
자바 스크립트가 예시로 210.10.10.5로 요청을 하면 서버는 쿠키를 거부한다. 쿠키는 기본적으로 httpOnly flag를 달고 있다. httpOnly flag는 쿠키가 http에서만 사용되도록 하는 것. 쿠키는 동일출처 정책을 사용하기에, 서버가 이를 허용하면 보안상 문제가 생긴다. 또한 쿠키를 쓰면 서버가 많아질 수록 확정성이 떨어진다 (동일한 세션을 공유하지 않기에)
그래서 header의 Anthoization에 ID와 PW를 담아서 요청한다 이게 HTTP 베이직 방식이다. 이러면 매 요청 마다 ID와 PW를 담아 요청함. 이 경우 쿠키 세션을 만들 필요가 없다. 따라서 확장성이 좋다. 그러나 ID와 PW가 암호화가 안되기에 중간에 노출이 될 수 있다. 그러면 https를 써야 한다. 이러면 암호화해서 날라감.
쓰려는 방식은 Authorization 필드에 토큰을 넣는 방식이다. 토큰은 노출되어도 ID와 PW 자체이진 않기에 위험부담이 적다.
토큰을 달고 요청하는 방식이 Bearer다. (Basic이 아니라)
토근은 유효시간이 존재해서 노출되어도 이후엔 로그인 못한다.
이런 방식을 사용할 예정이기에 기존 Session 방식도 사용하지 않고, 기본 인증 방식도 사용하지 않는다. 그래서 비활성화 시킨 거임.
5. JWT Filter 등록 테스트
필터를 하나 만들어보자.
package com.exam.jwtex01.filter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
System.out.println("MyFilter1 doFilter");
filterChain.doFilter(servletRequest,servletResponse); // 다시 등록을 시켜줘야 한다.
}
}
대충 일단 만들고, SecurityConfig에서 필터를 걸자.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.addFilter(new MyFilter1())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // session을 사용하지 않겠다. (stateless server)
.formLogin(AbstractHttpConfigurer::disable) // jwt 쓸거니깐 formLogin 안함
.httpBasic(AbstractHttpConfigurer::disable) // 기본적인 Http 형식도 사용하지 않는다.
.addFilter(corsFilter) // 인증이 필요한 경우는 filer에 등록을 해야한다.
.authorizeHttpRequests(auth -> auth // role에 따른 접근 권한 주기
.requestMatchers("/api/v1/user/**").hasAnyRole("USER", "MANAGER", "ADMIN")
.requestMatchers("/api/v1/manager/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
이렇게 첫 째줄에 걸면 오류가 생긴다.
The Filter class MyFilter1 does not have a registered order and cannot be added without a specified order.
Consider using addFilterBefore or addFilterAfter instead.
정리를 하면 addFilter를 하지 말고 addFilterBefore, addFilterAfter를 하란 말이다.
왜 발생하냐?
Spring Security는 여러 개의 필터 체인으로 연결해 동작하는 구조라 실행 순서가 중요하다. 근데 걍 순서에 맞춰 넣지 않으니깐 오류가 발생한거다.
그러면 왜 addFilter(corsFilter)는 오류가 안나지?
cors는 Spring contianer의 관리 대상이다. 어노테이션이 붙어 있어서 순서가 이미 결정된 인스턴스다. 근데 Spring Security는 Spring container의 관리 대상이 아니기에, 순서를 개발자가 넣어야 한다.
https://lhoju0158.tistory.com/60
[Spring] Filter vs Interceptor의 차이
면접에서 받은 기술 질문이다. 그땐 대답을 못했다.. 똥멍텅구리 녀석. 그래서 알기 위해 적어본다. + JWT 사용을 원할히 하기 위해. 1. 필터 (Filter)Dispatcher Servlet에 요청이 전달되기 전/후에 url 패
lhoju0158.tistory.com
관리 대상은 이전 글을 참고해주세염.
어쨌든 돌아와서.. 다시 순서에 맞게 add 하자.
.addFilterBefore(new MyFilter1(),BasicAuthenticationFilter.class)
BasicAuthenticationFilter전에 붙인다는 뜻.
근데 이러면 복잡하니깐 그냥 FilterConfig를 따로 만들자. addFilterBefore 라인 지우기
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<MyFilter1> filter1(){
FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
bean.addUrlPatterns("/*");
bean.setOrder(0); // 우선순위 = 낮을 수록 높음
return bean;
}
@Bean
public FilterRegistrationBean<MyFilter2> filter2(){
FilterRegistrationBean<MyFilter2> bean = new FilterRegistrationBean<>(new MyFilter2());
bean.addUrlPatterns("/*");
bean.setOrder(1);
return bean;
}
}
이렇게 여러 필터를 적용할 수 있다. 기본적으로 security filter가 우선으로 실행되고, 이후 커스텀 한 필터가 실행된다. 따라서 security filter보다 먼저 실행하고 싶으면 SecurityFilterChain에 등록하거나, 필터 우선순위를 찾아서 알맞게 변경하면 된다.

Spring filter 순서 참고하세염.
6. JWT 임시 토큰 만들어서 TEST
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest; // 다운케스팅
HttpServletResponse res = (HttpServletResponse) servletResponse;
if(req.getMethod().equals("POST")){
String headerAuth = req.getHeader("Authorization");
System.out.println(headerAuth);
}
System.out.println("MyFilter1 doFilter");
filterChain.doFilter(req,res); // 다시 등록을 시켜줘야 한다.
}
}
MyFilter1을 다음과 같이 변경하자. 이후 서버에 요청을 postman을 이용해서 보내자.

이런 식으로 hello를 요청하면 console 창에

hello가 찍힌다.
아직 토큰을 만들진 않았지만, 만들었다 가정하자. 토큰이 유효하면 인증이 되고, 아니면 필터를 못타서 컨트롤러에 접근하지 못하게 해보자.
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest; // 다운케스팅
HttpServletResponse res = (HttpServletResponse) servletResponse;
if(req.getMethod().equals("POST")){
System.out.println("POST 요청됨");
String headerAuth = req.getHeader("Authorization");
System.out.println(headerAuth);
System.out.println("Filter1");
if(headerAuth.equals("cos")) {
filterChain.doFilter(req, res);
} else{
PrintWriter out = res.getWriter();
out.println("인증 안됨");
}
}
}
이렇게 변경 후 POST를 날리면


이렇게 header 값에 따라서 다르게 결과를 보냄을 알 수 있다. 이 필터는 security 필터가 돌기 전에 수행돼야 한다. 따라서 SecurityConfig에 Filter3를 만들어서 추가하자.
이제 토큰을 생성해야한다. ID와 PW가 정상적으로 들어와서 로그인 완료되면 토큰을 만들어주고, 그걸 응답해준다.
이후 요청할 때 마다 header에 Authorization에 value값으로 토큰을 가지고 온다. 그때 토큰이 넘어오면, 이 토큰이 내가 만든 토큰이 맞는지만 검증하면 됨 (RSA, HS256)
7. JWT를 위한 로그인 시도
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
User userEntity = userRepository.findByUsername(username);
return new PrincipalDetails(userEntity);
}
}
이렇게 서비스를 하나 만든다. 해당 서비스는 http://localhost:8080/login으로 요청올 때 동작한다.
postman으로 test를 해보면 에러가 뜬다. 왜냐면 초기에 formLogin을 disable했기 때문.
그러면 필터로 직접 보내줘야한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
return super.attemptAuthentication(request,response);
}
}
작성 후 chian에 등록한다.
기본적으로 login으로 ID, PW를 보내면, UsernamePasswordAuthenticationFilter가 낚아채서, 함수를 자동 실행한다.
이제 해야하는 것,
1. username, password를 받아서
2. 정상인지 로그인 시도 -> AuthenticationManager로 로그인 시도를 하면, PrincipalDetailsService가 호출된다. 이후 loadUserByUsername이 자동으로 실행된다.
3. loadUserByUsername이 return이 되면, PrincipalDetails를 session에 담고,
4. JWT 토큰을 만들어서 응답하면 된다.
여기서 굳이 PrincipalDetails를 session에 담는 이유: 권한 관리가 안된다. (권한 관리 안할거면 굳이 담을 필욘 없음)
8. JWT를 위한 강제 로그인 진행
이제 ID와 PW를 받자.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("로그인 시도");
try{
BufferedReader br = request.getReader();
String input = null;
while( (input = br.readLine()) != null ) {
System.out.println(input);
}
System.out.println("--------------");
}catch (IOException e){
e.printStackTrace();
}
return super.attemptAuthentication(request,response);
}
이렇게 System.out으로 request의 값을 확인할 수 있다. 이제 얘를 parsing 하면 된다. 일단 요청은 JSON으로 온다 가정하자.
try{
ObjectMapper mapper = new ObjectMapper(); // JSON 데이터 파싱해준다.
User user = mapper.readValue(request.getInputStream(), User.class);
}catch (IOException e){
e.printStackTrace();
}
이러면 자동으로 UserObject에 파싱해서 데이터를 담아준다.
로그인을 하기 위해선 토큰을 직접 만들어야 한다.
// /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("로그인 시도");
try{
ObjectMapper mapper = new ObjectMapper(); // JSON 데이터 파싱해준다.
User user = mapper.readValue(request.getInputStream(), User.class);
// 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
// 로그인 시도
// 밑 코드가 실행될 때 PrincipalDetailsService의 loadUserByUsername 함수가 실행됨
// DB에 있는 username과 password가 일치한다는 뜻
Authentication auth = authenticationManager.authenticate(authenticationToken);
// auth에는 로그인 정보가 담긴다.
PrincipalDetails principalDetails = (PrincipalDetails) auth.getPrincipal();
System.out.println("principalDetails = " + principalDetails.getUser().getUsername());
return auth; // session에 저장됨
}catch (IOException e){
e.printStackTrace();
}
return null;
}
이렇게 변경된다.
굳이 JWT 토큰을 사용하면서 session을 만들 이유는 없다. 그러나 굳이 session에 authentication을 저장하는 이유는 권한처리 때문이다
attemptAuthentication뒤에 successfulAuthentication이 실행된다. (인증이 정상적으로 되었으면)
ㅊ에서 JWT를 만들어서 사용자에 응답해주면 된다.
9. JWT 토큰 만들어서 응답하기
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
// Hash 방식 (RAS는 아님)
String jwtToken = JWT.create()
.withSubject("cosToken") // 큰 의미 없음
.withExpiresAt(new Date(System.currentTimeMillis()+JwtProperties.EXPIRATION_TIME)) // token 만료시간
.withClaim("id", principalDetails.getUser().getId())
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
response.addHeader("Authorization", "Bearer " + jwtToken); // 무조건 Bearer 뒤에 한 칸 띄우기
}
이렇게 successfulAuthentication을 변경하고 다시 요청한다.

에러가 더 이상 발생하지 않는다.

headers에도 이렇게 잘 들어간다.
유저 네임, 페스워드 로그인이 정상이라면, 서버에선 세션 ID를 생성해서 Client에게 이걸 쿠키로 전달한다.
요청할 때 마다 쿠키값 세션 ID를 항상 들고 서버에 요청을 하기 때문에 서버는 해당 ID의 유효성을 검증해야한다.
유효하면 인증 필요 페이지로 접근 할 수 있다.
지금 방식은 정상이면, 세션 ID도 안만들고 쿠키에 저장도 안한다. JWT 토큰을 생성하고 Client에게 이걸 응답한다. 이후 서버는 JWT가 유효한지 판단을 필터를 통해서 한다 (이제 이거 해보자)
이렇게 하는 이유는 stateless 서버를 하기 위함!
10. JWT 토큰 서버 구축 완료
Security가 filter를 가지고 있는데, 그 중 BasicAuthenticationFilter는 권한, 인증이 필요한 경우 사용된다.
extends를 해서 class를 하나 생성하자
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
public JwtAuthorizationFilter(AuthenticationManager authManager) {
super(authManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// JWT 토큰 검증과정을 거쳐서 정상적인 사용자인지 확인이 필요함
super.doFilterInternal(request, response, chain);
}
}
이후 JWT 토큰 검증과정을 거쳐서 정상적인 사용자인지 확인한다. (이전에 SecurityConfig에 등록도 해야 한다)
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// JWT 토큰 검증과정을 거쳐서 정상적인 사용자인지 확인이 필요함
String jwtHeader = request.getHeader("Authorization");
//header가 있는지 확인
if (jwtHeader == null || !jwtHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String jwtToken = request.getHeader("Authorization").replace(JwtProperties.TOKEN_PREFIX, "");
String username =
JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwtToken).getClaim("username").asString();
if(username!=null){
// 서명이 정상적으로 불러와짐
User user = userRepository.findByUsername(username); // 사용자 불러오기
PrincipalDetails principalDetails = new PrincipalDetails(user);
// authentication 객체를 강제로 만든다.
// username이 null이 아니기 때문에 만들 수 있음
// JWT 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails,null,principalDetails.getAuthorities());
// 강제로 Security session에 접근해서 Authentication 객체를저
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
자세한 과정은 주석 참고
이제 요청을 하면 404 에러가 뜬다. (이건 권한 문제라서 당연)

이제 권한에 맞게 api를 분리하고 요청하면 정상적으로 된다!
생각보다 길었던.. 시큐리티 + JWT 끄읕!
'개발 끄적끄적' 카테고리의 다른 글
| [Network + Spring] URL 관련 (0) | 2025.08.19 |
|---|---|
| [Spring] cookie, token, session (0) | 2025.08.08 |
| [Spring] Filter vs Interceptor (2) | 2025.08.04 |
| [Spring] Security - 웹 보안 이해 (1) | 2025.08.03 |
| [Spring] Security - OAuth 2.0 (3) | 2025.08.01 |