Spring Boot로 REST CRUD + JWT + Swagger 한 방에 끝내기

Nolrimbo·2025년 8월 14일

포트폴리오

목록 보기
1/21

회원가입/로그인/로그아웃(JWT) + 게시글 CRUD + Swagger 문서화까지 최소구성으로 구현한 기록입니다. 실전에서 바로 재사용할 수 있도록 엔드포인트 표, 핵심 코드, 트러블슈팅을 정리했습니다.


✅ 구현 범위 (DONE)

  • JWT 인증: register, login, logout, me
  • 게시글 CRUD: create/read/update/delete, 페이지네이션 & 정렬
  • Swagger 문서화: /swagger-ui 에서 바로 테스트 가능(Authorize 버튼으로 JWT 입력)

선택적으로 /auth/refresh(액세스 토큰 재발급)를 붙일 수 있습니다. 본 글은 핵심 범위에 집중합니다.


🧰 Tech Stack & Versions

  • Java 17, Spring Boot 3.5.4
  • Spring Web, Spring Security, Spring Data JPA, Validation, Lombok
  • JWT: io.jsonwebtoken:jjwt (0.11.5)
  • Swagger: springdoc-openapi-starter-webmvc-ui (2.8.9) ← Boot 3.5.x와 호환
  • DB: MariaDB(로컬) ※ 빠른 테스트용 H2도 가능

🗂️ 패키지 구조

com.example.api
├── auth
│   ├── controller
│   ├── dto
│   ├── jwt
│   │   ├── JwtTokenProvider.java
│   │   └── JwtAuthenticationFilter.java
│   └── security
│       ├── CustomUserDetails.java
│       └── CustomUserDetailsService.java
├── common
│   ├── dto/ApiResponse.java
│   └── exception/GlobalExceptionHandler.java
├── config
│   ├── SecurityConfig.java
│   └── OpenApiConfig.java
├── user
│   ├── entity/User.java
│   └── repository/UserRepository.java
└── post
    ├── entity/Post.java
    ├── dto/{PostRequest,PostResponse}.java
    ├── repository/PostRepository.java
    └── controller/PostController.java

⚙️ Gradle 의존성

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

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'

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

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

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // 또는 H2
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

🔑 설정 (application.properties)

# DB (MariaDB 예시)
spring.datasource.url=jdbc:mariadb://localhost:3306/rest_crud_jwt
spring.datasource.username=app
spring.datasource.password=apppw
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# JWT
app.jwt.secret=CHANGE_ME_TO_A_LONG_RANDOM_SECRET_32B_PLUS
app.jwt.expiration-ms=3600000

# Swagger
springdoc.swagger-ui.path=/swagger-ui

빠른 테스트는 H2로 바꿔도 됩니다. (url을 jdbc:h2:mem:rest_crud_jwt;MODE=MySQL 등으로 교체)


🧱 핵심 코드 하이라이트

1) JwtTokenProvider (생성/파싱)

@Component
public class JwtTokenProvider {
  private final Key key;
  private final long validityMs;

  public JwtTokenProvider(@Value("${app.jwt.secret}") String secret,
                          @Value("${app.jwt.expiration-ms}") long validityMs) {
    this.key = Keys.hmacShaKeyFor(secret.getBytes());
    this.validityMs = validityMs;
  }

  public String createToken(String username){
    Date now = new Date();
    return Jwts.builder()
      .setSubject(username)
      .setIssuedAt(now)
      .setExpiration(new Date(now.getTime() + validityMs))
      .signWith(key, SignatureAlgorithm.HS256)
      .compact();
  }

  public String getUsername(String token){
    return Jwts.parserBuilder().setSigningKey(key).build()
      .parseClaimsJws(token).getBody().getSubject();
  }

  public long getValidityMs(){ return validityMs; }
}

