티스토리 뷰
부모 엔티티 삭제 시 자식 엔티티를 어떻게 처리할지 따로 지정해주지 않으면, 다음과 같이 DB 상에서 FK 제약 조건을 위반했다는 오류를 뱉으면서 요청 처리에 실패하게 된다.
부모 엔티티를 삭제하는 경우 자식 엔티티를 어떻게 처리할지에 대해서는 구현하고자 하는 어플케이션의 비즈니스 로직에 따라 다를 것이다. 예를 들어 게시글을 삭제하는 경우 해당 글에 포함된 댓글 역시 모두 삭제해야 하는 반면, 어느 회사 내의 팀이 해체된다면 즉 팀이 삭제되는 경우에는 해당 팀에 속한 사원들의 정보는 삭제하지 않고 해당 사원의 소속팀 값을 NULL 또는 "무소속" 등과 같은 기본값으로 수정해주어야 한다.
Spring Data JPA는 이렇게 부모 엔티티 삭제 시 자식 엔티티는 어떻게 처리할지에 대해 여러 옵션을 제공한다. 그럼 부모 엔티티 삭제 시 자식 엔티티 또한 삭제하는 경우, 자식 엔티티의 값을 수정하는 경우 크게 2가지로 나누어 살펴보자.
자식 엔티티도 함께 삭제
자식 엔티티 삭제 설명에서 사용할 예제는 다음과 같다. 게시글과 댓글은 1:N 매핑으로 두 엔티티는 양방향 연관관계를 맺고 있다. 그럼 하나의 게시글을 삭제하는 경우 어떻게 하면 효율적으로 연관관계에 있는 댓글들을 모두 삭제할 수 있는지 알아보자.
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
private String content;
@Builder.Default
@OneToMany(mappedBy = "post")
private List<Comment> comments = new ArrayList<>();
//연관관계 편의 메소드
public void addComments(List<Comment> comments) {
this.comments = comments;
for (Comment comment : comments) {
comment.updatePost(this);
}
}
}
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
public void updatePost(Post post) {
this.post = post;
}
}
public interface PostRepository extends JpaRepository<Post, Long> {
}
public interface CommentRepository extends JpaRepository<Comment, Long> {
}
cascade = CascadeType.REMOVE 설정
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
private String content;
@Builder.Default
@OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE) //추가
private List<Comment> comments = new ArrayList<>();
//연관관계 편의 메소드
public void addComments(List<Comment> comments) {
for (Comment comment : comments) {
this.comments.add(comment);
comment.updatePost(this);
}
}
}
cascade
속성은 특정 엔티티를 특정한 생명주기(PERSIST, REMOVE, MERGE 등)로 변환할 때 연관된 엔티티도 동일한 생명주기로 만들어주는 기능을 한다. 즉 엔티티의 생명주기를 자신이 관리하는 것이 아닌 다른 엔티티에 의해 관리당하게 되는 것이다. 따라서 엔티티 간 생명 주기가 서로 유사하면서(함께 저장되는 경우, 함께 삭제되는 경우 등), 생명주기를 관리당하는 엔티티의 소유자가 하나인 경우에만(위 예제에서 Comment
엔티티의 소유자는 Post
엔티티 하나뿐) cascade
속성을 사용하는 것이 좋다. 참고로 엔티티의 생명주기는 다음과 같다. (덧붙이자면 영속성 전이 cascade
는 연관관계 매핑과는 아무런 관련이 없으며 단순히 두 엔티티의 생명 주기를 보다 편리하게 관리하기 위해 제공되는 것일 뿐이다.)
여기서 CascadeType.REMOVE
는 부모 엔티티 삭제 시 연관관계에 있는 자식 엔티티도 JPA 상에서 함께 삭제하는 설정이다. (즉 영속성 컨텍스트에서 부모 엔티티가 REMOVE되면 관련 자식 엔티티들도 REMOVE) 다음과 같이 Post
객체 삭제 시 해당 Post
와 관련된 모든 Comment
에 대한 delete 쿼리가 나가고 이후 Post
에 대한 delete 쿼리가 발생한다.
@Rollback(value = false) //테스트 시 트랜잭션 커밋을 위해 Rollback 하지 않음
@DataJpaTest
class SafeWalletApplicationTests {
@Autowired
PostRepository postRepository;
@Autowired
CommentRepository commentRepository;
@Autowired
EntityManager em;
Post post;
@BeforeEach
void init() {
post = Post.builder().content("").build();
Comment comment1 = Comment.builder().content("").build();
Comment comment2 = Comment.builder().content("").build();
Comment comment3 = Comment.builder().content("").build();
List<Comment> comments = List.of(comment1, comment2, comment3);
post.addComments(comments); //연관관계 편의 메소드 호출
postRepository.save(post);
commentRepository.saveAll(comments);
em.flush();
em.clear();
}
@Test
void cascadeType_remove_test() {
//given
Post findPost = postRepository.findById(post.getId())
.orElseThrow(() -> new RuntimeException("해당 게시물 없음"));
//when
postRepository.delete(findPost); //Post, 관련 Comment 모두 REMOVE
//then
assertThat(em.contains(findPost)).isFalse(); //영속성 컨텍스트에서 관리되지 않음
assertThat(em.contains(findPost.getComments().get(0))).isFalse();
}
}
이 경우 post
, comment1
, comment2
, comment3
객체가 더이상 영속성 컨텍스트에서 관리되지 않는 Removed 상태가 되며 트랜잭션 커밋 시에는 delete 쿼리가 각각 발생하게 된다. 또한 관련 Comment
엔티티를 Removed 상태로 전이하기 위해서는 일단 해당 객체들이 영속성 컨텍스트에서 관리되어야 하기 때문에 delete 쿼리 전 comment 테이블에 대한 select 쿼리가 발생한 것을 확인할 수 있다.
-- cascadeType_remove_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;
delete from comment where comment_id=1;
delete from comment where comment_id=2;
delete from comment where comment_id=3;
delete from post where post_id=1;
orphanRemoval = true 설정
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
private String content;
@Builder.Default
@OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true) //추가
private List<Comment> comments = new ArrayList<>();
//연관관계 편의 메소드
public void addComments(List<Comment> comments) {
for (Comment comment : comments) {
this.comments.add(comment);
comment.updatePost(this);
}
}
}
orphanRemoval
는 부모 엔티티와 연관관계가 끊긴 자식 엔티티를 삭제하는 설정이다. 즉 부모 엔티티는 삭제되지 않더라도 다음과 같이 부모 엔티티와의 연관관계가 끊긴 자식 엔티티(고아 객체)에 대해서는 delete 쿼리가 발생한다. 당연히 개념적으로 부모 엔티티가 제거되면 자식 엔티티들은 고아 객체가 되므로, 부모 삭제 시 자식도 함께 삭제되는 CascadeType.REMOVE
옵션과 비슷하게 동작할 수 있다. (둘의 차이점은 orphanRemoval
는 부모 엔티티가 삭제되지 않더라도 자식 엔티티를 삭제할 수 있다는 것이다.)
@Test
void orphanRemoval_test() {
//given
Post findPost = postRepository.findById(post.getId())
.orElseThrow(() -> new RuntimeException("해당 게시물 없음"));
//when
Comment removeComment = findPost.getComments().remove(0);//Post 관련 첫번째 Comment delete
//then
assertThat(em.contains(findPost)).isTrue();
assertThat(em.contains(removeComment)).isTrue(); //여전히 영속성 컨텍스트에서 관리됨
}
post
객체와 관련된 comments
필드를 조회한 후 첫번째 Comment
객체를 remove하게 되면 removeComment
은 부모가 없는 고아 객체가 된다. 따라서 트랜잭션 커밋 시 comment 테이블에 delete 쿼리가 발생한다. 참고로 removeComment
객체가 영속성 컨텍스트에서 REMOVE된 것은 아니므로 여전히 영속성 컨텍스트에서 관리된다.
-- orphanRemoval_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;
delete from comment where comment_id=1;
또한 post
객체를 직접 delete하는 경우에는 orphanRemoval = true
설정이 cascade = CascadeType.REMOVE
처럼 동작한다. 아래 코드에서 삭제되는 findPost
와 관련된 Comment
객체 또한 영속성 컨텍스트에서 더이상 관리되지 않고 Removed 상태로 전이된 것을 확인할 수 있다.
@Test
void orphanRemoval_test2() {
//given
Post findPost = postRepository.findById(post.getId())
.orElseThrow(() -> new RuntimeException("해당 게시물 없음"));
//when
postRepository.delete(findPost); //CascadeType.REMOVE 처럼 동작
//then
assertThat(em.contains(findPost)).isFalse();
assertThat(em.contains(findPost.getComments().get(0))).isFalse();
}
참고로 orphanRemoval = true
설정은 cascade = CascadeType.PERSIST
설정이 존재해야만 post.getComments().remove(0)
호출 시 comment 테이블에 delete 쿼리가 날라간다. PERSIST
옵션이 존재하지 않으면 아무런 쿼리가 발생하지 않는다. 누군가 스프링 프로젝트 레포에 이슈를 남겨주셨는데 제대로 된 답을 얻지 못했다. 왜 PERSIST
옵션이 있어야만 orphanRemoval = true
설정이 제대로 동작하는지 아시는 분은 댓글로 남겨주시라...
일반적으로 1:N 매핑 관계에서 cascade = CascadeType.REMOVE
, orphanRemoval = true
설정을 사용하기 위해서는 두 엔티티 간 양방향 연관관계를 가질 수 밖에 없다. 어차피 연관관계 주인은 N 쪽의 엔티티이므로 1 쪽의 엔티티가 N 쪽의 엔티티를 의존하고 있을 필요가 없음에도 불구하고, 단지 연쇄적으로 삭제하기 위해서 새로운 의존 관계가 추가되는 것이다. 이러한 양방향 연관관계에서는 순수 객체 상태를 고려해서 연관관계 편의 메소드를 통해 항상 양쪽에 값을 설정해주어야 하며, 자칫 하면 toString 또는 JSON 생성 라이브러리로 인해 서로를 계속 참조하여 무한 루프에 빠질 수 있다. 따라서 정말 필요한 경우가 아니고서는 양방향 매핑은 지양된다.
또한 무엇보다 위의 두 설정 모두 JPA 단에서 처리되기 때문에 삭제되는 엔티티 개수만큼 delete 쿼리가 각각 발생한다는 큰 문제점이 있다. 즉 삭제에서의 N + 1 문제가 발생하게 된다. (Post
엔티티 1개 삭제로 인해 관련 있는 N개의 Comment
엔티티 삭제) 또한 JPA에서 연관된 자식 엔티티에 대한 삭제를 위해 영속성 컨텍스트에 자식 엔티티들을 영속화해야 하기 때문에 자식 테이블에 대한 불필요한 조회 쿼리가 추가로 발생할 수 있다. (생각해보면 select할 필요 없이 DB에 직접 delete 쿼리를 날리면 된다.)
@OnDelete(action = OnDeleteAction.CASCADE) 설정
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
private String content;
@Builder.Default
@OneToMany(mappedBy = "post") //cascade, orphanRemoval 속성 제거
private List<Comment> comments = new ArrayList<>();
//연관관계 편의 메소드
public void addComments(List<Comment> comments) {
for (Comment comment : comments) {
this.comments.add(comment);
comment.updatePost(this);
}
}
}
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
private String content;
@OnDelete(action = OnDeleteAction.CASCADE) //추가
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
public void updatePost(Post post) {
this.post = post;
}
}
해당 설정은 연관관계 주인 엔티티인 Comment
엔티티 내의 Post
에 대한 참조에 선언하는 것으로, post 테이블에서 특정 행 삭제 시 DB 상에서 FK 제약조건이 걸린 관련 comment 테이블 행들을 자동으로 삭제해준다. 즉 @OnDelete(action = OnDeleteAction.CASCADE)
어노테이션은 comment 테이블에 다음과 같은 DDL을 추가해준다. 또한 앞서 보았던 cascade
, orphanRemoval
속성과 다른 점은 cascade
, orphanRemoval
는 관련 자식 엔티티에 대한 삭제 처리 주체가 JPA인 반면 @OnDelete
어노테이션 설정에서는 처리 주체가 DB이다.
create table comment (
comment_id bigint not null auto_increment,
content varchar(255),
post_id bigint,
primary key (comment_id)
) engine=InnoDB
create table post (
post_id bigint not null auto_increment,
content varchar(255),
primary key (post_id)
) engine=InnoDB
alter table comment
add constraint FKs1slvnkuemjsq2kj4h3vhx7i1
foreign key (post_id)
references post (post_id)
on delete cascade -- 추가
실제 DB 내부 구현은 어떻게 동작하는지 알지 못하지만, 이렇게 하면 특정 Post
엔티티에 대한 delete 쿼리 1개만으로 제약조건 위반 없이 관련 자식 엔티티를 모두 삭제할 수 있다. (DB Connection으로 많은 delete 쿼리가 전달되지는 않는다는 장점?) 그러나 DB Vendor에 따라 on delete cascade
DDL을 지원하지 않을 수도 있다.
@Test
void onDelete_test() {
//given
Post findPost = postRepository.findById(post.getId())
.orElseThrow(() -> new RuntimeException("해당 게시물 없음"));
//when
postRepository.delete(findPost); //on delete cascade
//then
assertThat(em.contains(findPost)).isFalse();
assertThat(em.contains(findPost.getComments().get(0))).isTrue();
}
-- onDelete_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
select * from comment c where c.post_id=1;
delete from post where post_id=1; -- delete 쿼리 하나 발생
직접 delete JPQL 작성
현재로서는 가장 좋은 방법이라고 생각한다. 물론 Post
엔티티를 삭제하기 전 연관관계 엔티티들에는 어떤 것들이 있는지 개발자가 직접 판단해야 하며, 만약 잊어버리고 연관된 엔티티들에 대한 삭제 쿼리를 먼저 호출해주지 않는 경우 FK 제약조건 위반 예외를 뱉을 수 있다. 또한 만약 삭제하고자 하는 엔티티가 많은 자식 엔티티들과 관계를 맺고 있다면 일일이 모든 자식 테이블에 대해 delete 쿼리를 날려줘야 할 수도 있다. 참고로 이 경우 연관관계에 있는 comment 테이블 삭제 쿼리 + post 테이블 삭제 쿼리 총 2개의 delete 쿼리가 발생한다.
public class CommentRepository {
@Modifying
@Query("delete from Comment c where c.post.id = :postId")
void deleteByPost(@Param("postId") Long postId);
}
@Test
void jpql_test() {
Post findPost = postRepository.findById(post.getId())
.orElseThrow(() -> new RuntimeException("해당 게시물 없음"));
commentRepository.deleteByPost(findPost.getId()); //연관된 Comment 먼저 삭제
postRepository.delete(findPost); //이후 Post 삭제
}
-- jpql_test 메소드 내에서 발생한 쿼리
select * from post p where p.post_id=1;
delete from comment where post_id=1;
delete from post where post_id=1;
자식 엔티티의 FK 필드를 NULL 또는 default 값으로 수정
위와 같이 특정 부모 엔티티를 삭제하는 경우 관련된 자식 엔티티 또한 삭제해야 하는 경우도 있지만, 자식 엔티티 내 부모 엔티티에 대한 참조값을 NULL 또는 기본값으로 설정해야 하는 경우가 있다. 예를 들어 부모 엔티티인 Department
즉 부서 엔티티가 삭제된다고 해서 해당 부서에 소속된 사원 엔티티를 삭제해서는 안된다. (부서가 사라진다고 소속 사원을 전부 해고할 순 없다?) 이 경우엔 사원 엔티티 내에서 참조하고 있는 부서 엔티티 필드값을 NULL 또는 기본값으로 수정해야 한다. 이를 구현하기 위해서는 3가지 방법이 존재하는데 각각에 대해 자세히 알아보자. 현재 케이스에서 사용할 1:N 단방향 매핑의 Department
, Employee
엔티티 코드는 다음과 같다.
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Department {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "department_id")
private Long id;
private String name;
}
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
private String name;
private Integer age;
}
public interface DepartmentRepository extends JpaRepository<Department, Long> {
Optional<Department> findByName(String name);
}
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
}
@OnDelete(action = OnDeleteAction.SET_NULL) 또는 @OnDelete(action = OnDeleteAction.SET_DEFAULT) 설정
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_id")
private Long id;
@OnDelete(action = OnDeleteAction.SET_NULL) //추가
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
private String name;
private Integer age;
}
create table department (
department_id bigint not null auto_increment,
name varchar(255),
primary key (department_id)
) engine=InnoDB
create table employee (
employee_id bigint not null auto_increment,
age integer,
name varchar(255),
department_id bigint,
primary key (employee_id)
) engine=InnoDB
alter table employee
add constraint FKbejtwvg9bxus2mffsm3swj3u9
foreign key (department_id)
references department (department_id)
on delete set null -- 추가
1:N 관계인 Department
:Employee
에서 연관관계 주인인 Employee
엔티티 내 Department
참조에 해당 어노테이션을 선언할 수 있으며 이 경우 DDL에 on delete set null
이 추가된다. 위에서 설명한 것 같이 @OnDelete
옵션은 DB 단에서 처리되는 것으로 DB Connection을 통해서는 department 테이블에 대한 delete 쿼리 1개만 발생하며 DB 내부적으로 관련 있는 employee 테이블 행의 FK 값을 NULL로 update한다.
@Rollback(value = false) //테스트 시 트랜잭션 커밋을 위해 Rollback 하지 않음
@DataJpaTest
class SafeWalletApplicationTests {
@Autowired
DepartmentRepository departmentRepository;
@Autowired
EmployeeRepository employeeRepository;
@Autowired
EntityManager em;
@BeforeEach
void init() {
Department department = Department.builder().name("QA팀").build();
Employee employee1 = Employee.builder().name("Alice").age(21).department(department).build();
Employee employee2 = Employee.builder().name("Dana").age(26).department(department).build();
Employee employee3 = Employee.builder().name("Tom").age(37).department(department).build();
List<Employee> employees = List.of(employee1, employee2, employee3);
departmentRepository.save(department);
employeeRepository.saveAll(employees);
em.flush();
em.clear();
}
@Test
void onDelete_set_null_test() {
Department department = departmentRepository.findByName("QA팀")
.orElseThrow(() -> new RuntimeException("해당 부서 없음"));
departmentRepository.delete(department); //관련 Employee의 소속부서 값 NULL로 update
}
}
-- onDelete_set_null_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
delete from department where department_id=1;
NULL이 아닌 기본값으로 초기화하고자 하는 경우에는 @OnDelete(action = OnDeleteAction.SET_DEFAULT)
설정을 추가해주면 되는데, 이 경우 DDL에 기본값을 정의하기 위해 @ColumnDefault
어노테이션으로 선언해주면 된다.
자식 엔티티 리스트를 순회하며 직접 set
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Department {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "department_id")
private Long id;
private String name;
//양방향 매핑 추가
@OneToMany(mappedBy = "department")
private List<Employee> employees;
}
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
private String name;
private Integer age;
//department에 대한 setter 추가
public void updateDepartment(Department department) {
this.department = department;
}
}
@Test
void setter_test() {
Department department = departmentRepository.findByName("QA팀")
.orElseThrow(() -> new RuntimeException("해당 부서 없음"));
List<Employee> employees = department.getEmployees();
for (Employee employee : employees) { //관련 Employee 영속화
employee.updateDepartment(null); //트랜잭션 커밋 시 Dirty Checking
}
departmentRepository.delete(department);
}
이 경우에는 자식 엔티티 리스트를 DB에서 모두 조회한 후(영속성 컨텍스트에 영속화한 후) 각 리스트 요소를 순회하며 직접 setter를 호출해주어야 한다. 그리고 실제 날라간 쿼리를 확인해보면 수정된 각 Employee
엔티티마다 update 쿼리가 발생한다. 즉 삭제, 수정에 대한 N + 1 문제가 발생할 수 있다는 것이다. (Department
엔티티 하나를 삭제했더니 관련된 N개의 Employee
엔티티에 대한 update 쿼리 N개가 발생) 또 다른 문제는 employee 테이블에 직접 update 쿼리를 날려주면 될 것을, JPA의 Dirty Checking 기능을 위해 쓸데 없는 select 쿼리가 발생한다는 것이다. 즉 select 할 필요 없이 JPQL을 통해 DB에 직접 update 쿼리를 날려주면 된다.
-- setter_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
select * from employee e where e.department_id=1; -- 불필요한 select 쿼리
update employee set department_id=null where employee_id=1;
update employee set department_id=null where employee_id=2;
update employee set department_id=null where employee_id=3;
delete from department where department_id=1;
직접 update JPQL 작성
public inteface EmployeeRepository extends JpaRepository<Employee, Long> {
@Modifying
@Query("update Employee e set e.department.id = null where e.department.id = :departmentId")
void updateByDepartment(@Param("departmentId") Long departmentId);
}
@Test
void jpql_test() {
Department department = departmentRepository.findByName("QA팀")
.orElseThrow(() -> new RuntimeException("해당 부서 없음"));
employeeRepository.updateByDepartment(department.getId()); //관련 Employee update 후
departmentRepository.delete(department); //Department 삭제
}
-- jpql_test 메소드 내에서 발생한 쿼리
select * from department d where d.name='QA팀';
update employee set department_id=null where department_id=1;
delete from department where department_id=1;
JPQL을 통해 1번의 update 쿼리로 employee 테이블의 department_id(FK) 값을 수정할 수 있다. 이 경우에도 마찬가지로 Department
엔티티를 삭제하기 전 Department
엔티티와 관련된 자식 엔티티들을 모두 확인하고 일일이 update 메소드를 호출해주어야 한다. 개발자가 이를 잊어버린다면 FK 제약조건 위반 에러가 발생한다. 앞서 Dirty Checking을 통해 update 쿼리가 발생했던 것과는 달리 employee 테이블에 대한 불필요한 select 쿼리가 제거되었으며 각 Employee
엔티티마다 발생했던 N개의 update 쿼리가 1개로 줄어들게 되었다.
정리
JPA를 활용하는 경우에는 N + 1의 성능 문제가 발생할 수 있으며, 직접 JPQL 쿼리를 작성하는 경우에는 만약 개발자가 자식 엔티티에 대한 삭제 쿼리 호출을 까먹는다면 FK 제약조건 오류가 발생할 수 있다. 마지막으로 @OnDelete
방식을 사용하여 데이터베이스를 직접 다루면, on delete cascade
DDL에 의해 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제되는지 어플리케이션 단에서 파악이 어렵다.
부모 테이블과 자식 테이블 간 FK 제약조건이 걸려있을 때, 제약조건을 준수하기 위해 자식 테이블에 insert나 update 시 해당 FK 값이 실제 부모 테이블에 존재하는지 확인하기 위해, 또는 부모 테이블 delete 시에도 삭제되는 PK를 FK로 사용하는 레코드들이 있는지 판별하기 위해 테이블에 Lock이 걸리게 된다. 이렇게 FK 제약 조건으로 인해 Lock이 걸려 다양한 성능 이슈가 발생할 수 있으며, 변화하는 요구사항에 따른 스키마 변경의 어려움 등으로 실무에서는 FK를 지정하지 않는 경우가 많다고 한다. 이러한 이유로 FK를 지정하지 않는다면 @OnDelete
방법을 적용하는 것은 어려우며, JPA 상에서 Cascade 방식으로 연관된 엔티티들을 관리해주거나 직접 JPQL을 정의해야 할 것이다.
정말 마지막으로 정리하자면 부모 엔티티 삭제 시 연관된 자식 엔티티들을 처리하는 방법에는 크게 JPA의 영속성 컨텍스트를 통해 처리하는 방법(cascade
, orphanRemoval
, Dirty Checking 등), DDL을 정의하여 처리하는 방법, 직접 JPQL을 정의하는 방법이 존재한다. 각 방법에는 장단점이 존재하며 개발 서비스 요구사항에 맞게 적절히 선택하면 될 것이다. 아직 풀리지 않는 궁금증은 실무에서 FK를 지정하지 않는다면 그럼 FK 제약조건 없이 어떻게 RDB 테이블 간 연관관계를 매핑하지?이다. 이건 실무를 좀 더 경험한 후 추후 작성해보기로 하자.
끝.
참고
https://kukekyakya.tistory.com/546
https://chb2005.tistory.com/181
https://stackoverflow.com/questions/8243400/on-delete-set-null-in-hibernate-in-onetomany
https://www.baeldung.com/jpa-cascade-remove-vs-orphanremoval
'JPA' 카테고리의 다른 글
[JPA] 양방향 @OneToOne 매핑 시 LAZY 로딩 불가? (0) | 2024.07.28 |
---|
- Total
- Today
- Yesterday
- JPA
- 디자인 패턴
- ParameterizedTest
- QueryDSL
- spring boot
- 단위 테스트
- 전략 패턴
- Spring Security
- Java
- Gitflow
- Assertions
- Linux
- 서블릿 컨테이너
- facade 패턴
- 모두의 리눅스
- rest api
- Transaction
- FrontController
- github
- C++
- spring
- mockito
- Git
- 템플릿 콜백 패턴
- junit5
- Front Controller
- spring aop
- vscode
- servlet filter
- SSE
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |