Exception & Validation

2024. 1. 23. 16:46
728x90

1. 예외 처리 Exception Handling

: 예측하지 못한 상황이 발생했을 때, 프로그램은 예외를 발생시킨다.

 

1) ResponseStatusException

: 간단한 예외처리 방법. 

- 간편하게 사용할 수 있다

- 다만, 전체 프로젝트 단위에서 예외 처리를 적용하기 어려운 구조/ 똑같은 코드를 여러번 반복한다는 단점.

public UserDto updateUser(Long id, UserDto dto) {
    Optional<User> optionalUser
            = repository.findById(id);
    if (optionalUser.isEmpty())
        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    ...
    try {
    Files.createDirectories(Path.of("media"));
    Path path = Path.of("media/filename.png");
    multipartFile.transferTo(path);
} catch (IOException e) {
    throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}

 

2) ExceptionHandler

: Controller에 @ExceptionHandler 어노테이션 추가.

- 해당 컨트롤러 안에서 인자로 전달된 예외가 발생하면 >> 해당 예외를 인자로 전달받고 >> 전달받은 예외를 바탕으로 응답을 보낼 수 있다. 

// UserController
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorDto handleIllegalArgument(
    final IllegalArgumentException exception) {
    ErrorDto dto = new ErrorDto();
    dto.setMessage(exception.getMessage());
    log.warn(exception.getMessage());
    return dto;
}

@ExceptionHandler(IllegalArgumentException.class) //어떤 예외가 발생했을 때
public ResponseEntity<ErrorResponseDto> handleIllegalArgument(
        final IllegalArgumentException exception) { // 해당 예외를 인자로서 메서드를 실행하게 됨.
    return ResponseEntity
            .badRequest()
            .body(new ErrorResponseDto(exception.getMessage()));
}


// UserService
    public UserDto create(UserDto dto) {
        if (repository.existsByUsername(dto.getUsername())) {
            throw new IllegalArgumentException("duplicate username");
        }
        User user = new User(dto.getUsername(), dto.getEmail(), dto.getPhone(),
                dto.getBio());
        return UserDto.fromEntity(repository.save(user));
    }

 

--> 단점 : Controller 단위로만 작동한다.. 

 

3) @ControllerAdvice & @RestControllerAdvice

: 여러 Controller를 다루는 큰 프로젝트에서 전체 프로젝트에 관찰되는 예외를 정리하고 싶다면!? (프로젝트 전역 예외 처리)

- @ExceptionHandler를 모아두기 위한 Component의 일종

- @RestControllerAdvice : @ResponseBody가 포함된 형태.

@RestControllerAdvice
public class UserControllerAdvice {
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponseDto> handleIllegalArgument(
            final IllegalArgumentException exception) {
        return ResponseEntity
                .badRequest()
                .body(new ErrorResponseDto(exception.getMessage()));
    }
}

 

4) 커스텀 예외 활용하기 

: 프로젝트가 커지면서 예외 발생 상황도 다양하게 변할 수 있다. > 이 때 커스텀 예외를 만들 수 있음. 

- 이름에 상황에 대한 정보를 포함해서 빠르게 예외 상황에 대한 정보를 전달하기 편함.

- 상속 관계를 활용하여 예외 처리 과정 간소화

- @ResponseStatus 어노테이션과 함께 응답 상태 코드를 일관성 있게 전달 가능.

 

ex. 사용자 이름이 중복되었을 때

// UserService. class
    public UserDto create(UserDto dto) {
        if (repository.existsByUsername(dto.getUsername())) {
//            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
            throw new UsernameExistsException();
        }
        User user = new User(dto.getUsername(), dto.getEmail(), dto.getPhone(),
                dto.getBio());
        return UserDto.fromEntity(repository.save(user));
    }

// 사용자 이름이 중복일 때 발생하는 예외
public class UsernameExistsException extends RuntimeException {
    public UsernameExistsException() {
        super("username already exists");
    }
}

 

 

 

