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 문제를 피하고 한 번에 그래프를 구성하자
- “불러올 것만 명확히 정하고, 나머지는 관계만 유지”
Join Fetch Filtering 규칙
JPA에서 규정하는 Join Fetch는 Entity간의 온전한 관계성을 loading하는 데에 1차적인 목적이 있기때문에 로딩시 filtering을 지양한다. filtering이 되면 컬렉션은 ‘부분 로딩’된 상태지만 관계 상은 완전히 로딩됨"으로 간주해서 일관성이 깨짐.
- 정교한 필터링은 Java 코드(서비스 로직)**에서 하길 유도합니다.
https://docs.oracle.com/html/E13946_05/ejb3_langref.html
10.2. JPQL Language Reference
The SELECT clause denotes the query result. More than one value may be returned from the SELECT clause of a query. The SELECT clause may contain one or more of the following elements: a single range variable or identification variable that ranges over an e
docs.oracle.com
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 레벨에서 중복 제거를 합니다.
JPA에서 연관 테이블 조회방법
- JoinFetch는 영속성 컨텍스트 내 연관관계를 자동으로 맞춰줘야하는 반면, Projection은 DB에서 값만 뽑아서 DTO로 복사하면 됨.
- 때문에 성능차이가 많이 나고 그러므로 CRUD 내부 비즈니스 로직은 Entity로 하는 경우가 많고 API 응답, 조회, 통계 화면은 무조건 DTO Projection 쓴다
Entity fetch join | DTO Projection | |
주 사용처 | 내부 서비스 로직 (DB 데이터 갱신, 복잡한 객체 그래프 유지 필요할 때) | API 응답, 단순 조회용 화면 데이터, 배치 데이터 처리 |
장점 | 영속성 관리 쉬움 (변경감지, Dirty Checking) | 속도 빠르고, 필요한 데이터만 가져옴, 안전함 |
단점 | 가져오는 데이터가 커짐 (필요 없는 것까지 로딩) 여러 컬렉션을 동시에 fetch join 하지 말 것. |
수정 불가 (읽기 전용), 추가 가공 필요 가능성 |
일반 사용 빈도 |
적다 (10~20%) | 압도적으로 많다 (80~90%) |
필터규칙 | fetch join + where 사용 OK | fetch join에서 조건 걸지 말고, ✔ 서브쿼리 또는 DTO projection 사용 |
QueryDSL로 DTO Projection 하기
List<UserDto> result = queryFactory
.select(Projections.constructor(UserDto.class, user.name, user.age))
.from(user)
.where(user.status.eq(Status.ACTIVE))
.fetch();
JPA에서 제공하는 주요 쿼리 생성 방법
JPA는 객체를 기반으로 검색할 수 있도록 여러 가지 방법을 제공합니다.
조회 | 방법설명 |
JPQL (Java Persistence Query Language) | 엔티티 객체를 대상으로 SQL과 유사한 문법으로 조회 |
네이티브 SQL | JPA에서 직접 SQL을 사용할 수 있도록 지원 |
QueryDSL | JPQL을 타입 안전하게 편리하게 작성할 수 있도록 도와주는 빌더 클래스 |
JPA Method 생성시 Method Naming 룰 in Spring Data JPA
- findbyId : Id를 기준으로 해당하는 엔티티를 반환
- save() : PK에 해당하는 엔티티가 존재하지 않으면 create, 존해자면 update 함.
- 업데이트 : jpa에서는 업데이트문이 존재하지 않고 find()로 엔티티를 조회하고 필요할 필드를 수정후 save()진행함. 그러면 JPA는 조회된 엔티티의 필드를 변경하고 save를 호출할 때 이를 자동으로 변경사항으로 인식하여 update 쿼리를 생성합니다. 이 방식은 JPA의 변경 감지 기능을 이용합니다.
- saveAll() : Entity가 List형태로 보관되어 있을 때 전부 저장한다.
- existById() : Id로 해당 record가 존재하는지 검색하여 boolean값을 반환한다. Custom property를 사용하여 구현도가능
- findAll() :
- findByEntityProperty() : 맵핑된 엔티티의 특정 속성값을 지정하여 조회에 활용 할 수 있다.
- findAllById:
- deleteById:
- deleteAll:
JPA Method 생성시 Method Naming 룰 in Spring Data JPA
- findAllByIdStartingWithIgnoreCase : SQL문으로 WHERE UPPER(Id) LIKE UPPER('sam%')로 대소문자 구분없이 특정 키워드로 시작하는 ID값을 전체 조회하여 List 형태로 받는다.
- findAllByCommunityEntity_CommunityId : communityEntity를 부모로 두는 entity를 communityId로 데이터를 찾는다.
메소드 이름을 설정할때 아래와 같은 규칙을 따라서 생성해야 정확한 sql문 생성하는 메소드를 정의할 수있다.
- Start with a Prefix: such as (find...By = read...By =query...By= get...By), count...By, and. findfirstb
- Specify the Criteria: After the prefix, continue with a property name of the entity you are querying.
- Conditions and Keywords: . Some common keywords include Containing, Between, LessThan, GreaterThan, Like, In, StartsWith, EndsWith, and more.
- Connecting Keywords : For multiple properties, you can use connecting keywords like And and Or to combine conditions.
- Handling Collections: If you are dealing with collections, you can use expressions like IsEmpty, IsNotEmpty, In, etc., to express conditions on the collections.
- Ordering Results: You can specify the order of the results by appending OrderBy followed by the property name and direction (Asc or Desc) at the end of the method name..
Optional<User> findByEmailAddressAndLastName(String email, String lastName);
Optional<User> findByAgeGreaterThanEqual(int age);
Optional<User> findByLastNameIgnoreCase(String lastName);
Optional<User> findByStartDateBetween(Date start, Date end);
Optional<User> findByActiveTrue();
Optional<User> findByAgeIn(Collection<Integer> ages);
Optional<User> findByLastNameOrderByFirstNameAsc(String lastName);
- 쿼리시에 값이 존재하지 않아서 DB에서 아무 응답도 없는 경우도 있기 때문에 이 예외처리를 위해서 Optional으로 값을 받아주고 optional.else로 예외처리를 해준다
- 엔티티 필드 이름은 카멜 케이스로 작성하지만, 데이터베이스의 컬럼명은 보통 **스네이크 케이스(snake_case)**로 작성됩니다.
Repository method 커스텀 정의
커스텀 필요성
JPA의 method를 통해서 많은 부분들이 추상화되었지만 여전히 SQL작성은 필요함. 아래와 같은 케이스가 그 케이스임.
커스텀 쿼리 주요 어노테이션
- @Query : hibernate method 외에 JPQL 쿼리를 커스텀으로 작성할 수 있다.
- nativeQuery = true : SQL문을 작성가능(DB 종속적임)
- @Modifying : INSERT, UPDATE, and DELETE문으로 해당 쿼리가 단순 조회가 아닌 데이터를 수정하는 쿼리임을 표시하며, 수정된 행(row)의 수를 반환합니다.
- @Param (쿼리 파라미터 매핑) : JPQL/Native Query 내부의 :parameter과 메소드의 parameter 변수를 매핑
- @EntityGraph(attributePaths = {"relatedEntity"}) : Select 문으로 entity를 가져올 때 연관된 entity를 joinfetch 하여 N+1문제를 예방한다.
- BestPractie : JPA 내장 메소드에 사용시에는 동작을 변경시키기때문에 커스텀 메소드에 적합하며 Join문을 줄여주는 역할함
커스텀 메소드 정의 시 JPQL vs Native SQL vs QueryDSL 비교
Native SQL
- 네이티브 SQL은 JPQL과 달리 데이터베이스의 테이블을 직접 대상으로 함
- 결과가 엔티티 매핑이 보장되지 않음
- 특정 데이터베이스의 고유 기능(예: JSON 함수, 윈도우 함수, InsertIgnore, OnduplicateUpdate 등)을 사용 가능
@Modifying
@Query(value = "INSERT INTO heritage_entity"
+ " (heritage_Id, heritage_Name, location, heritage_Grade, basic_Description)"
+ " VALUES (:heritageId, :heritageName, :location, :heritageGrade, :basicDescription)"
+ " ON DUPLICATE KEY UPDATE"
+ " heritage_Name = :heritageName, location = :location, heritage_Grade ="
+ " :heritageGrade, basic_Description = :basicDescription", nativeQuery = true)
int insertOrUpdate(String heritageId, String heritageName, Point location,
String heritageGrade, String basicDescription);
JPQL
- SQL과 달리 테이블이 아니라 엔티티(Entity)를 대상으로 쿼리를 작성하여 Select *이 아니라 select entity(or alias) 사용
- 유지보수성 : DB가 엔티티로 추상화되어있기때문에 DB 엔진이나 테이블 구조가 바뀌어도 엔티티 구조에따라 JPQL을 그대로 사용가능
- JOIN FETCH : 테이블을 Join 시키면서 Join하는 테이블을 eager fetch하도록 한다.
@Query("select v.heritageEntity from VisitedHeritageEntity v join fetch v.heritageEntity where v.memberEntity.memberId = :memberId")
List<HeritageEntity> findAllVisitedHeritageByMemberId(String memberId);
// Custom query to calculate the average rating for a specific store
@Query("SELECT AVG(r.rating) FROM ReviewEntity r WHERE r.storeEntity = :storeEntity")
Double findAverageRatingByStore(@Param("storeEntity") StoreEntity storeEntity);
}
QueryDSL (타입 안전한 동적 쿼리 빌더)
JPQL을 Java 코드로 변환하여 더 안전하고, 유지보수하기 쉽게 만든 쿼리 빌더 프레임워크방식. QueryDSL을 사용하면 JPA가 실행할 때 QueryDSL → JPQL → SQL 변환 과정을 거칩니다.
- 동적 쿼리 작성이 편리함 (where() 절에 조건을 동적으로 추가 가능
- 자동 완성 지원 (user.username.eq(username)) → 유지보수성 향상
다양한 쿼리 상황에서 Native SQL, JPQL, QueryDSL 문법 비교
단순 조회 (단건 조회)
@Query(value = "SELECT * FROM user_table WHERE username = :username", nativeQuery = true)
Optional<UserEntity> findByUsernameNative(@Param("username") String username);
- SQL : from 절이 전체 쿼리에 영향을 미치기에 username앞에 테이블을 생략가능. 단, 여러 테이블을 JOIN시에명시
@Query("SELECT u FROM UserEntity u WHERE u.username = :username")
Optional<UserEntity> findByUsername(@Param("username") String username);
QUserEntity user = QUserEntity.userEntity;
Optional<UserEntity> userEntity = queryFactory
.selectFrom(user)
.where(user.username.eq(username))
.fetchOne();
다중 조건 필터링 조회 (동적 쿼리)
(예: username과 role이 일치하는 유저 검색, 조건이 없으면 전체 검색)
@Query(value = "SELECT * FROM user_table WHERE (:username IS NULL OR username = :username) AND (:role IS NULL OR role = :role)", nativeQuery = true)
List<UserEntity> findUsers(@Param("username") String username, @Param("role") String role);
String jpql = "SELECT u FROM UserEntity u WHERE 1=1";
if (username != null) jpql += " AND u.username = :username";
if (role != null) jpql += " AND u.role = :role";
@Query(jpql)
List<UserEntity> findUsers(@Param("username") String username, @Param("role") String role);
QUserEntity user = QUserEntity.userEntity;
BooleanBuilder builder = new BooleanBuilder();
if (username != null) builder.and(user.username.eq(username));
if (role != null) builder.and(user.role.eq(role));
List<UserEntity> users = queryFactory
.selectFrom(user)
.where(builder)
.fetch();
다중 테이블 JOIN
- Native SQL :
- Since the result contains columns from both user_table and order_table, JPA does not know where to map thus object[]
- native queries cannot directly return JPA entities with relationships.
@Query(value = "SELECT u.*, o.* FROM user_table u JOIN order_table o ON u.id = o.user_id WHERE u.username = :username", nativeQuery = true)
List<Object[]> findUserOrdersNative(@Param("username") String username);
- JPQL SQL :
- JOIN FETCH ensures that orders are eagerly loaded into UserEntity.orders
@Query("SELECT u FROM UserEntity u JOIN FETCH u.orders WHERE u.username = :username")
List<UserEntity> findUserOrders(@Param("username") String username);
QUserEntity user = QUserEntity.userEntity;
QOrderEntity order = QOrderEntity.orderEntity;
List<Tuple> userOrders = queryFactory
.select(user, order)
.from(user)
.join(user.orders, order)
.where(user.username.eq(username))
.fetch();
Native SQL :
/*
SELECT a.id AS auth_id, a.name AS auth_name, COUNT(ua.id) AS user_count
FROM auth a
LEFT JOIN user_auth ua ON ua.auth_id = a.id
GROUP BY a.id, a.name;
*/
QueryDSL Code :
public List<AuthorityDto.AuthListResDao> findAllWithUserCount() {
List<Tuple> results = queryFactory
.select(auth.id, auth.name, userAuth.count())
.from(auth)
.leftJoin(userAuth).on(userAuth.auth.eq(auth))
.groupBy(auth.id, auth.name)
.fetch();
객체지향쿼리의 필요성
A를 조회할 때 단순한 EntityManager.find() 또는 객체 그래프 탐색 (a.getB().getC()) 방식으로만 데이터를 조회하는 것은 어려움.
- 모든 회원 엔티티를 메모리에 올려두고 나서 조건에 맞추어 검색하는 것은 비현실적
- 데이터베이스 상에서 sql로 최대한 내용을 필터링하여 가져오는 것이 성능에서 유리함.
- 객체지향성을 유지하면서 필터링을 하는 기능이 필요함.
객체지향 쿼리의 장점
- 데이터베이스 테이블이 아니라 엔티티를 대상으로 검색
- SQL처럼 테이블을 다루지 않고, 객체를 대상으로 쿼리를 작성할 수 있음.
- 따라서 DB에 종속적인 코드가 줄어들어 유지보수가 편리함.
- 컴파일 시점에 문법 오류를 발견 가능
- JPQL은 문자열 기반이므로 런타임에서 오류가 발생할 수 있음.
- 하지만 QueryDSL은 컴파일 시점에 문법 오류를 잡을 수 있어 안정성이 높음.
- 동적 쿼리 작성이 쉬움
- 동적 쿼리는 사용자의 입력값이나 특정 조건에 따라 SQL이 변경되는 쿼리를 의미합니다.
- 검색 기능에서 "사용자가 입력한 값에 따라 WHERE 절을 다르게 적용" 하는 경우가 해당됩니다.
- BooleanBuilder, where() 조건 조합 등을 활용하여 가독성 높은 코드를 작성할 수 있음.
쿼리 DSL 작성하기
Dependency 설정
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
Spring에서 기본지원하지 않기 때문에 Bean을 생성해서 주입해줘야한다.
- JPAQueryFactory Configuration (Q Factory)
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}
- Define a Custom Interface
public interface UserRepositoryCustom {
List<User> findUsersByName(String name);
}
- Implement the Custom Interface
- QueryDSL Q-type objects : QueryDSL-generated classes that map to your JPA entity classes.
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
import static com.example.entity.QUser.user; // Import the generated Q-class
@Repository
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
private final JPAQueryFactory queryFactory; // Injected via Spring
@Override
public List<User> findUsersByName(String name) {
return queryFactory
.selectFrom(user)
.where(user.name.eq(name))
.fetch();
}
}
- Extend Your JPA Repository
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
}
Multi-Table Joins (QueryDSL / JPQL)
- Join Syntax Rule
- pathToB must be a navigable field in A (from JPA mapping)
.join(A.pathToB, B)
join(company.cameraSet, camera) // ✅ Valid if company has cameraSet
join(camera.company, company) // ✅ Valid if camera has company
- Manual Join is Allowed Using .on()
.leftJoin(camera).on(camera.company.eq(company)) // ✅ Valid, SQL-style
요약:
순서 | 내용 | 왜 필요한가 |
1 | Q타입 사용하는 법 | 기본 쿼리 작성 |
2 | select / where / join / orderBy 흐름 | CRUD 구현 |
3 | BooleanBuilder | 동적 쿼리 |
4 | DTO 조회 (Projections) | Entity 말고 필요한 데이터만 조회 |
5 | fetchJoin | N+1 문제 해결 (성능) |
6 | 서브쿼리 | 복잡한 조건 처리 |
'개발기술 > ORM' 카테고리의 다른 글
Persistence Context, EntityManager (0) | 2025.03.18 |
---|---|
JPA 기타기능 (Pageable, auditing, data.sql 기능) (0) | 2025.02.28 |
JPA 활용의 필요성과 활용법 (0) | 2025.02.27 |
Spring JPA 트랜잭션 (0) | 2024.12.22 |
Spring JPA Entity 설정 (2) | 2024.10.28 |