2) SecurityConfig (JWT 필터 + Swagger 경로 허용)

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
  private final JwtTokenProvider tokenProvider;
  private final CustomUserDetailsService uds;

  @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); }

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .csrf(csrf -> csrf.disable())
      .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/v3/api-docs/**","/swagger-ui.html","/swagger-ui/**").permitAll()
        .requestMatchers("/api/auth/**").permitAll()
        .anyRequest().authenticated()
      )
      .addFilterBefore(new JwtAuthenticationFilter(tokenProvider, uds),
        UsernamePasswordAuthenticationFilter.class);
    return http.build();
  }
}

3) OpenAPI 설정 (Authorize 버튼)

@Configuration
@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT")
@OpenAPIDefinition(info = @Info(title = "REST CRUD + JWT API", version = "v1"),
    security = { @SecurityRequirement(name = "bearerAuth") })
public class OpenApiConfig {}

4) AuthController (login, me 예시)

@Tag(name = "Auth")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final AuthenticationManager authenticationManager;
  private final JwtTokenProvider tokenProvider;

  @Operation(summary = "회원가입", security = {})
  @PostMapping("/register")
  public ApiResponse<String> register(@RequestBody @Valid RegisterRequest req){
    if (userRepository.existsByUsername(req.getUsername())) return ApiResponse.ok("이미 존재하는 사용자명입니다.");
    userRepository.save(User.builder()
      .username(req.getUsername())
      .password(passwordEncoder.encode(req.getPassword()))
      .role("ROLE_USER")
      .build());
    return ApiResponse.ok("registered");
  }

  @Operation(summary = "로그인", security = {})
  @PostMapping("/login")
  public ApiResponse<LoginResponse> login(@RequestBody @Valid LoginRequest req){
    authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()));
    String token = tokenProvider.createToken(req.getUsername());
    return ApiResponse.ok(LoginResponse.builder()
      .token(token).tokenType("Bearer")
      .expiresAt(Instant.now().plusMillis(tokenProvider.getValidityMs()))
      .build());
  }

  @Operation(summary = "내 정보", security = { @SecurityRequirement(name = "bearerAuth") })
  @GetMapping("/me")
  public ApiResponse<UserMeResponse> me(@AuthenticationPrincipal CustomUserDetails user){
    var u = user.getUser();
    return ApiResponse.ok(UserMeResponse.builder().id(u.getId()).username(u.getUsername()).role(u.getRole()).createdAt(u.getCreatedAt()).build());
  }
}

🔗 엔드포인트 표

구분메서드경로인증설명
AuthPOST/api/auth/register공개회원가입
AuthPOST/api/auth/login공개JWT 발급
AuthGET/api/auth/me필요로그인 사용자 정보
AuthPOST/api/auth/logout공개/선택로그아웃 처리(전략에 따라 구현)
PostPOST/api/posts필요게시글 생성
PostGET/api/posts/{id}필요단건 조회
PostGET/api/posts?page=&size=&sort=createdAt,desc필요목록(페이지/정렬)
PostPUT/api/posts/{id}필요수정(작성자만)
PostDELETE/api/posts/{id}필요삭제(작성자만)

🧪 Swagger로 테스트하는 순서

  1. 브라우저에서 `` 접속
  2. **POST /api/auth/register**로 계정 생성(한 번만)
  3. `→ 응답의data.token` 복사
  4. 화면 우측 상단 Authorize 버튼 → 토큰(문자열만) 입력 → Authorize
  5. 보호 API들(/api/auth/me, /api/posts/**) 바로 호출

Swagger에서 토큰은 자동으로 Authorization: Bearer <token> 헤더에 반영됩니다.


🧯 트러블슈팅 (요약)

  • Swagger 500: ``
    • 원인: Spring Boot 3.5.x(Framework 6.2)와 springdoc 버전 불일치
    • 해결: org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9 로 업그레이드
  • Swagger 접근 401/403
    • SecurityConfig에서 /v3/api-docs/**, /swagger-ui.html, /swagger-ui/**를 permitAll()로 허용
  • JWT 시크릿 너무 짧음
    • HS256 키는 32바이트 이상 권장(충분히 긴, 랜덤 문자열 사용)

📎 마무리

  • 이 최소구성은 실무 프로젝트의 인증 + CRUD 뼈대를 신속하게 세우는 데 초점이 있습니다.
  • 운영 적용 시에는 CORS 화이트리스트, 로그/예외 표준화, 테스트 코드, RBAC(권한) 등을 단계적으로 더하면 좋습니다.

✅ 참고

profile
Java 개발자 | 사이드 프로젝트 마니아 | GPT 기반 자동화 툴 연구 중 기술 리뷰, 개발일지

0개의 댓글