ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA cascade 멈춰!
    Spring Framework 2022. 2. 2. 18:23
    반응형

    1) 서론

    업무 중 수십 개의 테이블에 걸친 데이터를 한 번에 지워야 하는 API를 만들어야 했습니다. 

     

    처음에는 고작 CRUD인데, 금방 하겠지라는 마음을 가지고 시작했습니다. 이때 생각지도 못 한 어려움을 만났는데요. 하마터면 관련 없는 데이터까지 삭제할 뻔했습니다.

     

    왜 이러한 일이 발생했는지 재현 후 기록합니다. 

     

    cascade 멈춰!


    2) 전체 구조

    2. 1) DB 스키마

    • User, Role, Post 테이블이 있습니다.
    • User, Role을 연결시켜주는 User_Role 매핑 테이블이 존재합니다.

    2. 2) Entity

    글과 관련 없는 애노테이션들은 모두 생략했습니다.

    @Entity
    public class User {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private int idx;
        private String email;
        private String password;
    
        @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
        private Set<Post> posts;
    
        @ManyToMany(fetch = FetchType.LAZY)
        @JoinTable(name = "User_Role",
                joinColumns = @JoinColumn(name = "idxUser"),
                inverseJoinColumns = @JoinColumn(name = "idxRole"))
        private Set<Role> roles;
    }
    @Entity
    public class Post {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "idx", columnDefinition = "smallint")
        private int idx;
    
        private String title;
        private String content;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "idxUser")
        private User user;
    }
    @Entity
    public class Role {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private int idx;
        private String name;
    
        @ManyToMany(fetch = FetchType.LAZY, mappedBy = "roles")
        private Set<User> users;
    }

    2. 3) 상황 설명

    삭제하고자 하는 것은 User 데이터입니다. 

     

    하지만 FK로 참조하고 있는 여러 개의 테이블의 데이터도 삭제해야 합니다.


    3) 삭제 시 문제점

    User 테이블의 데이터를 삭제하겠습니다.

    @RequiredArgsConstructor
    @Service
    public class DeleteService {
    
        private final UserRepository userRepository;
    
        @Transactional
        public void deleteAllValues() {
            User user = userRepository.getById(1);
            userRepository.delete(user); // 조회한 User 삭제
        }
    }
    • User를 삭제하는 간단한 서비스 객체입니다.
    ... 생략 ...
    class SpringbootJpaBasicTestApplicationTests {
    
        @Autowired
        DeleteService deleteService;
    
        @Mock
        DeleteService mockDeleteService;
    
        @Mock
        UserRepository mockUserRepository;
    
        @Mock
        User mockUser;
    
        @Test
        @DisplayName("idx 1에 해당하는 유저와 게시글을 삭제합니다.")
        void SHOULD_DELETE_ALL_VALUES_SUCCESS() {
            // given
            given(mockUserRepository.getById(1)).willReturn(mockUser);
    
            // when
            deleteService.deleteAllValues(); // User 삭제
    
            // then
            then(mockDeleteService).shouldHaveNoInteractions();
        }
    }
    • 서비스 객체를 위한 간단한 테스트 코드입니다.

    • 테스트는 당연히 실패합니다.
    • 실패의 이유는 User를 삭제하려고 했지만, 이를 참조하는 Post가 여전히 남아있기 때문입니다.

    3. 1) 불편함

    User를 삭제하기 위해서는 두 가지 방법이 존재합니다.

    1. Post가 외래 키로 참조하고 있는 idxUsernull로 만들어줍니다. (참조 데이터 미삭제 경우)
    2. Post먼저 삭제User를 삭제합니다. (참조 데이터까지 모두 삭제)

     

    현재 글에서는 두 번째의 모두 삭제를 선택합니다.

     

    만약 User 테이블을 참조하고 있는 테이블이 수십 개라면 아래와 같을 것입니다.

        @Transactional
        public void deleteAllValues(User user) {
            aRepository.deleteByUser(User user); // a 엔티티부터
           	bRepository.deleteByUser(User user);
            cRepository.deleteByUser(User user);
            
            ... 중략 ...
            
            zRepository.deleteByUser(User user); // z 엔티티까지 모두 삭제 코드 필요
        }
    • a ~ z까지 삭제해줘야 하는 엔티티 수만큼 코드를 작성해야 합니다.
    • 코드가 지저분해지고, 불필요한 코드를 반복해서 작성해야 합니다.

    4) Cascade!

    위에서 발견한 문제점을 해결하기 위해 Cascade를 사용하려고 합니다.

     

    Cascade"영속성 전이"입니다. 영어 사전에서는 "계단식 폭포"라고 정의합니다. 즉 부모 엔티티부터 연관된 자식 엔티티까지 상태를 전파하는데요. @Transactional의 propagation과 비슷합니다.

     

    Cascade에는 여러 가지 type이 존재합니다.

    • ALL
      • 아래의 모든 type을 포함합니다.
    • PERSIST
      • 영속성을 전파합니다.
      • 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장합니다.
      • a.setB(b)를 사용해서 b.save()가 필요하지 않습니다.
    • MERGE
      • 병합합니다.
      • 비영속 상태에서 부모, 자식을 모두 변경합니다.
      • 이때 부모.merge() 연산을 하게 되면, 자식의 변경 사항도 수정됩니다.
      • 하지만 Lazy 로딩 + @Transactional X인 경우에는 부모.자식()을 사용할 수 없습니다.
      • 그래서 저장의 경우에는 Eager 로딩일 때 이를 사용할 수 있습니다.
    • REMOVE
      • 삭제합니다.
      • 부모 엔티티의 상태가 전파되어, 자식 엔티티도 모두 삭제됩니다.
      • 잘 못 사용하면, 원치 않는 엔티티까지 모두 삭제됩니다.
    • REFRESH
      • 다시 로드합니다.
      • 영속된 상태에서 조회한 엔티티가 변경되더라도, refresh() 해서 DB에 저장된 값을 override 합니다.
      • 마찬가지로 자식 엔티티도 refresh() 합니다.
    • DETACH
      • 준영속 상태로 만듭니다.
      • 조회한 부모 엔티티를 detach 상태가 된다면, 자식 엔티티도 마찬가지로 detach 됩니다.
      • 사실 부모 엔티티가 detach 상태가 되면, 당연히 자식 엔티티도 detach 됩니다.
      • 그렇다면 굳이 이 옵션이 필요할까라는 의문이 들기는 합니다.

    4. 1) Cascade 적용

    public class User {
    
        ... 생략 ...
        
        // CascadeType.ALL 추가
        @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        private Set<Post> posts;
        
        ... 생략 ... 
    }
    • Post를 한 번에 지우기 위해 CascadeType.ALL 추가했습니다.

    • User 테이블만 삭제했습니다.
    • User_Role, Post, User 테이블 모두 삭제됩니다.

    4. 2) Cascade는 위험하다.

    이렇게 편리해 보이는 Cascade를 잘 못 사용하면, 큰 문제가 발생할 수 있는데요.

     

    마찬가지로 User만 삭제합니다.

    @Entity
    public class User {
    
        ... 생략 ...
    
        @ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
        @JoinTable(name = "User_Role",
                joinColumns = @JoinColumn(name = "idxUser"),
                inverseJoinColumns = @JoinColumn(name = "idxRole"))
        private Set<Role> roles;
        
        ... 생략 ...
    }
    • Role 필드에 Cascade.ALL 선택합니다.

    • User_Role뿐만 아니라, Role까지 모두 삭제됐습니다.

     

    ALL 옵션은 PERSIST ~ DETACH까지 모든 옵션을 실행하는데요. 진짜 범인은 REMOVE 옵션입니다.

     

    앞서 REMOVE는 자식 엔티티까지 옵션이 전파되어, 삭제된다고 했었습니다. User가 삭제되면서 자식인 Role까지 함께 삭제가 됐습니다.

     

    만약 User만 삭제하고자 한다면 REMOVE 옵션만 없으면 됩니다. 이를 위해서 두 가지 방법이 있는데요.

    1. ALL, REMOVE가 아닌 필요한 옵션(PERSIST, MERGE 등)만 사용한다.
    2. cascade = {}을 사용해서, REMOVE 제외한 옵션들만 정의한다.

     

    자식 엔티티가 모두 삭제되는 것을 방지하고자, 아래와 같이 작성합니다.

    @Entity
    public class User {
    
        ... 생략 ...
    
        @ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
        @JoinTable(name = "User_Role",
                joinColumns = @JoinColumn(name = "idxUser"),
                inverseJoinColumns = @JoinColumn(name = "idxRole"))
        private Set<Role> roles;
        
        ... 생략 ...
    }
    • CascadeType.PERSIST로 변경합니다.
    • 사실 REMOVE 옵션만 아니라면, 삭제하고는 전혀 연관이 없습니다.

    • Role 엔티티의 삭제 쿼리는 발생하지 않습니다.

    4. 3) 다른 옵션도 알아봅시다.

    저장할 때 자주 쓰일 것 같은 MERGE에 대해 알아보겠습니다.

    @Entity
    public class User {
    
       ... 생략 ...
    
        @OneToMany(mappedBy = "user", fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
        private Set<Post> posts;
        
        ... 생략 ...
    }
    • CascadeType.MERGE로 변경합니다.
    • @Transactional을 사용하지 않으므로, Eager 로딩으로 변경합니다.
        public void mergeValues() {
            User user = userRepository.findById(1).orElseThrow(); // User 조회
            
            // user는 비영속 상태
            Set<Post> posts = user.getPosts(); // User의 Post 조회 
    
            for (Post newPost : posts) { // User의 Post값 수정
                newPost.setTitle("제목1");
                newPost.setContent("본문1");
            }
    
            userRepository.save(user); // user만 저장
        }
    • User만 조회하고, 저장합니다.
    • User.post(newPost)와 같이 저장하지 않습니다.

    • User.post(newPost)처럼 저장하지 않았습니다.
    • User만 저장했음에도 불구하고, Post update 쿼리 발생합니다.
    • 이미 비영속 상태인 User의 자식 엔티티인 Post까지 상태가 전파됐습니다.

    4. 3. 1) Persist vs Merge

    Persist와 Merge는 매우 비슷해 보입니다.

     

    persist영속성 상태에 있지 않는 것을 자식 엔티티까지 영속성 상태로 만드는 것이 핵심입니다.

    User user = new User();
    user.setName("persist");
    
    entityManager.save(user); // persist() 사용

     

    merge준영속(detach) 상태의 엔티티의 자식까지 병합하는데요.

     

    기본적으로 EntityManager는 엔티티의 상태를 @Id의 key를 통해 관리합니다.

     

    준영속 상태는 이미 영속성 컨텍스트가 관리하는 영속 상태였다가, 더 이상 관리되지 않는 상태입니다. 하지만 한번 영속성 컨텍스트에 올라갔기 때문에 여전히 식별자인 @Id가 남아있는데요. 

     

    이때 준영속 상태로 남아있는 식별자의 상태가 변경되면, persist()가 아닌 merge()가 발생합니다.

    User user = new User();
    user.setName("persist");
    
    entityManager.save(user); // persist() 사용
    
    =========================================
    
    User user2 = new User();
    user.setIdx(user.getIdx()); // 준영속 상태의 user.getIdx 사용
    user.setName("merge");
    
    entityManager.save(user2); // merge() 사용

     

    정리해보겠습니다.

     

    cascadeType.MERGE

    • 비영속 상태의 부모 엔티티를 변경했을 때
    • 비영속 상태의 자식 엔티티
    • merge() 수행됩니다.

    4. 3. 2) MERGE 옵션에 대한 의문

    User 엔티티를 조회한 시점에서, 이미 트랜잭션은 끝났습니다. User는 준영속 상태인데요. 그럼에도 불구하고 Post 엔티티의 변경된 값이 같이 Update 됩니다. 이는 위에서 설명한 MERGE의 특성 덕분입니다. 

     

    하지만 이는 Post 엔티티를 Eager 로딩으로 조회하고 있기 때문인데요. 실제 JPA를 사용할 때는 기본적으로 LAZY 로딩을 사용합니다. 왜냐하면 N+1과 같이 성능상 불리한 점이 있기 때문인데요. 필요에 따라서 특정 상황에서 @EntityGraph 혹은 join을 사용하여, Eager 로딩합니다.

     

    LAZY 상황에서 자식 메서드를 조회하려면, @Transactional이 필요합니다. 그리고 @Transactional이 존재한다면 MERGE 옵션을 주지 않아도, 어차피 update 쿼리 발생합니다. 왜냐하면 하나의 트랜잭션이 끝나지 않았기 때문입니다.

     

    그래서 사실 삭제를 위해 REMOVE 옵션을 제거하는 것이 아닌, MERGE 옵션만 따로 주는 것은 어떤 경우에 써야 하는 건지 의문이 듭니다. 왜냐하면 @Transactional이 걸려있다면, 의미가 없어지기 때문입니다. 

     

    아마도 제가 모르는 사용처가 있을 것이라 생각이 듭니다.

    4. 4) Cascade 사용 자체에 대한 의문

    만약 A, B, AB_mapping 테이블이 있다고 가정합니다. 

     

    매핑 테이블이 존재하는 A, B 엔티티는 아래와 같을 것입니다.

    • 서로 life cycle이 완전히 같지 않을 것입니다.
    • 어느 하나의 엔티티에 종속되지 않을 것입니다.

     

    그렇다면 꼭 cascade를 사용해야 할까요?

     

    개인적인 의견으로는 위의 경우에는 cascade를 사용하지 말고, 개별적으로 조회 후 insert / delete 해야 하지 않을까 생각합니다. 

     

    왜냐하면 cascade는 부모가 자식의 엔티티까지 상태를 전파하는 것입니다. 하지만 위에서 언급한 A, B 테이블은 부모와 자식의 관계라고 보기는 어렵기 때문입니다. 


    5) 결론

    Cascade는 매우 강력합니다.

     

    사전의 의미 그대로 "계단식 폭포"입니다. 부모 엔티티가 변경된다면, 자식 엔티티 그리고 그의 자식까지 계속해서 전파가 되는 특성을 가지고 있습니다.

     

    이것을 잘 사용한다면, 불필요한 save/delete 코드를 줄일 수 있을 것입니다. 

     

    하지만 잘 모르고 사용했을 때 원치 않는 정보까지 삭제가 될 수도 있는 무서운 옵션이기도 합니다.

     

    대게 저장이 더 된다고 해서, 심각한 문제가 발생하지는 않습니다. 하지만 더 삭제된다는 것은 심각한 문제를 발생시킵니다. 유저를 삭제했는데, 권한이 날아가버린다면 다른 유저들의 권한도 없어질 것입니다. 간단히 생각해본다면, 권한이 없으니 로그인부터 문제가 생길 것입니다.

     

    실제로 업무 중 ALL 옵션을 사용해서, 자식 엔티티까지 모두 삭제할뻔한 경험이 있었습니다. 다행히 테스트 과정에서 발견해서 다행이지만요. 오히려 공부하고 나니까, 얼마나 무서운 짓을 생각 없이 했는지 느껴지기도 합니다.

     

    이번의 공부를 통해서 JPA의 옵션은 모르고 사용한다면 무섭지만, 알고 사용한다면 강력한 기능들이 숨어있다는 것을 알 수 있었습니다.


    6) 참고 문헌

    https://docs.jboss.org/hibernate/orm/5.6/userguide/html_single/Hibernate_User_Guide.html#pc-cascade

    https://umanking.github.io/2019/04/12/jpa-persist-merge/

    https://ultrakain.gitbooks.io/jpa/content/chapter3/chapter3.6.html

    반응형

    댓글

Designed by Tistory.