Yejun Cheon
Yejun Cheon
log
study
read
coffee
로그인
배운 것을 정리합니다.

[JPA] 그 유명한 1:N관계

예
예준천
카테고리
  1. JPA

문제발생

데이터 정규화를 너무 잘지켜서 엔티티를 만들다보면 join이 많이 필요해지는건 당연하다. 그만큼 필요한 정보의 분리가 잘 일어났다는 뜻이고, 데이터의 중복과 정합성이 보장된다는 뜻이니까. 그런데 JPA에서는 지연로딩 때문에 연관관계에 부담을 느끼게 된다. 같은 작업 프로세스 내에서 조회가 확정된 연관관계가 지연로딩 상태라면 추가적인 쿼리가 발생할 것이 뻔하기 때문이다.
보통의 경우에는 여러가지 해결법이 있지만 이번에 만들게 된 유사 설문조사 도메인에서는 1:N이 연쇄적으로 그리고 동시에 여러개를 한번에 조회해야하는 문제를 경험했다.

엔티티 연관관계

가정으로 최상단이 1 이 하나에 N개가 붙어있고 각각 M개가 붙어있고, 또 각각 K개가 붙어있다고 가정해보자.

sol1: 추가쿼리? 상관없어

최상단 엔티티 조회후 그냥 원하는 엔티티 마음껏 조회.
이러면 모든 연관관계마다 추가쿼리가 나가게 된다. 가장 최악인것 같다. 이러면 NxMxK개 쿼리다.
queryfactory.select(evaluation)
     .from(evaluation)
     .where( 비즈니스조건)

sol2: 상남자는 LAZY 보다는 EAGER지.

이런 방법도 있다. 지연로딩 대신 즉시로딩을 사용하게 된다면 프록시를 사용하지 않고 바로 연관된 모든 데이터를 가져오기 때문에 추가쿼리가 발생할 염려가 없다.
fetch - FetchType (defaults to EAGER)
Defines whether this attribute should be fetched eagerly or lazily. EAGER indicates that the value will be fetched as part of loading the owner.  LAZY values are fetched only when the value is accessed. Jakarta Persistence requires providers to support EAGER, while support for LAZY is optional meaning that a provider is free to not support it. Hibernate supports lazy loading of basic values as long as you are using its bytecode enhancementsupport.
하지만, 우리는 이 엔티티그래프를 모든 연관엔티티를 조회하는 쿼리에'만' 사용하지는 않는다. 업체 엔티티만 단독으로 조회하고 싶을 수도 있고, 기타등등. 그래서 이건 거의 모든 교과서처럼 쓰면 안된다고 배웠다.

sol3: fetchJoin 한스푼 어떠세요

queryfactory.select(evaluation)
     .from(evaluation)
     .join(evaluation.category, category).fetchJoin()
     .join(category.user, user).fetchJoin()
     .where( 비즈니스조건 )
다행히도, 우리는 fetchJoin 이라는 기능 덕분에 LAZY 로 설정한 연관관계에서도 1+N 문제를 피해서 데이터 함께 불러오기를 사용할 수 있다.
모든 문제가 해결된 것 같았다. 최상단 엔티티에서 필요한 모든 연관관계를 조립하면 될 것만 같았다. 심지어 연쇄적으로 타고타고 들어가는 join의 표현도 간단했다.
문제는 하이버네이트는 하나의 쿼리에서 두개 이상의 1:N 연관관계의 fetchJoin을 허락하지 않는다. MultipleBagFetchException이 발생한다
The best way to fix the Hibernate MultipleBagFetchException
The best way to fetch multiple entity collections with JPA and Hibernate is to load at most one collection at a time while relying on the Hibernate Persistence Context guarantee that only a single entity object can be loading at a time in a given JPA EntityManager or Hibernate Session.
간혹 이걸 해결하기 위해 List 대신 Set을 사용하면 된다하는 글들이 있는데, 일단 나는 다른 오류가 발생했었고, 이것도 카르테시안 곱은 피할 수 없기에 좋은 해결책이 아니라고 한다.
결국 fetchJoin으로는 중첩,연속 1:N 연관관계를 모두 불러오기란 무리다.

sol4: ToMany연관관계 조회 없이, 밑에서부터 조립하면 되잖아.

이 도메인을 설계할 때, 사실 연관관계를 최소화 하고 싶은 마음이 좀 많았었다. 어디서 주워들은 얘기였던 것 같은데, JPA는 생각보다 내 맘대로 되는 날이 별로 없으니, 연관관계를 최소화하고(특히 OneToMany) FK만을 필드로 가져서 Application 단에서 조립하라는 이야기를 들은적이 있었다. (JPA의 책임을 쿼리 정도의 수준으로 유지하겠다는 이야기인 듯 하다.)
그러면 사실 fetchJoin 이런거도 고민할 필요가 없다. 그냥 각각 조건에 맞는 row들을 개별적으로 조회하고 열심히 map과 stream으로 DTO로 조립하면 된다.
예를 들자면,
List<Category> ctgrs = queryFactory.select(category)
.from(category) 
.where();

List<Long> ctgrsId = ctgrs.stream.map(c -> c.id).collect(Collectors.toList());

List<Question> questions = queryFactory.select(question)
.from(question) 
.where(question.ctgrId.in(ctgrsId)));

// map 어쩌구 저쩌구
다시생각해보니 이건 좀 아닌거 같고 ManyToOne 연관관계만 살려서라도 조립 과정을 줄여보자.
List<Category> ctgrs = queryFactory.select(category)
.from(category)
.where();

List<Question> questions = queryFactory.select(question)
.from(question) 
.where(question.ctgr.in(ctgrs)));

