관리 메뉴

bright jazz music

blog12: 예외처리 4 본문

Projects/blog

blog12: 예외처리 4

bright jazz music 2023. 1. 14. 22:54

현재 예외에 대한 응답은 아래와 같이 반환되고 있다.

 

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"400","message":"잘못된 요청입니다.","validation":{}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

요청의 성공과 실패 여부만 나타낼 뿐, 검증에 관한 이유를 밝히고 있지는 않다. 지금까지 우리가 작성한 코드 중에서는 validation에 값을 넣어 준 적이 없기 때문이다. 따라서 이번에는 validation 필드에 값을 추가하여 반환하는 기능을 구현한다.

 

비효율적인 방법과 효율적인 방법이 있다.

비효율적인 방법을 먼저 해본다.

 

PostController로 PostCreate 타입의 요청이 올 것이니까 추가.

//PostCreate.java

    public void validate() {
        if(title.contains("바보")){
            throw new InvalidRequest("title", "제목에 바보를 포함살 수 없습니다.");
        }
// InvalidRequest 생성자에 메시지를 넣어줌.
    }

}

 

//InvalidRequest.java
//status -> 400
package com.endofma.blog.exception;

import lombok.Getter;

//public class InvalidRequest extends RuntimeException {
@Getter
public class InvalidRequest extends BlogException {
    private static final String MESSAGE ="잘못된 요청입니다.";

    //final을 붙이면 오류 발생.
    // 인자를 받지 않는 생성자가 있으므로 초기화가 안될 수 있기 때문.
    public String fieldName;
    public String message;

    public InvalidRequest() {
        super(MESSAGE);
    }

    public InvalidRequest(String fieldName, String message){
        super(MESSAGE);
        this.fieldName = fieldName;
        this.message = message;
    }


    //글 작성 등에서 글 작성에서 @NotBlank와 같은 어노테이션으로
    //해결할 수 없을 경우 추가적인 방법이 필요할 때.
    //PostController의 post()에 보내는 요청의 제목에 "바보"라는 글자를 넣을 수 없다면.

    @Override
    public int getStatusCode() {
        return 400;
    }

}

 

//ExceptionController.java

/////BlogException을 사용하여 에러처리
    @ResponseBody
//    @ResponseStatus(HttpStatus.NOT_FOUND) //헤더스테이터스. 동적으로 변경하기 어려움.
    //빼면 기본값 200으로 내려감
    @ExceptionHandler(BlogException.class)
    //BlogException을 상속하는 에러클래스가 호출되었을 경우 무조건 이 메소드가 호출된다.
    public ResponseEntity<ErrorResponse> blogException(BlogException e) {
        //ResponseEntity는 스프링에서 기본적으로 제공하는 객체로 반환한다.

        int statusCode = e.getStatusCode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode)) //String.valuOf()사용해서 int를 String으로 변경
                .message(e.getMessage())
                .build();

        //응답 json validation필드에 값 추가. title을 키값으로.
        //title: 제목에 바보를 포함할 수 없습니다.
        if (e instanceof InvalidRequest){
            InvalidRequest invalidRequest = (InvalidRequest) e;
            String fieldName = invalidRequest.getFieldName();
            String message = invalidRequest.getMessage();
            body.addValidation(fieldName, message);
        }
        ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
                .body(body);

        return response;
    }

 

테스트 11

 

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"400","message":"제목에 바보를 포함살 수 없습니다.","validation":{"title":"제목에 바보를 포함살 수 없습니다."}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

 

 

그런데 이런 방법은 비효율적이다. 나중엔 if문이 너무 많아질 수도 있고 로직이 계속해서 추가되면 코드 가독성이 떨어질 수도 있다.

 

 

 

 

 

두 번째 방법.

 

 

 

 

 

 

 

 

 

 

 

 

 

//PostController.java

package com.endofma.blog.controller;

import com.endofma.blog.domain.Post;
import com.endofma.blog.exception.InvalidRequest;
import com.endofma.blog.request.PostCreate;
import com.endofma.blog.request.PostEdit;
import com.endofma.blog.request.PostSearch;
import com.endofma.blog.response.PostResponse;
import com.endofma.blog.service.PostService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;


@Slf4j
@RestController
@RequiredArgsConstructor
public class PostController {
    //에러는 ExceptionController에서 처리한다.
    private final PostService postService;


