(JPA+Boot)검증과 예외처리

스프링 애플리케이션에서 검증(Validation)과 예외처리(Exception Handling)를 효과적으로 구현하여 웹과 API 개발의 안정성을 확보하는 방법과 전략을 상세히 설명하는 가이드입니다.



검증(Valid)과 예외처리(Exception)

API의 경우 클라쪽 “검증”은 서버가 할 일이 아니다(JS는 프론트쪽 개발진이 해야지!)
웹의 경우 클라와 서버쪽 둘 다 “검증”해주는게 좋다(POSTMAN 같은건 클라 검증 무시하니까)

API의 “예외”의 경우 서버는 JSON으로 변경된 데이터를 “Valid(검증)”하는거라서 JSON→DTO매핑될 때 에러나 그 시점에 다양한 에러(주로 서비스로직)들은 “Exception(예외)”으로 해결! => 웹은 “검증”만으로 충분하지만 API는 “검증+예외”가 필요!
=> 근데, 막상 해보니 웹&API 둘다 “검증+예외”를 적용 했다. eGov에선 웹에 에러페이지만 연동해주는게 아니라 “예외”까지 굳이 하더라?

검증과 예외 예시

  • 예외처리 예시 => ex: String 타입에 int가 넘어온 에러를 처리
  • 검증 예시 => ex: 0~9999 숫자범위를 지정

예외(Exception): Error는 주로 JVM에서 발생하여 무시하고, Exception을 보자.

image

  • “가장 좋은 예외(복구가능 예외)”컴파일(체크) 예외, 그리고 애플리케이션 로딩 시점에 발생하는 예외
    ex: IOException, SQLException -> try-catch(처리), throws(회피)로 주로 해결
    • 검증기 사용시 “앱 로딩 시점 예외”로 나타내 줄 수 있다는 장점!
  • “가장 나쁜 예외(복구불가능 예외)”는 고객 서비스중에 발생하는 런타임(언체크) 예외
    ex: NullPointerException, IndexOutOfBoundException -> throw(전환)로 주로 해결
    • 참고: try-catch, throws, throw는 어디서든 사용할 수 있다.
  • 하지만, 기본적으로 런타임(언체크) 예외를 사용하자(문사화 필수!)
    왜 WHY??
    SQLException 의 경우 쿼리를 수정 후 재배포하지 않는 이상 복구할 수 없다.
    try-catch로 잡고 아무 조치도 하지 않는 건 위험하다.
    무분별한 throws Exception는 어떤 에러인지 알기 어려워 try-catch처리가 힘들다.
    • 따라서 “체크 예외”는 의도를 가지고 던지는 경우만 try-catch, throws로 처리하고, 그 외는 언체크 예외로 전환하자!
    • 그리고 예외를 공통으로 처리하는 ExceptionHandler를 만들어서 처리하자.
  • 주의점: 스프링 프레임워크가 제공하는 선언적 트랜잭션(@Transactional)안에서 에러 발생 시 체크 예외는 롤백이 되지 않고, 언체크 예외는 롤백이 된다. 이는 자바 언어와는 무관하게 프레임워크의 기능임을 반드시 알고 넘어가도록 하자. (물론 옵션을 변경할 수 있다.)
    또한, 예외변환 throw new 커스텀예외(e); 는 현재 error를 담는 e를 꼭 생성자 매개변수에 넘겨줘야 하위의 에러내용들을 다 기록한다는걸 기억!

검증: Param, Form, JSON 등이 POST 요청왔을때 원하는 “검증”을 진행하는 것

  • HTTP 요청 “Form데이터, URL파라미터” 는 “검증” 만으로 충분 - @ModelAttribute
  • HTTP 요청 “API” 는 “검증 + 예외처리” 까지 필요 - @RequestBody
    • 또한, API의 경우 API스펙에 맞춰 잘 반환


검증(Bean Validation)

API 방식으로 주로 정리 -> 웹(JSP)인 “MyBatis + Spring(JSP) 파트” 참고

Bean Validation 방식을 설명한다. (일반적인 스프링 제공 검증 방식)
⇒ eGov 플젝은 다른 방식 (Jakarta Valid)

  1. DTO 클래스에 검증 어노테이션(@NotNull 등) 적용하여 검증기에게 규칙 전달
  2. 컨트롤러에서 @Validated 어노테이션으로 검증 활성화 (스프링프레임워크의 LocalValidatorFactoryBean이 사용됨)
  3. BindingResult로 검증 결과 수집
  4. 오류출력: 타임리프는 th:error로 자동으로 bindingresult에서 해당필드 검증 오류있나 체크해서 등록해둔 오류메시지-@NotNull(“이미지가 없습니다”)를 출력
    JSP는 form:error로 할 수 있고,
    직접 bindingresult를 가져와 사용해도 된다.


