유효성검사와 예외처리

유요한·2023년 12월 1일
0

Spring Boot

목록 보기
21/25
post-thumbnail

유효성검사와 예외처리

애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요합니다. 이것을 유효성 검사 또는 데이터 검증이라 부릅니다. 유효성 검사의 예로는 여러 계층에서 들어오는 데이터에 대해 의도한 형식대로 값이 들어오는지 체크하는 과정이 있습니다. 이 같은 유효성 검사는 프로그래밍에서 매우 중요한 부분이며, 자바에서는 신경 써야 하는 것 중 하나로 NullPointException 예외가 있습니다.

일반적인 애플리케이션 유효성 검사의 문제점

일반적으로 사용되는 데이터 검증 로직에는 몇 가지 문제점이 있습니다. 계층별로 진행하는 유효성 검사는 검증 로직이 각 클래스별로 분산돼 있어 관리하기가 어렵습니다. 그리고 검증 로직에 의외로 중복이 많아 여로 곳에 유사한 기능의 코드가 존재할 수 있습니다. 검증해야할 값이 많다면 코드가 길어지고 가독성을 해치게 됩니다.

이 같은 문제를 해결하기 위해 자바 진영에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공합니다. Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공합니다. Bean Valiation을 사용한다는 것은 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미입니다. 또한 Bean Validation은 어노테이션을 사용한 검증 방식이기 때문에 코드의 간결함도 유지할 수 있습니다.

Hibernate Validator

Hibernate Validator는 Bean Validation 명세의 구현체입니다. 스프링 부트에서는 Hibernate Validator는 JSR-303 명세의 구현체로서 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와줍니다.

스프링 부트에서 유효성 검사

✅ Validation

올바르지 않은 데이터를 걸러내고 보안을 유지하기 위해 데이터 검증(validation)은 여러 계층에 걸쳐서 적용됩니다. Client의 데이터는 조작이 쉬울 뿐더러 모든 데이터가 정상적인 방식으로 들어오는 것도 아니기 때문에, Client Side뿐만 아니라 Server Side에서도 데이터 유효성을 검사해야 할 필요가 있습니다. 스프링부트 프로젝트에서는 @validated를 이용해 유효성을 검증할 수 있습니다.

🌱 Spring Boot Validation 적용하기

Gradle

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '3.0.2'

maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>3.0.2</version>
</dependency>
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.5.6'
}

유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시합니다. 스프링 부트 프로젝트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 아래와 같이 유효성 검사를 DTO 객체를 대상으로 수행하는 것이 일반적입니다.

package com.example.valid_exception.dto;

import lombok.*;

import javax.validation.constraints.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ValidRequestDTO {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "/^01([0|1|6|7|8|9])-?([0-9]{4})$/")
    private String phoneNumber;

    @Min(value = 20) @Max(value = 40)
    private int age;

    @Size(min = 0, max = 40)
    private String description;

    @Positive
    private int count;

    @AssertTrue
    private boolean booleanCheck;
}
import lombok.Data;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;

@Data
public class UserDTO {

    /* user 인덱스 (PK) */
    private int userIdx;

    @NotBlank(message = "아이디는 필수 입력사항 입니다.")
    private String userId;

    @NotBlank(message = "비밀번호는 필수 입력사항 입니다.")
    private String userPw;

    @NotBlank(message = "이름은 필수 입력사항 입니다.")
    private String userName;

    @NotBlank(message = "이메일은 필수 입력사항 입니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String userEmail;

    /* user 등록일자 */
    private String regDate;
    /* admin 체크 여부 0: user, 1: admin */
    private int adminCk;

}

package com.example.valid_exception.controller;

import com.example.valid_exception.dto.ValidRequestDTO;
import lombok.extern.log4j.Log4j2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequestMapping("/validation")
@Log4j2
public class ValidationController {
    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDTO validRequestDTO
            ) {
        log.info(validRequestDTO.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDTO.toString());
    }
}

