JPA cascade

JPA는 부모에서 자식에게 영속성을 전이하는 기능을 제공한다.
JPA를 잘 모를 때에는 이 말이 무슨 말인지 하나도 이해가 안갔다.
초보자의 입장에서 그냥 간단히 말하자면 부모가 변경 될 때 해당 부모의 자식의 상태도 변경되게 하는 것이다.

예를 들자면
게시물에 댓글들이 달려있다. 만약 게시물을 삭제하면 댓글들도 같이 삭제되도록 하고 싶은 것이다.

이를 위해 @OneToMany, @ManyToOne과 같은 매핑 어노테이션의 cascade를 통해 이를 지정할 수 있고
javax.persistence.CascadeType에 여러 cascade type이 정의되어 있다.

FetchType
  • ALL
    cascades all entity state transitions

  • PERSIST
    cascades the entity persist operation.

  • MERGE
    cascades the entity merge operation.

  • REMOVE
    cascades the entity remove operation.

  • REFRESH
    cascades the entity refresh operation.

  • DETACH
    cascades the entity detach operation.

출처 - http://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#pc-cascade

쉽고 단순하게 부모에서 해당 FetchType이 실행될 때 자식에게 영속성 전이를 한다고 생각할 수 있다.
(FetchType.REMOVE면 부모가 삭제될 때 자식도 같이 삭제됨)
(FetchType.ALL의 경우 FetchType이 모두 적용)


Spring Boot와 Spring Data JPA를 사용하여 게시물, 댓글 예제를 통해 cascade에 대해 알아보자

dependency는 아래와 같이 JPA, H2, Lombok 3개를 추가하여 Spring Boot 프로젝트를 생성하였다.

JPA를 사용하여 개발 할 때 application.properties에 아래 property들을 추가하여 쿼리를 확인할 수 있다.

1
2
3
4
5
6
# SQL 문 확인
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

# SQL paramter logging
logging.level.org.hibernate.type=trace

Board(게시물), Reply(댓글) Entity class를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import lombok.*;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Getter
@NoArgsConstructor
@Entity
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "board")
private List<Reply> replyList = new ArrayList<>();

@Builder
public Board(String title) {
this.title = title;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import lombok.*;

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
public class Reply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne
private Board board;

@Builder
public Reply(String content, Board board) {
this.content = content;
this.board = board;
}
}

ERD로 나타내면 다음과 같다.

Entity class에 매칭되는 Repository Interface를 생성한다.

Repository interface는 간단하게 Spring data JPA의 JpaRepository를 상속받아 사용한다.

1
2
3
4
5
import com.example.demo.domain.Board;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<Board, Long> {
}
1
2
3
4
5
import com.example.demo.domain.Reply;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ReplyRepository extends JpaRepository<Reply, Long> {
}

게시물에 댓글을 추가하고, 댓글에 게시물을 지정하기 위해 Board, Reply에 코드를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
@NoArgsConstructor
@Entity
public class Reply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne
private Board board;

@Builder
public Reply(String content, Board board) {
this.content = content;
this.board = board;
}

// Board setter 추가
public void setBoard(Board board) {
this.board = board;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Getter
@NoArgsConstructor
@Entity
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@OneToMany(mappedBy = "board")
private List<Reply> replyList = new ArrayList<>();

@Builder
public Board(String title) {
this.title = title;
}

// 댓글을 추가하고 댓글에 게시물을 지정하는 addReply 메서드 추가
public void addReply(Reply reply) {
this.replyList.add(reply);
reply.setBoard(this);
}
}

CommandLineRunner를 통해 아래 게시물 저장 코드를 실행한 후 로그를 확인해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}

@Bean
public CommandLineRunner commandLineRunner(BoardRepository boardRepository, ReplyRepository replyRepository) {
return args -> {
Board board = Board.builder().title("게시글").build();
Reply reply1 = Reply.builder().content("댓글1").build();
Reply reply2 = Reply.builder().content("댓글2").build();

System.out.println("====댓글 추가====");
board.addReply(reply1);
board.addReply(reply2);

System.out.println("====게시물 저장====");
boardRepository.save(board);
};
}
}
1
2
3
4
5
6
7
8
9
10
====댓글 추가====
====게시물 저장====
Hibernate:
insert
into
board
(id, title)
values
(null, ?)
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [게시글]

위 로그를 보면 게시물에 댓글 2개를 추가하고 save를 하였는데 INSERT 문은 BOARD 하나만 실행되는 것을 확인할 수 있다.
부모에 자식이 추가됐지만 자식에게 영속성 전이를 하지 않기 때문에 자식인 REAPLY에 대한 INSERT 문은 실행되지 않는 것이다.

위 코드에서 board를 저장할 때 자식인 reply를 같이 저장되게 하려면 Board의 replyList에 걸려있는 @OneToMany에 cascade = CascadeType.PERSIST 속성을 추가하면된다.

1
2
@OneToMany(mappedBy = "board", cascade = CascadeType.PERSIST)
private List<Reply> replyList = new ArrayList<>();

이제 CommandLineRunner 메서드를 다시 실행하면 다음과 같이 INSERT 문이 3개 (BOARD 1, REPLY 2) 가 로그로 출력되는 것을 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
====댓글 추가====
====게시물 저장====
Hibernate:
insert
into
board
(id, title)
values
(null, ?)
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [게시글]

Hibernate:
insert
into
reply
(id, board_id, content)
values
(null, ?, ?)
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]
o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [VARCHAR] - [댓글1]

