테이블을 설계하고 직접 코딩하면서 연관관계 옵션으로 Cascade.All 을 한 번쯤 사용 해보셨을꺼 같은데요!
단어 자체는 친숙하지만, 파고들어서 어떤 종류가 있고, 각각 어떤 역할을 하는지 물어본다면 저는 벙찔질 것 같습니다. (항상 개념을 찾아보는거 보면,,)
이번 기회에 확실하게 개념을 잡고자 Cascade 개념과 항상 붙어다니는 고아 객체 개념과 OrphanRemoval 옵션까지 묶어서 정리해 보려고 합니다.
그럼 시작하겠습니다!
먼저 Cascade
자체에 대해 알아보도록 하겠습니다.
Cascade
는 '영속성 전이' 라는 의미인데, 이는 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 동일하게 영속 상태로 만들고 싶은 경우 사용합니다.
즉, Cascade
설정을 통해 특정 엔티티의 영속 상태를 따로 처리하지 않고, 동일하게 가져갈 수 있는 개념입니다. (부모 자식 개념으로 생각하면 이해하는데 도움이 될 것 같습니다.)
그렇다면 영속 상태는 무엇일까요? 영속 상태 개념을 알기 전 영속성 컨텍스트를 먼저 알고가면 좋습니다.
영속성 컨텍스트는 엔티티를 영구 저장하는 환경 또는 논리적인 개념입니다.
즉, 어플리케이션과 실제 DB 사이에 잠시 객체를 보관하는 가상 DB입니다.
이렇게 텍스트로만 보면 잘 와닿지 않으므로 사진을 보도록 할까요?
해당 이미지에서 현재 객체가 Managed
상태라면 객체, 즉 엔티티가 영속성 컨텍스트라는 가상DB 속에 올라와 있는 상태입니다.
영속성 컨텍스트는 엔티티 매니저(Entity Manager a.k.a em)에 의해 관리됩니다.
위의 이미지는 엔티티의 생명주기 4가지에 대해 설명하고 있습니다. 하나씩 살펴보도록 합시다.
객체만 생성 했을 뿐 영속성 컨텍스트와 아무런 연관이 없는 상태입니다.
즉, 단순 “객체” 상태입니다 (엔티티와 연결되지 않은 상태)
생성된 객체가 엔티티 매니저에 의해 영속성 컨텍스트에 저장된 상태입니다.
위에도 설명했지만, 해당 객체가 실제 DB에 저장되는 것이 아닌 영속성 컨텍스트라는 가상DB 속에 1차적으로 저장됩니다.
이는 em.persist()
라는 메소드를 통해 이루어집니다.
//객체를 생성만 한 상태 (비영속 상태)
Member member = new Member();
member.setId("1L");
EntityManager em = emf.createEntityManager(); //엔티티 매니저 객체 생성
em.getTransaction().begin();
//객체를 저장한 상태(영속 상태로 전환)
em.persist(member);
영속 상태로 만들기 위해서는
@Id
로 매핑된 키 값이 존재해야 합니다.
준영속 상태는 영속성 컨텍스트에 저장되어 있는 엔티티를 분리한 후의 상태입니다. (영속성 컨텍스트에서 빠져나간다고 생각하면 될 것 같습니다)
아래와 같은 3가지의 경우 엔티티가 준영속 상태가 됩니다
em.detached(entity)
: 특정 엔티티를 제외 시키는 경우em.close()
: 엔티티 매니저를 닫는 경우em.clear()
: 영속성 컨텍스트를 비우는 경우그렇다면 왜 비영속 상태가 아니라 준영속 상태일까요?
이는 한번 영속성 컨텍스트에 저장된 후 분리되었기 때문에 비영속 상태인 순수 객체라고는 할 수 없기에 그렇습니다.
특징으로는 한 번 저장된 후 분리되었기 때문에 식별자(id)가 존재하고, 더 이상 JPA가 관리하지 않는 엔티티라는 점입니다.
따라서 만약 준영속 상태의 객체(엔티티)를 수정한다해도 해당 내용이 업데이트 되지 않습니다 (JPA의 관할이 아니기에)
그렇다면 준영속 상태의 엔티티를 수정하고 싶다면 어떡해야 할까요?
아래와 같은 두가지 방법이 존재합니다. (이는 따로 정리하여 포스트 하겠습니다.)
엔티티를 영속성 컨텍스트 뿐만 아니라 실제 DB에서도 삭제하는 경우입니다.
위의 개념들을 이해하고, 이제 본격적으로 Cascade
에 대해서 알아보도록 합시다.
Cascade
는 6가지의 옵션을 가지고 있습니다.
각각의 기능들을 ‘옵션’으로 가지고 있는 ‘영속성 전이’ 이기 때문에 특정 객체를 영속화 하거나, 삭제, 준영속 하게되는 경우 옵션에 따라 연관되어 있는 객체에게도 동일하게 적용(전이)됩니다.
여기서는 가장 기본이고 중요한 PERSIST와 REMOVE 옵션에 대해서만 알아보도록 하겠습니다.
PERSIST는 1번에서 설명했듯이 일반 객체를 영속화 컨텍스트에 저장하여 엔티티화 시키는, 즉 영속화 할 때 사용하는 메소드입니다.
아래와 같이 Parent 엔티티와 Child 엔티티로 예시를 들어보겠습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Parent {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> childs = new ArrayList<>();
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Child {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
// 연관관계 설정
public void setParent(Parent parent) {
this.parent = parent;
parent.getChilds().add(this);
}
}
새로운 Parent 객체와 Child 객체를 생성 후 연관관계를 설정해줍니다.
Parent parent = new Parent();
em.persist(parent);
Child child = new Child();
child.setParent(parent);
em.flush(); //실제 DB에 반영
위의 코드 처럼 엔티티 매니저에 의해 Parent 객체가 PERSIST 되면 현재 Cascade.PERSIST
옵션이 설정되어 있기 때문에 하위 객체인 Child 객체 또한 영속화가 일어납니다.
REMOVE는 영속화 컨텍스트와 실제 DB 모두에서 해당 엔티티를 삭제하는데 사용됩니다.
하지만 보통의 경우 엔티티는 다른 엔티티들과 다양한 연관관계를 맺고 있습니다.
당연히 Cascade
옵션이기 때문에 연관되어 있는 엔티티도 영속성 전이에 따라 같이 삭제가 되어야 합니다.
삭제하는 경우 아래와 같은 두가지 상황이 존재합니다.
첫번째 경우는 아마 끄덕끄덕 하실텐데, 두번째 경우는 꽤 흥미롭습니다. (아니라면 죄송합니다)
두번째 경우에 대한 예시를 통해 더 자세히 알아봅시다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Parent {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@OneToMany(mappedBy = "parent", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<Child> childs = new ArrayList<>();
}
REMOVE 옵션을 새로 추가했습니다.
이후 Child의 연관관계 설정 메소드를 살짝 수정해보겠습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Child {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
// 연관관계 설정
public void setParent(Parent parent) {
// 기존 팀과 연관관계 제거
if(this.parent != null) {
this.parent.getChilds().remove(this);
}
// 새로운 연관관계 설정
this.parent = parent;
if(parent != null) {
parent.getChilds().add(this);
}
}
}
/*
영속화 진행
..
*/
Parent parent = em.find(Parent.class, 0L);
parent.getChilds().get(0).setParent(null); //연관관계 제거
em.flush(); //실제 DB에 반영
위 코드를 수행한 후 확인해본 결과 자식 엔티티는 그대로 DB에 남아 있습니다.
또한 DELETE 쿼리가 실행되지 않은 걸 확인할 수 있었는데요.
그렇다면 연관관계가 제거된 자식 엔티티를 삭제하려면 어떻게 해야할까요?
여기서 등장하는 개념이 고아객체와 OrphanRemoval 옵션입니다.
먼저 고아 객체란 2-2에서처럼 부모와 자식간의 연관관계가 끊어지는 경우 자식 엔티티는 부모를 잃어버리게 되어 고아 객체가 됩니다.
이때 OrphanRemoval 옵션을 설정해주면 이러한 고아 객체를 자동적으로 판단해 엔티티를 삭제할 수 있습니다. (단어의 의미 그대로)
해당 옵션은 직접 설정해 주지 않는다면 기본적으로 false
로 설정되어 있습니다 (이유는 아래에서 나옵니다.)
두 가지 모두 부모 엔티티를 삭제하는 경우 연관관계로 묶여있는 자식 엔티티도 자동적으로 삭제되는 공통점이 있지만,
연관관계를 제거한 후 고아 객체가 되었을 때 해당 엔티티를 자동적으로 삭제하는 측면에서 차이가 있습니다.
그럼 두가지 옵션 모두 사용하는 것이 좋은 방향일까요?
이부분은 개발자라면 한 번쯤 고민해보면 좋은 주제인 것 같습니다.
만약 자식 엔티티가 연관 관계를 맺고 있는 부모 엔티티가 위의 예시처럼 한가지가 아니라 여러개인 경우라면, 즉 자식 엔티티를 공동 소유하고 있는 상황이라면
한 쪽 부모 엔티티를 삭제하거나 연관 관계를 끊어버리는 경우 자식 엔티티가 그냥 삭제되어 버리는 참사가 발생할 수 있습니다. (OrphanRemoval의 default가 false
로 설정되어 있는 이유 중 하나입니다.)
그렇기에 통상적으로 권장되는 Cascade
의 범위는 완전한 개인 소유인 경우, 즉 연관 관계가 하나만 존재하는 경우 사용합니다.
연관관계를 설정하다보면 @OneToMany
에 당연하게 CascadeType.ALL
을 사용하는 분들이 적지는 않은 것 같습니다.
그리고 테스트를 하던 중 부모 엔티티를 지웠는데 왜 자식 엔티티는 안 지워지지? 를 경험해본 분들도 계실거라 생각합니다.
예를 들어 설명해봅시다.
부모 엔티티(ParentOne, ParentTwo)가 두 개인 자식(Child) 엔티티를 만듭니다.
@Entity
class ParentOne{
@OneToMany(mappedBy = "parentOne ", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList;
}
@Entity
class ParentTwo{
@OneToMany(mappedBy = "parentTwo", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> childList;
}
@Entity
class Child{
@ManyToOne(fetch = FetchType.LAZY)
private ParentOne parentOne;
@ManyToOne(fetch = FetchType.LAZY)
private ParentTwo parentTwo;
}
ParentOne이 가진 Child들의 ParentTwo가 가진 Child의 크기를 알아보는 함수를 만듭니다.
ParentOne po = EntityManager.find(ParentOne.class, 0L);
List<Child> childList = po.getChildList();
for(Child c : childList){
Integer cSize = c.getParentTwo().getChildList().size();
}
po.getChildList().clear(); // ParentOne의 자식 엔티티 삭제
이 상황에서 Child의 사이즈를 얻기 위해 ParentTwo가 Lazy 로딩 되고, ParentTwo의 Child들도 Lazy 로딩 되었습니다.
이후 po.getChildList().clear()
를 통해 자식 엔티티를 지우려고 하는 상황입니다.
그렇다면 OrphanRemoval이 설정되어 있기 때문에 연관관계가 없어진 자식 엔티티 모두 삭제가 되어야 합니다.
하지만 결과는 삭제가 안됩니다.
이유는 ParentTwo가 로딩 되면 영속성 컨텍스트에 올라가게 되고, 이 때 CascadeType.ALL에 의해 ParentTwo의 자식 엔티티인 Child들에게도 영속성 전이가 일어나 (PERSIST 옵션) 삭제가 되지 않습니다.
이처럼 개인 소유 엔티티가 아닌 경우는 CascadeType.ALL 보단 개별 옵션을 잘 이해하고 설정하는 것이 좋은 개발이지 않을까 합니다.
읽어주셔서 감사합니다!🙌🏻
References
[Spring/JPA] CascadeType.ALL 사용시 주의 해야 할 점