본문 바로가기
프로젝트/Recipository

[Dev] 22.12.21. 게시글 수정 1 (feat. 게시글 작성 cascade)

by 규글 2022. 12. 21.

게시글 수정

 국비 과정에서의 프로젝트에서 무언가를 수정할 때는 logic이 간단했다. 단순히 client의 update form에 data를 setting 하도록 뿌리고 그 form의 내용을 수정한 후 submit 하면, controller에서 Dto로 data를 받아서 update query에 각 data를 setting 해주는 방식이었다. 게다가 게시글이 가질 수 있는 카테고리와 같은 항목은 여러 개를 가질 수 있도록 했었지만, 그 저장 방식을 쉼표로 연결된 String 을 저장하는 것이기도 해서 그리 복잡한 구현이라고 생각하지 않았다.

 

 이번에는 조금 다르게 Spring JPA 를 활용하고 있어서 따로 query문을 작성하고 있지 않고, data를 다루기 위한 Entity 객체 중에 게시글은 Recipe entity와 게시글 하위의 Link Entity는 1:N 연관 관계(Link와 Recipe가 N:1)로, 서로 양방향 관계를 맺도록 수정했었다. 그러다보니 게시글의 다른 내용들은 Recipe로 수정하고 Link는 따로 수정해야하는 상황이라고 생각했는데, Link를 제외한 다른 항목은 단지 repository의 save를 활용하면 update query로 수정이 가능하지만 Link는 아니었다. 다음의 세 가지 경우로 나눌 수 있었다.(개수만 그렇고 내용은 수정되었을 수도 있다.)

  • Link를 추가하는 경우
    : Link를 새로 추가해 준 뒤에 save 하면 수정된 사항은 update, 새로운 사항은 insert query가 동작할 것이다.
  • Link가 그대로인 경우
    : Link의 내용만을 수정해준 뒤에 save 하면 수정된 사항에 대해 update query가 동작할 것이다.
  • Link를 삭제하는 경우
    : Link 중에 수정할 것은 수정하고, 줄어든 Link에 대해서는 delete query를 먼저 수행한 뒤에 save 를 하면 남아 있는 사항에 대해 update query가 동작할 것이다.

 위와 같은 생각으로 다음의 update test를 진행했다.

 

Update test

    // recipe update test
    @Test
    @Transactional
    @Rollback(false)
    public void test6(){
        Long contentId = 7L;

        Recipe recipe = recipeRepository.getRecipeByContentId(contentId);

        RecipeDto recipeDto = recipe.toDto();
        System.out.println(recipeDto);

        List<LinkDto> linkDtoList = new ArrayList<>();
        recipe.getLink().forEach(tmp -> {
            linkDtoList.add(tmp.toDto());
        });
        System.out.println(linkDtoList);

        RecipeDto newRecipeDto = new RecipeDto();
        List<String> newLinkList = new ArrayList<>();
        newLinkList.add("ccc");
//        newLinkList.add("bbb");
        newRecipeDto.setLink(newLinkList);

        var dbLength = linkDtoList.size();
        var newLength = newLinkList.size();

        // link 수가 줄어든 경우
        if(dbLength > newLength){
            if(newLength == 0){
                linkRepository.deleteAll(recipe.getLink());
            } else {
                for(int i = dbLength - 1; i >= 0; i--){
                    if(i > newLength -1){
                        linkRepository.deleteById(linkDtoList.get(i).getId());
                        linkDtoList.remove(i);
                    } else {
                        linkDtoList.get(i).setLink(newLinkList.get(i));
                    }
                }
            }
        } else {
            for(int i = 0; i < newLength; i++){
                // link 수가 늘어난 경우
                if(newLength > dbLength && i > dbLength - 1){
                    linkDtoList.add(new LinkDto());
                }
                linkDtoList.get(i).setLink(newLinkList.get(i));
                // link 수가 그대로인 경우
                if(i == dbLength - 1 && dbLength == newLength){
                    break;
                }
            }
        }

        linkDtoList.forEach(tmp -> {
            if(newLength == 0){
                return;
            }
            Link newLink = tmp.toEntity(recipe);
            linkRepository.save(newLink);
        });
    }

 Client의 update form으로부터 새롭게 전달받은 Link의 수와 기존 DB의 Link 수를 비교해서 분기하고, 또 Link의 수가 늘어난 경우와 그대로인 경우를 분기했다. 중간에 delete query를 수행하도록 하는 등, 뭔가 상당히 번거로운 과정이라고 할 수 있다.

 

