Spring Boot , Spring Security, JWT, 그리고 Swagger(OpenAPI)

Kim jisu·2025년 1월 26일

spring

목록 보기
2/5
post-thumbnail

Spring Boot 3.2.0, Spring Security, JWT(Json Web Token), 그리고 Swagger(OpenAPI)를 사용하여 안전하고 문서화된 API를 구축하는 방법 기록.


최신 버전의 Spring Boot와 호환되는 설정 방법!!

1. 프로젝트 설정

Gradle 의존성 추가

먼저, Gradle을 사용하여 프로젝트를 설정합니다. build.gradle 파일에 필요한 의존성을 추가합니다. 특히, Spring Boot 3.2.0과 호환되는 라이브러리 버전을 명시하는 것이 중요합니다.


(3.4.1 사용하니까

Handler dispatch failed: java.lang.NoSuchMethodError: 'void org.springframework.web.method.ControllerAdviceBean.<init>(java.lang.Object)

에러 발생
)

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'org.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot 스타터
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web-services'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // Thymeleaf 보안 확장
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

    // 로깅 (Logback)
    implementation 'ch.qos.logback:logback-classic'

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

    // Swagger(OpenAPI)
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'

    // MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'

    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // 테스트
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

주의 사항:

  • Spring Boot 버전: org.springframework.boot 플러그인을 3.2.0으로 설정하여 안정화된 버전을 사용합니다.
  • SpringDoc 버전: springdoc-openapi-starter-webmvc-ui2.1.0 버전을 사용하여 Spring Boot 3.2.x와 호환되도록 합니다.

2. JWT 구성

JWT는 클라이언트와 서버 간의 인증을 안전하게 관리하기 위해 사용됩니다. JWT를 구성하기 위해 JwtUtil, JwtAuthenticationFilter, JwtAuthorizationFilter 클래스를 구현합니다.

JwtUtil 클래스

JWT 토큰 생성 및 검증을 위한 유틸리티 클래스입니다.

package org.example.infrastructure.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtUtil {

    private final SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private final long jwtExpirationMs = 86400000; // 1일

    // JWT 생성
    public String createToken(String email, String role) {
        return Jwts.builder()
                .setSubject(email)
                .claim("role", role)
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(key)
                .compact();
    }

    // JWT 파싱
    public Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // JWT 유효성 검사
    public boolean validateToken(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 로그 추가 가능
            return false;
        }
    }

    // 요청에서 JWT 추출
    public String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JwtAuthenticationFilter 구현

사용자가 로그인할 때 JWT를 생성하는 필터입니다.

package org.example.infrastructure.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.application.dto.LoginRequestDTO;
import org.example.domain.entity.UserRoleEnum;
import org.example.infrastructure.security.UserDetailsImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final JwtUtil jwtUtil;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            LoginRequestDTO requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDTO.class);
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                    requestDto.getEmail(),
                    requestDto.getPassword()
            );
            return getAuthenticationManager().authenticate(authToken);
        } catch (IOException e) {
            throw new RuntimeException("로그인 요청을 처리할 수 없습니다.", e);
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        UserDetailsImpl userDetails = (UserDetailsImpl) authResult.getPrincipal();
        String token = jwtUtil.createToken(userDetails.getUser().getEmail(), userDetails.getUser().getRole().name());
        response.setHeader("Authorization", "Bearer " + token);
        response.setStatus(HttpServletResponse.SC_OK);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

JwtAuthorizationFilter 구현

요청 시 JWT를 검증하여 인증을 수행하는 필터입니다.

package org.example.infrastructure.jwt;

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.example.infrastructure.security.UserDetailsServiceImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = jwtUtil.getTokenFromRequest(request);

        if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
            Claims claims = jwtUtil.parseClaims(token);
            String email = claims.getSubject();
            String role = claims.get("role", String.class);

            Authentication authentication = new UsernamePasswordAuthenticationToken(
                    userDetailsService.loadUserByUsername(email),
                    null,
                    userDetailsService.loadUserByUsername(email).getAuthorities()
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String path = request.getRequestURI();
        return path.startsWith("/v3/api-docs") || path.startsWith("/swagger-ui");
    }
}

3. Spring Security 설정

Spring Security를 설정하여 JWT 필터를 적용하고, 특정 경로에 대한 접근 권한을 관리합니다.

package org.example.infrastructure.config;

import lombok.RequiredArgsConstructor;
import org.example.infrastructure.jwt.JwtAuthenticationFilter;
import org.example.infrastructure.jwt.JwtAuthorizationFilter;
import org.example.infrastructure.jwt.JwtUtil;
import org.example.infrastructure.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;
    private final AuthenticationConfiguration authenticationConfiguration;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }

    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.disable())
            .csrf(csrf -> csrf.disable())
            .formLogin(form -> form.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http.authorizeHttpRequests(auth -> auth
            // Swagger/OpenAPI 문서 경로 전부 허용
            .requestMatchers(
                "/v3/api-docs",
                "/v3/api-docs/**",
                "/swagger-ui/**",
                "/swagger-ui.html",
                "/error"
            ).permitAll()
            // 정적 리소스 (CSS, JS, 이미지 등)
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            // 회원가입/로그인 등 공개 API
            .requestMatchers("/api/user/**").permitAll()
            // 나머지는 인증 필요
            .anyRequest().authenticated()
        );

        // JWT 필터 등록
        http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // 접근 불가 페이지 설정
        http.exceptionHandling(exception -> exception.accessDeniedPage("/forbidden.html"));

        return http.build();
    }
}

