Programing/JPA

JPA 부모, 자식 관계에서 부모 삭제 시 모든 경우의 수를 알아보자 Cascade, orphanRemoval 차이, 벌크연산

리커니 2024. 4. 5. 15:56
반응형

FK 로 연결된 부모-자식 간의 관계에서

Cascade 를 설정하여 부모가 삭제되는 경우 자식도 삭제 해야하는 경우가 있고, 

자식의 FK Column을 null로 초기화 해야 되는 경우가 있습니다.

 

Cascade 의 오용은 큰 문제를 야기하기 때문에 주의해서 사용을 해야 됩니다.

예를 들어 부모(권한)-자식(멤버) 의 관계에서

부모에 Cascade.REMOVE 옵션을 주고 부모를 삭제할 경우

자식까지 모두 삭제가 되게 됩니다.

권한을 삭제했는데 해당 권한을 가지고 있는 멤버까지 삭제가 된다면 큰 문제가 발생하게 되겠죠!

 

그럼 부모 자식간의 영속성 전이가 발생하는 Casecade 와 

고아객체 처리를 하는 orphanRemoval 옵션에 대해서 여러 예시를 들어 알아보도록 하겠습니다.

 

아래와 같이 권한과 멤버 엔티티가 있습니다.

Authority Entity

@Getter
@Setter
@Entity(name = "authority")
public class Authority extends BaseEntity {
    @Id
    @Column(name = "authority_cd")
    private String authorityCode;
    
    @Column(name = "authority_name")
    private String authorityName;
    
    @OneToMany(mappedBy = "authority", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
}

 

Member Entity

@Getter
@Setter
@Entity(name = "member")
public class Member extends BaseEntity {

    @Id
    @Column(name = "member_id")
    private String memberId;

    @Column(name = "member_password")
    private String password;

