로직 개선하기

복잡한 동적 쿼리 queryDSL 적용

 

피드 페이지에서 카테고리(도메인)과 제목이나 글쓴이 이름으로 검색하여 피드를 조회하는 기능을 구현했다.

위 동적 쿼리 문제를 구현하기 위해서 JPQL를 사용하여 여러 분기점으로 해결했다.

만약 검색 조건이 하나가 더 추가되거나 변경된다면 수정할 수 없을 만큼 복잡한 로직으로 구현된 것이 문제이다.

다른 문제점으로, 컨트롤러에서 응답하기 위한 DTO가 레포지토리에서 사용되기 때문에 

영속 계층이 컨트롤러, 서비스 계층과 의존하는 문제가 생긴다. 이를 해결하기 위해서 DTO를 계층 별로 분리해야 한다.

 

AS-IS

@Repository
@RequiredArgsConstructor
public class FeedRepositoryCustomImpl {

    private final EntityManager em;

    /**
     * 피드 조회
     * 도메인(건강, 사회, 과학 등)이나 글쓴이, 글 제목으로 검색한 결과로 응답
     * 추가로 요청한 사용자가 로그인이 되어있을 경우, 피드를 좋아요 표시 여부도 응답
     */
    public FeedSliceResponseDto findFeedListByFeedRequest(FeedSearchRequest request, Pageable pageable) {

        /**
         * 우선 글쓴이, 글 제목으로 검색하였는 지를 나누고
         * 그 다음에 도메인에 대해서 query 추가
         */
        List<Feed> feeds;
        if (request.getSearchType() != null) {
            if (request.getDomain() == 전체) {
                String jpql = (request.getSearchType() == TITLE)
                        ? "SELECT f FROM Feed f WHERE f.title LIKE CONCAT('%', :searchValue, '%') ORDER BY f.id DESC"
                        : "SELECT f FROM Feed f join f.user u on u.name LIKE CONCAT('%', :searchValue, '%') ORDER BY f.id DESC";

                feeds = em.createQuery(jpql, Feed.class)
                        .setParameter("searchValue", request.getSearchValue())
                        .setFirstResult(pageable.getPageNumber() * pageable.getPageSize())
                        .setMaxResults(pageable.getPageSize())
                        .getResultList();
            } else {
                String jpql = (request.getSearchType() == TITLE)
                        ? "SELECT f FROM Feed f WHERE f.title LIKE CONCAT('%', :searchValue, '%') and f.projectDomain = :domain ORDER BY f.id DESC"
                        : "SELECT f FROM Feed f join f.user u on u.name LIKE CONCAT('%', :searchValue, '%') and f.projectDomain = :domain ORDER BY f.id DESC";

                feeds = em.createQuery(jpql, Feed.class)
                        .setParameter("searchValue", request.getSearchValue())
                        .setParameter("domain", request.getDomain())
                        .setFirstResult(pageable.getPageNumber() * pageable.getPageSize())
                        .setMaxResults(pageable.getPageSize())
                        .getResultList();
            }
        } else {
            if (request.getDomain() == 전체) {
                String jpql = "SELECT f FROM Feed f ORDER BY f.id DESC";
                feeds = em.createQuery(jpql, Feed.class)
                        .setFirstResult(pageable.getPageNumber() * pageable.getPageSize())
                        .setMaxResults(pageable.getPageSize())
                        .getResultList();
            } else {
                String jpql = "SELECT f FROM Feed f WHERE f.projectDomain = :domain ORDER BY f.id DESC";
                feeds = em.createQuery(jpql, Feed.class)
                        .setParameter("domain", request.getDomain())
                        .setFirstResult(pageable.getPageNumber() * pageable.getPageSize())
                        .setMaxResults(pageable.getPageSize())
                        .getResultList();
            }
        }
        
        List<FeedSearchResponseDto> responseFeeds = feeds.stream().map(feed -> FeedSearchResponseDto.builder()
                .user(feed.getUser())
                .feed(feed)
                .build()).toList();
       	
        FeedSliceResponseDto response = new FeedSliceResponseDto();
        response.setFeedSearchResponsDtos(responseFeeds);
        response.setSize(feeds.size());
        response.setHasNextSlice(hasNextSlice(request, pageable));

        return response;
    }
}
private boolean hasNextFeedSlice(FeedSearchRequest request, Pageable pageable) {

    if (request.getSearchType() != null) {
        if (request.getDomain() == ProjectDomain.전체) {
            String jpql = (request.getSearchType() == FeedSearchType.TITLE)
                    ? "select count(cnt) from (select f.id AS cnt from Feed f where f.title like CONCAT('%', :searchValue, '%') GROUP BY f.id order by f.id DESC)"
                    : "select count(cnt) from (select f.id AS cnt from Feed f join f.user u on u.name like CONCAT('%', :searchValue, '%') GROUP BY f.id order by f.id DESC)";

            try {
                Long countQuery = em.createQuery(jpql, Long.class)
                        .setParameter("searchValue", request.getSearchValue())
                        .getSingleResult();
                return countQuery > ((pageable.getPageNumber() + 1L) * pageable.getPageSize());
            } catch (Exception e) {
                return false;
            }
        } else {
            String jpql = (request.getSearchType() == FeedSearchType.TITLE)
                    ? "select count(cnt) from (select f.id AS cnt from Feed f where f.title like CONCAT('%', :searchValue, '%') and f.projectDomain = :domain GROUP BY f.id order by f.id DESC)"
                    : "select count(cnt) from (select f.id AS cnt from Feed f join f.user u on u.name like CONCAT('%', :searchValue, '%') and f.projectDomain = :domain GROUP BY f.id order by f.id DESC)";

            try {
                Long countQuery = em.createQuery(jpql, Long.class)
                        .setParameter("domain", request.getDomain())
                        .setParameter("searchValue", request.getSearchValue())
                        .getSingleResult();
                return countQuery > ((pageable.getPageNumber() + 1L) * pageable.getPageSize());
            } catch (Exception e) {
                return false;
            }
        }
    } else {
        if (request.getDomain() == ProjectDomain.전체) {
            String jpql = "select count(cnt) from (select f.id AS cnt from Feed f GROUP BY f.id)";

            try {
                Long countQuery = em.createQuery(jpql, Long.class)
                        .getSingleResult();
                //                log.info("카운트 쿼리 " + Long.toString(countQuery));
                return countQuery > ((pageable.getPageNumber() + 1L) * pageable.getPageSize());
            } catch (Exception e) {
                return false;
            }
        } else {
            String jpql = "select count(cnt) from (select f.id AS cnt from Feed f WHERE f.projectDomain = :domain GROUP BY f.id)";

            try {
                Long countQuery = em.createQuery(jpql, Long.class)
                        .setParameter("domain", request.getDomain())
                        .getSingleResult();
                return countQuery > ((pageable.getPageNumber() + 1L) * pageable.getPageSize());
            } catch (Exception e) {
                return false;
            }
        }
    }
}

