개발기술/ORM

JPA 데이터 조회방식 개념

bsh6226 2025. 3. 5. 16:25

JPA 내에서 데이터를 조회하는 2가지 방식

1. 엔티티 기반 조회 (Entity-based fetch)

 

  • join fetch, find(), JPQL, EntityGraph
  • 반환: 엔티티
  • 특징: 영속성 컨텍스트에 등록됨 (관리됨)
    • 엔티티 기반 fetch는 도메인 객체를 조작하거나, 트랜잭션 내에서 상태를 변경할 때 사용
    • JoinFetch는 영속성 컨텍스트 내  연관관계를 자동으로 맞춰줘야하기에 대용량 데이터 조회에 성능이 좋지않음
SELECT u FROM User u JOIN FETCH u.posts

 

 

2. 값 기반 조회 (DTO Projection)

  • new Dto(...), Projections.constructor(), Tuple, native query
  • 반환: DTO, 값 객체, 튜플
  • 특징: 영속성 컨텍스트에 등록되지 않음 (단순 값)
    • DTO 기반 fetch는 DB에서 값만 뽑아서 DTO로 복사하여 성능이 좋아 조회용, API 응답용, 대용량 페이징 등에 사용
  Entity fetch join DTO Projection
주 사용처 내부 서비스 로직 (DB 데이터 갱신, 복잡한 객체 그래프 유지 필요할 때) API 응답, 단순 조회용 화면 데이터, 배치 데이터 처리
장점 영속성 관리 쉬움 (변경감지, Dirty Checking) 속도 빠르고, 필요한 데이터만 가져옴, 안전함
단점 가져오는 데이터가 커짐 (필요 없는 것까지 로딩)
여러 컬렉션을 동시에 fetch join 하지 말 것. 
수정 불가 (읽기 전용), 추가 가공 필요 가능성있음
사용
빈도
적다 (10~20%) 압도적으로 많다 (80~90%)

 

 

JPA의 핵심 철학: 엔티티 객체 그래프

엔티티 간의 관계(객체 그래프)를 자바 객체처럼 자연스럽게 탐색하면서 유지·관리하는 프레임 워크. JPA는 객체 간의 참조 관계를 그대로 보존하여, 개발자가 객체 탐색만으로 도메인 로직을 작성할 수 있도록 하기 위한 프레임워크입니다.

 

1. 관계형 데이터가 아닌 객체 그래프 관점에서 생각

  • DB는 테이블 중심, JPA는 객체 관계 중심
  • @OneToMany, @ManyToOne 등으로 연관 객체를 탐색할 수 있도록 설계
  • 핵심은 “SQL join 대신 자바 객체 탐색으로 비즈니스 로직을 구성하자
Order order = em.find(Order.class, 1L);
Member member = order.getMember(); // SQL 없이 객체 탐색

2. 엔티티는 항상 객체 그래프의 일부로 존재

  • JPA는 단일 객체가 아닌 서로 연결된 객체의 그래프 구조로 관리함
  • Persistence Context(영속성 컨텍스트)가 객체 간 관계를 추적하고, 동기화 유지

3. Fetch 전략은 객체 그래프 최적화 수단

  • Lazy → 필요한 객체만 불러오자
  • Join Fetch → N+1 문제를 피하고 한 번에 그래프를 구성하자
  • “불러올 것만 명확히 정하고, 나머지는 관계만 유지”

JPA Join Fetch Filtering 규칙

JPA에서 규정하는 Join Fetch는 Entity간의 온전한 관계성을 loading하는 데에 1차적인 목적이 있기때문에 로딩시 filtering을 지양한다. filtering이 되면 컬렉션은 ‘부분 로딩’된 상태지만 관계 상은 완전히 로딩됨"으로 간주해서 일관성이 깨짐. 

  • 정교한 필터링은 Java 코드(서비스 로직)**에서 하길 유도합니다.

https://docs.oracle.com/html/E13946_05/ejb3_langref.html

SELECT t FROM Team t
JOIN FETCH t.members     -- 컬렉션(members)을 FETCH JOIN
WHERE t.members.name = 'A'  -- ❌ 컬렉션 경로 탐색에서 필터링 불가

 