2. 유효성 검사

: 사용자가 입력한 데이터가 허용하는 형태의 데이터인지를 검증하는 작업.

 

1) spring-boot-starter-validation

- Jakarta Bean Validation : 유효성 검증을 위한 기술 명세. 어떤 항목이 어떤 규칙을 지켜야 하는지를 표시하는 기준.

- Hibernate Validation : Jakarta Bean Validation을 토대로 실제로 검증해주는 프레임워크. 

(JPA - Hibernate ORM의 관계와 비슷하다. Jakarta Bean Validation - Hibernate Validation)

>> 이들을 묶어서 Spring Boot에서 적용하게 해주는 것이 이 의존성

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

3. @Valid / @Validated 써보기

1) @Valid

- 사용자가 입력하는 데이터 중 검증하고 싶은 데이터에 @검증사항 애너테이션을 붙이고, 그 데이터의 입력을 검증해야 하는 곳에 @Valid를 사용한다.

// UserDto
@Data
public class UserDto {
    private Long id;
    @NotNull(message = "username is required")
    private String username;

    private String email;
}

// UserController
    @PostMapping("/validate-dto")
    public String validateDto(@Valid @RequestBody UserDto dto) {
        log.info(dto.toString());
        return "done";
    }

 

Jakarta Bean Validation specification

 

Jakarta Bean Validation specification

BeanNode, PropertyNode and ContainerElementNode host getContainerClass() and getTypeArgumentIndex(). If the node represents an element that is contained in a container such as Optional, List or Map, the former returns the declared type of the container and

beanvalidation.org

간단 정리

더보기
@AssertTrue boolean이 true인가?
@AssertFalse boolean이 false인가?
@Min(숫자) 지정 값 이상인가?
@Max(숫자) 지정 값 이하인가? 
@DecimalMin(value=실수) 지정된 값(실수) 이상인가?
@DecimalMax(value=실수) 지정된 값(실수) 이하인가?
@Negative 음수만 가능
@Positive 양수만 가능
@NegativeOrZero 음수와 0만 가능
@PositiveOrZero 양수와 0만 가능
@Digits(integer=정수, fraction=소수자리수) 대상 수가 지정된 정수와 소수 자리수보다 작은가?
@Pattern(regex=정규식) 정규식을 만족하는가
@Past 현재보다 과거인가?
@Future 현재보다 미래인가?
@NotNull 데이터가 null이 아님을 검증. 대부분의 null이 할당될 수 있는 타입에 사용.
- " " 공백 문자열을 넣어도 가능.
@NotEmpty 데이터가 비어있지 않음을 검증. String, List, 배열 등 여러 데이터를 가지고 있는 자료형에서 사용 가능. 
- String : 길이가 0이 아님 / " " 공백 문자열을 넣어도 가능. 
- List 등 Collection : 아이템이 하나 이상 존재하는지 검증.
@NotBlank 문자열에 대해 공백이 아님을 검증. 
- 공백 기준 : 공백문자, 탭, 개행문자 등 공백으로 나타나는 문자들을 제외한 문자열의 길이를 의미. 
- " " 공백 문자열 불가능. "" 이것도 불가능.
@Null null만 가능
@Email 이메일 형식만 가능
@Size(min=최소, max=최대) 문자열, 배열 등의 크기가 만족하는지

2) @Validated

- 메서드 파라미터 개별 검증

: 어떤 메서드의 파라미터를 개별적으로 검증해야 하는 상황에서 사용 가능. 

- 꼭 Controller가 아니고, Service에도 첨부하여 메서드 인자를 검증할 수 있다. 

@Validated
@RestController
public class UserController {
    @GetMapping("/validate-params")
    public Map<String, String> validateParams(
            @RequestParam("age")
            @Min(19)
            Integer age
    ) {
        // ...
    }
}

 

- 요청 객체 부분 검증

  • 회원가입 단계 : username, password만 검증
  • 추가정보 기입 단계 : email, phone만 검증

