본문 바로가기

프로젝트/트러블슈팅

[트러블 슈팅] n + 1 문제를 IN절로 해결하기

도입

원데이히어로 프로젝트에서 이미지 조회 시 n + 1 문제가 발생했다. 그래서 In절로 n + 1 문제를 해결하고 더 나아가 쿼리를 튜닝하여 성능을 개선하였다. 이번 글에서는 In절로 n + 1 문제를 해결한 경험만 이야기해 보겠다. 실행 계획을 통해 슬로우 쿼리를 튜닝한 경험은 다음 글에서 작성해 볼 계획이다. 

객체 사이의 관계

n + 1 문제 상황을 설명하기 전에 객체 사이의 관계에 대해 설명해 보겠다.

 

프로젝트 내 미션(Mission)이라는 도메인이 있다. 여기서 미션은 프로젝트 내 도메인 용어로 벌레 잡기, 포장, 청소처럼 소소한 일거리를 뜻한다. 미션은 이미지를 최대 5개 가질 수 있으므로 미션과 미션 이미지는 OneToMany 관계이다.

 

미션과 미션 이미지의 연관 관계

 

먼저 객체 참조와 아이디 참조 중 어떤 참조 관계를 선택할까? 미션이 삭제될 경우 미션 이미지도 함께 삭제되어야 하므로 생명주기가 어느 정도 일치한다. 따라서 강하게 연결되어 있으므로 객체 참조를 선택했다.

 

다음으로 누구를 연관 관계 주인으로 둘까? 비즈니스 상 미션이 주인이지만, 외래키를 관리하지 않는 미션이 연관 관계의 주인으로 둔다면 insert할 경우 미션 이미지에 대해서 update 쿼리가 날라간다. 따라서 미션 이미지를 연관관계 주인으로 두었다. 

 

마지막으로 양방향 매핑을 걸어줘야 할지 고민했다. 양방향 매핑 시, 객체 상태를 고려하여 항상 양쪽에 값을 등록해야 하므로 연관관계 메서드를 생성해야 한다. 이러한 연관 관계 메서드를 생성하고 객체를 서로 연결하는 로직이 복잡하기 때문에 양방향을 선호하지 않는다. 따라서 보통 함께 조회되고 생명 주기가 일치하는 경우에만 양방향 매핑을 걸어준다.

 

미션을 조회할 때 이미지를 함께 조회하고 싶으면 미션을 통해 미션 이미지를 조회하는게 비즈니스 상 옳다. 하지만 미션이 살아있을 동안 이미지가 생성될 수도 삭제될 수도 있어 생명주기가 완전히 일치하지는 않으므로 양방향 매핑은 걸어주지 않았다.

 

미션과 미션 이미지 엔티티 코드(일부 필드 생략)이다. 미션은 soft delete(논리 삭제)를 위해 @SQLDelete 어노테이션을 사용했다. 이때 soft delete된 미션은 조회되지 않도록 @Where 어노테이션을 사용했다. 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "mission_images")
@Entity
public class MissionImage extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "mission_id")
    private Mission mission;
    
    @Builder
    private MissionImage(
        Mission mission,
        String originalName,
        String uniqueName,
        String path
    ) {
        this.mission = mission;
        this.originalName = originalName;
        this.uniqueName = uniqueName;
        this.path = path;
    }
    
     public static MissionImage createMissionImage(
        Mission mission,
        String originalName,
        String uniqueName,
        String path
    ) {
        validNonNullMission(mission);
        return MissionImage.builder()
            .mission(mission)
            .originalName(originalName)
            .uniqueName(uniqueName)
            .path(path)
            .build();
    }
    
    private static void validNonNullMission(
        Mission mission
    ) {
        if (Objects.isNull(mission)) {
            throw new BusinessException(ErrorCode.INVALID_REQUEST_VALUE);
        }
    }
}

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE missions SET is_deleted = true WHERE id = ?")
@Where(clause = "is_deleted = false")
@Table(name = "missions")
@Entity
public class Mission extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

문제

미션과 해당 미션과 연관된 미션 이미지를 함께 조회할 때 n + 1 문제가 발생했다. 

 

