티스토리 뷰

최근 QueryDsl로 회사 프로젝트를 진행하면서 까다로운 상황을 잘 해결해왔다고 생각했는데

이번에 겪은 상황을 해결하는데만 대략 일주일이 걸렸다.

 

코드를 대략적으로 설명하자면

@Getter @Setter
@Entity
public class Movie {
   
    @Id @GeneratedValue
    private Long id;

    @Column(name = "title")
    private String title;
}

@Getter @Setter
@Entity
public class Actor {

    @Id @GeneratedValue
    private Long id;

    @Column(name = "name")
    private String name;
    
    @ManyToOne
    @JoinColumn(name = "movie")
    private Movie movie;
}

회사의 코드를 들고올 수는 없으니 나와 비슷한 상황을 겪었던 StackOverFlow 질문이 있어 비슷하게 변경해 보았다.(답변이 없다..)


https://stackoverflow.com/questions/70813314/querydsl-springjpa-limit-offset-when-using-transform

 

QueryDSL (Spring+JPA) Limit/Offset when using Transform

I am trying to use QueryDSL to transform a Many to Many select into a projection while also applying pagination using JPA. I have the following entities: @Getter @Setter @Entity public class Movie ...

stackoverflow.com

 

Actor의 N:1 단방향 관계를 세팅했다.

양방향관계를 생각하지 않은 이유는 그간 공부하면서 양방향 관계를 최대한 피해야한다는 김영한님의 JPA 가르침 때문이었다.

 

Projections을 사용해 반환하려는 DTO는

//롬복 생략
public class MovieDto {
    String title;
    List<ActorDto> actors;
}

public class ActorDto {
    String name;
}

이런 방식을 사용해 Movie를 조회할 때 Dto안에 List를 반환하려했다.

 

이 내용으로 내가 해결하려했던 방법을 몇개 소개하고 실패 케이스와 함께 해결한 방법을 소개하려 한다.

 

 

방법 1.

List<MovieDto> all = queryFactory
        .selectFrom(movie)
        .leftJoin(actor)
        .on(movie.id.eq(actor.movie.id))
        .offset(pageable.getPageOffset())
        .limit(pageable.getPageSize())
        .transform(groupBy(movie.id).list(Projections.constructor(MovieDto.class,
                            movie.title,
                            list(Projections.constructor(ActorDto.class, 
                            	actor.name))
)));

 

트랜스폼을 사용한 방법으로 맨 첫번째에 사용했던 방법이다.

 

문제의 상황은 페이지네이션을 적용하려고 할때 나오게 되는데

Movie 1, Actor N 의 관계에서 위의 QueryDsl을 사용해 size를 10개로 준다면

Actor의 데이터 수에 따라 결과가

 

Page = 0, Size = 10

ID 영화 제목 배우 이름
1 영화 제목 1 영화 1 배우
1 영화 제목 1 영화 1 배우
2 영화 제목 2 영화 2 배우
2 영화 제목 2 영화 2 배우
3 영화 제목 3 영화 3 배우
3 영화 제목 3 영화 3 배우
3 영화 제목 3 영화 3 배우
4 영화 제목 4 영화 4 배우
4 영화 제목 4 영화 4 배우
4 영화 제목 4 영화 4 배우

이런 데이터가 나오게 된다.

GroupBy로 MovieDto를 반환할 때 Actor 컬럼이 MovieDto내의 List 형태로 들어가는게 아닌 중복된 row로 추가되어 나온다는 얘기다.

 

이 데이터는 페이지네이션으로 적용할 수 없다. 10개의 Movie id가 필요한데 전혀 다른 데이터가 나오고 있기 때문이다.

그렇기에 고민해보며

 

노가다의 방법으로 이런 방법을 생각했다.

 

 

방법 2.

List<Movie> query = queryFactory
                .select(Projections.constructor(MovieDto.class,
                	movie.id
                ))
                .from(movie)
                .leftJoin(actor)
                .on(movie.id.eq(actor.member.id))
                .where()
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(member.id.desc())
                .groupBy(	//GroupBy 중복 제거
                        movie
                )
                .fetchJoin().fetch()
                .stream()	//stream 시작
                .peek(p -> {
                    Long movieId = p.getId();
                    List<ActorDto> actorList = queryFactory
                            .selectFrom(actor)
                            .where(actor.movie.id.eq(movieId))
                            .transform(
                                    groupBy(actor.id).list(Projections.constructor(ActorDto.class,
                                            actor.name
                                    ))
                            );
                    p.setActors(actorList);
                })
                .collect(Collectors.toList());

