FireDrago
[bobzip] 다중 MultipartFile 이미지를 포함한 엔티티 수정하기 본문
문제상황
레시피 수정할때, 여러 이미지가 포함된 엔티티를 어떻게 수정해야 할까?

먼저 레시피 수정 폼을 보고, JPA 레시피 엔티티가 어떻게 구성되어있는지 살펴보자
1. 레시피 기본정보 - 요리명, 요리소개, 요리 이미지
2. 레시피 재료정보(재료 추가 가능) - 재료명, 수량, 단위
3. 레시피 조리정보(단계 추가 가능) - 조리단계별 설명, 조리단계별 이미지
이를 JPA 엔티티로 표현하면 다음과 같다.
// 레시피 기본정보와 재료, 조리과정을 양방향 연관관계로 가지고 있다.
@Entity
@Getter
public class Recipe extends BaseEntity {
@Id
@GeneratedValue
@Column(name = "recipe_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@NotNull
@Size(max = 50)
private String title;
@Column(length = 100)
private String instruction;
@Embedded
private UploadFile thumbnail;
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecipeIngredient> recipeIngredients = new ArrayList<>();
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecipeStep> recipeSteps = new ArrayList<>();
}
@Entity
@Getter
public class RecipeIngredient {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id")
private Recipe recipe;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ingredient_id")
private Ingredient ingredient;
@Column(name = "quantity")
private int quantity;
@Column(length = 10)
private String unit;
}
@Entity
@Getter
public class RecipeStep {
@Id
@GeneratedValue
@Column(name = "recipe_step_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id")
private Recipe recipe;
@Column(name = "stepNumber")
private int stepNumber;
@Column(name = "thumbnail")
@Embedded
private UploadFile thumbnail;
@Column(name = "instruction")
private String instruction;
}
<JPA 엔티티 수정 원리>
엔티티 수정에서 변경 감지 기능은 영속성 컨텍스트 내에서 엔티티의 상태 변화를 추적하고,
이를 바탕으로 필요한 데이터베이스 작업을 자동으로 수행한다.
cascade=CascadeType.ALL과 orphanRemoval=true 설정을 통해, Recipe 엔티티와의 연관 관계가 끊어진 RecipeIngredient와 RecipeStep 엔티티는 자동으로 삭제된다. 이로 인해 Recipe 엔티티를 수정하면
기존의 RecipeIngredient 와 RecipeStep 엔티티는 연관 관계가 끊어져 자동으로 삭제되고,
새로운 엔티티들이 그 자리를 대체하게 된다.
레시피를 수정할 때는 Recipe 엔티티에서 RecipeIngredient와 RecipeStep을 새로 교체해주기만 하면 된다.
<근데 이미지는 어떻게 수정, 보존하지?>
레시피 수정하려면, 어떻게 해야할지 알았다. 그런데 새로 교체하는 것이 어려운 엔티티가 있다. 수정하려면,
Recipe 엔티티의 기본 정보 변경하고, 새로운 RecipeIngredient , RecipeStep 엔티티 생성해서 교체해주면 된다.
RecipeIngredient 는 새로 생성하는 것이 어렵지 않다. 그런데 RecipeStep 생성에는 몇가지 문제가 있다.
1. MultipartFile => UploadFile 변경된 필드를 어떻게 수정폼에 전달하지?

@Component
public class FileStore {
@Value("${recipeThumbnail.dir}")
private String recipeThumbnailDir;
@Value("${stepThumbnail.dir}")
private String stepThumbnailDir;
public UploadFile addThumbnail(MultipartFile thumbnail) throws IOException {
return addFile(thumbnail, recipeThumbnailDir);
}
public List<UploadFile> addStepThumbnails(List<MultipartFile> stepThumbnail) throws IOException {
ArrayList<UploadFile> uploadFiles = new ArrayList<>();
for (MultipartFile multipartFile : stepThumbnail) {
uploadFiles.add(addFile(multipartFile, stepThumbnailDir));
}
return uploadFiles;
}
private UploadFile addFile(MultipartFile thumbnail, String fileDir) throws IOException{
if (thumbnail.isEmpty()) {
return null;
}
String originalFilename = thumbnail.getOriginalFilename();
String storeFilename = createStoreFilename(originalFilename);
thumbnail.transferTo(new File(getStorePath(fileDir, storeFilename)));
return new UploadFile(originalFilename, storeFilename);
}
private String createStoreFilename(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString().substring(0,8);
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int index = originalFilename.lastIndexOf(".");
return originalFilename.substring(index + 1);
}
private String getStorePath(String fileDir, String storeFilename) {
return fileDir + storeFilename;
}
}
@Data
@Embeddable
public class UploadFile {
private String uploadFileName;
private String storedFileName;
public UploadFile() {
uploadFileName = "default.jpg";
storedFileName = "default.jpg";
}
public UploadFile(String uploadFileName, String storedFileName) {
this.uploadFileName = uploadFileName;
this.storedFileName = storedFileName;
}
}
RecipeStep 의 UploadFile thumbnail 은 이미지 파일의 원래 파일명과 저장된 파일명 두개의 문자열 필드를 가진다.
그런데 수정을 하기위해서는 <input> 태그에 기존의 값을 전달해야 한다.
그런데 파일을 첨부하는 <input[type='file']> 태그에 전달할수 있는 값이 없다.
우리가 알고있는건, 저장된 이미지의 파일명과 경로뿐이기 때문이다.
이미 업로드된 파일을 수정할 때 기존 파일을 표시하고
새 파일을 업로드할 수 있는 옵션을 제공하는 방법을 고려해야 한다.
<img> 태그를 통해 기존 업로드 된 이미지 파일을 표시하고 (src 속성)
새로운 파일을 업로드 한 경우 <input[type='hidden']> 태그를 추가하여
변경된 파일의 정보를 전달한다.
새로운 파일이 업로드 된 경우 <input type='hidden' name="${recipeStep.id}"> 태그가 추가되도록 하면,
서버에서 변경된 이미지 파일이 어떤 파일인지 구분할 수 있게 된다. 보존해야할 파일과 수정할 파일을 구분할 수 있다.
이 방법을 찾는데, 생각보다 오랜 시간이 걸렸다. 그리고 왜 프론트엔드 개발자들이 필요한지 뼈저리게 느꼈다...
사용자가 원하는 이미지의 수정을 시각화해주고,
동시에 백엔드 개발자에게 정보를 원할하게 전달하는 역할이 필요하다는 것을 느꼈다.
이제 백엔드 서버에서 전달받은 레시피 데이터를 수정해보자
문제해결
기존의 파일은 <img> 태그를 통해 표시하고, 파일 변경이 있을경우, <input[type='hidden'>태그를 통해 변경된 파일 정보를 서버에 전달하자
/** 자바스크립트 코드 완성되면 추가할것! **/
@Getter
@Setter
public class RecipeEditForm {
@NotEmpty(message = "요리명을 입력해주세요")
private String title;
@NotEmpty(message = "요리 설명을 입력해주세요")
private String instruction;
private MultipartFile thumbnail;
private boolean changedRecipeThumbnail;
private List<@NotEmpty(message = "재료명을 입력해주세요")String> ingredientNames = new ArrayList<>();
private List<@NotNull(message = "재료수량을 입력해주세요")Integer> quantities = new ArrayList<>();
private List<@NotNull(message = "단위를 입력해주세요")String> units = new ArrayList<>();
private List<@NotEmpty(message = "조리법을 입력해주세요")String> stepInstructions = new ArrayList<>();
private List<MultipartFile> stepThumbnails = new ArrayList<>();
private List<Integer> changedStepThumbnail = new ArrayList<>();
}
private boolean changedRecipeThumbnail:
private List<Integer> changedStepThumbnail = new ArrayList<>()
두 필드는 <input> hidden 을 통해 전달받는 값이다.
레시피 이미지, 레시피 조리단계 이미지가 변경될 경우,
이 필드를 통해 서버는 변경여부를 알 수 있게 된다. (오늘의 핵심)
※ List 미리 초기화
참고로 DTO나 엔티티에서 List<T> 타입의 필드가 있는경우,
= new ArrayList<>(); 미리 초기화 해주는 것이 필요하다는 것을 배웠다.
초기화 되지 않은 필드가 가져올 문제를 예방한다. (NullPointException 등)
@Transactional
public void updateRecipe(Long id, RecipeEditForm recipeEditForm, List<Ingredient> ingredients) throws IOException {
Recipe recipe = recipeRepository.findById(id).get();
updateRecipeBasicInfo(recipeEditForm, recipe);
updateRecipeIngredients(recipeEditForm, ingredients, recipe);
updateRecipeSteps(recipeEditForm, recipe);
}
private void updateRecipeBasicInfo(RecipeEditForm recipeEditForm, Recipe recipe) throws IOException {
recipe.updateTitle(recipeEditForm.getTitle()); // 제목 변경
recipe.updateInstruction(recipeEditForm.getInstruction()); // 설명 변경
// 이미지 변경이 있을경우, 이미지 변경
if (recipeEditForm.isChangedRecipeThumbnail()) {
UploadFile newRecipeThumbnail = fileStore.updateRecipeThumbnail(recipe.getThumbnail(), recipeEditForm.getThumbnail());
recipe.updateRecipeThumbnail(newRecipeThumbnail);
}
}
private static void updateRecipeIngredients(RecipeEditForm recipeEditForm, List<Ingredient> ingredients, Recipe recipe) {
List<RecipeIngredient> recipeIngredient = RecipeIngredient.createRecipeIngredient(
ingredients,
recipeEditForm.getQuantities(),
recipeEditForm.getUnits());
recipe.updateRecipeIngredient(recipeIngredient);
}
private void updateRecipeSteps(RecipeEditForm recipeEditForm, Recipe recipe) throws IOException {
List<String> stepInstructions = recipeEditForm.getStepInstructions();
List<Integer> changedStepThumbnail = recipeEditForm.getChangedStepThumbnail();
List<MultipartFile> stepThumbnails = recipeEditForm.getStepThumbnails();
List<UploadFile> thumbnails = new ArrayList<>(recipe.getRecipeSteps().stream()
.map(RecipeStep::getThumbnail)
.toList());
// 조리단계 이미지 변경
for (int i = 0; i < changedStepThumbnail.size(); i++) {
// 바꿀 이미지의 인덱스 번호 가져오기
int indexToChange = changedStepThumbnail.get(i) - 1;
// 기존 이미지 변경과 이미지 추가된 경우를 다르게 처리
if (indexToChange < thumbnails.size()) {
MultipartFile multipartFile = stepThumbnails.get(i);
UploadFile newThumbnail = fileStore.updateStepThumbnail(thumbnails.get(i), multipartFile);
thumbnails.set(indexToChange, newThumbnail);
} else {
thumbnails.add(fileStore.addThumbnail(stepThumbnails.get(i)));
}
}
List<RecipeStep> recipeSteps = RecipeStep.createRecipeSteps(thumbnails, stepInstructions);
recipe.updateRecipeSteps(recipeSteps);
}
@Component
public class FileStore {
@Value("${recipeThumbnail.dir}")
private String recipeThumbnailDir;
@Value("${stepThumbnail.dir}")
private String stepThumbnailDir;
public UploadFile addThumbnail(MultipartFile thumbnail) throws IOException {
return addFile(thumbnail, recipeThumbnailDir);
}
public List<UploadFile> addStepThumbnails(List<MultipartFile> stepThumbnail) throws IOException {
ArrayList<UploadFile> uploadFiles = new ArrayList<>();
for (MultipartFile multipartFile : stepThumbnail) {
uploadFiles.add(addFile(multipartFile, stepThumbnailDir));
}
return uploadFiles;
}
public UploadFile updateStepThumbnail(UploadFile uploadFile, MultipartFile multipartFile) throws IOException {
deleteFile(stepThumbnailDir, uploadFile.getStoredFileName());
return addFile(multipartFile, stepThumbnailDir);
}
public UploadFile updateRecipeThumbnail(UploadFile uploadFile, MultipartFile multipartFile) throws IOException {
deleteFile(recipeThumbnailDir, uploadFile.getStoredFileName());
return addFile(multipartFile, recipeThumbnailDir);
}
private UploadFile addFile(MultipartFile thumbnail, String fileDir) throws IOException{
if (thumbnail.isEmpty()) {
return null;
}
String originalFilename = thumbnail.getOriginalFilename();
String storeFilename = createStoreFilename(originalFilename);
thumbnail.transferTo(new File(getStorePath(fileDir, storeFilename)));
return new UploadFile(originalFilename, storeFilename);
}
private String createStoreFilename(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString().substring(0,8);
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int index = originalFilename.lastIndexOf(".");
return originalFilename.substring(index + 1);
}
private String getStorePath(String fileDir, String storeFilename) {
return fileDir + storeFilename;
}
private void deleteFile(String fileDir, String storeFilename) {
File file = new File(getStorePath(fileDir, storeFilename));
if (file.exists()) {
file.delete();
}
}
}
서비스 객체와 FileUpload 객체에 update를 위한 로직들이 추가되어 레시피 수정이 잘 완료되었다.
향후 보완할 점
1. 레시피 등록시 단계별 이미지가 필수로 입력되어야 한다.
2. 수정시에도, 단계별 이미지가 반드시 존재한다는것을 전제로 작동한다.
사용자가 단계별 이미지를 입력하지 않는 경우에도 잘 작동할 수 있도록 완성후에 수정해보자
'프로젝트' 카테고리의 다른 글
| [bobzip] Ajax 비동기 요청을 통한 댓글 작성 (0) | 2024.07.03 |
|---|---|
| [bobzip] AJAX 비동기 요청을 통한 댓글 조회 (0) | 2024.07.02 |
| [bobzip] JPA 엔티티의 equals 구현 (동등성) (0) | 2024.06.15 |
| [bobzip] 로컬에 저장된 이미지 표시하기 (ResourceHandler) (0) | 2024.06.14 |
| [bobzip] 사용자정의 Validation으로 중복검사 하기 (0) | 2024.06.11 |