상세 컨텐츠

본문 제목

[스프링 1팀] 10-11장. 유효성 검사와 예외처리 및 액츄에이터

24-25/Spring 1

by oze 2025. 1. 17. 10:00

본문

728x90



[10. 유효성 검사와 예외 처리]

개요

 

유효성 검사의 문제점

📌유효성 검사란?

애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요한데 이것을 유효성 검사(데이터 검증)이라고 한다. ex) Java - NullPointException 예외

  1. 계층별로 진행하는 유효성 검사는 검사 로직이 각 클래스별로 분산되어 있어 관리하기 어렵다.
  2. 검증 로직에 중복이 많아 여러 곳에 유사한 기능의 코드가 존재할 수 있다.
  3. 만약 검증해야 할 값이 많다면 검증하는 코드가 길어진다. => 코드가 복잡해지고 가독성이 떨어진다.

📌 @Bean Validation

이 같은 문제를 해결하기 위해 자바에서는 'Bean Validation'이라는 데이터 유효성 검사 프레임워크를 제공한다.

Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공한다.

 

 

Hibernate Validator

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

 

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

* 프로젝트 생성 (7장 코드 활용)

 

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

원래 스프링 부트의 유효성 검사 기능은 spring-boot-starter-web에 포함되어 있었지만 스프링 부트 2.3 버전 이후로 별도의 라이브러리로 제공한다. 

// pom.xml
<dependencies>
    ...생략...
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    ...생략...
</dependencies>

 

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

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

 

DTO 객체 생성(data/dto/ValidRequestDto.java)

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

    @NotBlank
    String name;

    @Email
    String email;

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

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

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

    @Positive
    int count;

    @AssertTrue
    boolean booleanCheck;

}

 

 

📌유효성 검사를 위한 조건을 설정하는 어노테이션 

<문자열 검증>

  • @Null : null 값만 허용한다.
  • @NotNull : null을 허용하지 않는다.  ( "", " " 허용)
  • @NotEmpty : null, ""을 허용하지 않는다.  ( " " 허용)
  • @NotBlank : null, "", " "을 허용하지 않는다.

<최댓값 / 최솟값 검증>

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

<값의 범위 검증>

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

<시간에 대한 검증>

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

<이메일 검증>

  • @Email : 이메일 형식을 검사한다. ( "" 허용)

<자릿수 범위 검증>

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

<Boolean 검증>

  • @AssertTrue : true인지 체크한다. (null값 체크 x)
  • @AssertFalse : false인지 체크한다. (null값 체크 x)

<문자열 길이 검증>

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

<정규식 검증>

  • @Pattern(regexp = "$expression") : 정규식을 검사한다. (자바의 java.util.regex.Pattern 패키지의 컨벤션을 따름)

 

 

 

DTO객체를 사용하는 컨트롤러 객체 생성(controller/ValidationController.java)

@Restcontroller
@RequestMapping("/validation")
public class ValidationController {
    private final Logger LOGGER=LoggerFactory.getLogger(ValidatonController.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 객체에 대해서 유효성 검사를 수행할 수 있다.

 

 

@Validated 활용

앞에서 사용한 @valid는 자바에서 지원하는 어노테이션이며, 스프링 @validated 라는 별도의 어노테이션으로 유효성 검사를 지원한다. @Validated은 @Valid 어노테이션의 기능을 포함하고 있기 때문에 @Validated로 변경할 수 있고, 추가로 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.

 

data 패키지 안에 group 패키지를 생성하고 ValidationGroup1, ValidationGroup2 인터페이스를 생성한다.

public interface ValidationGroup1 {
}
public interface ValidationGroup2 {
}

 

data/dto/ValidatedRequestDto 경로가 되도록 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;
}

 

📌validRequestDTO와 차이점

@Min, @Max의 groups 속성을 사용하여 ValidationGroup1과 @Positive의 groups 속성으로  ValidationGroup2 그룹을 설정할 수 있다. 이를 통해 어느 그룹에 맞춰 유효성 검사를 실시할 것인지 지정한다.

 

 

이제 유효성 검사 그룹을 설정하기 위해 컨트롤러 클래스에 메서드를 추가한다.

controller/ValidationController.java

@Restcontroller
@RequestMapping("/validation")
public class ValidatonController {
    private final Logger LOGGER=LoggerFactory.getLogger(ValidatonController.class);

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