주요 설정 포인트:

  • JWT 필터 추가: JwtAuthorizationFilterJwtAuthenticationFilterUsernamePasswordAuthenticationFilter 전에 추가하여 JWT 인증을 처리합니다.
  • 경로 보안 설정: Swagger 관련 경로 및 정적 리소스, 회원가입/로그인 API는 인증 없이 접근할 수 있도록 설정하고, 나머지 모든 경로는 인증을 요구합니다.
  • 세션 관리: SessionCreationPolicy.STATELESS를 설정하여 세션을 사용하지 않도록 합니다.

4. Swagger(OpenAPI) 설정

Swagger를 통해 API 문서를 자동으로 생성하고, JWT를 통한 인증을 지원하도록 설정합니다.

SwaggerConfig 클래스

package org.example.infrastructure.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

    private static final String SECURITY_SCHEME_NAME = "JWT";

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .openapi("3.0.3")
                .info(apiInfo())
                .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
                .components(securityComponents());
    }

    private Info apiInfo() {
        return new Info()
                .title("")
                .version("1.0.0")
                .description("")
                .contact(new Contact()
                        .name("")
                        .email("")
                        .url("")
                );
    }

    private Components securityComponents() {
        return new Components()
                .addSecuritySchemes(SECURITY_SCHEME_NAME, new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")
                        .in(SecurityScheme.In.HEADER)
                        .name("Authorization")
                );
    }
}

설명:

  • OpenAPI 정보 설정: API의 제목, 버전, 설명, 연락처 정보를 설정합니다.
  • 보안 스키마 추가: bearer 타입의 JWT 인증 방식을 추가하여 Swagger UI에서 인증 토큰을 입력할 수 있도록 합니다.

5. 컨트롤러 작성

API 엔드포인트를 작성하고 Swagger 어노테이션을 통해 문서화를 지원합니다.

package org.example.presentation.controller;

import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.domain.service.FinanceService;
import org.example.domain.service.SubscriptionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/data")
public class DataController {
    private final SubscriptionService subscriptionService;
    private final FinanceService financeService;

    @GetMapping("/sub")
    @Operation(summary = "", description = "Swagger 문서화 예시")
    public ResponseEntity<?> getSub(){
        String result = subscriptionService.updateSub();
        return ResponseEntity.ok(result);
    }
}

설명:

  • @Operation 어노테이션: 각 엔드포인트에 대한 요약 및 설명을 추가하여 Swagger UI에 문서화합니다.
  • 엔드포인트 구현: SubscriptionServiceFinanceService를 이용하여 데이터를 처리하고 응답합니다.

6. 테스트 및 검증

프로젝트를 빌드하고 실행하여 Swagger UI를 통해 API 문서를 확인합니다.

프로젝트 빌드 및 실행

./gradlew clean build
./gradlew bootRun

Swagger UI 접속

브라우저에서 다음 URL로 접속하여 Swagger UI를 확인합니다:

http://localhost:8082/swagger-ui/index.html

확인 사항:

  • API 문서 확인: 모든 엔드포인트가 정상적으로 문서화되어 있는지 확인합니다.
  • JWT 인증 테스트: Authorize 버튼을 클릭하여 JWT 토큰을 입력하고, 인증이 필요한 엔드포인트에 접근할 수 있는지 테스트합니다.

7. 문제 해결

프로젝트 설정 과정에서 발생할 수 있는 문제와 그 해결 방법을 소개합니다.

NoSuchMethodError 발생 시

문제 상황:

Swagger UI 접속 시 다음과 같은 오류가 발생할 수 있습니다:

Unable to render this definition
The provided definition does not specify a valid version field.

원인 및 해결 방법:

  • 버전 충돌: Spring Boot 버전과 의존성 라이브러리의 호환성 문제로 발생할 수 있습니다.
    • 해결 방법: Spring Boot 버전을 3.2.0으로 안정화하여 의존성 충돌을 방지합니다.
  • Security 설정 오류: Swagger 관련 경로가 제대로 허용되지 않아 발생할 수 있습니다.
    • 해결 방법: WebSecurityConfig 클래스에서 /v3/api-docs와 Swagger UI 관련 모든 경로를 permitAll()로 설정합니다.
  • 클래스패스 문제: IDE나 외부 서버에 이전 버전의 라이브러리가 남아 있을 수 있습니다.
    • 해결 방법: ./gradlew clean build를 실행하고, 내장 톰캣을 사용하여 애플리케이션을 실행합니다.

8. 결론

이번 포스팅에서는 Spring Boot 3.2.0, Spring Security, JWT, 그리고 Swagger(OpenAPI)를 사용하여 안전하고 문서화된 API를 구축하는 방법을 알아보았습니다. 특히, 버전 호환성 문제를 사전에 인지하고, 안정화된 버전을 사용함으로써 개발 과정에서 발생할 수 있는 오류를 예방하는 방법을 배웠습니다.

핵심 정리:

  1. 의존성 관리: Spring Boot와 호환되는 라이브러리 버전을 명확히 지정하여 의존성 충돌을 방지합니다.
  2. JWT 구성: 인증 및 인가를 안전하게 처리하기 위해 JWT를 활용합니다.
  3. Spring Security 설정: 필요한 경로에 대한 접근 권한을 세밀하게 관리합니다.
  4. Swagger 설정: API 문서를 자동으로 생성하고, JWT 인증을 지원하도록 설정합니다.
  5. 문제 해결: 버전 충돌과 보안 설정 오류 등 발생할 수 있는 문제를 체계적으로 해결합니다.

이 가이드가 여러분의 Spring Boot 프로젝트에 도움이 되었기를 바랍니다. 추가적인 질문이나 개선 사항이 있다면 댓글로 남겨주세요. 감사합니다!


참고 자료:

profile
Dreamer

0개의 댓글