회원 삭제 N+1 문제 - @Query로 해결하기 (Feat. 커버링 인덱스)

후추·2023년 11월 8일
0
post-thumbnail

들어가기

트립드로우 팀 프로젝트를 진행하고 있습니다.

프로젝트에서 회원 삭제 API를 개발했고, 더미 데이터가 존재하는 DEV 서버에서 회원 삭제 API를 테스트 해보았습니다.

깜짝 놀랄 결과가 나왔습니다.

API 요청을 처리하는데 총 16초 이상이 걸렸고, 쿼리가 19078개 실행 되었습니다.

어째서 이런 문제가 발생하는지 파악하고 문제를 해결해보았습니다.

회원 삭제 N+1 문제

배경

회원 삭제는 서비스에서 회원에 관한 모든 데이터를 삭제하는 것을 의미합니다.

서비스에서 회원과 관련한 데이터는 Member, Trip, Post, Point 입니다.

member_id 를 통해 회원 삭제 요청을 하면, Member, Trip, Post, Point가 Hard Delete 됩니다.

DB Schema를 그려보면 다음과 같습니다.

Trip과 Point는 1:N 관계, Point와 Post는 1:1 관계입니다.

삭제했던 회원은 Trip 약 100개, Trip 하나 당 Point 약 100개, Point 마다 Post 하나씩을 갖고 있었습니다.

따라서 쿼리 19078개는 삭제된 데이터의 수와 동일했습니다.

이를 통해 JPA N+1 문제가 발생했다고 유추하고 쿼리 로그를 살펴보았습니다.

쿼리

쿼리 로그를 보기 위해 실험을 진행했습니다.

Trip 개수, Point 개수, Post 개수를 달리하며, 회원을 삭제했습니다.

실험 결과

실험 결과 회원 삭제 시 실행되는 쿼리를 다음과 같이 관찰했습니다.

select post_id,
       address,
       created_at,
       member_id,
       point_id,
       post_image_url,
       route_image_url,
       title,
       trip_id,
       updated_at,
       writing
from post
where member_id = ?
-- 회원 Id로 감상을 검색했을 때, 감상이 여러 개라면 감상의 개수만큼 point를 검색합니다.
select point_id,
       created_at,
       has_post,
       latitude,
       longitude,
       recorded_at,
       trip_id,
       updated_at
from point
where point_id = ?
select trip_id,
       created_at,
       image_url,
       member_id,
       name,
       route_image_url,
       status,
       updated_at
from trip
where member_id = ?
-- 회원 Id로 여행을 검색했을 때, 여행이 여러 개라면 여행의 개수만큼 point를 검색합니다.
select trip_id,
       point_id,
       created_at,
       has_post,
       latitude,
       longitude,
       recorded_at,
       updated_at
from point
where trip_id = ?
select member_id,
       created_at,
       nickname,
       oauth_id,
       oauth_type,
       updated_at
from member
where member_id = ?
-- 지워야 할 감상의 개수만큼 쿼리가 실행됩니다.
delete
from post
where post_id = ?
-- 지워야 할 위치정보의 개수만큼 쿼리가 실행됩니다.
delete
from point
where point_id = ?
-- 지워야 할 여행의 개수만큼 쿼리가 실행됩니다.
delete
from trip
where trip_id = ?
delete
from member
where member_id = ?

이처럼 Trip, Point, Post 데이터의 개수에 따라 쿼리가 n 개 이상 실행되는 문제가 발생하고 있었습니다.

원인 파악

백엔드에서는 각 데이터를 삭제할 때 JPA의 deleteSomethingById() 메서드를 사용했습니다.

스크린샷 2023-09-23 오후 10 19 14

스크린샷 2023-09-23 오후 10 19 53

이 메서드는 entity를 삭제할 때, 먼저 select 문으로 entity를 찾습니다.

select post_id,
       address,
       created_at,
       member_id,
       point_id,
       post_image_url,
       route_image_url,
       title,
       trip_id,
       updated_at,
       writing
from post
where member_id = ?
-- 회원 Id로 감상을 검색했을 때, 감상이 여러 개라면 감상의 개수만큼 point를 검색합니다.
select point_id,
       created_at,
       has_post,
       latitude,
       longitude,
       recorded_at,
       trip_id,
       updated_at
from point
where point_id = ?

그후 entity의 id를 조건으로 delete 문을 실행합니다.

-- 지워야 할 감상의 개수만큼 쿼리가 실행됩니다.
delete
from post
where post_id = ?