    @PostMapping("/validation/group1")
    public ResponseEntity<String> checkValidation1(
            @Validated(ValidationGroup1.class) @RequestBody ValidationRequestDto 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.toStirng());
    }

    @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());
    }
}

 

커스텀 Validation 추가

유효성 검사 중 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 써야 할 때 ConstraintValidator와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있다. ex) @Pattern

 

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

 

TelephoneValidator클래스 생성(config/annotation/TelephoneValidator.java)

public class TelephoneValidator implements ConstraintValidator<Telephone, String> {
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context){
        if(value==null){
            return false;
        }
        return value.matches("01(?|:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]$");
    }
}

 

TelephoneValidator 클래스를 ConstraintValidator 인터페이스의 구현체로 정의한다. 인터페이스를 선언할 때는 어떤 어노테이션 인터페이스인지 타입을 지정해야 한다.

ContraintValidator 인터페이스는 isvalid() 메서드를 정의하고 있다.(유효성 검사 로직 직접 작성) 만약 false가 리턴되면 MethodArgumentNotValidException 예외가 발생한다.

 

이제 ConstraintValidator 인터페이스에서 정의한 Telephone 인터페이스를 살펴보자.

 

Telephone 인터페이스(config/annotation/Telephone.java)

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=TelephoneValidator.class)
public class Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다.";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

📌@Target

해당 어노테이션이 선언될 수 있는 위치를 정의한다. 여기서는 필드에 선언할 수 있게 설정되어 있다.

  • ElementType.PACKAGE
  • ElementType.TYPE
  • ElementType.CONSTRUCTOR
  • ElementType.FIELD
  • ElementType.METHOD
  • ElementType.ANNOTATION_TYPE
  • ElementType.LOCAL_VARIABLE
  • ElementType.PARAMETER
  • ElementType.TYPE_PARAMETER
  • ElementType.TYPE_USE

📌 @Retention

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

  • RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조한다. (리플렉션이나 로깅에 많이 사용)
  • RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유지한다.
  • RetentionPolicy.SOURCE : 컴파일 전까지만 유지된다. 컴파일 이후에는 사라진다.

📌@Constraint

TelephoneValidator와 매핑하는 작업을 수행한다. @Telephone 인터페이스 내부에는 message(), groups(), payload() 요소를 정의해야 한다.

  • message() : 유효성 검사를 실패할 경우 반환되는 메시지를 의미한다.
  • groups() : 유효성 검사를 사용하는 그룹으로 설정한다.
  • payload() : 사용자가 추가 정보를 위해 전달하는 값이다.

 

이렇게 하면 인텔리제이 IDEA의 [Bean Validation] 탭에서 @Telephone 추가된 것을 확인할 수 있다.

ValidatedRequestDto 클래스에서 phoneNumber 변수의 어노테이션을 변경한다.

 

직접 생성한 새로운 유효성 검사 어노테이션 적용(data/dto/ValidatedRequestDto.java)

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

    @NotBlank
    private String name;

    @Email
    private String email;

    @Telephone //@Pattern을 @Telephone으로 변경
    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;
}

 

 

예외 처리

예외와 에러

📌예외(exception)

애플리케이션이 정상적으로 동작하지 못하는 상황으로 개발자가 직접 처리할 수 있다.

ex) 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등

 

📌에러(error)

주로 자바의 가상머신에서 발생시키는 것으로 예외와 달리 코드에서 처리할 수 있는 것이 거의 없다. 이러한 에러는 발생시점에 처리하는 것이 아니라 미리 예방해서 원천적으로 차단해야 한다.

ex) 메모리 부족, 스택 오버플로 등

 

 

예외 클래스