위 코드는 쿼리를 JPQL로 구현한 FeedRepositoryCustomImpl 과 페이징 처리를 위한 boolean hasNextSlice() 이다.

여러 조건을 분기로 풀어내서 비슷한 코드들이 반복되는 모습이다.

 

TO-BE

@RequiredArgsConstructor
public class FeedRepositoryCustomImpl implements FeedRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<FeedRepositoryResponse> searchSlice(FeedSearchCondition condition, Pageable pageable) {
        ProjectDomain domain = condition.getDomain();
        FeedSearchType type = condition.getType();
        String likeKeyword = "%" + condition.getKeyword() + "%";

        List<FeedRepositoryResponse> content = queryFactory
                .select(
                        Projections.constructor(FeedRepositoryResponse.class,
                            feed.id,
                            feed.title,
                            feed.content,
                            user.id,
                            user.name)
                )
                .from(feed)
                .join(feed.user, user)
                .where(
                        domainEq(domain),
                        keywordEq(type, likeKeyword)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            hasNext = true;
            content.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }

    private BooleanExpression domainEq(ProjectDomain domain) {
        return (domain != null) ? feed.projectDomain.eq(domain) : null;
    }

    private BooleanExpression keywordEq(FeedSearchType type, String likeKeyword) {
        if (type == WRITER) {
            return likeKeyword != null ? feed.user.name.like(likeKeyword) : null;
        }
        if (type == TITLE) {
            return likeKeyword != null ? feed.title.like(likeKeyword) : null;
        }

        return null;
    }
}
public FeedSliceServiceResponse getSliceFeed(FeedSearchRequest request, Pageable pageable) {
    Slice<FeedRepositoryResponse> feedRepositoryResponses = feedRepository.searchSlice(request.toConditionEntity(), pageable);
    return FeedSliceServiceResponse.of(feedRepositoryResponses);
}

queryDSL을 적용하여 개선한 코드이다.

2개의 기능에서 300줄 코드를 80줄의 코드로 줄이는 효과를 얻었고 가독성을 개선했다.

 

또한, 영속 계층이 상위 계층을 의존하는 문제를 해결하기 위해서

FeedRepositoryResponse DTO를 생성했고 서비스 계층에서 of 메서드로 Convert 역할을 수행하도록 했다.

 

테스트 코드를 작성하는 것으로 마무리했다.

@SpringBootTest
class FeedRepositoryCustomImplTest {

    @Autowired
    FeedRepository feedRepository;
    @Autowired
    UserRepository userRepository;
    @Mock
    Pageable pageable;

