본문 바로가기

Spring

JPA 복기하기 1 : 다대일 관계와 N+1 문제해결 및 페이징

새로운 사이드 프로젝트를 시작하면서 엔티티부터 처음부터 만들기 시작했다!!

그런데,,,!! 만들면서 다대일 관계나 엔티티에 붙이는 어노테이션, N+1 문제 같은 것들이 기억이 흐릿흐릿 해진 것이다 😳

엔티티를 구성하면서 기억이 가물가물했던 부분들을 다시 정리해보고자 한다

엔티티 만들기

애매모호 개념 1  : @NoArgsConstructor(access = AccessLevel.PROTECTED)를 왜 쓰는걸까?

protected 접근 범위

  • 같은 패키지 내의 클래스
  • 다른 패키지라도 상속한 자식 클래스

엔티티의 생성자를 protected로 제한을 걸었을 때 이점

  • JAP는 리플랙션을 사용해서 엔티티객체를 만들기 때문에 기본 생성자가 필요함
  • 저렇게 기본 생성자를 생성할 수 있는 범위를 정해주면 외부에서 실수로 new Entity()를 할 수 없게 해줌 ⇒ 안전하게 필요한 경우에만 객체를 생성할 수 있음

 

애매모호 개념 2 : 다대일 관계 테이블

ERD

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class UserCoupon extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;

    @Enumerated(EnumType.STRING)
    private Status status;

}

 

@ManyToOne

  • 현재 이 컬럼을 가지고 있는 엔티티는 N 쪽이라는 뜻
  • 즉 user는 하나고, 여기에 대응되는 usercoupon은 여러개 라는 뜻

fetch = FetchType.LAZY

  • usercoupon을 조회할 때마다 user까지 같이 조회하는 것이 아니라 user까지 실제로 조회할 일이 있을 때 해당 객체까지 조회해오겠다
  • UserCoupon.getUser() 하기 전까지는 User 객체를 로딩하지 않음
  • 성능 최적화에 좋음

@JoinColumn(name = "user_id")

  • 외래키 컬럼으로 user_id를 만들겠다는 의미
  • user_id를 통해서 user엔티티와 연결됨

 

애매모호 개념 3 : 즉시로딩과 지연로딩의 차이

즉시 로딩 (FetchType.EAGER)

SELECT m.*, t.* 
FROM member m
JOIN team t ON m.team_id = t.id
WHERE m.id = 1;
  • 연관된 엔티티를 즉시 함께 조회
  • N+1 문제를 유발할 수 있으며, 불필요한 데이터 조회가 발생할 가능성이 큼
  • @ManyToOne이나 @OneToOne 기본값이 EAGER이므로 LAZY로 변경하는 것이 일반적

지연 로딩 (FetchType.LAZY)

-- 첫 번째 쿼리
SELECT * FROM member WHERE id = 1;

-- team을 실제로 사용할 때 쿼리 발생 (N+1의 원인)
SELECT * FROM team WHERE id = ?;
  • 연관된 엔티티를 실제로 사용할 때만 조회
  • 대부분의 경우 지연 로딩을 기본값으로 설정하는 것이 권장
  • @OneToMany는 기본값이 LAZY이므로 특별한 이유가 없다면 그대로 유지해야 함

 

양방향 연관관계

애매모호 개념 4 : 연관관계의 주인

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Member> members = new ArrayList<>();

    // 연관관계 편의 메서드
    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this);
    }

	// 이렇게 서로 양쪽에서 끊어줘야 DB에서 DELETE 쿼리 날림
    public void removeMember(Member member) {
        members.remove(member);
        member.setTeam(null); // 고아 객체로 만들기 위해 반대쪽도 끊어줌
    }
}
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    public void setTeam(Team team) {
        this.team = team;
    }
}

 

연관관계의 주인

  • 외래키를 가지고 있는 쪽이 연관관계의 주인
  • 데이터베이스에서 외래키를 가지고 있는 테이블을 말함
  • 양방향 연관관계를 만들어도, 결국 데이터베이스 테이블에서 외래키는 한쪽만 가지고 있기 때문에 주인을 명시해줘야 함
@OneToMany(mappedBy = "team")
private List<Member> members;
  • mappedBy는 나는 연관관계의 주인이 아니라고 말하는 거임
  • members는 Member 엔티티의 team 필드에 의해 매핑된 것
  • 읽기 전용 필드라는 뜻

 

애매모호 개념 5 : Cascade 옵션 (cascade = CascadeType.ALL)

  • 부모 엔티티를 삭제할 때 자식 엔티티도 함께 삭제되도록 설정
  • 부모의 모든 생명주기를 자식도 따름
  • 실무에서는 CascadeType.ALL을 조심스럽게 사용해야 함

 

