RestApi에서 Validator 사용하기 (RestApi에서 에러 메시지 소스 사용)

Validator로 필드 검증하기

필드를 검증하는 것은 아래 포스팅에서도 게시했다.

그런데, RestApi에서 Validator는 사용하기가 매우 까다로웠다. 직접 Validator를 구현하기 보다는 다른 방법을 쓰는 것 같은데, 우선 현재 진도 내용상에서 구현해보고 싶었다.

기본적으로 form에서 오는 데이터를 검증하는 방법은 다시 폼 view를 보여주면 되는데, restApi는 response를 주는 방식이기 때문에, 방법이 달랐다. 그리고, 직접 js로 오류시 오류메시지를 띄워주는 로직도 직접 구현해야 했다. 타임리프가 얼마나 많은 일을 해주는지 다시금 깨달았다!

RestApi에서 Validator

검증이 필요한 곳은, 도서를 등록할때, 도서의 글자수가 적절한지, 그리고 중복된 도서는 아닌지였다. 중복된 도서 여부는 isbn코드가 중복되었는지로 체크했다.

Controller

Error을 받아서, Json으로 넘기기 위해 Map을 만들어서 넘기는 작업이 필요한데, 로직이 꽤나 복잡했다. 별도의 빈으로 분리했다.

...

/* validation hadle을 위한 빈 분리 */
private final ValidationUtils validationUtils;

...

@ResponseBody
@PostMapping("/books/add")
public ResponseEntity<JsonResponse> addBook(HttpSession session, @Validated @RequestBody NewBookDto book) {

    if (adminUtils.isDefault(session)) {
        return new ResponseEntity<>(ErrorResponse.builder()
                .code("roleError")
                .message("권한이 없습니다.")
                .build(), HttpStatus.FORBIDDEN);
    }

    BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(book, "book");

    log.debug("bindingResult.objectName={}", bindingResult.getObjectName());
    log.debug("bindingResult.target={}", bindingResult.getTarget());

    log.debug("book={}", book);
    log.debug("book.bookName={}", book.getBookName());
    log.debug("book.isbn={}", book.getIsbn());

    bookAddValidator.validate(book, bindingResult);

    if (bindingResult.hasErrors()) {

        log.debug("errors={}", bindingResult);
        return validationUtils.handleValidationErrors(bindingResult);
    }

    adminService.addBook(book);

    return new ResponseEntity<>(JsonResponse.builder()
            .message("정상 등록되었습니다.")
            .build(), HttpStatus.OK);
}

ValidationUtils

RestApi에서 메시지 소스를 사용하려면, 복잡한 과정이 필요하다.

@Component
@RequiredArgsConstructor
public class ValidationUtils {

    /* 메시지 소스 빈 DI */
    private final MessageSource messageSource;

    public ResponseEntity<JsonResponse> handleValidationErrors(BindingResult bindingResult) {
        Map<String, String> errors = new HashMap<>();
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        List<ObjectError> globalErrors = bindingResult.getGlobalErrors();
        log.debug("Locale.getDefault()={}", Locale.getDefault());

        /* 글로벌 에러 담기 */
        for (ObjectError globalError : globalErrors) {
            String errorMessage = messageSource.getMessage(
                    globalError.getCode(), //에러 코드 가지고 온다. 글로벌 에러는 별도의 code resolve 과정이 없다.
                    globalError.getArguments(), //argument 가지고 옴
                    Locale.getDefault()); //언어 정보는 기본 정보를 가져온다.
            errors.put(globalError.getCode(), errorMessage);
        }

        for (FieldError fieldError : fieldErrors) {
            String errorMessage = messageSource.getMessage(
                    fieldError.getCodes()[0], //에러 코드 가지고 온다. MessageCodesResolver에 의해서 가장 상세한 코드가 가장 앞으로 온다.
                    fieldError.getArguments(),
                    Locale.getDefault());
            errors.put(fieldError.getField(), errorMessage);
        }

        return new ResponseEntity<>(ErrorResponse.builder()
                .code("validation")
                .message("validation 실패")
                .errors(errors).build()
                , HttpStatus.BAD_REQUEST);
    }
}

