RestApi로 파일 전송하기

온라인 도서관에 파일 전송 기능을 추가하려고 하고 있는데, 도서 등록 기능은 현재 RestApi로 하고 있었다.

form 태그를 사용하면, 단순히 enctype=”multipart/form-data” attr을 추가해주면 되는데, restApi는 어떻게 추가할 수 있을까? 바로 FormData 객체를 사용하면, Content-Type이 자동으로 multipart/form-data로 들어간다.

addBook 함수

기존 addBook 함수를 수정했다. FormData 객체를 만들고, 객체 안에 값을 append 해주는 방식으로 추가한다. 그리고 공통으로 사용하는 fetchRequest 함수를 사용한다.#

const addBook = async errorContainer => {
    const bookName = document.getElementById("addBookName").value;
    const isbn = document.getElementById("addBookIsbn").value;
    const coverImage = document.getElementById("addBookCoverImage").files[0]; // 파일 선택
    const body = new FormData();
    body.append("bookName", bookName);
    body.append("isbn", isbn);
    body.append("coverImage", coverImage);

    try {
        const data = await fetchRequest(`/books/add`, "POST", body);
        if (data.redirected) {
            console.log("페이지가 리다이렉트되었습니다.");
            return;
        }
        alert("결과 : " + data.message);
        location.reload();
    } catch (e) {
        handleError(e, errorContainer);
    }
};

FetchRequest 함수

FormData 객체의 특징은, stringfy로 직렬화를 해주면 안 된다는 것이다. 당연하게도, json 데이터가 아니여서 그렇다.

fetchRequest 함수는 공통으로 사용해주어야 하기 때문에, instanceof 연산으로 분기처리를 해줬다.

export const fetchRequest = async (url, method, body = null) => {
    resetErrorFields();

    const options = {
        method: method,
    };

    // body가 FormData일 경우, JSON.stringify를 사용하지 않도록 처리
    if (body) {
        if (body instanceof FormData) {
            options.body = body; // FormData는 그대로 사용
        } else {
            options.body = JSON.stringify(body); // 그 외에는 JSON으로 변환
            options.headers = {
                'Content-Type': 'application/json', // JSON 형식인 경우 Content-Type 설정
            };
        }
    }

    const response = await fetch(url, options);

    // 페이지가 리다이렉트된 경우 페이지 이동
    const contentType = response.headers.get("Content-Type");
    if (contentType && contentType.startsWith("text/html")) {
        location.href = response.url;
        return { redirected: true }; // 리다이렉트를 명시적으로 반환
    }

    // 리디렉션이 아닌 다른 응답 처리
    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();
};

BeanValidation을 사용할 수 없다.

RestApi로 파일 전송을 할 때의 단점은, BeanValidation을 사용해서 간단히 하던 비어있는 필드, size 체크가 불가하다. @RequestBody NewForm form 으로 객체화해서 받아올 수 없기 때문인데, RequestParam으로 각각 받아온 다음, validator에서 처리했다.

기본적으로 null이 들어올 수 있게 해준 다음, validator에서 체크한다.

controller

public ResponseEntity<JsonResponse> addBook(@RequestParam(required = false) String bookName,
                                            @RequestParam(required = false) String isbn,
                                            @RequestParam(required = false) MultipartFile coverImage) {

    NewBookForm book = new NewBookForm(bookName, isbn, coverImage);
    BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(book, "newBookForm");
    log.debug("bindingResult.target={}", bindingResult.getTarget());
    log.debug("bindingResult.objectName={}", bindingResult.getObjectName());

    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 errorResponseUtils.handleValidationErrors(bindingResult);
    }

    adminService.addBook(book);

    return ResponseEntity.ok().body(JsonResponse.builder()
            .message("정상 등록되었습니다.")
            .build());
}

Validator

public class BookAddValidator implements Validator {

    private final BookService bookService;
    private static final String DUPLICATED_FIELD = "duplicated";
    private static final String MIN_FIELD = "min";
    private static final String NOT_BLANK_FIELD = "NotBlank";
    private static final String NOT_NULL_FIELD = "NotNull";
    private static final int BOOK_NAME_MIN_SIZE = 5;
    private static final int ISBN_MIN_SIZE = 5;

    @Override
    public boolean supports(Class<?> clazz) {
        return NewBookForm.class.isAssignableFrom(clazz);
    }

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


        String bookName = book.getBookName();
        String isbn = book.getIsbn();
        MultipartFile coverImage = book.getCoverImage();

        boolean isBookNameEmpty = !StringUtils.hasText(bookName);
        boolean isIsbnEmpty = !StringUtils.hasText(isbn);
        boolean isCoverImageEmpty = coverImage == null;
        boolean isBookNameTooShort = bookName.length() < BOOK_NAME_MIN_SIZE;
        boolean isIsbnTooShort = isbn.length() < ISBN_MIN_SIZE;

        if (isBookNameEmpty || isIsbnEmpty || isCoverImageEmpty ||
        isBookNameTooShort || isIsbnTooShort
        ) {
            if (isBookNameEmpty) {
                errors.rejectValue("bookName", NOT_BLANK_FIELD);
            } else if (isBookNameTooShort) {
                errors.rejectValue("bookName", MIN_FIELD, new Object[]{BOOK_NAME_MIN_SIZE}, null);
            }
            if (isIsbnEmpty) {
                errors.rejectValue("isbn", NOT_BLANK_FIELD);
            } else if (isIsbnTooShort) {
                errors.rejectValue("isbn", MIN_FIELD, new Object[]{ISBN_MIN_SIZE}, null);
            }
            if (isCoverImageEmpty) {
                errors.rejectValue("coverImage", NOT_NULL_FIELD);
            }
            return;
        }

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

    }

    private boolean isDuplicated(String isbn) {
        return bookService.findBookByIsbn(isbn).isPresent();
    }
}

댓글

개발자  김철준

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

주요 프로젝트