hobokai 님의 블로그

Spring Boot 완전 가이드 Part 3: 웹 개발과 REST API 본문

Backend

Spring Boot 완전 가이드 Part 3: 웹 개발과 REST API

hobokai 2025. 8. 6. 10:36

시리즈 소개: 실무에서 바로 써먹는 Spring Boot 완전 가이드

Part 1: Spring Boot 기초 - 시작하기
Part 2: 설정과 자동구성
Part 3: 웹 개발과 REST API ← 현재
Part 4: 데이터 액세스와 배치
Part 5: 운영과 모니터링


🌐 Spring MVC vs WebFlux 선택 가이드

Spring MVC (전통적인 서블릿 기반)

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    // 동기 방식: 요청 처리가 완료될 때까지 스레드 블로킹
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);  // DB 조회 대기
        return ResponseEntity.ok(user);
    }
}

Spring WebFlux (리액티브 기반)

@RestController
@RequestMapping("/api/users")
public class ReactiveUserController {
    
    private final ReactiveUserService userService;
    
    public ReactiveUserController(ReactiveUserService userService) {
        this.userService = userService;
    }
    
    // 비동기 방식: 논블로킹, 리액티브 스트림
    @GetMapping("/{id}")
    public Mono<ResponseEntity<User>> getUser(@PathVariable Long id) {
        return userService.findById(id)  // Mono<User> 반환
                .map(user -> ResponseEntity.ok(user))
                .defaultIfEmpty(ResponseEntity.notFound().build());
    }
    
    @GetMapping
    public Flux<User> getAllUsers() {
        return userService.findAll();  // Flux<User> 스트림 반환
    }
}

선택 기준

특성Spring MVCSpring WebFlux

학습 곡선 쉬움 어려움 (리액티브 개념 필요)
동시성 처리 스레드 기반 이벤트 루프 기반
메모리 사용량 높음 (스레드당 1-2MB) 낮음
처리량 보통 높음 (대용량 동시 요청)
디버깅 쉬움 어려움
기존 코드 호환성 높음 낮음 (새로 작성 필요)
적합한 상황 일반적인 웹 애플리케이션 대용량 동시 처리, 스트리밍

🎯 실무 권장:

  • Spring MVC: 대부분의 경우 (80-90%)
  • WebFlux: 대용량 동시 처리, 실시간 스트리밍이 필요한 경우

🛠️ REST API 설계 Best Practices

RESTful API 설계 원칙

1. 리소스 중심 URL 설계

// ✅ 좋은 예시
GET    /api/users              // 사용자 목록 조회
GET    /api/users/{id}         // 특정 사용자 조회
POST   /api/users              // 사용자 생성
PUT    /api/users/{id}         // 사용자 전체 업데이트
PATCH  /api/users/{id}         // 사용자 부분 업데이트
DELETE /api/users/{id}         // 사용자 삭제

GET    /api/users/{id}/posts   // 특정 사용자의 게시글 목록
POST   /api/users/{id}/posts   // 특정 사용자의 게시글 생성

// ❌ 나쁜 예시
GET    /api/getUser            // 동사 사용
POST   /api/user/delete        // 잘못된 HTTP 메서드
GET    /api/users/1/delete     // GET으로 삭제

2. 표준 HTTP 상태 코드 사용

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping
    public ResponseEntity<Page<UserDto>> getUsers(Pageable pageable) {
        Page<UserDto> users = userService.findAll(pageable);
        return ResponseEntity.ok(users);  // 200 OK
    }
    
    @PostMapping
    public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDto user = userService.create(request);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(user.getId())
                .toUri();
        return ResponseEntity.created(location).body(user);  // 201 Created
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        return userService.findById(id)
                .map(user -> ResponseEntity.ok(user))           // 200 OK
                .orElse(ResponseEntity.notFound().build());     // 404 Not Found
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<UserDto> updateUser(
            @PathVariable Long id, 
            @Valid @RequestBody UpdateUserRequest request) {
        try {
            UserDto user = userService.update(id, request);
            return ResponseEntity.ok(user);                     // 200 OK
        } catch (UserNotFoundException e) {
            return ResponseEntity.notFound().build();           // 404 Not Found
        }
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        if (userService.existsById(id)) {
            userService.delete(id);
            return ResponseEntity.noContent().build();          // 204 No Content
        } else {
            return ResponseEntity.notFound().build();           // 404 Not Found
        }
    }
}

DTO와 Entity 분리

