티스토리 뷰

문제상황

아래코드는 유저의 pk로 유저 관련 정보를 조회하는 로직이다. 간단하게 id로 유저를 찾은 뒤 DTO로 변환시킨다. 여기서 getUserPort에 집중해보자. 

    @Override
    public UserResponseDto getUser(Long id) {
        var user = getUserPort.getUser(id);
        return userMapper.of(user);
    }

 

getUserPort는 유저를 들고오는 getUser 메서드를 정의한 인터페이스다.

public interface GetUserPort {
    User getUser(Long id);
}

 

getUserPort의 구현체는 다음과 같다. 

    @Override
    public User getUser(Long id) {
        return userRepository.findById(id)
                .orElseThrow(UserNotFoundException::new);
    }

 

이제 Postman으로 API를 테스트 해보면 유저 쿼리가 나가는 것을 볼 수 있다. 여기까지는 당연한 결과이다. 하지만 다른 쿼리들이 나가고 있는 것을 볼 수 있다.

 

 

분명 유저만 조회했는데 어디서 이런 쿼리들이 나간 것일까? 바로 응답 DTO에 정답이 숨어있다. 아까 서비스 로직을 다시 보면, UserResponseDto를 반환하고 있다. 

    @Override
    public UserResponseDto getUser(Long id) {
        var user = getUserPort.getUser(id);
        return userMapper.of(user);
    }

 

UserResponseDto.java

public class UserResponseDto {
    private Long id;
    private String image;
    private String nickname;
    private String bio;
    private int grade;
    private int contributionLevel;
    private List<QuestionAllResponseDto> getMyQuestions;
    private List<QuestionStoreResponseDto> getSavedQuestions;

    public UserResponseDto() {
    }

    public UserResponseDto(User user) {
        this.id = user.getId();
        this.image = user.getImgUrl();
        this.nickname = user.getNickname();
        this.bio = user.getBio();
        this.grade = user.getGrade();
        this.contributionLevel = user.getContributionLevel();
        this.getMyQuestions = toQuestionsResponse(user);
        this.getSavedQuestions = toStoreQuestionsResponse(user);
    }

    private List<QuestionAllResponseDto> toQuestionsResponse(User user) {
        return user.getQuestions().stream()
                .map(QuestionAllResponseDto::new)
                .collect(Collectors.toList());
    }

    private List<QuestionStoreResponseDto> toStoreQuestionsResponse(User user) {
        return user.getQuestionStores().stream()
                .map(QuestionStoreResponseDto::new)
                .collect(Collectors.toList());
    }
}

 

여기서 밑에 부분을 자세히 보자.

this.getMyQuestions = toQuestionsResponse(user);

 

toQuestionsResponse는 아래와 같다.

private List<QuestionAllResponseDto> toQuestionsResponse(User user) {
        return user.getQuestions().stream()
                .map(QuestionAllResponseDto::new)
                .collect(Collectors.toList());
    }

 

현재 user에서 question으로 접근하고 있다. user와 question은 일대다 연관관계를 맺고 있다. 

@Entity
@Getter
@Table(name = "USERS")
public class User {
    ...

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private final List<Question> questions = new ArrayList<>();

    ...
}

 

Question.java

@Entity
@Getter
@Table(name = "QUESTION")
public class Question extends BaseTimeEntity {
    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    ...
}

 

OneToMany는 기본적으로 지연 로딩으로 걸려있어, 유저와 연관된 엔티티들을 프록시 객체로 생성하여 조인을 막고 있다. 하지만 지연 로딩도 단점이 있는데, 진짜 객체에 접근하게 되면 프록시 초기화가 발생한다. 이 말은 프록시 초기화를 하기 위해 db에서 question을 조회해야 한다는 말이다. 그래서 따로 question 조회 쿼리가 나간 것이다.

 

참고로 위 toQuestionsResponse 메서드에서는 아직 프록시 초기화가 나가지 않았다. 진짜 객체로 접근하는 부분은QuestionAllResponseDto에 있다. 

@Getter
public class QuestionAllResponseDto {
    private Long id;
    private QuestionStatus status;
    private String title;
    private Category category;
    private int commentCount;
    private int likeCount;
    private LocalDateTime createdDate;

