📝 정리

[PUDDY] 일대일 관계에서의 지연로딩 고민해보기

waveofmymind 2023. 4. 8. 20:49

프로젝트가 진행됨에 따라 테이블이 점점 많아지고 있다.

그에 따라 하나를 조회할 때 발생하는 쿼리 수도 예상치 못하게 많아지게 되어 한 테이블 조회 시 쿼리가 6개나 발생하는 것도 경험해 보았다.

 

테이블 중 유저 테이블은 펫과, 전문가 테이블과 일대일 관계이다.

한 명의 유저는 한 마리의 펫, 만약 전문가일 경우 전문가 테이블 한 개만 가질 수 있기 때문에 일대일 관계로 설정했다.

그러나, 일대일 관계에서 지연 로딩으로 설정해도 즉시 로딩으로 데이터를 가져오는 바람에 유저 테이블과 연관된 펫, 전문가 테이블을 조회하게 된다.

 

질문글 테이블과 유저 테이블은 다대일 관계인데, 질문글의 작성자 정보를 가져올 때 유저 테이블과 연관된 펫, 전문가 테이블까지 조회하기 때문에 쿼리가 추가로 발생한다.

발생 순서는 다음과 같다.

  1. 질문글 테이블 조회 요청
  2. 질문글 조회 쿼리 발생 - 1
  3. 질문글과 연관된 이미지 쿼리 발생 -2 
  4. 질문글과 연관된 답변글 조회하는 쿼리 발생 - 3
  5. 연관된 답변글에 대한 작성자 명을 위한 연관된 유저 조회 - 4
  6. 질문글과 연관된 유저 쿼리 발생 (+ 유저와 연관된 펫, 전문가 테이블 조회 쿼리 발생) - 5, 6, 7

질문글을 하나 조회하는데 쿼리가 7개나 발생한다.

현재 querydsl를 사용하기 때문에, 연관관계에 있는 테이블을 페치 조인을 통해 다음과 같이 작성했다.

public Optional<Question> getQuestion(Long questionId) {
        return Optional.ofNullable(queryFactory
                .selectFrom(question)
                .leftJoin(question.user, user).fetchJoin() // User 엔티티를 함께 조회하기 위한 조인
                .leftJoin(user.pet, pet).fetchJoin() // Pet 엔티티를 함께 조회하기 위한 조인
                .leftJoin(user.expert, expert).fetchJoin() // Expert 엔티티를 함께 조회하기 위한 조인
                .leftJoin(question.answerList, answer).fetchJoin() // Answer 엔티티를 함께 조회하기 위한 조인
                .leftJoin(answer.user, user).fetchJoin()
                .where(question.id.eq(questionId)) // 주어진 questionId에 해당하는 엔티티를 조회하기 위한 조건
                .fetchOne());
    }

이것으로 쿼리 수를 4개로 줄였다.

다시 쿼리 발생 순서를 살펴보면,

  1. 질문글 조회 쿼리 발생 + 1개
  2. 질문글 관련 이미지 쿼리 발생 + 1개
  3. 답변글을 가져올 때, 연관된 유저 테이블을 가져오므로, 이 유저에 대한 연관된 펫, 유저 테이블 조회 + 2개

질문글과 이미지는 일대다 관계에 있으므로 지연 로딩 상태이다.

querydsl에서 컬렉션을 두 개 이상 페치조인을 사용할 경우 'MultipleBagFetchException' 예외가 발생한다.

그래서 일단 줄일 수 있을 것 같은 3번 항목에 대해 살펴보자.

 

현재, User 엔티티 클래스에는 다음과 같이 펫, 전문가 테이블이 일대일 양방향 관계로 선언되어 있다.

    @Builder.Default
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private Expert expert = null;

    @Builder.Default
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private Pet pet = null;

일대일 양방향 관계이며, 반대쪽의 Pet, Expert 엔티티 클래스에도 User 필드가 선언되어 있다.

외래키는 Pet, Expert 테이블에 존재하도록 설정했다. 

그러나 페치 타입을 지연로딩으로 설정했음에도 불구하고, 사용하지 않은 테이블에 대해서도 조회 쿼리가 발생했다.

