개발자취

프로젝트의 쿼리 개선 히스토리 돌아보기

SeongOnion 2024. 2. 19. 23:44
728x90

서론

입사 후 가장 많은 커밋을 남긴 프로젝트라고 한다면 역시 PHP 기반 레거시 코드를 Java + Spring Boot 기반 프로젝트로 마이그레이션 한 메일 API가 되겠다.

 

비슷한 시기에 개발된 모바일 애플리케이션에서만 선제적으로 배포되어 다소 시험적으로 운영되던 해당 프로젝트는 이제 정말 웹 환경에서의 본격적인 릴리즈를 앞두고 있다. (두근두근)

 

릴리즈가 얼마 남지 않은 현 시점에서, 프로젝트 중 가장 많은 트래픽을 받을 것으로 예측되는 엔드포인트의 쿼리가 어떤 식으로 개선되어왔는지 정리하고자 한다.

 

참고로 사내 메일 데이터는 MySQL 데이터베이스에서 관리되고 있으며, 본문에 작성되는 코드들은 모두 예시를 위해 임의로 작성되었음을 알린다.

 

문제상황

메일 API에서 가장 많은 트래픽을 받는 엔드포인트는 무엇일까?

 

그렇다. 너무나도 당연하게 메일 목록을 조회하는 엔드포인트가 되겠다. (GET /mails)

 

사용자가 메일 서비스에서 가장 첫 번째로 만나는 기능이자 동시에 가장 많은 경험을 쌓는 기능인만큼, 메일 목록 조회 API와 관련된 요구사항은 정말 단순히 메일 목록을 조회하는 것만으로는 끝나지 않는다.

 

메일과 관련된 여러 부가 기능들, 예컨데 수신확인 기능이나 메일 관련 인증 정보, 서비스에서 제공하고 있는 암호 메일, 승인 메일 기능 관련 정보 등... 연관된 요구사항을 충족시키기 위해 함께 내려줘야할 데이터들이 너무나도 많았다.

 

요구사항 이행은 당연하고, 동시에 성능까지 최적화시켜 사용자 경험(UX)을 해치지 않도록 하는 것이 우리 팀이 풀어야 할 문제였다.

 

단계적인 해결 과정

1. API 내부에서 동적 호출

처음엔 역시 API 부터 만지작거렸다.

 

가장 먼저 고려했던 방향성은 메일 조회 API에서는 정말 메일 정보만을 내려주고, 이외 필요한 부가적인 정보들은 별도 API로 분리해 클라이언트 측에서 조립하여 사용하도록 하는 것이었다. (MSA틱한..)

 

하지만 메일 목록 조회에 페이징 요구사항이 당연히 있었기 때문에, 개별 메일 데이터마다 API를 호출해주지 않는 한 필요한 데이터들을 한 번의 API 호출을 통해 가져올 수 있는 방법을 도저히 찾을 수 없었다.

 

결국, 부가적인 정보들에 대해 반드시 호출이 필요한 것들과 그렇지 않은 것들을 나누고 선택적으로 호출할 수 있는 데이터들에 대해선 with와 같은 쿼리 파라미터로 대상 데이터를 함께 요청할 때만 내려주도록 하였다.

 

예컨대 메일에 대한 인증 정보는 목록 조회 시 반드시 포함되어야하지만, 수신확인정보는 보낸 편지함에 저장된 메일을 조회할 때만 응답에 포함되면 된다. 

 

따라서 쿼리는 아래와 같이 변경되었다.

 

AS-IS

@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<Mail> findList(SearchDto searchDto, Pageable pageable) {
        JPAQuery<Mail> query = jpaQueryFactory.select(mail)
                .from(mail)
                .leftJoin(mailAuth).on(mailAuth.mailId.eq(mail.id))
                .leftJoin(readReceipt).on(readReceipt.mailId.eq(mail.id)) // 연관 데이터 모두 JOIN
                .where(
                	...
                );

        JPQLQuery<Mail> mailJPQLQuery = querydsl.applyPagination(pageable, query);

        return PageableExecutionUtils.getPage(mailJPQLQuery.fetch(), pageable, mailJPQLQuery::fetchCount);
    }
}

 

TO-BE

@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<Mail> findList(SearchDto searchDto, Pageable pageable) {
        JPAQuery<Mail> query = jpaQueryFactory.select(mail)
                .from(mail)
                .leftJoin(mailAuth).on(mailAuth.mailId.eq(mail.id))
                .where(
                	...
                );
        
        if (searchDto.isWithReadReceipt()) { // 수신확인 데이터 조회 요청시에만 조인
        	query.leftJoin(readReceipt).on(readReceipt.mailId.eq(mail.id))
        }

        JPQLQuery<Mail> mailJPQLQuery = querydsl.applyPagination(pageable, query);

        return PageableExecutionUtils.getPage(mailJPQLQuery.fetch(), pageable, mailJPQLQuery::fetchCount);
    }
}