chekValidationByValid()메서드는 ValidRequestDTO 객체를 RequestBody 값으로 받고 있습니다. 이 경우 @Valid 어노테이션을 지정해야 DTO 객체에 대해 유효성 검사를 수행합니다.

UserDTO

package com.example.velog.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Getter
@ToString
public class UserDTO {
    private Long userId;
    @NotBlank(message = "이메일은 필수 입력사항 입니다.")
    // 이메일 형식이여야 함
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String userEmail;
    @NotBlank(message = "비밀번호는 필수 입력 값입니다.")
    @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\\\W)(?=\\\\S+$).{8,20}",
        message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자의 비밀번호여야 합니다.")
    private String userPw;
    @NotBlank(message = "이름은 필수 입력사항 입니다.")
    private String userName;
    @NotBlank(message = "주소는 필수 입력사항 입니다.")
    private String userAddr;

    private MultipartFile userImg;

    @Builder
    public UserDTO(String userEmail, String userPw, String userName, String userAddr, MultipartFile userImg) {
        this.userEmail = userEmail;
        this.userPw = userPw;
        this.userName = userName;
        this.userAddr = userAddr;
        this.userImg = userImg;
    }

}

UserService

Map<String,String> validateHandling(Errors errors);

UserServiceImpl

 @Override
    public Map<String, String> validateHandling(Errors errors) {
        Map<String, String> validatorResult = new HashMap<>();
		// 유효성 검사에 실패한 필드 목록을 가져옵니다.
        for (FieldError error: errors.getFieldErrors()
             ) {
             // 유효성 검사에 실패한 필드명을 가져옵니다.
            String validKeyName = String.format("valid_%s", error.getField());
            // 유효성 검사에 실패한 필드에 정의된 메시지를 가져옵니다.
            validatorResult.put(validKeyName, error.getDefaultMessage());
        }
        return  validatorResult;
    }

UserController

    @PostMapping("/signUp")
    public String signUp(@Validated UserDTO userDTO, Errors errors, HttpServletResponse resp, Model model) {
        if(errors.hasErrors()) {
            // 회원 가입 실패시, 입력 데이터를 유지
            model.addAttribute("userDTO", userDTO);

            // 유효성 통과 못한 필드와 메시지를 핸들링
            Map<String, String> validatorResult = userService.validateHandling(errors);
            for (String key: validatorResult.keySet()
                 ) {
                model.addAttribute(key,validatorResult.get(key));
            }
            return "/signUp";
        }
        if(userService.signUp(userDTO)) {
            Cookie cookie = new Cookie("userEmail", userDTO.getUserEmail());
            // 30분
            cookie.setMaxAge(1800);
            resp.addCookie(cookie);
        }
        return "redirect:/";
    }

@RequestBody

이 어노테이션이 붙은 파라미터에는 http요청의 본문(body)이 그대로 전달된다. 일반적인 GET/POST의 요청 파라미터라면 @RequestBody를 사용할 일이 없을 것이다.

반면에 xml이나 json기반의 메시지를 사용하는 요청의 경우에 이 방법이 매우 유용하다. HTTP 요청의 바디내용을 통째로 자바객체로 변환해서 매핑된 메소드 파라미터로 전달해준다.

@ResponseBody

자바객체를 HTTP요청의 바디 내용으로 매핑하여 클라이언트로 전송한다. @ResponseBody 가 붙은 파라미터가 있으면 HTTP요청의 미디어 타입과 파라미터의 타입을 먼저 확인한다.

  • dispatcher-servlet.xml 의 <annotation-drvien>태그 내에 선언하는 <message-converter> 에서 확인.

메세지 변환기 중에서 해당 미디어타입과 파라미터 타입을 처리할 수 있다면, HTTP요청의 본문 부분을 통째로 변환해서 지정된 메소드 파라미터로 전달해준다.

