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

[Dev] 23.02.03. 회원 탈퇴 기능 (with. modify delete query)

by 규글 2023. 2. 3.

 회원 탈퇴 기능을 마지막으로 기획했던 마이 페이지에서의 모든 기능은 구현이 완료된다. 하지만 기능에서 동작하는 query 에 대한 고민이 계속되는 중에 여러 게시글을 일괄적으로 삭제하는 기능에 대해 동작하는 query 에서도 다른 query가 동작했으면 하는 바람이 있었다. 회원 탈퇴 기능으로 인해 동작하는 query 또한 사실 거기에 사용자의 정보를 하나 더한 delete query가 동작하는 것만이 다르다. 따라서 회원 탈퇴 기능을 구현하면서 delete query가 다르게 동작하도록 수정하는 것이 이번 작업의 목표이다.

 

작업

회원 탈퇴 기능 구현

my-page.js

    document.querySelector("#exitBtn").addEventListener("click", function(e){
        var exit = confirm("관련된 모든 정보가 삭제됩니다. 정말 회원 탈퇴를 진행하시겠습니까? ");

        if(exit){
            var url = "/user/exit";

            var token = document.querySelector("meta[name=_csrf]").content;
            var header = document.querySelector("meta[name=_csrf_header]").content;

            var promise = fetch(url, {
                method: "DELETE",
                headers: {
                    "header": header,
                    "X-Requested-With": "XMLHttpRequest",
                    "X-CSRF-Token": token
                }
            });

            promise.then(function(response){
                return response.json();
            }).then(function(data){
                if(data.beDelete){
                    window.location = "/";
                    alert("회원 탈퇴가 완료되었습니다. 그동안 이용해주셔서 감사합니다.");
                    window.location = "/";
                } else {
                    alert("회원 탈퇴가 정상적으로 이루어지지 않았습니다. 문제가 반복된다면 문의 바랍니다.");
                }
            });
        }
    });

 회원 탈퇴 버튼을 눌렀을 때 동작에 대한 javascript 이다. Server로 request를 넘기고 그 response를 받아 경우에 따라 다르게 동작하도록 작성했다.

 

UserController.java

    // 회원 탈퇴
    @DeleteMapping("/user/exit")
    public ResponseEntity<Object> deleteUser(@AuthenticationPrincipal SpUser spUser){

        Member member = spUser.toMember();

        Map<String, Object> map = new HashMap<>();

        boolean beDeleted = userService.exit(member);
        // 성공 시 Authentication 을 authenticated false로 하여 로그아웃 처리
        if(beDeleted){
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            authentication.setAuthenticated(false);
        }
        map.put("beDeleted", beDeleted);

        return ResponseEntity.ok().body(map);
    }

 Client 로부터의 회원 탈퇴 request에 대한 처리를 하는 controller 의 method 이다. 현재 로그인한 사용자의 정보를 service logic으로 넘기고, 그 결과를 client 로 response 하도록 작성했다. 탈퇴 logic이 성공했을 시에는 response 하기 전에 Authentication을 false로 변경하여 로그아웃 처리를 하도록 했다.

 