기존 작업에 대한 고찰 (Cascade)

RecipeServiceImpl.java

 다른 부분도 있지만 가장 중요한 부분은 이곳이다. 서로 연관 관계가 있는 Entity에 대해 따로 save했던 이곳. 무엇이 문제인 것일까?

 

Recipe.java / Link.java

  필자가 생각한 최소한의 내용만을 듣고 작업하려니 Entity 객체와 JPA 관련해서 계속해서 문제에 부딪히는 것 같다. 정확히는 계획한 대로 하지 않고 새로운 방식을 계속해서 시도해보려고 하니 문제게 부딪히고 있다.

  • 처음에는 CascadeType을 All로 했었는데[각주:1], Comment data를 수정하려고 하니 Recipe를 update 하려는 것을 확인[각주:2]하고 REMOVE로 바꾸었다.
  • CascadeType을 REMOVE로 하니, Link를 삭제하는 test 중에 Recipe가 삭제되는 상황을 마주했다.

 

 문제는 Cascade, 그리고 그것이 위치한 자리였다. Cascade란 어떤 target entity에 취한 action이 수행될 때, 같은 action을 연관된 entity에도 적용하는 것을 말한다.[각주:3] 즉, 필자의 경우 Recipe entity를 생성하면서 연관된  Link entity를 생성하거나, 반대로 삭제하거나 하는 등의 작업을 하고자 한다면 cascade 옵션을 Recipe에 작성해주어야 했던 것이다.

 

Recipe.java / Link.java / Comment.java

    @OneToMany(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Link> link;
    
    @OneToMany(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    @OrderBy("groupId asc, commentId asc")
    private List<Comment> commentList;
    
(Recipe)
------------------------------------------------------------------------------------------
(Link)

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "content_id")
    private Recipe recipe;
    
----------------------------------------
(Comment)

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "target_id")
    private Recipe recipe;

 

 위와 같이 cascade의 위치를 Link와 Comment에서 Recipe로 옮겨주었고, 게시글을 작성할 때 Link를 따로 save 하지 않고 field를 setting 하고 Recipe Entity 만을 save 하는 것으로 Link 까지 save 되는 것을 확인했다.

 

 