1) 그룹으로 묶기 위해 interface 정의

2) 각 속성의 제약사항의 groups 인자로 전달한다. 

3) 검증 단계에서는 @Valid 대신 @Validated 사용 >> 이 때 어떤 그룹의 속성들을 검증할 것인지 인자로 전달!

// 그룹 interface
public interface MandatoryStep { }

// UserPartialDto class
@Data
public class UserPartialDto {
    //회원가입 단계에서 반드시 첨부해야하는 데이터
    // 단 회원정보 업데이트 단계에서는 반드시는 아님.
    @Size(min = 8, groups = MandatoryStep.class, 
            message = "회원 이름은 8자 이상 입력해주세요.")
    private String username;
    @Size(min = 10, groups = MandatoryStep.class, 
            message = "비밀번호는 10자 이상 입력해주세요.")
    private String password;

    // 회원가입 완료 후 추가 정보 기입 단계에서 첨부하는 데이터
    // 단 추가정보 기입시에는 반드시 포함해야 한다.
    @NotNull
    @Email
    private String email;
    @NotNull
    private String phone;
}

// UserController class
// 필수 정보 유효성 검사
    @PostMapping("/validate-man")
    public ResponseEntity<Map<String, String>> validateMan(
            @Validated(MandatoryStep.class)
            @RequestBody
            UserPartialDto dto
    ) {
        log.info(dto.toString());
        Map<String, String> responseBody = new HashMap<>();
        responseBody.put("message", "success!");
        return ResponseEntity.ok(responseBody);
    }

1) 유효성 검사 통과시 :: message가 표시된다. 

2) 유효성 검사 미통과시 :: username과 password에 대한 유효성 검증 실패 이유만 나온다. email, phone은 유효성에 맞지 않아도 상관없다.  

 

- 검증 실패시 응답을 원하는 형태로 정의하기 : ExceptionHandler 사용하여 예외처리 해주기

유효성 검증에 실패하면 >> MethodArgumentNotValidException이 발생한다!! > 이에 대해 ExceptionHandler 정의하면 된다.

+ 사용자 오류의 응답 코드 400 설정.

1) 유효성 검사 미통과시 :: 자동 오류 메시지

2) 유효성 검사 미통과시 :: 개발자 설정 오류 메시지가 보여진다.

// UserController class
	@ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationException(
            final MethodArgumentNotValidException exception
    ) {
        Map<String, Object> errors = new HashMap<>();
        List<FieldError> fieldErrorList = exception.getBindingResult().getFieldErrors(); // 필드 유효성 검사중 발생한 오류 목록이 저장된다.
        for (FieldError error : fieldErrorList) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return errors;
    }

 

4. 사용자 지정 유효성 검사

: 상황에 따라 부족한 경우 어노테이션을 직접 만들고, 그 어노테이션이 적용된 필드를 검사하는 방법.

1) 어노테이션 만들기

- Java 파일에 class 대신 @interface를 붙여준다.

public @interface EmailWhiteList {}

- @Target(ElementType.xxx) 어노테이션 추가 :: 어디에 덧붙일 수 있는지를 정의하는 용도. 

@Target(ElementType.FIELD)
public @interface EmailWhiteList {}

 

- @Retention(RetentionPolicy.xxx) 어노테이션 추가 :: 어노테이션이 어느 시점까지 유지될지를 정의하는 용도. > 유효성 검사는 서비스 실행 중에도 확인가능해야 하기 때문에 > RUNTIME으로 설정.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EmailWhiteList {}

 

2) Annotation Element 정의하기

- message

- groups

- payload

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EmailWhiteList {
    String message() default "Email not in whitelist";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

3) ConstraintValidator

: 어노테이션이 붙은 필드가 조건을 만족하는지 검증하는 클래스를 만든다.

- @EmailWhiteList가 붙은 String 데이터에 대해서 동작하는 검증기

