[Spring] 게시판 실습 : JWT 인증 시스템 완성하기

이지연·2026년 1월 26일

GitHub basic 레포지토리의 b2_board 모듈에서 초기 데이터 로드 → JWT 토큰 생성/검증 → 필터 체인 → Security 설정 → 컨트롤러 적용까지의 인증 시스템 구현 과정을 순서대로 따라가보자.
각 클래스의 역할과 서로 연동되는 워크플로우를 정리한다.


1. 초기 데이터 로드 (InitialDataLoad.java)

사용자가 제공한 실제 레포지토리 코드를 보면, @Component@Transactional을 사용한 CommandLineRunner 구현체로 작동한다.
애플리케이션 시작 시 admin 사용자 더미 데이터를 DB에 넣는 초기화 클래스다. @Transactional로 트랜잭션 안전성을 보장하고, PasswordEncoder로 비밀번호를 해시 처리한다.

@Component
@Transactional
public class InitialDataLoad implements CommandLineRunner {
    private final AuthorRepository authorRepository;
    private final PasswordEncoder passwordEncoder;

    @Autowired
    public InitialDataLoad(AuthorRepository authorRepository, PasswordEncoder passwordEncoder) {
        this.authorRepository = authorRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public void run(String... args) throws Exception {
        // admin 계정이 이미 존재하면 중복 생성 방지
        if (authorRepository.findAllByEmail("admin@naver.com").isPresent()) {
            return;
        }

        // 최초 1회(없을 때만) admin 계정 생성
        // - 비밀번호는 PasswordEncoder로 해시 처리 후 저장(평문 저장 방지)
        authorRepository.save(Author.builder()
                .name("admin")
                .email("admin@naver.com")
                .role(Role.ADMIN)
                .password(passwordEncoder.encode("12341234"))
                .build());
    }
}

왜 필요한가?
로그인 테스트를 위해 DB에 최소 사용자 데이터가 있어야 JWT 발급이 가능하다. 이전 설명과 달리 CommandLineRunner로 애플리케이션 시작 직후 동기 실행되며, findAllByEmail로 중복 체크하고 실제 BCrypt 인코딩을 적용해 보안이 강화됐다.


2. JWT 토큰 생성/검증 (JwtTokenProvider.java)

로그인 성공 시 토큰 발급하고, 이후 요청에서 토큰 검증/사용자 추출을 담당하는 핵심 클래스다.
@Valueapplication.yml에서 secretKey와 만료시간을 주입받고, @PostConstruct로 Base64 디코딩 후 SecretKey 초기화한다.

주요 메서드들

@Component
public class JwtTokenProvider {

    @Value("${jwt.secretKey}")
    private String st_secret_key;

    @Value("${jwt.expiration}")
    private int exp_minuet;

    private Key secret_key;

    @PostConstruct
    public void init(){
        secret_key = new SecretKeySpec(Base64.getDecoder().decode(st_secret_key), SignatureAlgorithm.HS512.getJcaName());
    }

    // 로그인 성공 → 토큰 발급 (Author 객체 기반)
    public String createToken(Author author) {
        Claims claims = Jwts.claims().setSubject(author.getEmail());
        claims.put("role", author.getRole().toString());
        
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp_minuet * 60 * 1000L))
                .signWith(secret_key)
                .compact();
    }
}

핵심:
createToken → 로그인 Controller에서 Author 객체 전달 후 호출 (role 등 사용자 정보 포함). 이전 예시와 달리 Author 전체를 받아 role을 동적으로 넣고, 만료시간은 분 단위 설정값 사용하며 HS512 서명으로 보안 강화됐다. 검증 로직은 필터에서 직접 Jwts 파싱으로 처리.


3. JWT 인증 필터 (JwtTokenFilter.java)

모든 요청 전에 Authorization 헤더에서 토큰을 추출하고 검증하는 필터다.
GenericFilter를 상속하며, JwtTokenProvider 없이 직접 @Value로 secretKey 주입받아 Jwts로 토큰 파싱/검증한다.

@Component // 스프링 빈 등록용 어노테이션
public class JwtTokenFilter extends GenericFilter {

    @Value("${jwt.secretKey}")
    private String st_secret_key;

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        
        try {
            HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
            String bearerToken = httpServletRequest.getHeader("Authorization");
            
            if (bearerToken == null) {
                filterChain.doFilter(servletRequest, servletResponse);
                return;
            }
            
            String token = bearerToken.substring(7);
            
            // 토큰 검증 및 claims 추출
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(st_secret_key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
            
            List<GrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("ROLE_" + claims.get("role")));
            
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
        } catch (Exception e) {
            e.printStackTrace();
        }
        
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

중요 포인트: SecurityContextHolder.setAuthentication()으로 컨트롤러에서 @AuthenticationPrincipal 사용 가능. 이전 설명과 달리 OncePerRequestFilter 대신 GenericFilter 사용하고, Provider 없이 필터 내에서 직접 secretKey로 검증하며 try-catch로 예외 처리 후 체인 진행한다.


4. Spring Security 설정 (SecurityConfig.java)

JwtTokenFilterSpring Security 필터 체인에 등록하고, 인증/인가 규칙을 정의한다.
@EnableMethodSecurity@PreAuthorize 지원하고, CORS/CSRF/세션 등 JWT에 맞게 설정하며 JwtAuthenticationHandler로 401 예외 처리한다.

@Configuration
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위한 설정
public class SecurityConfig {
    private final JwtTokenFilter jwtTokenFilter;
    private final JwtAuthenticationHandler jwtAuthenticationHandler;

