성능 테스트와 쿼리 튜닝

이호석·2023년 8월 17일
1

서버 성능 향상

목록 보기
2/2

지난 시간

쿼리 튜닝하기로 한 이유

  • 우리 프로젝트는 숙박업체 예약 사이트인데, 이 쿼리가 홈페이지 처음 들어서면 실행되는 쿼리이다. 기본적인 숙박업체 리스트 조회 쿼리이며, 도시나 날짜 변경시마다 where조건과 함께 쿼리가 실행되므로 가장 많이 실행되는 쿼리라고 예상할 수 있다.
  • 중복된 이야기지만, DBCP와 Thread Pool 조정으로 만족할 만한 성능이 안나와서 이 쿼리를 튜닝하게 되었다.
  • Redis같은 NoSQL을 쓸까 하다가, 인프랩 CTO이신 이동욱님이 유튜브에서 '서버 환경 개선도 좋지만 쿼리 튜닝도 매우 좋은 경험이다'라고 하셔서 쿼리 튜닝을 하기로 결정하였다.

쿼리 소개

연관관계

숙박업체와 방은 일대다 관계이다.
숙박업체와 숙박업체 이미지는 일대다 관계이다.
숙박업체와 숙박업체 주소는 일대일 관계이다.
관심숙소 Entity는 숙소와 멤버 복합키로 이루어져있다.

DTO

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class GetHouseListRes {
    private long houseId;			//숙박업체 ID
    private String name;			//숙박업체 이름
    private HouseType type;			//숙박업체 종류
    private int price;				//제일 낮은 숙박업체 가격
    private String sido;			//숙박업체 위치 (시도)
    private String sigungu;			//숙박업체 위치 (시군구)
    private long roomId;			//대표 방 ID
    private double ratio;			//숙박업체 평균 평점
    private boolean soldout;		//품절 현황
    private boolean wished;			//관심숙소 등록 여부
    private List<String> houseImages;	//숙박업체 이미지 배열

    @QueryProjection
    public GetHouseListRes(long houseId, String name, HouseType type,
    						int price, String sido, String sigungu, long roomId, double ratio) {
        this.houseId = houseId;
        this.name = name;
        this.type = type;
        this.price = price;
        this.sido = sido;
        this.sigungu = sigungu;
        this.roomId = roomId;
        this.ratio = ratio;
        this.soldout = false;
        this.wished = false;
    }
}

Querydsl Repository

public List<GetHouseListRes> findAllHouse(Pageable pageable, SearchFilterReq searchFilter, Member member) {
	//숙박업체 리스트 가져오기
    List<GetHouseListRes> foundHouseList = jqf.select(new QGetHouseListRes(house.id, house.name, house.type,
                    room.price.min(), houseAddress.sido, houseAddress.sigungu, room.id.min(), house.avgRating))
            .from(house)
            .join(house.houseAddress, houseAddress).on(house.id.eq(houseAddress.house.id))
            .join(house.rooms, room).on(house.id.eq(room.house.id))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .groupBy(house.id, houseAddress.sido, houseAddress.sigungu)
            .orderBy(house.avgRating.desc())
            .fetch();

    //숙박업체 이미지 불러오기 및 관심숙소 등록여부 검증
    for (GetHouseListRes houseRes : foundHouseList) {
        // 이미지 불러오기
        houseRes.setHouseImages(findHouseImagesByHouseId(houseRes.getHouseId()));
            
        // 관심숙소 등록여부 검증
        if (member != null) {
            long result = jqf.select(wish.member.id.count())
                    .from(wish)
                    .where(wish.member.id.eq(member.getId()), wish.house.id.eq(houseRes.getHouseId()))
                    .fetchOne();
            if (result > 0) houseRes.setWished(true);
        }
    }
    return foundHouseList;
}

사실 이 쿼리는 내가 작성한 쿼리가 아니지만(다른 팀원 파트), 이미 끝난 프로젝트라 내가 개선해도 상관없다고 판단하였다. 물론 쿼리 전체를 그대로 가지고 온 것은 아니고 필요한 부분만 가져왔다. 쿼리 구조는 다음과 같다.

  • 조건에 따라서(where 조건 생략) 숙박업체 리스트를 가져오는 부분
  • 아래 문단(for문)
    • 각 숙박업체 이미지 배열을 추가해주는 부분
    • 만약 로그인을 했다면 관심 숙소로 등록 여부 확인

문제 상황 파악

일단 눈에 보이는 고쳐야할 점은 바로 for문의 쿼리이다. for문을 살펴보면 이미지를 불러오는 곳에서 다른 메소드를 호출하고(추가 쿼리 발생), 관심 숙소 등록 여부 확인에서 쿼리가 발생한다. 즉 findAllHouse 메소드를 호출하면 1+2N개의 쿼리가 자연스레 DB로 전송된다. 물론 이 쿼리는 limit로 20개씩 보여주기로 해서 단건의 요청은 쉽게 처리 하겠지만, 성능 테스트 실행시에는 문제가 생길 가능성이 충분하다(결과는 지난 포스트로 확인 가능하다).
일단 이미지부분부터 개선을 시도해보자.

