DS's TechBlog
[Spring Data JPA] 이미지 파일과 DB 간의 일관성 유지하기 with SQL Order Based on Foreign Key Constraints - 1 본문
[Spring Data JPA] 이미지 파일과 DB 간의 일관성 유지하기 with SQL Order Based on Foreign Key Constraints - 1
dsjo 2024. 12. 27. 23:22이미지를 파일 시스템에 저장하고 관련 정보를 DB에 저장할 때, 이미지 파일과 DB 간의 일관성을 유지하는 방법에 대해서 알아보겠습니다. 1편은 외래키 제약조건에 따른 SQL 순서 최적화를 중심으로 작성하였습니다.
배경
프로젝트의 요구사항은 아래와 같습니다.
- 게시판의 대표 이미지는 파일 시스템에 저장하고, 파일의 정보는 DB에 저장합니다.
- 대표 이미지 업데이트 시에 기존의 이미지 파일은 삭제되어야 합니다.
이러한 요구사항에서는 이미지 파일과 DB 간의 일관성이 중요하다는 생각을 했습니다.
board_image 테이블이 존재하지 않는 파일의 정보를 담고 있으면 안 되기 때문입니다.
기존 이미지 업데이트 방식의 문제점
@Override
@Transactional
public void updateBoardImage(Long activityId, Long boardId, MultipartFile boardImageFile) {
Board board = boardService.getBoardWithImageAndUser(activityId, boardId);
// 생성자와 로그인한 유저가 같은지 검증
User loggedInUser = securityService.getLoggedInUser();
User creator = board.getUser();
boardService.validateAuthorityOfBoardManagement(loggedInUser, creator);
// 새 이미지를 파일 시스템에 저장
String originalFile = boardImageFile.getOriginalFilename();
String saveFile = UUID.randomUUID() + originalFile.substring(originalFile.lastIndexOf('.'));
ImageUtil.uploadImage(boardImageFile, uploadPath, saveFile);
// 기존의 이미지 정보를 조회
BoardImage removeBoardImage = board.getBoardImage();
String removeFile = removeBoardImage.getSaveFile();
// 새 이미지 정보 생성
BoardImage boardImage = BoardImage.builder()
.saveFile(saveFile)
.originalFile(originalFile)
.board(board)
.build();
// orphanRemoval=true 로 인해서 기존 이미지인 removeBoardImage 자동 삭제가 되고, 새로운 이미지를 저장
board.setBoardImage(boardImage);
// 저장되어 있던 이미지 파일 삭제
ImageUtil.removeImage(uploadPath, removeFile);
}
새 이미지 파일 저장 -> DB에 관련 데이터 저장 -> 기존 이미지 파일 삭제 순으로 구현을 하면서, 다음과 같은 이유로 파일 시스템과 DB 간의 일관성을 유지할 수 있다고 생각했습니다.
- 새 이미지 파일 저장 -> DB에 관련 데이터 저장: 존재하는 파일에 대해서만 DB가 정보를 가지고 있게 됩니다.
- DB에 관련 데이터 저장 -> 기존 이미지 파일 삭제: 만약, 기존 이미지 파일을 삭제하고 DB에 관련 데이터를 저장한다면, Rollback 상황에서 DB가 존재하지 않는 파일의 정보를 가리키는 문제가 발생할 수 있습니다.
하지만, 위의 코드는 다음과 같은 board_image 테이블의 외래키인 board_id가 중복되었다는 Duplicate Key 에러가 발생했습니다.
문제의 원인을 Board와 BoardImage 클래스에서 찾을 수 있었습니다.
@Entity
public class Board {
...
@OneToOne(mappedBy = "board", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) // FetchType.Lazy를 설정해도, 연관관계의 주인이 BoardImage 이므로 적용 x
private BoardImage boardImage;
...
}
@Entity
public class BoardImage {
...
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", nullable = false, unique = true)
private Board board;
...
}
Board와 BoardImage는 OneToOne 관계 + unique 제약조건이 설정되어 있어서, 하나의 board를 여러 board_image가 참조할 수 없어서 발생한 에러였습니다.
하지만, orhpanRemoval = true 설정으로 인해서 board.setBoardImage(boardImage) 구문이 실행될 때, 기존 board_image가 delete 된 후, 새로운 board_image가 insert 되기를 기대했기 때문에 이해할 수 없었습니다.
여기서, delete가 먼저 실행되지 않고 insert가 먼저 실행되어 Duplicate key 에러가 발생한 원인에 대해서 의문이 생겨 알아보았습니다.
이와 관련해, hibernate의 공식문서에서 원인을 찾을 수 있었습니다.
@Transactional로 묶어놓은 DB 작업은 단일 트랜잭션으로 처리되는데, SQL 구문이 바로 실행되지 않고 메서드가 끝나면 hibernate가 모아놓은 SQL을 최적화하여 실행합니다. 이때, 외래키와 관련된 SQL 구문은 위와 같이 Insert 이후 Delete를 실행하도록 해서 DB의 일관성을 지키도록 설계되어있었습니다. 그래서, 제가 기대한 순서와 다르게 SQL 구문이 실행된 것이었습니다.
해결 방법 - 1
@Override
@Transactional
public void updateBoardImage(Long activityId, Long boardId, MultipartFile boardImageFile) {
Board board = boardService.getBoardWithImageAndUser(activityId, boardId);
User loggedInUser = securityService.getLoggedInUser();
User creator = board.getUser();
boardService.validateAuthorityOfBoardManagement(loggedInUser, creator);
String originalFile = boardImageFile.getOriginalFilename();
String saveFile = UUID.randomUUID() + originalFile.substring(originalFile.lastIndexOf('.'));
ImageUtil.uploadImage(boardImageFile, uploadPath, saveFile);
BoardImage removeBoardImage = board.getBoardImage();
String removeFile = removeBoardImage.getSaveFile();
board.setBoardImage(null); // boardImage와의 관계를 끊기
em.flush(); // orphanRemoval=true DELETE 강제 실행
BoardImage boardImage = BoardImage.builder()
.saveFile(saveFile)
.originalFile(originalFile)
.board(board)
.build();
board.setBoardImage(boardImage);
ImageUtil.removeImage(uploadPath, removeFile);
}
하지만, Board와 BoardImage는 OneToOne 관계 이므로 Delete 이후 Insert가 발생해야 했습니다. @Transactional 안에서 직접 순서를 조정하기 위해서 EntityManager 의 flush() 메서드를 사용하였습니다. flush() 는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영할 때 사용합니다.
위와 같이 코드를 변경 후에, 기대했던 순서로 쿼리가 실행되는 것을 볼 수 있습니다.
하지만, Spring Data JPA와 JPA의 EntityManager를 같이 사용하는 것은 코드의 복잡성을 높일 수 있었습니다.
해결 방법 - 2
@Override
@Transactional
public void updateBoardImage(Long activityId, Long boardId, MultipartFile boardImageFile) {
Board board = boardService.getBoardWithImageAndUser(activityId, boardId);
User loggedInUser = securityService.getLoggedInUser();
User creator = board.getUser();
boardService.validateAuthorityOfBoardManagement(loggedInUser, creator);
String originalFile = boardImageFile.getOriginalFilename();
String saveFile = UUID.randomUUID() + originalFile.substring(originalFile.lastIndexOf('.'));
ImageUtil.uploadImage(boardImageFile, uploadPath, saveFile);
BoardImage boardImage = board.getBoardImage();
String removeFile = boardImage.getSaveFile();
// 이미지 테이블 수정
boardImage.setOriginalFile(originalFile);
boardImage.setSaveFile(saveFile);
ImageUtil.removeImage(uploadPath, removeFile);
}
연결된 BoardImage의 필드값을 setter로 변경하면서, 이미지 테이블에 Update 쿼리가 발생할 수 있도록 수정하였습니다. Board와 BoardImage 연결의 변경 없이 단순히 속성만 변경하면 되므로 Update 쿼리만 실행되면 됩니다.
이미지 파일이 삭제되고 저장된다는 포인트에서 DB에서도 Delete와 Insert가 발생해야 한다고 착각해서 단순한 문제를 어렵게 생각했습니다.
아직 남은 문제
위의 코드는 에러 없이 실행되지만, 아직 문제가 남았습니다.
- ImageUtil.uploadImage(...) 이후에 예외가 발생한다면?
- @Transactional 키워드로 인해서 DB는 Rollback 됩니다. 하지만, 이미지 파일은 Transaction과 관계가 없으므로 그대로 저장된 상태를 유지하게 됩니다. 이로 인해 불필요한 이미지가 파일 시스템에 남는 문제가 생길 수 있습니다. - 메서드가 끝나고 Transaction commit 단계에서 에러가 발생한다면?
- ImageUtil.removeImage(...)에 의해 이미지가 파일 시스템에서 삭제됩니다. 그 후, 메서드가 끝나게 되면서 트랜잭션 커밋이 실행되는데, 이때 문제가 발생하면 DB는 Rollback 되지만, 지워진 이미지는 복구할 수 없기에 일관성의 문제가 발생할 수 있습니다.
이미지 파일과 DB 간의 일관성을 확실하게 유지하기 위해서, 이러한 문제를 추가적으로 해결해야 합니다.
이 문제를 해결한 방법은 다음 포스팅에서 더 소개하겠습니다. 읽어주셔서 감사합니다.
'Java & Spring' 카테고리의 다른 글
[Spring Data JPA] 이미지 파일과 DB 간의 일관성 유지하기 With @TransactionalEventListener - 2 (0) | 2025.01.05 |
---|---|
[Spring] AWS S3 + Spring Boot 3.2 설정 및 적용하기 (1) | 2024.06.09 |
[Spring Security] authorizeHttpRequests 우선순위 적용하기 (3) | 2024.05.17 |
[Spring] Swagger default 200 response 삭제하기 (0) | 2024.05.03 |
[Spring] Swagger 공통 응답 코드 처리 및 Enum으로 정의한 응답 코드 사용하기 (0) | 2024.05.03 |