자바의 예외 클래스는 상속 구조를 갖추고 있다. 모든 예외 클래스는 Throwable 클래스를 상속받는다.

 

   Exception 클래스는 크게 Checked Exception과 Unchecked Exception으로 구분할 수 있다.

📌checked Exception vs Unchecked Exception 

 

 

예외 처리 방법

1. 예외 복구

2. 예외 처리 회피

3. 예외 전환

 

예외 복구 

예외 상황을 파악하여 문제를 해결하는 방식이다. ex) try/catch문

int a=1;
String b="a"

try{ // 예외가 발생할 수 있는 코드 작성
    System.out.println(a+Integer.parseInt(b));
 } catch (NumberFormatException e){  // try 블록에서 발생하는 예외 상황을 처리하는 내용 작성
     b="2"; 
     System.out.println(a+Integer.parseInt(b));
 }

 

 

예외 처리 회피

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

int a = 1;
String b = "a";

try {
    System.out.println(a + Integer.parseInt(b));
} catch (NumberFormatException e) {
    throw new NumberFormatException("숫자가 아닙니다.");
}

 

 

예외 전환

예외 복구 예외 처리 회피 방식을 적절하게 섞은 방식이다.  

어떤 예외가 발생했는지에 따라 더 적합한 예외 타입을 전달할 필요가 있다. 그리고 예외처리를 좀 더 단순하기 위해 래핑(wrapping) 해야 하는 경우도 있다.

이런 경우에는 try/catch 방식을 사용하면서 catch블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 된다.

 

 

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

 

예외가 발생했을 때 클라이언트에 오류 메시지를 전달하려면, 각 레이어에서 발생한 예외를 엔드포인트 레벨인 컨트롤러로 전달해야 한다. 

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

  ( * @ControllerAdvice 대신 @RestControllerAdvice를 사용하면 결괏값을 JSON 형태로 반환할 수 있음)

2. @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리

 

<1번 방법>

@RestControllerAdvice를 활용한 핸들러 클래스 생성(Common/exception/CustomExceptionHandler.java)

@RestControllerAdvice
public class CustomException {
    
    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 내 handleException 호출, {}, {}", requeset.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);
    }   
}

 

📌@RestControllerAdvice, @ControllerAdvice

@Controller, @RestController 에서 발생하는 예외를 한 곳에서 관리하고 처리할 수 있게 하는 기능을 수행하는 어노테이션이다. 다음과 같이 별도 설정을 통해 예외를 관제하는 범위를 지정할 수 있다.

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

 

📌@ExceptionHandler

@Controller, @RestController가 적용된 빈에서 발생하는 예외를 잡아 처리하는 메서드를 정의하는 기능을 하는 어노테이션이다. 어떤 예외 클래스를 처리할지는 value 속성으로 등록한다. (value는 배열 형식으로도 전달받을 수 있음)

 

 

ExceptionController 생성(controller/ExceptionController.java)

@RestController
@RequestMapping("/exception")
public class ExceptionController {
    
    @GetMapping
    public void getRuntimeException(){
        throw new RuntimeException("getRuntimeException 메서드 호출");
    }
}

 

예외를 발생시킬 수 있는 컨트롤러로, getRuntimeException() 메서드는 컨트롤러로 요청이 들어오면 RuntimeException을 발생시킨다. 이처럼 컨트롤러에서 던진 예외는 @ControllerAdvice 또는 @RestControllerAdvice가 선언되어 있는 핸들러 클래스에서 매핑된 예외 타입을 찾아 처리하게 된다. 

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

 

 

 

 

<2번 방법>

 @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리(controller/ExceptionController.java)

@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와 컨트롤러 내에 동일한 예외 타입을 처리한다면 좀 더 우선수위가 높은 클래스 내의 핸들러 메서드가 사용된다.

 

📌우선순위 비교 방법

1. 예외 타입 레벨에 따른 예외 처리 우선순위

: 좀 더 구체적인 클래스가 지정된 쪽이 우선순위가 높다.

 

 

2. 핸들러 위치에 따른 예외 처리 우선순위

: 범위가 더 좁은 컨트롤러의 핸들러 메서드가 우선순위가 높다.

 

 

 

 

