상세 컨텐츠

본문 제목

[스프링2] 10-11. 유효성 검사와 예외 처리 & 액추에이터 활용하기

23-24/Spring 2

by hyom1n 2023. 12. 23. 01:12

본문

728x90

 애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요합니다. 이것을 유효성 검사 또는 데이터 검증이라고 부릅니다. 10장에서는 이 같은 유효성 검사 방법을 알아보겠습니다.


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

  • 계층별 유효성 검사는 검증 로직이 각 클래스별로 분산되어 있어 관리가 어렵습니다.
  • 검증 로직에 중복이 많을 수 있습니다.
  • 검증해야 할 값이 많다면 코드가 길어집니다.

 이런 다양한 문제로 코드가 복잡해지고 가독성이 떨어집니다. 이 같은 문제를 해결하기 위해 자바 진영에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공합니다. 

 

 Bean Validation 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공합니다. 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행하는 것입니다. 

코드의 간결함을 유지할 수 있습니다.

 

10.2 Hibernate Validator

 Bean Validation 명세의 구현체입니다. 스프링 부트에서는 이것을 유효성 검사 표준으로 채택해서 사용하고 있습니다. 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와줍니다.

 

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

지금부터 애플리케이션에 유효성 검사 기능을 추가하겠습니다. 7장에서 사용한 패키지와 클래스 구조를 그대로 가져와 만들겠습니다.

 

10.3.1 프로젝트 생성

 새로운 프로젝트를 다음과 같이 생성해 주세요.

  • groupId: com.springboot
  • artifactId: valid_exception
  • name: vaild_exception
  • Developer Tools: Lombok, Spring Configuration Processor
  • Web: Spring Web
  • SQL: Spring Data JPA, MariaDB Driver

7장에서 다음과 같이 파일을 가져와 기본적인 프로젝트를 생성합니다. 그리고 5장에서 다룬 것처럼 SwaggerConfiguration 클래스 파일을 생성하고 관련 의존성을 pom.xml에 추가합니다.

 

10.3.2 스프링 부트용 유효성 검사 관련 의존성 추가

 pom.xml에 유효성 검사 라이브러리를 의존성으로 추가하면 사용할 수 있습니다.

<dependencies>
    '''
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-validation</artifactId>
	</dependency>
    '''
</dependencies>

 

10.3.3 스프링 부트의 유효성 검사

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

이번 장의 실습을 위한 DTO와 컨트롤러를 생성하겠습니다. 먼저 ValidRequestDto라는 이름의 DTO 객체를 생성합니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{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;

}

 

각 어노테이션은 유효성 검사를 위한 조건을 설정하는 데 사용됩니다.

 

문자열 검증

  • @Null: null 값만 허용합니다.
  • @NotNull: null값을 허용하지 않습니다. "", " "는 허용합니다.
  • @NotEmpty: null, ""을 허용하지 않습니다. " "는 허용합니다.
  • @NotBlank: null, "", " "을 허용하지 않습니다.

최댓값/최솟값 검증

  • BigDecimal, BigInteger, int long 등의 타입을 지원합니다.
  • @DecimalMax(value = "$numberString") : $numberString보다 작은 값을 허용합니다.
  • @DecimalMin(value = "$numberString") : $numberString보다 큰 값을 허용합니다.
  • @ Min(value = $number) : $number 이상의 값을 허용합니다.
  • @ Max(value = $number) : $number 이하의 값을 허용합니다.

값의 범위 검증

  • BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.
  • @Positive: 양수를 허용합니다.
  • @PositiveOrZero: 0을 포함한 양수를 허용합니다.
  • @Negative: 음수를 허용합니다.
  • @NegativeOrZero: 0을 포함한 음수를 허용합니다.

시간에 대한 검증

  • Date, LocalDate, LocalDateTime 등의 타입을 지원합니다.
  • @Future: 현재보다 미래의 날짜를 허용합니다.
  • @FutureOrPresent: 현재를 포함한 미래의 날짜를 허용합니다.
  • @Past: 현재보다 과거의 날짜를 허용합니다.
  • @PastOrPresent: 현재를 포함한 과거의 날짜를 허용합니다.