on 절에 필터 조건 (ex: status = 'YES') 추가됨 

select c from Company c
join fetch c.employees e on e.age > 30
  • SQL관점
    • 조인할 때부터 조건 걸림 → 조인 대상 줄어듦, 조인 최적화에 유리 (필터 먼저 → 조인)
  • Hibernate관점 : 스펙상 금지
    • Hibernate가 join … with로 변환하는데 일부DBMS에서는 join with를 허용하지 않아서 hibernate6부 SemanticException
    • Company 엔티티의 employees 컬렉션이 일부만 채워짐→ 일관성 깨짐, Hibernate가 with로 번역 → 에러 터짐 ❌

where절 필터링

select c from Company c
join fetch c.employees e
where e.age > 30
  • SQL관점
    • 쿼리 최적화 엔진이 영리해서 내부적으로 JOIN ON과 JOIN + WHERE를 같은 방식으로 실행하기도 함
  • Hibernate관점 : 허용하지만 비권장
    • where 에 fetch join 경로를 넣는 건 스펙에 명시적으로 허용
    • Company row가 employee 조건에 맞는 경우만 결과에 옴. 그러나 가져온 employee만 컬렉션에 남아 → 부분 로딩

종합하면 fetch join은 연관 연결만 on에서 하고, 필터링은 무조건 where로 내려라

select *
from company c
join employee e on c.id = e.company_id
where e.age > 30

 

올바른 fetch join

.selectFrom(companySolution)
.innerJoin(companySolution.aiSolution, aISolution)  // 단순 연결
.fetchJoin()
.where(
    aISolution.dataStatus.eq(DataStatus.Yes)  // 필터는 where절
)
    .fetch();

 

에러 나는 fetch join

.selectFrom(companySolution)
.innerJoin(companySolution.aiSolution, aISolution)
.on(aISolution.dataStatus.eq(DataStatus.Yes))  // ❌ 추가조건 on에 때려서 with절 발생
    .fetchJoin()
.where(...)
.fetch();

 

Entity Fetching의 비효율성

select c from Company c
join fetch c.employees
join fetch c.projects

 

to-many 컬렉션 fetch join 여러 개 → 카티션 곱 발생

  • 만약 employees가 3명, projects가 2개면?
    • SQL에서 조인의 결과 row는 3 x 2 = 6줄 → 같은 Company와 Employee, Project가 반복되어 조합됨
  • 카티션 곱이 발생한 row를 메모리에 올리고, hibernate 수준에서 중복을 제거하기 위한 연산이 비싼 작업임.
    • 각 row마다 Company 객체 생성 전에 “이미 영속성 컨텍스트에 있는가?” 확인
    • 특히 List<객체>는 순서까지 신경 써야 해서 더 비쌈

카티션 곱 SQL 조인결과

Row 1: Company1, Employee1, Project1
Row 2: Company1, Employee1, Project2
Row 3: Company1, Employee2, Project1
Row 4: Company1, Employee2, Project2
Row 5: Company1, Employee3, Project1
Row 6: Company1, Employee3, Project2

 

Hibernate 처리

Company company = companyMap.computeIfAbsent(row.companyId, id -> new Company(id, row.companyName));
Employee employee = employeeMap.computeIfAbsent(row.employeeId, id -> new Employee(id, row.employeeName));
Project project = projectMap.computeIfAbsent(row.projectId, id -> new Project(id, row.projectName));

 

실무에서의 대략적인 기준 (JPA fetch join 기준)

Row 수 상황 의미
100 ~ 500 row 대부분 안전 일반 요청이나 화면 조회 수준
1,000 ~ 3,000 row 경계선 컬렉션 fetch join 1~2개까진 괜찮음
5,000 row 이상 위험 가능성 ↑ 중복 row 증가, OOM 가능성 존재
10,000 row 이상 주의 필요 절대 fetch join 여러 개 금지, DTO projection 고려
상황 권장
단건 조회 + to-one 관계 (1 depth) fetch join OK
단건 조회 + to-many 1개 fetch join 가능 (주의)
to-many 2개 이상 (1 depth라도) fetch join ❌ → N+1 방지용 별도 쿼리 또는 DTO
2 depth 이상 탐색 join fetch는 피하고 DTO Projection 사용 권장

 