Hibernate:
insert
into
reply
(id, board_id, content)
values
(null, ?, ?)
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]
o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [VARCHAR] - [댓글2]

그럼 삭제는 어떨까?

만약 cascade = CascadeType.PERSIST를 추가하지 않고 게시물을 저장할 때 처럼 BOARD(게시물)을 삭제하려 할 경우 BOARD만 삭제가 될 것이고
BOARD_ID가 foreign key로 걸려있는 REPLY(댓글)가 남아있기 때문에 SQL에서 오류가 날 것이다.
foreign key가 걸려있는지는 역시 쿼리 로그를 통해 확인할 수 있다.
(spring.jpa.hibernate.ddl-auto 속성의 값이 default는 create-drop 이기 때문에 해당 쿼리가 나온다.)

1
2
3
4
5
Hibernate: 
alter table reply
add constraint FKcs9hiip0bv9xxfrgoj0lwv2dt
foreign key (board_id)
references board

코드로 실제로 오류가 나는지 확인해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
Board board = Board.builder().title("게시글").build();
Reply reply1 = Reply.builder().content("댓글1").build();
Reply reply2 = Reply.builder().content("댓글2").build();

System.out.println("====댓글 추가====");
board.addReply(reply1);
board.addReply(reply2);

System.out.println("====게시물 저장====");
boardRepository.save(board);

System.out.println("====게시물 삭제====");
boardRepository.delete(board);

위 코드를 실행하면 다음과 같이 Exception 메세지를 뿜는 것을 볼 수 있다.

1
2
Caused by: org.h2.jdbc.JdbcSQLException: Referential integrity constraint violation: "FKCS9HIIP0BV9XXFRGOJ0LWV2DT: PUBLIC.REPLY FOREIGN KEY(BOARD_ID) REFERENCES PUBLIC.BOARD(ID) (1)"; SQL statement:
delete from board where id=? [23503-197]

이 경우에 앞서 게시물 저장과 마찬가지로 cascade를 지정해줘야한다.

1
2
@OneToMany(mappedBy = "board", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<Reply> replyList = new ArrayList<>();

위와 같이 CascadeType.REMOVE를 추가한 다음 코드를 재실행하면 정상적으로 실행 된 후 종료가 될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Hibernate: 
delete
from
reply
where
id=?
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]

Hibernate:
delete
from
reply
where
id=?
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [2]

Hibernate:
delete
from
board
where
id=?
o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [BIGINT] - [1]

위 DELETE 쿼리를 날리기 전 해당 BOARD가 갖고 있는 REPLY를 찾기 위해 SELECT 문을 사용할 것이다.
총 4개의 SELECT 문을 사용하는데 @OneToMany의 default fet가 FetchType.LAZY이기 때문에 쿼리를 여러개 사용한다.
이 경우 아래와 같이 @OneToMany에 fetch = FetchType.EAGER 속성을 추가하면 쿼리문이 줄어들게 된다.

1
2
@OneToMany(mappedBy = "board", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, fetch = FetchType.EAGER)
private List<Reply> replyList = new ArrayList<>();

FetchType 설정 전

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Hibernate: 
select
board0_.id as id1_0_0_,
board0_.title as title2_0_0_
from
board board0_
where
board0_.id=?

Hibernate:
select
reply0_.id as id1_1_0_,
reply0_.board_id as board_id3_1_0_,
reply0_.content as content2_1_0_,
board1_.id as id1_0_1_,
board1_.title as title2_0_1_
from
reply reply0_
left outer join
board board1_
on reply0_.board_id=board1_.id
where
reply0_.id=?

Hibernate:
select
reply0_.id as id1_1_0_,
reply0_.board_id as board_id3_1_0_,
reply0_.content as content2_1_0_,
board1_.id as id1_0_1_,
board1_.title as title2_0_1_
from
reply reply0_
left outer join
board board1_
on reply0_.board_id=board1_.id
where
reply0_.id=?

Hibernate:
select
replylist0_.board_id as board_id3_1_0_,
replylist0_.id as id1_1_0_,
replylist0_.id as id1_1_1_,
replylist0_.board_id as board_id3_1_1_,
replylist0_.content as content2_1_1_
from
reply replylist0_
where
replylist0_.board_id=?

fetch = FetchType.EAGER 설정 후

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
====게시물 삭제====
Hibernate:
select
board0_.id as id1_0_0_,
board0_.title as title2_0_0_
from
board board0_
where
board0_.id=?

Hibernate:
select
replylist0_.board_id as board_id3_1_0_,
replylist0_.id as id1_1_0_,
replylist0_.id as id1_1_1_,
replylist0_.board_id as board_id3_1_1_,
replylist0_.content as content2_1_1_
from
reply replylist0_
where
replylist0_.board_id=?

지금은 쿼리 몇개의 차이밖에 없지만 만약 게시물의 댓글이 더 많다면 n + 1 문제의 영향을 받을 수 있기 때문에 지금의 케이스에서는 FetchType을 EAGER로 설정하는게 더 효과적이다.
하지만 실제 앱개발시에는 여러 상황을 고려해야하기 때문에 테스트를 거쳐 보다 안정적이고 효과적인 fetch type을 선택해야할 것이다.


삭제가 되는 것을 보고 ON DELETE CASCADE를 사용하는 건가? 라고 생각했었지만
콘솔에서 Table CREATE 쿼리를 확인했을 때 DB에서 Table에 ON DELETE CASCADE를 적용하는 건 아닌 것 같고
위 DELETE의 예제를 봤을 때 DELETE 쿼리를 생성하기 때문에 Hibernate에서 cascade를 관리하는 것 같다.

Share