이메일 검증

  • @Email: 이메일 형식을 검사합니다. ""는 허용합니다.

자릿수 범위 검증

  • BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.
  • @Digits(integer = $number1, fraction = $number2): $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용합니다.

Boolean 검증

  • @AssertTrue: true인지 체크합니다. null 값은 체크하지 않습니다.
  • @AssertFalse: false인지 체크합니다. null 값은 체크하지 않습니다.

문자열 길이 검증

  • @Size(min = $number1, max = $number2) :  $number1 이상 $number2 이하의 범위를 허용합니다.

정규식 검증

  • @Pattern(regexp = "$expression"): 정규식을 검사합니다. 정규식은 자바의 java.util.regex.Pattern 패키지의 컨밴션을 따릅니다.

 

다음으로 앞에서 생성한 DTO를 사용하는 컨트롤러 객체를 생성하겠습니다. ValidationController를 생성합니다.

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

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

}
 

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

 

동작을 확인하기 위해 Swagger 페이지에 접속하여 값을 다음과 같이 적어줍니다.

age는 20 이상, 40살 이하인 데이터만 받으며, booleanCheck는 @AssertTrue를 통해 값이 true인지 체크합니다. count에는 @Positive가 설정되어 있으므로 0이 아닌 양수가 값으로 들어오는지 체크합니다. description은 @Size를 통해 문자열의 길이를 제한했습니다. @Email 어노테이션을 설정한 email 필드에서는 값에 '@' 문자가 있는지 확인합니다. name은 @NotBlank로 null 값이나 "", " " 모두 허용하지 않게 설정해서 값을 의무적으로 받도록 설정했습니다. phoneNumber는 @Pattern을 통해 정규식을 설정하여 검증하고 있습니다.

 

10.3.4 @Validated 활용

 @Validate 어노테이션은 자바에서 지원하는 어노테이션이며, 스프링에서는 @Validated라는 별도의 어노테이션을 지원합니다. @Validate를 @Validated를 변경할 수 있으며, 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있습니다.

 

 검증 그룹은 별다른 내용이 없는 마커 인터페이스를 생성해서 사용합니다. 실습을 위해 data 패키지 내 group 패키지를 생성하고 ValidationGroup1과 ValidationGroup2라는 인터페이스를 생성합니다. 두 인터페이스 모두 내부 코드는 없으며, 인터페이스만 생성해서 그룹화하는 용도로 사용합니다. 

 

검증 그룹 설정은 DTO 객체에서 합니다. 

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    private String phoneNumber;

    @Min(value = 20, groups = ValidationGroup1.class)
    @Max(value = 40, groups = ValidationGroup1.class)
    private int age;

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

    @Positive(groups = ValidationGroup2.class)
    private int count;

    @AssertTrue
    private boolean booleanCheck;

}

@Min, @Max, @Positive 어노테이션의 groups 속성을 사용해 각각 그룹을 설정해 주었습니다. 이 설정을 통해 어느 그룹에 맞춰 유효성 검사를 실시할 것인지 지정한 것입니다. 

 

