JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

Spring

백엔드에서 API 응답의 HTTP 상태코드를 설정하는 방법과 원칙

lamarcK 2025. 6. 26. 10:23

개요

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의 품질을 결정하는 중요한 요소입니다

핵심 원칙

  1. 의미에 맞는 상태코드 사용: 실제 처리 결과와 일치해야 함
  2. 일관성 유지: 프로젝트 전체에서 동일한 패턴 적용
  3. 클라이언트 친화적: 클라이언트가 쉽게 이해하고 처리할 수 있도록
  4. RESTful 원칙 준수: HTTP 표준과 REST 가이드라인 따르기

개발자의 역할

  • 비즈니스 로직에 맞는 적절한 상태코드 선택
  • 예외 상황에 대한 명확한 응답 제공
  • 클라이언트와의 원활한 소통을 위한 일관된 API 설계