애매모호 개념 6 : 고아 객체 제거 (orphanRemoval = true)

  • member.setTeam(null) → 연관관계를 끊는 것 (Java 객체 기준)
  • orphanRemoval = true → JPA가 연관관계가 끊긴 객체를 DB에서 DELETE 하도록 명령하는 설정
  • JPA가 "이 객체를 삭제해야 한다"라고 인식하게 만드는 트리거
  • 부모 엔티티에서 리스트에서 제거하면, 해당 엔티티도 삭제되도록 설정할 수 있음

 

N + 1 문제

애매모호 개념 7 : N + 1 문제란?

  • 연관관계를 가진 엔티티를 조회할 때 해당 엔티티의 갯수만큼 연관관계를 조회하는 쿼리가 추가적으로 N번 나가는 것
@Entity
public class Owner {
    @Id
    private Long id;

    private String name;

    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private List<Cat> cats = new ArrayList<>();
}

@Entity
public class Cat {
    @Id
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "owner_id")
    private Owner owner;
}

 

애매모호 개념 8 : N + 1 문제 해결 방법

Eager Loading으로 N+1문제를 해결하려고 하면 안됨

  • Eager Loading은 연관된 엔티티 객체를 한번에 조회하도록 하는 기능으로 특정 경우에 N+1문제를 부분적으로 해결해줄 수 있지만 사용하지 않는 것이 좋음
  • 어떤 entity 연관관계 범위까지 join 쿼리로 조회해올지 예상하기 힘들어지기 때문에 오히려 필요없는 데이터까지 로딩하여 비효율적
  • entity 관계가 복잡해지면 n+1 문제가 해결되지 않는 경우가 많음

 

N + 1 문제 해결 방법 1 : @BatchSize(size = N)

SELECT * FROM cat WHERE owner_id IN (1, 2, 3, 4, 5);
@Entity
public class Owner {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    @BatchSize(size = 5)
    private Set<Cat> cats = new LinkedHashSet<>();
}
spring:
  jpa:
    properties:
        default_batch_fetch_size: 100
  • Hibernate가 비슷한 엔티티 ID들을 모아서 IN 쿼리로 조회
  • 페이징 사용 가능
  • 연관관계의 데이터 사이즈를 정확하게 알 수 있다면 최적화할 수 있는 size를 구할 수 있겠지만 사실상 연관 관계 데이터의 최적화 데이터 사이즈를 알기는 쉽지 않음

 

N + 1 문제 해결 방법 2 : EntityGraph

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • outer join으로 호출
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

N + 1 문제 해결 방법 3 : Fetch join

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • FetchType을 Lazy로 해놓는것이 무의미
  • 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는것이 불가능하므로 페이징 쿼리를 사용할 수 없음
  • inner join으로 호출
@Query("select o from Owner o join fetch o.cats")
List<Owner> findAllJoinFetch();

 

Fetch join 주의 사항

  • Distinct 절을 사용해야 함
  • Collection Fetch Join은 하나까지만 가능
    • 여러 Collection에 대해서 Fetch Join을 하게 되면 잘못된 결과과 발생하기 때문에 꼭 하나까지만 Fetch Join해야 함

 

Fetch join은 Paging을 해서는 안됨(Out Of Memory 발생 가능) = 양방향연관관계에서만

 

데이터 구성 (1:N 관계)

Article 1: Opinion 101, Opinion 102  
Article 2: Opinion 201, Opinion 202  
Article 3: Opinion 301
article.id opinion.id
1 101
1 102
2 201
2 202
3 301

경우 1 : 일반 페이징 쿼리

사용법

@Query("SELECT a FROM Article a")
Page<Article> findAll(Pageable pageable); // PageRequest.of(0, 2)

 

DB 조회 결과

SELECT * FROM article LIMIT 2
article.id
1
2

 

JPA 결과

[
  Article(id=1, opinions=[❌LAZY]),
  Article(id=2, opinions=[❌LAZY])
]

 

연관관계 조회 시 2번의 쿼리 추가로 발생 → 성능은 떨어지지만, 페이징은 정확하게 적용됨

SELECT * FROM opinion WHERE article_id = 1;
SELECT * FROM opinion WHERE article_id = 2;

 

경우 2 : Fetch Join + 페이징

사용법

@Query("SELECT a FROM Article a JOIN FETCH a.opinions")
Page<Article> findAllWithOpinions(Pageable pageable); // PageRequest.of(0, 3)

 

DB 조회 결과

-> JOIN이라서 article 1개가 여러 줄로 나옴

SELECT a.*, o.*
FROM article a
JOIN opinion o ON o.article_id = a.id

 

 

 

쿼리 결과

article.id opinion.id
1 101
1 102
2 201

 

JPA는 이걸 보고 “Article 1에 Opinion 1, 2 있고, Article 2엔 Opinion 1개 있다”고 착각하게 됨

 

JPA 결과

데이터가 누락된 상태로 영속화됨

[
  Article(id=1, opinions=[101, 102]),
  Article(id=2, opinions=[201])  // ❗ 원래는 201, 202여야 하는데, 1개만 포함됨
]

 