덕분에 상황에 따라 불필요한 JOIN 쿼리를 줄일 수 있었을 뿐 아니라, 메일 목록 조회 시 필요한 부가 정보들에 대한 요구사항이 추가되어도 기존 기능에 성능 부담을 주는 것 없이 보다 유연하게 대처 가능한 코드가 되었다.

 

2. Query Projection

대부분의 경우에 QueryDSL 및 Spring Data JPA 환경에서 실행시킨 쿼리의 결과들을 엔티티 클래스로 바로 매핑시키곤 한다.

 

이는 보통 ORM을 사용함에 있어 객체지향성을 위해 내려지는 결정인데, 해당 방식은 엔티티로 정의된 클래스의 모든 필드값들이 SELECT 쿼리에 포함되기 때문에 불필요한 필드들까지 모두 조회된다는 단점이 존재한다.

 

따라서 최대한의 성능을 끌어올리는 것이 요구될 때엔 Query Projection을 사용해 쿼리를 응답으로 내려줘야 할 API에 핏하게 맞추도록 개선할 수 있다.

 

메일 목록 조회 API는 성능 개선을 위해 객체지향성을 어느정도 포기할만한 가치가 충분했으므로 Query Projection을 적용하였다.

 

특히나 메일 데이터의 경우 실제 테이블에 정의된 컬럼 개수가 많았고, API에서 응답으로 내려줘야할 정보들은 그 중 일부에 불과했기 때문에 Query Projection을 통한 불필요한 필드 조회의 제거 효과가 더 클 것이라 예상하였다.

 

AS-IS

@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<Mail> findList(SearchDto searchDto, Pageable pageable) {
        JPAQuery<Mail> query = jpaQueryFactory
        	.select(mail)
            ...
        
        JPQLQuery<Mail> mailJPQLQuery = querydsl.applyPagination(pageable, query);
        return PageableExecutionUtils.getPage(mailJPQLQuery.fetch(), pageable, mailJPQLQuery::fetchCount);
    }
}

 

TO-BE

public class MailResponseDto {
    private final long id;
    ...

    @QueryProjection
    public MailResponseDto(long id, ...) {
        this.id = id;
        ...
    }
}
@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<MailResponseDto> findList(SearchDto searchDto, Pageable pageable) {
        JPAQuery<MailResponseDto> query = jpaQueryFactory
	        .select(new QMailResponseDto(
            			mail.id,
                		...
            			))
            ...
                
        JPQLQuery<MailResponseDto> mailJPQLQuery = querydsl.applyPagination(pageable, query);

        return PageableExecutionUtils.getPage(mailJPQLQuery.fetch(), pageable, mailJPQLQuery::fetchCount);
    }
}

실제로 Query Projection 적용 후 10~20 가량의 TPS 개선 효과를 확인할 수 있었다!

 

3. 커버링 인덱스

성능이 가장 드라마틱하게 개선됐던 것은 커버링 인덱스를 적용한 후 였다.

 

커버링 인덱스란 SELECT, WHERE, ORDER BY, GROUP BY 등에 사용된 컬럼들이 모두 인덱스 컬럼에 포함된 경우를 말한다.

 

인덱스 블록에 조회하고자 할 컬럼의 데이터가 모두 존재하므로 실제 데이터 블록까지 접근할 필요 없이 B-Tree 스캔만으로 데이터를 획득할 수 있어 성능을 크게 향상시킬 수 있다.

 

QueryDsl, 정확히 말하자면 JPQL을 사용할 때 커버링 인덱스를 적용하는 가장 보편적인 방법(나는 우아콘을 통해 알게된)은 커버링 인덱스를 사용해 원하는 데이터의 PK값을 가져오고, 해당 PK값으로 실제 데이터 블록을 가져오도록 하는 방식이다.

 

다시 말해, 커버링 인덱스 조회 쿼리 + 데이터 블록 조회 쿼리를 나누는 것이다.

 

AS-IS

@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<MailResponseDto> findList(SearchDto searchDto, Pageable pageable) {
        JPAQuery<MailResponseDto> query = jpaQueryFactory
	        .select(new QMailResponseDto(
            			mail.id, 
                        	mail.subject, // 인덱스 컬럼에 포함되어있지 않은 필드
                		...
            			))
            .where(
            	createWherePredicate(searchDto)
            )
            ...
                
        JPQLQuery<MailResponseDto> mailJPQLQuery = querydsl.applyPagination(pageable, query);

        return PageableExecutionUtils.getPage(
        	mailJPQLQuery.fetch(), 
            pageable,
            mailJPQLQuery::fetchCount); // 카운트 쿼리 또한 커버링 인덱스를 타지 못함
    }
}