Distinct사용

distinct는 JPA와 SQL 양쪽에서 서로 조금 다르게 작동하기 때문에 주의가 필요

SQL의 DISTINCT — 행(row) 단위 중복 제거

SELECT DISTINCT c.*
FROM company c
JOIN employee e ON ...
  • 결과 테이블(row) 기준으로 중복 제거
  • 모든 컬럼 값이 같은 row만 제거 대상
  • JPA에서는 DTO projection일 때는 이 동작 그대로 적용됨

JPA Entity 조회 시 distinct — 엔티티의 식별자 기준 중복 제거

List<Company> result = em.createQuery(
        "SELECT DISTINCT c FROM Company c JOIN FETCH c.employees", Company.class)
    .fetch();

 

  • 이 경우 SQL 쿼리에는 그대로 DISTINCT가 들어감
  • 하지만 Hibernate는 Java 쪽에서도 한 번 더 중복 제거를 함
    • 기준: equals()/hashCode() 가 아니라 식별자(PK) 기준
    • 이유: fetch join으로 인해 카디션 곱처럼 여러 row가 생기니까
  • 즉, 중복된 Company 객체는 하나만 영속성 컨텍스트에 올라감
  • Hibernate는 fetch join으로 생긴 row 중복을 제거하지 않습니다 → distinct를 명시적으로 써야만 Java 레벨에서 중복 제거를 합니다.

 

 

객체지향쿼리(QueryDSL)의 필요성

  • A를 조회할 때 단순한 EntityManager.find() 또는 객체 그래프 탐색 (a.getB().getC()) 방식으로만 데이터를 조회하는 것은 어려움.
  • 모든 회원 엔티티를 메모리에 올려두고 나서 조건에 맞추어 검색하는 것은 비현실적
    • 데이터베이스 상에서 sql로 최대한 내용을 필터링하여 가져오는 것이 성능에서 유리함.
    • 객체지향성을 유지하면서 필터링을 하는 기능이 필요함. 
Member member = entityManager.find(Member.class, 1L);
String city = member.getAddress().getCity();

 

  • 여기서 find()는 ID 1L 하나만 가져옴
  • 조건 검색, 정렬, 여러 개 가져오기 등은 못함, 필터링 = 없음 (조건 없음)

 

List<Member> all = memberRepository.findAll();
List<Member> filtered = all.stream()
    .filter(m -> m.getAge() > 30)
    .collect(Collectors.toList());
  • 전체를 다 DB에서 가져온 뒤, 자바 코드에서 필터링
  • DB 부하 증가, 네트워크 낭비, 메모리 낭비

객체지향 쿼리의 장점

SQL처럼 DB에서 필터링하되, 테이블이 아닌 엔티티를 대상으로 하고, 연관관계도 객체처럼 쓸 수 있다.

queryFactory.selectFrom(member)
    .where(member.age.gt(30))
    .fetch();
  • SQL처럼 DB에서 필터링하되, 테이블이 아닌 엔티티를 대상으로 하고, 연관관계도 객체처럼 쓸 수 있다.
    • 따라서 DB에 종속적인 코드가 줄어들어 유지보수가 편리함.
  • 컴파일 시점에 문법 오류를 발견 가능
    • JPQL은 문자열 기반이므로 런타임에서 오류가 발생할 수 있음.
    • 하지만 QueryDSL은 컴파일 시점에 문법 오류를 잡을 수 있어 안정성이 높음.
  • 동적 쿼리 작성이 쉬움
    • 동적 쿼리는 사용자의 입력값이나 특정 조건에 따라 SQL이 변경되는 쿼리를 의미합니다.
    • 검색 기능에서 "사용자가 입력한 값에 따라 WHERE 절을 다르게 적용" 하는 경우가 해당됩니다. 
      • BooleanBuilder, where() 조건 조합 등을 활용하여 가독성 높은 코드를 작성할 수 있음.