    @DisplayName("검색 조건을 통해 피드 목록을 조회할 수 있다.")
    @Test
    void searchFeedSlice() throws Exception {
        // given
        User user = User.builder()
                .email("test@test.com")
                .name("test user")
                .build();
        userRepository.save(user);

        // 4 개의 피드를 생성하고 3 개를 조회하여 다음 페이지가 있는 것을 확인한다.
        Feed feed1 = createFeed(user, "test title 1", "test content 1");
        Feed feed2 = createFeed(user, "test title 2", "test content 2");
        Feed feed3 = createFeed(user, "test title 3", "test content 3");
        Feed feed4 = createFeed(user, "test title 4", "test content 4");
        feedRepository.saveAll(List.of(feed1, feed2, feed3, feed4));

        FeedSearchCondition condition = FeedSearchCondition.builder()
                .domain(문화)
                .type(FeedSearchType.TITLE)
                .keyword("test")
                .build();

        given(pageable.getOffset()).willReturn(0L);
        given(pageable.getPageSize()).willReturn(3);

        // when
        Slice<FeedRepositoryResponse> feedRepositoryResponses = feedRepository.searchSlice(condition, pageable);

        // then
        assertThat(feedRepositoryResponses.getSize()).isEqualTo(3);
        assertThat(feedRepositoryResponses.hasNext()).isTrue();
        assertThat(feedRepositoryResponses.getContent()).hasSize(3)
                .extracting("id", "title", "content")
                .containsExactly(
                        tuple(1L, feed1.getTitle(), feed1.getContent()),
                        tuple(2L, feed2.getTitle(), feed2.getContent()),
                        tuple(3L, feed3.getTitle(), feed3.getContent())
                );
        assertThat(feedRepositoryResponses.getContent()).allMatch(response ->
                response.getUserId().equals(user.getId()));
    }

    private Feed createFeed(User user, String title, String content) {
        return Feed.builder()
                .title(title)
                .content(content)
                .user(user)
                .projectDomain(문화)
                .build();
    }
}

 

 

깃허브 commit 링크

https://github.com/jujemu/toy-project-platform/commit/d508df5ffabf06fa69acba8e31ce6f194606013f

https://github.com/jujemu/toy-project-platform/commit/6314f8e6f255103ad076f6805138f84f86bb0072

https://github.com/jujemu/toy-project-platform/commit/f31fba08cc8c8d423f303b70589957a4b1480c98

 

Test: 피드 목록 조회하는 로직에서 DTO 역할 분리 · jujemu/toy-project-platform@f31fba0

- FeedRepositoryCustomImpl 반환형이 비즈니스 계층 DTO를 사용하여 의존하고 있었다. - 따라서 레포지토리 DTO를 따로 선언하고 이를 서비스 DTO로 전환면서 의존 문제 해결 - 위 문제를 해결하고 테스트

github.com


 

유저 전체를 불러오는 로직 개선

 

메인 페이지에서 팀 프로젝트의 목록을 조회하는 로직에서 리더와 관련된 정보를 불러오는 기능이 있다.

문제는 팀의 리더의 id와 닉네임을 불러오는 과정에서 유저 전체를 가져와 hash map으로 초기화하는 것에 있다.

 

AS-IS

public Map<Long, User> getUserMap() {
    Map<Long, User> userMap = new HashMap<>();
    userRepository.findAllUser().stream().forEach(user -> userMap.put(user.getId(), user));
    return userMap;
}
public List<TeamSearchResponse> teamSearchResponseList(List<Team> teamList) {
    List<TeamSearchResponse> teamSearchResponseList = new ArrayList<>();
    Map<Long, User> userMap = getUserMap();
    teamList.stream()
            .forEach(team -> {
                teamSearchResponseList.add(TeamSearchResponse.from(team, userMap));
            });
    return teamSearchResponseList;
}
public static TeamSearchResponse from(Team team) {
    List<TeamStack> teamStacks = team.getTeamStacks();
    List<TeamStackDto> teamStackDtos = teamStacks.stream()
            .map(TeamStackDto::of).toList();

    return TeamSearchResponse.builder()
            .id(team.getId())
            .title(team.getTitle())
            .description(team.getDescription())
            .teamStacks(teamStackDtos)
            .leaderID(team.getLeader().getId())
            .leaderNickname(team.getLeader().getNickname())
            .isFinished(team.isFinished())
            .build();
}

위 코드는 메인 페이지의 팀 목록을 반환하기 위한 구현의 일부이다.

getUserMap은 팀의 리더 id와 대응되는 유저를 찾기 위해서 유저 테이블의 모든 레코드를 가져와 메모리에 올린다. 😱

 

TO-BE

public static TeamSearchResponse from(Team team) {
    List<TeamStack> teamStacks = team.getTeamStacks();
    List<TeamStackDto> teamStackDtos = teamStacks.stream()
            .map(TeamStackDto::of).toList();

    return TeamSearchResponse.builder()
            .id(team.getId())
            .title(team.getTitle())
            .description(team.getDescription())
            .teamStacks(teamStackDtos)
            .leaderID(team.getLeader().getId())
            .leaderNickname(team.getLeader().getNickname())
            .isFinished(team.isFinished())
            .build();
}

프로젝트를 다시 보면서 경악했다.

해결은 간단하게 리더로 연관관계를 추가하여 getter로 수정했다.

필요없는 getUserMap 부분은 삭제했다.