따라서 회원이 여러 Post를 갖는 경우 Post의 개수만큼 관련된 select 문을 실행하거나 delete 문을 실행하는 상황이 발생하는 것입니다.

해결

Post 삭제

public interface PostRepository extends JpaRepository<Post, Long> {
    
    //(...)
    @Modifying
    @Query("DELETE FROM Post p WHERE p.member.id = :memberId")
    void deleteByMemberId(@Param(value = "memberId") Long memberId);
}

@Query 애노테이션을 통해 JPQL로 delete 문을 작성합니다.

WHERE 조건절로 memberId 에 해당하는 모든 Post를 지우는 것입니다.

위와 같이 JPQL을 작성함으로써 하나의 쿼리로 모든 Post가 지워질 수 있습니다.

Trip 삭제

Trip 삭제는 다소 복잡합니다.

Trip을 삭제하기 위해서는 Trip에 포함된 모든 Point를 우선 삭제해야 합니다.

Point 테이블이 trip_id를 외래키로 갖고 있기 때문입니다.

외래키로 인한 데이터 무결성 제약 조건을 만족하면서 삭제를 해야 합니다.

따라서 다음과 같은 과정을 밟습니다.

  • Trip table에서 회원 Id로 삭제할 여행 Id의 목록을 조회합니다.
  • Point table에서 IN() 문법으로 trip_id가 삭제할 여행 Id 목록에 포함되면 삭제합니다.
  • Trip table에서 회원 Id로 여행을 삭제합니다.
@RequiredArgsConstructor
@Component
public class TripDeleteEventHandler {

    private final TripRepository tripRepository;
    private final PointRepository pointRepository;

    @EventListener
    public void deletePostByMemberId(MemberDeleteEvent event) {
    	// 회원 Id로 삭제할 Trip Id의 목록 조회
        List<Long> tripIds = tripRepository.findAllTripIdsByMemberId(event.memberId());
        // IN() 문법으로 trip_id가 삭제할 tripIds 에 포함되면 삭제
        pointRepository.deleteByTripIds(tripIds);
        // 회원 Id로 Trip 삭제
        tripRepository.deleteByMemberId(event.memberId());
    }
}

Point를 삭제하기 위해 Trip 을 직접 조회할 수도 있습니다.

하지만 TripId의 목록을 조회한 후, 이 TripId를 통해 Point를 지우는 것으로 결정했습니다.

public interface TripRepository extends JpaRepository<Trip, Long> {

    // (...)

    List<Trip> findAllByMemberId(Long memberId);

    @Query("SELECT t.id FROM Trip t WHERE t.member.id = :memberId")
    List<Long> findAllTripIdsByMemberId(@Param(value = "memberId") Long memberId);

    @Modifying
    @Query("DELETE FROM Trip t WHERE t.member.id = :memberId")
    void deleteByMemberId(@Param(value = "memberId")
}

구체적으로 @Query를 설정한 findAllTripIdsByMemberId 메서드를 활용해 TripId의 목록을 조회했습니다.

findAllByMemberId 로 List<Trip>을 조회할 수도 있지만 이러한 방법을 택한 것은 TripId 만 조회하는 것이 특별히 더 빠르기 때문입니다.

Trip table에서 member_id 는 FK이고, trip_id 는 PK 입니다.

즉, 두 컬럼 모두 index로 설정되어 있습니다.

따라서 member_id 로 trip_id를 조회하는 것은 커버링 인덱스에 해당합니다.

커버링 인덱스는 테이블을 읽는 과정이 없기 때문에 속도가 빠릅니다.

스크린샷 2023-09-23 오후 10 41 42

이후 Point 삭제, Trip 삭제는 @Query 를 활용합니다. 하나의 쿼리로 모든 Point, Trip 이 지워질 수 있도록 합니다.

public interface PointRepository extends JpaRepository<Point, Long> {

    @Modifying
    @Query("DELETE FROM Point p WHERE p.trip.id IN :tripIds")
    void deleteByTripIds(@Param(value = "tripIds") List<Long> tripIds);
}

리팩터링 이후 더 이상 N + 1 문제가 발생하지 않는 것을 확인했습니다.

회원을 삭제할 때 이제 데이터의 수와 관계없이 7개의 쿼리가 실행됩니다.

또한 삭제 요청 처리 시간도 299ms 로 매우 단축된 것을 확인할 수 있습니다.
(단, 삭제 요청 처리 시간은 데이터의 개수에 따라 차이가 날 수 있습니다.)

0개의 댓글

관련 채용 정보