실제로 그룹을 어떻게 설정해서 유효성 검사를 실시할지 결정하는 것은 @Validated 어노테이션에서 합니다. 유효성 검사 그룹을 설정하기 위해 컨트롤러 클래스에 메서드를 추가하겠습니다.

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

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

    @PostMapping("/validated")
    public ResponseEntity<String> checkValidation(
            @Validated @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group1")
    public ResponseEntity<String> checkValidation1(
            @Validated(ValidationGroup1.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group2")
    public ResponseEntity<String> checkValidation2(
            @Validated(ValidationGroup2.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/all-group")
    public ResponseEntity<String> checkValidation3(
            @Validated({ValidationGroup1.class,
                    ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }
}

 

 테스트를 통해 @Validated 어노테이션에 특정 그룹을 설정한 경우에는 지정한 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행하는 것을 알 수 있습니다.

@Validated 어노테이션에 특정 그룹을 지정하지 않은 경우에는 groups 속성을 설정하지 않은 필드에 대해서만 유효성 검사를 실시하게 됩니다. 

 

10.3.5 커스텀 Validation 추가

 ConstraintValidator와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있습니다. @Pattern 어노테이션의 경우가 가장 흔한 사례입니다. 

 전화번호 형식이 일치하는지 확인하는 간단한 유효성 검사 어노테이션을 생성해 보겠습니다. 

public class TelephoneValidator implements ConstraintValidator<Telephone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value==null){ // null 허용 여부 검사
            return false;
        }
        return value.matches("01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$"); // 지정한 형식이 맞는지 검사
    }
}

 

클래스를 ConstraintValidator 인터페이스의 구현체로 정의합니다. ConstraintValidator 인터페이스의 invalid() 메서드를 구현하기 위해 직접 유효성 검사 로직을 작성합니다. false가 리턴되면 MethodArgumentNotValidException 예외가 발생합니다. 

 

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다."; // 실패 시 반환 메시지
    Class[] groups() default {}; // 유효성 검사를 사용하는 그룹으로 설정
    Class[] payload() default {}; // 사용자가 추가 정보를 위해 전달하는 값
}

 

@Target 어노테이션 이 어노테이션을 어디에서 선언할 수 있는지 정의하는 데 사용됩니다. 예제에서는 필드에서 선언할 수 있게 설정되어 있습니다. 

 

@Retention 어노테이션 실제로 적용되고 유지되는 범위를 의미합니다. RetentionPolicy에 의해 지정하며 지정 가능한 항목은 다음과 같습니다.

  • RetentionPolicy.RUNTIME: 컴파일 이후에도 JVM에 의해 계속 참조합니다. 리플렉션이나 로깅에 많이 사용되는 정책입니다.
  • RetentionPolicy.CLASS: 컴파일러가 클래스를 참조할 때까지 유지합니다.
  • RetentionPolicy.SOURCE: 컴파일 전까지만 유지됩니다. 컴파일 이후에는 사라집니다.

@Constraint 어노테이션은 TelephoneValidator와 매핑하는 작업을 수행합니다. 

 

직접 생성한 어노테이션을 적용해 보겠습니다. ValidatedRequestDto 클래스에서 phoneNumber 변수의 어노테이션을 변경합니다.

    @Telephone
    private String phoneNumber;

 

다음과 같이 유효성 검사에서 형식 오류를 감지하는 것을 볼 수 있습니다. 

[org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.phoneNumber,phoneNumber]; arguments []; default message [phoneNumber]]; default message [전화번호 형식이 일치하지 않습니다.]]

 

 

10.4 예외 처리

 이번 절에서는 예외 처리의 기초를 소개하고 스프링 부트에서 적용할 수 있는 예외 처리 방식을 알아보겠습니다.

 

10.4.1 예외와 에러

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

 

에러는 주로 자바의 가상 머신에서 발생시키는 것으로 애플리케이션 코드에서 처리할 수 있는 것이 거의 없습니다. 메모리 부족(OutOfMemory), 스택 오버플로(StackOverFlow) 등이 있으며, 코드를 보며 문제가 발생하지 않도록 원천적으로 차단해야 합니다.

 

10.4.2 예외 클래스

자바 예외 클래스는 그림과 같은 상속 구조를 갖고 있습니다. 모든 예외 클래스는 Throwable 클래스를 상속받습니다. 가장 익숙하게 볼 수 있는 Exception 클래스는 다양한 자식 클래스를 가지고 있습니다.

  Checked Exception Unchecked Exception
처리 여부 반드시 예외 처리 필요 명시적 처리를 강제하지 않음
확인 시점 컴파일 단계 실행 중 단계
대표적인 예외 클래스 IOException
SQLException
RuntimeException
NullPointerException
IllegalArgumentException
IndexOutOfBoundException
SystemException

Checked Exception은 IDE에서 캐치해서 반드시 예외 처리를 할 수 있게 표시해 줍니다. 반면 UnChecked Exception은 문법상 문제는 없지만 프로그램이 동작하는 도중 예기치 않은 상황이 생겨 발생하는 예외를 말합니다.

 

10.4.3 예외 처리 방법

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

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

예외 복구 방법은 예외 상황을 파악해서 문제를 해결하는 방식입니다. 대표적인 방법으로 try/catch 구문이 있습니다. 

 

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

 