Entity (데이터베이스 모델)

@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    @Column(nullable = false)
    private String password;  // 민감한 정보
    
    @Column(nullable = false)
    private String name;
    
    @Enumerated(EnumType.STRING)
    private UserStatus status;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @UpdateTimestamp
    private LocalDateTime updatedAt;
}

DTO (데이터 전송 객체)

// 응답용 DTO
@Data
@Builder
public class UserDto {
    private Long id;
    private String email;
    private String name;
    private UserStatus status;
    private LocalDateTime createdAt;
    // password는 노출하지 않음
}

// 생성 요청 DTO
@Data
@NoArgsConstructor
public class CreateUserRequest {
    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "올바른 이메일 형식이어야 합니다")
    private String email;
    
    @NotBlank(message = "비밀번호는 필수입니다")
    @Size(min = 8, message = "비밀번호는 최소 8자리 이상이어야 합니다")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]",
             message = "비밀번호는 대소문자, 숫자, 특수문자를 포함해야 합니다")
    private String password;
    
    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다")
    private String name;
}

// 업데이트 요청 DTO
@Data
@NoArgsConstructor
public class UpdateUserRequest {
    @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다")
    private String name;
    
    @Enumerated(EnumType.STRING)
    private UserStatus status;
    
    // 이메일과 비밀번호는 별도 API로 변경
}

페이징과 정렬

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping
    public ResponseEntity<Page<UserDto>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt") String sort,
            @RequestParam(defaultValue = "desc") String direction,
            @RequestParam(required = false) String search) {
        
        // 정렬 방향 설정
        Sort.Direction sortDirection = Sort.Direction.fromString(direction);
        
        // Pageable 생성
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort));
        
        Page<UserDto> users;
        if (StringUtils.hasText(search)) {
            users = userService.searchUsers(search, pageable);
        } else {
            users = userService.findAll(pageable);
        }
        
        return ResponseEntity.ok(users);
    }
    
    // 사용 예시:
    // GET /api/users?page=0&size=10&sort=name&direction=asc&search=john
}

API 버전 관리

// 1. URL 버전 관리 (권장)
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    // v1 구현
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    // v2 구현 (새로운 기능 추가)
}

// 2. Header 버전 관리
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping(headers = "API-Version=1")
    public ResponseEntity<UserDto> getUserV1(@PathVariable Long id) {
        // v1 로직
    }
    
    @GetMapping(headers = "API-Version=2")  
    public ResponseEntity<UserDtoV2> getUserV2(@PathVariable Long id) {
        // v2 로직
    }
}

⚠️ 예외 처리와 검증

전역 예외 처리 (@ControllerAdvice)

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    // 1. 비즈니스 예외 처리
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("User not found: {}", e.getMessage());
        ErrorResponse error = ErrorResponse.builder()
                .code("USER_NOT_FOUND")
                .message("사용자를 찾을 수 없습니다")
                .timestamp(LocalDateTime.now())
                .build();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
    
    // 2. 검증 실패 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidationError(
            MethodArgumentNotValidException e) {
        log.warn("Validation failed: {}", e.getMessage());
        
        Map<String, String> fieldErrors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error -> 
                fieldErrors.put(error.getField(), error.getDefaultMessage())
        );
        
        ValidationErrorResponse error = ValidationErrorResponse.builder()
                .code("VALIDATION_FAILED")
                .message("입력값 검증에 실패했습니다")
                .fieldErrors(fieldErrors)
                .timestamp(LocalDateTime.now())
                .build();
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
    
    // 3. 권한 부족
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException e) {
        log.warn("Access denied: {}", e.getMessage());
        ErrorResponse error = ErrorResponse.builder()
                .code("ACCESS_DENIED")
                .message("접근 권한이 없습니다")
                .timestamp(LocalDateTime.now())
                .build();
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
    }
    
    // 4. 일반적인 서버 에러
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneralException(Exception e) {
        log.error("Unexpected error occurred", e);
        ErrorResponse error = ErrorResponse.builder()
                .code("INTERNAL_SERVER_ERROR")
                .message("서버 내부 오류가 발생했습니다")
                .timestamp(LocalDateTime.now())
                .build();
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

커스텀 예외 클래스

// 기본 예외 클래스
public abstract class BusinessException extends RuntimeException {
    private final String code;
    
    protected BusinessException(String code, String message) {
        super(message);
        this.code = code;
    }
    
    public String getCode() {
        return code;
    }
}

// 구체적인 예외들
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(Long userId) {
        super("USER_NOT_FOUND", "User not found with id: " + userId);
    }
}