즉, @Responsebody 어노테이션을 사용하면 http요청 body를 자바 객체로 전달받을 수 있다.

      @GetMapping("hello-string")
    // HTTP에서 body의 데이터에 직접 넣어주겠다는 뜻이다.
    @ResponseBody
    public String helloString(@RequestParam("name") String name) {
        return "hello " + name;
    }
  • @ResponseBody 를 사용하면 뷰 리졸버( viewResolver )를 사용하지 않음
  • 대신에 HTTP의 BODY에 문자 내용을 직접 반환(HTML BODY TAG를 말하는 것이 아님)
package com.example.study02;

public class TestApi {
    private String id;
    private String password;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
package com.example.study02.controller;

import com.example.study02.TestApi;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class JsonController {

    @GetMapping("Test")
    @ResponseBody
    public TestApi testApi() {
        TestApi testApi = new TestApi();
        testApi.setId("testId");
        testApi.setPassword("testPw");
        return testApi;
    }
}

위의 getter을 삭제하게 된다면 나오지 않습니다.

여기서 더 간편하게 하려면 @Controller가 아니라 @RestController로 하면 됩니다. @RestController는 @Controller와 @ResponseBody를 합쳐놓은 어노테이션입니다.

사용하는 이유

@ResponseBody : 자바 객체를 HTTP 요청 몸체로 변환

저 어노테이션을 쓰면 return "/result"는 컨트롤러에서 /result라는 url을 찾으라는 의미가 아니라 "/result"라는 String을 반환하라는 것이다. 즉, 내가 어떤 상황으로 인해 url을 통해 뷰로 가는게 아니라 Stirng 자체를 반환하고 싶을때 쓰면된다.

      @GetMapping("hello-api")
    @ResponseBody
    public Hello helloApi(@RequestParam("name") String name) {
        Hello hello = new Hello();
        hello.setName(name);
        return hello;
    }
    static class Hello {
        private String name;

        public String getName() {
            return name;
        }
        public void setName(String name) {
            this.name = name;
        }
    }

  • HTTP의 BODY에 문자 내용을 직접 반환
  • viewResolver 대신에 HttpMessageConverter 가 동작
  • 기본 문자처리: StringHttpMessageConverter
  • 기본 객체처리: MappingJackson2HttpMessageConverter
  • byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

클라이언트의 HTTP Accept 해더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해서 HttpMessageConverter 가 선택된다.

@Validated 활용

앞의 예제에서 유효성 검사를 수행하기 위해 @Valid 어노테이션을 선언했습니다. @Valid 어노테이션은 자바에서 지원하는 어노테이션이며, 스프링도 @Validated라는 별도의 어노테이션으로 유효성 검사를 지원합니다.

@Validated은 @Valid 어노테이션의 기능을 포함하고 있기 때문에 @validated로 변경할 수 있습니다. 또한 @Validated는 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있습니다. 검증 그룹은 별다른 내용이 없는 마커 인터페이스를 생성해서 사용합니다.

커스텀 Validation 추가

실무에서는 유효성 검사를 실시할 때 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 써야 할 때도 있습니다. 이 경우 ConstraintValidator와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있습니다. 동일한 정규식을 계속 쓰는 @Pattern 어노테이션의 경우가 가장 흔한 사례입니다.

이번에는 전화번호 형식이 일치하는지 확인하는 간단한 유효성 검사 어노테이션을 생성해 보겠습니다. 먼저 ConstraintValidator 인터페이스를 구현하는 클래스를 생성해야 합니다.

사용자 정의 예외 발생하기

애플리케이션을 개발할 때는 불가피하게 많은 오류가 발생하게 됩니다. 자바에서는 이러한 오류를 try/catch, throw 구문을 활용해 처리합니다. 스프링 부트에서는 더욱 편리하게 예외 처리를 할 수 있는 기능을 제공합니다.

대부분의 웹 애플리케이션은 사용자의 부주의나 시스템 운영 과정에서 발생된 문제에 대해서 적절한 처리와 함께 관련된 화면을 사용자에게 제공합니다.

예외와 에러

프로그래밍에서 예외(exception)란 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 의미합니다. 예외는 개발자가 직접 처리할 수 있는 것이므로 미리 코드 설계를 통해 처리할 수 있습니다.

다음으로는 에러(error)가 있습니다. 많은 사람들이 예외와 비슷한 의미로 사용하고 있지만 소프트웨어 공학에서는 엄연히 다르게 사용되는 용어입니다. 에러는 주로 자바의 가상머신에서 발생시키는 것으로서 예외와 달리 애플리케이션 코드에서 처리할 수 있는것이 거의 없습니다.

사용자가 시스템을 사용하다보면 웹 페이지 URL 경로를 잘못 입력하거나 파라미터를 잘못전달하여 문제가 발생하는 경우가 있다. 또는 서버에서 실행되는 프로그램에서 생각하지 못 했던 문제가 발생할 수도 있다. 이렇게 사용자의 부주의나 코드 자체의 오류로 인해 문제가 발생할 때, 발생된 문제를 적절하게 처리하지 않으면 사용자 브라우저에는 에러 페이지가 출력된다.

자바는 시스템에서 발생되는 문제를 시스템 에러(Error)와 예외(Exception)로 구분한다. 시스템 에러는 개발자가 제어할 수 없는 문제이므로 제외하고 예외에 집중한다. 일반적인 자바 애플리케이션이라면 try-catch를 사용하겠지만 스프링 기반의 웹 애플리케이션은 스프링에서 지원하는 예외처리 기법을 이용한다.

일반적으로 스프링에서 예외를 처리하는 방법는 두 가지가 있다.