예외 전환 방법은 앞의 두 방식을 적절하게 섞은 방식입니다. try/catch 방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하는 방식입니다. 

 

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

 웹 서비스 애플리케이션에서는 외부에서 들어오는 요청에 담긴 데이터를 처리하는 경우가 많습니다. 그 과정에서 요청을 보낸 클라이언트에 어떤 문제가 발생했는지 상황을 전달하는 경우가 많습니다. 이번 절에서는 이를 반영해 스프링 부트에서 사용하는 예외 처리 방법을 중심으로 설명하겠습니다.

 

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

 

  • @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
  • @ExceptionHandler를 특정 컨트롤러의 예외를 처리

@RestControllerAdvice를 활용한 핸들러 클래스를 작성하겠습니다. 

@RestControllerAdvice 
public class CustomExceptionHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
                                                               HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders(); // 응답 메시지 구성
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
                e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

}

 

@ControllerAdvice @Controller나 @RestController에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능을 수행합니다. 즉 다음과 같이 별도 설정을 통해 예외를 관제하는 범위를 지정할 수 있습니다.

@RestControllerAdvice(basePackages = "com.springboot.valid_exception")

 

@ExceptionHandler는 @Controller나 @RestController가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의할 때 사용합니다. 어떤 예외 클래스를 처리할지는 value 속성으로 등록합니다. 

 

테스트하기 위해 예외를 발생시킬 수 있는 컨트롤러를 생성하겠습니다. 

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    @GetMapping
    public void getRuntimeException() {
        throw new RuntimeException("getRuntimeException 메소드 호출");
    }

}

getRuntimeException() 메서드는 컨트롤러로 요청이 들어오면 RuntimeException을 발생시킵니다. 

 

이처럼 컨트롤러에서 던진 예외는 @ControllerAdvice 또는 @RestControllerAdvice가 선언되어 있는 핸들러 클래스에서 매핑된 예외 타입을 찾아 처리하게 됩니다. 별도 범위 설정이 없으면 전역 범위에서 예외를 처리하기 때문에 특정 컨트롤러에서만 동작하는 @ExceptionHandler 메서드를 생성해서 처리할 수도 있습니다. 

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);

    @GetMapping
    public void getRuntimeException() {
        throw new RuntimeException("getRuntimeException 메소드 호출");
    }

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.setContentType(MediaType.APPLICATION_JSON);
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("클래스 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }
}

이처럼 컨트롤러 클래스 내에 @ExceptionHandler 어노테이션을 사용한 메서드를 선언하면 해당 클래스에서 국한해서 예외 처리를 할 수 있습니다. 

 

로그 메시지를 보았을 때, 만약 @ControllerAdvice와 컨트롤러 내의 동일한 예외 타입을 처리한다면 좀 더 우선순위가 높은 클래스 내의 핸들러 메서드가 사용되는 것을 볼 수 있습니다. 

com.springboot.valid_exception.controller.ExceptionController 클래스 내 handleException 호출, /exception, getRuntimeException 메소드 호출

 

 

 

10.4.5 커스텀 예외

 애플리케이션을 개발하다 보면 점점 예외로 처리할 영역이 늘어나고 예외 상황이 다양해지면서 사용하는 예외 타입도 많아집니다. 대부분의 상황에서는 자바에서 제공하는 표준 예외를 사용하면 해결됩니다. 사실 애플리케이션의 예외 처리에는 표준 예외만 사용해도 모든 상황들을 처리할 수 있습니다. 그런데 왜 커스텀 예외를 만들어 사용할까요?

 

커스텀 예외를 만들어서 사용하면 네이밍에 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있습니다. 또한 관리하기 수월해집니다. 표준 예외를 상속받은 커스텀 예외들을 개발자가 직접 코드로 관리하기 때문에 책임 소재를 애플리케이션 내부로 가져올 수 있게 됩니다. 이를 동일한 예외 상황이 발생할 경우 한 곳에서 처리하며 특정 상황에 맞는 예외 코드를 적용할 수 있게 합니다. 

 