Query Projection까지만 적용된 커버링 인덱스 적용 전의 쿼리를 보자.

 

SELECT 절에 조회할 필드가 선언되어있는데, 만약 필드 중 인덱스 컬럼에 포함되어있지 않은 것이 하나라도 존재하면 절대로 커버링 인덱스를 탈 수가 없다.

 

따라서, 대상이 되는 데이터의 PK만 우선 조회한 후, 조회된 PK 값으로 실제 데이터블록을 다시 한 번 조회하는 방식으로 커버링 인덱스를 유도할 수 있다. (물론 searchDto를 통해 WHERE 조건을 만드는 과정에서 인덱스 컬럼에 포함되어있지 않은 컬럼이 추가된다면 어쩔 수 없다)

 

또한, 이는 메일 데이터 조회 뿐 아니라 Count 쿼리에도 동일하게 적용 가능하다.

 

해당 내용의 적용된 코드의 예시는 아래와 같다.

 

TO-BE

@RequiredArgsConstructor
@Component
public class MailRepositoryImpl implements MailRepository {

    private final JPAQueryFactory jpaQueryFactory;
    private final Querydsl querydsl;

    public Page<MailResponseDto> findList(SearchDto searchDto, Pageable pageable) {
        Page<Long> idQuery = getIdQuery(searchDto, pageable);
		
        // PK를 in 조건으로 조회하여 실제 데이터 블록 접근
        JPAQuery<MailResponseDto> query = jpaQueryFactory
                .select(new QMailReseponseDto(
                	mail.id,
                    mail.subject,
                    ...
                ))
                .from(mail)
                .where(mail.id.in(idQuery.getContent()));
		
        List<MailResponseDto> result = querydsl.applySorting(pageable.getSort(), query).fetch();

        return new PageImpl<>(result, idQuery.getPageable(), idQuery.getTotalElements());
    }

	// PK(id)만 우선 조회하여 커버링 인덱스 타도록 유도
    private Page<Long> getIdQuery(SearchDto searchDto, Pageable pageable) {
        JPAQuery<Long> idQuery = jpaQueryFactory
                .select(mail.id)
                .from(mail)
                .where(
                	createWherePredicate(searchDto)
                );

        JPQLQuery<Long> idPageQuery = querydsl.applyPagination(pageable, idQuery);

        return PageableExecutionUtils.getPage(
        	idPageQuery.fetch(), 
            pageable, 
            idPageQuery::fetchCount); // 카운트 쿼리도 커버링 인덱스 적용
    }

}

 

4. 중간점검과 남은 문제들

위 세 가지 사항 적용을 통해 팀에서 파악할 수 있는 수준까지는 쿼리 튜닝이 완료되었다.

 

튜닝 완료 nGrinder를 통해 측정한 TPS는 Vusers 250기준 260~270대로 측정되었다.

해당 테스트가 k8s 파드가 1개인 환경에서 실시되었다는 점과 약 60만개 이상이라는 상당히 많은 수의 메일 데이터를 가진 계정에서 진행되었다는 점을 미루어보았을 때, 나름 만족할만한 지표라고 보인다.

 

다만 여전히 TPS가 급락하는 케이스가 존재한다.

 

바로 쿼리가 기대하지 않았던 인덱스를 타는 경우이다.

 

꽤 오래 사용된 데이터베이스 테이블답게 메일 테이블에는 출처나 쓰임새가 불분명한 인덱스들이 다수 존재하고 이로 인해 간헐적(꽤 많이 간헐적)으로 우리가 원하는 인덱스를 타지 않는 경우가 있다.

 

네이티브 쿼리를 사용하는 경우 USE INDEX 등을 사용하여 충분히 특정 인덱스를 강제해줄 수 있지만, QueryDsl의 경우 JPAQuery 사용을 유지하는 경우 해당 기능을 지원하지 않는 것으로 보인다. (사실 QueryDsl이 특정 DBMS에 종속된 라이브러리가 아니므로 어찌보면 당연하다)

 

이 문제를 해결하기 위해서 여러가지 방법을 시도해봤지만... 사실상 QueryDsl를 거쳐 하이버네이트가 쿼리를 생성하는 과정에 끼어들기 매우 어려워 여러가지 한계에 부딪혔다.

 

현재로는 최종적으로 완성된 쿼리의 WHERE 절을 정규식으로 잡아 WHERE 절 앞에 INDEX를 강제로 주입하는 방법을 사용하고 있지만, JOIN 등의 쿼리가 필요한 상황에선 해당 방식이 통하지 않아 한계에 봉착했다.

 

하이버네이트의 MySQL 관련 Dialect를 좀 더 뒤져보면 뭔가 나올 것 같아보이긴하는데.. 이 문제만큼은 정말 꼭꼭 해결하고 싶다!