반응형

게시판 API 만들기 시리즈를 연재하다가,

연관 관계에 있는 엔티티를 연쇄적으로 제거하기 위해 사용되는 위 두 가지 방식의 차이점이 궁금해졌고, 이에 대해 간단히 정리해보았습니다.

 

* 주관적인 내용도 많이 포함되어있습니다.

 

가장 큰 차이로는, JPA에 의해 처리되느냐, DDL에 의해 DB단에서 처리되느냐입니다.

 

전자의 방식을 취할 경우, JPA에 의해 외래 키를 찾아가며 참조하는 레코드를 제거해주게 됩니다.

따라서, JPA 상에서는 참조하고 있는 레코드의 개수만큼 delete 쿼리가 생성됩니다.

 

후자의 방식을 취할 경우, 데이터베이스 자체에서 on delete cascade 제약조건이 걸리게 됩니다. 이를 통해 참조하는 레코드가 모두 제거되는 것입니다. 

따라서, JPA 상에서는 한 개의 delete 쿼리가 생성되고, 데이터베이스에서 이를 처리해줍니다.

 

간단한 테스트로 예시를 들어보겠습니다.

@Test
void deleteCascadeTest() {
    // given
    Category category1 = categoryRepository.save(createCategoryWithName("category1"));
    Category category2 = categoryRepository.save(createCategory("category2", category1));
    Category category3 = categoryRepository.save(createCategory("category3", category2));
    Category category4 = categoryRepository.save(createCategoryWithName("category4"));
    clear();

    // when
    categoryRepository.deleteById(category1.getId());
    clear();

    // then
    List<Category> result = categoryRepository.findAll();
    assertThat(result.size()).isEqualTo(1);
    assertThat(result.get(0).getId()).isEqualTo(category4.getId());
}

우리는 위 테스트를 수행하면서 각 방식마다 쿼리가 어떻게 수행되는지 살펴볼 것입니다.

1번 카테고리를 제거하면, 그의 모든 하위 카테고리인 2번과 3번 카테고리도 연쇄적으로 제거되어야합니다.

최종 결과는 1개의 카테고리만 조회될 것입니다.

 

위에서 작성했던 Category 엔티티를, 다음과 같이 설정하여 테스트를 수행해보겠습니다.

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.REMOVE)
    private List<Category> children;

 

테스트는 성공하였고, 쿼리 결과는 다음과 같습니다.

Hibernate: create table category (category_id bigint generated by default as identity, name varchar(30) not null, parent_id bigint, primary key (category_id))
...
Hibernate: alter table category add constraint FK2y94svpmqttx80mshyny85wqr foreign key (parent_id) references category
...

Hibernate: delete from category where category_id=?
Hibernate: delete from category where category_id=?
Hibernate: delete from category where category_id=?

category 테이블에 외래 키 제약 조건 외에 별다른 제약 조건이 추가되지는 않았습니다.

상위 카테고리 한 개를 제거하기 위한 DELETE 쿼리와, 그의 하위 카테고리를 제거하기 위해 2개의 DELETE 쿼리가 연쇄적으로 수행되었습니다.

 

이번에는 Category 엔티티를 다음과 같이 설정하여 테스트를 수행해보겠습니다.

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Category parent;

@OneToMany에 설정했던 cascade=CascadeType.REMOVE는 제거하고,

@ManyToOne에 @OnDelete(action = onDeleteAction.CASCADE)를 설정해주었습니다.

 

테스트는 성공하였고, 쿼리 결과는 다음과 같습니다.

Hibernate: create table category (category_id bigint generated by default as identity, name varchar(30) not null, parent_id bigint, primary key (category_id))
...
Hibernate: alter table category add constraint FK2y94svpmqttx80mshyny85wqr foreign key (parent_id) references category on delete cascade
...
Hibernate: delete from category where category_id=?

alter 문의 뒷 부분을 자세히 보면, "on delete cascade"가 추가되어있습니다.

DDL에 의해 스키마 자체에 해당 제약 조건이 추가된 것입니다.

또한, delete 쿼리는 한 건만 생성되었지만, 테스트는 성공하였습니다.

JPA가 아닌, DDL로 제약 조건을 설정해둔 데이터베이스에서 참조하는 레코드들을 연쇄적으로 제거해준 것입니다. 

 

그래서 이 두 방식의 차이로 인해 언제 어떤 방식을 선택해야하는지 묻는다면, 저도 잘 모르겠습니다.

 

일단 제가 선택한 이유는, 어떤 카테고리의 하위 카테고리와 @OneToMany로 JPA 관계를 형성해줄 필요가 없었기 때문입니다.

@ManyToOne 관계로 부모 카테고리만 무엇인지 알고 있으면 되므로, 굳이 새로운 @OneToMany 관계를 만들어주지않고자 @OnDelete를 사용한 것입니다.

 

성능 상의 차이도 있긴 할 것입니다.

@OnDelete 방식은, JPA에서는 단일한 DELETE 쿼리만 전송하여 참조하는 레코드들을 연쇄적으로 제거해줍니다.

하지만 CascadeType.REMOVE 방식은, JPA에서 외래 키를 통해 참조하는 레코드들을 제거하기 위해 그 개수만큼 DELETE 쿼리를 전송해야합니다.

이러한 까닭에 쿼리를 1건만 생성하는 @OnDelete 방식이 성능적으로 이점이 있지 않을까 싶습니다.

물론, 데이터베이스에서 외래키에 지정한 on delete cascade 제약 조건을 어떤 식으로 처리하고 정확히 얼만큼의 비용이 드는지는 저도 모릅니다.