이러한 이유로 Hibernate는 아예 DB 페이징 안 하고 모든 row를 읽은 후, Java 메모리에서 중복 제거 후 페이징 처리를 강제함 → 큰 데이터면 OOM(OutOfMemory) 가능성 발생

 

애매모호 개념 9 : 페이징 + n+1 문제를 해결하고 싶다면? (@OneTomany에서)

@Entity
public class Article {
    @Id @GeneratedValue
    private Long id;

    private String title;

    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    @BatchSize(size = 100) // 💡 이게 핵심
    private List<Opinion> opinions = new ArrayList<>();
}
@Entity
public class Opinion {
    @Id @GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Article article;
}

 

방법 1 : @BatchSize + 일반 페이징 사용

  • Article만 페이징으로 가져오고
  • Opinion은 지연 로딩(LAZY)이지만, 한 번에 IN 절로 묶어서 가져오게
public interface ArticleRepository extends JpaRepository<Article, Long> {
    Page<Article> findAll(Pageable pageable); // 일반 페이징
}

 

동작 : 2번의 쿼리 발생

  1. SELECT * FROM article LIMIT 20
  2. 이후 opinions 조회 할 때 in 절로 조회조건을 묶은 쿼리 발생
SELECT * FROM opinion WHERE article_id IN (1, 2, ..., 20)

 

방법 2 : ID 기반 2단계 조회 전략 (Fetch Join + 페이징)

  • 1단계 : 페이징 기준 엔티티의 ID만 먼저 조회
  • 2단계 : 해당 ID들을 기준으로 fetch join 쿼리 실행
public interface ArticleRepository extends JpaRepository<Article, Long> {

    // Step 1: ID만 페이징 조회
    @Query("SELECT a.id FROM Article a")
    Page<Long> findPagedIds(Pageable pageable);

    // Step 2: ID 목록으로 fetch join
    @Query("SELECT DISTINCT a FROM Article a JOIN FETCH a.opinions WHERE a.id IN :ids")
    List<Article> findArticlesWithOpinions(@Param("ids") List<Long> ids);
}
public Page<Article> getPagedArticles(Pageable pageable) {
    Page<Long> pagedIds = articleRepository.findPagedIds(pageable);
    List<Article> articles = articleRepository.findArticlesWithOpinions(pagedIds.getContent());
    return new PageImpl<>(articles, pageable, pagedIds.getTotalElements());
}

 

동작 : 2번의 쿼리 발생

  1. SELECT id FROM article LIMIT 10
  2. SELECT a.*, o.* FROM article a JOIN opinion o ON ... WHERE a.id IN (...)

 

애매모호 개념 10 : Fetch Join과 EntityGraph 주의할 점 (@OneToMany, @ManyToMany 관계에서)

  • ⭐️@OneToMany, @ManyToMany 관계⭐️에서 연관된 컬렉션을 한번에 조회하려고 할 때 둘 다 JPQL을 사용하여 JOIN문을 호출
  • 둘 다 공통적으로 카테시안 곱이 발생하여 중복된 엔티티 결과가 생길 수 있음
// Owner와 Cat을 Fetch Join으로 가져옴
SELECT o FROM Owner o JOIN FETCH o.cats
Owner ID Owner Name Cat ID Cat Name
1 철수 10 나비
1 철수 11 치즈

 

JPA는 주인이 1명인데 2행이 나왔기 때문에 List<Owner>로 받을 경우에 Owner가 중복 저장될 수 있음

 

해결방안 1 : DISTINCT 사용

SELECT DISTINCT o FROM Owner o JOIN FETCH o.cats
  • JPA는 DISTINCT를 보면 중복된 Owner 객체를 메모리 차원에서도 제거
  • 실제 SQL 쿼리에는 DISTINCT가 들어가고
  • 결과 리스트에는 Owner 객체가 1개만 들어옴
  • 페이징 불가

해결 방안 2 : FetchMode.SUBSELECT

SELECT * FROM owner;

SELECT * FROM cat 
WHERE owner_id IN (SELECT id FROM owner);
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
@Fetch(FetchMode.SUBSELECT)
private Set<Cat> cats;
  • 두번의 쿼리로 해결하는 방법
  • 해당 엔티티를 조회하는 쿼리는 그대로 발생하고 연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법
  • 페이징 사용 불가
  • FethType을 EAGER로 설정해야하는 단점을 가짐

해결 방안 3 : @BatchSize(size = N)

@OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    @BatchSize(size = 5)
    private Set<Cat> cats = new LinkedHashSet<>();
  • Hibernate가 비슷한 엔티티 ID들을 모아서 IN 쿼리로 조회

 

결론

  • 사실 @ManyToOne관계만 사용한다면 fetchjoin + 페이징을 사용할 수 있기 때문에 저런 주의사항들을 신경쓰지 않아도 됨!
  • 속편하게 @ManyToOne을 쓰자