[GooJakgyo] Spring Security + JWT 인증

이재·2025년 10월 30일
post-thumbnail

🔐 Spring Security + JWT 인증 흐름 이해하기

GooJakgyo 프로젝트에 보안 기능을 강화하기 위해 Spring Security와 JWT 기반 인증 방식을 적용했다.
처음 시도해보는 구조였기 때문에 요청 흐름의 변화와 인증 처리 방식에 대해 명확히 이해하는 과정이 필요했다.
이번 글은 이를 정리한 개발 기록이다.

📌 요청 흐름의 변화 : 필터 기반 인증

기존에는 클라이언트의 요청이 바로 Controller로 전달되는 단순한 구조였다.
하지만 Spring Security가 추가되면 모든 요청은 Security Filter 를 반드시 거친 후에 Controller로 전달된다.

즉, 컨트롤러의 로직이 동작하기 전에
“이 요청을 처리할 자격이 있는 사용자인가?”를 먼저 판단하게 된다.

이 구조를 이해하지 못한 상태에서 개발을 진행하면
컨트롤러까지 도달하지 않는 요청 때문에 원인을 찾기 어려운 상황이 발생한다.

🔑 Authentication 객체의 중요성

Spring Security에서 인증 여부를 판단하는 핵심 기준은
SecurityContext에 Authentication 객체가 존재하는가이다.

Authentication 객체에는 다음과 같은 정보가 포함된다.

  • 사용자 식별 정보(이메일, ID 등)
  • 사용자 권한(멘토/멘티/관리자 등)
  • 인증 수단(JWT Token 등)

로그인 성공 시 이 객체가 생성되어 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 기반 인증 처리의 핵심

로그인 성공 시 서버는 JWT Access Token을 발급한다.
이후 클라이언트는 매 요청마다 이 토큰을 요청 헤더에 포함시킨다.

Security Filter는 요청을 처리할 때 아래 단계를 수행한다.

  1. 요청 URL이 인증이 필요한 API인지 확인
  2. 헤더에서 JWT Token 추출
  3. 서명 및 만료 기준으로 Token 유효성 검사
  4. Token에 있는 사용자 정보 기반으로 Authentication 객체 생성
  5. SecurityContext에 저장하여 컨트롤러 접근 허용

인증은 필터에서 수행되고
컨트롤러는 인증이 된 사용자를 대상으로 로직만 수행한다.

🤔 JWT?

JWT(Json Web Token)는 인증 정보를 JSON 형태로 담아 서버와 클라이언트가 주고받을 수 있는 토큰 기반 인증 방식이다.

어떤 암호화 알고리즘과 토큰을 사용할 것 인지에 대한 정보

Payload

전달하려는 정보(Claim)가 들어있다.
Payload는 수정이 가능하고 많은 정보를 추가할 수 있다.
하지만 노출이 될 수 있기 때문에 중요하지 않은 최소한의 정보만을 담아야 한다.

Signature

Header와 Payload를 합친 후 서버가 지정한 secret key로 암호화 시켜 토큰을 변조할수 없게 만든다.
Header와 Payload는 단순 인코딩 값이어서 제 3자가 복호화나 조작할 수 있지만
Signature는 서버에서 관리하는 secret key가 유출되는 것이 아니면 복호화할 수 없다.
그래서 Signature는 Token의 위조, 변조 여부 확인에 사용한다.

🔄 JWT 동작 과정

  1. 사용자 로그인
  2. Server에서 secret key와 Hearder, Payload로 Signature를 만들고 조립한 JWT 발급
  3. JWT 토큰 client에 전달
  4. client에서 API를 요청할 때, 인증 Header에 Token을 담아 보냄
  5. Server는 JWT Signature를 확인하고 Payload로부터 사용자 정보를 확인해서 data 반환

☕ SecurityConfig - 인증 적용 기준과 요청 흐름 제어

@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 메서드에서는 아래와 같이 설정을 수행한다.

  • CORS 허용
    • Frontend와 통신을 위해 설정 필요
  • CSRF 비활성화
    • 세션 기반이 아니므로 Stateless하기 때문에 불필요
  • HTTP Basic 비활성화
  • Stateless 방식의 인증 유지
    • Session 없이 매 요청마다 토큰 기반 인증 수행
  • permitAll() URL 설정
    • 로그인, 회원가입 등 인증 없이 접근 가능 경로 명시
  • Filter Chain에 Custom Filter 추가
    - JWT 검증을 수행하는 JwtAuthFilter를 Chain 앞단에 배치

    “JWT 인증이 적용되어야 할 요청은 필터에서 토큰 검증을 거쳐
    인증이 필요한 컨트롤러로 전달된다.”

☕ JwtTokenProvider - JWT 생성 및 Signature 처리

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 인증이 가능해지며 확장성 면에서 유리하다.

☕ JwtAuthFilter - JWT 검증 및 Authentication 생성

@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이 유효하면 다음 과정을 수행한다.

  1. Bearer 스키마 형태인지 검증
  2. JWT 파싱을 통한 Token 검증 및 Claims 추출
  3. Claims 내 role 기반으로 GrantedAuthority 생성
  4. UserDetails 객체 구성
  5. Authentication 객체 생성
  6. SecurityContestHolder에 설정

    이 과정에서 Security는
    “컨트롤러 접근 가능 여부를 판단할 수 있는 상태”가 된다.

    Token이 비정상적이거나 유효하지 않으면 401 Unauthorized 로 예외 처리한다.

🎯 마무리

Spring Security와 JWT 인증 방식을 직접 적용해보면서 가장 크게 느낀 점은,
보안 기능은 단순히 “추가 기능”이 아니라 서비스의 안정성과 신뢰를 뒷받침하는 핵심 기반이라는 점이었다.
처음에는 요청이 필터에서 막히는 문제나 Authentication 객체가 생성되지 않는 상황을 해결하는 데에도 많은 시간이 필요했지만, 구조 자체를 이해하게 되자 프레임워크가 제공하는 장점을 자연스럽게 체감할 수 있었다.
이번 구현을 통해 “인증”이란 기능이 시스템 전반에 어떤 방식으로 영향을 끼치는지 깊이 있게 이해할 수 있었고, 앞으로 서비스가 확장되더라도 보안 측면에서 보다 안정적인 구조를 유지할 수 있는 기반을 마련했다고 생각한다.

profile
고민을 좋아하는 개발자

0개의 댓글