스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초

스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 3편 [책의 마지막 부분]

개발학생 2026. 4. 25. 18:51
반응형

*책 내용과 다르게, 다음과 같은 환경에서 프로젝트 생성  

 

[Java] 차근차근 Java 설치하기 (JDK17, Window 11)

자바 개발 도구 설치 방법에 대해서 알아보겠습니다. Java17은 LTS(Long Term Support : 장기 지원) 릴리즈로 1년 후까지 기술 지원 및 버그를 개선한 서비스를 제공받을 수 있습니다. 업데이트 버전을 꾸

yungenie.tistory.com

 

[Windows] Spring Tool Suite 4(STS 4) 다운로드 및 설치

STS란?Spring Tool Suite(STS)는 스프링 프로젝트를 생성하고, 개발할 수 있게 해주는 도구입니다. STS 설치 과정에 대해 설명드리겠습니다. 설치 파일 다운로드STS 공식 사이트에서 설치 파일을 다운로

priming.tistory.com

 

MySQL :: Download MySQL Community Server

Select Version: 9.3.0 Innovation 8.4.5 LTS 8.0.42 Select Operating System: Select Operating System… Microsoft Windows Ubuntu Linux Debian Linux SUSE Linux Enterprise Server Red Hat Enterprise Linux / Oracle Linux Fedora Linux - Generic Oracle Solaris mac

dev.mysql.com

  • Gradle

**STS에서 Gradle 프로젝트 생성한 과정 

*** 함께 보면 좋은 글

 

2025.07.10 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링 부트 핵심 가이드(장정우 지음) - 스프링 부트 개요

 

스프링 부트 핵심 가이드(장정우 지음) - 스프링 부트 개요

1. 스프링 프레임워크자바(Java) 기반 애플리케이션 프레임워크로, 엔터프라이즈급(기업 환경 대상 개발) 애플리케이션을 위한 다양한 기능 제공-> 오픈소스 경량급 애플리케이션 프레임워크로

keep-programming-study.tistory.com

2025.07.11 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링 부트 핵심 가이드(장정우 지음) - 개발에 앞서 알면 좋은 기초 지식

 

스프링 부트 핵심 가이드(장정우 지음) - 개발에 앞서 알면 좋은 기초 지식

1. 서버 간 통신마이크로서비스 아키텍처에서 한 서버가 다른 서버에 통신을 요청하는 것을 의미-> 한 대는 서버/다른 한 대는 클라이언트가 됨 가장 많이 사용되는 방식은 HTTP/HTTPS 방식(TCP/IP, SOA

keep-programming-study.tistory.com

2026.04.10 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 1편

 

스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 1편

*책 내용과 다르게, 다음과 같은 환경에서 프로젝트 생성 Windows11(윈도우 11) 환경자바 JDK 17 버전 설치 https://yungenie.tistory.com/11 [Java] 차근차근 Java 설치하기 (JDK17, Window 11)자바 개발 도구 설치 방법

keep-programming-study.tistory.com

2026.04.16 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 2편

 

스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 2편

*** 함께 보면 좋은 글2026.04.10 - [스프링(Spring), 스프링부트(SpringBoot)/스프링부트(SpringBoot) 기초] - 스프링부트 핵심 가이드(장정우 지음) - 스프링 시큐리티(Spring Security)와 JWT 1편 스프링부트 핵심

keep-programming-study.tistory.com

 

8) 스프링 시큐리티 테스트

  • '스프링 시큐리티(Spring Security)와 JWT' 1편과 2편에서 작성한 코드들을 바탕으로,
    클라이언트 입장에서 스프링 시큐리티가 동작하는 상황에서 테스트 수행
  • Swagger 활용하여 테스트
    : WebSecurity를 사용하는 configure() 메서드에서 인증에 대한 예외 처리를 했으므로 정상 접속 가능