    @Autowired
    public SecurityConfig(JwtTokenFilter jwtTokenFilter, JwtAuthenticationHandler jwtAuthenticationHandler) {
        this.jwtTokenFilter = jwtTokenFilter;
        this.jwtAuthenticationHandler = jwtAuthenticationHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .cors(c -> c.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(e -> e.authenticationEntryPoint(jwtAuthenticationHandler))
                .authorizeHttpRequests(a -> a
                        .requestMatchers("/author/create", "/author/login").permitAll()
                        .anyRequest().authenticated())
                .build();
    }

    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000","https://www.jiyean.shop", "http://localhost:5173"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

필터 체인 순서:
JwtTokenFilterUsernamePasswordAuthenticationFilter → Controller.
CORS 프론트 도메인 허용(로컬+프로덕션),
/author/create/author/login만 permitAll,
PasswordEncoder 빈 제공하며 authenticationEntryPoint로 JWT 예외 핸들링.


5. 전역 예외 처리 (CommonExceptionHandler.java)

AuthorizationDeniedException 같은 권한 예외를 잡아서 일관된 JSON 에러 응답을 반환한다.
CommonErrorDto로 status_code와 error_message를 표준화해 403 Forbidden 응답을 처리한다.[1]

@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler {
    
    @ExceptionHandler(AuthorizationDeniedException.class)
    public ResponseEntity<?> authorizationDeniedException(AuthorizationDeniedException e) {
        e.printStackTrace();
        CommonErrorDto dto = CommonErrorDto.builder()
                .status_code(403)
                .error_message(e.getMessage())
                .build();
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(dto);
    }
}

ErrorResponse 예시: { "status_code": 403, "error_message": "접근 권한이 없습니다" }


6. JWT 인증 예외 처리 (JwtAuthenticationHandler.java)

토큰 관련 예외는 401 Unauthorized로 명확히 처리한다.
AuthenticationEntryPoint 구현체로 필터 체인에서 토큰 에러 발생 시 ObjectMapper로 JSON 응답을 직접 스트림에 씀.[1]

@Component
public class JwtAuthenticationHandler implements AuthenticationEntryPoint {
    private final ObjectMapper objectMapper;
    
    @Autowired
    public JwtAuthenticationHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // Header 설정 (401 + JSON)
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        // Body: CommonErrorDto 직렬화
        String data = objectMapper.writeValueAsString(CommonErrorDto.builder()
                .status_code(401)
                .error_message("토큰이 없거나 유효하지 않습니다.")
                .build());
        
        PrintWriter printWriter = response.getWriter();
        printWriter.write(data);
        printWriter.flush();
    }
}

왜 분리했나?
일반 비즈니스 예외와 인증 실패를 HTTP 상태코드로 구분하기 위해. SecurityConfig에서 authenticationEntryPoint(jwtAuthenticationHandler)로 등록해 필터 에러를 JSON으로 처리한다.


7. AuthorController에서 JWT 적용

로그인 API (토큰 발급)

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AuthorLoginDto dto) {
    // 1. authorService.login()에서 이메일/비밀번호 검증 후 Author 반환
    Author author = authorService.login(dto);
    
    // 2. JWT 토큰 발급 (Author 전체 정보 사용)
    String token = jwtTokenProvider.createToken(author);
    
    return ResponseEntity.status(HttpStatus.OK).body(token);
}

관리자 전용 목록 조회 (토큰 + 권한 검증)

@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')")  // ADMIN 권한만 접근 가능
public List<AuthorListDto> findAll() {
    // JwtTokenFilter에서 세팅된 Authentication 자동 사용
    return authorService.findAll();
}

인증 흐름:
필터에서 토큰 검증 → SecurityContextAuthentication 저장 → @PreAuthorize로 권한 체크 → 컨트롤러 실행. 로그인 시 authorService.login()에서 검증 후 토큰 반환, 문자열 직접 body로 간단 응답.


전체 워크플로우 (요청 기준)


1. InitialDataLoad → DB에 admin@naver.com (ADMIN) 생성

2. POST /author/login 요청 (permitAll)
   → Controller.login() → authorService.login() (이메일/비번 검증)
   → jwtTokenProvider.createToken(author) → 200 "eyJhbGciOiJIUzUxMiJ9..."

3. GET /author/list 요청 (Bearer <토큰>)
   → JwtTokenFilter (토큰 파싱 → Authentication 저장)
   → @PreAuthorize("hasRole('ADMIN')") 체크 → Controller.findAll()
   → 200 [사용자 목록]

❌ 토큰 없음/만료 → JwtAuthenticationHandler → 401 JSON
❌ 권한 부족 → CommonExceptionHandler → 403 JSON

정리: 왜 이런 순서로 구현했나?

  1. InitialDataLoad: BCrypt 적용 admin 계정으로 테스트 준비
  2. JwtTokenProvider: Author 기반 role 포함 토큰 발급 (HS512)
  3. JwtTokenFilter: 모든 요청 자동 토큰 검증 + SecurityContext 세팅
  4. SecurityConfig: 필터 등록 + CORS + stateless + @PreAuthorize + 401 핸들러
  5. ExceptionHandler들: 401(토큰)/403(권한) 구분 처리
  6. Controller: service.login() + @PreAuthorize만으로 완성
profile
Eazy하게

0개의 댓글