본문 바로가기

JPA

(1) Querydsl 성능 최적화와 테스트

목차

1. 서론

2. dto로 조회하기

3. 성능 테스트

4. 결론

 

1. 서론

기존에 JPA를 사용하면서 생기는 N+1 문제를 모두 해결했었습니다.

또한 fetch join, batch_size를 사용하여 쿼리 횟수를 최소화하였으며, 중복되는 insert 쿼리를 jdbc template을 사용하여 bulk 쿼리로 대체하는 모든 최적화 과정을 마쳤었습니다.

하지만 DTO로 필요한 데이터만 가져와 최적화하는 것은 하지 않았기 때문에, 이 글에서 기존의 조회 API를 DTO로 조회하면 성능 차이가 얼마나 생길지, 적용 후 테스트 하고자 합니다.

 

2. DTO로 조회하기

DTO로 조회하는 것을 기존에 하지 않았던 이유

  • 1:N 관계에 있는 컬렉션은 DTO로 바로 조회하지 못합니다.
    one 쪽 엔티티의 id(들)을 사용하여 many 쪽 엔티티들을 in 절을 사용해서 조회한다면 1번의 추가 쿼리로 모두 조회할 수 있지만, 그 이후에 many 쪽의 중복들을 거르는 코드까지 추가 되어, 코드의 복잡성이 높아집니다.
  • API 스펙에 맞춘 코드가 repository에 들어가게 되고, 이는 재사용성이 떨어집니다.

위의 이유 등으로 DTO로 바로 조회하는 방법을 사용하지 않았었지만,

QueryDSL을 사용하면 그나마 편리해지고,

단순 조회는 API 스펙에 맞춰 하기 때문에 Service 계층을 최소화하거나 없앨 수 있어 트랜잭션 범위가 짧아지고 DB 커넥션을 빠르게 반환하기에 동시에 많은 트래픽이 몰릴 때 더 많은 양을 처리할 수 있을 것이라 생각하여 이를 테스트 해보기로 합니다.

 

DTO로 조회하는 방법

방법은 Setter 사용, 필드 직접 접근, 생성자 사용, 생성자 + @QueryProjection 4가지 방법이 있습니다.

 

1. Setter

@Getter
@Setter
public class MemberDto { 
    private String username; 
    private int age;
    
    public MemberDto() { 
    }
    
    public MemberDto(String username, int age) { 
    	this.username = username;
    	this.age = age;
    } 
}

List<MemberDto> result = queryFactory
        .select(Projections.bean(MemberDto.class, 
                member.username, 
                member.age))
        .from(member) 
        .fetch();

롬복으로 @Setter를 사용하거나, 직접 필드를 set하는 함수를 만들어야 합니다.

 

2. 필드 직접 접근

public class MemberDto { 
    private String username; 
    private int age;
    
    public MemberDto() { 
    }
    
}

List<MemberDto> result = queryFactory
        .select(Projections.fields(MemberDto.class, 
                member.username,
                member.age)) 
        .from(member) 
        .fetch();
        
//별칭이 다를 경우
List<MemberDto> result = queryFactory
        .select(Projections.fields(MemberDto.class, 
                member.username.as("username"),
                member.age)) 
        .from(member) 
        .fetch();

기본 생성자가 필요하고, 필드가 private이라도 다른 라이브러리에서 처리해주기 때문에 괜찮습니다.

 

3. 생성자 사용

public class MemberDto { 
    private String username; 
    private int age;
    
    public MemberDto(String username, int age) { 
    	this.username = username;
    	this.age = age;
    } 
}

List<MemberDto> result = queryFactory
        .select(Projections.constructor(MemberDto.class, 
                member.username,
                member.age)) 
        .from(member) 
        .fetch();
}

생성자 파라미터의 순서와 맞아야 하기 때문에 주의가 필요합니다.

 

4. 생성자 + @QueryProjection

public class MemberDto {
	private String username;
    private int age;
    
    public MemberDto() {
    }
    