일대다 연관관계에서 리스트 조회

일대다 관계에서 다 쪽을 일반 컬럼처럼 조회한다면

한방쿼리가 무조건적으로 좋다고 얘기하지 않는 지금, 만약 숙박업체 하나와 숙박업체 이미지들을 가져온다면, 그냥 각각 따로 가져와서 DTO로 합쳐도 성능상 크게 문제될 것은 없다. 하지만 숙박업체 N개와 각각의 숙박업체 이미지를 따로 가져오게 된다면 1+N개의 쿼리가 나오므로 신중히 결정할 필요가 있다.

처음에 House Entity를 직접 가져와서 house.getImages하는 방법(fetch join 이용)을 생각했는데, 우아한테크 영상에서 DTO로 조회하는 것을 권장하기도 했고, 무엇보다 이미지뿐만 아니라 관심숙소 부분도 처리해야 했기에 다른 방법을 찾기로 하였다.

Aggregation(transform) 사용

Querydsl 4.0 버전 한글 Document

Aggregation은 DB에서 결과를 가져온 다음에, 메모리에서 원하는 자료형으로 변형시킬 수 있는 쿼리 집계 기능이다. transform을 사용하면 groupBy에 지정된 key를 기준으로 list를 만들 수 있게 된다. 여기서 groupBy는 querydsl에서의 groupBy와 다르다.
Aggregation을 사용할 때 유의할 점은 이 기능은 DB에서 어플리케이션으로 데이터를 전부 가져오는 것이기 때문에 메모리 사용량이 증가할 수 있다는 점과 list부분의 데이터가 없어도 최소 데이터 개수가 1이 된다는 점이다.

DTO

@Getter
@Setter
@NoArgsConstructor
public class GetHouseListRes {
    private long houseId;
    private String name;
    private HouseType type;
    private int price;
    private String sido;
    private String sigungu;
    private long roomId;
    private double ratio;
    private boolean soldout;
    private boolean wished;
    private List<String> houseImages;

    @QueryProjection
    public GetHouseListRes(long houseId, String name, HouseType type, int price, String sido,
                           String sigungu, long roomId, double ratio, List<String> houseImages) {
        this.houseId = houseId;
        this.name = name;
        this.type = type;
        this.price = price;
        this.sido = sido;
        this.sigungu = sigungu;
        this.roomId = roomId;
        this.ratio = ratio;
        this.soldout = false;
        this.wished = false;
        this.houseImages = houseImages;
    }
}

Querydsl repository

import com.querydsl.core.group.GroupBy;

...
public List<GetHouseListRes> findAllHouseNoLogin(Pageable pageable, SearchFilterReq searchFilter) {
    return jqf.select(house)
            .from(house)
            .leftJoin(house.images, houseImage)
            .leftJoin(house.houseAddress, houseAddress)
            .leftJoin(house.rooms, room)
            .orderBy(house.avgRating.desc())
            .offset(pageable.getOffset())
            .groupBy(houseImage)
            .transform(GroupBy.groupBy(house.id).list(Projections.constructor(GetHouseListRes.class,
            			house.id, house.name, house.type, room.price, houseAddress.sido,
                        houseAddress.sigungu, room.id, house.avgRating, houseAddress.sido,
                        GroupBy.list(houseImage.savedURL)))
            );
}

여러 시도 끝에 적당한 쿼리를 만들었다. groupBy(houseImage)를 해준 이유는 일대다 조인을 두번하니, room개수에 따라서 houseImage가 중복되었기 때문이다.

querydsl에서 DTO로 조회할 때 Projection과 @QueryProjection을 이용하는 방법이 있는데 후자를 추천한다. Projection방식은 DTO 생성자로 생성시 런타임에만 에러가 잡히는 반면, @QueryProjection는 컴파일시에 에러를 체크할 수 있기 때문이다. @QueryProjection을 사용하면 위 코드는 아래 코드로 대체할 수 있다.

.transform(GroupBy.groupBy(house.id).list(new QGetHouseListRes(
    house.id, house.name, house.type, room.price, houseAddress.sido,
    houseAddress.sigungu, room.id, house.avgRating,
    GroupBy.list(houseImage.savedURL))
));

관심숙소 여부 체크

...
for (GetHouseListRes houseRes : foundHouseList) {
	...
    // 관심숙소 등록여부 검증
    if (member != null) {
        ...
    }
}

관심 숙소 여부 기능은 로그인한 유저한테만 해당하는 기능이다. 위와 같은 상황이면 for문을 돌 때마다 if문의 체크 기능이 실행 될 것이다. 따라서 repository가 아닌 service단에서 로그인 여부에 따라 다른 메소드가 실행되게끔 하였다.