(1) 애플리케이션 가동 시(서버 시작 시), 스프링 시큐리티 관련 로그 살펴보기 

  • JwtTokenProvider 클래스는 @Component로 등록되어 있고, @PostConstruct로 init() 메서드가 정의되어 있음
  • init() 메서드에서는 application.properties 파일에 정의된 secretKey의 값을 가져와 인코딩
    -> 기본값인 "secretKey"를 그대로 사용하면 보안에 취약하므로, application.properties 파일에 꼭 다음과 같이 'https://randomkeygen.com/'등에서 생성한 키를 입력
    • 이때 application.properties의 변수명을 꼭 JwtTokenProvider와 대조해보자..
      본인은 @Value("${springboot.jwt.secret}")@Value("${springbot.jwt.secret}")로 해서 오류가 났다.

  • 아래는 JwtTokenProvider 관련 로그이다(실제 운영 환경이라면 비밀번호(security password)를 절대로 올리면 안된다!!)
2026-04-17 16:40:02.870 [main] INFO  c.e.d.c.security.JwtTokenProvider - [init] JwtTokenProvider 내 secretKey 초기화 시작
2026-04-17 16:40:02.881 [main] INFO  c.e.d.c.security.JwtTokenProvider - [init] JwtTokenProvider 내 secretKey 초기화 완료
2026-04-17 16:40:03.725 [main] WARN  o.s.b.a.o.j.JpaBaseConfiguration$JpaWebConfiguration - spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2026-04-17 16:40:04.978 [main] INFO  o.s.b.a.e.web.EndpointLinksResolver - Exposing 14 endpoints beneath base path '/actuator'
2026-04-17 16:40:05.106 [main] WARN  o.s.b.a.s.s.UserDetailsServiceAutoConfiguration - 

Using generated security password: 2376ca77-a5da-4db4-8cf9-f71ca896045f

This generated password is for development use only. Your security configuration must be updated before running your application in production.

 

  • DefaultSecurityFilterChain 관련 로그:
    Spring Boot 3.x + Spring Security 6.x부터는 로깅 정책이 바뀌어 디버그 레벨에서만 확인 가능
    • 이 프로젝트에서는 application.properties에 logging.level.org.springframework.security=DEBUG를 추가하면 출력됨
      ->
      Spring Security 로그를 DEBUG 레벨로 출력하는 것 

(2) Hibernate 오류 해결

    • H2 데이터베이스에서 USER는 예약어(reserved keyword)
      -> 따라서 references user라고 쓰면 SQL 파서가 식별자(identifier)가 아닌 예약어로 인식해서 문법 오류가 발생
      • Hibernate가 user_roles 테이블에 외래 키를 만들면서 user 테이블을 참조하려고 했는데, 예약어 충돌 때문에 실패
  • 엔티티 클래스에서 @Table(name="users")로, 테이블 이름을 예약어가 아닌 이름으로 바꾸기
@Entity
@Table(name = "users") // user 대신 users
public class User implements UserDetails { 
  ... 
}

 

(3) JwtAuthenticationFilter에서, 특정 경로는 JWT 검증을 건너뛰도록 조건을 추가

  • http://localhost:8080/swagger-ui/index.html에 접속했을 때, 아래와 같은 시큐리티 로그인 화면이 뜨지 않도록 만들어야 함

package com.example.demo.config.security;