클라단 Valid검증: “직접 JS코드로 작성” -> 프론트 사람있으면 애초에 프론트 담당꺼


서버단 Valid검증: “@Validated + {HTTP요청 + BindingResult} + 검증 애노테이션 + errors.properties(메시지)”

  • ex: public ResponseEntity<ApiResponse<String>> login(@RequestBody @Validated LoginMemberRequestDto loginDto, BindingResult bindingResult, HttpServletRequest request) {}

    • {HTTP요청(@ModelAttribute or @RequestBody) + BindingResult} 순서 지켜서 파라미터 작성
  • 오류 메시지를 erros.propeties활용하면 부트는 이 파일 먼저 우선 체크 및 사용.

    • @NotNull(message =”…”) 이런식으로 메시지 바로 설정도 가능 / @Pattern(정규식)도 자주사용

    • BeanValidation의 메시지 찾는순서errors.properties 를 먼저 찾고 검증 애노테이션의 message 속성 사용

      NotBlank.item.itemName=상품 이름을 적어주세요.
      NotBlank={0} 공백X
      Range={0}, {2} ~ {1} 허용
      Max={0}, 최대 {1}
      
      • NotBlanck 보다 NotBlank.item.itemName 같이 세부 필드를 더 우선순위 높게 출력
  • bindingresult 미사용 시? 검증 오류 발생 시 “자동 처리”

    • 참고: 실제 객체말고 DTO에서 @NotNull 하는게 좋다!! 안전하기도 하고!
  • bindingresult 사용 시? 오류가 이곳에 담기다보니 “컨트롤러가 정상처리!!”
    따라서 if문으로 if(a.getId()!=null && b.getId()!=null) 이나 if(bindingResult.hasErros()) 이런걸로 검증오류 처리를 “직접 하는 방식”

    • 보통 API응답 양식을 정해놓고 반환한다. 커스텀 가능하니까. => ApiResponse.java 추가 + 제네릭까지 활용

      • 제네릭을 왜?? -> ResponseEntity<ResDto> 처럼 반환 타입이 ResDto이지 bindingResult 가 아니라서 타입 문제가 발생ㅜ

        해결 예시


        ApiResponse.java 에서 제네릭을 사용해서 해결 (아래 코드)
        POINT: error함수 사용할 때 제네릭 필드부분에 null을 주기 때문에 타입에서 자유로워 질 수 있다, 반대로 success함수 사용할 때 ResDto같은 데이터가 들어오면 이를 반환할 수도 있다!

        //ApiResponse.java
        public static <T> ApiResponse<T> success(int status, T data) {
            return new ApiResponse<>(status, "정상", data, null, null, null);
        }
        public static <T> ApiResponse<T> error(int status, BindingResult bindingResult) {
            return new ApiResponse<>(status, "검증 오류", null, bindingResult.getFieldError().getDefaultMessage(), bindingResult.getFieldError().getRejectedValue(), bindingResult.getObjectName());
        }
        public static <T> ApiResponse<T> errorObject(int status, BindingResult bindingResult) {
            return new ApiResponse<>(status, "검증 오류", null, bindingResult.getGlobalError().getDefaultMessage(), null, bindingResult.getGlobalError().getObjectName());
        }
        
    • FiledError -> erros.properties 또는 @NotNull(message=”…”) 활용 권장 (메시지)
      ObjectError -> bindingResult.reject() 활용 권장 (메시지)

      • FieldError 는 도메인에 “검증 애노테이션” 사용 + “bindingResult.hasErrors()” 필수

      • ObjectError 는 “검증 직접 작성 -> bindingResult.reject() 함수 권장”

        • bindingResult.hasErrors() 는 errors가 있는지 여부를 반환하고,
        • errors 에는 “검증결과 에러” 들을 기록하며 이는 “검증 애노테이션”에 걸린 에러들을 의미
      • GET, POST 뭐든 입력 들어오는건 “검증” 사용하자.

      • 아래 예제에선 errors.properties는 굳이 안썼다. 메시지 국제화에는 좋겠지만 뭐 기본제공 메시지도 넘 좋고, @NotNull(“메시지내용”) 처럼 직접 작성도 있어서!

        코드 예시 - 이미지 포함


        FiledError 예시

        //이런 느낌으로 사용 -> FiledError
        public ResponseEntity<ApiResponse<String>> login(@RequestBody @Validated LoginMemberRequestDto loginDto, BindingResult bindingResult, HttpServletRequest request) {
            log.info("bindingResult 때문에 검증에 걸려도 정상 동작");
            if (bindingResult.hasErrors()) {
                log.info("검증 오류 발생 errors={}", bindingResult);
                ApiResponse res = ApiResponse.error(HttpStatus.BAD_REQUEST.value(), bindingResult);
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(res);
            }
            ApiResponse res = ApiResponse.success(HttpStatus.OK.value(), SUCCESS_LOGIN);
            return ResponseEntity.status(HttpStatus.OK)
        

        image

        ObjectError 예시

        //이런 느낌으로 사용 -> ObjectError
        if (request.getPrice() != null && request.getPurchase_quantity() != null) {
            int resultPrice = request.getPrice() * request.getPurchase_quantity();
            if (resultPrice <= 0) {
                bindingResult.reject(null, null, "전체 가격은 0원 초과야 합니다. 현재 가격은 "+resultPrice); //errors.properties 안써서 그냥 null, null 한거임.
                log.info("검증 오류 발생 errors={}", bindingResult.getAllErrors());
                ApiResponse res = ApiResponse.errorObject(HttpStatus.BAD_REQUEST.value(), bindingResult);
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(res);
            }
        }
        

        image

  • 타임리프에서 검증 -> “MyBatis + Spring(JSP) 파트”도 보길 추천

    • th:field, th:errors, th:errorclass 문법을 주로 같이 활용
      • <div th:errors="*{id}"... 등 이런형태로 바로 사용!! 에러 시 해당 태그 출력!!
      • @ModelAttribute(“item”) 사용이 중요!!
        • 왜냐하면, 컨트롤러 단에서 파라미터로 AddItemDto form 로 선언하면 Model에 “AddItemDto”객체 형태로 자동으로 담기 때문!
        • 타임리프의 th:object를 “item”로 사용한다면, 반드시 Model에 “item”으로 담아야 null문제 피함.


예외처리(Exception)

예외를 처리하는 방법 크게 2가지

  1. 예외를 잡아서 정상화 하는 방법 ⇒ 예로 try, catch

  2. 예외를 해결할 수 없는 문제로 인정하고 공통 처리하는 방법(사용자에게 죄송합니다. 같은 화면을 보여주는 방법) ⇒ 예로 @ExceptionHandler + @ControllerAdvice

    특히, API(JSON)의 경우 대부분 2번으로 해결 됨. 직접 예외를 throw로 던져서 공통 관리해도 되니까.

예외를 처리하는 계층의 흐름 이해:

  1. 서비스계층의 비즈니스 로직의 예외 발생하면 “정상화”하거나 “공통 처리” 위해 던지기
  2. 컨트롤러에서 웹이면 서비스의 Exception을 JSP 뷰로 매핑, API면 JSON으로 응답


예외처리 - Spring Exception

  • 웹(타임리프) -> “MyBatis + Spring(JSP) 파트”도 보길 추천

    • 자동으로 에러에 필요한 로직을 등록하므로 바로 활용가능 (스프링 부트가 에러 페이지도 자동으로 제공해준다는 것!! 물론 직접 추가도 되고)

    • ErrorPage, BasicErrorController 자동 등록 및 /error 경로로 기본설정

    • BasicErrorControllerErrorPage 에서 등록한 /error 를 매핑해서 처리하는 컨트롤러

    • 뷰선택 우선순위(BasicErrorController 가 제공하는 기능)

      1. 뷰템플릿

        • resources/templates/error/500.html

        • html resources/templates/error/5xx.html

      2. 정적리소스( static , public ) resources/

        • static/error/400.html

        • resources/static/error/404.html

        • resources/static/error/4xx.html

      3. 적용대상이없을 때뷰이름( error )

        • resources/templates/error.html
    • 여기 타임리프 플젝 코드는 “공통 처리-ExceptionHandler” 없이 개발.

      html 적용 예시


      errorTest.java -> 예외 페이지 확인 용 (직접 커스텀 페이지인 4xx.html, 404.html, 500.html 테스트 코드!)

      @Controller
      @RequiredArgsConstructor
      @Slf4j
      public class errorTest {
          /**
           * 일부러 에러 발생 시키기 -> 예외 페이지 확인위해
           * 테스트가 아닌 실제로 에러 발생시 해당 에러내용들이 예외 페이지로 정리되어 출력
           */
          @GetMapping("/error-404")
          public void error404(HttpServletResponse response) throws IOException {
              response.sendError(404, "404 오류!");
          }
          @GetMapping("/error-500")
          public void error500(HttpServletResponse response) throws IOException {
              response.sendError(500);
          }
          @GetMapping("/error-403")
          public void error403(HttpServletResponse response) throws IOException {
              response.sendError(403, "403 오류!");
          }
      }
      


      /templates/error/4xx.html 코드 (404, 500도 이런식)

      <!DOCTYPE HTML>
      <html xmlns:th="http://www.thymeleaf.org">
      <head>
          <meta charset="utf-8">
      </head>
      <body>
      <div class="container" style="max-width: 600px">
          <div class="py-5 text-center">
              <h2>4xx 오류 화면 스프링 부트 제공</h2>
          </div>
          <div>
              <p>오류 화면 입니다.</p>
          </div>
          <ul>
              <li>오류 정보</li>
              <ul>
                  <li th:text="|timestamp: ${timestamp}|"></li>
                  <li th:text="|path: ${path}|"></li>
                  <li th:text="|status: ${status}|"></li>
                  <li th:text="|message: ${message}|"></li>
                  <li th:text="|error: ${error}|"></li>
                  <li th:text="|exception: ${exception}|"></li>
                  <li th:text="|errors: ${errors}|"></li>
                  <li th:text="|trace: ${trace}|"></li>
              </ul>
              </li>
          </ul>
          <hr class="my-4">
      </div> <!-- /container -->
      </body>
      </html>
      
  • API

    • API는 html보다 예외처리가 세부적이므로 ExceptionHandlerExceptionResolver 로 해결

    • 즉, 자동 등록한 에러 로직을 사용하지 않고 @ExceptionHandelr + @ControllerAdvice 조합 사용
      참고: @RestControllerAdvice = @ControllerAdvice + @ResponseBody

      • @RestControllerAdvice 를 통해서 컨트롤러를 “기존코드, 예외코드” 나눠서 분류 가능
      • ResponseBody 덕분에 에러 반환 형식도 JSON으로 처리 됨!
      • 컨트롤러에 예외가 @ExceptionHandler 덕분에 “정상처리” 되므로 상태코드 변환은 필수
      API 적용 예시


      음.. 에러는 IllegalArgumentException(사용자가 값 잘못입력) JSON 변경전 잘못 입력된 값인 400에러(BAD REQUEST)랑 중복검증처럼 IllegalStateException(Conflict:409)이랑 그냥 서버자체에서 오류뜬 500에러(모든에러인 Exception으로 하겠음) 까지 총 3개를 기준으로 예외처리 컨트롤 해보겠음.

      • (1)API 예외처리 응답 양식은 ErrorResult 객체로 code, message 필드 가지게 만들자.
      • (2)ApiControllerAdvice 를 추가 개발했다. 코드보면 이해 잘 될거다.
      /**
       * API 에외처리 응답 양식
       */
      @Data
      public class ErrorResult {
        private String code;
        private String message;
        public ErrorResult(String code, String message) {
          this.code=code;
          this.message=message;
        }
      }
      //
      //
      //
      @Slf4j
      @RestControllerAdvice(basePackages = "com.lepl.api") //컨트롤러 예외들 여기서 처리하게끔 역할
      public class ApiControllerAdvice {
      //
        @ResponseStatus(HttpStatus.BAD_REQUEST) //이거 덕분에 ResponseEntity 없이 응답 코드 설정! (간단하게 하려고 추가함)
        @ExceptionHandler(IllegalArgumentException.class) //해당 예외가 잡히면 이 함수를 실행 역할!
        public ErrorResult illegalApiHandler(IllegalArgumentException e) {
          log.error("[exceptionHandler] ", e);
          return new ErrorResult("BAD REQUEST", e.getMessage());
        }
      //
        @ResponseStatus(HttpStatus.CONFLICT) //중복 충돌이니까:409
        @ExceptionHandler(IllegalStateException.class)
        public ErrorResult illegalApiHandler(IllegalStateException e) {
          log.error("[exceptionHandler] ", e);
          return new ErrorResult("CONFLICT", e.getMessage());
        }
      //
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ExceptionHandler
        public ErrorResult exceptionApiHandler(Exception e) {
          log.error("[exceptionHandler] ex", e);
          return new ErrorResult("SERVER ERROR", e.getMessage());
        }
      }
      //
      //MemberService.java
      private void validateDuplicateMember(Member member) {
        Member findMember = memberRepository.findByUid(member.getUid());
        if (findMember != null) {
          // IllegalStateException 예외를 호출
          throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
      }
      

      아래는 결과 확인해보기 위한 테스트를 진행! -> register, login

      • register에 있는 중복검증 예외처리가 첫번째이다.(IllegalStateException)
      • 요청할 때 body에 아무것도 입력 안했을때 예외처리가 두번째이다.(Exception)
      • 요청할 때 body에 요청 json형식에 안맞게 입력했을 때가 세번째이다.(Exception)
      • 아쉽지만 IllegalArgumentException 에러는 확인 못했다. Json 입력 에러는 다른 예외처리 사용하더라구.
        image
        image
        image



댓글남기기