    @QueryProjection
    public MemberDto(String username, int age) {
    	this.username = username;
        this.age = age;
    }
}

List<MemberDto> result = queryFactory
        .select(new QMemberDto(member.username, member.age)) 
        .from(member)
        .fetch();

컴파일러로 타입을 체크할 수 있으므로 가장 안전한 방법입니다.

하지만 DTO에 QueryDSL 어노테이션이 적용되어 있어야 하고, DTO도 Q 파일을 생성해야하며 DTO가 QueryDSL과 의존성이 생긴다는 단점이 있습니다.

 

적용하기

저는 DTO에 QueryDSL과 의존성을 만들지 않기 위해 기본 생성자를 사용하는 방법을 선택했습니다.

@Getter
@NoArgsConstructor
public class GetBoardListResponseDto {
    private Long boardId;
    private String title;
    private String name;
    private String content;
    private String representImage;
    private Integer view;
    private Integer bookmarkCount;
    private ContractState contractState;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

	.......
    
    
}

 

위 GetBoardListResponseDto 클래스로 DTO를 바로 페이징 조회하여, 리스트로 클라이언트에 반환합니다.

    public List<GetBoardListResponseDto> getBoardListDtoByQuery(Pageable pageable) {

        JPAQuery<GetBoardListResponseDto> getBoardListDtoQuery = jpaQueryFactory
                .select(Projections.fields(GetBoardListResponseDto.class,
                        board.boardId,
                        board.title,
                        board.user.name,
                        board.content,
                        board.representImage,
                        board.view,
                        board.bookmarkCount,
                        board.contract.contractState,
                        board.createdAt,
                        board.modifiedAt))
                .from(board)
                .join(board.user, user)
                .join(board.contract, contract)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());

        sort(getBoardListDtoQuery, pageable);

        return getBoardListDtoQuery.fetch();

    }

 

User와 Contract는 Board에게 ToOne 관계이기 때문에, 기존에는 성능 최적화를 위해 fetch join하여 한 번에 조회했었습니다. 하지만 위 GetBoardListResponseDto를 보면, User에서 필요한 정보는 name밖에 없고, Contract에서 필요한 정보는 contractState밖에 없습니다. 

즉, User와 Contract에서 각 1개 필드를 제외한 나머지 정보는 필요하지 않은 정보이기 때문에 오버헤드가 될 수 있습니다.

그렇기 때문에, fetch join이 아닌 DTO로 바로 조회한다면 오버헤드가 없어지고 성능이 더 좋아질 것이라 예상합니다.

 

테스트 코드

@SpringBootTest
class BoardQueryRepositoryTest {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private ContractRepository contractRepository;
    @Autowired
    private BoardRepository boardRepository;

    @Autowired
    private BoardQueryRepository boardQueryRepository;


    @BeforeEach
    public void setUp() {
        User user = User.create("email@email.com", "name", "picture", "value", List.of("USER"));
        Contract contract = Contract.create(user, "title", "content");

        List<Board> boards = new ArrayList<>();

        for (int i = 1; i <= 30; i++) {
            Board board = Board.create(user, "title " + i, "content " + i, "image " + i, "info " + i, contract);
            boards.add(board);
        }

        userRepository.save(user);
        contractRepository.save(contract);
        boardRepository.saveAll(boards);
    }

    @AfterEach
    public void tearDown() {
        boardRepository.deleteAllInBatch();
        contractRepository.deleteAllInBatch();
        userRepository.deleteAllInBatch();
    }

    @Test
    @DisplayName("게시글 리스트를 dto로 조회하고, dto의 필드들은 null이 아니다.")
    void 게시글_리스트_dto_조회() {

        //given
        PageRequest pageRequest = PageRequest.of(0, 10);

        //when
        List<GetBoardListResponseDto> result = boardQueryRepository.getBoardListDtoByQuery(pageRequest);

        //then
        GetBoardListResponseDto dto = result.get(0);

        assertThat(dto.getBoardId()).isNotNull();
        assertThat(dto.getName()).isNotNull();
        assertThat(dto.getTitle()).isNotNull();
        assertThat(dto.getContent()).isNotNull();
        assertThat(dto.getContractState()).isNotNull();
        assertThat(dto.getRepresentImage()).isNotNull();
        assertThat(dto.getBookmarkCount()).isNotNull();
        assertThat(dto.getView()).isNotNull();
        assertThat(dto.getCreatedAt()).isNotNull();
        assertThat(dto.getModifiedAt()).isNotNull();

    }

}

 

