이번 시간에는 저번 포스팅에서 배운 JPA를 더 자세히 배워보는 시간을 갖겠습니다!
영속성 컨텍스트는 JPA가 엔티티 객체를 관리하고 데이터베이스와 연동하는 가상의 메모리 공간입니다. ORM, 즉 객체와 RDB 데이터를 자동으로 매핑해주는 기능을 제공하는 JPA에서 아주 핵심적인 녀석이죠.
일종의 캐시 메모리라고 생각하시면 됩니다.
먼저 최초로 엔티티에 접근하면, DB에서 끌어온 엔티티를 영속성 컨텍스트에 등록해줍니다.
이후에 Spring Data JPA에서 Repository 인터페이스 를 통하여 같은 엔티티에 접근하면, DB에 쿼리를 날리는 게 아닌 영속성 컨텍스트에 있는 데이터를 조회하게 되는 거죠.
JPA에서는 테이블과 매핑해주는 엔티티 객체를 영속성 컨텍스트를 통해 애플리케이션 내부에서 오래 지속되도록 돕습니다.
JPA를 통해 데이터베이스에서 조회한 엔티티는 영속성 컨텍스트에 저장되고, 그 안에서 문맥적(contextual)으로 하나의 상태를 유지하면서, 여러 기능을 할 수 있게 하죠.
다시 말해, 애플리케이션과 데이터베이스 사이에서 데이터를 저장하는 가상의 데이터베이스 같은 역할을 합니다.
@EntityManager을 이용하여 접근합니다 ‼️
영속성 컨텍스트는 이처럼 데이터베이스 요청을 최소화해 성능을 높이고, 엔티티 변경 사항을 감지하는 등 다양한 이점을 제공합니다.
객체가 ‘영속’ 상태이면, 다음과 같이 크게 3가지 장점을 지닙니다.
- 1차 캐시
- 변경 감지(Dirty Checking)
- 지연 로딩(FetchType.LAZY)
| 기능 | 설명 | 예시 |
|---|---|---|
| 1차 캐시 | 동일한 엔티티를 조회할 때, DB 쿼리 생략 후 캐시 데이터를 반환합니다. | em.find(Member.class, 1L); 캐시에 저장된 데이터 반환 |
| 변경 감지(Dirty Checking) | 엔티티 변경 사항을 감지하고 트랜잭션 종료 시 자동으로 DB에 반영합니다. | member.setName("New Name"); → UPDATE member 쿼리 실행 |
| 지연 로딩 | 연관 데이터를 필요할 때만, 분리하여 DB가 아닌 프록시에서 데이터를 가져옵니다‼️ | FetchType.LAZY 설정 시, 연관 데이터 접근 시점에 쿼리 실행 |
즉시 로딩은 연관된 모든 데이터를 한 번에 가져오는 로딩 전략입니다.
@ManyToOne(fetch = FetchType.EAGER)지연 로딩은 실제로 데이터가 필요할 때 DB에서 가져오는 로딩 전략입니다.
@ManyToOne(fetch = FetchType.LAZY)지연 로딩은 MemberPrefer과 Member, FoodCategory이 각각 분리되어 MemberPrefer은 DB를, Member 및 FoodCategory 은 프록시를 조회합니다.
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class MemberPrefer extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
**@ManyToOne(fetch = FetchType.LAZY)**
@JoinColumn(name = "member_id")
private Member member;
**@ManyToOne(fetch = FetchType.LAZY)**
@JoinColumn(name = "category_id")
private FoodCategory foodCategory;
}
즉, MemberPrefer을 조회할 때, Member과 FoodCategory 를 굳이 조회할 필요가 없다면 분리하여 조회하는 지연 로딩 방법을 사용합시다👍
| 로딩 전략 | 특징 | 장점 | 단점 |
|---|---|---|---|
| 즉시 로딩(FetchType.EAGER) | 연관된 데이터를 한 번에 가져옴 | 연관된 데이터 접근 시 추가 쿼리가 발생하지 않음 | 불필요한 데이터 로딩, N+1 문제 발생 가능 |
| 지연 로딩(FetchType.LAZY) | 데이터 접근 시점에 DB에서 가져옴 | 초기 로딩 속도가 빠르고, 필요 시점에만 데이터를 로딩 | 데이터 접근 시마다 추가 쿼리 발생 |
즉시 로딩에서 발생할 수 있다는 N+1 문제는 뭘까요?
N+1 문제는 하나의 메인 쿼리로 N개의 데이터를 가져온 뒤, 각 데이터에 대해 N개의 추가 쿼리가 실행되는 문제입니다.
RDB와 객체 지향의 패러다임 간극에 의해 발생하는 문제인데, 1개의 쿼리를 실행한 후에, 관련된 N개의 데이터를 각각 가져오기 위해 추가적으로 N번의 불필요한 쿼리가 실행되어 성능 저하가 발생합니다.
| 방법 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Fetch Join | 연관 데이터를 한 번의 쿼리로 로딩 | N+1 문제 해결 가능, 성능 최적화 | 복잡한 쿼리에선 성능 저하 가능 |
| Batch Size | 지연 로딩 시 여러 데이터를 한 번에 로딩 | 추가 쿼리 수를 줄여 N+1 문제 해결 | 설정에 따라 불필요한 데이터 로딩 가능 |
| @EntityGraph | 특정 필드만 즉시 로딩으로 변경 | 간결한 설정, 필요 시점에만 즉시 로딩 가능 | JPQL 사용 시 비효율적 |
| QueryDSL | 동적 쿼리를 통해 필요한 데이터만 로딩 | 타입 안전성 보장, 동적 쿼리 작성 가능 | 설정이 다소 복잡 |
JPQL(Java Persistence Query Language)은 JPA 엔티티 객체를 대상으로 작성하는 쿼리 언어입니다.
JPQL을 사용하는 방법에는 두 가지가 있습니다:
[1] EntityManager 인터페이스
[2] Spring Data JPA의 Repository 인터페이스
EntityManager란?
// EntityManager 생성 및 트랜잭션 시작
EntityManager em = entityManagerFactory.createEntityManager();
em.getTransaction().begin();
// JPQL 쿼리 작성
String jpql = "SELECT m FROM Member m WHERE m.name = :name";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
query.setParameter("name", "Alice");
// 결과 리스트로 받기
List<Member> members = query.getResultList();
for (Member member : members) {
System.out.println("Member Name: " + member.getName());
}
// 트랜잭션 종료
em.getTransaction().commit();
em.close();
createQuery(String jpql, Class<T> resultClass): JPQL 쿼리를 작성하고 실행합니다.setParameter(String name, Object value): JPQL 쿼리에서 사용할 파라미터를 설정합니다.getResultList(): 결과를 리스트로 반환합니다.getSingleResult(): 단일 결과를 반환합니다.JPQL을 사용하면 업데이트나 삭제 작업도 가능합니다.
em.getTransaction().begin();
// JPQL 업데이트 쿼리
String jpql = "UPDATE Member m SET m.age = :age WHERE m.name = :name";
Query query = em.createQuery(jpql);
query.setParameter("age", 30);
query.setParameter("name", "Alice");
int rowsUpdated = query.executeUpdate(); // 업데이트된 행 수 반환
System.out.println("Rows updated: " + rowsUpdated);
em.getTransaction().commit();
Spring Data JPA는 Repository 인터페이스를 통해 JPQL 쿼리를 간편하게 작성할 수 있습니다.
이 방법은 더 직관적이고 간결하며, Spring 환경과 잘 통합됩니다.
Spring Data JPA는 메서드 이름을 기반으로 JPQL 쿼리를 자동 생성합니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByNameAndStatus(String name, MemberStatus status);
}
// 호출
List<Member> activeMembers = memberRepository.findByNameAndStatus("Alice", MemberStatus.ACTIVE);
Spring 내부 동작:
위 메서드 호출은 다음 JPQL 쿼리로 변환됩니다.
SELECT m FROM Member m WHERE m.name = 'Alice' AND m.status = 'ACTIVE';
더 복잡한 쿼리가 필요할 경우, @Query 어노테이션을 사용해 직접 JPQL을 작성할 수 있습니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("SELECT m FROM Member m WHERE m.name = :name AND m.status = :status")
List<Member> findByNameAndStatus(@Param("name") String name, @Param("status") MemberStatus status);
}
장점
| 구분 | JPQL | SQL |
|---|---|---|
| 대상 | 엔티티 객체를 대상으로 작성 | 테이블을 대상으로 작성 |
| 언어 | 객체 지향적 문법 반영 | 관계형 데이터베이스 중심의 문법 |
| 예시 | SELECT m FROM Member m | SELECT * FROM member |
| 사용 환경 | JPA 환경에서 사용 | JPA 외에 모든 SQL 환경에서 사용 가능 |
엔티티 필드 이름을 사용
m.name은 엔티티의 name 필드를 참조.Lazy Loading과의 연관성
동적 쿼리 작성
JPQL 사용으로는 작성하기 너무 복잡한 동적 쿼리를 위해서는 QueryDSL를 사용하는 게 효과적인 방법입니다.
QueryDSL은 코드 기반의 쿼리 빌더 라이브러리로, 타입 안전성과 동적 쿼리 작성에 강점을 가진 도구입니다.
QueryDSL을 사용하려면 Q 클래스를 자동 생성해야 합니다. 이를 위해 build.gradle 파일에 다음 설정을 추가합니다.
plugins {
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
}
sourceSets {
main.java.srcDir 'src/main/generated'
}
Q 클래스는 각 엔티티를 기준으로 자동 생성됩니다.
src/main/generated 폴더에 생성된 QMember 또는 QStore 등을 사용하여 QueryDSL로 쿼리를 작성합니다.
QueryDSL은 JPAQueryFactory를 사용해 쿼리를 작성합니다.
JPAQueryFactory를 Bean으로 등록하려면 다음과 같은 설정이 필요합니다.
@Configuration
@RequiredArgsConstructor
public class QueryDSLConfig {
private final EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
BooleanBuilder를 사용해 조건을 동적으로 추가할 수 있습니다.
private List<Store> dynamicQueryWithBooleanBuilder(String name, Float score) {
BooleanBuilder predicate = new BooleanBuilder();
if (name != null) {
predicate.and(store.name.eq(name));
}
if (score != null) {
predicate.and(store.score.goe(score));
}
return jpaQueryFactory
.selectFrom(store)
.where(predicate)
.fetch();
}
.and(): 조건 추가.or(): 조건 선택적으로 추가Custom Interface 생성
public interface StoreRepositoryCustom {
List<Store> dynamicQueryWithBooleanBuilder(String name, Float score);
}
Custom 구현체 작성
@Repository
@RequiredArgsConstructor
public class StoreRepositoryImpl implements StoreRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
private final QStore store = QStore.store;
@Override
public List<Store> dynamicQueryWithBooleanBuilder(String name, Float score) {
BooleanBuilder predicate = new BooleanBuilder();
if (name != null) predicate.and(store.name.eq(name));
if (score != null) predicate.and(store.score.goe(score));
return jpaQueryFactory
.selectFrom(store)
.where(predicate)
.fetch();
}
}
Repository 연결
public interface StoreRepository extends JpaRepository<Store, Long>, StoreRepositoryCustom {
}
Service Layer에서 호출
@Service
@RequiredArgsConstructor
public class StoreQueryServiceImpl implements StoreQueryService {
private final StoreRepository storeRepository;
public List<Store> findStoresByNameAndScore(String name, Float score) {
return storeRepository.dynamicQueryWithBooleanBuilder(name, score);
}
}
| 장점 | 단점 |
|---|---|
| 타입 안전성 보장 | 초기 설정 및 학습 곡선 필요 |
| 동적 쿼리 작성 간편 | QueryDSL 라이브러리 의존성 추가 필요 |
| 메서드 체이닝으로 직관적 쿼리 작성 가능 | 설정 과정이 다소 복잡할 수 있음 |
QueryDSL 설정
build.gradle에 종속성 추가Q 클래스 자동 생성 설정JPAQueryFactory Bean 등록
EntityManager와 연동Repository 구성
Custom Interface와 Custom 구현체 작성Service Layer에서 활용