@Override
public List<GetHouseListRes> readHouseList(Pageable pageable,
											SearchFilterReq searchFilter, Member member) {
    if(member == null){
        return houseQuerydsl.findAllHouseNoLogin(pageable, searchFilter);
    }
    return houseQuerydsl.findAllHouseLogin(pageable, searchFilter, member);
}

DTO

@QueryProjection
public GetHouseListRes(long houseId, String name, HouseType type, int price, String sido,
                       String sigungu, long roomId, double ratio, List<String> houseImages) {
    this.houseId = houseId;
    this.name = name;
    this.type = type;
    this.price = price;
    this.sido = sido;
    this.sigungu = sigungu;
    this.roomId = roomId;
    this.ratio = ratio;
    this.soldout = false;
    this.wished = false;
    this.houseImages = houseImages;
}

새로운 생성자를 추가해주고

Querydsl repository

public List<GetHouseListRes> findAllHouseLogin(Pageable pageable, 
												SearchFilterReq searchFilter, Member member) {
    return jqf.selectFrom(house)
            .leftJoin(house.images, houseImage)
            .leftJoin(house.houseAddress, houseAddress)
            .leftJoin(house.rooms, room)
            .leftJoin(house.wishList, wish).on(wish.house.eq(house), wish.member.eq(member))
            .orderBy(house.avgRating.desc())
            .offset(pageable.getOffset())
            .groupBy(houseImage)
            .transform(GroupBy.groupBy(house.id).list(new QGetHouseListRes(
                    house.id, house.name, house.type, room.price, houseAddress.sido,
                    houseAddress.sigungu, room.id, house.avgRating, 
                    wish.member.id.eq(member.getId()), GroupBy.list(houseImage.savedURL))
            )); 
}

관심숙소(wish) 테이블은 앞서말했듯이 복합키로 이루어졌기 때문에 house와 member에 대한 조건을 추가해주고, QGetHouseListRes를 생성할 때 wish.member.id.eq(member.getId())로 관심숙소 체크여부를 확인할 수 있다.

hibernate query

Hibernate: 
    select
        house0_.house_id as col_0_0_,
        house0_.house_id as col_1_0_,
        house0_.name as col_2_0_,
        house0_.type as col_3_0_,
        rooms3_.price as col_4_0_,
        houseaddre2_.sido as col_5_0_,
        houseaddre2_.sigungu as col_6_0_,
        rooms3_.id as col_7_0_,
        house0_.avg_rating as col_8_0_,
        wishlist4_.member_id=? as col_9_0_,
        images1_.saved_url as col_10_0_ 
    from
        house house0_ 
    left outer join
        house_image images1_ 
            on house0_.house_id=images1_.house_id 
    left outer join
        house_address houseaddre2_ 
            on house0_.house_id=houseaddre2_.house_id 
    left outer join
        room rooms3_ 
            on house0_.house_id=rooms3_.house_id 
    left outer join
        wish wishlist4_ 
            on house0_.house_id=wishlist4_.house_id 
            and (
                wishlist4_.house_id=house0_.house_id 
                and wishlist4_.member_id=?
            ) 
    where
        rooms3_.min_people<=? 
        and rooms3_.max_people>=? 
    group by
        images1_.id ,
        houseaddre2_.sido ,
        houseaddre2_.sigungu 
    order by
        house0_.avg_rating desc

기존 1+2N(41개)개의 쿼리기 나가던 것을 1개 쿼리가 나가게 바꾸었다.

성능 테스트 결과

전과 같은 조건이다.

Jmeter

  • 요약 보고서

  • TPS 그래프

그라파나

  • CPU 사용률

  • DBCP

AWS

  • EC2

  • RDS

결과 분석

  • 최초로 JVM의 CPU사용량이 100%를 달성하지 않았고, TPS그래프를 확인하면 테스트 예상 시간인 20초에 끝낸 것을 확인할 수 있다.
  • TPS는 211로 확인되었다.

요약

숙박업체 리스트 조회

  • 기존
    1. TPS : 65
    2. 응답 시간 : 2787ms
  • DBCP & Thread Pool 조정
    1. TPS : 117
    2. 응답 시간 : 1622ms
  • 쿼리 튜닝
    1. TPS : 211
    2. 응답 시간 : 880ms

기존보다 3배 이상 증가한 TPS

테스트할 때 초당 Thread수가 200이고, DB에 데이터가 많지 않다는 것을 생각하면 TPS가 아주 잘 나온다고 보기는 힘들듯 하다. 여기서 서버 성능을 더 이끌어 낼려면 auto scaling, NoSQL 사용등 다른 방식을 적용해야 한다.

1개의 댓글

comment-user-thumbnail
2023년 8월 17일

개발자로서 배울 점이 많은 글이었습니다. 감사합니다.

답글 달기