    @PostMapping("/posts")
    public void post(@RequestBody @Valid PostCreate request) {

        //PostController의 post()에 보내는 요청의 제목에 "바보"라는 글자를 넣을 수 없다면.
//        if(request.getTitle().contains("바보")){
//            throw new InvalidRequest();
//        } 이렇게 데이터를 꺼내서 분리해서 재조립하는 방식보다는 아래처럼 메시지를 통해 가져오는 방식 권장
        request.validate(); //검증

        postService.write(request);
//        return Map.of();
    }

    //단건 조회
    @GetMapping("/posts/{postId}")
    public PostResponse get(@PathVariable Long postId){

        PostResponse response = postService.get(postId);
        return response;
    }

    //여러 글 조회(글 목록 가져오기)
    //  /posts
    @GetMapping("/posts")

    //글이 너무 많은 경우 비용이 너무 많이 든다.
    //DB가 뻗을 수 있음.
    //DB -> 애플리케이션 서버로 전달하는 시간, 트래픽 비용이 많이 발생할 수 있다.
    //따라서 페이지 설정

//    원래는 int로 받았음
//   public List<PostResponse> getList(@RequestParam int page){

// 그러나 사용의 용이성을 위해 pageable을 사용함
//   public List<PostResponse> getList(@PageableDefault Pageable pageable){ //1로 넘겨도 0으로 보정해서 넣어줌.
    //근데 PageableDefault의 기본 size가 10이라 yml에서 설정해도 먹히지 않는다.
    //이 떄는 어노테이션을 그대로 유지하면서 size를 파라미터로 넣어주는 방법이 있다.
    //public List<PostResponse> getList(@PageableDefault(size=10) Pageable pageable){

    //또는 어노테이션을 빼고 application.yml에서 default-page-size를 설정하여 해결할 수 수있다.

    //전에는 Pageable을 사용했으나 여러 요구사항을 수용 할 수 있는 클래스를 사용하기 위해
    //postSearch 클래스 사용


   public List<PostResponse> getList(@ModelAttribute PostSearch postSearch){//따로 만든 요청클래스 사용하려고 함
//   public List<PostResponse> getList(Pageable pageable){
        return postService.getList(postSearch);

    }

    @PatchMapping("/posts/{postId}")
    public void edit(@PathVariable Long postId, @RequestBody @Valid PostEdit request){


        postService.edit(postId, request);
    }

    @DeleteMapping("/posts/{postId}")
    public void delete(@PathVariable Long postId){
        postService.delete(postId);
    }

}

 

//PostCreate.java

package com.endofma.blog.request;

import com.endofma.blog.exception.InvalidRequest;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.validation.constraints.NotBlank;

@Setter
@Getter
@ToString
public class PostCreate {
    @NotBlank(message = "타이틀을 입력하세요!")
    //@Valid를 @RequestBody 옆에 @valid를 붙임. 그러면 바인딩 되면서 빈 값이 넘어오면 에러발생시킴.
    private String title;

    @NotBlank(message = "내용을 입력하세요!")
    private String content;

    @Builder //클래스에 달면 다른 생성자와 모순이 발생할 수 있기 때문에 생성자에 다는 것을 추천. builder + NoArgsConstructor 에러
    public PostCreate(String title, String content) {
        this.title = title;
        this.content = content;
    }
    //빌더의 장점
    // - 가독성 up (값 생성에 대한 유연함)
    // - 필요한 값만 받을 수 있다. // -> (오버로딩 가능한 조건 찾아보기)
    // - 객체의 불변성


    public void validate() {
        if(title.contains("바보")){
            throw new InvalidRequest("title", "제목에 바보를 포함살 수 없습니다.");
        }
        //boolean을 통해 검증 실패 시 false, 성공시 true를 컨트롤러로 반환하고
        //컨트롤러에서 에러를 생성해 줄 수도 있지만 사실 그럴 필요도 없다.
        //여기서 검증하고 예외를 죽이는 게 더 효율적이기 때문이다.
        //이런 역할을 하는 라이브러리를 사용해도 된다.
    }

}

 

 

//InvalidRequest.java
//status -> 400
package com.endofma.blog.exception;

import lombok.Getter;

