ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 공통 응답 처리와 예외 핸들링
    이커머스 devops/국비교육 2025. 11. 16. 11:57

    일관된 API를 위한 공통 응답 객체 설계

    • 예측 가능성 : 모든 API 응답이 동일한 구조를 가지므로 클라이언트 개발자가 결과를 예측하고 처리하기 쉬움
    • 유연한 확장 : 공통 포맷 안에 서버 상태, 페이징 정보 등 추가적인 메타데이터를 담기 용이하다

     

     

    ApiResponse<T> 설계 예시

    • 모든 응답을 감싸는 제네릭 클래스 설계
    • 성공 시에는 data 필드에, 실패 시에는 error 필드에 정보를 담는다
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @FieldDefaults(level = AccessLevel.PRIVATE)
    public class ApiResponse<T> {
        Boolean result;
        Error error;
        T message;
    
        public static <T> ApiResponse<T> success() {
            return success(null);
        }
    
        public static <T> ApiResponse<T> success(T message) {
            return ApiResponse.<T>builder()
                    .result(true)
                    .message(message)
                    .build();
        }
    
        public static <T> ResponseEntity<ApiResponse<T>> error(String code, String errorMessage) {
            return ResponseEntity.ok(ApiResponse.<T>builder()
                    .result(false)
                    .error(Error.of(code, errorMessage))
                    .build());
        }
    
        public static <T> ResponseEntity<ApiResponse<T>> badRequest(String code, String errorMessage) {
            return ResponseEntity.badRequest().body(ApiResponse.<T>builder()
                    .result(false)
                    .error(Error.of(code, errorMessage))
                    .build());
        }
    
        public static <T> ResponseEntity<ApiResponse<T>> serverError(String code, String errorMessage) {
            return ResponseEntity.status(500).body(ApiResponse.<T>builder()
                    .result(false)
                    .error(Error.of(code, errorMessage))
                    .build());
        }
    
        @JsonInclude(JsonInclude.Include.NON_NULL)
        public record Error(String errorCode, String errorMessage) {
            public static Error of(String errorCode, String errorMessage) {
                return new Error(errorCode, errorMessage);
            }
        }
    }
    // Controller 적용 예시
    // 단일 상품 조회
    @GetMapping("/{id}")
    public ApiResponse<ProductResponse> getById(@PathVariable Long id) {
        return ApiResponse.success(productService.getById(id));
    }
    
    // 상품 삭제
    @DeleteMapping("/{id}")
    public ApiResponse<Void> delete(@PathVariable Long id) {
        productService.delete(id);
        return ApiResponse.success();
    }

     

     

    예측 가능한 오류 처리를 위한 전역 예외 핸들링

    • 애플리케이션의 모든 예외를 한 곳에서 중앙 처리하여 일관되고 정제된 오류 메시지를 클라이언트에게 반환한다

     

     

    1. 커스텀 예외와 에러 코드 정의

    - 애플리케이션에서 발생할 수 있는 비즈니스 예외 상황을 명확하게 정의한다

    • ServiceExceptionCode Enum : 오류 코드와 메시지를 한 곳에서 관리한다
    @Getter
    @AllArgsConstructor
    @FieldDefaults(level = AccessLevel.PRIVATE)
    public enum ServiceExceptionCode {
        NOT_FOUND_PRODUCT("상품을 찾을 수 없습니다."),
        INSUFFICIENT_STOCK("상품의 재고가 부족합니다.");
        // ... 다른 예외 코드들
    
        final String message;
    }
    • ServiceException 클래스 : 비즈니스 로직에서 발생하는 예외를 위한 커스텀 클래스
    @Getter
    @NoArgsConstructor
    @FieldDefaults(level = AccessLevel.PRIVATE)
    public class ServiceException extends RuntimeException {
        String code;
        String message;
    
        public ServiceException(ServiceExceptionCode response) {
            super(response.getMessage());
            this.code = response.name();
            this.message = super.getMessage();
        }
    
        @Override
        public String getMessage() {
            return message;
        }
    }

     

     

    2. @RestControllerAdvice 전역 예외 처리기 구현

    - 모든 Controller에서 발생하는 예외를 가로채 처리하는 클래스를 만든다

    - @RestControllerAdvice = @ControllerAdvice + @ResponseBody

    @Hidden
    @RestControllerAdvice
    public class GlobalExceptionHandler {
        private final String VALIDATE_ERROR = "VALIDATE_ERROR";
        private final String SERVER_ERROR = "SERVER_ERROR";
    
        @ExceptionHandler(ServiceException.class)
        public ResponseEntity<?> handleResponseException(ServiceException ex) {
            // 비즈니스 로직에서 직접 던진 커스텀 예외 처리
            return ApiResponse.error(ex.getCode(), ex.getMessage());
        }
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResponseEntity<?> methodArgumentNotValidException(MethodArgumentNotValidException ex) {
            // @Valid 또는 @Validated가 붙은 DTO 검증 실패 시 발생
            AtomicReference<String> errors = new AtomicReference<>("");
            ex.getBindingResult().getAllErrors().forEach(c -> errors.set(c.getDefaultMessage()));
    
            return ApiResponse.badRequest(VALIDATE_ERROR, String.valueOf(errors));
        }
    
        @ExceptionHandler(BindException.class)
        public ResponseEntity<?> bindException(BindException ex) {
            // @RequestParam, @ModelAttribute 바인딩 시 검증 실패했을 때
            // (JSON → DTO 변환이 아닌 폼데이터 / 쿼리파라미터 검증 실패)
            AtomicReference<String> errors = new AtomicReference<>("");
            ex.getBindingResult().getAllErrors().forEach(c -> errors.set(c.getDefaultMessage()));
    
            return ApiResponse.badRequest(VALIDATE_ERROR, String.valueOf(errors));
        }
    
        @ExceptionHandler(Exception.class)
        public ResponseEntity<?> serverException(Exception ex) {
            // 잡히지 않은 모든 예외의 마지막 방어
            return ApiResponse.serverError(SERVER_ERROR, ex.getMessage());
        }
    }

    cf. methodArgumentNotValidException & bindException

    - 체크 예외 계열로 스프링 MVC가 컨트롤러 메서드 호출 전 던진다

    - 명시적으로 잡지 않으면 스프링 기본 리졸버가 HTTP 400으로 변환한다

     

     

    3. 서비스 로직에서 예외 발생

    - 서비스 코드에서 try-catch 없이, 필요한 상황에 직접 정의한 예외를 발생시키면 된다

    public ProductResponse getById(Long id) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ServiceException(ServiceExceptionCode.NOT_FOUND_PRODUCT));
    
        return ProductResponse.builder()
            .id(product.getId())
            .categoryId(product.getCategoryId())
            .name(product.getName())
            .description(product.getDescription())
            .price(product.getPrice())
            .stock(product.getStock())
            .createdAt(product.getCreatedAt())
            .build();
      }
    {
      "result": false,
      "data": null,
      "error": {
        "code": "NOT_FOUND_PRODUCT",
        "message": "상품을 찾을 수 없습니다."
      }
    }

     

     

    cf. 성공 응답 코드 상황별 반환

    • 200 Ok : 요청이 성공적으로 처리 (주로 GET, PUT 응답)
    • 201 Created : 리소스가 성공적으로 생성(POST 응답), 응답 헤더의 Location에 생성된 리소스의 URL 포함
    • 204 No Content : 요청은 성공했지만 응답으로 보낼 데이터 없음 (주로 DELETE 성공 응답)
    // 200 OK
    @GetMapping("/books/{id}")
    public ResponseEntity<ApiResponse<BookResponse>> getBook(@PathVariable Long id) {
        BookResponse book = bookService.find(id);
        return ResponseEntity.ok(ApiResponse.success(book)); 
    }
    
    // 201 Created
    @PostMapping("/books")
    public ResponseEntity<ApiResponse<BookResponse>> create(@RequestBody @Valid BookRequest request) {
        BookResponse created = bookService.save(request);
        URI location = URI.create("/books/" + created.getId());
        return ResponseEntity.created(location)
                             .body(ApiResponse.success(created));
    }
    
    // 204 No Content
    @DeleteMapping("/books/{id}")
    public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
        bookService.delete(id);
        return ResponseEntity.noContent().build();
    }
    • 표준 REST 규약 준수
    • 클라이언트 해석 명확
    • Swagger/문서화 깔끔
    • 불필요한 body 제거 : 204는 body 없음, 네트워크 비용 절약
    • 테스트/예외처리 용이 : status code 기준으로 테스트케이스 분기 가능
    728x90

    '이커머스 devops > 국비교육' 카테고리의 다른 글

    QueryDSL 기본 문법  (0) 2025.11.19
    영속성 컨텍스트와 트랜잭션 & QueryDSL  (0) 2025.11.16
    RESTful API 설계하기  (0) 2025.11.16
    ORM 심화 및 데이터 로딩 전략  (0) 2025.11.15
    프로젝트 기본 설정  (0) 2025.11.15
Designed by Tistory.