import java.io.IOException;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * JWT 인증을 처리하는 커스텀 필터 클래스
 * 
 * OncePerRequestFilter를 상속하여 모든 요청마다 한 번만 실행됨.
 *
 * 주요 역할:
 * 1. HTTP 요청에서 JWT 토큰 추출 (resolveToken)
 * 2. 토큰 유효성 검증 (validateToken)
 * 3. 토큰이 유효하다면 Authentication 객체 생성 후 SecurityContext에 저장
 *    - SecurityContextHolder는 현재 요청의 인증 상태를 관리하는 Spring Security의 핵심 컨텍스트
 * 4. 필터 체인을 계속 진행하여 이후 요청 처리 로직으로 전달
 * ++ Swagger UI, OpenAPI 문서 등 공개 엔드포인트는 필터를 건너뛰도록 예외 처리 추가
 *
 * 즉, 이 필터는 클라이언트 요청이 들어올 때마다 JWT 기반 인증을 수행하고,
 * 인증된 사용자 정보를 Spring Security 컨텍스트에 주입하는 역할을 담당
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    // JWT 검증을 건너뛸 경로들
    private static final List<String> EXCLUDE_URLS = List.of(
        "/swagger-ui",
        "/swagger-ui.html",
        "/v3/api-docs",
        "/api/public"
    );
    
    /**
     * 실제 필터 로직 구현 메서드
     *
     * 동작 흐름:
     * 1. 요청(servletRequest)에서 JWT 토큰 추출
     * 2. 토큰 값 로그 출력
     * 3. 토큰 유효성 검증 시작
     * 4. 토큰이 존재하고 유효하다면:
     *    - JwtTokenProvider.getAuthentication(token) 호출 → Authentication 객체 생성
     *    - SecurityContextHolder.getContext().setAuthentication(authentication) → 인증 정보 저장
     *    - 로그 출력: 유효성 체크 완료
     * 5. 필터 체인 계속 진행 (filterChain.doFilter)
     *
     * @param servletRequest  클라이언트 요청 객체
     * @param servletResponse 클라이언트 응답 객체
     * @param filterChain     필터 체인 객체
     */
    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
                                    HttpServletResponse servletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
    	
    	// Swagger, 공개 API는 JWT 검증 건너뛰기
    	String path = servletRequest.getRequestURI();
    	
        if (EXCLUDE_URLS.stream().anyMatch(path::startsWith)) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        
        // 요청에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken(servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token: {}", token);
        
        // 토큰 유효성 검증
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 Authentication 객체 생성 후 SecurityContext에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        
        // 필터 체인 계속 진행
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

(4) build.gradle에서 해당 코드를 지운 후, 파일 우클릭 -> Gradle -> Refresh Gradle Project 클릭 

  • springdoc-openapi-starter-webmvc-ui:2.5.0은 내부적으로 swagger-annotations 최신 버전을 기대하는데,
    swagger-annotations:2.2.15가 충돌을 일으켜서 springdoc이 기대하는 메서드가 없는 구버전 클래스가 로딩되어 java.lang.NoSuchMethodError: 'java.lang.Class[] io.swagger.v3.oas.annotations.Parameter.validationGroups()' 오류 발생 
implementation 'io.swagger.core.v3:swagger-annotations:2.2.20'  // Swagger(OpenAPI)의 어노테이션들을 직접 사용할 수 있게 해주는 라이브러리

(4) 정상 동작 시나리오 기반 테스트 수행 - http://localhost:8080/swagger-ui/index.html에 접속하여 진행 

  • 절차: 회원가입 성공 -> 회원가입 성공 계정 정보를 기반으로 로그인 성공 -> 로그인 성공으로 토큰 발급 -> 상품 컨트롤러의 상품 등록 API 호출 -> API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가해서 전달 -> 상품 등록 완료

1. 회원가입

  • http://localhost:8080/swagger-ui/index.html#/sign-controller/signUp 부분에서 'Try it out' 클릭 후,
    각 칸에 다음과 같이 입력하고 'Execute' 클릭

  • 정상적인 회원가입 완료 화면 출력

 

2. 로그인 

  • http://localhost:8080/swagger-ui/index.html#/sign-controller/signIn에서 'Try it out' 클릭 후,
    각 칸에 다음과 같이 입력하고 'Execute' 클릭

  • 정상적인 로그인 성공 화면 + 발급된 jwt 토큰 값 출력

3. 상품 등록 및 조회

  • SwaggerConfig 필요
package com.example.demo.config;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityScheme;

@OpenAPIDefinition(info = @Info(title = "Demo API", version = "v1"))
@SecurityScheme(
    name = "X-AUTH-TOKEN",              // Swagger UI에서 표시될 이름
    type = SecuritySchemeType.APIKEY,   // API Key 방식
    in = io.swagger.v3.oas.annotations.enums.SecuritySchemeIn.HEADER, // 헤더에서 받음
    paramName = "X-AUTH-TOKEN"          // 실제 헤더 이름
)
public class SwaggerConfig {
}
  • 이전에 만들었던 ProductController를 살짝 수정하고, Product와 관련 DTO들은 그대로 사용
