@OnDelete 와 cascade 속성

랏 뜨·2025년 8월 12일

🔎 Overview

  JPA 에 대한 학습을 진행하던 중, @OneToManycascade 속성에 대한 복습을 진행 중이었다.

  보통 @OneToMany 에서 주로 사용되는 속성 연계는 @OneToMany(mappedBy = "fieldName", cascade = CascadeType.??, orphanRemoval = true) 이다.
데이터 삭제 시 이후에 객체의 양방향 연관관계를 끊어주면, DB 에서 부모와 자식들의 삭제를 외래키 제약 조건 위반 없이 제대로 제거할 수 있다.

  근데 여기서 궁금증이 하나 생겼다. 예를들어 MemberTeam 엔티티가 있을 경우, Team을 삭제한다고 Member도 삭제해버릴 수는 없을 것이다. MemberTeam 참조를 NULL 로 변경하던가 하는 식으로 진행해야 한다.
이러한 경우는 cascade 속성으로 처리할 수 없다. cascade 는 사실상 부모에 따른 자식의 CRUD 처리일 뿐, 자식FK 변경에 대한 관여는 없다. 즉, 자식의 생명주기가 부모에 종속될 때만 사용해야 하는 속성이다.

  그렇다면 어떻게 해야 할까? 가장 간단한 방법은 @OneToMany 에서 별다른 속성 사용 없이 비즈니스 로직에서 member.setTeam(null)Team 삭제 로직을 혼합해서 사용하는 것이다.

  그런데 방법을 고민하며 찾아보던 중, Hibernate 에 흥미로운 기능이 있었다. @OnDelete 라는 어노테이션이었다. 이 어노테이션을 사용하면 외래키 제약 조건 기능을 활용하며, 부모, 자식 삭제 관련 여러 가지 작업을 아주 편리하게 할 수 있었다.

  처음보는 신기한 기능이기에 공부 내용을 잘 정리해서 남겨놓기로 결정했다.


1️⃣ @OnDelete

  • Hibernate 에서 제공하는 어노테이션으로, 엔티티 삭제 시 연관된 엔티티에 어떤 동작을 수행할지 DB 레벨에서 정의
    • DDL 에서 직접 정의하거나, ddl-auto 옵션을 사용하는 경우 Hibernate 가 자동 설정하며, 이 경우 1번만 실행
    • JPA 표준이 아닌 Hibernate 구현체의 기술
  • 외래키 제약 조건을 위반하지 않으며, DB참조 무결성을 반드시 유지
  • @ManyToOne 어노테이션과 함께 사용하여, 연관 필드를 FK 를 가진 주인 쪽에서 어떻게 처리할지 세팅 가능
  • DB 에서 반영하므로, 영속성 컨텍스트에는 바로 반영되지 않음
    • 영속성 컨텍스트DB 상태 불일치 가능

➕ 옵션

1. OnDeleteAction.NO_ACTION (OnDeleteAction.RESTRICT)

  • 부모 엔티티 삭제 시, 연관된 자식 엔티티가 존재하면 삭제 불가
  • @OnDelete 어노테이션의 기본값
  • 두 옵션이 완전히 동일한 옵션은 아니지만, 대부분 DB 에서 동일하게 동작
    • DB 별 미묘한 차이는 존재
  • DDL Constraint : ON DELETE NO ACTION 또는 ON DELETE RESTRICT

2. OnDeleteAction.CASCADE

  • 부모 엔티티 삭제 시, 자동으로 연관된 자식 엔티티 함께 삭제
  • @OneToMany(cascade = CascadeType.REMOVE) 를 지정해주지 않아도, DB 레벨에서 외래키 제약 조건 위반 없이 부모, 자식을 모두 삭제
    • cascade 속성과는 별개로 동작
    • CascadeType.REMOVE : JPADELETE 쿼리를 실행하며, 쓰기 지연 버퍼에는 자식 -> 부모 순으로 쿼리 등록
  • DDL Constraint : ON DELETE CASCADE

3. OnDeleteAction.SET_NULL

  • 부모 엔티티 삭제 시, 연관된 자식 엔티티FK 값을 NULL 로 설정
  • 반드시 해당 컬럼의 nullable 제약 조건이 true 여야 사용 가능
  • DDL Constraint : ON DELETE SET NULL

4. OnDeleteAction.DEFAULT

  • 부모 엔티티 삭제 시, 연관된 자식 엔티티FK 값을 기본값으로 설정
  • 반드시 해당 컬럼의 기본값이 미리 정의되어 있어야 가능
  • MySQL 에서는 해당 옵션 사용 불가능
  • DDL Constraint : ON DELETE SET DEFAULT


