-
ORM 심화 및 데이터 로딩 전략이커머스 devops/국비교육 2025. 11. 15. 22:57
1. JPA 로딩 전략: LAZY vs EAGER
- 지연 로딩 (Lazy Loading)
- 연관된 엔티티 데이터를 실제로 사용하는 시점까지 조회를 미루는 방식
- 엔티티를 조회할 때 연관된 엔티티를 가져오지 않고, 프록시라는 가짜 객체를 넣어둔다 이후에 프록시 객체의 필드를 실제로 호출할 때 DB에 조회 쿼리가 실행된다
// --- Entity 코드 --- @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List<Purchase> purchases; // --- 실행 코드 --- // 1. User 조회 시점: user 테이블만 조회한다. User user = userRepository.findById(1L).get(); // 2. user.getPurchases()는 아직 비어있는 '가짜' 리스트(프록시)다. System.out.println("사용자 이름: " + user.getUsername()); // 3. purchases 리스트를 실제로 사용하는 시점에 purchase를 조회하는 쿼리가 실행된다. System.out.println("주문 개수: " + user.getPurchases().size()); // --- 3번 라인 실행 시 나가는 쿼리 --- // select p1_0.user_id, p1_0.id ... from purchase p1_0 where p1_0.user_id=?- 장점 : 초기 로딩 속도가 빠르고, 당장 필요 없는 데이터까지 조회하는 낭비를 막을 수 있다
- 단점 : 데이터를 사용할 때마다 추가 쿼리가 발생할 수 있다 (N+1 문제)
- 즉시 로딩 (Eager Loading)
- JPA가 엔티티를 조회할 때, 연관된 엔티티를 함께 가져오기 위해 JOIN 쿼리를 사용한다
// --- Entity 코드 --- @OneToMany(mappedBy = "user", fetch = FetchType.EAGER) private List<Purchase> purchases; // --- 실행 코드 --- // User 조회 시점에 user와 purchase 테이블을 JOIN하여 모든 데이터를 한 번에 가져온다. User user = userRepository.findById(1L).get(); // --- user 조회 시 바로 나가는 쿼리 --- // select u1_0.id, ..., p1_0.user_id, p1_0.id, ... // from user u1_0 // left join purchase p1_0 on u1_0.id=p1_0.user_id // where u1_0.id=?- 장점 : 연관된 데이터를 바로 사용할 때 추가 쿼리 없이 즉시 접근할 수 있다
- 단점 : 사용하지도 않을 데이터를 미리 조회하여 초기 로딩이 느려지고, 메모리 낭비가 발생할 수 있다 (특히 심각한 성능 문제)
2. N+1 문제
- JPA를 사용하면서 애플리케이션의 선능을 저하시키는 가장 대표적인 원인
N+1 문제
- 연관관계가 설정된 엔티티를 조회할 때, 첫 쿼리의 결과(1개)로 N개의 데이터가 조회된 후 이 N개의 데이터 각각에 연관된 데이터를 얻기 위해 N개의 추가 쿼리가 발생하는 현상
- 지연로딩 N+1 문제 시나리오
List<User> users = userRepository.findAll(); // 쿼리 1번 발생 for (User user : users) { // user.getOrders()를 호출하는 순간, 각 User마다 쿼리 N번 발생 System.out.println("주문 개수: " + user.getOrders().size()); }- 즉시로딩 N+1 문제 시나리오
- findAll()과 같이 여러 건(컬렉션)을 조회하는 경우
- JOIN 포기 이유 : 여러 User와 각자의 Purchase 목록을 JOIN하면, 한 User가 5개의 주문을 가졌을 경우 User 정보가 5번 중복된 데이터가 생성되며 이는 데이터 부정확성 및 페이징 처리 불가 문제가 일어난다
// 1. 먼저 루트 엔티티(User)를 모두 조회(쿼리 1번) SELECT * FROM user; // 2. 이후, 조회된 각 User에 대해 연관된 컬렉션(Purchase)을 개별적으로 조회(쿼리 N번) SELECT * FROM purchase WHERE user_id = 1; SELECT * FROM purchase WHERE user_id = 2; ...N+1 문제 해결 방법
- Fetch Join : 처음부터 다 가져오기
- JPQL의 JOIN FETCH 문법을 사용하여, 조회 시점부터 연관된 엔티티 데이터를 함께 가져오는 방식
- SQL의 JOIN과 동일하게 동작하여, 쿼리 한 번으로 모든 데이터를 가져온다
// UserRepository.java (purchases는 User 엔티티의 필드명) @Query("SELECT u FROM User u JOIN FETCH u.purchases") List<User> findAllWithPurchases(); -- 위 JPQL 실행 시 실제 나가는 쿼리 (단 1개) // select u1_0.id, p1_0.id, ... // from user u1_0 // join purchase p1_0 on u1_0.id=p1_0.user_id- 주의사항 : 2개 이상의 컬렉션을 Fetch Join 하거나, 페이징 쿼리와 함께 사용할 때 제약
- @BatchSize : 묶어서 가져오기
- LAZY 로딩을 유지하면서, 연관된 데이터 조회가 필요할 때 지정된 size 만큼의 ID를 모아 IN 절 쿼리로 한 번에 조회하는 방식
// User.java의 컬렉션 필드에 어노테이션 추가 @BatchSize(size = 100) // 100개씩 묶어서 조회 @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List<Purchase> purchases = new ArrayList<>(); -- user.getOrders().size() 호출 시 나가는 쿼리 -- 1. User 전체 조회 // select u1_0.id, ... from user u1_0 -- 2. user_id를 100개씩 묶어 IN 절로 조회 // select p1_0.user_id, p1_0.id, ... // from purchase p1_0 // where p1_0.user_id in (?, ?, ..., ?) -- ID 100개- 장점 : LAZY 로딩의 장점을 살리면서 쿼리 수를 1 + (N / batch_size)로 감소, 글로벌 설정으로 프로젝트 전체에 적용하기도 편리함
- 주의사항 : IN 절에 들어갈 파라미터 개수가 너무 많아지면 DB에 따라 성능 저하가 발생할 수 있다
Fetch Join vs @BatchSize

결론
- 모든 연관관계는 무조건 지연로딩으로 설정
- @ManyToOne, @OneToOne 관계는 기본값이 EAGER, 반드시 fetch=FetchType.LAZY 명시적 추가
- 필요한 데이터는 Fetch Join으로 한 번에 가져온다
- Lazy로 설정한 후 특정 기능에 연관된 데이터가 필요하다면 Fetch Join을 사용하여 N+1 문제를 해결 권장
- @BatchSize는 차선책, 글로벌 최적화 전략으로 고려
728x90'이커머스 devops > 국비교육' 카테고리의 다른 글
QueryDSL 기본 문법 (0) 2025.11.19 영속성 컨텍스트와 트랜잭션 & QueryDSL (0) 2025.11.16 RESTful API 설계하기 (0) 2025.11.16 공통 응답 처리와 예외 핸들링 (0) 2025.11.16 프로젝트 기본 설정 (0) 2025.11.15