(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을 보자.
-
“가장 좋은 예외(복구가능 예외)”는 컴파일(체크) 예외, 그리고 애플리케이션 로딩 시점에 발생하는 예외
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)
- DTO 클래스에 검증 어노테이션(
@NotNull
등) 적용하여 검증기에게 규칙 전달 - 컨트롤러에서
@Validated
어노테이션으로 검증 활성화 (스프링프레임워크의 LocalValidatorFactoryBean이 사용됨) -
BindingResult
로 검증 결과 수집 - 오류출력: 타임리프는 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)
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); } }
-
-
-
타임리프에서 검증 -> “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가지
-
예외를 잡아서 정상화 하는 방법 ⇒ 예로 try, catch
-
예외를 해결할 수 없는 문제로 인정하고 공통 처리하는 방법(사용자에게 죄송합니다. 같은 화면을 보여주는 방법) ⇒ 예로 @ExceptionHandler + @ControllerAdvice
특히, API(JSON)의 경우 대부분 2번으로 해결 됨. 직접 예외를 throw로 던져서 공통 관리해도 되니까.
예외를 처리하는 계층의 흐름 이해:
- 서비스계층의 비즈니스 로직의 예외 발생하면 “정상화”하거나 “공통 처리” 위해 던지기
- 컨트롤러에서 웹이면 서비스의 Exception을 JSP 뷰로 매핑, API면 JSON으로 응답
예외처리 - Spring Exception
-
웹(타임리프) -> “MyBatis + Spring(JSP) 파트”도 보길 추천
-
자동으로 에러에 필요한 로직을 등록하므로 바로 활용가능 (스프링 부트가 에러 페이지도 자동으로 제공해준다는 것!! 물론 직접 추가도 되고)
-
ErrorPage, BasicErrorController
자동 등록 및/error
경로로 기본설정 -
BasicErrorController
는ErrorPage
에서 등록한/error
를 매핑해서 처리하는 컨트롤러 -
뷰선택 우선순위(BasicErrorController 가 제공하는 기능)
-
뷰템플릿
-
resources/templates/error/500.html
-
html resources/templates/error/5xx.html
-
-
정적리소스( static , public ) resources/
-
static/error/400.html
-
resources/static/error/404.html
-
resources/static/error/4xx.html
-
-
적용대상이없을 때뷰이름( 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 입력 에러는 다른 예외처리 사용하더라구.
-
-
댓글남기기