예외 상황에 대한 처리도 용이합니다. 의도하지 않았던 부분에서 발생한 예외는 개발자가 관리하는 예외 처리 코드가 처리하지 않으므로 개발 과정에서 혼동할 여지가 줄어듭니다.

 

10.4.6 커스텀 예외 클래스 생성하기

커스텀 예외를 생성하고 활용하는 방법을 살펴보겠습니다. 커스텀 예외는 만드는 목적에 따라 생성하는 방법이 다릅니다. 

Exception 클래스의 커스텀 예외를 만들어 보겠습니다. 예외 클래스의 상속 구조를 보면 Exception 클래스는 Throwable 클래스를 상속받습니다. Exception 클래스는 부모 클래스인 Throwable 클래스의 생성자를 호출하게 되며, message 변수의 값을 detailMessage 변수로 전달받습니다. 커스텀 예외를 생성하는 경우에도 이 message 변수를 사용하게 됩니다. 

 

HttpStatus를 커스텀 예외 클래스에 포함시키면 예외 클래스만 전달받으면 그 안에 내용이 포함되어 있는 구조로 설계할 수 있습니다. 

 

이번에 만들어 볼 커스텀 예외 클래스를 생성하는 데 필요한 내용은 다음과 같습니다.

  • 에러 타입(error type): HttpStatus의 reasonPhrase
  • 에러 코드(error code): HttpStatus의 value
  • 메시지(message): 상황별 상세 메시지

추가로 애플리케이션에서 가지고 있는 도메인 레벨을 메시지에 표현하기 위해 ExceptionClass 열거형 타입을 생성하겠습니다. 이를 도식화하면 다음과 같은 구조가 됩니다.

커스텀 예외 클래스를 생성하기 앞서 도메인 레벨을 표현하기 위한 열거형을 다음과 같이 생성하겠습니다.

public class Constants {

    public enum ExceptionClass {

        PRODUCT("Product");

        private String exceptionClass;

        ExceptionClass(String exceptionClass) {
            this.exceptionClass = exceptionClass;
        }

        public String getExceptionClass() {
            return exceptionClass;
        }

        @Override
        public String toString() {
            return getExceptionClass() + " Exception. ";
        }

    }

}

확장성을 위해 Contants라는 상수들을 통합 관리하는 클래스를 생성하고 내부에 ExceptionClass를 선언했습니다. ExceptionClass라는 열거형은 커스텀 예외 클래스에서 메시지 내부에 어떤 도메인에서 문제가 발생했는지 보여주는 데 사용됩니다. 지금은 상품이라는 도메인에 대해서만 실습 코드를 작성해 왔기 때문에 PRODUCT라는 상수만 선언했습니다.

 

public class CustomException extends Exception{

    private Constants.ExceptionClass exceptionClass;
    private HttpStatus httpStatus;

    public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus,
                           String message) { // 클래스 초기화
        super(exceptionClass.toString() + message);
        this.exceptionClass = exceptionClass;
        this.httpStatus = httpStatus;
    }

    public Constants.ExceptionClass getExceptionClass() {
        return exceptionClass;
    }

    public int getHttpStatusCode() {
        return httpStatus.value();
    }

    public String getHttpStatusType() {
        return httpStatus.getReasonPhrase();
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }

}

커스텀 예외 클래스는 앞에서 만든 ExceptionClass와 HttpStatus를 필드로 가집니다. 두 객체를 기반으로 예외 내용을 정의합니다.

 

이제 커스텀 예외를 활용해 보겠습니다.  먼저 ExceptionHandler 클래스에 CustomException에 대한 예외 처리 코드를 추가합니다.

    @ExceptionHandler(value = CustomException.class)
    public ResponseEntity<Map<String, String>> handleException(CustomException e,
                                                               HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
                e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", e.getHttpStatusType());
        map.put("code", Integer.toString(e.getHttpStatusCode()));
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
    }

위와 같이 처리하면 기존에 작성했던 핸들러 메서드와 달리 예외 발생 시점에 HttpStatus를 정의해서 전달하기 때문에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다는 장점이 있습니다. 

 

컨트롤러 메서드를 생성합니다.

