과정을 즐기자

Spring Data JPA, Querydsl 사용하여 조회 기능 구현하기 본문

Spring Data

Spring Data JPA, Querydsl 사용하여 조회 기능 구현하기

320Hwany 2023. 3. 17. 12:56

웹툰 만들기 토이 프로젝트를 진행하면서 Spring Data JPA, QueryDsl을 사용하여 여러 조회 기능을 구현하였습니다.

이번 글에서는 여러 구현한 부분 중 대표적인 사례 4개를 작성해보려고 합니다.  

 

1. ManyToOne에서 EntityGraph 사용하여 엔티티 조회

 

구현한 기능은 만화 제목을 검색하면 검색어를 포함한 모든 만화를 페이징 처리해서 가져오는 것입니다. 

우선 Cartoon과 Author는 ManyToOne 양방향 매핑 관계입니다. 

 

Author

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Author extends BaseTimeEntity {
	
   	...

    @OneToMany(mappedBy = "author", cascade = CascadeType.REMOVE)
    private List<Cartoon> cartoonList = new ArrayList<>();
    
   	...
}

 

Cartoon

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Cartoon extends BaseTimeEntity {

    	...
    
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "author_id")
    private Author author;
    
    	...
}

 

CartoonJpaRepository

@EntityGraph(attributePaths = {"author"})
List<Cartoon> findAllByTitleContains(String title, Pageable pageable);

 

CartoonRepositoryImpl

@Override
public List<Cartoon> findAllByTitle(CartoonSearchTitle cartoonSearchTitle) {
    PageRequest pageRequest = PageRequest.of(cartoonSearchTitle.getPage(),
    cartoonSearchTitle.getSize(), Sort.by(DESC, "likes"));
    return cartoonJpaRepository
    	.findAllByTitleContains(cartoonSearchTitle.getTitle(), pageRequest);
}

N+1 문제를 해결하기위해 @EntityGraph(attributePaths = {"author"})를 사용하여 

Cartoon을 조회할 때 Author 정보도 가져오게 하였습니다. 다음은 실행한 쿼리입니다.

 

조회 기능으로 가져온 엔티티를 변경해야 한다면 이러한 방식이 좋겠지만 단순히 조회만 하기 때문에

Dto 조회로 개선을 하는 것이 더 좋은 방법일 것 같습니다.  Dto 조회 방식은 3번에서 설명하겠습니다.  

 

2. ManyToOne 엔티티로 조회, 동적 쿼리 생성

웹툰을 보다보면 필터링 기능으로 조회를 할 때가 있습니다. 예를 들면 요일별, 장르별, 만화 진행상황(연재, 완결, 휴재)

이렇게 다양한 조건이 있는 동적 쿼리를 QueryDsl로 만들어보겠습니다.

 

우선 BooleanExpression을 사용하여 각각의 조건을 만들어보겠습니다. 

private BooleanExpression dayOfTheWeekEq(DayOfTheWeek dayOfTheWeek) {
    return dayOfTheWeek != DayOfTheWeek.NONE ? cartoon.dayOfTheWeek.eq(dayOfTheWeek) : null;
}

private BooleanExpression genreEq(Genre genre) {
    return genre != Genre.NONE ? cartoon.genre.eq(genre) : null;
}

private BooleanExpression progressEq(Progress progress) {
    return progress != Progress.NONE ? cartoon.progress.eq(progress) : null;
}

각각에 맞는 값이 존재한다면 조건을 추가하고 없다면 null을 반환해서 where절에서 제거하는 방식입니다.  

 

CartoonRepositoryImpl

@Override
public List<Cartoon> findAllByCartoonCondOrderByLikes(CartoonSearch cartoonSearch) {
    return jpaQueryFactory
            .selectFrom(cartoon)
            .leftJoin(cartoon.author, author)
            .fetchJoin()
            .where(
                    dayOfTheWeekEq(cartoonSearch.getDayOfTheWeek()),
                    genreEq(cartoonSearch.getGenre()),
                    progressEq(cartoonSearch.getProgress())
            )
            .orderBy(cartoon.likes.desc())
            .offset(cartoonSearch.getOffset())
            .limit(cartoonSearch.getLimit())
            .fetch();
}

우선 1번과 마찬가지로 N+1 문제를 해결하기 위해 fetchJoin을 사용하였습니다. 여기에 더해 동적쿼리를 

만들기 위해 위에서 만든 메소드를 where 안에 넣어주었습니다. 다음은 실행한 쿼리입니다.

 

만약 요일별, 장르별 두 필터링 조건을 선택하고 페이지를 선택하면 만화에 대한 정보 한 페이지를 가져옵니다.

역시 N+1 문제를 해결하고 동적쿼리를 잘 생성했지만 엔티티를 변경하는 것이 아니고 조회만 하는 것이므로 

Dto로 가져오는 것이 더 좋은 방법일 것 같습니다.

 

3. ManyToOne Dto로 조회

 

이번 기능은 선택한 연령대별 인기 만화 정보를 가져오는 것입니다.

예를 들면 20대를 선택하면 20대가 가장 많이 좋아요를 누른 만화 정보를 한 페이지 가져오는 것입니다.

이때 인기 만화 정보는  Dto를 만들어 만화 제목, 작가 닉네임, 만화의 좋아요 수만 가져오겠습니다. 

 

이번에는 회원 정보도 필요합니다.  

 