Map<Long, List<Question>> categorizedQuestion = questions.stream
.collect(Collectors.groupingBy(q -> q.getCategory().getId);

List<FormDto> response = ctgrs.stream().map(c -> FormDto.from(c, categorizedQuestion.get(c.getId()))).collect(Collectors.toList());
단점은 이제 코드가 너무 길어져서 5중 fetch를 하기는 힘들다는 거다.

sol4.1 : 조립 편하게 하기

MapStruct library
이걸 편하게 해줄 라이브러리가 있다고 들었다.
@Service
public class EvaluationService {

    @Autowired
    private EvaluationRepository evaluationRepository;

    public List<CategoryDTO> getEvaluationDetails(Long evaluationId) {
        Evaluation evaluation = evaluationRepository.findByIdWithDetails(evaluationId);

        List<CategoryDTO> categoryDTOs = new ArrayList<>();
        for (Category category : evaluation.getCategories()) {
            CategoryDTO categoryDTO = new CategoryDTO();
            categoryDTO.set분류(category.getName());

            List<QuestionDTO> questionDTOs = new ArrayList<>();
            for (Question question : category.getQuestions()) {
                QuestionDTO questionDTO = new QuestionDTO();
                questionDTO.set문항이름(question.getName());
                questionDTO.set배점(question.getScore());

                List<AnswerLineDTO> answerLineDTOs = new ArrayList<>();
                for (AnswerLine answerLine : question.getAnswerLines()) {
                    AnswerLineDTO answerLineDTO = new AnswerLineDTO();
                    answerLineDTO.set유저(answerLine.getUser().getName());
                    answerLineDTO.set코멘트(answerLine.getComment());

                    List<VendorScoreDTO> vendorScoreDTOs = new ArrayList<>();
                    for (Answer answer : answerLine.getAnswers()) {
                        VendorScoreDTO vendorScoreDTO = new VendorScoreDTO();
                        vendorScoreDTO.set업체명(answer.getVendor().getName());
                        vendorScoreDTO.set점수(answer.getScore());
                        vendorScoreDTOs.add(vendorScoreDTO);
                    }

                    answerLineDTO.set업체별점수(vendorScoreDTOs);
                    answerLineDTOs.add(answerLineDTO);
                }

                questionDTO.set응답라인(answerLineDTOs);
                questionDTOs.add(questionDTO);
            }

            categoryDTO.set문항(questionDTOs);
            categoryDTOs.add(categoryDTO);
        }

        return categoryDTOs;
    }
}
@Mapper(componentModel = "spring")
public interface EvaluationMapper {
    CategoryDTO toCategoryDTO(Category category);
}

... service code
 public List<CategoryDTO> getEvaluationDetails(Long evaluationId) {
        Evaluation evaluation = evaluationRepository.findByIdWithDetails(evaluationId);
        
        // 엔티티를 DTO로 변환
        return evaluation.getCategories().stream()
            .map(evaluationMapper::toCategoryDTO)
            .collect(Collectors.toList());
    }
자세히 알아보지는 않았지만 mapStruct Library를 사용한다면, 반복적인 dto 매핑을 쉽게 해줄 수 있다고 했다.

sol5: 누가 엔티티 단위로 조립하라고 협박함? 필드만 선택해서 가져오셈!

물론 엔티티로 조회하는걸 포기한다면 간단해진다. 중복되는 필드가 있더라도, join한 데이터를 각각의 필드를 지정해서 select하면 된다.
JPA에서 제공하는 영속성 컨텍스트를 사용하지 못하고, 조회시 상위 엔티티는 중복이 발생한다는 단점이 있지만, 가장 간단하게 추가쿼리 없이 모든 데이터를 불러오고 구조화 시키기도 편하다.
엔티티기반 조회 As Is : select Evaluation from Evaluation.
필드 기반 조회 To Be : select evalId, evalName, CategoryId, CategoryName, QuestionId, QuestionName…….. 이런 느낌
이 Projection 방법은 여러가지가 있었는데, select부에 new Dto(필드 각각) 방식으로 하거나,
>> 원하는 요소 get**() 으로 작성
public interface UserInfoMapping {
    String getId();
    String getUsername();
    Whatever getDepartment();
    
    interface Whatever{
        String getTeamName();
    }
}

public interface PersonRepository extends Repository<Person, Long> {
    <T> T findByLastName(String lastName, Class<T> type);
}
이런식으로 interface based projection을 사용하거나, 제네릭을 활용한 동적 프로젝션을 활용할 수 도 있긴하다.
하지만 아직 귀찮은게 하나 더 남았다. 조회해야하는 모든 필드가 적힌 DTO를 만들어야한다는 점. 심지어 Category 가 재귀적인 구조를 가져서 정적으로 정의되는 dto는 만들기 너무 귀찮고 힘들었다.

sol6: Projection을 이용해서 리스트로 묶어서 DTO 만들기?

QueryDsl의 쿼리문 안에서 컬렉션 필드도 같이 가져올 수 있는 방법이 있다. 이걸 쓰면 Projection 방식의 상위 엔티티 컬럼 중복문제를 해결 할 수 있으면서 귀찮은 문제도 없다.

sol7: 결국 돌고돌아 fetchJoin (여러번)

sol8: BatchSize 조절을 통한 그냥 조회

Yejun Cheon
'Yejun Cheon' 구독하기
사이트를 구독하면 새 포스트 등 최신 업데이트를 알림과 메일로 가장 먼저 받아보실 수 있습니다.
Slashpage에 가입하고 'Yejun Cheon'을 구독하세요!
구독
👍