작업

 게시글의 제목이나 내용, 공개 여부와 첨부한 image를 저장한 path는 바로 Recipe Entity를 수정해서 save 하는 것으로 쉽게 가능하다. 하지만 작성한 참조 링크에 대한 것은 Link Entity를 통해 변경해야하므로, 조금 더 과정을 거치게 된다. 우선 가장 쉽게 처리할 수 있는 것은 client로부터 게시글 update form 으로부터 data가 넘어왔을 때, 넘겨받은 게시글 id를 foreign key로 하는 모든 Link data를 지우고 새롭게 data를 save하는 방식이다. 이때 기존 Link data의 수를 m이라 하고 새로운 data의 수를 n이라고 했을 때, 수행되는 총 query문은 첫 select를 제외하고 delete * m + insert *n 으로 m+n 개이다. 이 방법은 작성할 service logic도 단순하여  조금은 끌리는 방식이지만, 극단적으로 100개에서 1개를 추가한다고 했을 때 100개를 삭제하고 101개를 추가하는 방식은 뭔가 낭비인 기분이 들었다.

 

 실제로 data를 지우는 것이라면 delete query가 수행되는 것이 맞겠지만, 그것이 아닌 경우를 생각해봤다. 단순히 n개를 추가하는 경우에도 첫 select를 제외하면 n개, 기존의 내용을 삭제하고 수정하는 것이어도 최대 m개의 query 문이 동작하게 된다. 어차피 컴퓨터가 하게 될 일이지만 조금 덜 번거롭고, query 문을 덜 사용하게 되면 조금 더 빠를 것이라고 생각했다. (이 부분을 생각만 실제로 테스트 해보지 않고 시작했으나 방금 작성하면서 궁금해서 테스트 했고, 결과론적으로 말하면 큰 차이가 없다. 데이터 수가 많지 않아서 그런 것 같은데, 유일한 차이라고 한다면 id 값이 매번 갱신되다보니까 숫자가 빠르게 상승한다는 점 뿐이다.) 어떤 방향성이 옳은, 혹은 좋은 것인지는 미궁 속으로... 그래도 필자는 후자의 방식이 좋다.

 

 그래도 작업했던 내용이니 둘 다 기록해보고자 한다.

 

Link data 처리

  • DB에서 전달받은 content id에 대한 기존의 Recipe data를 불러온다.
  • Recipe 와 연관 관계를 맺은 List<Link> data를 불러온다.
  • 새로운 List와 기존 List의 size를 비교한다.
    • 새로운 List가 작으면 나머지 것들을 삭제한 후, 남은 기존 data를 update 해준다.
    • 기존 List와 같으면 기존 data를 update 해준다.
    • 새로운 List가 크면 새 것을 추가해준 후, 기존 data를 update 하고 save 해준다.

 방식은 처음 update test를 할 때와 동일하다.

 

content.html

            <div th:if="${recipe.writer} == ${#authentication.name}">
                <a th:href="@{/user/content/delete/{contentId}(contentId = ${recipe.contentId})}"
                   id="contentDeleteBtn" class="btn btn-light float-end">삭제</a>
                <a th:href="@{/user/content/updateform/{contentId}(contentId = ${recipe.contentId})}"
                   class="btn btn-light float-end">수정</a>
            </div>

 게시글에 해당하는 content.html 에서는 해당 게시글의 작성자와 Authentication의 name이 일치하는 경우에만 게시글 수정, 삭제 버튼을 위치하도록 했다. 수정 버튼을 누르면 게시글 수정 form page로 이동한다.

 

PageController.java

    @GetMapping("/user/content/updateform/{contentId}")
    public ModelAndView updateForm(@PathVariable Long contentId){

        ModelAndView mView = new ModelAndView();

        mView.addObject("recipe", recipeService.getRecipeOnly(contentId));
        mView.setViewName("pages/content_updateform");

        return mView;
    }

 게시글 수정 form page로의 이동시, 작성했던 data를 미리 한 번 뿌려주기 위해서 Recipe data만을 가져올 수 있도록 service logic을 간단히 만들어서 활용했다.

 