Member

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Member extends BaseTimeEntity {
	
        ...
    
    @DateTimeFormat(pattern = YEAR_MONTH_DAY)
    private LocalDate birthDate;

	...
}

 

CartoonMemberRepositoryImpl

@Override
public List<CartoonCore> findAllByMemberAge(CartoonSearchAge cartoonSearchAge) {
    return jpaQueryFactory
            .select(new QCartoonCore(
                    cartoon.title,
                    author.nickname,
                    cartoon.likes
            ))
            .from(cartoonMember)
            .leftJoin(cartoonMember.cartoon, cartoon)
            .leftJoin(cartoon.author, author)
            .leftJoin(cartoonMember.member, member)
            .where(
                    member.birthDate.between(
                            LocalDate.now()
                            	.minusYears(lowerBoundary(cartoonSearchAge.getAgeRange())),
                            LocalDate.now()
                            	.minusYears(upperBoundary(cartoonSearchAge.getAgeRange())))
            )
            .groupBy(cartoon.title)
            .orderBy(cartoon.likes.desc())
            .offset(cartoonSearchAge.getPage())
            .limit(cartoonSearchAge.getSize())
            .fetch();
}

Dto로 조회하기 때문에 fetchJoin이 아닌 일반 조인을 사용합니다.

where안에 있는 조건은 birthdate를 확인해 입력한 연령대에 속하는지 확인하는 로직입니다.

다음은 실행한 쿼리입니다.

 

조회만 할 때는 위와 같이 Dto로만 가져오는 방법이 더 좋은 방법인 것 같습니다. 

왜냐하면 엔티티로 조회를 하더라도 json으로 반환하기 전에 dto로 바꿔주는 작업은 필요합니다. 

또한 성능상으로도 적은 수의 컬럼을 조회하는 것이 더 좋은 방법입니다.  

 

4. OneToMany 컬렉션 조회

1, 2, 3번은 모두 Many, 연관관계의 주인이 있는 테이블에서 조회를 하였습니다. 

이번에는 One 쪽에서 조회를 해보겠습니다.  

구현한 기능은 작가 닉네임을 검색하면 검색어를 포함한 닉네임을 가진 작가들의

모든 만화를 페이징 처리해서 가져오는 기능입니다. 

 

AuthorRepositoryimpl

@Override
public List<Author> findAllByNicknameContains(AuthorSearchNickname authorSearchNickname) {
    PageRequest pageRequest = PageRequest.of(authorSearchNickname.getPage(),
    		authorSearchNickname.getSize(), Sort.by(DESC, "id"));
    return authorJpaRepository
    		.findAllByNicknameContains(authorSearchNickname.getNickname(), pageRequest);
}

먼저 작가의 이름을 포함 작가 정보를 가져왔습니다. 

가져온 작가 리스트를 stream으로 돌리면서 각각 지연로딩으로 가져옵니다. 

이렇게 가져올 수 있는 이유는 application.yml에 default_batch_fetch_size를 설정해주어

in 안으로 그 숫자만큼 들어갈 수 있습니다. 컬렉션 조회는 코드가 조금 더 복잡해졌습니다.

 

AuthorService

public List<AuthorCartoonResponse> findAllByNicknameContains(
				AuthorSearchNickname authorSearchNickname) {
        List<Author> authorList = authorRepository
        			.findAllByNicknameContains(authorSearchNickname);
        return authorList.stream()
                .map(AuthorCartoonResponse::getFromAuthor)
                .collect(Collectors.toList());
    }

AuthorCartoonResponse

public static AuthorCartoonResponse getFromAuthor(Author author) {
    return AuthorCartoonResponse.builder()
            .nickname(author.getNickname())
            .count(author.getCartoonList().size())
            .cartoonResponseList(
                    author.getCartoonList().stream()
                    .map(CartoonResponse::getFromCartoon)
                    .collect(Collectors.toList()))
            .build();
}

 

 

다음은 실행한 쿼리입니다.

 

위 쿼리는 입력한 닉네임 검색어를 포함 작가를 가져오는 쿼리입니다. 

연관관계를 생각하지 않고 단지 Author에 대한 정보만 가져옵니다. 

 

만약 작가 리스트에 3명이 있다면 각각의 작가의 stream을 돌려 지연로딩으로 가져옵니다.

 

작가를 가져오는 쿼리 1개, 지연로딩으로 연관관계를 가져오는 쿼리 1개로 가져올 수 있습니다. 

이때 물론 작가 리스트의 수가 default_batch_fetch_size를 넘는다면 쿼리의 수가 증가합니다.  

 

정리

지금까지 프로젝트에서 Spring Data JPA와 QueryDsl로 조회기능을 어떻게 구현했는지에 대해서 알아보았습니다.  

아무래도 OneToMany 컬렉션 조회 보다는 ManyToOne으로 조회하는 것이 더 구현하기 편했습니다. 

따라서 가능하면 양방향 매핑을 만들지 않고 Many 쪽에서 조회를 할 수 있도록 하는 것이 좋을 거 같습니다.

 

또한 조회를 할 때 엔티티로 조회할 수 있고 Dto로 조회할 수 있습니다. 

조회한 데이터가 변경을 해야한다면 당연히 엔티티로 조회를 해야합니다. 

이때 fetchJoin, EntityGraph를 사용하여 N+1 문제를 해결할 수 있습니다.

 

단순히 조회만 하는 기능이라면 Dto로 조회를 하는 것이 더 좋은 방법입니다. 

이때는 단순히 일반 조인을 사용하면 됩니다.