만약 이 비용이 생각보다 크다면, 오히려 후자의 방식이 더욱 뛰어난 성능을 가지도록 설계할 수 있을 것입니다.

JPA에서 참조하고 있는 레코드들을 모두 찾아준 뒤, 데이터베이스는 해당되는 레코드의 DELETE 쿼리만 수행할 것이고,  데이터베이스에 비해 JPA를 사용하는 서버 애플리케이션은 확장이 용이할 것이기 때문입니다.

물론, 이것도 참조하고 있는 엔티티들이 영속된 상태여야 JPA 레벨에서 찾아낼 수 있을 것이고, 쿼리를 생성 및 전송하는 비용도 생각해야겠지만요.

이에 대한 구체적인 동작 방식은 모르기 때문에, 그래도 일반적인 성능은 전자가 더 뛰어나지 않을까 싶지만, 이는 공식적인 내용이 아니라 제 개인적인 의견입니다.

 

성능에 관해 추가적으로 언급하자면, 실무에서는 FK를 지정하지 않는 경우가 많다고 들었습니다.

이 부분도 제가 실무를 아직 접해보지않아서 명확한 설명은 어렵지만, FK 제약 조건으로 인해 Lock이 걸리는 상황이나 스키마 변경의 어려움으로 사용하지 않는 것으로 알고 있습니다.

예를 들어, 다른 테이블의 pk를 fk로 지정하여 insert할 때, pk에 해당하는 레코드가 정말 있는지 제약 조건을 확인하기 위해 부모 테이블에 Lock을 걸어야 하거나,

어떤 레코드를 delete 할때, 이 레코드의 pk를 fk로 사용하는 레코드들이 있는지 판별해야 하는 등 다양한 성능 이슈가 있을 것입니다.

또, FK 제약 조건은 요구사항에 따라 변화할 수 있는 스키마 구조의 변경을 어렵게 만들 것입니다. 

이러한 이유로 인해, FK를 지정하지 않는다면 @OnDelete 방식으로 수행하긴 어려울 것이고, 결국 JPA 상에서 Cascade 방식으로 연관 레코드들을 관리해줘야할 것입니다.

 

다른 블로그를 뒤적거리다보니, 운영 상의 차이가 있을 거라는 의견도 있었습니다.

@OnDelete 방식으로 테이블을 생성하여 데이터베이스를 직접 다루다보면, on delete cascade에 의해 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제해버리는 실수 할 여지가 있는 반면,

CascadeType.REMOVE 방식으로 데이터베이스를 직접 다루다보면, 의존성이 있는 레코드를 제거하기 위해서는 JPA에서 프로그램적으로만 처리할 수 있다보니, 운영자가 의존성이 있는 레코드를 직접 삭제하게 되는 실수의 여지가 비교적 줄어든다는 내용이었습니다.

하지만 반대로 생각해보면, 스키마 구조를 변경하는 등 데이터베이스를 조작하는데 번거로움이 생길 수 있을 것 같습니다.

실제로는 소프트하게 제거하는 경우도 있고, 데이터를 복구하는 다양한 방법도 있을테지만, 제가 제대로 된 운영 경험이 있는 것이 아니다보니 함부로 말하긴 어려운 내용인 것 같습니다. 

 

 

코드에서도 차이가 있을 것입니다.

Team과 Member이라는 1 : N 관계의 엔티티가 있다고 가정해보겠습니다.

Member는 하나의 Team에 속할 수 있고, Team에는 여러 명의 Member가 소속될 수 있습니다.

연관관계의 주인은, Team의 primary key를 외래 키로 가지고 있는 Member가 될 것입니다. 

@OnDelete 방식을 취하면, Member에서 @ManyToOne으로 선언한 Team 필드에 단순히 해당 어노테이션을 지정해주기만 하면 됩니다.

CascadeType.REMOVE 방식을 취해도, Team에서 @OneToMany으로 선언한 Member 필드에 해당 어노테이션을 지정해주기만 하면 된다는 것은 동일합니다.

하지만 @OneToMany는 JPA로 인하여 만들어지는 양방향 의존 관계입니다.

실제 연관 관계의 주인은 Member이기 때문에, Team에서 반드시 Member를 의존하고 있을 이유가 없습니다.

JPA를 이용한 양방향 의존 관계를 만들어줘야할 특별한 이유가 없음에도, 단지 연쇄적으로 삭제하기 위해서 새로운 의존 관계를 만들어줘야하는 것입니다. 

양방향 의존 관계를 무분별하게 설정하면, 언제 어떠한 문제가 생길지 모릅니다.

예를 들어, 롬복으로 @ToString이나 @EqualsAndHashCode 등을 사용하는 경우 서로를 계속 참조하며 스택오버플로우가 발생하는 문제가 있습니다.

새로운 의존 관계 생성을 방지하고, 그로 인해 발생할 수 있는 문제를 미연에 방지할 수 있다는 점에서, 코드 측면에서는 @OnDelete 방식이 우위를 점한다고 생각합니다.

물론, 이것도 상황에 따라 다를 것입니다.

 

또 다른 차이는, 스택오버플로우에서 확인하였는데 다음을 참조바랍니다.

https://stackoverflow.com/questions/8563592/jpas-cascade-remove-and-hibernates-ondelete-used-together

위 링크는 @OneToOne을 예시로 들었기 때문에, 다른 관계에서는 차이가 있을 수 있습니다.

 

* 주관적인 내용도 많이 섞여있기 때문에, 오류가 있다면 지적 부탁드립니다.

반응형

+ Recent posts