//public class InvalidRequest extends RuntimeException {
@Getter
public class InvalidRequest extends BlogException {
    private static final String MESSAGE ="잘못된 요청입니다.";

    public InvalidRequest() {
        super(MESSAGE);
    }

    public InvalidRequest(String fieldName, String message){
        super(MESSAGE);
        addValidation(fieldName, message); //생성 시점에 설정. BlogException으로부터 상속
    }


    //글 작성 등에서 글 작성에서 @NotBlank와 같은 어노테이션으로
    //해결할 수 없을 경우 추가적인 방법이 필요할 때.
    //PostController의 post()에 보내는 요청의 제목에 "바보"라는 글자를 넣을 수 없다면.

    @Override
    public int getStatusCode() {
        return 400;
    }

}

 

//BlogException.java

package com.endofma.blog.exception;

import lombok.Getter;

import java.util.HashMap;
import java.util.Map;

@Getter
public abstract class BlogException extends RuntimeException{

    //validation값 추가 위해 작성
    public final Map<String, String> validation = new HashMap<>();

    public BlogException(String message){
        super(message);
    }

    public BlogException(String message, Throwable cause){
        super(message, cause);
    }

    //추상메서드: 반드시 구현 필요
    public abstract int getStatusCode();

    public void addValidation(String fieldName, String message){
        this.validation.put(fieldName, message);

    }

}

 

//ExceptionController.java

package com.endofma.blog.controller;

import com.endofma.blog.exception.BlogException;
import com.endofma.blog.exception.InvalidRequest;
import com.endofma.blog.exception.PostNotFound;
import com.endofma.blog.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@ControllerAdvice
public class ExceptionController {


    @ResponseStatus(HttpStatus.BAD_REQUEST) //400
    @ResponseBody //응답을 json으로 보내기 위해 추가
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse invalidRequestHandler(MethodArgumentNotValidException e) {

//        ErrorResponse response = new ErrorResponse("400", "잘못된 요청입니다!");
        ErrorResponse response = ErrorResponse.builder()
                .code("400")
                .message("잘못된 요청입니다!")
                .build();

        for(FieldError fieldError : e.getFieldErrors()){
            response.addValidation(fieldError.getField(), fieldError.getDefaultMessage());

        }

        return response;
    }

    @ResponseBody
    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(PostNotFound.class)
    public ErrorResponse postNotFound(PostNotFound e) {
        ErrorResponse response = ErrorResponse.builder()
                .code("404")
                .message(e.getMessage()) //익셉션으로 받은 에러 메시지
                .build();

        return response;
    }

//    @ResponseBody
//    @ResponseStatus(HttpStatus.NOT_FOUND)
//    @ExceptionHandler(PostNotFound.class)
//    public ErrorResponse invalidRequest(InvalidRequest e) {
//        ErrorResponse response = ErrorResponse.builder()
//                .code("404")
//                .message(e.getMessage()) //익셉션으로 받은 에러 메시지
//                .build();
//
//        return response;
//    }


    /////BlogException을 사용하여 에러처리
    @ResponseBody
//    @ResponseStatus(HttpStatus.NOT_FOUND) //헤더스테이터스. 동적으로 변경하기 어려움.
    //빼면 기본값 200으로 내려감
    @ExceptionHandler(BlogException.class)
    //BlogException을 상속하는 에러클래스가 호출되었을 경우 무조건 이 메소드가 호출된다.
    public ResponseEntity<ErrorResponse> blogException(BlogException e) {
        //ResponseEntity는 스프링에서 기본적으로 제공하는 객체로 반환한다.

        int statusCode = e.getStatusCode();

        ErrorResponse body = ErrorResponse.builder()
                .code(String.valueOf(statusCode)) //String.valuOf()사용해서 int를 String으로 변경
                .message(e.getMessage())
                //응답 json validation필드에 값 추가. title을 키값으로. title: 제목에 바보를 포함할 수 없습니다.
                .validation(e.getValidation())
                .build();





        ResponseEntity<ErrorResponse> response = ResponseEntity.status(statusCode)
                .body(body);

        return response;
    }




}

 

//ErrorResponse.java
package com.endofma.blog.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.HashMap;
import java.util.Map;

