회원가입/로그인/로그아웃(JWT) + 게시글 CRUD + Swagger 문서화까지 최소구성으로 구현한 기록입니다. 실전에서 바로 재사용할 수 있도록 엔드포인트 표, 핵심 코드, 트러블슈팅을 정리했습니다.
register, login, logout, mecreate/read/update/delete, 페이지네이션 & 정렬/swagger-ui 에서 바로 테스트 가능(Authorize 버튼으로 JWT 입력)선택적으로 /auth/refresh(액세스 토큰 재발급)를 붙일 수 있습니다. 본 글은 핵심 범위에 집중합니다.
io.jsonwebtoken:jjwt (0.11.5)springdoc-openapi-starter-webmvc-ui (2.8.9) ← Boot 3.5.x와 호환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
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'
}
# 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등으로 교체)
@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; }
}
@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();
}
}
@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 {}
@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());
}
}
| 구분 | 메서드 | 경로 | 인증 | 설명 |
|---|---|---|---|---|
| Auth | POST | /api/auth/register | 공개 | 회원가입 |
| Auth | POST | /api/auth/login | 공개 | JWT 발급 |
| Auth | GET | /api/auth/me | 필요 | 로그인 사용자 정보 |
| Auth | POST | /api/auth/logout | 공개/선택 | 로그아웃 처리(전략에 따라 구현) |
| Post | POST | /api/posts | 필요 | 게시글 생성 |
| Post | GET | /api/posts/{id} | 필요 | 단건 조회 |
| Post | GET | /api/posts?page=&size=&sort=createdAt,desc | 필요 | 목록(페이지/정렬) |
| Post | PUT | /api/posts/{id} | 필요 | 수정(작성자만) |
| Post | DELETE | /api/posts/{id} | 필요 | 삭제(작성자만) |
POST /api/auth/register**로 계정 생성(한 번만)→ 응답의data.token` 복사/api/auth/me, /api/posts/**) 바로 호출Swagger에서 토큰은 자동으로
Authorization: Bearer <token>헤더에 반영됩니다.
org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9 로 업그레이드SecurityConfig에서 /v3/api-docs/**, /swagger-ui.html, /swagger-ui/**를 permitAll()로 허용