개요
HTTP 상태코드는 클라이언트에게 요청 처리 결과를 명확하게 전달하는 중요한 수단입니다. Spring Boot에서는 ResponseEntity
를 통해 개발자가 상황에 맞는 적절한 상태코드를 설정할 수 있습니다.
HTTP 상태코드의 기본 분류
2xx - 성공
- 200 OK: 요청이 성공적으로 처리됨
- 201 Created: 새로운 리소스가 성공적으로 생성됨
- 204 No Content: 요청은 성공했지만 응답할 내용이 없음
4xx - 클라이언트 오류
- 400 Bad Request: 잘못된 요청
- 401 Unauthorized: 인증이 필요함
- 403 Forbidden: 권한이 없음
- 404 Not Found: 요청한 리소스를 찾을 수 없음
- 409 Conflict: 리소스 충돌
5xx - 서버 오류
- 500 Internal Server Error: 서버 내부 오류
- 503 Service Unavailable: 서비스 이용 불가
Spring Boot에서 상태코드 설정 방법
기본 성공 응답
@RestController
@RequestMapping("/api/members")
public class MemberController {
// 조회 성공 - 200 OK
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMemberById(@PathVariable Long id) {
MemberResponseDto member = memberService.getMemberById(id);
return ResponseEntity.ok(member); // 200 OK
}
// 생성 성공 - 201 Created
@PostMapping
public ResponseEntity<MemberResponseDto> createMember(@RequestBody MemberRequestDto requestDto) {
MemberResponseDto responseDto = memberService.createMember(requestDto);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); // 201 Created
}
// 삭제 성공 - 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMember(@PathVariable Long id) {
memberService.deleteMember(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
예외 상황별 상태코드 설정
@GetMapping("/{id}")
public ResponseEntity<MemberResponseDto> getMemberById(@PathVariable Long id) {
try {
MemberResponseDto member = memberService.getMemberById(id);
return ResponseEntity.ok(member); // 200 OK
} catch (MemberNotFoundException e) {
return ResponseEntity.notFound().build(); // 404 Not Found
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); // 403 Forbidden
} catch (Exception e) {
return ResponseEntity.internalServerError().build(); // 500 Internal Server Error
}
}
@PostMapping
public ResponseEntity<MemberResponseDto> createMember(@RequestBody MemberRequestDto requestDto) {
try {
MemberResponseDto responseDto = memberService.createMember(requestDto);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); // 201 Created
} catch (DuplicateEmailException e) {
return ResponseEntity.status(HttpStatus.CONFLICT) // 409 Conflict
.body(new ErrorResponse("이미 존재하는 이메일입니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest() // 400 Bad Request
.body(new ErrorResponse("잘못된 요청입니다."));
}
}
ResponseEntity 편의 메서드
성공 응답
// 200 OK - 데이터와 함께
ResponseEntity.ok(data)
ResponseEntity.ok().body(data)
// 201 Created - 생성 완료
ResponseEntity.status(HttpStatus.CREATED).body(data)
// 204 No Content - 내용 없음
ResponseEntity.noContent().build()
오류 응답
// 400 Bad Request
ResponseEntity.badRequest().build()
ResponseEntity.badRequest().body(errorMessage)
// 404 Not Found
ResponseEntity.notFound().build()
// 401 Unauthorized
ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
// 500 Internal Server Error
ResponseEntity.internalServerError().build()
실무 적용 예시
RESTful API 설계에 따른 상태코드
@RestController
@RequestMapping("/api/products")
public class ProductController {
// GET /api/products - 목록 조회
@GetMapping
public ResponseEntity<List<ProductDto>> getProducts() {
List<ProductDto> products = productService.getAllProducts();
if (products.isEmpty()) {
return ResponseEntity.ok(Collections.emptyList()); // 200 OK (빈 리스트도 성공)
}
return ResponseEntity.ok(products); // 200 OK
}
// GET /api/products/{id} - 단건 조회
@GetMapping("/{id}")
public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) {
Optional<ProductDto> product = productService.getProductById(id);
return product.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
// POST /api/products - 생성
@PostMapping
public ResponseEntity<ProductDto> createProduct(@Valid @RequestBody CreateProductDto request) {
ProductDto createdProduct = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdProduct); // 201 Created
}
// PUT /api/products/{id} - 전체 수정
@PutMapping("/{id}")
public ResponseEntity<ProductDto> updateProduct(@PathVariable Long id,
@Valid @RequestBody UpdateProductDto request) {
if (!productService.existsById(id)) {
return ResponseEntity.notFound().build(); // 404 Not Found
}
ProductDto updatedProduct = productService.updateProduct(id, request);
return ResponseEntity.ok(updatedProduct); // 200 OK
}
// DELETE /api/products/{id} - 삭제
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
if (!productService.existsById(id)) {
return ResponseEntity.notFound().build(); // 404 Not Found
}
productService.deleteProduct(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
비즈니스 로직에 따른 상태코드 분기
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
try {
// 입력값 검증
if (request.getEmail() == null || request.getPassword() == null) {
return ResponseEntity.badRequest() // 400 Bad Request
.body(new LoginResponse(null, "이메일과 비밀번호는 필수입니다."));
}
// 사용자 존재 여부 확인
if (!userService.existsByEmail(request.getEmail())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) // 401 Unauthorized
.body(new LoginResponse(null, "존재하지 않는 사용자입니다."));
}
// 비밀번호 검증
if (!userService.isValidPassword(request.getEmail(), request.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) // 401 Unauthorized
.body(new LoginResponse(null, "잘못된 비밀번호입니다."));
}
// 계정 상태 확인
if (!userService.isAccountActive(request.getEmail())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) // 403 Forbidden
.body(new LoginResponse(null, "비활성화된 계정입니다."));
}
// 로그인 성공
String token = authService.generateToken(request.getEmail());
return ResponseEntity.ok(new LoginResponse(token, "로그인 성공")); // 200 OK
} catch (Exception e) {
return ResponseEntity.internalServerError() // 500 Internal Server Error
.body(new LoginResponse(null, "서버 오류가 발생했습니다."));
}
}
상태코드 설정 원칙
일관성 유지
// ✅ 좋은 예 - 일관된 패턴
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
@GetMapping("/profile/{id}")
public ResponseEntity<ProfileDto> getProfile(@PathVariable Long id) {
return profileService.findById(id)
.map(ResponseEntity::ok) // 200 OK - 동일한 패턴
.orElse(ResponseEntity.notFound().build()); // 404 Not Found - 동일한 패턴
}
의미있는 상태코드 사용
// ❌ 나쁜 예 - 의미없는 상태코드
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody UserDto userDto) {
UserDto createdUser = userService.createUser(userDto);
return ResponseEntity.ok(createdUser); // 생성인데 200 OK?
}
// ✅ 좋은 예 - 의미있는 상태코드
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody UserDto userDto) {
UserDto createdUser = userService.createUser(userDto);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser); // 201 Created
}
클라이언트 친화적 응답
@PostMapping("/upload")
public ResponseEntity<UploadResponse> uploadFile(@RequestParam("file") MultipartFile file) {
// 파일 크기 검증
if (file.getSize() > MAX_FILE_SIZE) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) // 413 Payload Too Large
.body(new UploadResponse(false, "파일 크기가 너무 큽니다."));
}
// 파일 형식 검증
if (!isValidFileType(file)) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) // 415 Unsupported Media Type
.body(new UploadResponse(false, "지원하지 않는 파일 형식입니다."));
}
// 업로드 성공
String fileUrl = fileService.upload(file);
return ResponseEntity.ok(new UploadResponse(true, "업로드 성공", fileUrl)); // 200 OK
}
잘못된 사용 예시와 문제점
상태코드와 실제 결과 불일치
// ❌ 문제가 있는 코드
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id); // 실제로는 정상 조회됨
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(user); // 404인데 데이터 있음??
}
문제점:
- 클라이언트가 혼란스러워함
- API 신뢰성 하락
- 디버깅 어려움
클라이언트에서의 문제 상황
// 클라이언트에서는 이렇게 처리하게 됨
fetch('/api/users/1')
.then(response => {
if (!response.ok) { // 404라서 여기로 빠짐
throw new Error('사용자를 찾을 수 없습니다.');
}
return response.json(); // 실행되지 않음
})
.catch(error => {
console.log('에러:', error); // 데이터가 있는데도 에러로 처리
});
전역 예외 처리를 통한 일관된 상태코드 관리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleEntityNotFound(EntityNotFoundException e) {
ErrorResponse errorResponse = new ErrorResponse("NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); // 404
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
ErrorResponse errorResponse = new ErrorResponse("BAD_REQUEST", e.getMessage());
return ResponseEntity.badRequest().body(errorResponse); // 400
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException e) {
ErrorResponse errorResponse = new ErrorResponse("FORBIDDEN", "접근 권한이 없습니다.");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); // 403
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception e) {
ErrorResponse errorResponse = new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다.");
return ResponseEntity.internalServerError().body(errorResponse); // 500
}
}
결론
HTTP 상태코드는 API의 품질을 결정하는 중요한 요소입니다
핵심 원칙
- 의미에 맞는 상태코드 사용: 실제 처리 결과와 일치해야 함
- 일관성 유지: 프로젝트 전체에서 동일한 패턴 적용
- 클라이언트 친화적: 클라이언트가 쉽게 이해하고 처리할 수 있도록
- RESTful 원칙 준수: HTTP 표준과 REST 가이드라인 따르기
개발자의 역할
- 비즈니스 로직에 맞는 적절한 상태코드 선택
- 예외 상황에 대한 명확한 응답 제공
- 클라이언트와의 원활한 소통을 위한 일관된 API 설계
'Spring' 카테고리의 다른 글
🚨 Spring Boot 컴포넌트 스캔 범위 문제 트러블 슈팅 (2) | 2025.06.26 |
---|---|
스프링 부트 SQLite DB 연결 (0) | 2025.06.24 |
Spring Boot 설정 시스템 application.properties, application-dev.yml (0) | 2025.06.24 |
스프링에서 받을 데이터는 '명시적'이어야 한다. (0) | 2025.06.13 |
스프링 빈의 종류 : 스코프 (1) | 2025.06.10 |