  1. @ControllerAdvice 어노테이션을 이용하여 모든 컨트롤러에서 발생하는 예외를 일괄적으로 처리하는 것이다. 전역 예외처리라고 하며 일반적으로 가장 많이 사용한다.

  2. @ExceptionHandler 어노테이션을 이용하여 각 컨트롤러마다 발생하는 예외를 개별적으로 처리하는 것이다.

예외 클래스

모든 예외 클래스는 Throwable 클래스를 상속받습니다. 그리고 가장 익숙하게 볼 수 잇는 Exception 클래스는 다양한 자식 클래스를 가지고 있습니다. 이 클래스는 크게 Checked Exception과 Unchecked Exception으로 구분할 수 있습니다.

Checked Exception은 컴파일 단계에서 확인 가능한 예외 상황입니다. 이러한 예외는 IDE에서 캐치해서 반드시 예외 처리를 할 수 있게 표시해줍니다. 반면 Unckecked Exception은 런타임 단계에서 확인되는 예외 상황을 나타냅니다. 즉, 문법상 문제는 없지만 프로그램이 동작하는 도중 예기치 않은 상황이 생겨 발생하는 예외를 의미합니다.

예외 처리 방법

예외가 발생했을 때 이를 처리하는 방법은 크게 세 가지 있습니다.

  • 예외 복구
  • 예외 처리 회피
  • 예외 전환

먼저 예외 복구 방법은 예외 상황을 파악해서 문제를 해결하는 방식입니다. 대표적인 방법이 try/catch구문 입니다. try 블록에는 예외가 발생할 수 있는 코드를 작성합니다. 대체로 외부 라이브러리를 사용하는 경우에는 try블록을 사용하라는 IDE의 알람이 발생하지만 개발자가 직접 작성한 로직은 예외 상황을 예측해서 try 블록에 포함시켜야 합니다. 이 때 catch 블록은 여러 개를 작성할 수 있습니다. 이 경우 예외 상황이 발생하면 여러 개의 catch 블록이 순차적으로 거치면서 예외 유형과 매칭되는 블록을 찾아 예외 처리 동작을 수행합니다.

또 다른 예외 처리 방법 중 하나는 예외 처리를 회피하는 방법입니다. 이 방법은 예외가 발생한 시점에서 바로 처리하는 것이 아니라 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식입니다. 이 때 throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있습니다.

마지막으로 예외 전환 방법이 있습니다. 이 방법은 앞의 두 방식을 섞은 방식입니다. 예외가 발생했을 때 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달할 필요가 있습니다. 또는 애플리케이션에서 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping)해야 하는 경우도 있습니다. 이런 경우에는 try/catch 방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 됩니다.