    @Column(name = "member_name")
    private String memberName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "authority_cd")
    private Authority authority;

 

@OneToMany, @ManyToOne 의 양방향 관계가 설정되어 있습니다.

권한을 삭제하는 경우를 보도록 하죠.

 

deleteAuthority

public Long deleteAuthority(String authorityCode) {
	Authority entity = authorityRepository.findById(authorityCode)
                	.orElseThrow(() -> new EntityNotFoundException(authorityCode));
	authorityRepository.delete(entity);
}

 

deleteAuthority 메서드를 호출하게 되면 아래와 같은 오류 메시지를 보실 수 있습니다.

org.springframework.dao.DataIntegrityViolationException: could not execute statement [오류: "authority" 테이블의 자료 갱신, 삭제 작업이 "member_authority_fk" 참조키(foreign key) 제약 조건 - "member" 테이블 - 을 위반했습니다

 

'삭제 할 authorityCode를 참조하는 FK가 있기 때문에 지울 수 없다.' 라는 의미 입니다. 이 경우 Member 에서 삭제하고자 하는 authorityCode를 없애주면 삭제가 되게 됩니다.

 

CascadeType.RMOVE

좀 잘못된 예시긴 하지만 Authority 를 지울 경우 자식 객체인 Member 까지 지워야 한다면 CascadeType.RMOVE 를 Authority에 추가하면 됩니다.

 

@Getter
@Setter
@Entity(name = "authority")
public class Authority extends BaseEntity {
    @Id
    @Column(name = "authority_cd")
    private String authorityCode;
    
    @Column(name = "authority_name")
    private String authorityName;
    
    @OneToMany(mappedBy = "authority", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private List<Member> members = new ArrayList<>();
}

 

이렇게 설정을 하고 deleteAuthority 를 다시 실행하면, 부모와 자식이 모두 삭제되게 됩니다.

Hibernate: delete from member where member_id=?
Hibernate: delete from authority where authority_cd=?

 

삭제 할 권한을 가진 member 가 한명이기 때문에 member를 삭제하는 쿼리가 한번 발생했지, 해당 권한을 가진 member 가 많다면, 그 수만큼 member를 삭제하는 쿼리가 발생할것입니다.

 

그럼 Authority 에서 Member의 관계만 끊는다면 어떤 상황이 발생할까요?

Authority의 FetchType.EAGER 로 변경하고, authority 의 member를 초기화 해보겠습니다.

(FetchType.EAGER  or 메서드에 @Transactional 을 추가해서 자식 객체에 접근할 수 있게 합니다.)

 

deleteAuthority

public Long deleteAuthority(String authorityCode) {
	Authority entity = authorityRepository.findById(authorityCode)
                	.orElseThrow(() -> new EntityNotFoundException(authorityCode));
    entity.getMembers().clear();
    authorityRepository.save(entity);
}

 

select 쿼리만 발생하게되고, 아무런 변경이 없습니다.

영속성 전이 옵션은 부모-자식간의 관계가 끊어졌다고 해서 자식을 삭제하거나 하지 않습니다.

orphanRemoval = true

해당 옵션은 부모-자식 관계에서 관계가 끊어진 고아 객체를 삭제하는 옵션입니다.

CascadeType.RMOVE 옵션에서는 관계를 끊는다고 해서 변경이 발생하지 않았지만,

orphanRemoval = true 옵션은 관계가 끊어진 모든 자식 객체를 삭제하게 됩니다.

 

부모의 영속성을 전이 하기 위해 cascade = CascadeType.PERSIST 로 변경하고,

orphanRemoval = true 옵션을 추가합니다.

@OneToMany(mappedBy = "authority", fetch = FetchType.EAGER, cascade = CascadeType.PERSIST, orphanRemoval = true)

 

관계를 끊었을 뿐인데, delete 쿼리가 발생하고, 관계가 끊어진 고아 객체는 모두 삭제가 되게 됩니다.

Hibernate: delete from member where member_id=?

 

정리

cascade = CascadeType.PERSIST

부모 삭제 시 자식도 삭제

부모-자식 관계 해제 시 아무 변경 없음

 

orphanRemoval = true

부모 삭제 시 자식도 삭제

부모-자식 관계 해제 시 모든 자식(고아 객체) 삭제

 

부모 삭제 시 자식 컬럼 Null 로 변경

그럼 부모 삭제 시 자식의 FK column 을 Null 로 초기화 하는 방법을 알아보겠습니다.

권한 삭제 시 해당 권한을 가지고 있는 멤버가 삭제되면 안되니 관계 설정의 옵션을 제거합니다.

 

@OneToMany(mappedBy = "authority", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();

 

cascade, orphanRemoval 제거

 

이제 권한을 삭제하기 전에 Member의 Authority 를 null 로 초기화 하는 코드를 추가합니다.

 

@Transactional
public Long deleteAuthority(String authorityCode) {
    Authority entity = authorityRepository.findById(authorityCode)
                .orElseThrow(() -> new EntityNotFoundException(authorityCode));
    List<Member> members = entity.getMembers();
    for (Member member : members) {
        member.setAuthority(null);
    }
    authorityRepository.delete(entity);
}

 

오류: "authority" 테이블의 자료 갱신, 삭제 작업이 "member_authority_fk" 참조키(foreign key) 제약 조건 - "member" 테이블 - 을 위반했습니다
java.util.ConcurrentModificationException: null

 

위와 같은 Exception 이 발생하는 이유는 member.setAuthority(null); 로 관계 해제 시 members의 사이즈가 변경되어 제대로 null로 초기화 되지 않기 때문입니다. 

위와 같은 Exception 을 방지하기 위해 반복문을 아래와 같이 변경합니다.

for (int i = members.size() - 1; i >= 0; i--) {
    members.get(i).setAuthority(null);
}
for (Iterator<Member> iterator = members.iterator(); iterator.hasNext(); ) {
    iterator.next().setAuthority(null);
}

 

 

이제 멤버의 FK 는 Null 로 초기화 되고, Authority는 삭제되었습니다. 하지만 여기도 문제가 있습니다. 

지우려고 하는 부모를 FK로 가지고 있는 자식 개수만큼 update 가 발생한다는 것인데요, 

이를 보완하기 위해 벌크 연산을 해야 합니다.

 

repository 에 query 를 작성하여 한번만 요청하도록 합니다.

 

MemberRepository

@Modifying
@Query(value = "update member set authority_cd = null where authority_cd = :authorityCode", nativeQuery = true)
int updateAuthority(@Param("authorityCode") String authorityCode);

 

@Modifying 은 Spring Data JPA 어노테이션으로 @Query 를 통해 변경이 일어나는 쿼리를 실행할 때 사용합니다.

@Transactional
public Long deleteAuthority(String authorityCode) {
    Authority entity = authorityRepository.findById(authorityCode)
                .orElseThrow(() -> new EntityNotFoundException(authorityCode));
    int updateSize = memberRepository.updateAuthority(entity.getAuthorityCode());
    LOGGER.info("update member size : {}", updateSize);
    authorityRepository.delete(entity);
}

 

update query 는 한번만 전속이 되고 기기존 원하던 기능대로 동작하는 것을 확인하실 수 있습니다.

 

벌크연산은 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 바로 Query를 실행하기 때문에 영속성 컨텍스는 변경된 데이터를 알 수 없습니다. 즉, 벌크연산 실행 시 영속성 컨텍스트와 DB 데이터 싱크가 맞지 않게 됩니다.

그렇기 때문에 벌크 연산을 통해 변경된 데이터를 사용하기 전에 영속성 컨텍스트를 비워주는 작업이 필요합니다.

이러한 작업을 해주는 옵션이 @Modifying 의 clearAutomatically=true 옵션입니다.

 

@Modifying(clearAutomatically = true)
@Query(value = "update member set authority_cd = null where authority_cd = :authorityCode", nativeQuery = true)
int updateAuthority(@Param("authorityCode") String authorityCode);

 

이렇게 설정하면 벌크연산 실행 후 자동으로 1차 캐시를 초기화 하기 때문에 이후 조회 시 DB 쿼리를 조회하여 데이터 동기화에 따른 문제도 해결되게 됩니다.

반응형