Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
Tags
- 메시징 패턴
- 세션저장소
- 클라우드
- 메시지 브로커
- 마이크로서비스 운영
- 분산 시스템
- 클러스터
- RabbitMQ Exchange
- 서비스 메시
- Python
- rabbitmq
- ApacheBench
- Kafka 클러스터
- 마이크로서비스
- 서비스 설계
- 고가용성
- kubernetes
- 분산 모니터링
- 이벤트 스트리밍
- 모니터링
- devops
- 마이크로서비스 통신
- 모노리스 분해
- infrastructureascode
- 보안
- 컨테이너오케스트레이션
- CI/CD
- 프로덕션 운영
- docker
- 인메모리데이터베이스
Archives
- Today
- Total
hobokai 님의 블로그
Spring Boot 완전 가이드 Part 3: 웹 개발과 REST API 본문
시리즈 소개: 실무에서 바로 써먹는 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 정리
핵심 포인트 요약
- 웹 프레임워크 선택:
- Spring MVC: 일반적인 웹 애플리케이션 (권장)
- WebFlux: 대용량 동시 처리가 필요한 경우
- REST API 설계:
- 리소스 중심 URL 설계
- 적절한 HTTP 메서드와 상태 코드 사용
- DTO와 Entity 분리
- API 버전 관리
- 예외 처리:
- @ControllerAdvice로 전역 예외 처리
- 커스텀 예외 클래스 정의
- 검증 그룹 활용
- 보안:
- Spring Security 설정
- JWT 토큰 기반 인증
- 역할 기반 접근 제어
- 문서화 및 테스트:
- 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 #웹개발 #실무가이드
'Backend' 카테고리의 다른 글
| Spring Boot 완전 가이드 Part 5: 운영과 모니터링 (1) | 2025.08.06 |
|---|---|
| Spring Boot 완전 가이드 Part 4: 데이터 액세스와 배치 (2) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 2: 설정과 자동구성 (2) | 2025.08.06 |
| Spring Boot 완전 가이드 Part 1: 기초편 - 시작하기 (2) | 2025.08.06 |
| Redis 완전 마스터 가이드: 캐싱부터 실시간 애플리케이션까지 고성능 데이터 솔루션 (3) | 2025.07.23 |