트립드로우 팀 프로젝트를 진행하고 있습니다.
프로젝트에서 회원 삭제 API를 개발했고, 더미 데이터가 존재하는 DEV 서버에서 회원 삭제 API를 테스트 해보았습니다.
깜짝 놀랄 결과가 나왔습니다.
API 요청을 처리하는데 총 16초 이상이 걸렸고, 쿼리가 19078개 실행 되었습니다.
어째서 이런 문제가 발생하는지 파악하고 문제를 해결해보았습니다.
회원 삭제는 서비스에서 회원에 관한 모든 데이터를 삭제하는 것을 의미합니다.
서비스에서 회원과 관련한 데이터는 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()
메서드를 사용했습니다.
이 메서드는 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 문을 실행하는 상황이 발생하는 것입니다.
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에 포함된 모든 Point를 우선 삭제해야 합니다.
Point 테이블이 trip_id를 외래키로 갖고 있기 때문입니다.
외래키로 인한 데이터 무결성 제약 조건을 만족하면서 삭제를 해야 합니다.
따라서 다음과 같은 과정을 밟습니다.
삭제할 여행 Id의 목록
을 조회합니다.삭제할 여행 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를 조회하는 것은 커버링 인덱스에 해당합니다.
커버링 인덱스는 테이블을 읽는 과정이 없기 때문에 속도가 빠릅니다.
이후 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 로 매우 단축된 것을 확인할 수 있습니다.
(단, 삭제 요청 처리 시간은 데이터의 개수에 따라 차이가 날 수 있습니다.)