앞서 말했듯이 미션 이미지는 미션을 통해서 조회하는게 비즈니상 옳다. 따라서 미션 레포지토리에서 미션과 미션 이미지를 join해서 가져와야 하는데 이렇게 되면 데이터 중복 문제가 발생한다. 

 

mission_id mission_image_id
mission_A image_1
mission_A image_2
mission_A image_3

 

데이터 중복 문제란, 왼쪽과 같은 관계일 때 미션과 미션 이미지를 join하면 미션의 데이터가 미션 이미지의 수만큼 데이터가 중복되는 문제를 말한다.

 

이 문제를 피하기 위해 미션을 페이징 조회한 뒤 조회된 미션들에 대한 이미지를 조회하고 응답 DTO를 만들어 반환했다. 

 

프로젝트 내에서 미션을 조회할 때 미션 이미지 뿐만 아니라 카테고리, 지역, 북마크 등 연관된 여러 객체와 함께 조회하기 때문에 조회 로직이 복잡하다. 따라서 이 글에서는 핵심만 보여주기 위해 코드를 간략화하였다.  

// java17
private final MissionReader missionReader;
private final MissionImageRepository missionImageRepository;

public Slice<MissionResponse> findAll(Pageable pageable) {
    var missions = missionReader.findAll(pageable);
    return missions.stream()
    	.map(m -> {
            var missionImages = missionImageRepository.findByMission(m);
            return MissionResponse.from(m, missionImages);
        }).toList();
}

 

 

 

스프링 부트 서버를 구동시키고 엔드포인트에 요청을 보내보면, 로그를 통해 조회된 미션의 수(n)만큼 이미지를 조회하는 쿼리가 n번 날라가고 있다. 

n + 1번 쿼리가 발생

 

JMeter로 n + 1 문제가 발생하는 엔드포인트에 대해 성능 테스트를 진행했다. 참고로 미리 데이터베이스에 미션 10만개, 미션 이미지에는 50만개의 더미 데이터를 넣어 두었다. 

 

100명의 유저가 100초 동안 생성되고 요청을 10번 반복하는 테스트를 진행한 결과, 평균 응답속도가 33811ms로 매우 느릴 뿐만 아니라 오류율이 79.20%로 매우 높았다. 트랜잭션이 길어지다보니 커넥션 고갈 현상이 나타났기 때문이다.

JMeter로 성능 테스트

해결

단건 반복 쿼리로 인해 n + 1번의 쿼리가 발생하고 성능이 매우 나빴다. 따라서 In절로 한 번에 연관된 이미지를 조회하여 1 + 1 쿼리만 발생하도록 개선했다. 

 

개선된 코드를 하나씩 살펴보자면, 먼저 미션을 페이징 조회한 뒤 조회된 미션들에서 아이디만 추출한다. 그리고 In절에 미션 아이디들을 넣어 연관된 이미지를 한꺼번에 가져온다. 이때 어떤 미션의 이미지인지 구분해야하기 때문에 미션 아이디도 함께 가져왔다.

 

다음으로 Collectors의 groupingBy를 이용해 미션 별로 이미지를 그루핑해줬다. 이렇게 생성된 미션 이미지의 Map을 사용해 응답 DTO를 만들었다. 

// java17
var missions = missionReader.findAll(pageable);

var missionIds = missions.getContent().stream()
    .map(Missions::getId)
    .toList();

var missionImageGroupingByMission = missionImageRepository.findByMissionIdIn(missionIds)
                .stream()
                .collect(Collectors.groupingBy(MissionImageQueryResponse::missionId));

missions.stream()
        .map(m -> {
            var missionId = m.id();
            var missionImages = missionImageGroupingByMission.get(missionId);
            return MissionResponse.from(queryResponse, missionImage);
        }).toList();

 

다시 전과 같은 환경에서 JMeter로 성능 테스트를 진행한 결과, 평균 응답속도가 5889ms이고 오류율이 0%로 이전보다 매우 준수한 성능을 보여주었다. 

 

 

다음으로 실행계획을 분석하고 쿼리를 튜닝하여 여기서 성능을 좀 더 개선해보겠다.

 

다음 글

[트러블 슈팅] 인덱스 컨디션 푸시다운, 인덱스를 이용한 정렬, 커버링 인덱스로 슬로우 쿼리 튜닝하기