1. 엔티티에는 가급적 Setter를 사용하지 말자
JPA에서 엔티티는 단순한 데이터 그릇이 아니다.
비즈니스 규칙과 상태를 표현하는 도메인 객체이기 때문에 값을 아무 때나 바꿀 수 있는 @Setter는 유지보수와 안정성을 해친다.
Setter가 모두 열려있을 경우 변경 포인트가 너~무 많아서 유지보수하기 어렵다!(Getter는 열어놔도 됨)
2. 모든 연관관계는 지연로딩(Lazy)으로 설정하자(⭐️)
- 즉시로딩(EAGER)은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
JPA는 크게 2가지 방식으로 데이터를 조회한다.
| JPQL (객체 중심) | "SELECT o FROM Order o" | 엔티티 기준 쿼리. SQL 아님 |
| JPA 메서드 직접 조회 | em.find(Order.class, id) | PK로 한 건만 조회할 때 |
그 중에서 N+1 문제가 자주 터지는 곳은 JPQL로 리스트를 조회할 때 !
즉, 연관관계로 매핑된 엔티티를 조회할 때 첫 번째 쿼리로 날린 결과만큼 n번의 쿼리가 나가는 것을 말한다.
Q) 그럼 N+1문제는 무엇일까?
N+1 문제란 원래는 쿼리 1번이면 충분할 것 같았는데, 실제로는 추가적인 N번의 쿼리가 실행되는 현상을 말한다.
(1+N이라고 생각하면 더 이해하기 편하다.)
JPA는 @ManyToOne, @OneToOne, @OneToMany, @ManyToMany 등 여러 연관관계 매핑을 자동으로 지원해준다.
그리고 JPA는 객체 중심(Entity 중심) 으로 개발할 수 있도록 SQL 대신 객체를 다루는 쿼리 언어인 JPQL도 제공한다.
하지만 바로 이 연관관계 즉시 로딩(EAGER) 과 JPQL이 만나면 개발자가 모르게 추가 쿼리(N) 가 발생하게 되는 것이다.
- 즉시로딩(EAGER): 연관된 엔티티를 함께 한 번에 조회하려고 JPA가 자동으로 JOIN 쿼리를 만들어 실행하는 방식
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
@Table(name="orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER) // 또는 기본 설정
@JoinColumn(name = "member_id")
private Member member;
}
예를 들어 하나의 Member는 여러 개의 Order를 가질 수 있는 엔티티 구조가 있을 때(일대다), Order와 Member의 관계는 다대일의 관계다. 이 경우 우리는 Order를 1번 가져오면 추가 쿼리 발생 없이 Order에 필요한 Member 데이터도 자동으로 가져올 수 있을 거라 생각한다.
List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class).getResultList();
그러나 위 코드를 보면 알 수 있듯 Order는 쿼리로 1번 가져오지만, Order 마다 연결된 Member를 즉시로딩하기 때문에 100번의 Member select가 추가로 발생한다. (Order를 가져오는 시점에 Member를 가져오는 것)
만약 100만명의 회원이 있다면? .. Order 쿼리 한번이 발생할 때 Member 100만번이 실행되어야 한다. 즉, 쿼리 1번 + 연관된 엔티티 N번 = 총 N+1번의 쿼리가 발생한다. 한 번의 서비스 로직 실행에 DB 쿼리가 100만번 실행된다는 것은 엄청난 비효율적인 방식이다.
Q) 그럼 어떻게 해결해야할까?
- 실무에서 모든 연관관계는 지연로딩(LAZY)으로 설정해야 한다.
- @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩(LAZY)으로 설정해야 한다.
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
- @XtoMany(OneToMany, ManyToMany) 관계는 기본이 지연로딩(LAZY)이므로 따로 설정해줄 필요 없다.
- 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
fetch join: 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능 (연관된 엔티티까지 영속성 컨텍스트에 전부 올림)
@Query("select o from Order o join fetch o.member")
List<Order> findOrdersWithMembers();
- 대표적인 fetch join 문제 상황: OneToMany 관계에서 페이징 처리할 때
- fetch join과 페이징을 함께 처리하면, fetch join한 데이터를 전부 가져와서(full scan) 인메모리(RAM, Heap Area)에 넣어놓고 가공하는 작업을 거치게 된다. 근데, 만약 데이터가 100만건이라면 이 100만건의 데이터를 전부 가져와서 메모리에서 관리하게 되면 메모리 부하가 일어나게 된다. (페이징은 JPA 내부 메모리에서 수행하게 된다. 이걸 in-memory라고 함)
- OneToMany, ManyToMany (1:N or N:M)의 연관관계에서 문제가 발생한다.
이걸 해결하기 위한 것이 바로!
@BatchSize()
@Entity
public class Member {
@OneToMany(mappedBy = "member")
@BatchSize(size = 100) // member 100명 기준으로 한 번에 orders 조회
private List<Order> orders = new ArrayList<>();
}
List<Member>를 LAZY 로딩으로 가져온 다음, getOrders()를 호출할 때 100명 단위로 IN 쿼리 한 번에 가져옴
SELECT * FROM orders WHERE member_id IN (?, ?, ..., ?) -- 최대 100개
100개의 SELECT 쿼리가 아니라, 1개의 IN 쿼리로 최적화할 수 있다!
3. 컬렉션은 필드에서 초기화 하자
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
컬렉션은 필드에서 바로 초기화 하는 것이 안전하다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(member);
System.out.println(member.getOrders().getClass());
//출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag
- `null` 문제에서 안전하다.
public List<Order> getOrders() {
if (orders == null) {
orders = new ArrayList<>();
}
return orders;
}
- 하이버네이트는 엔티티를 영속화 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders() 처럼 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생할 수 있다. 따라서 필드레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다.
- 이건 게으른 초기화(lazy init) 방식이지만, Hibernate가 proxy로 감싸둔 컬렉션을 개발자가 직접 new로 덮어쓰기 때문에 위험
- 개발자가 임의로 new를 하면 프록시가 깨지고 Hibernate가 더 이상 추적하거나 조작할 수 없게 된다.
객체를 영속화 한다는 것은 DB에 저장할 수 있는 상태로 객체를 관리 한다는 의미.
- EntityManager.persist(entity) 호출
- 이 시점에서 entity는 영속 상태가 되어 영속성 컨텍스트(1차 캐시)에 등록된다. (DB에 바로 저장X)
- 트랜잭션 안에서 flush() 또는 commit() 발생
- 이때 영속성 컨텍스트에 쌓인 변경사항이 DB로 반영된다 (INSERT, UPDATE 등 SQL 날아감).
- 즉, commit 시점에 DB에 저장됨
- 그 전까지는 1차 캐시에만 존재
- 트랜잭션 중 find()를 하면 DB가 아니라 영속성 컨텍스트(1차 캐시)에서 먼저 찾는다.
- 이를 통해 성능 최적화도 이뤄지고, 같은 엔티티는 항상 동일성을 유지할 수 있음
4. 테이블, 컬럼명 생성 전략
스프링 부트에서 하이버네이트 기본 매핑 전략을 변경해서 실제 테이블 필드명은 다름
하이버네이트 기존 구현: 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용(SpringPhysicalNamingStrategy)
스프링 부트 신규 설정 (엔티티(필드) 테이블(컬럼))
1. 카멜 케이스 ➡️ 언더스코어(memberPoint member_point)
2. .(점) ➡️ _(언더스코어)
3. 대문자 ➡️ 소문자
'BE > SpringBoot' 카테고리의 다른 글
| [JPA] 4. 회원 도메인 개발 (0) | 2025.05.13 |
|---|---|
| [JPA] 3. 애플리케이션 구현 준비 (0) | 2025.05.12 |
| [JPA] @Embedded @Embedable 임베디드 타입이란? (0) | 2025.05.07 |
| [JPA] 2. 도메인 분석 설계 - 요구사항 분석 / 도메인 모델과 테이블 설계 / 엔티티 클래스 개발 (1) | 2025.05.04 |
| [JPA] 1. 프로젝트 환경설정 (0) | 2025.05.04 |