public class DuplicateEmailException extends BusinessException {
    public DuplicateEmailException(String email) {
        super("DUPLICATE_EMAIL", "Email already exists: " + email);
    }
}

public class InvalidPasswordException extends BusinessException {
    public InvalidPasswordException() {
        super("INVALID_PASSWORD", "Password does not meet requirements");
    }
}

에러 응답 DTO

@Data
@Builder
public class ErrorResponse {
    private String code;
    private String message;
    private LocalDateTime timestamp;
    private String path;  // 요청 경로
}

@Data
@Builder
public class ValidationErrorResponse {
    private String code;
    private String message;
    private Map<String, String> fieldErrors;
    private LocalDateTime timestamp;
    private String path;
}

검증 그룹 사용

// 검증 그룹 인터페이스
public interface CreateValidation {}
public interface UpdateValidation {}

// DTO에서 그룹별 검증 규칙 적용
@Data
public class UserRequest {
    
    @NotBlank(groups = CreateValidation.class, message = "이메일은 필수입니다")
    @Email(groups = {CreateValidation.class, UpdateValidation.class})
    private String email;
    
    @NotBlank(groups = CreateValidation.class, message = "비밀번호는 필수입니다")
    @Size(min = 8, groups = CreateValidation.class)
    private String password;
    
    @NotBlank(groups = {CreateValidation.class, UpdateValidation.class})
    private String name;
}

// 컨트롤러에서 그룹별 검증 적용
@PostMapping
public ResponseEntity<UserDto> createUser(
        @Validated(CreateValidation.class) @RequestBody UserRequest request) {
    // 생성 시 검증
}

@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
        @PathVariable Long id,
        @Validated(UpdateValidation.class) @RequestBody UserRequest request) {
    // 업데이트 시 검증
}

🔐 Spring Security 보안 설정

기본 Security Configuration

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtTokenProvider jwtTokenProvider;
    
    public SecurityConfig(JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                         JwtAccessDeniedHandler jwtAccessDeniedHandler,
                         JwtTokenProvider jwtTokenProvider) {
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 비활성화 (REST API에서는 일반적으로 비활성화)
            .csrf().disable()
            
            // 세션 관리 설정
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            
            // 예외 처리 설정
            .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
            
            // 권한 설정
            .authorizeHttpRequests(authz -> authz
                // 공개 엔드포인트
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/health", "/actuator/health").permitAll()
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                
                // 역할별 접근 제어
                .requestMatchers(HttpMethod.GET, "/api/users").hasRole("USER")
                .requestMatchers(HttpMethod.POST, "/api/users").hasRole("ADMIN")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                
                // 나머지는 인증 필요
                .anyRequest().authenticated()
            )
            
            // JWT 필터 추가
            .addFilterBefore(
                new JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class
            );
            
        return http.build();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }
}

JWT 토큰 처리

@Component
@Slf4j
public class JwtTokenProvider {
    
    private static final String AUTHORITIES_KEY = "auth";
    private final String secretKey;
    private final long tokenValidityInMilliseconds;
    
    public JwtTokenProvider(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }
    
    // 토큰 생성
    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
        
        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);
        
        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(SignatureAlgorithm.HS512, secretKey)
                .setExpiration(validity)
                .compact();
    }
    
    // 토큰에서 인증 정보 추출
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
        
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());
        
        User principal = new User(claims.getSubject(), "", authorities);
        
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }
    
    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.info("Invalid JWT token: {}", e.getMessage());
            return false;
        }
    }
}

인증/인가 컨트롤러

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
    
    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final UserService userService;
    
    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
            );
            
            String token = jwtTokenProvider.createToken(authentication);
            
            return ResponseEntity.ok(TokenResponse.builder()
                    .accessToken(token)
                    .tokenType("Bearer")
                    .expiresIn(3600L)
                    .build());
        } catch (BadCredentialsException e) {
            throw new InvalidCredentialsException("이메일 또는 비밀번호가 올바르지 않습니다");
        }
    }
    
    @PostMapping("/register")
    public ResponseEntity<UserDto> register(@Valid @RequestBody RegisterRequest request) {
        UserDto user = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }
    
    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshToken(
            @Valid @RequestBody RefreshTokenRequest request) {
        // 리프레시 토큰 처리 로직
        return ResponseEntity.ok(/* new token */);
    }
}

