개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › JPA › 연관관계

영속성 전이와 고아 객체

부모만 저장하거나 지우면 자식은 어떻게 되나

게시글(부모) 하나에 댓글(자식) 둘이 달려 있습니다. 게시글만 persist하거나 remove했을 때 댓글까지 함께 저장되고 지워지는지는 @OneToMany에 적는 cascade와 orphanRemoval이 정합니다.

실행으로 대조 · Hibernate 7.4.5와 H2에서 스물네 가지 조합을 실행해 SQL의 수와 순서, 예외, DB에 남은 행을 대조

이 실습에 나오는 개념

영속성 전이(cascade)
부모 엔티티에 한 작업을 연관된 자식 엔티티에게도 똑같이 전달하는 설정이다. @OneToMany나 @OneToOne 같은 연관관계 애너테이션의 cascade 속성에 적는다. CascadeType.PERSIST는 저장을, REMOVE는 삭제를 전달하고, ALL은 모든 작업을 전달한다. 설정이 없으면 부모만 persist해도 자식은 저장되지 않고, 자식이 남아 있는 부모를 remove하면 외래 키 제약 때문에 실패한다. 연관관계를 맺어 주는 기능이 아니라 작업을 전달해 주는 편의 기능이며, 자식을 그 부모 하나만 소유할 때 쓴다.
고아 객체 제거(orphanRemoval)
부모 엔티티와 연결이 끊긴 자식 엔티티를 DB에서도 지우는 설정이다. @OneToMany나 @OneToOne에 orphanRemoval = true로 적는다. 부모의 컬렉션에서 자식을 빼면 트랜잭션을 커밋할 때 그 자식에 대한 DELETE가 나간다. 이 설정이 없으면 컬렉션에서 빼도 DB의 행은 그대로 남는다. 부모가 지워질 때도 자식이 함께 지워지므로, CascadeType.REMOVE의 동작을 포함한다고 볼 수 있다. 다른 엔티티가 함께 참조하는 자식에는 쓰지 않는다.
영속성 컨텍스트
엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
쓰기 지연과 flush
쓰기 지연은 INSERT, UPDATE, DELETE 문을 바로 DB에 보내지 않고 모아 두었다가 한꺼번에 보내는 것이다. 단, 기본 키를 IDENTITY 전략(MySQL의 AUTO_INCREMENT 등)으로 생성하는 엔티티는 식별자를 얻기 위해 저장을 요청한 시점에 INSERT가 바로 실행된다. flush는 모아 둔 변경 내용을 DB에 보내는 동작으로, 트랜잭션 커밋 직전이나 JPQL 쿼리 실행 직전에 자동으로 일어난다. flush는 SQL을 DB에 전송할 뿐 트랜잭션을 커밋하는 것은 아니다.
엔티티
데이터베이스 테이블의 행에 대응하는 자바 객체다. @Entity를 붙인 클래스로 정의하며 식별자(@Id)를 가진다. JPA는 엔티티의 상태 변화를 추적해 그에 맞는 SQL을 실행한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. new Post, addComment 두 번: 게시글 객체 하나와 댓글 객체 둘을 만들고 서로 연결했습니다. 아직 JPA가 모르는 보통의 자바 객체입니다(비영속). DB에는 아무것도 없습니다.
  2. em.persist(post): 게시글이 영속성 컨텍스트의 관리를 받게 됩니다. 기본 키가 IDENTITY 전략이라 id를 받아 오려고 INSERT가 바로 나갑니다.
  3. cascade에 PERSIST가 없으므로 persist는 게시글에서 멈춥니다. 목록에 담겨 있는 댓글 둘은 여전히 JPA가 모르는 비영속 객체입니다.
  4. 메서드 종료, 커밋: 예외 없이 커밋됩니다. comments는 mappedBy가 붙은 쪽이라 외래 키를 관리하지 않으므로, Hibernate는 이 목록에 저장되지 않은 객체가 들어 있어도 문제 삼지 않습니다. 댓글 INSERT는 끝내 나가지 않았습니다.

실행 결과

코드

@Entity
class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    String title;

    @OneToMany(mappedBy = "post")
    List<Comment> comments = new ArrayList<>();

    void addComment(String text) {     // 양쪽을 함께 맞춘다
        Comment c = new Comment(text);
        c.post = this;
        comments.add(c);
    }
}

@Entity
class Comment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    String text;

    @ManyToOne(fetch = FetchType.LAZY)
    Post post;                         // 외래 키 post_id
}

// 트랜잭션 안에서 실행
Post post = new Post("공지");
post.addComment("첫째 댓글");
post.addComment("둘째 댓글");
em.persist(post);                      // 게시글만 persist한다
// 메서드가 끝나면 커밋

이 모형의 전제

양방향 연관관계(@OneToMany(mappedBy)와 @ManyToOne), 기본 키는 IDENTITY 전략, 하나의 트랜잭션 안에서 실행한다고 보았습니다. 스물네 가지 조합(cascade 넷, orphanRemoval 둘, 동작 셋)을 모두 실제 Hibernate 7.4.5와 H2 데이터베이스에서 실행해, 나간 SQL의 수와 순서, 예외, 끝난 뒤 DB에 남은 행 수가 화면과 일치하는 것을 확인했습니다. 화면의 SQL 문장은 읽기 쉽게 줄인 것이며 실제 문장의 글자와는 다릅니다. "cascade 없이 부모만 persist하면 예외 없이 자식이 빠진다"는 결과는 댓글 목록에 mappedBy가 붙은 이 매핑에서의 결과입니다. 목록 쪽이 외래 키를 직접 관리하는 단방향 @OneToMany(@JoinColumn)로 바꿔 같은 코드를 실행하면 커밋할 때 TransientPropertyValueException이 나고 게시글도 저장되지 않는 것을 같은 환경에서 확인했습니다. 반대로 저장되지 않은 게시글을 가리키는 댓글만 persist하는 경우는 이 실습에서 다루지 않았습니다. 기본 키가 SEQUENCE 전략이면 INSERT가 persist를 부를 때가 아니라 커밋할 때 나갑니다(이 부분은 "영속성 컨텍스트" 실습에서 다룹니다). DB의 외래 키에 ON DELETE CASCADE를 걸어 두는 방법은 JPA의 cascade와 다른 기능이며 여기서는 다루지 않았습니다.

앞 실습: 상속 관계 매핑 · 다음 실습: 프록시와 지연 로딩

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요