커스텀 예외

대부분의 상황에서는 자바에서 제공하는 표준 예외(Standard Exception)를 사용하면 해결된다.

그렇다면 커스텀 예외(Custom Exception)가 필요한 이유는 무엇일까?

 

1. 네이밍 개발자의 의도를 담을 수 있기 때문에 이름만으로도 어느 정도 예외 상황을 짐작할 수 있다.

2.  애플리케이션에서 발생하는 예외를 개발자가 직접 관리하기가 수월해진다. 

3.  예외 상황에 대한 처리가 용이하다.

 

 

 

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

@ControllerAcvice와 @ExceptionHandler의 무분별한 예외 처리를 방지하기 위한 커스텀 예외를 생성해 보자.

 

Exception 클래스

public class Exception extends Throwable { // Throwable 클래스 상속
    static final long serialVersionUID=-3387516993124229948L;
    
    public Exception(){
        super();
    }
    
    public Exception(String message, Throwable cause){
        super(message,cause);
    }
    
    public Exception(Throwable cause){
        super(cause);
    }
    
    protected Exception(String message, Throwable cause,
                        boolean enableSuppression,
                        boolean writableStackTrace){
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

Exception 클래스는 Throwable 클래스를 상속받는다. 생성자는 String 타입의 메시지 문자열을 받고, 이 생성자는 Throwable 클래스의 생성자를 호출한다.

 

 


Trowable 클래스

public class Throwable implements Serializable {
    
    private static final long serialVersionUID=-3042686055658047285L;
    
    private transient Object backtrace;
    
    private String detailMessage;
    
    //생략
    
    public Throwable(){
        fillInStackTrace();
    }
    
    public Throwable(String message){
        fillInStackTrace();
        detailMessage=message;
    }
    
    public String getMessage(){
        return detailMessage;
    }
    
    public String getLocalizedMessage(){
        return getMessage();
    }
    //생략
}

 

Exception 클래스는 부모 클래스인 Throwable 클래스의 생성자를 호출하고, message 변수 값을 detailMessage 변수로 전달받는다.

 

그리고 HttpStatus를 커스텀 예외 클래스에 포함시키면 핸들러 안에서 선언해서 사용하는 것이 아닌 예외 클래스만 전달받으면 그 안에 내용이 포함돼 있는 구조로 설계할 수 있다. (HttpStatus => 열거형)


Exception 클래스

public enum HttpStatus{
    
    BAD_REQUEST(400,Series.CLIENT_ERROR, "Bad Request"),
    UNAUTHORIZED(401, Series.CLIENT_ERROE,"Unauthorized"),
    PAYMENT_REQUIRED(402, Series.CLIENT_ERROR,"Payment Requred"),
    FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"),
    NOT_FOUND(404, Series.CLIENT_ERROE,"Not Found"),
    METHOD_NOT_ALLOWED(405, Series.CLIENT_ERROR,"Method Not Allowed"),
    
    HttpStatus(int value, Series series, String reasonPhrase){
        this.value=value;
        this.series=series;
        this.reasonPhrase=reasonPhrase;
    }
    
    public int value(){
        return this.value;
    }
    
    public Series series(){
        return this.series;
    }
    
    public String getReasonPhrase(){
        return this.reasonPhrase;
    }
}

 

HttpStatus는 value, series, reasonPhrase 변수로 구성된 객체를 제공한다. 이것은 흔히 볼 수 있는 Http응답 코드와 메시지이다. 코드 모음은 1xx, 2xx, 3xx, 4xx, 5xx으로 구성되어 있다.

 

📌커스텀 예외 클래스를 생성하는데 필요한 내용

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


Constants 클래스
(common/Constants.java)

:도메인 레벨 표현을 위한 열거형

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.";
        }
    }
}

 

열거형을 별도로 생성해도 무관하지만 상수 개념으로 사용하기 때문에 앞으로의 확장성을 위해 Constants라는 상수들을 통합 관리하는 클래스를 생성하고 내부에 ExceptionClass를 선언했다.

 

 