// ProductController.java
package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import com.example.demo.jpa.data.dto.ChangeProductNameDTO;
import com.example.demo.jpa.data.dto.ProductDTO;
import com.example.demo.jpa.data.dto.ProductResponseDTO;
import com.example.demo.service.ProductService;

import io.swagger.v3.oas.annotations.security.SecurityRequirement;

/**
 * 상품 관련 HTTP 요청을 처리하는 REST 컨트롤러
 * 클라이언트로부터 요청을 받아 Service 계층에 전달하고,
 * 처리 결과를 ResponseEntity로 감싸서 응답
 */
@RestController
@RequestMapping("/product") // 모든 요청의 기본 경로 설정
public class ProductController {

    private final ProductService productService;

    @Autowired
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    /**
     * 상품 조회 API
     * GET /product?number=1
     * @param number 조회할 상품의 고유 번호 (쿼리 파라미터)
     * @return 상품 정보를 담은 응답 DTO
     */
    @GetMapping
    public ResponseEntity<ProductResponseDTO> getProduct(@RequestParam("number") Long number) {
        ProductResponseDTO productResponseDTO = productService.getProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
    }

    /**
     * 상품 생성 API
     * POST /product
     * @param productDTO 클라이언트가 전달한 상품 정보 (JSON Body)
     * @return 저장된 상품 정보를 담은 응답 DTO
     */
    @PostMapping
    // jwt 인증 필요
    @SecurityRequirement(name = "X-AUTH-TOKEN") 
    public ResponseEntity<ProductResponseDTO> createProduct(@RequestBody ProductDTO productDTO) {
        ProductResponseDTO productResponseDTO = productService.saveProduct(productDTO);
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
    }

    /**
     * 상품 이름 변경 API
     * PUT /product
     * @param changeProductNameDTO 상품 번호와 새로운 이름을 담은 DTO (JSON Body)
     * @return 변경된 상품 정보를 담은 응답 DTO
     * @throws Exception 상품이 존재하지 않거나 변경 실패 시 예외 발생
     */
    @PutMapping
    // jwt 인증 필요
    @SecurityRequirement(name = "X-AUTH-TOKEN") 
    public ResponseEntity<ProductResponseDTO> changeProductName(
            @RequestBody ChangeProductNameDTO changeProductNameDTO) throws Exception {
        ProductResponseDTO productResponseDTO = productService.changeProductName(
            changeProductNameDTO.getNumber(), changeProductNameDTO.getName());
        return ResponseEntity.status(HttpStatus.OK).body(productResponseDTO);
    }

    /**
     * 상품 삭제 API
     * DELETE /product?number=1
     * @param number 삭제할 상품의 고유 번호 (쿼리 파라미터)
     * @return 삭제 성공 메시지
     * @throws Exception 상품이 존재하지 않거나 삭제 실패 시 예외 발생
     */
    @DeleteMapping
    // jwt 인증 필요
    @SecurityRequirement(name = "X-AUTH-TOKEN") 
    public ResponseEntity<String> deleteProduct(@RequestParam("number") Long number) throws Exception {
        productService.deleteProduct(number);
        return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다.");
    }
}
  • http://localhost:8080/swagger-ui/index.html 상단에서 'Authorize' 클릭
    -> 클릭하면 X-AUTH-TOKEN 입력창이 뜨고,
        거기에 '로그인 성공 후 발급받은 token 값'을 입력하면 이후 모든 API 호출에 자동으로 헤더가 붙음

  • http://localhost:8080/swagger-ui/index.html#/product에서,
    Body에 { "name":"공책", "price":"2000", "stock":"3500"}을 넣으면 정상적으로 상품 등록 성공 화면이 출력됨
  • 상품 등록이 정상 완료됐다면, 상품 조회 API에서 상품 조회 가능

 

(5) 비정상 동작 시나리오 기반 테스트 수행 시

  • token 값을 변조하여 상품 등록 API를 호출하면, 인증 예외 발생으로 { "msg": "인증이 실패하였습니다" }가 출력됨
  • 사용자의 role이 ADMIN이 아닐 경우(USER 권한으로 회원가입 및 로그인 진행),
    인가 예외 발생으로 /exception으로 리다이렉트된 후 예외 메시지가 응답으로 돌아옴 
반응형