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 쿼리를 조회하여 데이터 동기화에 따른 문제도 해결되게 됩니다.
'Programing > JPA' 카테고리의 다른 글
JPA Entity 내 Subquery 로 동작하는 속성 추가 방법 @Formula (0) | 2024.05.08 |
---|---|
@MappedSuperclass 에 대해서 알아보자 (0) | 2023.08.25 |
JPA could not initialize proxy - no Session 원인 / 해결방법 (1) | 2023.08.24 |
@IdClass @EmbeddedId 의 활용 차이 (0) | 2023.08.18 |
JPA 연관관계 매핑 알짜만 빼먹기. 일대일 일대다 다대일 다대다 (0) | 2023.08.18 |
댓글