FireDrago

[bobzip] 다중 MultipartFile 이미지를 포함한 엔티티 수정하기 본문

프로젝트

[bobzip] 다중 MultipartFile 이미지를 포함한 엔티티 수정하기

화이용 2024. 6. 24. 11:26

문제상황

레시피 수정할때, 여러 이미지가 포함된 엔티티를 어떻게 수정해야 할까?

 

레시피 등록 폼

먼저 레시피 수정 폼을 보고, 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.ALLorphanRemoval=true 설정을 통해, Recipe 엔티티와의 연관 관계가 끊어진 RecipeIngredientRecipeStep 엔티티는 자동으로 삭제된다. 이로 인해 Recipe 엔티티를 수정하면

기존의 RecipeIngredientRecipeStep 엔티티는 연관 관계가 끊어져 자동으로 삭제되고,

새로운 엔티티들이 그 자리를 대체하게 된다.

 

레시피를 수정할 때는 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;
    }
}

 

RecipeStepUploadFile 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. 수정시에도, 단계별 이미지가 반드시 존재한다는것을 전제로 작동한다.

    사용자가 단계별 이미지를 입력하지 않는 경우에도 잘 작동할 수 있도록 완성후에 수정해보자