Validator

각 필드는 상수로 지정해주었다.

@Component
@RequiredArgsConstructor
public class BookAddValidator implements Validator {

    private final BookService bookService;
    private static final String REQUIRED_FIELD = "required";
    private static final String MIN_FIELD = "min";
    private static final String DUPLICATED_FIELD = "duplicated";
    private static final int MIN_BOOKNAME_LENGTH = 5;
    private static final int MIN_ISBN_LENGTH = 5;

    ...

    @Override
    public void validate(Object target, Errors errors) {
        NewBookDto book = (NewBookDto) target;

        String bookName = book.getBookName();
        String isbn = book.getIsbn();

        boolean isBookNameEmptyOrBlank = !StringUtils.hasText(bookName);
        boolean isIsbnEmptyOrBlank = !StringUtils.hasText(isbn);

        boolean isBookNameTooShort = bookName.length() < MIN_BOOKNAME_LENGTH;
        boolean isIsbnTooShort = isbn.length() < MIN_ISBN_LENGTH;

        if (isBookNameEmptyOrBlank || isIsbnEmptyOrBlank || isBookNameTooShort || isIsbnTooShort) {
            if (isBookNameEmptyOrBlank) {
                errors.rejectValue("bookName", REQUIRED_FIELD, null);
            } else {
                if (isBookNameTooShort) {
                    errors.rejectValue("bookName", MIN_FIELD, new Object[]{MIN_BOOKNAME_LENGTH}, null);
                }
            }

            if (isIsbnEmptyOrBlank) {
                errors.rejectValue("isbn", REQUIRED_FIELD, null);
            } else if (isIsbnTooShort) {
                errors.rejectValue("isbn", MIN_FIELD, new Object[]{MIN_ISBN_LENGTH}, null);
            }
        }

        if (isDuplicated(isbn)) {
            errors.rejectValue("isbn", DUPLICATED_FIELD, null);
        }

    }

    private boolean isDuplicated(String isbn) {
        return bookService.findBookByIsbn(isbn) != null;
    }
}

JS 코드

fetch 함수 사용 시 중복 코드 함수화에서, Text를 받는 fetch 중복 코드를 함수했는데, 이번 기회에 모두 Json을 받아서 처리하도록 수정했다.

validation인 경우의 처리를 해서 오류를 보이고, 메시지를 넣어주도록 처리해주었다.

const resetErrorFields = () => {
    const errorFieldErrors = document.querySelectorAll(".field-error");
    errorFieldErrors?.forEach((el) => {
        el.style.display = "none";
    });
}

const handleValidationError = (error, errorContainer) => {
    const errors = error.response.errors;

    resetErrorFields();

    // errors 객체에 해당하는 클래스만 표시
    Object.keys(errors).forEach((field) => {
        const fieldErrorElement = document.querySelector(`#${errorContainer} .field-error.${field}`);
        if (fieldErrorElement) {
            fieldErrorElement.style.display = "block";
            fieldErrorElement.textContent = errors[field]; // 에러 메시지 추가
        }
    });
}

// 공통 fetch 요청 함수
export const fetchRequest = async (url, method, body = null) => {
    resetErrorFields();

    const options = {
        method: method,
        headers: {
            "Content-Type": "application/json",
        },
    };

    if (body) {
        options.body = JSON.stringify(body);
    }

    const response = await fetch(url, options);

    if (!response.ok) {
        const responseJson = await response.json();
        const error = new Error(`HTTP Error! status: ${response.status}, message: ${responseJson}`);
        error.status = response.status;
        error.response = responseJson;
        throw error;
    }

    return await response.json();
};

// 공통 에러 처리 함수
export const handleError = (error, errorContainer) => {
    if (error.status === 403) {
        location.href = "/access-denied"
    } else if (error.status == 400) {
        if (error.response.code == "validation") {
            handleValidationError(error, errorContainer);
        } else {
            alert(error.response.message);
        }
    } else {
        console.log(error.message)
    }
};

댓글

개발자  김철준

백엔드 개발자 김철준의 블로그입니다.

주요 프로젝트