Recipe.java

    @OneToMany(mappedBy = "recipe", fetch = FetchType.LAZY,
            cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Link> link;

 조금 달라진 점이 있다면 Recipe Entity의 List<Link> type field의 @OneToMany annotation에서 orphanRemoval 속성을 true로 주었다는 것이다. 이 내용은 '고아 제거' 라고 표현하는데, 이또한 듣고 있던 강의의 목록에 있던 내용이다. 당장 필요해서 검색을 통해 알아본 정도로는 해당 field에서 제거된 Link Entity를 delete query로 삭제하는 옵션이다. 이를 작성하는 것으로 service logic에서 repository의 delete method를 따로 사용할 필요가 없어졌다.

 

RecipeServiceImpl.java

    // 게시글 수정
    @Override
    public boolean update(Long contentId, RecipeDto recipeDto, MultipartFile imageFile) {
        try {
            // 기존 DB의 data에 대해
            Recipe recipe = recipeRepository.getRecipeByContentId(contentId);
            // data 변경 가능하도록 Dto로 바꿔서
            RecipeDto dbRecipeDto = recipe.toDto();

            // 게시글 update form 에서 변경 가능한
            // 제목, 내용, 게시글 공개 여부
            dbRecipeDto.setTitle(recipeDto.getTitle());
            dbRecipeDto.setContent(recipeDto.getContent());
            dbRecipeDto.setBePublic(recipeDto.isBePublic());

            // 넘겨받은 file이 있다면 그것까지 포함하고
            if(imageFile != null){
                String savePath = this.savePath + File.separator;
                
                // 넘겨받은 file name
                String originFileName = imageFile.getOriginalFilename();
                // save file name에 사용할 UUID String
                String uuid = UUID.randomUUID().toString();
                // save file name
                String saveFileName = uuid + originFileName;

                // directory에 upload file save
                imageFile.transferTo(new File(savePath + saveFileName));
                // RecipeDto에 image save path setting
                dbRecipeDto.setImagePath(savePath + saveFileName);
            }

            // 기존 DB의 Link data 와 새로 변경할 Link data에 대해
            List<Link> linkList = recipe.getLink();
            List<String> newLinkList = recipeDto.getLink();

            int dbLength = linkList.size();
            int newLength = newLinkList.size();

            if(dbLength > newLength){
                linkList.subList(newLength, dbLength).clear();
                for(int i = 0; i < newLength; i++){
                    LinkDto tmpLinkDto = linkList.get(i).toDto();
                    tmpLinkDto.setLink(newLinkList.get(i));
                    linkList.set(i, tmpLinkDto.toEntity(recipe));
                }
            } else {
                for(int i = 0; i < newLength; i++){
                    // link 수가 늘어난 경우
                    if(newLength > dbLength && i > dbLength - 1){
                        linkList.add(Link.builder()
                                .recipe(recipe)
                                .build());
                    }
                    LinkDto tmpLinkDto = linkList.get(i).toDto();
                    tmpLinkDto.setLink(newLinkList.get(i));
                    linkList.set(i, tmpLinkDto.toEntity(recipe));
                    // link 수가 그대로인 경우
                    if(i == dbLength - 1 && dbLength == newLength){
                        break;
                    }
                }
            }

            // 다른 data를 수정한 Recipe Entity에 새로운 Link data를 넣고 save
            Recipe updateRecipe = dbRecipeDto.toEntity();
            updateRecipe.updateLinkList(linkList);
            recipeRepository.save(updateRecipe);

            return true;
        } catch (Exception e) {
            return false;
        }
    }

 Recipe Entity에 setter method를 만들고 싶지 않아서 DB에서 data를 받아온 Recipe Entity를 RecipeDto로 변환하여 게시글 중에 참고 링크를 제외한 나머지 수정 가능한 제목, 내용, 공개여부, 이미지 저장 경로를 setting 하는 부분이다. Recipe Entity의 값을 직접 바꾸는 방식을 취한다면 훨씬 쉬워지지만, 여러 블로그를 살펴보면서 Entity에는 setter method가 지양된다는 말을 더러 보았기 때문에 최대한 사용하지 않는 방식으로 작업해왔다.

 

 하지만 참고 링크에 해당하는 Link를 수정하는 과정이 조금은 답답하다는 생각이 들었다. Recipe Entity와 Recipe Dto를 변환하는 과정에서의 가장 큰 문제는 Entity에서의 Link data는 List<Link> 이고, Dto에서의 Link data는 List<String> 이라는 점이다. Dto는 client로부터 넘어오는 link라는 name의 input를 받기 위해 List<String>으로 받은 것이지만, DB에 이들을 저장하기 위해서는 List<Link>로 변환해야 한다. 이것이 왜 문제가 되냐면 client로부터 전달받은 참조 링크의 정보를 가지고 변환하여 save 하더라도 Link의 id가 없어서 기존 data를 수정하는 것이 아니게 되기 때문이다.

 결국 data를 수정하기에 앞서서 수행되어야 하는 것은 기존 data를 DB로부터 불러오는 것이고, 그 data를 수정하여 다시 save 하는 방식을 선택하게 되었다. Recipe Entity에서 Dto로의 반대 변환에 대해서도 문제가 생기는데, Link Entity의 data 중에서 String type의 link data 만이 남아 save를 위해 다시 Recipe Entity로 변환하면 기존의 Link id 가 누락된다.

 

 그래서 취한 방법이 DB로 부터 가져온 Recipe Entity로부터 List<Link> 를 가져온 뒤, 참조 링크 수의 변동에 따라 List<Link>의 각 항목을 LinkDto로 변환하여 그 link data를 수정한 뒤 다시 Link Entity로 변환한 것으로 바꿔주는 것이었다. 그리고 이렇게 수정된 List<Link>를 다른 data를 모두 수정한 RecipeDto로부터 새롭게 변환된 Recipe Entity에 담아 save 하도록 했다.

 

 좀 길어졌는데 직접적인 Entity의 수정을 최소화하면서 기존 data를 수정하는 과정에서의 핵심은 다음과 같다.

  • Recipe Entity로부터 Recipe Dto의 변환 시에는 Link의 primary key인 id와 foreign key인 content id가 누락된다.
  • Recipe Dto에 수정된 data를 setting 하고 다시 Recipe Entity로 변환하더라도 여전히 Link의 id는 누락된 상태이다.
  • 따라서 새로운 List<Link>를 Recipe Entity에 넣어주지 않는다면 Link data는 수정할 수 없다.

 

 물론 이 상태로도 원하는 동작이 이루어지기는 한다. 하지만 Entity의 setter 사용을 절대 금하는 것이 아니라 지양하는 것이라면, 원하는 목적에 맞는 method를 통해서 Entity의 data를 바꾸는 것은 괜찮지 않을까? 이미 필자는 RecipeDto로부터 Entity로의 변환 시에 Recipe Entity에 있는 List<Link>의 각 foreign key 항목에 그 Recipe Entity를 할당해주는 method를 이미 활용하고 있다. 꼭 필요한 경우라면 작성한 내용을 조금은 수정할 수 있을 것 같다.

 

 이어지는 작업은 게시글을 삭제하는 기능이지만, 그 전에 게시글 수정을 위해 Entity의 몇 가지 field를 update 하는 method를 만들어서 활용하는 작업을 잠시 하고 넘어가는 것으로 하겠다.

 

번외 : 모두 삭제하고 새로 만들기

            // 기존 DB의 Link data 와 새로 변경할 Link data에 대해
            List<Link> linkList = recipe.getLink();

            int dbLength = linkList.size();

            // 기존의 Link를 모두 지우고
            linkList.subList(0, dbLength).clear();
            Recipe newRecipe = recipeDto.toEntity();

            // 다른 data를 수정한 Recipe Entity에 새로운 Link data를 넣고 save
            Recipe updateRecipe = dbRecipeDto.toEntity();
            updateRecipe.updateLinkList(newRecipe.getLink());
            updateRecipe.setRecipeAtLink();
            
            recipeRepository.save(updateRecipe);

 바로 위 이미지 세 개를 이것 하나로 바꿀 수 있긴 하다. 하지만 테스트는 테스트일 뿐, 아무리 변동되는 data의 수가 적다고 하더라도 이렇게 기존 data를 모두 지우고 새롭게 추가해주는 방식은 영 꺼림칙하다. 별로 사용하고 싶지 않는 방식이지만 테스트를 해보긴 했으니 일단 작성은 해둔다. 실제로 이렇게 작업해놓는 경우가 있을까 싶긴 하다.

 

Reference

댓글