커스텀 예외 클래스(common/exception/CustomException.java)

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를 필드로 가진다. 두 객체를 기반으로 예외 내용을 정의한다.

 

 


handleException() 메서드
( common/exception/CustomExceptionHandler.java)

@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());
    }

 

 

ExceptionHandler 클래스에 CustomException에 대한 예외 처리 코드를 추가한다. 위와 같이 처리하면 예외 발생 시점에

HttpStatus를 정의해서 전달하기 때문에 클라이언트 요청에 따라 유동적인 응답 코드를 설정할 수 있다.

 


 

 

[11. 액추에이터 활용하기]

개요

 

📌스프링 부트 액추에이터

운영단계에 접어들면 애플리케이션이 정상적으로 동작하는지 모니터링하는 환경을 구축하는 것이 매우 중요해진다.

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

 

 

프로젝트 생성 및 의존성 추가

spring-boot-starter-actuator 모듈 종속성 추가

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

 

 

엔드 포인트

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

 

  /actuator 대신 다른 경로를 사용하고 싶은 경우

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

 

 

📌앤드포인트 활성화 여부와 노출 여부

활성화는 기능 자체를 활성화할 것인지를 결정하는 것으로, 비활성화된 엔트포인트는 애플리케이션 컨텍스트에서 완전히 제거된다. 엔트포인트를 활성화하려면 application.propertis 파일에 속성을 추가하면 된다. 

management.endpoint.shutdown.enabled=true
management.endpoint.caches.enabled=false

 

액추에이터 설정을 통해 엔드포인트의 노출 여부만 설정하는 것도 가능하다.

1. JMX를 통한 노출

2. 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

 

web과 jmx 환경에서 엔드포인트를 전체적으로 노출하며, 스레드 덤프(thread dump)와 힙 덤프(heap dump) 기능은 제외하겠다는 의미이다.  엔드포인트는 애플리케이션에 관한 민감한 정보를 포함하고 있으므로 노출 설정을 신중하게 고려해야 한다.

 

 

액추에이터 기능 살펴보기

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

 

 

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

가동 중인 애플리케이션의 정보를 볼 수 있다. 

application.properties파일에 ‘info.’ 로 시작하는 속성 값들을 정의하는 것이 가장 쉬운 방법이다.

http://localhost:8080/actuator/info에 접근하면 결괏값을 확인할 수 있다.

info.organization.name=wikibooks
info.contact.email=thinkground.flature@email.com
info.contact.phoneNumber=010-1234-5678

 

* 결과

{
   "organization":{
      "name":"wikibooks"
   },
   "contact":{
      "email":"thinkground.flature@email.com",
      "phoneNumber":"010-1234-5678"
   }
}

 

 

 

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

애플리케이션의 상태를 확인할 수 있다.

별도의 설정 없이 http://localhost:8080/actuator/health에 접근하면 된다.

{"status":"UP"}

 

 

 

빈 정보 확인(/beans)

스프링 컨테이너에 등록된 스프링 빈의 전체 목록을 표시할 수 있다. 이 엔드포인트는 JSON 형식으로 빈의 정보를 반환한다. 

http://localhost:8080/actuator/beans