/*
* {
*   "code": "400",
*   "message": "잘못된 요청입니다."
*   "validation": {
*       "title": "값을 입력해주세요"
*   }
* }
*/
@Getter    //게터가 없으면 값을 가져올 수 없어서 ResponseBody에 넣어주질 못함.(빈 바디)
//@RequiredArgsConstructor
//@JsonInclude(value = JsonInclude.Include.NON_EMPTY) //비어있지 않은 데이터만 보낸다.
public class ErrorResponse {
    private final String code;
    private final String message;
    private final Map<String, String> validation;

    @Builder
    public ErrorResponse(String code, String message, Map<String, String> validation){
        this.code = code;
        this.message = message;
        this.validation = validation;
    }
    public void addValidation(String fieldName, String errorMessage){
//        ValidationTuple validationTuple = new ValidationTuple(fieldName, errorMessage);
        this.validation.put(fieldName, errorMessage);
    }

//    Map을 안 쓰기 위해서 내부 클래스를 만들었으니 이걸로 위의 Map을 대체해 보자..현재는 안 되어 있음
//    @RequiredArgsConstructor
//    private class ValidationTuple{
//        private final String fieldName;
//        private final String errorMessage;
//}

}

 

 

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2023-01-14 23:37:53.307  INFO 36980 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Starting PostControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 36980 (started by user in D:\personal\blog)
2023-01-14 23:37:53.309  INFO 36980 --- [    Test worker] c.e.blog.controller.PostControllerTest   : No active profile set, falling back to 1 default profile: "default"
2023-01-14 23:37:54.311  INFO 36980 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2023-01-14 23:37:54.390  INFO 36980 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 68 ms. Found 1 JPA repository interfaces.
2023-01-14 23:37:55.122  INFO 36980 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2023-01-14 23:37:55.464  INFO 36980 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2023-01-14 23:37:55.583  INFO 36980 --- [    Test worker] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
2023-01-14 23:37:55.671  INFO 36980 --- [    Test worker] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 5.6.14.Final
2023-01-14 23:37:55.981  INFO 36980 --- [    Test worker] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
2023-01-14 23:37:56.191  INFO 36980 --- [    Test worker] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
2023-01-14 23:37:57.084  INFO 36980 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-01-14 23:37:57.095  INFO 36980 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-01-14 23:37:57.959  WARN 36980 --- [    Test worker] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2023-01-14 23:37:58.390  INFO 36980 --- [    Test worker] o.s.b.a.h2.H2ConsoleAutoConfiguration    : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:blog'
2023-01-14 23:37:58.815  INFO 36980 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2023-01-14 23:37:58.815  INFO 36980 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2023-01-14 23:37:58.816  INFO 36980 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 1 ms
2023-01-14 23:37:58.855  INFO 36980 --- [    Test worker] c.e.blog.controller.PostControllerTest   : Started PostControllerTest in 6.07 seconds (JVM running for 8.924)

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /posts
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"61"]
             Body = {"title":"나는 바보입니다.","content":"제이드빌."}
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#post(PostCreate)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = com.endofma.blog.exception.InvalidRequest

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"400","message":"잘못된 요청입니다.","validation":{"title":"제목에 바보를 포함살 수 없습니다."}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
2023-01-14 23:37:59.576  INFO 36980 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-01-14 23:37:59.577  INFO 36980 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2023-01-14 23:37:59.581  WARN 36980 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-01-14 23:37:59.581 ERROR 36980 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-01-14 23:37:59.582  WARN 36980 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-01-14 23:37:59.583 ERROR 36980 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-01-14 23:37:59.583  WARN 36980 --- [ionShutdownHook] o.s.b.f.support.DisposableBeanAdapter    : Invocation of destroy method failed on bean with name 'entityManagerFactory': org.hibernate.exception.JDBCConnectionException: Unable to release JDBC Connection used for DDL execution
2023-01-14 23:37:59.584  INFO 36980 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2023-01-14 23:37:59.590  INFO 36980 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

'Projects > blog' 카테고리의 다른 글

blog13: Spring REST Docs2 - 요청, 응답필드  (0) 2023.01.16
blog13: Spring REST Docs 1 -기본설정  (0) 2023.01.15
blog12: 예외처리 3  (0) 2023.01.14
blog12: 예외처리2  (0) 2023.01.13
blog12: 예외처리1  (0) 2023.01.10
Comments