새로운 사이드 프로젝트를 시작하면서 엔티티부터 처음부터 만들기 시작했다!!
그런데,,,!! 만들면서 다대일 관계나 엔티티에 붙이는 어노테이션, 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번의 쿼리 발생
- SELECT * FROM article LIMIT 20
- 이후 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번의 쿼리 발생
- SELECT id FROM article LIMIT 10
- 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을 쓰자
'Spring' 카테고리의 다른 글
JPA 복기하기 2 : 영속성 컨텍스트와 엔티티 생명주기 (1) | 2025.05.23 |
---|---|
FCM 알람 전송 재시도 구현하기 (0) | 2025.05.14 |
컨트롤러 테스트에 인증/인가가 들어있는 경우 (0) | 2025.05.12 |
FCM의 구조와 이를 구현하기 위한 고민(Feat : FCM토큰관리와 전송 메서드) (1) | 2025.05.12 |
알람 전송 구현 기술의 고민 (0) | 2025.05.12 |