📝 API 문서화 (OpenAPI/Swagger)

OpenAPI 설정

@Configuration
public class OpenApiConfig {
    
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("User Management API")
                        .version("1.0.0")
                        .description("사용자 관리 시스템 REST API 문서")
                        .contact(new Contact()
                                .name("개발팀")
                                .email("dev@example.com")
                                .url("https://example.com"))
                        .license(new License()
                                .name("Apache 2.0")
                                .url("https://www.apache.org/licenses/LICENSE-2.0")))
                .addSecurityItem(new SecurityRequirement().addList("JWT"))
                .components(new Components()
                        .addSecuritySchemes("JWT", new SecurityScheme()
                                .name("JWT")
                                .type(SecurityScheme.Type.HTTP)
                                .scheme("bearer")
                                .bearerFormat("JWT")
                                .in(SecurityScheme.In.HEADER)
                                .description("JWT 토큰을 입력하세요")));
    }
}

API 문서 어노테이션 사용

@RestController
@RequestMapping("/api/users")
@Tag(name = "User", description = "사용자 관리 API")
@RequiredArgsConstructor
public class UserController {
    
    @Operation(
        summary = "사용자 목록 조회",
        description = "페이징과 검색을 지원하는 사용자 목록을 조회합니다.",
        responses = {
            @ApiResponse(responseCode = "200", description = "성공"),
            @ApiResponse(responseCode = "400", description = "잘못된 요청"),
            @ApiResponse(responseCode = "401", description = "인증 필요")
        }
    )
    @GetMapping
    public ResponseEntity<Page<UserDto>> getUsers(
            @Parameter(description = "페이지 번호", example = "0")
            @RequestParam(defaultValue = "0") int page,
            
            @Parameter(description = "페이지 크기", example = "20")  
            @RequestParam(defaultValue = "20") int size,
            
            @Parameter(description = "검색어", example = "john")
            @RequestParam(required = false) String search) {
        
        // 구현...
        return ResponseEntity.ok(users);
    }
    
    @Operation(summary = "사용자 생성", description = "새로운 사용자를 생성합니다.")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "생성 성공",
                    content = @Content(schema = @Schema(implementation = UserDto.class))),
        @ApiResponse(responseCode = "400", description = "입력값 검증 실패",
                    content = @Content(schema = @Schema(implementation = ValidationErrorResponse.class))),
        @ApiResponse(responseCode = "409", description = "이메일 중복",
                    content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
    })
    @PostMapping
    public ResponseEntity<UserDto> createUser(
            @Valid @RequestBody 
            @io.swagger.v3.oas.annotations.parameters.RequestBody(
                description = "생성할 사용자 정보",
                required = true,
                content = @Content(schema = @Schema(implementation = CreateUserRequest.class))
            ) 
            CreateUserRequest request) {
        
        // 구현...
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }
}

🧪 API 테스트 작성

Controller 단위 테스트 (@WebMvcTest)

