소개
JPA를 사용하여 데이터베이스와 객체를 연결할 때, 부모-자식 1:N 관계에서 자식 엔티티가 부모 엔티티와의 관계에서 벗어났을 때 어떻게 처리할지를 결정하는 orphanRemoval 속성의 사용법에 대해 설명하는 글입니다.
orphanRemoval의 true와 false 설정에 따른 동작 차이를 알아보고, 이를 사용방법을 설명하겠습니다.
도메인 모델 정의
하나의 게시글(Board)에 대해 국가별로 보기 권한(BoardCountry)을 설정할 수 있다고 가정해보겠습니다.
이 예제에서는 Board 엔티티가 특정 국가에서만 볼 수 있는 게시글을 나타내며, BoardCountry 엔티티가 그 국가 정보를 담고 있습니다.
Board 엔티티
@Getter
@Entity
@Builder
@AllArgsConstructor
@Table(name = "tb_board")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Comment("게시판 ID")
@Column(name="board_id", columnDefinition = "bigint")
private Long boardId;
@Comment("게시판 제목")
@Column(name="board_title", columnDefinition = "varchar(255)")
private String boardTitle;
@Comment("게시판 내용")
@Column(name="board_content", columnDefinition = "text")
private String boardContent;
@Comment("게시판 구분")
@Enumerated(EnumType.STRING)
@Column(name="board_type", columnDefinition = "varchar(20)")
private BoardType boardType;
@Builder.Default
@OneToMany(mappedBy = "board", cascade = {CascadeType.PERSIST}, orphanRemoval = true)
private Set<BoardCountry> boardCountries = new HashSet<>();
public void addCountries(CountryCode...countryCodes) {
Set<BoardCountry> boardCountrySet = createBoardCountries(countryCodes);
boardCountries.addAll(boardCountrySet);
}
public void changeCountries(CountryCode ...countryCodes) {
Set<BoardCountry> boardCountrySet = createBoardCountries(countryCodes);
this.boardCountries = boardCountrySet;
}
private Set<BoardCountry> createBoardCountries(CountryCode[] countryCodes) {
Set<BoardCountry> boardCountrySet = new HashSet<>();
for (CountryCode countryCode : countryCodes) {
boardCountrySet.add(BoardCountry.builder()
.board(this)
.countryCode(countryCode)
.build());
}
return boardCountrySet;
}
}
Board 엔티티는 게시글을 나타내며, 여러 국가별로 접근 권한을 관리하는 BoardCountry 엔티티와 일대다 관계를 가집니다.
이 엔티티에서 주목할 메서드는 다음과 같습니다.
- addCountries(CountryCode... countryCodes)
- 입력받은 국가 코드를 BoardCountry로 변환하여 현재 컬렉션에 추가합니다.
- changeCountries(CountryCode... countryCodes)
- 입력받은 국가 코드로 새로운 BoardCountry 컬렉션을 생성하고, 기존 컬렉션을 대체합니다.
이 두 메서드는 각각 컬렉션을 관리하는 방식이 다르며, orphanRemoval 속성의 동작을 테스트할 때 중요한 역할을 합니다.
BoardCountry 엔티티
@Getter
@Entity
@Builder
@AllArgsConstructor
@Table(name = "tb_board_country")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardCountry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_country_id")
private long boardCountryId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
@Comment("국가코드")
@Enumerated(EnumType.STRING)
@Column(name="country_code", columnDefinition = "varchar(10)")
private CountryCode countryCode;
}
BoardCountry 엔티티는 게시글과 국가 정보를 연결하는 역할을 하며, Board와 다대일(N:1) 관계를 가집니다.
Enum (BoardType, CountryCode)
@Getter
public enum BoardType {
NOTICE, // 공지사항
ETC; // 기타
}
// --
@Getter
@RequiredArgsConstructor
public enum CountryCode {
ALL("전체"),
KOR("대한민국"),
JPN("일본"),
CHN("중국"),
USA("미국"),
MNG("몽골"),
TWN("대만"),
THA("태국"),
VNM("베트남"),
AUS("호주"),
NZL("뉴질랜드"),
GBR("영국"),
FRA("프랑스"),
ITA("이탈리아"),
DEU("독일"),
EGY("이집트"),
BRA("브라질"),
MEX("멕시코"),
CAN("캐나다"),
RUS("러시아"),
IND("인도"),
MMR("미얀마");
private final String desc;
}
BoardType은 게시판의 종류를 나타내며, CountryCode는 국가 코드를 관리합니다.
BoardRepository
public interface BoardRepository extends JpaRepository<Board, Long> {}
이 예제에서 Board는 하나의 게시글을 나타내며, 여러 개의 BoardCountry와 One-to-Many 관계를 가집니다.
BoardCountry는 게시글이 어느 국가에서 접근 가능한지를 정의합니다.
orphanRemoval 속성의 역할
JPA에서 orphanRemoval 속성은 부모 엔티티와의 관계가 끊긴 자식 엔티티를 어떻게 처리할지 결정합니다.
- orphanRemoval = true로 설정하면 부모 엔티티와의 연관 관계가 끊어진 자식 엔티티는 자동으로 삭제됩니다.
- orphanRemoval = false로 설정하면 자식 엔티티는 그대로 남아있으며, 직접 삭제 명령을 내리지 않는 한 데이터베이스에서 삭제되지 않습니다.
orphanRemoval = false 설정 테스트
먼저, orphanRemoval을 false로 설정하고, 자식 엔티티(BoardCountry)가 부모 엔티티(Board)와의 관계가 끊어졌을 때 어떤 일이 일어나는지 살펴보겠습니다.
@Builder.Default
@OneToMany(mappedBy = "board", cascade = {CascadeType.PERSIST}, orphanRemoval = false)
private Set<BoardCountry> boardCountries = new HashSet<>();
flase가 default 설정이지만 예제를 위해 작성하겠습니다.
결론부터 말하자면 위의 orphanRemoval = false 설정은 자식 엔티티가 부모와의 관계가 끊어지더라도, 자식 엔티티는 데이터베이스에 그대로 남게 됩니다.
이제 테스트를 통해 눈으로 직접 확인해보겠습니다.
첫 번째 테스트 - orphanRemoval = false
@SpringBootTest
@Transactional
class OrphanRemovalTest {
@Autowired
private BoardRepository boardRepository;
@Autowired
private EntityManager em;
@DisplayName("orphanRemoval = false")
@Test
void orphanRemovalFalse() {
// given
Board board = Board.builder()
.boardTitle("공지사항")
.boardContent("공지사항입니다.")
.boardType(BoardType.NOTICE)
.build();
boardRepository.save(board);
// when
board.addCountries(CountryCode.AUS, CountryCode.KOR);
em.flush();
board.changeCountries(CountryCode.CHN, CountryCode.JPN);
em.flush();
// then
Board noticeBoard = boardRepository.findById(board.getBoardId())
.orElseThrow();
noticeBoard.getBoardCountries().stream()
.map(BoardCountry::getCountryCode)
.forEach(System.out::println);
}
}
- Board 객체를 만들고 저장한 뒤, addCountries 메서드를 사용해 국가 두 개를 추가합니다.
- changeCountries 메서드를 통해 추가된 국가를 교체합니다. 이 경우, 기존의 국가 정보가 삭제되지 않고 남아 있는 것을 확인할 수 있습니다.
조회 결과
CHN
AUS
KOR
JPN
- 기존의 AUS와 KOR 국가 정보가 여전히 컬렉션에 남아 있는 것을 확인할 수 있습니다.
- orphanRemoval = false 설정으로 인해 자식 엔티티가 자동으로 삭제되지 않기 때문입니다.
쿼리 결과
Hibernate:
insert
into
tb_board
(board_content, board_title, board_type, board_id)
values
(?, ?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
select
b1_0.board_id,
b1_0.board_content,
b1_0.board_title,
b1_0.board_type
from
tb_board b1_0
where
b1_0.board_id=?
Hibernate:
select
bc1_0.board_id,
bc1_0.board_country_id,
bc1_0.country_code
from
tb_board_country bc1_0
where
bc1_0.board_id=?
쿼리 로그를 보면 insert 쿼리들이 실행되지만, delete나 update 쿼리는 발생하지 않음을 확인할 수 있습니다.
이는 기존의 BoardCountry 엔티티 값이 DB에 그대로 남아 있는것을 의미합니다.
orphanRemoval = true 설정 테스트
이번에는 orphanRemoval을 true로 설정하고, 자식 엔티티가 부모와의 관계가 끊어졌을 때 자동으로 삭제되는지 확인해 보겠습니다.
먼저 @OneToMany의 orphanRemoval = false 였던 옵션을 orphanRemoval = true로 바꿔줍니다.
// Board
@Builder.Default
@OneToMany(mappedBy = "board", cascade = {CascadeType.PERSIST}, orphanRemoval = true)
private Set<BoardCountry> boardCountries = new HashSet();
위와같이 orphanRemoval = true로 하면 참조가 풀려버린 고아 객체가 자동으로 제거될까요??
두 번째 테스트 - orphanRemoval = true
테스트 메서드의 내용은 첫 번째 테스트와 동일합니다.
@SpringBootTest
@Transactional
class OrphanRemovalTest {
@Autowired
private BoardRepository boardRepository;
@Autowired
private EntityManager em;
@DisplayName("orphanRemoval = true")
@Test
void orphanRemovalTrueV1() {
// given
Board board = Board.builder()
.boardTitle("공지사항")
.boardContent("공지사항입니다.")
.boardType(BoardType.NOTICE)
.build();
boardRepository.save(board);
// when
board.addCountries(CountryCode.AUS, CountryCode.KOR);
em.flush();
board.changeCountries(CountryCode.CHN, CountryCode.JPN);
em.flush(); // 여기서 에러 발생
// ...생략
}
}
- 첫 번째 테스트와 마찬가지로 board.changeCountries(CountryCode.CHN, CountryCode.JPN) 메서드가 실행되며 기존 컬렉션의 참조가 해제됩니다.
- orphanRemoval = true 설정으로 인해 기존의 BoardCountry 엔티티가 자동으로 삭제되어야 할 것 같지만, 아래와 같은 오류가 발생합니다.
org.hibernate.HibernateException: A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: com.example.board.domain.Board.boardCountries
위의 오류는 orphanRemoval = true 설정이 있는 컬렉션이 소유 엔티티에서 분리되거나 새로 교체될 때 발생합니다. Hibernate가 관리하는 엔티티 컬렉션(여기서는 boardCountries)이 엔티티 인스턴스에서 더 이상 참조되지 않게 되었음을 감지했기 때문입니다.
요약하자면 Hibernate가 예상한 방식으로 컬렉션을 관리할 수 없을 때 발생하는 오류입니다.
문제 해결 - 컬렉션 clear 후 추가
이 문제를 해결하려면 컬렉션을 완전히 새로 교체하는 대신, 기존 컬렉션을 비운 다음 새 요소를 추가하는 방식으로 수정할 수 있습니다.
Board 엔티티에 새로운 메서드를 추가합니다.
추가된 메서드 - removeAndChangeCountries(CountryCode ...countryCodes)
// Board
public void removeAndChangeCountries(CountryCode ...countryCodes) {
this.boardCountries.clear();
Set<BoardCountry> boardCountrySet = createBoardCountries(countryCodes);
this.boardCountries.addAll(boardCountrySet);
}
이 메서드는 객체 참조를 변경하지 않고, 기존 컬렉션을 비운 후 새로운 국가 코드를 추가합니다.
최종 테스트 - orphanRemoval = true, 컬렉션 clear 후 추가
@SpringBootTest
@Transactional
class OrphanRemovalTest {
@Autowired
private BoardRepository boardRepository;
@Autowired
private EntityManager em;
@DisplayName("orphanRemoval = true")
@Test
void orphanRemovalTrueV2() {
// given
Board board = Board.builder()
.boardTitle("공지사항")
.boardContent("공지사항입니다.")
.boardType(BoardType.NOTICE)
.build();
boardRepository.save(board);
// when
board.addCountries(CountryCode.AUS, CountryCode.KOR);
em.flush();
board.removeAndChangeCountries(CountryCode.CHN, CountryCode.JPN);
em.flush();
em.clear();
// then
Board noticeBoard = boardRepository.findById(board.getBoardId())
.orElseThrow();
noticeBoard.getBoardCountries().stream()
.map(BoardCountry::getCountryCode)
.forEach(System.out::println);
}
}
조회 결과를 바로 확인해 보겠습니다.
조회 결과
CHN
JPN
이제 컬렉션에는 CHN과 JPN만 남아 있으며, 기존의 AUS와 KOR 국가 정보는 삭제되었습니다.
정말 삭제 쿼리가 자동으로 실행되었는지 쿼리 결과를 통해 확인해 보겠습니다.
쿼리 결과
Hibernate:
insert
into
tb_board
(board_content, board_title, board_type, board_id)
values
(?, ?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
insert
into
tb_board_country
(board_id, country_code, board_country_id)
values
(?, ?, default)
Hibernate:
delete
from
tb_board_country
where
board_country_id=?
Hibernate:
delete
from
tb_board_country
where
board_country_id=?
Hibernate:
select
b1_0.board_id,
b1_0.board_content,
b1_0.board_title,
b1_0.board_type
from
tb_board b1_0
where
b1_0.board_id=?
Hibernate:
select
bc1_0.board_id,
bc1_0.board_country_id,
bc1_0.country_code
from
tb_board_country bc1_0
where
bc1_0.board_id=?
쿼리 로그에서 delete 쿼리가 발생한 것을 확인할 수 있습니다.
removeAndChangeCountries 메서드가 기존 국가 데이터를 정상적으로 삭제하고 추가했다는 것을 보여줍니다.
마무리하며
이번 글에서는 JPA에서 orphanRemoval 속성을 활용해 부모-자식 엔티티 관계에서 자식 엔티티를 어떻게 처리할지에 대해 알아보았습니다.
- orphanRemoval = false 설정 시, 부모와의 관계가 끊어진 자식 엔티티는 데이터베이스에 그대로 남아 있게 됩니다.
- orphanRemoval = true 설정 시, 부모와의 관계가 끊어진 자식 엔티티는 자동으로 삭제됩니다. 하지만 컬렉션을 직접 교체하는 방식은 오류가 발생할 수 있으므로, 기존 컬렉션을 비우고 새 요소를 추가하는 방법이 안전합니다.