    public QuestionAllResponseDto() {
    }

    @Builder
    public QuestionAllResponseDto(Question question) {
        this.id = question.getId();
        this.status = question.getQuestionStatus();
        this.title = question.getTitle();
        this.category = question.getCategory();
        this.commentCount = question.getCommentsSize();
        this.likeCount = question.getLikeCount();
        this.createdDate = question.getCreatedDate();
    }
}

 

여기도 마찬가지이다. toStoreQuestionsResponse 부분을 자세히 봐보자. 

this.getSavedQuestions = toStoreQuestionsResponse(user);

 

toStoreQuestionsResponse는 다음과 같다.

    private List<QuestionStoreResponseDto> toStoreQuestionsResponse(User user) {
        return user.getQuestionStores().stream()
                .map(QuestionStoreResponseDto::new)
                .collect(Collectors.toList());
    }

 

이것도 역시 user가 questionStore에 접근하고 있다. 둘의 관계는 일대다 관계이다. 

@Entity
@Getter
@Table(name = "USERS")
public class User {
    ...

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true)
    private final Set<QuestionStore> questionStores = new HashSet<>();

    ...
}

 

QuestionStore.java

@Entity
@Getter
@Table(name = "QUESTION_STORE")
public class QuestionStore {
    ...
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    ...
}

 

역시 지연로딩이 걸려있어 유저를 조회할 때는 프록시 객체가 생성되고, 실제 객체에 접근할 때 프로시 초기화가 발생하여 내부적으로 쿼리가 더 나간 것이다. 결국엔 JPA의 지연로딩을 사용하면 이러한 문제점이 발생하게 된다. 이렇게 나는 한번의 쿼리가 실행되길 원했지만(select user) 추가로 쿼리들이(select question, select questionStore) 나가는 것을 N+1이라고 한다. 그럼 어떻게 N+1을 해결해야 할까? 

 

해결

결국엔 우리는 join을 하고 싶은 것이다. 그렇다면 fetchType을 즉시로딩으로 하는 것이 좋아 보이지만, 즉시로딩은 내가 알지 못하는 쿼리들이 많이 나가기 때문에 실무에서 사용하지 않는다고 한다. 그럼 우리가 바로 사용할 객체를 직접 조정해 주면 된다. 대표적인 방법으로 fetch join이 있다. fetch join은 지연 로딩이 걸려 있는 연관관계에 대해서 한번에 즉시 로딩을 해준다. 그럼 한번 fetch join을 사용해보자. 

 

먼저 연관관계가 걸려있는 question을 한번에 join 해주도록 하겠다. 

@Query("select u from User u left join fetch u.questions where u.id =:id")
Optional<User> findByIdFetch(@Param("id") Long id);

 

만약 두개 이상의 연관관계가 걸려있어도 괜찮다. 이번엔 question, questionStore 둘 다 한방쿼리로 만들어보겠다. 

@Query("select u from User u left join fetch u.questions left join fetch u.questionStores where u.id =:id")
Optional<User> findByIdFetch(@Param("id") Long id);

 

jpql에서 fetch join을 구현하면 하드코딩을 해야한다는 단점이 있다. 그래서 가독성이 더 좋은 Querydsl로 구현하는 것이 더 좋다. (@EntityGraph 어노테이션을 쓰는 방법도 있지만, 실무에서 자주 사용하지 않는다고 한다.)

@Repository
@RequiredArgsConstructor
public class UserQuerydsl implements UserQuerydslRepository {
    private final JPAQueryFactory factory;

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(factory
                .selectFrom(user)
                .leftJoin(user.questions).fetchJoin()
                .leftJoin(user.questionStores).fetchJoin()
                .where(user.id.eq(id))
                .fetchOne());
    }
}

 

그리고 실행해보면 user, question, questionStore 연관관계가 한방 쿼리로 나가게 된다!

 

 

나중에 페이징을 하게 된다면 다른 방법으로 해야 한다고 한다. 그때는 batch fetch size를 적용해봐야겠다. 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday