JPA 에 대한 학습을 진행하던 중, @OneToMany 의 cascade 속성에 대한 복습을 진행 중이었다.
보통 @OneToMany 에서 주로 사용되는 속성 연계는 @OneToMany(mappedBy = "fieldName", cascade = CascadeType.??, orphanRemoval = true) 이다.
데이터 삭제 시 이후에 객체의 양방향 연관관계를 끊어주면, DB 에서 부모와 자식들의 삭제를 외래키 제약 조건 위반 없이 제대로 제거할 수 있다.
근데 여기서 궁금증이 하나 생겼다. 예를들어 Member와 Team 엔티티가 있을 경우, Team을 삭제한다고 Member도 삭제해버릴 수는 없을 것이다. Member의 Team 참조를 NULL 로 변경하던가 하는 식으로 진행해야 한다.
이러한 경우는 cascade 속성으로 처리할 수 없다. cascade 는 사실상 부모에 따른 자식의 CRUD 처리일 뿐, 자식의 FK 변경에 대한 관여는 없다. 즉, 자식의 생명주기가 부모에 종속될 때만 사용해야 하는 속성이다.
그렇다면 어떻게 해야 할까? 가장 간단한 방법은 @OneToMany 에서 별다른 속성 사용 없이 비즈니스 로직에서 member.setTeam(null) 과 Team 삭제 로직을 혼합해서 사용하는 것이다.
그런데 방법을 고민하며 찾아보던 중, Hibernate 에 흥미로운 기능이 있었다. @OnDelete 라는 어노테이션이었다. 이 어노테이션을 사용하면 외래키 제약 조건 기능을 활용하며, 부모, 자식 삭제 관련 여러 가지 작업을 아주 편리하게 할 수 있었다.
처음보는 신기한 기능이기에 공부 내용을 잘 정리해서 남겨놓기로 결정했다.
@OnDeleteHibernate 에서 제공하는 어노테이션으로, 엔티티 삭제 시 연관된 엔티티에 어떤 동작을 수행할지 DB 레벨에서 정의DDL 에서 직접 정의하거나, ddl-auto 옵션을 사용하는 경우 Hibernate 가 자동 설정하며, 이 경우 1번만 실행JPA 표준이 아닌 Hibernate 구현체의 기술DB 의 참조 무결성을 반드시 유지@ManyToOne 어노테이션과 함께 사용하여, 연관 필드를 FK 를 가진 주인 쪽에서 어떻게 처리할지 세팅 가능DB 에서 반영하므로, 영속성 컨텍스트에는 바로 반영되지 않음DB 상태 불일치 가능@OnDelete 어노테이션의 기본값DB 에서 동일하게 동작DB 별 미묘한 차이는 존재ON DELETE NO ACTION 또는 ON DELETE RESTRICT@OneToMany(cascade = CascadeType.REMOVE) 를 지정해주지 않아도, DB 레벨에서 외래키 제약 조건 위반 없이 부모, 자식을 모두 삭제cascade 속성과는 별개로 동작CascadeType.REMOVE : JPA 가 DELETE 쿼리를 실행하며, 쓰기 지연 버퍼에는 자식 -> 부모 순으로 쿼리 등록ON DELETE CASCADEFK 값을 NULL 로 설정nullable 제약 조건이 true 여야 사용 가능ON DELETE SET NULLFK 값을 기본값으로 설정MySQL 에서는 해당 옵션 사용 불가능ON DELETE SET DEFAULT@OnDelete 기능은 DB 에서 직접 데이터 삭제를 조작cascade 속성 사용과 비교했을 때, 쿼리 횟수 압도적 감소⚒️ 실습을 통해 알아보자
@Entity(name = "newMember")
public class Member {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Team team;
public void setTeam(Team team) {
this.team = team;
}
}
@Entity
public class Team {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
Member 와 Team 은 N:1(다대일) 관계public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team team = new Team();
em.persist(team);
Member member = new Member();
member.setTeam(team);
Member member2 = new Member();
member2.setTeam(team);
em.persist(member);
em.persist(member2);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
em = emf.createEntityManager();
tx = em.getTransaction();
tx.begin();
try {
Team team = em.find(Team.class, 1L);
em.remove(team);
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
Team 을 1개 생성 후 DB 반영Member 를 2개 생성 후 team 과 매핑 후 DB 반영Team 데이터 조회Team 삭제
SELECT 와 1번의 DELETETeam 삭제 중 예외 발생Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referential integrity constraint violation: "FKA84Y4NUL9OTEXFOY22AFBG081: PUBLIC.NEWMEMBER FOREIGN KEY(TEAM_ID) REFERENCES PUBLIC.TEAM(ID) (CAST(1 AS BIGINT))"; SQL statement:
Member 와 1개의 Team 모두 삭제되지 않음@Entity
public class Team {
...
// cascade 속성 적용
@OneToMany(mappedBy = "team", cascade = CascadeType.REMOVE)
private List<Member> members = new ArrayList<>();
}
메서드 실행 결과

2번의 SELECT 와 3번의 DELETE
Team 에 해당된 Member 를 찾기 위한 1번의 추가 SELECT 발생Member 를 삭제하는 DELETE 쿼리 추가 발생Member (자식) -> Team (부모) 순서로 DELETE 쿼리 발생JPA 는 총 5번의 쿼리 요청
데이터베이스 데이터 확인

문제 없이 모두 정상 삭제
@OnDelete(action = OnDeleteAction.CASCADE) 만 사용@Entity(name = "newMember")
public class Member {
...
// @OnDelete 와 CASCADE 옵션 사용
@ManyToOne
@OnDelete(action = OnDeleteAction.CASCADE)
private Team team;
...
}
메서드 실행 결과

1번의 SELECT 와 1번의 DELETE
Team 객체만 조회JPA 는 이 Team 의 DELETE 쿼리 1개만 DB 로 요청JPA 는 총 2번의 쿼리 요청
데이터베이스 데이터 확인

문제 없이 모두 정상 삭제
NULL 지정@Entity
public class Team {
...
// mappedBy 를 제외한 속성 제거
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// Getter 추가
public List<Member> getMembers() {
return this.members;
}
}
...
Team team = em.find(Team.class, 1L);
// Member의 Team을 NULL로 변경
for (Member member : team.getMembers()) {
member.setTeam(null);
}
em.remove(team);
tx.commit();
...
main() 메서드의 2번째 트랜잭션에서 각 Member 의 Team 을 NULL 로 변경
메서드 실행 결과

2번의 SELECT , 2번의 UPDATE , 1번의 DELETE
Team 의 Member 조회를 위한 1번의 추가 SELECT 발생Member 의 FK를 NULL 로 지정하기 위한 2번의 추가 UPDATE 발생JPA 는 총 5번의 쿼리 요청
데이터베이스 데이터 확인

Team 삭제 성공Member 의 FK를 NULL 로 변경 성공@OnDelete(action = OnDeleteAction.SET_NULL) 사용@Entity(name = "newMember")
public class Member {
...
// @OnDelete 와 SET_NULL 옵션 사용
@ManyToOne
@OnDelete(action = OnDeleteAction.SET_NULL)
private Team team;
...
}
Team team = em.find(Team.class, 1L);
// setNull 제거
em.remove(team);
tx.commit();
최초 메인 메서드로 롤백
메서드 실행 결과

1번의 SELECT 와 1번의 DELETE
Team 객체만 조회JPA 는 이 Team 의 DELETE 쿼리 1개만 DB 로 요청JPA 는 총 2번의 쿼리 요청
데이터베이스 데이터 확인

Team 삭제 성공Member 의 FK를 NULL 로 변경 성공💡
@OnDelete와cascade속성을 함께 사용한다면?
@OneToMany(cascade = CascadeType.REMOVE) 사용했을 때 만큼의 쿼리 실행@OnDelete 의 이점인 쿼리 최소화를 전혀 살릴 수 없음| 삭제 방식 | CascadeType.REMOVE | @OnDelete |
|---|---|---|
| 삭제 주체 | JPA | DB |
| 쿼리 발생 | SELECT (부모) + SELECT (자식) + DELETE (자식 수) + DELETE (부모) | SELECT (부모) + DELETE (부모) |
| 쿼리 발생 횟수 | 실습에서 총 5회 | 실습에서 총 2회 |
| 성능 | JPA가 추가적인 쿼리를 요청하여 네트워크 통신 비용 추가 발생 | DB 내부에서 한 번의 쿼리로 처리 |
| 스펙 | JPA 표준 기능 | Hibernate 확장 기능 |
em.find() 사용으로 부모 SELECT 1건 추가 발생em.getReferene() 로 프록시를 사용하면 삭제할 FK ID 를 알고 있으므로, 부모 조회 쿼리 절약 가능VS 1회@OnDelete 🆚 Cascade@OnDelete 의 문제점1. 비즈니스 로직 추가 수행의 어려움
@OnDelete 는 모든 삭제 로직을 DB 내부에서 수행하도록 위임delete 호출 만으로 간편하게 연관된 모든 데이터 삭제 가능@OneToMany + cascade 속성 방식은 연관관계 제거 메서드가 필수DB 의 일관성 유지 가능@OnDelete 방식은 영속성 컨텍스트가 제거 상태를 반영하지 않으므로, 이후 작업에서 DB 와의 일관성에서 문제 발생 가능2. JPA 표준이 아님
@OnDelete 는 Hibernate 구현체의 확장 기능JPA 구현체를 변경하게 되면, 해당 코드 변경 필요JPA 를 사용한 스프링 개발 시 Hibernate 를 사용하기는 함@OnDelete + EntityManager.flush() 사용delete 메서드 호출 후, 영속성 컨텍스트와 DB 의 일관성 유지를 위한 em.flush() 호출DB 의 일관성이 유지flush() 호출이 필요하다는 단점 존재@OnDelete 🆚 Cascade1️⃣ @OnDelete
JPA 가 부모 삭제 쿼리만 호출2️⃣ Cascade
🎉
OnDelete()승리
DB 부하 고려DB 부하 감소OnDelete() 가 더 좋은 성능 발휘1️⃣ @OnDelete
JPA 는 부모 삭제 쿼리 만 JDBC API에 전달@OnDelete 는 DB 에서 자식을 한 번에 삭제2️⃣ Cascade
JPA 는 자식 삭제 쿼리를 모두 전달try-catch 나 반복문 등을 활용하여, 삭제되는 객체 대상 추가적인 작업 가능🎉
@OneToMany(cascade)승리
@Transactional 과 함께 사용하면, 양쪽 다 롤백 관련 문제는 발생하지 않음1️⃣ @OnDelete
delete 호출마다 em.flush() 필수em.flush() 호출이 반드시 필요하므로, 코드의 복잡성 및 실수 확률 증가2️⃣ Cascade
@OnDelete 방식에 비해 낮은 실수 확률EntityManager 호출이 필요 없으므로, 비교적 더 간결한 복잡성🎉
@OneToMany(cascade)승리
@OneToMany 가 더 낫다고 생각@OnDelete 를 사용@OneToMany(cascade = CascadeType.REMOVE) 사용이 더 유리‼️ 웬만해서
@OneToMany(cascade = CascadeType.REMOVE)를 사용하자
검수)
- Google Gemini ( https://gemini.google.com/app )
- ChatGPT ( https://chatgpt.com/ )