| name | spring-boot-guidelines |
| description | Spring Boot 3 + Java 21 백엔드 — Controller→Service→Repository 계층, JPA Entity(Lombok+@PrePersist), Spring Security JWT Filter(jjwt 0.12.x + Refresh Rotation), GlobalExceptionHandler, ApiResponse<T>, @Transactional(readOnly), @PreAuthorize 메서드 인가. |
| triggers | ["spring boot","spring security","jpa entity","spring controller","restcontroller","transactional","jwt filter","hibernate","jwtprovider","refreshtoken","businessexception","errorcode","globalexceptionhandler","spring repository","spring service"] |
Spring Boot 3 Backend Guidelines
Purpose
Spring Boot 3 + Java 21 + Spring Security + JPA 기반 REST API 개발 표준 패턴.
base/spring-boot/ 베이스 코드의 핵심 패턴을 정리한 실전 가이드.
프로젝트 구조
src/main/java/com/base/
├── BaseApplication.java
├── common/
│ ├── exception/ # ErrorCode, BusinessException, GlobalExceptionHandler
│ └── response/ # ApiResponse<T>
├── config/ # SecurityConfig, S3Config
├── security/ # JwtProvider, JwtAuthenticationFilter, CustomUserDetailsService
├── auth/ # RefreshToken entity/repo, AuthService, AuthController
├── domain/
│ └── user/ # User entity, UserRepository, UserService, UserController
└── utils/ # S3Uploader
Pattern 1: 계층 구조 (Controller → Service → Repository)
핵심 규칙:
- Controller: HTTP 처리, DTO 검증,
@AuthenticationPrincipal로 userId 추출
- Service: 비즈니스 로직,
@Transactional 관리, BusinessException 발생
- Repository: JPA 쿼리,
JpaRepository 확장
Controller 패턴:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@GetMapping("/me")
public ApiResponse<UserResponse> getMe(@AuthenticationPrincipal Long userId) {
return ApiResponse.ok(userService.getMe(userId));
}
@PatchMapping("/me")
public ApiResponse<UserResponse> updateMe(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody UpdateUserRequest request) {
return ApiResponse.ok(userService.updateMe(userId, request));
}
@DeleteMapping("/me")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ApiResponse<Void> deleteMe(@AuthenticationPrincipal Long userId) {
userService.deleteMe(userId);
return ApiResponse.noContent();
}
}
Service 패턴 (@Transactional 분리):
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public UserResponse getMe(Long userId) {
return UserResponse.from(findUserById(userId));
}
@Transactional
public UserResponse updateMe(Long userId, UpdateUserRequest request) {
User user = findUserById(userId);
if (!user.getUsername().equals(request.username())
&& userRepository.existsByUsername(request.username())) {
throw new BusinessException(ErrorCode.USERNAME_ALREADY_EXISTS);
}
user.updateUsername(request.username());
return UserResponse.from(user);
}
private User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
}
Pattern 2: JPA Entity
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(unique = true, nullable = false, length = 30)
private String username;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public void updateUsername(String username) {
this.username = username;
}
public enum Role { USER, ADMIN }
}
DTO는 Java Record 사용:
public record SignUpRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password,
@NotBlank @Size(max = 30) String username
) {}
public record UserResponse(Long id, String email, String username, String role) {
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getEmail(),
user.getUsername(), user.getRole().name());
}
}
Pattern 3: JWT 인증 (JwtProvider + Refresh Token Rotation)
JwtProvider (jjwt 0.12.x):
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final long accessTokenExpiry;
private final long refreshTokenExpiry;
public JwtProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiry}") long accessExpiry,
@Value("${jwt.refresh-token-expiry}") long refreshExpiry) {
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.accessTokenExpiry = accessExpiry;
this.refreshTokenExpiry = refreshExpiry;
}
public String createAccessToken(Long userId, String email, String role) {
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("email", email)
.claim("role", role)
.claim("type", "access")
.issuedAt(Date.from(Instant.now()))
.expiration(Date.from(Instant.now().plusSeconds(accessTokenExpiry)))
.signWith(secretKey)
.compact();
}
public Claims parseToken(String token) {
try {
return Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new BusinessException(ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
}
}
Refresh Token Rotation — 1유저 1토큰:
private TokenResponse issueTokens(User user) {
String accessToken = jwtProvider.createAccessToken(...);
String refreshToken = jwtProvider.createRefreshToken(user.getId());
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(jwtProvider.getRefreshTokenExpiry());
refreshTokenRepository.findByUserId(user.getId()).ifPresentOrElse(
existing -> existing.rotate(refreshToken, expiresAt),
() -> refreshTokenRepository.save(RefreshToken.builder()
.userId(user.getId()).token(refreshToken).expiresAt(expiresAt).build())
);
return TokenResponse.of(accessToken, refreshToken);
}
public TokenResponse refresh(RefreshRequest request) {
if (!jwtProvider.isRefreshToken(request.refreshToken()))
throw new BusinessException(ErrorCode.INVALID_TOKEN);
RefreshToken stored = refreshTokenRepository.findByToken(request.refreshToken())
.orElseThrow(() -> new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND));
if (stored.isExpired()) {
refreshTokenRepository.delete(stored);
throw new BusinessException(ErrorCode.REFRESH_TOKEN_EXPIRED);
}
Long userId = jwtProvider.getUserId(request.refreshToken());
User user = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
String newAccess = jwtProvider.createAccessToken(userId, user.getEmail(), user.getRole().name());
String newRefresh = jwtProvider.createRefreshToken(userId);
stored.rotate(newRefresh, LocalDateTime.now().plusSeconds(jwtProvider.getRefreshTokenExpiry()));
return TokenResponse.of(newAccess, newRefresh);
}
Pattern 4: 예외 처리 (ErrorCode + BusinessException + GlobalExceptionHandler)
ErrorCode Enum — HTTP 상태 + 에러코드 중앙 관리:
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "INVALID_INPUT_VALUE", "잘못된 입력값입니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 HTTP Method입니다."),
ACCESS_DENIED(HttpStatus.FORBIDDEN, "ACCESS_DENIED", "접근 권한이 없습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."),
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "INVALID_CREDENTIALS", "이메일 또는 비밀번호가 올바르지 않습니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "EXPIRED_TOKEN", "만료된 토큰입니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "INVALID_TOKEN", "유효하지 않은 토큰입니다."),
REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_NOT_FOUND", "리프레시 토큰을 찾을 수 없습니다."),
REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_EXPIRED", "리프레시 토큰이 만료되었습니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "사용자를 찾을 수 없습니다."),
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "EMAIL_ALREADY_EXISTS", "이미 사용 중인 이메일입니다."),
USERNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "USERNAME_ALREADY_EXISTS", "이미 사용 중인 사용자명입니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
}
BusinessException — 도메인 예외의 기반 클래스:
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
GlobalExceptionHandler:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
log.warn("BusinessException: code={}, message={}", errorCode.getCode(), e.getMessage());
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ApiResponse.error(e.getMessage(), errorCode.getCode()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(ApiResponse.error(message, ErrorCode.INVALID_INPUT_VALUE.getCode()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(ErrorCode.ACCESS_DENIED.getMessage(), ErrorCode.ACCESS_DENIED.getCode()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(
ErrorCode.INTERNAL_SERVER_ERROR.getMessage(),
ErrorCode.INTERNAL_SERVER_ERROR.getCode()));
}
}
Pattern 5: ApiResponse 표준 응답 래퍼
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private final boolean success;
private final T data;
private final String message;
private final String code;
private ApiResponse(boolean success, T data, String message, String code) {
this.success = success; this.data = data; this.message = message; this.code = code;
}
public static <T> ApiResponse<T> ok(T data) { return new ApiResponse<>(true, data, "OK", "SUCCESS"); }
public static <T> ApiResponse<T> ok(T data, String msg) { return new ApiResponse<>(true, data, msg, "SUCCESS"); }
public static <T> ApiResponse<T> created(T data) { return new ApiResponse<>(true, data, "Created", "CREATED"); }
public static ApiResponse<Void> noContent() { return new ApiResponse<>(true, null, "No Content", "NO_CONTENT"); }
public static <T> ApiResponse<T> error(String message, String code) { return new ApiResponse<>(false, null, message, code); }
}
Controller 응답 예시:
return ApiResponse.ok(userService.getMe(userId));
return ApiResponse.created(authService.signUp(request));
return ApiResponse.noContent();
Spring Security 설정 포인트
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/api/v1/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ApiResponse<Page<UserResponse>> getAllUsers(Pageable pageable) { ... }
환경 분리 포인트
| 설정 | local | prod |
|---|
jpa.hibernate.ddl-auto | create-drop | validate |
spring.profiles.active | local | prod |
| DB credentials | .env 직접 | 환경변수 주입 |
application.yaml에서 모든 민감 값은 ${ENV_VAR} 형태로 주입. .env.example에 전체 목록 유지.
Anti-patterns (하지 말 것)
@GetMapping("/me")
public User getMe(@AuthenticationPrincipal CustomUserDetails details) {
return userRepository.findById(details.getUserId()).get();
}
public ApiResponse<UserResponse> getMe(@AuthenticationPrincipal Long userId) {
return ApiResponse.ok(userService.getMe(userId));
}
@Transactional(readOnly = true)
public void updateUser(...) { user.updateName(name); }
@Transactional(readOnly = true) public UserResponse getUser(...) { ... }
@Transactional public UserResponse updateUser(...) { ... }
public void setUsername(String username) { this.username = username; }
public void updateUsername(String username) { this.username = username; }
@Column private Role role;
@Enumerated(EnumType.STRING) @Column private Role role;
throw new RuntimeException("User not found");
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
Quick Reference: 신규 도메인 추가 체크리스트
관련 스킬
- docker-guidelines: Dockerfile 멀티 스테이지 빌드 | database-guidelines: JPA N+1, 인덱스 전략