06. 연관 관계의 관리 - 영속성 전이와 고아객체 관리
- -
이번 시간에는 CASCADE 옵션을 이용한 영속성 전이와 고아객체의 관리에 대해 살펴보자.
Cascade 옵션을 이용한 영속성 전이
영속성 전이?
영속성 전이란 특정 entity의 영속 상태를 변경할 때 연된된 entity의 영속 상태도 함께 변경하는 것을 의미한다. 앞서 살펴봤던 Attachment, Post, Member의 관계를 생각해보자.
ERD를 살펴보면 Attachment는 Post의 P.K인 pno를 post로 참조하고 있다. 따라서 DB 입장에서 Attachment에 데이터를 저장하려면 먼저 Post에 데이터가 있어야 한다. 또한 Post를 삭제하기 위해서 먼저 Attachment에서 대상 Post를 참조하는 데이터를 먼저 지워야 한다. 이에 따라 관련 동작을 위해서는 아래와 같이 장황한 코드가 필요하다.
@Test
@DisplayName("cascade 옵션이 없을 때에는 주엔티티를 먼저 저장한 후 보조 엔티티를 저장해야 함")
public void insertTest() {
Attachment attachment = Attachment.builder().file("somefile").build();
Post post = Post.builder().title("title").content("content").build();
post.setAttachment(attachment);
Member member = findById(1L).get();
member.addPost(post); // 사용하는 시점에 Member 조회
prepo.save(post); // 먼저 post가 자리 잡아야 불필요한 쿼리가 덜 실행됨
arepo.save(attachment);
prepo.flush(); // 동일 T.X의 모든 변경사항 DB에 반영
}
Hibernate:
select ...
from member m1_0
left join paid_member m1_1 on m1_0.mno=m1_1.mno
where m1_0.mno=?
Hibernate:
insert into post (content, create_time, title, writer, pno) values (?, ?, ?, ?, default)
Hibernate:
insert into attachment (create_time, file, pno, ano) values (?, ?, ?, default)
참고로 JPA는 SQL 저장소를 이용하기 때문에 이들의 insert, delete 순서는 중요치 않고 그냥 반영해주기만 하면 된다. 하지만 쿼리의 최적화를 위해서는 순서에 주의해야 한다. 만약 Attachment를 먼저 save하고 Post를 save 하게 되면 attachment(insert: post는 null) -> post(insert) -> attachment(update: null -> post)의 순으로 쿼리가 실행된다. (물론 attachment에 post가 nullable이므로 가능한 일이다.)
Hibernate:
select ...
from member m1_0
left join paid_member m1_1 on m1_0.mno=m1_1.mno
where m1_0.mno=?
Hibernate:
insert into attachment (create_time, file, pno, ano) values (?, ?, ?, default)
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:TIMESTAMP) <- [2024-11-19T10:44:09.782229]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:VARCHAR) <- [somefile]
[o.h.orm.jdbc.bind.logNullBinding.35] binding parameter (3:BIGINT) <- [null] # 지금 PNO는 null
Hibernate:
insert into post (content, create_time, title, writer, pno) values (?, ?, ?, ?, default)
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [content]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:TIMESTAMP) <- [2024-11-19T10:44:09.805233]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:VARCHAR) <- [title]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:BIGINT) <- [1]
Hibernate:
update attachment set file=?, pno=?, update_time=? where ano=?
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [somefile]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:BIGINT) <- [19] # 이제야 PNO 업데이트
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:TIMESTAMP) <- [2024-11-19T10:44:09.817221]
[o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:BIGINT) <- [2]
@Test
public void deleteTest() {
Post post = prepo.findById(1L).get();
List<Reply> replies = post.getReplies();
Attachment attachment = post.getAttachment();
rrepo.deleteAll(replies); // Iterable 요소 삭제
arepo.delete(attachment);
prepo.delete(post);
prepo.flush();
}
위의 코드에서의 삭제 순서도 중요하다. 먼저 reply와 attachment를 지운 후 post를 지워야 한다. 만약 post를 먼저 지워버리면 reply와 attachment의 pno를 모두 null로 업데이트 했다가 다시 delete하는 불상사가 발생한다.
이처럼 연관관계가 있는 Entity를 추가/삭제할 때에는 연관관계에 있는 엔티티에 대한 고려가 필요하다. 까딱 순서를 잘못 생각해서 지워버리면 불필요한 쿼리를 남발 할 수도 있다. 어차피 Post에 속한 것들인데 Post를 삭제하면 이를 참조하고 있는 Attachment나 Reply도 함께 삭제할 수는 없을까?
관계의 cascade 속성과 CascadeType
위와 같은 요청을 처리하기 위해 관계를 나타내는 @OneToMany , @ManyToOne, @OneToOne 등 애너테이션에는 cascade 옵션이 제공된다. cascade는 '작은 폭포'라는 뜻으로 '단계적'이라는 의미를 내포한다.
public @interface OneToMany {
CascadeType[] cascade() default {};
FetchType fetch() default LAZY;
}
cascade의 목적은 특정 entity의 영속 상태를 변경할 때 연된된 entity의 영속 상태도 함께 변경하는 것이다.
cascade의 값인 CascadeType은 enum으로 다음과 같은 enum 상수를 갖는다.
public enum CascadeType {
ALL, /** 아래의 모든 상황에 적용 */
PERSIST, /** Entity가 persist 될 때 적용 */
MERGE, /** Entity가 merge 될 때 적용 */
REMOVE, /** Entity가 remove 될 때 적용 */
REFRESH, /** Entity가 refresh 될 때 적용 */
DETACH /** Entity가 detach 될 때 적용 */
}
각 상수의 의미는 주석을 통해서 알수 있듯이 어떤 상태를 전이시킬 것인가 하는 것이다.
cascade 옵션은 관계의 소유 여부와 관계없이 사용할 수 있는데 cascase가 선언된 엔티티에서 관련 동작이 일어날 때 연관된 객체를 함께 처리해준다.
예를 들어 Post(1)와 Reply(N: 관계 소유)의 관계에서 Post에 cascade=PERSIST를 주면 Post 저장 시 Reply까지 같이 저장된다. Reply를 저장할 때는 Post를 저장하지 않는다. 반면에 Reply에 cascade=PERSIST를 주면 Reply를 저장할 때 Post까지 함께 저장되고 Post를 저장할 때는 Reply가 저장되지 않는다.
cascade 옵션의 활용과 주의점
cascade 선언
어떤 엔티티에게 어떤 속성을 적용할 것인가는 역시 관계의 성격에 따라 달라진다. Post를 등록할 때 Attachment는 함께 등록되지만 Reply는 함께 등록되지 않는다. 하지만 Post가 삭제될 때 Attachment, Reply는 모두 삭제된다.
Post 엔티티에 cascade 옵션을 적용해보자.
@Entity
public class Post extends BaseEnttiy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long pno;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
@Builder.Default
@ToString.Exclude
private List<Reply> replies = new ArrayList<>();
@OneToOne(mappedBy="post",fetch=FetchType.EAGER,cascade={CascadeType.PERSIST, CascadeType.REMOVE})
@ToString.Exclude
private Attachment attachment;
}
동작 확인
이제 앞서 진행했던 테스트 코드는 아래와 같이 변경될 수 있다.
@Test
@DisplayName("cascade 옵션이 없을 때에는 주엔티티를 먼저 저장한 후 보조 엔티티를 저장해야 함")
public void insertTest() {
Attachment attachment = Attachment.builder().file("somefile").build();
Post post = Post.builder().title("title").content("content").build();
post.setAttachment(attachment);
Member member = mrepo.findById(1L).get();
member.addPost(post);
prepo.save(post); // 먼저 post가 자리 잡아야 불필요한 쿼리가 덜 실행됨
//arepo.save(attachment);
prepo.flush(); // 동일 T.X의 모든 변경사항 DB에 반영(영속성 ctx는 T.X 단위로 관리됨)
}
로그를 살펴보면 post와 attachment가 순서대로 잘 저장되는 것을 볼 수 있다.
Hibernate:
insert into post (content, create_time, title, writer, pno) values (?, ?, ?, ?, default)
Hibernate:
insert into attachment (create_time, file, pno, ano) values (?, ?, ?, default)
@Test
public void deleteTest() {
Post post = prepo.findById(1L).get();
//List<Reply> replies = post.getReplies();
//Attachment attachment = post.getAttachment();
//rrepo.deleteAll(replies); // Iterable 요소 삭제
//arepo.delete(attachment);
prepo.delete(post);
prepo.flush();
}
delete 역시 깔금하다.
Hibernate:
delete from attachment where ano=?
Hibernate:
delete from reply where rno=?
11:52:57 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [1]
Hibernate:
delete from reply where rno=?
11:52:57 [TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:BIGINT) <- [2]
Hibernate:
delete from post where pno=?
주의사항
위에서 사용해본 것처럼 cascade를 사용하면 연관 엔티티의 상태를 별도의 코드 없이 처리해주기 때문에 코드가 간결해지고 데이터 간의 일관성 유지가 용이해진다. 하지만 비즈니스 로직의 이해 없이 잘못 사용한 cascade 옵션은 예상치 못한 데이터의 손실을 일으킬 수도 있다.
예를 들어 게시글(1)과 댓글(N)의 관계와 유사하게 부서와 부서원의 관계를 생각해보자. 게시글에 cascade 옵션을 주고 게시글을 삭제하면 연관된 댓글들을 모두 삭제해줘서 매우 편리하다.
동일한 형태로 부서에 cascade 옵션을 주고 부서를 지우면 모든 직원은 바로 해고되버린다. 이것은 편리함을 뛰어넘는 참사다. 만약 cascade 옵션이 없이 부서를 지웠다면 참조하는 객체가 있기 때문에 오류가 발생하고 실수 없이 다른 부서로 배치했을 것이다.
이처럼 cascade는 잘 쓰면 매우 편리하지만 매우 강력하기 때문에 사용 시 주의가 필요하다.
고아 엔티티 제거
고아(Orphan) 엔티티란?
고아 엔티티란 부모/자식의 관계에서 부모가 제거되고 자식만 남은 엔티티를 의미한다. 아래 그림에서 Reply 2는 원래 Post_1에 속해있었으나 setPost(null)을 통해 더 이상 Post_1에 속하지 않게 되었다.
이런 고아 엔티티를 쉽게 처리하기 위해 관계에서 1쪽에 사용하는 @OneToOne, @OneToMany에는 orphanRemoval 속성(default false)이 제공된다. 부모 입장에서 자식을 어떻게 대할지 결정한다고 생각하면 쉽다. orphanRemoval은 얼핏 생각하면 CascadeType.REMOVE와 유사하게 생각할 수 있으나 둘의 동작은 상황에 따라 다르다.
CascadeType.REMOVE | orphanRemoval=true | |
부모 엔티티 삭제 시 | 모두 삭제됨 | 모두 삭제됨 |
관계 제거 시( 부모.set자식(null)) | 삭제 없음(attachment update) | 고아 객체 삭제(attachment delete) |
적용 및 테스트
orphanRemoval 속성을 사용하려면 CascadeType.PERSIST(CascadeType.ALL 포함)가 함께 선언되어 있어야 한다. 고아 Entity 에 대한 자동 삭제 코드를 적용해보자. Post의 Attachment에 대한 관계 설정을 다음과 같이 변경해보자.
@Entity
public class Post extends BaseEnttiy {
...
@OneToOne(mappedBy = "post", fetch = FetchType.EAGER,
cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
@ToString.Exclude
private Attachment attachment;
}
그리고 다음과 같이 테스트 해보자.
@Test
public void orphanTest() {
Post post = prepo.findById(1L).get();
Attachment attachment = post.getAttachment();
post.setAttachment(null);// 부모가 자식과의 관계를 끊는다.
prepo.flush();
Assertions.assertTrue(arepo.findById(attachment.getAno()).isEmpty());
}
주의사항
Reply는 특정 글에 대한 댓글이라 어쩌면 부모가 없이 존재한다는게 말이 안된다. 따라서 부모를 잃는 순간 삭제된다고해서 문제될 것은 없어보인다. 하지만 Dept에서 Employee는 상황이 다르다.
단순히 부서 이동을 위해 대기발령된 직원을 바로 해고 해버리는 것은 옳지 않다. 따라서 정말로 쉽게 삭제해도 되는 데이터인지 잘 고민해보고 사용해야 하는 옵션이다.
일반적으로
데이터는 실무에서 엄청나게 소중하다. 특히나 어떤 데이터가 CascadeType.REMOVE나 orphanRemoval=true 속성에 의해 쥐도 새도 모르게 삭제되버리는 상황을 바라는 시스템은 아마 없을 것이다.
일반적으로 CascadeType.PERSIST와 CascadeType.MERGE의 조합은 상대적으로 안전하고 흔히 사용되는 방식이다. 이를 통해 저장과 수정 작업에 대해 모두 Cascade 처리할 수 있다.
CascadeType.REMOVE는 상대적으로 위험하므로 조금 복잡할 수 있지만 수동으로 삭제하는 것을 권장한다.
'Spring Model > 03. 연관관계 처리' 카테고리의 다른 글
05. 연관 관계 관리 - fetch 속성 (0) | 2020.06.05 |
---|---|
04. 연관 관계 - M:N 관계 (0) | 2020.06.04 |
03. 연관 관계 - 1:1 관계 (0) | 2020.06.04 |
02. 연관 관계 - N:1 양방향 관계 처리 (0) | 2020.06.04 |
01. 연관 관계 - N:1 단방향 관계 처리 (0) | 2020.06.03 |
소중한 공감 감사합니다