- + ConstraintValidator를 상속받았으므로 isValid() 메서드를 구현한다. => 이 때 어떤 검증과정이 있는지를 구현.

public class EmailWhiteListValidator
        implements ConstraintValidator<EmailWhiteList, String> {

    private final Set<String> whiteList;

    public EmailWhiteListValidator() {
        this.whiteList = new HashSet<>();
        this.whiteList.add("gmail.com");
        this.whiteList.add("naver.com");
    }
    
    // EmailWhiteList 어노테이션이 붙은 대상의 데이터가
    // 검사를 통과하면 true, 실패하면 false 반환하도록
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // value = 사용자가 실제로 입력한 내용이다.

        // value가 null인지 체크, value에 @ 포함 체크
        if (value == null || !value.contains("@")) {
            return false;
        }
        // value를 @기준으로 자른 뒤 EmailWhiteList에 담긴 값 중 하나인지 확인
        String[] split = value.split("@");
        if (! this.whiteList.contains(split[split.length-1])) {
            return false;
        }
        return true;
    }
}

 

- 그리고 나서 이 ConstraintValidator를 어떤 어노테이션에서 검증을 할지를 표시해주어야 한다.

>> @Constraint(validatedBy= xx)로 정의.

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailWhiteListValidator.class)
public @interface EmailWhiteList {
    String message() default "Email not in whitelist";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

이렇게 하고 실제 Dto에 어노테이션을 붙여주어야 한다.

@Data
public class UserDto {
    private Long id;
    @NotBlank(message = "username is required")
    private String username;

    @EmailWhiteList
    private String email;
    private int age;
    @Future(message = "time") //현재 이후의 시간만 가능.
    private LocalDate validUntil;
}

 

결과로 잘 나왔다~~

 

** BlackList도 만들어보았다! 이번에는 생성자에서 블랙리스트를 지정해주는 것이 아닌, 실제 사용시에 설정해주는 방법.

 

더보기

- EmailBlackList @interface

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailBlackListValidator.class)
public @interface EmailBlackList {
    String message() default "Email in BlackList";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    // 어노테이션 사용시 blacklist()를 설정해줄 수 있다.
    String[] blacklist() default {};
}

 

- EmailBlackListValidator : 검증기 만들어주기

public class EmailBlackListValidator
        implements ConstraintValidator<EmailBlackList, String> {

    Set<String> blacklist;

	// initialize시 받아오는 것을 가져와서 blacklist에 넣기.
    @Override
    public void initialize(EmailBlackList annotation) {
        this.blacklist = new HashSet<>();
        this.blacklist.addAll(Arrays.asList(annotation.blacklist()));
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // value = 사용자가 실제로 입력한 내용이다.
        // value가 null인지 체크, value에 @ 포함 체크
        if (value == null || !value.contains("@")) {
            return false;
        }
        // value를 @기준으로 자른 뒤 EmailWhiteList에 담긴 값 중 하나인지 확인
        String[] split = value.split("@");
        if (this.blacklist.contains(split[split.length - 1])) {
            return false;
        }
        return true;
    }
}

 

- UserDto 에서 어노테이션 달아주기.

@Data
public class UserDto {
    private Long id;
    @NotBlank(message = "username is required")
    private String username;

    @EmailBlackList(blacklist = {"hanmail.net", "yahoo.com"})
    @EmailWhiteList
    private String email;
    
    private int age;
    @Future(message = "time") //현재 이후의 시간만 가능.
    private LocalDate validUntil;
}

 

결과도 잘 나왔다~~~~ 예

 

728x90

'Programming > Spring, SpringBoot' 카테고리의 다른 글

Spring Security... 그리고 인터페이스  (0) 2024.01.25
Testing  (1) 2024.01.24
File Handling (정적파일과 Multipart/form-data)  (0) 2024.01.22
Query Parameter  (0) 2024.01.22
Spring Boot & REST  (0) 2024.01.18

BELATED ARTICLES

more