스프링 부트의 예외 처리 방식

웹 서비스 애플리케이션에서는 외부에서 들어오는 요청에 담긴 데이터를 처리하는 경우가 많습니다. 그 과정에서 예외가 발생하면 예외를 복구해서 정상으로 처리하기 보다는 요청을 보낸 클라이언트에 어던 문제가 발생했는지 상황을 전달하는 경우가 많습니다.

예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 합니다. 이렇게 전달받은 예외를 스프링 부트에서 처리하는 방식으로 크게 두 가지 있습니다.

1) 사용자 정의 예외

예외를 처리하려면 우선 문제가 발생될 만한 상황에서 예외를 발생시킬 수 있어야 한다. 따라서 게시판 프로그램에서 발생할 수 있는 모든 예외를 표현하기 위해서 BoardException을 만든다.

package com.example.demo.exception;



public class BoardException extends RuntimeException{
    
    private static final long serialVersionUID = 1L;
    
    public BoardException(String message) {
        super(message);
    }
}

자바의 예외는 Checked Exception하고 Uncecked Exception로 구분된다. 체크드 예외는 컴파일 시점에서 발생하는 예외고 언체크드 예외는 컴파일은 통과하지만 실행 시점에 발생하는 예외입니다. 따라서 게시판 프로그램 실행 시 발생할 수 있는 모든 예외의 최상위 부모로 사용할 BoardException 클래스를 RuntimeException을 상속하여 구현했다. 이제 각종 예외를 BoardException 클래스를 상속해서 구현하면 됩니다. 따라서 Board 데이터가 없을 때 사용할 BoardNotFoundException도 클래스를 구현해준다.

package com.example.demo.exception;

public class BoardNotFoundException  extends  BoardException{
    
    private static final long serialVersionUID = 1L;

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

2) 예외발생하기

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>home</h1>
<p align="center"><a href="#">글 목록 바로가기</a> </p>
<p>
    <a href="illegalArgumentError">IllegalArgumentException 발생</a>
    <a href="sqlEroor">SQLException 발생</a>
    <a href="boardError">BoardError 발생</a>
</p>
</body>
</html>

이제 예외 발생 요청이 들어왔을 때 해당 예외를 강제로 발생시키도록 ExceptionController를 작성한다.

package com.example.demo.controller;

import com.example.demo.exception.BoardNotFoundException;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import java.sql.SQLException;

@Controller
public class ExceptionController {

    @RequestMapping("/boardError")
    public String boardError() {
        throw new BoardNotFoundException("검색된 게시글이 없습니다.");
    }
    
    @RequestMapping("/illegalArgumentError")
    public String illegalArgumentError() {
        throw new IllegalStateException("부적절한 인자가 전달되었습니다.");
    }
    
    @RequestMapping("/sqlError")
    public String sqlError() {
        throw new SQLException("SQL 구문에 오류가 있습니다.")
    }
}

예외 처리하기

@ExceptionHandler와 @ControllerAdvice를 이용한 처리

예외 사항을 전부 매번마다 핸들링해야 한다면 중복적이고 많은 양의 코드를 작성해야 하지만, 공통적인 예외사항에 대해서는 별도로 @ControllerAdvice를 이용해서 분리한다. AOP를 이용하는 방식이다.

@ControllerAdvice