{
  "contexts":{
    "application":{
      "beans":{
          "endpointCachingOperationInvokerAdvisor":{
             "aliases":[],
             "scope":"singleton",
             "type":"org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor", ...
      },
      "parentId":null
    }
  }
}

 

 

 

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

스프링 부트의 자동설정(AutoConfiguration) 조건 내역을 확인할 수 있다.

http://localhost:8080/actuator/conditions

 

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

 

 

 

 

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

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

http://localhost:8080/actuator/env

 

* 일부 내용에 포함된 민감한 정보를 가리기 위해서는 management.endpoint.env.keys-to-sanitize 속성을 사용하면 된다. 해당 속성에 넣을 수 있는 값은 단순 문자열이나 정규식을 활용한다.

 

 

 

로깅 레벨 확인(/loggers)

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

http://localhost:8080/actuator/loggers

 

 

 

 

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

액추에이터는 개발자의 요구사항에 맞춘 커스텀 기능 설정도 제공한다. 

1. 기존 기능에 내용을 추가하는 방식

2. 새로운 엔드포인트를 개발하는 방식

 



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

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

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

 

CustomInfoContributor 클래스

: InfoContributor 인터페이스에 대한 구현 클래스

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

 

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

{
  "organization":{
    "name":"wikibooks"
  },
  "contact":{
    "email":"thinkground.flature@email.com",
    "phoneNumber":"010-1234-5678"
  },
  "custom-info-contributor":{
    "code-info":"InfoContributor 구현체에서 정의한 정보입니다."
  }
}

 

 



커스텀 엔드포인트 생성

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

 

이번에는 간단하게 애플리케이션에 메모 기록을 남길 수 있는 기능을 엔드포인트로 생성해 보자.

 

NoteEndpoint 클래스

@Component
@Endpoint(id = "note")
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라는 속성으로 현재 생성하는 엔드포인트의 기본 활성화 여부도 설정 가능하다.

엔드포인트를 설정하는 클래스에는 @ReadOpertation, @WriteOperation, @DeleteOperation 어노테이션을 사용해 각 동작 메서드를 생성할 수 있다.

 

@ReadOperation 어노테이션

HTTP GET 요청을 반응하는 메서드를 생성할 수 있다. 여기서는 noteContent라는 Map 객체를 전달하고 있다.

 

@WriteOperation 어노테이션

POST 호출을 통해 @WriteOperation 동작을 확인할 수 있다.

 

@WriteOperation 어노테이션

DELETE 호출을 통해 사용할 수 있으며, 현재 구성된 메서드에서는 key 값을 받아 Map 객체에서 해당 값을 삭제하는 작업을 수행한다.

 

 

 


 

QUIZ

1. 애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요한데 이것을 

(  유효성 검사   ) 라고 한다.
2. 스프링에서는 (  @validated   )라는 별도의 어노테이션으로 유효성 검사를 지원한다. 
3. 예외처리 방법에는 (  예외 복구  ), (  예외 처리 회피  ), (  예외 전환  ) 3가지가 있다.
4. 스프링부트 (  액추에이터  )는 HTTP 엔드포인트나 JMX를 활용해 애플리케이션을 모니터링하고 관리할 수 있는 기능을 제공한다. 
5. 애플리케이션의 상태를 확인할 수 있는 엔드포인트는 (  /health  )이다.

 

 

 

PROGRAMMING QUIZ

1. 다음은 DTO 객체에 대해서 유효성 검사를 수행하는 컨트롤러 객체 코드이다. 빈칸에 알맞은 어노테이션을 작성하시오. (@valid 대신 스프링이 지원하는 어노테이션 사용하기)

// 1번
@RequestMapping("/validation")
public class ValidatonController {
    private final Logger LOGGER=LoggerFactory.getLogger(ValidatonController.class);

    @PostMapping("/valided")
    public ResponseEntity<String> checkValidationByValid(
            (  2번  ) (  3번  ) ValidatedRequestDto validatedRequestDto){
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }
    
    ... 생략 ...
}

 

 

 

2. 다음은 커스텀 엔드포인트를 생성하는 코드이다. 엔드포인트의 경로가 /actuator/note ~ 일 때, 다음 코드를 완성하시오.

@Component
// 1번
public class NoteEndpoint {

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

    // 2번
    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;
    }
}

 

 

 

 

 

 

Answer

1.

@Restcontroller  // 1
@RequestMapping("/validation")
public class ValidatonController {
    private final Logger LOGGER=LoggerFactory.getLogger(ValidatonController.class);

    @PostMapping("/valided")
    public ResponseEntity<String> checkValidationByValid(
            @Validated @RequestBody ValidatedRequestDto validatedRequestDto){  // 2 & 3
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }
    
    ... 생략 ...
}

 

 

 

2.

@Component
@Endpoint(id = "note")  // 1
public class NoteEndpoint {

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

    @ReadOperation  // 2
    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;
    }
}

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

 

 

Corner Spring 1

Editor : via

 

 

728x90

관련글 더보기