2️⃣ cascade 와의 쿼리 실행 횟수 비교

  • @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<>();
}
  • MemberTeamN: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();
}
  • 1번째 트랜잭션
    • Team1개 생성DB 반영
    • Member2개 생성team 과 매핑 후 DB 반영
  • 2번째 트랜잭션
    • 1번 Team 데이터 조회
    • 1번 Team 삭제

최초 실행 결과

  • 1번SELECT1번DELETE
  • Team 삭제 중 예외 발생
    • 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:
    • 즉, 외래키 제약 조건 위반으로 인한 데이터 삭제 불가능

  • 데이터베이스 데이터 확인
  • 2개의 Member1개의 Team 모두 삭제되지 않음

🥇 부모 삭제 시 자식 삭제 제약 조건


1️⃣ cascade 속성만 사용

@Entity
public class Team {
    ...
    
    // cascade 속성 적용
    @OneToMany(mappedBy = "team", cascade = CascadeType.REMOVE)
    private List<Member> members = new ArrayList<>();
}

  • 메서드 실행 결과

  • 2번SELECT3번DELETE

    • Team 에 해당된 Member 를 찾기 위한 1번의 추가 SELECT 발생
    • 2명의 연관된 Member 를 삭제하는 DELETE 쿼리 추가 발생
    • 이 때, Member (자식) -> Team (부모) 순서DELETE 쿼리 발생
  • JPA 는 총 5번의 쿼리 요청

  • 데이터베이스 데이터 확인

  • 문제 없이 모두 정상 삭제

2️⃣ @OnDelete(action = OnDeleteAction.CASCADE) 만 사용

@Entity(name = "newMember")
public class Member {
    ...
    
    // @OnDelete 와 CASCADE 옵션 사용
    @ManyToOne
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Team team;

	...
}

  • 메서드 실행 결과

  • 1번의 SELECT1번의 DELETE

    • 부모인 Team 객체만 조회
    • JPA 는 이 TeamDELETE 쿼리 1개만 DB 로 요청
  • JPA 는 총 2번의 쿼리 요청

  • 데이터베이스 데이터 확인

  • 문제 없이 모두 정상 삭제

🥈 부모 삭제 시 자식 FK를 NULL로 변경


1️⃣ 비즈니스 로직에서 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번째 트랜잭션에서 각 MemberTeamNULL 로 변경

  • 메서드 실행 결과

  • 2번SELECT , 2번UPDATE , 1번DELETE

    • TeamMember 조회를 위한 1번의 추가 SELECT 발생
    • MemberFKNULL 로 지정하기 위한 2번의 추가 UPDATE 발생
  • JPA 는 총 5번의 쿼리 요청

  • 데이터베이스 데이터 확인

  • Team 삭제 성공
  • MemberFKNULL 로 변경 성공

2️⃣ @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번SELECT1번DELETE

    • 부모인 Team 객체만 조회
    • JPA 는 이 TeamDELETE 쿼리 1개만 DB 로 요청
  • JPA 는 총 2번의 쿼리 요청

  • 데이터베이스 데이터 확인

  • Team 삭제 성공
  • MemberFKNULL 로 변경 성공

💡 @OnDeletecascade 속성을 함께 사용한다면?

  • @OneToMany(cascade = CascadeType.REMOVE) 사용했을 때 만큼의 쿼리 실행
    • @OnDelete 의 이점인 쿼리 최소화를 전혀 살릴 수 없음

📋 정리

삭제 방식CascadeType.REMOVE@OnDelete
삭제 주체JPADB
쿼리 발생 SELECT (부모) + SELECT (자식) + DELETE (자식 수) + DELETE (부모)SELECT (부모) + DELETE (부모)
쿼리 발생 횟수실습에서 총 5회실습에서 총 2회
성능JPA가 추가적인 쿼리를 요청하여 네트워크 통신 비용 추가 발생DB 내부에서 한 번의 쿼리로 처리
스펙JPA 표준 기능Hibernate 확장 기능
  • 실습 예제에서는 em.find() 사용으로 부모 SELECT 1건 추가 발생
  • em.getReferene()프록시를 사용하면 삭제할 FK ID 를 알고 있으므로, 부모 조회 쿼리 절약 가능
    • 이 경우, 예제의 쿼리 횟수는 4회 VS 1회

3️⃣ @OnDelete 🆚 Cascade

@OnDelete 의 문제점