@ControllerAdvice는 뒤에서 배우는 AOP를 이용하는 방식이다. 핵심적인 로직은 아니지만 프로그램에서 필요한 공통적인 관심사는 분리하자는 개념입니다. Controller를 작성할 때는 메서드의 모든 예외사항을 전부 핸들링해야 한다면 중복적이고 많은 양의 코드를 작성해야 하지만, AOP 방식을 이용하면 공통적인 예외사항에 대해서는 별도로 @ControllerAdvice를 이용해서 분리하는 방식입니다.

@ExceptionHandler

발생하는 예외의 타입별로 흐름을 나눠줄 때 사용하는 매핑방식

1) 예외 처리기 작성

이제 발생되는 모든 예외에 대해서 적절한 예외 화면을 연결해주는 전역 예외 처리기를 만든다.

package com.example.demo.exception;

import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BoardException.class)
    public String handleCustomException(BoardException exception, Model model) {
        model.addAttribute("exception", exception);
        return "/errors/boardError";
    }

    @ExceptionHandler(Exception.class)
    public String handlerException(Exception exception, Model model) {
        model.addAttribute("exception", exception);
        return "/errors/globalError";
    }
}

우선 @ControllerAdvice 어노테이션을 GlobalExceptionHandler 클래스에 선언함으로써 컨트롤러에서 발생하는 모든 예외를 GlobalExceptionHandler 객체가 처리하도록 했다. 그리고 발생된 예외의 타입에 따라 다양한 화면이 처리되도록 @ExceptionHandler를 가진 메소드를 여러개 선언했다. handleCustomException()메소드는 BoardException 타입의 예외가 발생했을 때 동작하는 메소드다. 따라서 BoardException을 상속한 BoardNotFoundException이 발생했을 때 동작할 것이다. 그리고 handleException()메소드는 모든 예외의 최상위 부모인 Exception 타입의 객체를 처리하는 메소드다. 따라서 BoardException 타입의 예외를 제외한 나머지 모든 예외는 handleException() 메소드가 처리할 것이다.

2) 예외 전용 페이지 작성

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>boardError</title>
</head>
<body>
<h1><span style="color: green; ">BoardException 발생!</span> </h1>
<a th:href="@{/}">메인 화면으로</a>
<table>
  <tr>
    <th bgcolor="green" align="left">예외 메시지 : [[${exception.message}]]</th>
  </tr>
  <tr th:each="trace : ${exception.stackTrace} ">
    <td th:text="${trace}" />
  </tr>
</table>
</body>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>GlobalError</title>
</head>
<body>
<h1><span style="color: green; ">GolbalException 발생!</span> </h1>
<a th:href="@{/}">메인 화면으로</a>
<table>
  <tr>
    <th bgcolor="green" align="left">예외 메시지 : [[${exception.message}]]</th>
  </tr>
  <tr th:each="trace : ${exception.stackTrace} ">
    <td th:text="${trace}" />
  </tr>
</table>
</body>
</html>

404페이지 같은 경우는 properties에 별도로 설정을 추가