setUp()에서 30개의 Board를 저장했으며, DTO로 조회 시 모든 필드가 null이 아닌 것으로 테스트 했습니다.

테스트 코드 결과

 

쿼리 비교

엔티티 조회 쿼리
DTO 조회 쿼리

기존의 연관 관계에 있는 엔티티들을 fetch join하여 가져오는 방식과 DTO로 조회하는 방식의 쿼리를 비교했습니다.

결과는 한 눈에 보아도 가져오는 데이터 양의 차이가 큽니다.

엔티티를 fetch join하여 가져오는 방식은 연관된 엔티티와 사용하지 않는 데이터까지 모두 가져와서 한 화면에 다 담을 수가 없었습니다. 또한 User들의 ROLE은 1:N으로 별도의 테이블에 저장되어 있기 때문에 사용하지 않는 ROLE 데이터들까지 모두 가져와 1번의 쿼리가 추가되었습니다.

 

반면 DTO로 조회하는 쿼리는 필요한 데이터들만 가져오기 때문에, 쿼리와 가져오는 데이터들까지 짧아졌습니다.

 

 

3. 성능 테스트

기존 프로젝트의 아키텍처는 1개의 nginx 서버가 2개의 스프링 서버로 로드 밸런싱 하고, 2개의 DB를 Master-Slave 구조를 가지도록 했습니다. 

하지만 서버 비용으로 인해 서버를 종료했고, 이번 테스트는 기존의 엔티티 fetch join 방식과 DTO 조회 방식의 성능 차이를 확인하는 것이기 때문에,

단순하게 스프링 서버와 DB를 각 1개씩만 사용해서 둘의 차이를 비교하겠습니다.

 

테스트 환경

  • EC2 t2.micro
  • RDS db.t3micro
  • User 1명 당 Contract 1개와 Board 3개를 가지고, 총 User 40만, Contract 40만, Board 120만 개
  • 조회할 데이터는 메인 페이지의 게시글 리스트를 조회한다고 가정하여, /boards?page=0&sort=createdAt,desc&size=10으로 페이지 1, 게시글 10개, 최신순으로 조회합니다.
  • DTO 조회는 API 스펙에 맞춘 단순 조회이므로, Controller에서 Repository로 바로 호출하여 응답합니다.
  • JMeter로 부하 테스트

 

그런데,,, 

데이터를 모두 저장 후 잘 되었는지 로컬에서 포스트맨으로 조회해 본 결과,,,

테스트 예정 데이터(40만, 40만, 120만)

 

엔티티로 조회했을 때 39.29초가 걸렸고, DTO로 조회했을 때 8.71초가 걸렸습니다.

 

이미 4배 이상의 성능 차이가 나지만, 원래는 JMeter로 부하 테스트를 해볼 예정이었으므로 120만 개의 Board 데이터는 현재 EC2와 RDS의 스펙으로는 무리가 있을 것이라 판단했습니다.

 

그래서 데이터의 양을 줄이기로 했습니다.

 

1/2 데이터 엔티티 조회 결과

User, Contract, Board 각 데이터의 양을 1/2로 줄여도, 여전히 엔티티 조회는 13.59초가 나와 JMeter로는 테스트가 불가능 했습니다.

 

1/4 데이터 조회 결과

데이터의 양을 1/4로 줄였습니다. 엔티티 조회는 5.4초로 아직 오랜 시간이 걸리지만, DTO 조회는 0.7초로 1초 안으로 조회되었습니다.