1. 비즈니스 로직 추가 수행의 어려움

  • @OnDelete 는 모든 삭제 로직을 DB 내부에서 수행하도록 위임
    • 비즈니스 로직 내에서 별도의 연관관계 제거 메서드를 호출하지 않고도, 단순히 레포지터리의 메서드 delete 호출 만으로 간편하게 연관된 모든 데이터 삭제 가능
    • 트랜잭션 종료 직전에야 삭제 쿼리 실행이 가능하므로, 트랜잭션 내에서는 삭제가 즉시 반영되지는 않음
  • @OneToMany + cascade 속성 방식은 연관관계 제거 메서드가 필수
    • 이를 통해 영속성 컨텍스트최종 DB일관성 유지 가능
  • @OnDelete 방식은 영속성 컨텍스트제거 상태를 반영하지 않으므로, 이후 작업에서 DB 와의 일관성에서 문제 발생 가능

2. JPA 표준이 아님

  • @OnDeleteHibernate 구현체의 확장 기능
  • 만약 JPA 구현체를 변경하게 되면, 해당 코드 변경 필요
  • 단, 일반적으로 JPA 를 사용한 스프링 개발 시 Hibernate 를 사용하기는 함

💡 비즈니스 로직 추가 수행 방법

  • @OnDelete + EntityManager.flush() 사용
    • 레포지터리delete 메서드 호출 후, 영속성 컨텍스트DB일관성 유지를 위한 em.flush() 호출
    • 이후부터는 영속성 컨텍스트DB일관성이 유지
  • 단, 매 삭제 메서드 호출 후에 반드시 flush() 호출이 필요하다는 단점 존재
    • 코드의 복잡성 증가

🔗 @OnDelete 🆚 Cascade

1. 성능

1️⃣ @OnDelete

  • 애플리케이션 레벨에서 추가 로직 작업 필요 없음
    • 빠르고 효율적
  • JPA부모 삭제 쿼리만 호출
    • 네트워크 통신 비용 절약

2️⃣ Cascade

  • 애플리케이션 레벨에서 추가 로직 필요
    • 양방향 연관관계 제거 메서드 필요
  • 모든 자식 삭제 쿼리를 JDBC API로 요청
    • 네트워크 통신 비용 증가

🎉 OnDelete() 승리

  • 단, 자식 객체의 수에 따른 DB 부하 고려
  • 네트워크 통신 비용 절약 VS DB 부하 감소
  • 자식 객체가 엄청나게 많은 게 아니라면, OnDelete() 가 더 좋은 성능 발휘

2. 유연성 (후속 작업, 개별 작업)

1️⃣ @OnDelete

  • 삭제되는 자식 객체의 개별 작업 불가능
    • JPA부모 삭제 쿼리JDBC API에 전달
    • @OnDeleteDB 에서 자식을 한 번에 삭제
    • 삭제되는 개별 자식 객체 정보는 획득 불가능
    • 삭제되는 자식 객체 대상 추가적인 작업 불가능

2️⃣ Cascade

  • 삭제되는 자식 객체의 개별 작업 가능
    • JPA자식 삭제 쿼리를 모두 전달
    • try-catch반복문 등을 활용하여, 삭제되는 객체 대상 추가적인 작업 가능

🎉 @OneToMany(cascade) 승리

  • @Transactional 과 함께 사용하면, 양쪽 다 롤백 관련 문제는 발생하지 않음

3. 유지보수성

1️⃣ @OnDelete

  • 레포지터리delete 호출마다 em.flush() 필수
    • 영속성 컨텍스트최종 DB의 일관성 유지를 위해 반드시 필요
  • 매번 em.flush() 호출이 반드시 필요하므로, 코드의 복잡성 및 실수 확률 증가

2️⃣ Cascade

  • 양방향 연관관계 제거 메서드 필수
    • 영속성 컨텍스트최종 DB의 일관성 유지
  • 매번 연관관계 제거 메서드 호출이 반드시 필요하지만, 논리적으로 추가할 수밖에 없음
    • @OnDelete 방식에 비해 낮은 실수 확률
    • 직접적인 EntityManager 호출이 필요 없으므로, 비교적 더 간결한 복잡성

🎉 @OneToMany(cascade) 승리

  • 실수할 확률, 다른 의존관계 사용 여부 등에서 @OneToMany 가 더 낫다고 생각

📓 결론

  • 개별 작업이 전혀 필요 없고, 엔티티의 삭제만 최대한의 성능으로 처리하고 싶다면 @OnDelete 를 사용
    • 하지만 사실상, 이런 경우는 거의 없다고 봐도 무방할 것으로 예상됨
  • 그 외의 경우 @OneToMany(cascade = CascadeType.REMOVE) 사용이 더 유리

‼️ 웬만해서 @OneToMany(cascade = CascadeType.REMOVE) 를 사용하자


검수)

profile
기록

0개의 댓글