# Spring Boot 개발 가이드 ## 스택 가정 - Spring Boot 3.x + Java 17+ / Kotlin - Spring Data JPA + Hibernate - Spring Security (JWT) - Flyway (DB 마이그레이션) - Gradle 빌드 - 데이터베이스: PostgreSQL (기본), MySQL 지원 --- ## 레이어 아키텍처 ``` Controller (HTTP 진입점) ↓ DTO (Request) Service (비즈니스 로직) ↓ Domain/Entity Repository (데이터 접근) ↓ Database ``` ### 폴더 구조 ``` src/main/java/{base.package}/ ├── controller/ # @RestController — HTTP 요청 처리만 ├── service/ # @Service — 비즈니스 로직 ├── repository/ # @Repository — JPA 인터페이스 ├── domain/ # @Entity — JPA 엔티티 ├── dto/ │ ├── request/ # {Action}{Resource}Request │ └── response/ # {Resource}Response ├── config/ # SecurityConfig, CorsConfig, etc. ├── exception/ # 커스텀 예외 + GlobalExceptionHandler └── util/ # 유틸리티 클래스 ``` --- ## 컨트롤러 패턴 ```java @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping public ResponseEntity> getUsers() { return ResponseEntity.ok(userService.findAll()); } @GetMapping("/{id}") public ResponseEntity getUser(@PathVariable Long id) { return ResponseEntity.ok(userService.findById(id)); } @PostMapping public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { return ResponseEntity.status(HttpStatus.CREATED) .body(userService.create(request)); } @PutMapping("/{id}") public ResponseEntity updateUser( @PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) { return ResponseEntity.ok(userService.update(id, request)); } @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { userService.delete(id); return ResponseEntity.noContent().build(); } } ``` --- ## 서비스 패턴 ```java @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; public List findAll() { return userRepository.findAll().stream() .map(UserResponse::from) .toList(); } public UserResponse findById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found: " + id)); return UserResponse.from(user); } @Transactional public UserResponse create(CreateUserRequest request) { User user = User.builder() .email(request.getEmail()) .name(request.getName()) .build(); return UserResponse.from(userRepository.save(user)); } } ``` --- ## 엔티티 패턴 ```java @Entity @Table(name = "users") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor public class User extends BaseEntity { // createdAt, updatedAt 포함 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String name; } // BaseEntity.java @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @CreatedDate private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; } ``` --- ## DTO 패턴 ```java // Request public record CreateUserRequest( @NotBlank @Email String email, @NotBlank @Size(min=2, max=50) String name ) {} // Response — 정적 팩토리 메서드 포함 public record UserResponse(Long id, String email, String name) { public static UserResponse from(User user) { return new UserResponse(user.getId(), user.getEmail(), user.getName()); } } ``` --- ## 예외 처리 ```java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity handleNotFound(EntityNotFoundException e) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse("NOT_FOUND", e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .collect(Collectors.joining(", ")); return ResponseEntity.badRequest() .body(new ErrorResponse("VALIDATION_ERROR", message)); } } public record ErrorResponse(String code, String message) {} ``` --- ## JWT 보안 설정 ```yaml # application.yml spring: datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/myapp} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:password} jpa: hibernate: ddl-auto: validate # Flyway 사용 시 validate open-in-view: false jwt: secret: ${JWT_SECRET} expiration: 86400000 # 24시간 (ms) ``` --- ## DB 마이그레이션 (Flyway) ``` src/main/resources/db/migration/ ├── V1__create_users_table.sql ├── V2__add_user_role.sql └── V3__create_posts_table.sql ``` 네이밍: `V{version}__{description}.sql` (V 대문자, 밑줄 2개) --- ## 테스트 패턴 ```java // 서비스 단위 테스트 @ExtendWith(MockitoExtension.class) class UserServiceTest { @InjectMocks UserService userService; @Mock UserRepository userRepository; @Test void 사용자_생성_성공() { // given given(userRepository.save(any())).willReturn(testUser()); // when UserResponse response = userService.create(new CreateUserRequest("a@b.com", "Alice")); // then assertThat(response.email()).isEqualTo("a@b.com"); } } // 통합 테스트 @SpringBootTest @AutoConfigureMockMvc class UserControllerTest { @Autowired MockMvc mockMvc; @Test void GET_users_returns_list() throws Exception { mockMvc.perform(get("/api/v1/users").contentType(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()); } } ``` --- ## 컨벤션 | 항목 | 규칙 | |------|------| | 엔티티 | 단수 명사 (User, Post) | | 컨트롤러 | {Resource}Controller | | 서비스 | {Resource}Service | | 리포지토리 | {Resource}Repository | | Request DTO | {Action}{Resource}Request | | Response DTO | {Resource}Response | | API 경로 | `/api/v1/{resource}` (복수, kebab-case) |