1/10 데이터 조회 결과

데이터의 양을 1/10으로 줄였습니다. 엔티티 조회는 2.4초 정도 걸렸으며, DTO 조회는 0.25초 정도로 이제는 괜찮은 응답 속도를 보여줍니다. 

 

1/100 데이터 조회 결과

데이터의 양을 1/100로 줄였습니다. 엔티티 조회는 0.25초 정도 걸렸으며, DTO 조회는 0.06초로 빠른 응답 속도를 보여줍니다.

 

1/100으로 줄인 결과까지 모두 DTO 조회가 엔티티 fetcj join 조회보다 몇 배는 빠른 성능을 보여주고 있습니다.

포스트맨으로 1번 조회 시 총 데이터의 양에 따라 달랐지만, DTO 조회가 최대 약 10배 정도 빠른 조회 성능을 보여주었습니다.

 

그래서 처음에는 User 40만, Contract 40만, Board 120만 개의 데이터로 JMeter까지 테스트 할 예정이었지만, 

비용 문제로 높은 성능의 서버를 사용할 수 없기 때문에 User 4000개, Contract 4000개, Board 12000개의 데이터를 사용하여 JMeter로 테스트를 하겠습니다. 

 

JMeter 테스트

  • 5초
  • 1초당 100개의 스레드로 동시 요청
  • 총 500개의 요청
  • EC2 t2.micro
  • RDS db.t3micro

엔티티 조회 JMeter 결과
DTO 조회 JMeter 결과

  엔티티 조회 DTO 조회
평균 응답 시간 8.025 초 1.285 초
평균 TPS 11.2 / s 62.6 / s

 

응답 시간 기준으로는 DTO로 조회했을 때 약 624% 개선되었고, TPS 기준으로는 약 5.6배 더 높은 처리량을 보여줬습니다.

 

4. 결론

기존에 엔티티로 조회했을 때는 많은 양의 필요하지 않은 데이터들까지 조회하여, 가공 후 응답했습니다.

하지만 DTO로 조회하면서 필요한 데이터들만 가져온 후 응답 시간 기준 약 624%, TPS 기준 약 5.6배 성능이 개선되었습니다.

 

그 이유는 ?

  • DB에서 가져오는 데이터의 양의 차이에 의한 네트워크 부하 차이
  • 엔티티 조회는 Service ~ Repository까지 트랜잭션을 유지하지만 (OSIV = false),
    DTO 조회는 API 스펙에 맞춰 단순 조회하므로 Repository 트랜잭션만 유지하고,
    이로 인해 DB 커넥션과 영속성 컨텍스트를 유지하는 시간도 짧아지므로 connection pool이 더 여유로워졌기 때문

저의 경우는 DTO로 조회하는 것이 매우 유의미하게 성능이 개선되었지만, 무조건 그러한 것은 아닙니다.

대부분의 성능 차이는 where 문과 join 에서 일어나고, 여기서의 성능 개선은 fetch join과 batch_size로 최대한 개선할 수 있으며, 요즘은 네트워크 성능이 좋기 때문에 데이터를 가져오는 양의 차이가 적다면 성능이 크게 개선되지 않을 수 있습니다. 

또한 DTO로 조회하면 특히 연관된 컬렉션까지 조회할 때 코드 복잡성이 매우 증가할 수 있고, API 스펙에 맞추기 때문에 재사용이 불가능해지기 때문에 여러 trade-off 를 고려해야 합니다.

 

 

 

하지만 기존의 쿼리에는 치명적인 오류가 있었습니다.

https://prefercoding.tistory.com/68

 

(2) Mysql 실행 계획 분석 및 쿼리 튜닝

1. 서론2. 쿼리 응답 속도3. 부하 테스트4. 실행 계획 분석5. 쿼리 튜닝6. Spring에서 쿼리 힌트7. 결과8. 결론과 마무리 1. 서론지난 글에서 QueryDsl을 사용하여 DTO 조회로 극한의 성능 최적화를 진행했

prefercoding.tistory.com