@GetMapping("/custom")
public void getCustomException() throws CustomException {
    throw new CustomException(Constants.ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출");
}

 

Respose Body를 통해 예외 발생 지점에서 설정한 값이 정상적으로 담겨 클라이언트로 응답한 것을 볼 수 있습니다. 

 


 애플리케이션을 개발하는 단계를 지나 운영 단계에 접어들면 애플리케이션이 정상적으로 동작하는지 모니터링하는 환경을 구축하는 것이 매우 중요해집니다. 스프링 부트 액추에이터 HTTP 엔드포인트나 JMX를 활용해 애플리케이션을 모니터링하고 관리할 수 있는 기능을 제공합니다. 11장에서는 액추에이터의 환경을 설정하고 활용하는 방법을 다룰 예정입니다.


11.1 프로젝트 생성 및 액추에이터 종속성 추가

새로운 프로젝트를 다음과 같이 생성해 주세요.

  • groupId: com.springboot
  • artifactId: actuator
  • name:actuator
  • Developer Tools: Spring Configuration Processor
  • Web: Spring Web

그리고 이전 장에서 사용한 SwaggerConfiguration 클래스를 가져오고 그에 따른 의존성을 추가합니다. 

액추에이터 기능을 사용하려면 애플리케이션에 spring-boot-starter-actuator 모듈의 종속성을 추가해야 합니다. 

<dependencies>   
    '''
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    '''
</dependencies>

11.2 엔드포인트

액추에이터의 엔드포인트 애플리케이션의 모니터링을 사용하는 경로입니다. 스프링 부트에는 여러 내장 엔드포인트가 포함되어 있으며, 커스텀 엔드포인트를 추가할 수도 있습니다. 엑추에이터는 기본적으로 엔드포인트 URL로 /actuator가 추가되며 이 뒤에 경로를 추가해 상세 내역에 접근합니다. 

만약 /actuator 경로가 아닌 다른 경로를 사용하고 싶다면 application.properties 파일에 작성합니다. 

managment.endpoints.web.base-path=/custom-path

 

엔드포인트는 활성화 여부와 노출 여부를 설정할 수 있습니다. 활성화는 기능 자체를 활성화할 것인지를 결정하는 것으로 비활성화된 엔드포인트는 애플리케이션 컨텍스트에서 완전히 제거됩니다.  엔드포인트를 활성화하려면 application.properties 파일에 속성을 추가하면 됩니다.

// 엔드포인트활성화 예시
management.endpoint.shutdown.enabled=true
management.endpoint.caches.enabled=false

 

또한 액추에이터 설정을 통해 엔드포인트의 노출 여부만 설정하는 것도 가능합니다. 노출 여부는 JMX를 통한 노출과 HTTP를 통한 노출이 있어 다음과 같이 설정이 구분됩니다.

// 엔드포인트 노출 설정
// HTTP 설정
management.endpoints.web.exposure.include=*
management.endpoints.web.exposure.exclude=threaddump,heapdump

// JMX 설정
management.endpoints.jmx.exposure.include=*
management.endpoints.jmx.exposure.exclude=threaddump, heapdump

엔드포인트는 애플리케이션에 관한 민감한 정보를 포함하고 있으므로 노출 설정을 신중하게 고려해야 합니다.

 

11.3 액추에이터 기능 살펴보기

액추에이터를 활성화하고 노출 지점도 설정하고 나면 애플리케이션에서 해당 기능을 사용할 수 있습니다. 

 

11.3.1 애플리케이션 기본 정보(/info)

액추에이터의 /info 엔드포인트를 활용하면 가동 중인 애플리케이션의 정보를 볼 수 있습니다. application.properties 파일에 'info.'로 시작하는 속성 값들을 정의하는 것이 가장 쉬운 방법입니다. 

// 액추에이터 info 정보 설정
info.organization.name=dodam
info.contact.email=dodam@naver.com
info.contact.phoneNumber=010-1234-5678

 

11.3.2 애플리케이션 상태(/health)

/health 엔드 포인트를 활용하면 애플리케이션의 상태를 확인할 수 있습니다. 

이 결과는 주로 네트워크 계층 중 L4(Loadbalancing) 레벨에서 애플리케이션의 상태를 확인하기 위해 사용됩니다. 상세 상태를 확인하고 싶다면 다음과 같이 설정하면 됩니다.

// 액추에이터 health 상세 내역 활성화
management.endpoint.health.show-details=always

설정할 수 있는 값은 다음과 같습니다.

  • never(기본값): 세부 사항은 표시하지 않습니다.
  • when-authorized: 승인된 사용자에게만 세부 상태를 표시합니다.
  • always: 모든 사용자에게 세부 상태를 표시합니다.

11.3.3 빈 정보 확인(/beans)

액추에이터의 /beans 엔드포인트를 사용하면 스프링 컨테이너에 등록된 스프링 빈의 전체 목록을 표시할 수 있습니다. JSON 형식으로 빈의 정보를 반환합니다. 

 

11.3.4 스프링 부트의 자동설정 내역 확인(/conditions)

스프링 부트의 자동설정(AutoConfiguration) 조건 내역을 확인하기 위해 사용합니다.

출력 내용은 크게 positiveMatches와 negativeMatches 속성으로 구분되는데, 자동설정의 @Conditional에 따라 평가된 내용을 표시합니다.

 

11.3.5 스프링 환경변수 정보(/env)

스프링의 환경변수 정보를 확인하는 데 사용됩니다. 기본적으로 application.properties 파일의 변수들이 표시되며, OS, JVM의 환경 변수도 함께 표시됩니다. 

 민감한 정보를 가리기 위해서는 management.endpoint.env.keys-to-sanitize 속성을 사용하면 됩니다. 

 

11.3.6 로깅 레벨 확인(/loggers)

애플리케이션의 로깅 레벨 수준이 어떻게 설정되어 있는지를 확인할 수 있습니다. 

get 메서드로 호출한 결과이며, POST 형식으로 호출하면 로깅 레벨을 변경하는 것도 가능합니다.

 

11.4 액추에이터에 커스텀 기능 만들기

앞에서 살펴봤듯이 액추에이터는 다양한 정보를 가공해서 제공합니다. 그 밖에 개발자의 요구사항에 맞춘 커스텀 기능 설정도 제공합니다. 

 

11.4.1 정보 제공 인터페이스의 구현체 생성

액추에이터를 커스터마이징 하는 간단한 방법은 application.properties 파일 내에 내용을 추가하는 것입니다. 그러나 관리 측면에서 좋지 않습니다.

그래서 커스텀 기능을 설정할 때는 별도의 구현체 클래스를 작성해서 내용을 추가하는 방법이 많이 활용됩니다. 엑추에이터에서는 InfoContributor 인터페이스를 제공하고 있는데, 이 인터페이스를 구현하는 클래스를 생성하면 됩니다. 

@Component
public class CustomInfoContributor implements InfoContributor {

    @Override
    public void contribute(Builder builder) {
        Map<String, Object> content = new HashMap<>();
        content.put("code-info", "InfoContributor 구현체에서 정의한 정보입니다.");
        builder.withDetail("custom-info-contributor", content);
    }
}

파라미터로 받는 Builder 객체는 액추에이터 패키지의 Info 클래스 안에 정의되어 있는 클래스로서 Info 엔드포인트에서 보여줄 내용을 담는 역할을 수행합니다.

 

11.4.2 커스텀 엔드포인트 생성

@Endpoint 어노테이션으로 빈에 추가된 객체들은 @ReadOperation, @WriteOperation, @DeleteOperation을 사용해 JMX나 HTTP를 통해 커스텀 엔드포인트를 노출시킬 수 있습니다. 만약 JMX나 HTTP에서만 사용하는 것으로 제한하고 싶다면 @JmxEndpoint, @WebEndpoint를 사용하면 됩니다.

 

간단하게 애플리케이션에 메모 기록을 남길 수 있는 기능을 엔드포인트로 생성하겠습니다.

@Component
@Endpoint(id = "note", enableByDefault = true)
public class NoteEndpoint {

    private Map<String, Object> noteContent = new HashMap<>();

    @ReadOperation
    public Map<String, Object> getNote(){
        return noteContent;
    }

    @WriteOperation
    public Map<String, Object> writeNote(String key, Object value){
        noteContent.put(key,value);
        return noteContent;
    }

    @DeleteOperation
    public Map<String, Object> deleteNote(String key){
        noteContent.remove(key);
        return noteContent;
    }

}

@Endpoint를 선언하면 액추에이터에 엔드포인트로 자동으로 등록되며 id 속성값으로 경로를 정의할 수 있습니다. enableByDefault라는 속성으로 현재 생성하는 엔드포인트의 기본 활성화 여부도 설정 가능합니다.

 

@ReadOperation를 정의해 HTTP GET 요청에 반응하는 메서드를 생성했습니다. noteContent라는 Map 객체를 전달하고 있습니다. 

 

@WriteOperation 동작을 확인하기 위해 Talend API Tester에서 POST 호출을 시도합니다. 

값을 추가하고 다시 GET 메서드로 /note를 호출하면 결괏값이 변경된 것을 확인할 수 있습니다.

 

@DeleteOperation이 선언된 메서드를 호출해 보겠습니다. DELETE 호출을 통해 사용할 수 있으며, 현재 구성된 메서드에서는 key 값을 받아 Map 객체에서 해당 값을 삭제하는 작업을 수행합니다.

위와 같이 호출하면 지정된 키 값이 삭제된 결과를 확인할 수 있습니다.

 

지금까지 액추에이터의 커스텀 엔드포인트를 생성해 봤습니다. 커스텀 엔드포인트는 코드로 구현되기 때문에 더욱 확장성 있는 기능을 개발할 수 있습니다.

 


QUIZ

1. 애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요합니다. 이것을 (유효성 검사) 또는 데이터 검증이라고 부릅니다.

2. 문자열 검증 시, (@Null) 어노테이션을 사용해 null 값만 허용할 수 있도록 검증합니다.

3. 정규식을 검증할 때는 (@Pattern) 어노테이션을 사용해 정규식을 검사합니다.

4. 프로그래밍에서 (예외)란 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 의미합니다. 

5. @ControllerAdvice나 @RestControllerAdvice 어노테이션은 별도의 범위 설정이 없으면 전역 범위에서 예외 처리를 하기 때문에 특정 컨트롤러에서만 동작하는 (@ExceptionHandler) 메서드를 생성해서 처리할 수 있습니다.

6. (스프링 부트 액추에이터)는 HTTP 엔드포인트나 JMX를 활용해 애플리케이션을 모니터링하고 관리할 수 있는 기능을 제공합니다.

7. 액추에이터의 (/Info) 엔드포인트를 활용하면 가동 중인 애플리케이션의 정보를 볼 수 있습니다.

8. (@Endpoint) 어노테이션을 선언하면 액추에이터에 엔드포인트로 자동으로 등록되며 id 속성 값으로 경로를 정의할 수 있습니다.

9. 아래의 사진과 같이 결괏값이 출력되도록 application.properties 속성을 수정해 주세요.

10. 다음과 같은 DTO에 유효성 검사를 위한 조건을 추가해 주세요.

조건
- name은 null, "", " "을 허용하지 않습니다.
- email은 이메일 형식을 검사해주세요.
- phoneNumber은 전화번호로, 정규식을 세워 검사해주세요.
- age는 0이 아닌 양수여야 합니다.
- description은 문자열이 0이상 1500 미만이어야 합니다.
- count는 5이상 20 이하여야 합니다.
- booleanCheck는 false인지 체크해 주세요.

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    private String name;

    private String email;

    private String phoneNumber;

    private int age;

    private String description;

    private int count;

    private boolean booleanCheck;

}

 

정답

9. 

info.organization.name=Corner
info.contact.email=1234@naver.com
info.contact.phoneNumber=010-0000-1234

10. 

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    private String phoneNumber;

    @Positive
    private int age;

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

    @Min(value = 5)
    @Max(value = 20)
    private int count;

    @AssertFalse
    private boolean booleanCheck;

}

[출처] 장정우, 스프링 부트 핵심 가이드』, 위키북스(2022), p.292-348.

Corner Spring 2

Editor : 도담

728x90

관련글 더보기