같은 트랜잭션 안에서 insert 주의하기

온라인 도서관 프로젝트에서, 현재로서는 책 커버이미지(BookCover)는 book당 1개밖에 갖지 못한다.

그런데, test에서 실패하는데 그 이유를 보니 findByBookId로 찾은 요소가 db에서 1개 이상의 요소를 찾았다고 한다.

@Test
void modify() {
    //given
    MultipartFile multipartFile = new MockMultipartFile("file", "test.jpg", "image/jpeg", "test data".getBytes());
    NewBookForm newBookForm = new NewBookForm("test", "testAuthor", "12345", multipartFile);
    Book savedBook = bookService.save(newBookForm);

    //when
    ModifyBookForm modifyBookForm = new ModifyBookForm(savedBook.getBookId(), "modify", "modifyAuthor", "modify12345", multipartFile);
    bookService.modify(modifyBookForm);

    //then
    Book findBook = bookService.findBookById(savedBook.getBookId()).get();
    assertThat(findBook.getBookName()).isEqualTo("modify");

    //cleanUp
    bookService.deleteBook(savedBook.getBookId()); // 문제 발생!
}

같은 트랜잭션 안에서 삭제와 저장을 동시에

문제는 책을 수정한 다음 바로 삭제할 때 일어났다. 테스트 코드에서 cleanUp을 위해서 책을 저장 후, 수정 후, 저장한 책을 삭제하는 로직이었다. @Transcational이 걸려있지만, 로컬에 파일을 저장하는 로직이 있기 때문에 직접 cleanUp 코드를 작성해두었었다.

BookService에서 modifyBook 메서드를 보자.

public void modify(ModifyBookForm form) {
    findBookById(form.getId()).ifPresentOrElse(book -> {

        if (isDuplicated(book.getIsbn(), form.getIsbn())) {
            throw new DuplicateIsbnException();
        }

        book.modify(form);
        modifyBookCover(form, book);

    }, () -> {
        throw new NotFoundBookException();
    });
}

서비스 계층은 전체적으로 @Transactional이 걸려 있고, 테스트 코드 역시 @Transaction으로 묶여 있다. 한 작업을 시작할 때 트랜잭션이 걸려 있어서, remove 후에 save를 하는 방식의 저장은 문제가 될 수 있다.

제대로 삭제되기 전에, 새로운 BookCover를 저장하는 것이다.

그러면 다음번에 deleteBook을 할 때 역시 같은 트랜잭션 안이므로, (테스트 코드) 아직 BookCover는 삭제가 되지 않고, 새로운 BookCover만 저장된 상태이다. 이때, deleteBook 내부에서 호출하는 remobeBookCover가 BookCoverRepository.findByBookId를 호출하면서 두 개의 BookCover를 찾아서 문제가 발생한다.

public void deleteBook(Long bookId) {
    Book removedBook = bookRepository.findById(bookId)
            .orElseThrow(NotFoundBookException::new);
    removeBookCover(removedBook);
    bookRepository.remove(bookId);
}

private void removeBookCover(Book book) {
    BookCover bookCover = bookCoverRepository.findByBookId(book.getBookId())
            .orElseThrow(NotFoundBookCoverException::new);
    uploadFileRepository.remove(bookCover.getUploadFileId());
}

private void saveBookCover(Book book, MultipartFile multipartFile) {
    UploadFile image = uploadFileRepository.save(multipartFile);
    BookCover newBookCover = new BookCover(book.getBookId(), image.getUploadFileId());
    bookCoverRepository.save(newBookCover);
}

private void modifyBookCover(ModifyBookForm form, Book book) {
    if (form.getCoverImage() != null) {
        removeBookCover(book);
        saveBookCover(book, form.getCoverImage());
    }
}

삭제 – 저장이 아닌 수정으로

방법은, 삭제 후 새로 저장이 아닌 파일은 실제로 삭제하고, 새 파일의 id만 BookCover에 수정해주는 방식이다. 이렇게 하면, 한 트랜잭션 안에서도 문제 없이 수정이 가능하다.

private void modifyBookCover(ModifyBookForm form, Book book) {
    if (form.getCoverImage() != null) {
        // 1. 새 이미지를 저장한다
        // 2. 기존 커버 객체를 가져온다
        // 3. 기존 커버 객체에서 uploadFileId를 변수에 임시 저장해둔다
        // 4. 기존 커버 객체의 uploadFileId를 새 이미지의 uploadFileId로 변경한다
        // 5. 기존 이미지를 삭제한다.
        UploadFile updateImage = uploadFileService.save(form.getCoverImage());
        bookCoverRepository.findById(book.getBookId())
                .ifPresent(bookCover -> {
                    Long oldUploadFileId = bookCover.getUploadFileId();
                    bookCover.setUploadFileId(updateImage.getUploadFileId());
                    uploadFileService.remove(oldUploadFileId);
                });
    }
}

댓글

개발자  김철준

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

주요 프로젝트