@WebMvcTest(UserController.class)
@Import(TestSecurityConfig.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Test
    @DisplayName("사용자 목록 조회 - 성공")
    void getUsersSuccess() throws Exception {
        // Given
        List<UserDto> users = Arrays.asList(
                createUserDto(1L, "user1@test.com", "User 1"),
                createUserDto(2L, "user2@test.com", "User 2")
        );
        Page<UserDto> userPage = new PageImpl<>(users);
        
        when(userService.findAll(any(Pageable.class))).thenReturn(userPage);
        
        // When & Then
        mockMvc.perform(get("/api/users")
                        .param("page", "0")
                        .param("size", "10")
                        .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.content").isArray())
                .andExpect(jsonPath("$.content", hasSize(2)))
                .andExpect(jsonPath("$.content[0].email").value("user1@test.com"))
                .andExpect(jsonPath("$.totalElements").value(2));
        
        verify(userService).findAll(any(Pageable.class));
    }
    
    @Test
    @DisplayName("사용자 생성 - 검증 실패")
    void createUserValidationFail() throws Exception {
        // Given
        CreateUserRequest request = new CreateUserRequest();
        request.setEmail("invalid-email");  // 잘못된 이메일
        request.setPassword("123");         // 너무 짧은 비밀번호
        request.setName("");                // 빈 이름
        
        // When & Then
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andDo(print())
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
                .andExpect(jsonPath("$.fieldErrors").exists())
                .andExpect(jsonPath("$.fieldErrors.email").exists())
                .andExpect(jsonPath("$.fieldErrors.password").exists())
                .andExpect(jsonPath("$.fieldErrors.name").exists());
        
        verify(userService, never()).create(any());
    }
    
    private UserDto createUserDto(Long id, String email, String name) {
        return UserDto.builder()
                .id(id)
                .email(email)
                .name(name)
                .status(UserStatus.ACTIVE)
                .createdAt(LocalDateTime.now())
                .build();
    }
}

통합 테스트 (@SpringBootTest)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "spring.jpa.hibernate.ddl-auto=create-drop"
})
class UserControllerIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @LocalServerPort
    private int port;
    
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }
    
    @Test
    @DisplayName("사용자 CRUD 통합 테스트")
    void userCrudIntegrationTest() {
        String baseUrl = "http://localhost:" + port + "/api/users";
        
        // 1. 사용자 생성
        CreateUserRequest createRequest = new CreateUserRequest();
        createRequest.setEmail("test@example.com");
        createRequest.setPassword("Password123!");
        createRequest.setName("Test User");
        
        ResponseEntity<UserDto> createResponse = restTemplate.postForEntity(
                baseUrl, createRequest, UserDto.class);
        
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody().getEmail()).isEqualTo("test@example.com");
        Long userId = createResponse.getBody().getId();
        
        // 2. 사용자 조회
        ResponseEntity<UserDto> getResponse = restTemplate.getForEntity(
                baseUrl + "/" + userId, UserDto.class);
        
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getId()).isEqualTo(userId);
        
        // 3. 사용자 업데이트
        UpdateUserRequest updateRequest = new UpdateUserRequest();
        updateRequest.setName("Updated User");
        
        HttpEntity<UpdateUserRequest> updateEntity = new HttpEntity<>(updateRequest);
        ResponseEntity<UserDto> updateResponse = restTemplate.exchange(
                baseUrl + "/" + userId, HttpMethod.PUT, updateEntity, UserDto.class);
        
        assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(updateResponse.getBody().getName()).isEqualTo("Updated User");
        
        // 4. 사용자 삭제
        ResponseEntity<Void> deleteResponse = restTemplate.exchange(
                baseUrl + "/" + userId, HttpMethod.DELETE, null, Void.class);
        
        assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
        
        // 5. 삭제 확인
        ResponseEntity<ErrorResponse> getDeletedResponse = restTemplate.getForEntity(
                baseUrl + "/" + userId, ErrorResponse.class);
        
        assertThat(getDeletedResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

🎯 Part 3 정리

핵심 포인트 요약

  1. 웹 프레임워크 선택:
    • Spring MVC: 일반적인 웹 애플리케이션 (권장)
    • WebFlux: 대용량 동시 처리가 필요한 경우
  2. REST API 설계:
    • 리소스 중심 URL 설계
    • 적절한 HTTP 메서드와 상태 코드 사용
    • DTO와 Entity 분리
    • API 버전 관리
  3. 예외 처리:
    • @ControllerAdvice로 전역 예외 처리
    • 커스텀 예외 클래스 정의
    • 검증 그룹 활용
  4. 보안:
    • Spring Security 설정
    • JWT 토큰 기반 인증
    • 역할 기반 접근 제어
  5. 문서화 및 테스트:
    • OpenAPI/Swagger 문서 자동화
    • 단위 테스트와 통합 테스트
    • MockMvc를 활용한 API 테스트

실습 체크리스트

  •  RESTful API 컨트롤러 작성
  •  DTO 클래스와 검증 어노테이션 적용
  •  전역 예외 처리 구현
  •  페이징과 정렬 기능 구현
  •  Spring Security 설정
  •  JWT 토큰 기반 인증 구현
  •  OpenAPI 문서화 설정
  •  단위 테스트와 통합 테스트 작성

다음 편 예고

Part 4: 데이터 액세스와 배치에서는:

  • Spring Data JPA 고급 기능과 최적화
  • Connection Pool 설정과 튜닝 (실무 경험!)
  • Spring Batch 완전 정복
  • 트랜잭션 관리 전략
  • Redis 캐싱 활용법

데이터베이스 연동부터 배치 처리까지, 실무에서 가장 중요한 데이터 처리 기술들을 다룹니다! 💾


📚 참고 자료:

🏷️ 태그: #SpringBoot #REST_API #SpringMVC #SpringSecurity #웹개발 #실무가이드