옌의 로그

[JPA] @OneToMany 연관관계, List vs Set 뭐가 더 나을까? 본문

스터디/스프링

[JPA] @OneToMany 연관관계, List vs Set 뭐가 더 나을까?

dev-yen 2025. 9. 17. 00:55

현재 회사에서 진행중인 프로젝트가 api 서버를 먼저 만들고 admin 서버를 추후에(지금ㅋ) 만들고 있는데,

그러다 보니 조회만 할 땐 문제없던 엔티티들이 어드민에서 CRUD를 하려니 JPA를 활용할 수 없는 등 문제가 발생해서(ㅠㅠ)

그 중 고민했던 내용을 포스팅하고자 한다

 


 

List로 관리하는 경우

(JPA에서 연관관계를 맺을 때 가장 흔하게 사용하는 방식)

@Entity
public class Author {

    @OneToMany(mappedBy = "author")
    private List<AuthorEvent> events = new ArrayList<>();

    @OneToMany(mappedBy = "author")
    private List<AuthorAward> awards = new ArrayList<>();

    // ...
}

 

장점

  • 순서가 보장되어 클라이언트에 그대로 응답하거나 순서를 기준으로 로직을 구현할 때 편리
  • 대부분의 상황에서 직관적으로 사용 가능

 

 

단점

Hibernate의 MultipleBagFetchException

List 타입 컬렉션을 2개 이상 Fetch Join하면 예외 발생
@Override
public Optional<Author> findAuthorById(Long id) {
    return Optional.ofNullable(
        queryFactory
            .selectFrom(author)
            .leftJoin(author.events, authorEvent).fetchJoin()
            .leftJoin(author.awards, authorAward).fetchJoin()
            .where(author.id.eq(id))
            .fetchOne()
    );
}

 

위 쿼리처럼 List 형태의 @OneToMany 컬렉션을 여러 개 Fetch Join하면 MultipleBagFetchException이 발생한다.

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

 

-> Hibernate 내부적으로 Bag 타입(List)을 중복 제거 없이 매핑하려 할 때, 조인 결과가 예상보다 기하급수적으로 증가하기 때문

 

 

어떻게 해결?

  • Fetch Join은 꼭 필요한(중요한) 연관 객체 1개만 사용
  • 나머지 컬렉션은 @BatchSize를 사용하여 지연 로딩 시 N+1 문제 완화
    • 아니면 전역으로 설정 : hibernate.default_batch_fetch_size
  • 영속성 컨텍스트에서 지연 로딩이 동작할 수 있도록, 조회 메서드는 @Transactional(readOnly = true)로 감싸는 것이 중요
@BatchSize(size = 100)
@OneToMany(mappedBy = "author")
private List<AuthorAward> authorAwards;

 

 

 

 

DTO 프로젝션

public record AuthorAwardDto(
    Long awardId,
    String awardName,
    LocalDate awardDate
) {}

 

public List<AuthorAwardDto> findAuthorAwards(Long authorId) {
    QAuthor author = QAuthor.author;
    QAuthorAward award = QAuthorAward.authorAward;

    return queryFactory
        .select(Projections.constructor(AuthorAwardDto.class,
            award.id,
            award.name,
            award.awardDate
        ))
        .from(award)
        .join(award.author, author)
        .where(author.id.eq(authorId))
        .fetch();
}
  • 필요한 필드만 뽑아서 보내고 싶은 경우
    • 네트워크 비용 절감 + 쿼리효율 증가
  • 페이징을 사용하는 경우
    • fetch join과 Pageable은 Hibernate에서 오류를 발생시킨다

 


 

Set으로 관리하는 경우

@OneToMany(mappedBy = "author")
private Set<AuthorEvent> events;

@OneToMany(mappedBy = "author")
private Set<AuthorAward> awards;

Set은 중복을 허용하지 않기 때문에, 객체가 중복되었을 때 문제가 생기지 않도록 하는 데 유리하다

 

 

장점

  • 중복 제거 기능이 있어 Join 시 중복 데이터 필터링에 유리
  • Hibernate 내부적으로는 Bag이 아니라 Set으로 인식되기 때문에 여러 개 Fetch Join 가능

 

 

But? 카디널리티 곱...

Fetch Join을 여러 개 사용한다고 해도, 결국 조인된 결과가 곱집합(Cartesian Product) 형태로 나오기 때문에 성능상 불리한 건 마찬가지

-> DB 레벨에서 조인이 중복되어 row수가 기하급수적으로 늘어나는 문제 발생

 

But? equals/hashCode 지옥...

Set의 핵심은 중복 제거 → JPA는 객체 동등성 비교를 위해 equals/hashCode가 잘 정의되어 있어야 함

@Entity
public class AuthorAward {

    @Id @GeneratedValue
    private Long id;

    private String title;

    private int year;

    @ManyToOne(fetch = FetchType.LAZY)
    private Author author;

    // 비즈니스 키 기반 equals/hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AuthorAward)) return false;
        AuthorAward that = (AuthorAward) o;

        return Objects.equals(title, that.title) &&
               year == that.year &&
               Objects.equals(author, that.author);  // author.id만 비교할 수도 있음
    }

    @Override
    public int hashCode() {
        return Objects.hash(title, year, author);
    }
}

Author가 영속화되지 않은 경우 author.getId()null일 수 있으므로, 비즈니스 키를 기준으로 equals, hashCode를 수정해야한다.

(이걸 toMany 객체들마다 따로따로 구현해줘야한다;;)

 

 

 

 

 


 

 

 마무리하며...

 

연관관계에서 List냐 Set이냐는 단순한 컬렉션 선택이라 생각했었는데....

Fetch 전략, 성능, 유지보수성, 그리고 JPA의 동작 방식과 깊게 연결되어 있다는걸 깨달았다. 

(사실 admin부터 만들었으면 지금 이런 고민도 안했을것 같다. ㅋㅋ)

 

List + @Transactional(readOnly = true) + @BatchSize 조합으로 정리하고,

Fetch Join은 꼭 필요한 컬렉션 1개만 가져오도록 규칙을 세워 사용하기로 했다

 

Comments