
GooJakgyo 프로젝트에 보안 기능을 강화하기 위해 Spring Security와 JWT 기반 인증 방식을 적용했다.
처음 시도해보는 구조였기 때문에 요청 흐름의 변화와 인증 처리 방식에 대해 명확히 이해하는 과정이 필요했다.
이번 글은 이를 정리한 개발 기록이다.
기존에는 클라이언트의 요청이 바로 Controller로 전달되는 단순한 구조였다.
하지만 Spring Security가 추가되면 모든 요청은 Security Filter 를 반드시 거친 후에 Controller로 전달된다.
즉, 컨트롤러의 로직이 동작하기 전에
“이 요청을 처리할 자격이 있는 사용자인가?”를 먼저 판단하게 된다.
이 구조를 이해하지 못한 상태에서 개발을 진행하면
컨트롤러까지 도달하지 않는 요청 때문에 원인을 찾기 어려운 상황이 발생한다.
Spring Security에서 인증 여부를 판단하는 핵심 기준은
SecurityContext에 Authentication 객체가 존재하는가이다.
Authentication 객체에는 다음과 같은 정보가 포함된다.
로그인 성공 시 이 객체가 생성되어 SecurityContext에 저장된다.
이후 인증이 필요한 요청은 이 객체를 기준으로 접근 권한을 부여받는다.
인증 과정 없이 특정 API를 호출하면 Security Filter 단계에서 요청이 차단된다.
이 때문에 로그인과 회원가입 API처럼 초기 접근이 필요한 요청에 대해서는
Security 설정에서 인증 없이 접근할 수 있도록 명시적으로 허용해야 한다.
.authorizeHttpRequests(a -> a.requestMatchers("/member/create", "/member/doLogin",
"/member/google/doLogin", "/member/kakao/doLogin", "/member/naver/doLogin", "/member/oauth/create", "/member/reissue", "/connect/**").permitAll().anyRequest().authenticated())
이 설정이 이루어지지 않으면 인증이 불가능한 단계에서 요청이 무조건 거절된다.
로그인 성공 시 서버는 JWT Access Token을 발급한다.
이후 클라이언트는 매 요청마다 이 토큰을 요청 헤더에 포함시킨다.
Security Filter는 요청을 처리할 때 아래 단계를 수행한다.
인증은 필터에서 수행되고
컨트롤러는 인증이 된 사용자를 대상으로 로직만 수행한다.
JWT(Json Web Token)는 인증 정보를 JSON 형태로 담아 서버와 클라이언트가 주고받을 수 있는 토큰 기반 인증 방식이다.
어떤 암호화 알고리즘과 토큰을 사용할 것 인지에 대한 정보
전달하려는 정보(Claim)가 들어있다.
Payload는 수정이 가능하고 많은 정보를 추가할 수 있다.
하지만 노출이 될 수 있기 때문에 중요하지 않은 최소한의 정보만을 담아야 한다.
Header와 Payload를 합친 후 서버가 지정한 secret key로 암호화 시켜 토큰을 변조할수 없게 만든다.
Header와 Payload는 단순 인코딩 값이어서 제 3자가 복호화나 조작할 수 있지만
Signature는 서버에서 관리하는 secret key가 유출되는 것이 아니면 복호화할 수 없다.
그래서 Signature는 Token의 위조, 변조 여부 확인에 사용한다.

@Bean
public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception{
return httpSecurity
.cors(cors->cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)//csrf 비활성화
.httpBasic(AbstractHttpConfigurer::disable) //HTTP Basic 비활성화
.sessionManagement(s->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //세션방식을 사용하지 않겠다라는 의미
// 특정 url패턴에 대해서는 Authentication객체 요구하지 않음.(인증처리 제외)
.authorizeHttpRequests(a -> a.requestMatchers("/member/create", "/member/doLogin",
"/member/google/doLogin", "/member/kakao/doLogin", "/member/naver/doLogin", "/member/oauth/create", "/member/reissue", "/connect/**").permitAll().anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("*")); // 모든 HTTP메서드 허용
configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 헤더값 허용
configuration.setAllowCredentials(true); // 자격증명 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 url에 패턴에 대해 cors 허용 설정
return source;
}
Spring Security 설정의 핵심은
어떤 요청을 인증 없이 통과시킬 것인지, 어떤 요청은 인증을 요구할 것인지 정의하는 작업이다.
SecurityFilterChain 메서드에서는 아래와 같이 설정을 수행한다.
“JWT 인증이 적용되어야 할 요청은 필터에서 토큰 검증을 거쳐
인증이 필요한 컨트롤러로 전달된다.”
public String createToken(String email, String role, int expiration) {
Claims claims = Jwts.claims().setSubject(email);
if (role!= null) claims.put("role", role);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration * 60 * 1000L))
.signWith(SECRET_KEY)
.compact();
return token;
}
JWT 생성 역할을 담당한다.
“JWT 자체가 사용자의 인증 정보를 직접 들고 다니므로
서버는 추가 저장소 없이 인증 상태를 확인할 수 있다.”
이 덕분에 Server는 Stateless 인증이 가능해지며 확장성 면에서 유리하다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
String token = httpServletRequest.getHeader("Authorization");
try{
if (token != null) {
if (!token.substring(0, 7).equals("Bearer ")) {
throw new AuthenticationServiceException("Bearer 형식이 아닙니다.");
}
String jwtToken = token.substring(7);
// 토큰 검증 및 claims 추출
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(jwtToken)
.getBody();
// Authentication 객체 생성
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));
UserDetails userDetails = new User(claims.getSubject(), "", authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, jwtToken, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
} catch (Exception e){
e.printStackTrace();
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().write("invalid token");
}
}
사용자의 request header에서 Authorization 값을 추출하고 Token이 유효하면 다음 과정을 수행한다.
Token이 비정상적이거나 유효하지 않으면 401 Unauthorized 로 예외 처리한다.이 과정에서 Security는
“컨트롤러 접근 가능 여부를 판단할 수 있는 상태”가 된다.
Spring Security와 JWT 인증 방식을 직접 적용해보면서 가장 크게 느낀 점은,
보안 기능은 단순히 “추가 기능”이 아니라 서비스의 안정성과 신뢰를 뒷받침하는 핵심 기반이라는 점이었다.
처음에는 요청이 필터에서 막히는 문제나 Authentication 객체가 생성되지 않는 상황을 해결하는 데에도 많은 시간이 필요했지만, 구조 자체를 이해하게 되자 프레임워크가 제공하는 장점을 자연스럽게 체감할 수 있었다.
이번 구현을 통해 “인증”이란 기능이 시스템 전반에 어떤 방식으로 영향을 끼치는지 깊이 있게 이해할 수 있었고, 앞으로 서비스가 확장되더라도 보안 측면에서 보다 안정적인 구조를 유지할 수 있는 기반을 마련했다고 생각한다.