페이징 카운트 코드를 제외한 코드가 되겠다.

 

성능과 효율도 굉장히 안좋은 코드가 아닌가 싶다.

대략적으로 Movie와 Actor를 설명하자면

GroupBy를 통해 Movie 객체의 중복컬럼 없는 데이터를 뽑아내고

Movie id값을 for문 혹은 stream으로 하나 하나 찾아내어 MovieDto안에 actor 이름을 List로 만들어 넣어주는 것이다.

이렇게 된다면 쿼리문이

N+1 이 아니라 N+size 의 쿼리문 개수가 나가게 된다ㅋㅋ

size 의 수만큼 for 문을 돌면서 Actor 테이블을 조회할 텐데 Actor의 수가 늘면 늘수록 아주 재밌는 현상이 벌어질 것이다ㅋㅋ

 

이 Entity관계에 대해 고민해서 나온 해결방법은 하나였다.

 

 

방법 3.

양방향 관계 설정

@Getter @Setter
@Entity
public class Movie {
   
    @Id @GeneratedValue
    private Long id;

    @Column(name = "title")
    private String title;
    
    //추가부분
    @OneToMany(mappedBy = "movie")
    private List<Actor> actors;
}

@Getter @Setter
@Entity
public class Actor {

    @Id @GeneratedValue
    private Long id;

    @Column(name = "name")
    private String name;
    
    @ManyToOne
    @JoinColumn(name = "movie")
    private Movie movie;
}

최대한 단방향 관계로써 테이블을 구현하려고 하다 양방향관계를 도입하였다.

연관관계의 주인은 Actor로 설정하여 Actor 테이블에 FK 키가 매핑되도록 만들었다.

 

이후 구현한 코드

public Page<MovieDto> findMovie(Pageable pageable, SearchRequest request) {

        List<Movie> query = queryFactory
                .selectFrom(movie)
                .leftJoin(actor)
                .on(movie.id.eq(actor.movie.id))
                .where(
                	//동적쿼리 작성부분
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .groupBy(movie)
                .orderBy(movie.id.desc())
                .fetchJoin().fetch();

        //MovieDto 데이터 삽입
        List<MovieDto> dtoList = query.stream().map(m -> {

            Movie dto = Movie.builder()
                    .title(m.getTitle)
                    .build();

            List<Actor> actorList = m.getActors().stream()
                    .map(a ->
                            ActorDto.builder()
                                    .name(a.getName())
                                    .build()
                    ).toList();

            dto.setActors(actorList);
            return dto;
        }).toList();

        JPAQuery<Long> count = queryFactory
                .select(movie.count())
                .from(movie)
                .where(
                	//동적쿼리부분
                );

        return PageableExecutionUtils.getPage(dtoList, pageable, count::fetchOne);
    }

카운트 쿼리와 전체적인 부분을 추가한 코드를 완성했다.

성능과 효율의 측면에서도 N+1의 문제를 예방했다고 생각하고

방법2. 에서 나온 size만큼의 N+size 도 없이 한번의 쿼리를 사용하여

어플리케이션 레벨에서의 처리로 해결하였다.

 

 

 

결론.

최대한 단방향 관계로 모든 테이블을 구현하고자 했었다.

그게 정답이라고 생각하였고 양방향관계의 공부를 소홀히 하니 쉽게 접근하기 두려웠던 것이었다.

 

하지만 MyBatis와 다르게 서브쿼리를 지양하고

코드적으로 테이블이 아닌 객체를 다루는 것에 대해 일주일 가까히 생각하다보니

Spring Data Jpa를 사용하게 된다면 모든 테이블을 단방향 관계로 설계하는 게 아닌

필요에 따라

양방향 관계를 설정하는 방법과 이유에 대해 많이 이해하게 되었지 않았나 싶다.

'Backend > JPA' 카테고리의 다른 글

JPA] 자연키 vs 대리키  (2) 2023.10.11
Comments