기본기를 다지는 혼자만의 프로젝트에서 잘 쓰이지않는 Spring Data Jpa의 deleteAll() 메서드를 사용하게 되었다.
DB에서 데이터의 삭제는 매우 중요한 부분으로 실무에서는 잘 쓰이지 않는다고 하였다.
(개인정보는 없애고 나머지 부분은 남긴다던지, 복구라든지 데이터가 필요한 부분이 있기 때문에)
자 그럼 나의 상황을 먼저 간략하게 설명하고 deleteAll()와 deleteAllInBatch()를 비교하면서 동작원리도 파헤쳐보자.
엔티티 구조
Comment 엔티티 (댓글)
댓글과 대댓글은 셀프조인 되어있는 구조
Post 엔티티 (게시글)
현재 상황
본인이 등록한 게시글을 DB에서 삭제하려는 행동을 하려고 한다.
이 때 게시글과 연관되어 있는 댓글을 먼저 삭제하여야 게시글을 삭제할 수 있다.
현재 댓글 테이블은 게시글의 pk(기본키)를 fk(외래키)로 가지고 있기 때문에,
댓글을 삭제하지 않고 게시글을 삭제하려고 하면 다음과 같은 에러가 나는 것이다.
위 에러내용은 Referential integrity constraint violation(참조 무결성 제약조건 위반)이 발생했다는 의미이다.
좀 더 읽어보면 COMMENT(댓글) 테이블의 FK값에 참조할 수 없는 값이 발생해서 생겼다고 한다.
즉, POST(게시글)을 삭제하려고 하니 이 게시글을 참조하고 있는 댓글 테이블에서 참조할 수 없는 외래키 값을 가지게 되어서 발생한 상황인 것이다.
이 상태를 부모가 제거되고 자식만 남게된 Orphan(고아) 상태라고 한다.
(이 상태에 맞춰서 JPA가 제공하는 orphanRemoval 또는 cascade를 사용하면 알아서 외래키를 참조하는 것까지 삭제하여 진행한다.)
🧐 무결성(Integrity)이란?
- 데이터의 정확성, 일관성, 유효성을 나타내는 것
- 데이터를 정확하고 일관되게 유지하는 것
🧐 참조 무결성 제약조건이란?
- 두 릴레이션의 연관된 투플들 사이의 일관성을 유지하는데 사용
- 각 릴레이션(관계)은 참조할 수 없는 외래키 값을 가질 수 없음
- 외래키의 값은 NULL 또는 참조 릴레이션의 기본키 값과 동일해야 함
위 에러의 해결방법은 크게 3가지로 나눌 수 있다.
1. 자식을 먼저 삭제하고 부모를 삭제한다.
2. cascade 사용
3. orphanRemoval 사용
나는 1번을 선택하여 진행하는데 혹시 궁금할 수 있으니 아래 접은 글을 펼치면 2,3번의 해결방법이 나온다.
orphanRemoval과 cascade는 둘 다 부모 Entity(엔티티) 에서 적용할 수 있다.
CASCADE : 영속성 전이
특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이(Transitive Persistence) 기능을 사용하면 된다.
JPA는 CASCADE 옵션으로 영속성 전이를 제공하는데, 이를 통해서 부모 엔티티의 영속 상태가 자식 엔티티에게 전이된다.
사용 방법은 부모 테이블인 Post 에서 @OneToMany의 옵션으로 CASCADE 옵션을 적어준다. (양방향 관계)
CASCADE 옵션은 다양하지만 여기서는 삭제만 한정해서 REMOVE 옵션만 사용했다.
이 상태에서 게시글을 삭제하게 되면 연관된 자식도 모두 삭제하게 된다.
삭제 순서는 외래키 제약조건을 고려하여 자식 먼저 삭제하고 부모를 삭제하게 된다.
ORPHANREMOVAL : 고아 객체
JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공한다.
이것을 고아객체(ORPHAN) 제거라고 한다. 이 기능을 사용하면 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제된다. 또는 부모 엔티티를 제거하면 자식은 자동으로 고아가 되기 때문에 자식도 같이 제거된다. 이 부분이 CASCADE의 REMOVE와 동일하게 작동하는 부분이다.
사용 방법은 cascade처럼 @OneToMany의 옵션으로 orphanRemoval = true 를 설정하면 된다.
이 상태라면 cascade 방식처럼 부모가 삭제될 때 자식도 삭제된다.
거기서 더 추가되는 기능으로 cascade는 부모의 영속 상태를 전이하는 것이지만 이것은 자식이 고아상태가 되면 자동으로 삭제가 되기 때문에 이 부분을 조심해야 한다.
만약 comments.remove(0); 을 실행하게 되면 현재comments 컬렉션에서 첫 번째 자식을 제거하게 된다.
orphanRemoval 옵션으로 인해 컬렉션에서 엔티티를 제거하면 DB의 데이터도 제거가 되는 것이다.
따라서 자식을 전부 제거하려면 comments.clear(); 를 쓰게되면 컬렉션을 비우게되므로 자식들을 전부 제거하게 된다.
이 고아 객체 제거는 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능이다.
따라서 참조하는 곳이 하나일 때만 사용해야 한다.
만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있기 때문이다.
따라서 게시글에 등록되어 있는 댓글과 대댓글을 먼저 삭제하기 위해 다음 코드를 추가하였다.
게시글과 연관되어 있는 댓글을 조회 → 댓글이 가지고 있는 대댓글(자식)을 조회 → 대댓글 삭제 → 댓글 삭제 → 게시글 삭제
이렇게 진행하려고 한다.
위 코드를 추가하여 삭제를 진행해보니 내 예상은 리스트를 만들어서 인자로 넘기기 때문에 SQL의 IN절을 사용해서 한 번에 삭제하는 줄 알았으나 콘솔에 찍힌 로그를 보니 delete쿼리가 1건씩 진행되고 있었다.
대댓글을 조회하는 쿼리를 시작으로 대댓글을 1건씩 삭제하고 있던 것이다.
이렇게 수많은 쿼리를 1건씩 진행하는 것은 성능이 낭비되고 있으므로 성능 개선을 해보자.
deleteAll()이란?
CrudRepository에 정의된 메서드이며, SimpleJpaRepository에 재정의(Override) 되어 있다.
이 메서드는 요청 데이터로 전달한 모든 엔티티를 삭제할 수 있다.
내부적으로 엔티티들을 하나씩 꺼내서 delete()를 사용하고 있다.
delete()는 결국 내부적으로 EntityManager의 remove()를 추가로 사용하여 제거하고 있다.
따라서 deleteAll()를 이용하는 삭제는 위와 같이 여러건이나 단건을 삭제하더라도 먼저 조회를 하고, 그 결과로 얻은 엔티티를 1건씩 삭제하는 과정을 지니고 있다.
deleteAllInBatch()란?
SimpleJpaRepository에 재정의되어 있는 deleteAllInBatch()는 applyAndBind()를 사용하여 쿼리를 끝낸다.
이것을 사용하게 되면 인자로 List<Comment>를 entities로 넘겨주기 때문에 if 조건문을 넘어가고 applyAndBind가 실행된다.
QueryUtils.java에 정의되어 있는 applyAndBind는 3가지 매개변수가 있다.
여기서 우리는 첫번째 매개변수에 getQueryString(DELETE_ALL_QUERY_STRING,entityInformation.getEntityName())이 들어갔는데 이것의 리턴값은 "delete from Comment x"이다.
📝 매개변수(Parameter)와 인자(Argument)는 비슷하지만 다른 의미입니다.
매개변수는 함수를 정의할 때 사용되는 변수(미지수)를 의미합니다.
인자는 실제로 함수가 호출될 때 넘기는 값을 의미합니다.
ex) getQueryString(String template)라는 함수를 정의할 때 여기서 template은 매개변수이고,
실제로 사용할 때 getQueryString("진짜")에서 "진짜"는 인자이다.
잠깐 getQueryString()을 설명하자면 이 메서드를 실행한 Comment (엔티티 이름)을 template의 문자열 형식에 맞게 넣는데 이 template은 인자로 "delete from %s x"가 들어왔기에 "%s"에 엔티티 이름이 들어가게 되어 리턴값이 저렇게 나오는 것이다.
그럼 마저 실행중이었던 applyAndBind()을 살펴보자.
detectAlias(queryString)은 정규 표현식에 해당 문자열을 검증하게 되는 메서드이다.
이것도 잠깐 살펴보면 ALIAS_MATCH는 Pattern 클래스로 정규 표현식이 컴파일된 클래스이다.
정규 표현식에 대상 문자열을 검증하거나 활용할 때 쓰는 클래스이다.
Pattern 클래스의 matcher()는 패턴에 매칭할 문자열을 입력해 Matcher 클래스를 생성하게 된다.
따라서 현재 위 코드에서 query는 "delete from Comment x"인데 현재 ALIAS_MATCH 정규 표현식에 매칭된 query의 2번째 그룹을 꺼내서 alias에는 "x"가 들어간다.
자 그럼 다시 applyAndBind()를 설명하면 StringBuilder에 queryString을 넣어서 생성한다.
(String과 비슷한데 다른점은 변경 가능한 문자열이라는 것)
그 후 만들었던 builder 문자열에 " where"를 붙여서 "delete from Comment x where"를 만들었다.
다음으로 while 반복문을 돌려서 쿼리를 만들기 시작한다.
아까 String.format()은 문자열 형식에 맞게 변환해준다고 했다.
따라서 "%s"에는 아까 꺼낸 alias를 넣고, "%d"에는 i를 넣고 있다.
그 후 다음 값 (대댓글 리스트에서)이 있으면 " or"을 붙여서 대댓글 리스트의 값만큼 추가한다.
그렇게 만들어진 문자열을 builder 문자열에 뒤에 계속 붙인다.
이렇게 최종적으로 만든 문자열을 EntityManager.createQuery() 메서드로 쿼리문을 만들고, 해당 번호에 맞춰서 파라미터를 넣어준다.
🧐 Query.setParameter(int position, Object value)란?
JPQL로 만들어진 쿼리문에 파라미터 바인딩을 하는 것이다.
해당 위치인 position에 value값을 넣는다는 것.
ex) "delete from Comment x where x = ?1" 이라는 쿼리에서 setParameter(1, "대댓글")을 쓴다면
=> "delete from Comment x where x = '대댓글'" 이렇게 만들어진다.
따라서 이 deleteAllInBatch()는 EntityManager의 createQuery()로 쿼리를 준비하여 직접 excuteUpdate()를 호출하여 한번의 쿼리로 여러개의 삭제를 진행하는 것이다.
성능 개선
이런 동작원리를 가지고 있는 deleteAllInBatch()를 적용해보자.
처음에 만들었던 위 쿼리가 아래로 변경되었다.
(코드를 쓰다보니 대댓글말고 댓글을 1건씩 삭제하고 있던 것도 발견하였다.....)
코드와 가독성, 간결성을 높이기 위해 for-each 루프 방식을 Iterable.forEach()로 바꾼 것이니 놀라지말자.
여기서 Stream로 변경하면 아래와 같아진다.
이렇게 바꾼 코드로 다음 게시글을 삭제해보자.
그 많았던 delete 쿼리들이 한번에 여러개씩 삭제되는 것을 볼 수 있다.
먼저 첫번째 댓글의 대댓글인 2개가 한번에 삭제되고, 그 후 댓글들이 한번에 삭제되는 것을 볼 수 있다.
결론
deleteAll()은 1건씩 삭제되는 것에 비해 deleteAllInBatch()는 where 조건에 or을 붙여서 한번에 삭제하게 된다.
삭제하는 것들이 많을수록 성능차이가 나는 부분이었다.
N+1문제를 생각나게하는 성능튜닝이었다.
아 그리고 deleteAll()과 deleteAllInBatch()는 인자로 아무것도 넘겨주지 않으면 그 테이블 데이터를 전체 삭제하겠다는 의미이다.
'Spring > JPA' 카테고리의 다른 글
QueryDSL과 1차 캐시 의문점 (0) | 2022.12.06 |
---|---|
@Transactional은 조회만 할 때 있어야할까? (4) | 2022.08.10 |
Spring Data JPA - 파라미터 바인딩 (0) | 2022.04.18 |
Spring Data JPA - DTO 직접 조회 (0) | 2022.04.15 |
Spring Data JPA - 쿼리 메서드 기능 (0) | 2022.04.14 |