ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [QueryDSL] WHERE 절에 서브쿼리 전달하기
    Today I Learned 2023. 6. 25. 01:11

    검색 기능을 개선하는 과정에서 쿼리 로직의 테스트를 작성했지만, 뭔가 제대로 된 검증을 진행하지 않고 큰 틀만 검증하고 있는 것 같다는 생각이 들었다. 그래서 테스트에서 기존에 크게 고려하지 않았던 부분에 대한 테스트를 혹시나 해서 시도해보는 과정에서 이전에 발견하지 못했던 오류를 발견했고, 오류를 개선하면서 서브쿼리를 적용해본 과정을 정리하고자 한다.
     
    새로 개선을 진행하고 있는 참가자 키워드 검색 결과 쿼리 로직을 살펴보도록 하자. 참가자 키워드 검색 결과 로직은 참가신청자 키워드 검색 결과 쿼리 로직과 거의 똑같은 형태를 갖는데, join하는 Register 테이블의 status Column 값을 APPLIED가 아닌 ACCEPTED로 전달한다는 한 부분에만 차이가 있고 나머지는 똑같다. 따라서 참가신청자 키워드 검색 결과 로직을 그대로 가져온 뒤, 식별할 status 값만 ACCEPTED로 변경해주는 방식으로 쿼리 로직을 작성했었다.
     

    // repositories/GameSearchByMemberRepositoryImpl.java
    
    @Repository
    public class GameSearchByMemberRepositoryImpl implements GameSearchByMemberRepository {
        private final JPAQueryFactory jpaQueryFactory;
    
        public GameSearchByMemberRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
            this.jpaQueryFactory = jpaQueryFactory;
        }
    
        @Override
        public List<GameSearchQueryResultDto> findAllSearchResultsByMember(
            String keyword
        ) {
            List<Tuple> tuples = jpaQueryFactory
                .select(game, place, register)
                .from(game)
                .rightJoin(place).on(place.id.eq(game.placeId))
                .rightJoin(register).on(register.gameId.eq(game.id))
                .rightJoin(user).on(register.userId.eq(user.id))
                .where(user.name.value.contains(keyword)
                    .and(game.status.eq(GameStatus.ACTIVE))
                    .and(register.status.eq(RegisterStatus.ACCEPTED)))
                .orderBy(game.createdAt.desc())
                .fetch();
    
            return toGameSearchQueryResultDtos(tuples);
        }
    }

     

    이 로직을 테스트하기 위해 작성한 테스트 코드를 살펴보자. 여기서는 하나의 GameSearchQueryResultDto가 의도대로 List<Register>를 구성했는지만을 확인할 것이지만, 실제 테스트 코드에서는 필요하다면 다른 요소들에 대해서도 테스트를 수행해야 할 것이다.

    // repositories/GameSearchByMemberRepositoryImplTest.java
    
    @DataJpaTest
    @Import(QueryDslTestConfig.class)
    @ActiveProfiles("test")
    class GameSearchByMemberRepositoryImplTest {
        @Autowired
        private GameSearchByMemberRepositoryImpl repository;
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
        
        @Test
        void findAllSearchResultsByMember() {
            jdbcTemplate.execute("DELETE FROM registers");
            jdbcTemplate.execute("DELETE FROM places");
            jdbcTemplate.execute("DELETE FROM games");
            jdbcTemplate.execute("DELETE FROM users");
    
            String keyword = "승준";
            // "송승준" 이름의 User를 데이터베이스에 삽입한다.
            // "노승준" 이름의 User를 데이터베이스에 삽입한다.
            // "이대호" 이름의 User를 데이터베이스에 삽입한다.
            // "조성환" 이름의 User를 데이터베이스에 삽입한다.
            // "가르시아" 이름의 User를 데이터베이스에 삽입한다.
    
            // Place 하나를 데이터베이스에 삽입한다.
    
            // "송승준"이 게시한 Game을 데이터베이스에 삽입한다.
            
            // "송승준"이 Game에 "ACCEPTED" 상태인 Register를 데이터베이스에 삽입한다.
            // "노승준"이 Game에 "ACCEPTED" 상태인 Register를 데이터베이스에 삽입한다.
            // "이대호"가 Game에 "ACCEPTED" 상태인 Register를 데이터베이스에 삽입한다.
            // "조성환"이 Game에 "APPLIED" 상태인 Register를 데이터베이스에 삽입한다.
            // "가르시아"가 Game에 "REJECTED" 상태인 Register를 데이터베이스에 삽입한다.
            
            List<GameSearchQueryResultDto> gameSearchQueryResultDtos
                = repository.findAllSearchResultsByMember(keyword);
            assertThat(gameSearchQueryResultDtos).isNotNull();
            
            assertThat(gameSearchQueryResultDtos).hasSize(1);
            
            GameSearchQueryResultDto gameSearchQueryResultDto
                = gameSearchQueryResultDtos.get(0);
            assertThat(gameSearchQueryResultDto.getRegisters()).hasSize(3);
        }
    }

     

    쿼리 메서드를 수행했을 때 반환되는 List에는 하나의 DTO가 존재해야 하고, 해당 DTO는 하나의 Game과, Game과 연결된 Place, Game과 연결되면서 ACCEPTED 상태인 Register들이였던 "송승준", "노승준", "이대호" User와 연결된 Register들이 포함되어 있어야 한다. 그러나 테스트는 실패한다.
     

     
    List<GameSearchQueryResultDto>에는 하나의 DTO가 들어있지만, List<Register>에 포함된 Register의 개수는 3개가 아닌 2개이다. 구체적으로는 다음의 Register들만 포함되었다.
     

     
    각각 "송승준"과 "노승준" User의 Register이다. "이대호" User의 Register는 어디로 가버린 것일까?
     

     
    쿼리문에 어떤 문제가 있는지 살펴보도록 하자. 먼저 join을 통해 구성될 하나의 Tuple을 살펴보자.
     

    .from(game)
        .rightJoin(place).on(place.id.eq(game.placeId))
        .rightJoin(register).on(register.gameId.eq(game.id))
        .rightJoin(user).on(register.userId.eq(user.id))

     

    하나의 Tuple에는 Game, Place, Registe, User가 join되어 다음의 조건들을 모두 만족하는 Entity들이 조회되게 된다.
     

    - 특정 Place에 연결된 Game
    - 특정 Game과 연결된 Register
    - 특정 Register와 연결된 User

     
    이제 WHERE 구문을 살펴보자.
     

    .where(user.name.value.contains(keyword)
        .and(game.status.eq(GameStatus.ACTIVE))
        .and(register.status.eq(RegisterStatus.ACCEPTED)))


    적법한 Register를 쿼리하기 위한 조건이 아닌 Game의 status에 대한 조건을 제외하고 보면, Tuple이 조회되기 위한 조건을 충족하기 위해서는 이름에 keyword가 포함되어야 함과 동시에 Register의 status가 ACCEPTED 값이여야 한다.

    즉, "이대호" User의 Register는 "이대호"의 이름에 "승준"이 포함되지 않았기 때문에 Tuple List에 포함되지 않았던 것이다. 그렇다면 해당 쿼리문으로는 주어진 키워드가 포함된 이름을 갖는 사용자가 참가하는 Game을 식별해낼 수는 있지만, 해당 Game에 참가하는 모든 Register들을 올바르게 쿼리할 수는 없다.
     
     
    문제를 해결하기 위해 주어진 키워드가 포함된 이름을 갖는 사용자가 참가하는 Game을 쿼리하는 로직과, Game에 참가하는 모든 Register들을 쿼리하는 로직을 분리하는 것을 고려해보았다.

    특정 Game과 연결된 Place, Register Tuple을 생성하는 것이 쿼리의 주 목적이고, 이때 대상으로 지정할 Game들을 결정하는 과정에서 이름에 키워드가 포함된 사용자가 참가하는 Game이 어떤 Game인지 알아야 하는 것이므로, 해당 쿼리를 서브쿼리로 작성해 전달해보기로 했다.
     
    다음과 같이 서브쿼리 로직을 작성했다.
     

    @Override
    public List<GameSearchQueryResultDto> findAllSearchResultsByMember(String keyword) {
        JPQLQuery<Long> gameIds = JPAExpressions
            .select(game.id)
            .from(game)
            .rightJoin(register).on(register.gameId.eq(game.id))
            .rightJoin(user).on(register.userId.eq(user.id))
            .where(user.name.value.contains(keyword)
                .and(game.status.eq(GameStatus.ACTIVE))
                .and(register.status.eq(RegisterStatus.ACCEPTED)));
        
        // ...
    }

     

    해당 쿼리에서는 Game, Register, User 테이블들만을 join한 뒤, keyword 이름을 포함하고 있는 사용자가 참가하는 Game이면 모두 조회한 뒤, 조회된 Game들의 id만을 쿼리했다.

    이렇게 서브쿼리로 생성된 결과인 Game의 id들을 본 쿼리에 전달했다.

     

    List<Tuple> tuples = jpaQueryFactory
        .select(game, place, register)
        .from(game)
        .rightJoin(place).on(place.id.eq(game.placeId))
        .rightJoin(register).on(register.gameId.eq(game.id))
        .where(game.id.in(gameIds)
            .and(register.status.eq(RegisterStatus.ACCEPTED)))
        .orderBy(game.createdAt.desc())
        .fetch();
    
    return toGameSearchQueryResultDtos(tuples);

     

    전달된 Game의 id들은 WHERE 절에서 IN 조건으로 전달되어 포함되는 Game들을 쿼리하도록 하는데, 여기에서 참가하는 인원들만을 집계하기 위해 Register의 상태가 ACCEPTED인 경우에만 조회된다.
     

    개선한 쿼리 동작을 테스트했을 때, 기존의 쿼리 로직에서는 실패했던 테스트가 정상적으로 통과하는 것을 확인할 수 있었다.
     

     

     

     




     

    댓글

Designed by Tistory.