즉, 연관관계의 주인이 호출할 때는 지연 로딩이 정상적으로 동작하지만, 연관관계의 주인이 아닌 곳에서 호출한다면 지연 로딩이 아닌 즉시 로딩으로 동작한다는 것이다.

 

우선, 지연로딩이 동작하는 순서을 알 필요가 있었다.

  1. 지연 로딩은 로딩되는 시점에 Fetch 전략이 Lazy로 설정되어있는 엔티티를 프록시 객체로 가져온다. 해당 예제에서는 User를 조회할때 Pet과 Expert를 프록시 객체로 가져오게 된다.
  2. 이후 실제로 Pet, Expert 객체를 사용하는 시점에 초기화 되면서 쿼리가 실행된다.
  3. 예를들어, getPet(),getExpert() 처럼 객체가 사용되었을때 쿼리가 실행되는 것이다.

이렇게 지연 로딩으로 설정이 되어있는 엔티티를 조회할 때는 프록시로 감싸서 동작하게 되는데, 프록시는 null을 감쌀 수 없기 때문에 이와 같은 문제점이 발생하게 된다프록시의 한계로 인해 발생하는 문제이다.

 

정리하면,  펫 테이블에는 유저를 참조할 수 있는 컬럼이 존재하지 않는다. 따라서 펫은 어떤 유저에 의해 참조되고 있는지 알 수 없다.

 

펫이 어떤 유저에 의해 참조되고 있는지 알 수 없다는 뜻은 만약 유저가 null이더라도 펫는 이 사실을 알지 못한다는 것이다.

 

만약 유저가 null이 아니라고 해도, 펫의 입장에서는 유저가null인지 null이 아닌지 확인할 방법이 없다.

따라서 유저의 존재 여부를 확인하는 쿼리를 실행하기 때문에 지연 로딩으로 동작하지 않는 것이다.

 

그래서 나는 꼭 양방향 관계가 필요할까? 라는 의문을 가지고 현재 프로젝트를 뜯어보았고,

펫, 전문가 테이블에서 유저 정보를 보는 것은 사용하지 않고 있었다.

 

그래서 관계를 일대일 단방향 관계로 수정했고, 외래키를 모두 User 테이블에 주었다.

양방향일때에는 반대쪽 테이블에 주었지만, 회의 끝에 유저 - 펫, 유저 - 전문가 관계를 일대다로 확장할 일은 없을 것이라고 결론이 났다.

또한, 일대일 관계에서 일대다 - 다대일로 풀어서 중간에 다른 테이블로 두어 해결하는 방법은 과하다고 생각했다.

 

그러나 외래키를 User테이블에 준다는 것은 User테이블에 Pet,Expert가 없을때 null값을 허용해야 된다는 단점이 있었고, 주 테이블을 조회하면 해당 값 유무를 알 수 있다는 장점이 있었다.

반대로 외래키를 대상 테이블에 주면 전통적인 데이터베이스 스타일이며, 확장성을 고려했을때 이점이 있지만,

나처럼 연관된 테이블이 많을때 지연로딩이 안되므로 쿼리 수가 증가하는 단점이 있었다.(프록시 객체를 생성하려면 해당 테이블이 있는지 알아야하므로)

 

위와 같은 사항을 고려해봤을때, 나는 Pet, Expert 테이블에서 User 테이블을 조회하는 경우가 많지 않았고, 없었다!

즉, 외래키를 Pet,Expert에 둘 필요가 없다고 생각했다.

그리고, 일대일 단방향으로 설정할 경우, User 테이블을 통해 대상 테이블에 접근할 수 있다는 제약이 생기지만,

내 프로젝트에서는 굳이 유저 조회 없이 Pet, Expert 테이블 데이터를 조회할 필요가 없었다.

 

그리고 지연로딩으로 설정하는 것이, 필요할 때 페치 조인을 통해서 조회하면 되므로 오히려 일대일 단방향에 User테이블에 외래키를 두는 것이 내 프로젝트에서는 더 좋은 선택일 것이라고 생각했다.