UserServiceImpl.java

    @Transactional
    @Override
    public boolean exit(Member member) {
        try {
            userRepository.delete(member);

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

 회원 탈퇴에 대한 service logic이다. 단순히 사용자의 정보를 삭제하도록 작성했다.

 

 하지만 문제는 삭제되지 않는다는 점이다. 정확히는 사용자와 연관 관계가 있는 게시글이나 댓글 data가 존재하고 있으므로, 사용자의 정보를 먼저 삭제할 수 없다는 1451 error code 로 query 가 동작하다가 말고 오류를 발생시키는 것이었다. 당연히 삭제되지 않는다. 왜냐하면 logic의 Member 객체는 Authentication Principal 로부터 변환된 것으로, Recipe List와 Comment List data를 가지고 있지 않기 때문이다.

 

 그래서 이미지와 같이 member 에 대한 data를 온전히 가져와서 삭제 요청을 하게 된다면 어떻게 될까? 아주 잘 삭제된다. 다만 이전 게시글에서도 고민했던 query가 또 걸린다. 사용자의 권한에 대한 delete query, 다른 게시글에 작성한 댓글 각각에 대한 delete query, 작성한 게시글의 댓글 각각에 대한 delete query, 게시글에 포함된 link 각각에 대한 delete query, 게시글 각각에 대한 delete query, 마지막 최종적으로 사용자에 대한 delete query 가 동작한다.

 

 사실 이 형태의 삭제 과정이 가지는 문제는 다른 게시글에 작성한 댓글 정보도 없애버린다는 것이다. 물론 처음 계획은 그러했으나, 실제로 댓글을 삭제하는 logic에서 결정한 것은 삭제되었음을 표기하는 것으로 방식을 변경했었다.[각주:1] 그러면 댓글 data를 삭제하기에 앞서서 다른 게시글에 작성한 댓글의 작성자를 바꿔야 그것이 삭제되지 않을 수 있다.

 

    // 회원 탈퇴 service logic
    @Transactional
    @Override
    public boolean exit(Member member) {
        try {
            // 온전한 Member (Recipe List, Comment List 보유)
            member = userRepository.getMemberByEmail(member.getEmail());
            // table에 임의로 만들어 둔 0번 사용자의 정보. 탈퇴한 회원임을 name으로 한다.
            Member deleteMember = userRepository.getReferenceById(0L);

            // 게시글의 작성자를 변경
            commentRepository.updateAllByMember(member, deleteMember);

            // 사용자의 정보 삭제 (작성한 게시글과 댓글 삭제)
            userRepository.delete(member);

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

 그래서 필자가 생각한 방법은 0번 사용자를 추가하여 탈퇴할 사용자가 작성한 댓글의 작성자 정보를 0번 사용자로 변경하는 것이다. 이렇게 변경한 뒤에 사용자의 정보를 삭제하게 되면 사용자의 정보와 사용자가 작성한 게시글 및 게시글 하위에 있는 모든 댓글들이 삭제되지만, 사용자가 작성한 타 게시글의 댓글은 탈퇴한 회원임을 명시함과 동시에 삭제되었다는 메시지를 출력할 수 있게 된다.

 

 이렇게 사용자를 변경하지 않으면 사용자 정보에 대한 삭제 요청이 있을 때 함께 요청되기 때문에 처음에 모든 항목이 삭제된 것이다. 이는 게시글이나 댓글이 작성자 정보를 사용자의 id 값이 들어갈 수 있도록 양방향 연관 관계를 형성해주었기 때문이다.[각주:2] 또한 사용자를 변경하지 않는다면 사용자와 관련된 data가 다른 table에 남아있기 때문에 최종적으로 사용자의 정보를 삭제하는 것은 foreign key로 인해 불가능하다. 실질적으로 댓글을 삭제하는 것이 아닌 경우이므로 comment table에서 참조하고 있는 member table 의 id를 다른 것으로 바꿔주어야 원활히 사용자의 data 까지 삭제하는 것이 가능해진다는 것이다.

 

CommentRepository.java

    @Modifying
    @Query("update Comment c set c.beDeleted = true, c.member = :newId where c.member = :id")
    int updateAllByMember(@Param("id") Member member, @Param("newId") Member deleteMember);

 새로운 annotation이 등장했다. @Modifying 인데, 이 annotation이 없으면 InvalidDataAccessApiUsageException 을 발생시켜서 logic에서 catch 에 걸려서 false 를 return 하게 된다.

 InvalidDataAccessApiUsageException 을 발생시키는 요인에는 @Param annotation 과 관련된 문제, @Transactional annotation과 관련된 문제 등 여러 가지가 있겠지만, 필자의 경우는 @Modifying annotation의 부재로 발생한 것이었다.

 

 관련 내용을 spring docs에서 찾을 수 있었다.[각주:3]

 

 이미지가 작으니 간단히 해석을 해보겠다.


 수행되는 데에 필요한 방법을 변경하기 때문에 query method가 modifying query로 고려되어야 한다는 것을 가리킨다. 이 annotation은 Query annotation을 통해 정의된 query method에 사용될 때만 고려된다. 이미 근원적인 data access API에 대한 제어력을 가지고 있거나 이름으로 수정한다면 그것을 이미 명시하고 있기 때문에 method 이름으로부터 도출된 custom implementation method나 query 에는 적용되지 않는다.

 @Modifying annotation을 필요로 하는 query에는 insert, update, delete와 기타 DDL statement 가 있다.


 기존에 필자가 @Query annotation을 사용한 것에 아무런 오류가 없었던 것은 단순히 select 로 조회하는 query 였기 때문이다. 하지만 이번에는 update query 를 작성했고, 때문에 @Modifying annotation이 없어 오류가 발생한 것이라고 할 수 있겠다. 따라서 @Modifying annotation을 붙여주고 실행했을 때, 온전하게 기능이 동작하는 것을 확인했다. 하지만 여전히 동작하는 select query나 delete query는 동일하다.

 

 첫 문장의 '수행되는 데에 필요한 방법' 에 해당하는 부분은 한 블로그를 참고하여 의문을 해결할 수 있었다.[각주:4] 해당 블로그에서는 JPA로 query가 수행될 때 executeQuery( ) method가 동작하여, 이는 select query 에 해당하는 것이라고 말한다. 나머지 insert, update, delete query가 수행되도록 하려면 executeUpdate( ) method가 동작해야하는데, 이 method로 변경하는 것이 @Modifying annotation인 것으로 추정할 수 있겠다.

 

 필자의 경우는 당장에 문제가 발생하지 않았지만, 문제가 생겼을 경우에 사용할 clearAutomatically 속성에 대해 언급해둔 블로그가 있었다. 혹시라도 관련된 문제가 생겼을 경우 참고를 위해 기록해두고 넘어간다.[각주:5]

 

 

참고 내용

 관련된 정보를 검색하다가 한 블로그에서 언급한 내용을 발견할 수 있었다.[각주:6] 해당 블로그에서는 Entity 사이에 연관 관계가 복잡할 경우에 삭제가 원활히 이루어지지 않는다고 언급하고 있었다. 그러면서 연관 관계 설정 시 주의해야 할 점은 Entity 사이에 life cycle이 동일해야 하며, 또한 단일 Entity에 완전히 종속할 때만 cascade 옵션을 사용할 수 있다는 것이라 언급하고 있다.

 

 필자의 경우 Member(사용자)와 Recipe(게시글), Comment(댓글) Entity 에 대해 위 이미지와 같이 이상한 연관 관계를 형성해 준 상태이다. 사실 이상하다는 점을 그림을 그리고서 깨달았다. 게시글이 댓글 정보를 가져야하는 것도 맞고, 사용자가 작성한 게시글의 목록을 가지고 있는 것도 맞는데, 사용자가 댓글의 목록을 가지고 있으면 안될까?

 사실 목적은 두 가지였다. 첫 번째는 바로 작성자의 이름 대신 작성자의 사용자 id 를 넣어주기 위함이었다. 그래서 그 부분에 대해서는 이제까지 잘 동작하고 있었다. 두 번째는 지금 작업하고 있는 회원 탈퇴를 염두해둔 것이었다. 회원 탈퇴 시 사용자와 관련된 모든 게시글 정보와 댓글 정보를 삭제해야하기 때문이다.

 

 이렇게 따로 정보를 알아보기는 했지만, 필자가 사용자의 정보를 불러오는 것에 문제가 있었음을 찾아냈다. 하지만 혹시나 알 수 없는 이유로 발생할 오류가 있을 것을 생각해서 Member와 Comment 사이의 연관 관계에서 cascade 옵션은 지워주기로 했다. 어차피 댓글은 실질적으로 삭제하는 것이 아니기 때문이다.

 

Delete query 동작 변경

 회원 탈퇴 기능은 동작하지만, query 가 많이 동작하고 있다. 댓글도 한 번, 참조 링크도 한 번, 게시글도 한 번, 사용자 권한 한 번, 사용자 한 번, 삭제해야하는 다섯 항목에 대해 총 다섯 번의 query로 동작하게 하는 것이 최종 목표이다.

 

  1. 두 번의 사용자 data를 조회하는 select query 각각 1개 (총 2개)
  2. 삭제하고자 하는 게시글 data를 조회하는 select query 1개 (총 1개)
  3. 타 게시글의 댓글 작성자를 수정하는 update query 1개
    댓글, 게시글의 참조 링크, 게시글 data를 삭제하는 delete query 각 1개 (총 4개)
  4. 사용자의 권한 data를 조회하는 select query 1개
    사용자가 작성한 게시글의 댓글 정보와 참조 링크 정보를 조회하는 select query 각 1개 (게시글 수 만큼)

 이제 data를 삭제하는 query를 각각 수행하도록 했으니 위 네 가지 중에 4번에 해당하는 query 가 수행되지 않도록 Entity에서 cascade 옵션을 없애줘도 될 것 같다.

 

 위 이미지는 왼쪽부터 각각 Member 와 Recipe Entity의 연관 관계에 해당하는 field를 가져온 것인데, 표기된 위치에 원래 있었던 cascade 옵션을 없애주었다. 이미 Member Entity의 commentList의 경우는 지난 게시글에서 없앴으나, 이번에 recipeList에서도 없애주었다. 기존에는 연관 관계에 있는 항목에 대한 삭제 query를 자동으로 수행하도록 하는 옵션이었으나, 지금 작업에서와 같이 직접 delete query가 수행되도록 한 경우에는 cascade type REMOVE 옵션에 대한 필요성이 없어진다.

 대신 참조 링크에 대한 경우는 조금 남겨두는데, 이는 게시글을 작성하고 수정할 때 해당 field를 사용하기 때문이다. 아예 cascade 옵션을 지워주면 게시글을 작성하고 수정할 때, 참조 링크 관련 data 처리를 할 수 없게 된다.(경험담이다. 필자도 알고 싶지 않았다. 그저 테스트 하는 과정에서 알게 되었다.)

 

UserServiceImpl.java

    // 회원 탈퇴 service logic
    @Transactional
    @Override
    public boolean exit(Member member) {
        try {
            // 온전한 Member (Recipe List, Comment List 보유)
            member = userRepository.getMemberByEmail(member.getEmail());
            // table에 임의로 만들어 둔 0번 사용자의 정보. 탈퇴한 회원임을 name으로 한다.
            Member deleteMember = userRepository.getReferenceById(0L);

            // 삭제하고자 하는 게시글 목록
            List<Recipe> recipeList = recipeRepository.getAllByMember(member);

            // 타 게시글에 있는 사용자의 댓글 작성자를 0번으로 변경
            commentRepository.updateAllByMember(member, deleteMember, recipeList);
            // 사용자가 작성한 게시글 하위에 있는 모든 댓글 삭제
            commentRepository.deleteAllByRecipes(recipeList);
            // 사용자가 작성한 게시글의 참조 링크 삭제
            linkRepository.deleteAllByRecipes(recipeList);
            // 사용자가 작성한 게시글 삭제
            recipeRepository.deleteAllByMember(member);

            // 사용자의 정보 삭제 (작성한 게시글과 댓글 삭제)
            userRepository.delete(member);

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

 그래서 완성한 회원 탈퇴에 대한 service logic이다.

 

 그래서 수행되는 query문이다. 다음과 같이 총 10개의 query가 동작하면서 사용자와 관련된 댓글, 게시글의 참조 링크, 게시글, 권한, 사용자의 5개 항목에 대한 삭제가 이루어진다. 마지막 사용자에 대한 항목은 select query가 수행되는 점을 비교하는 차원에서 남겨두었다.

  1. 두 번의 사용자 data를 조회하는 select query 각각 1개 (총 2개)
  2. 삭제하고자 하는 게시글 data를 조회하는 select query 1개 (총 1개)
  3. 타 게시글의 댓글 작성자를 수정하는 update query 1개
    댓글, 게시글의 참조 링크, 게시글 data를 삭제하는 delete query 각 1개 (총 4개)
  4. 삭제할 사용자의 권한을 조회하는 select query 1개
    삭제할 사용자의 권한, 사용자의 data를 삭제하는 delete query 각 1개 (총 3개)

 

 이렇게 회원 탈퇴에 대한 기능을 작업했고, 동작하는 query를 줄이기 위해서 이런 저런 작업을 했다. 원래도 많아보이긴 했는데, 조금 줄였다고 드라마틱하게 줄어드는 것은 또 아닌가 싶긴 하다. 한 명의 사용자에 대한 삭제 관련해서 query가 10개가 동작하는 것이 뭔가 많아보이지만, 여기에서 각 게시글 전부, 작성한 댓글 사용자 각각 등을 모두 조회했다고 생각하면 끔찍하긴 하다.

 이렇게 작업한 것처럼 기존의 삭제 과정에 대한 query도 다음 게시글에서 변경해볼 것이고, 테스트 과정에서 발견한 게시글 data 조회 시 두 번 조회하는 부분에 대해 짚고 넘어갈 생각이다. 마이 페이지 관련 기능을 거의 다 완성해간다. 마이 페이지의 마지막 작업은 작성한 게시글 목록에 대한 페이징이 될텐데, 이는 index 에서 출력하는 게시글 목록에도 해당되는 작업이 되겠다.

 

Reference

댓글