#  이것만 써도 404에러를 캐치해낸다.
spring.mvc.throw-exception-if-no-handler-found=true
    // 404예외처리 핸들러
    @ExceptionHandler(NoHandlerFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String handle404(NoHandlerFoundException e, Model model){
        model.addAttribute("exception", e);
        return "/error/notfound";
    }

@RestControllerAdivce

6번 줄에 지정된 @ExceptionHandler는 @Controller나 @RestController가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의할 때 사용합니다. 어떤 예외 클래스를 처리할지는 value 속성으로 등록합니다. value 속성은 배열의 형식으로도 전달받을 수 있어 여러 예외 클래스를 등록할 수 도 있습니다. 예제에서는 RuntimeException이 발생하면 처리하도록 코드를 작성했으므로 RuntimeException에 포함되는 각종 예외가 발생하는 경우를 포착해서 처리하게 됩니다.

8~18번 줄에서는 클라이언트에게 오류가 발생했다는 것을 알리는 응답 메시지를 구성해서 리턴합니다. 컨트롤러의 메서드에 다른 타입의 리턴이 설정돼 있어도 핸들러 메서드에서 별도의 리턴 타입을 지정할 수 있습니다.

커스텀 예외

애플리케이션을 개발하다 보면 점점 예외로 처리할 영역이 늘어나고, 예외 상황이 다양해지면서 사용하는 예외 타입도 많아집니다. 대부분의 상황에서는 자바에서 이미 적절한 상황에 사용할 수 있도록 제공하는 표준 예외(Standard Exception)을 사용하면 해결됩니다. 그러면 왜 커스텀 예외가 필요할까요?

커스텀 예외를 만들어서 사용하면 네이밍에 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있습니다. 앞에서 언급했듯이 표준 예외에서도 다양한 예외 상황을 처리할 수 있는 클래스를 제공하고 있지만 표준 예외에서 제공하는 클래스는 해당 예외 타입의 이름만으로 이해하기 어려운 경우가 있습니다. 그래서 표준 예외를 사용할 때는 예외 메시지를 상세히 작성해야 하는 번거로움이 있습니다. 또한 커스텀 예외를 사용하면 애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기 수월해집니다. 표준 예외를 상속받은 커스텀 예외들을 개발자가 직접 코드로 관리하기 때문에 책임 소재를 애플리케이션 내부로 가져올 수 있게 됩니다. 이를 통해 동일한 예외 상황이 발생할 경우 한 곳에서 처리하며 특정 상황에 맞는 예외 코드를 적용할 수 있게 됩니다.

마지막으로 커스텀 예외를 사용하면 예외 상황에 대한 처리도 용이합니다. 앞에서 @ControllerAdivce와 @ExceptionHandler에 대해 알아봤는데, 이러한 어노테이션을 사용해 애플리케이션에서 발생하는 예외 상황들을 한 곳에서 처리할 수 있었습니다.

package com.example.devproject.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@Slf4j
// ResponseEntityExceptionHandler를 상속받고 클래스에 @ControllerAdvice를 붙인다.
@ControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    // 응답 본문에 오류 정보를 표시하려면 handleExceptionInternal 메서드를 재정의 해야 한다.

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex,
                                                             Object body,
                                                             HttpHeaders headers,
                                                             HttpStatus status,
                                                             WebRequest request) {
        log.info("handleExceptionInternal");
        ApiErrorInfo apiErrorInfo = new ApiErrorInfo();
        apiErrorInfo.setMessage(ex.getClass().getSimpleName());

        return super.handleExceptionInternal(ex, apiErrorInfo, headers, status, request);
    }
}
package com.example.devproject.exception;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class ApiErrorInfo {
    private String message;


}

처리할 수 있는 예외

  • 등록할 때 제목에 빈 값을 입력하여 유효값 검증 예외 발생

처리할 수 없는 에외

  • 존재하지 않는 게시물을 조회할 때 사용자가 정의한 에외 발생

ApiExceptionHandler

    // 사용자 정의 예외를 처리하려면 사용자가 예외 처리 메서드를 구현해야 한다.
    @ExceptionHandler
    // 메서드 매개변수에 처리해야 하는 예외 클래스를 선언한다.
    public ResponseEntity<Object> handleBoardRecordNotFoundException(BoardRecordNotFoundException ex, WebRequest request) {
        ApiErrorInfo apiErrorInfo = new ApiErrorInfo();
        apiErrorInfo.setMessage("BoardRecord Not Found");

        return super.handleExceptionInternal(ex, apiErrorInfo, null, HttpStatus.NOT_FOUND, request);
    }

처리할 수 있는 예외

  • 등록할 때 제목에 빈 값을 입력하여 유효값 검증 예외 발생
  • 존재 하지 않는 게시물을 조회할 때 사용자가 정의한 예외 발생
profile
발전하기 위한 공부

0개의 댓글