-
영속성 컨텍스트와 트랜잭션 & QueryDSL이커머스 devops/국비교육 2025. 11. 16. 13:49
JPA 영속성 컨텍스트 : 엔티티를 관리하는 보이지 않는 공간
- 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경
- 애플리케이션과 데이터베이스 사이에서 엔티티를 담아두고 관리하는 논리적인 공간(메모리 캐시)
- EntityManager를 통해 이 공간에 접근하고 관리할 수 있다
엔티티의 4가지 상태 (Lifecycle)
- 엔티티는 영속성 컨텍스트와 어떤 관계를 맺고 있는지에 따라 4가지 상태를 가진다
- 비영속 (Transient) : new User()처럼 객체를 생성했지만 아직 영속성 컨텍스트에 넣지 않은 상태
- 영속 (Managed) : entityManager.persist(user) 또는 entityManager.find(..)를 통해 엔티티를 영속성 컨텍스트에 저장한 상태
- JPA가 엔티티를 직접 관리 시작
- 준영속 (Detached) : 영속성 컨텍스트가 관리하던 엔티티였지만 컨텍스트가 종료되거나 개발자가 직접 분리하여 더 이상 JPA가 관리하지 않는 상태
- 삭제 (Removed) : 데이터베이스에서 삭제하기로 결정된 상태
- 실제 DB 삭제는 트랜잭션이 커밋될 때 일어난다
영속성 컨텍스트가 제공하는 혜택
- 1차 캐시
- 영속성 컨텍스트는 내부에 <Key, Entity> 형태의 캐시(Map)을 가지고 있다
- key는 엔티티의 @Id 값
- 불필요한 조회 쿼리를 줄여 성능 향상
- em.find(User.class, 1L)을 처음 호출하면 DB에서 데이터를 조회한 후 1차 캐시에 저장하고 그 결과 반환 (sql 실행 o)
- 같은 트랜잭션 내에서 em.find(User.class, 1L)을 다시 호출하면 DB에 가지 않고 1차 캐시에서 바로 엔티티 반환 (sql 실행 x)
- 동일성 보장
- 1차 캐시 덕분에 같은 트랜잭션 내에서 같은 ID로 조회한 엔티티는 항상 동일한 메모리 주소를 가진 객체 인스턴스임이 보장된다
- 쓰기 지연 (Transactional Write-Behind)
- em.persist(user)를 호출해도 즉시 DB에서 SQL 실행되지 않는다
- JPA는 실행할 쿼리들을 쓰기 지연 sql 저장소에 쌓아둔다
- 쌓아둔 쿼리들은 트랜잭션이 커밋되는 시점에 한꺼번에 데이터베이스로 전송된다
- 여러 쿼리를 모아 한 번에 보내므로 DB와 통신 횟수를 줄여 성능을 최적화할 수 있다
- 변경 감지 (Dirty Checking)
- JPA는 1차 캐시에 엔티티를 저장할 때 그 상태 그대로의 스냅샷을 함께 저장한다
- 트랜잭션이 커밋되는 시점에 JPA는 관리 중인 모든 영속 엔티티의 현재 상태와 최초에 저장된 스냅샷을 비교한다
- 만약 변경된 점이 있다면 JPA가 자동으로 UPDATE 쿼리를 생성하여 쓰기지연 저장소에 보낸 후 DB에 반영한다
- 개발자는 UPDATE 쿼리를 직접 작성하거나 save 메서드를 호출하는 번거로움 없이 객체의 상태 변경만으로 DB 데이터를 수정할 수 있어 편리하다
Dirty Checking
- Dirty : 상태가 변한이라는 의미
- 변경 감지란 영속성 컨텍스트가 관리하는 엔티티의 변경 사항을 감지하여 UPDATE를 자동으로 생성하고 실행해 주는 JPA의 핵심 기능
@Transactional public void decreaseStock(Long productId, int quantity) { // 1. ID=1 상품 조회. (이때 엔티티와 스냅샷이 영속성 컨텍스트에 로드됨) Product product = productRepository.findById(productId).get(); // 2. 객체의 상태 변경. (메모리 상의 엔티티 객체 필드만 변경됨) // - 현재 엔티티: Product(id=1, name="노트북", stock=90) // - 스냅샷 : {name="노트북", stock=100} product.decreaseStock(quantity); } // 3. 메서드 종료 -> @Transactional에 의해 트랜잭션 커밋 시도 -> Flush 자동 호출Flsuh
- 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업
- 쓰기 지연 SQL 저장소에 쌓여있던 SQL 쿼리들을 실제 DB로 전송한다
- Flush는 쿼리를 DB로 '전송'만 헐 뿐, 트랜잭션을 커밋하지 않는다
- Flush 후에도 Rollback이 일어나면 모든 변경은 취소된다
- 트랜잭션은 아직 끝나지 않았지만, 지금까지의 변경 내용을 DB에 즉시 반영하고 싶을 때 사용한다
@Transactional public void createAndFlush(Product product) { // 1. 엔티티를 영속성 컨텍스트에 저장. (아직 INSERT 쿼리는 DB에 안 감) productRepository.save(product); // 2. flush()를 호출하여 INSERT 쿼리를 DB에 즉시 전송. entityManager.flush(); // 3. 이후 로직에서 이 product를 ID로 조회하는 다른 쿼리를 실행해도, // 이미 DB에 데이터가 있으므로 정상적으로 조회된다. }- 다른 시스템과의 연동을 위해 DB에 저장된 트리거(Trigger)를 실행시켜야 하거나, 여러 DB 작업을 섞어 쓰는 복잡한 테스트 환경에서 특정 시점의 DB 상태를 보장하고 싶을 때 유용하다
QueryDSL
- QueryDSL이란 문자열이 아닌 Java 코드를 통해 타입 안전한 쿼리를 작성할 수 있도록 도와주는 프레임워크
- JPQL을 코드로 조립하는 빌더 역할
- 해결책의 핵심: Q클래스
- 자동 생성 : @Entity로 정의한 엔티티 클래스들을 기반으로 컴파일 시점에 해당 엔티티의 메타데이터를 담은 Q클래스를 자동으로 생성한다
- 타입 안전성 확보 : Q클래스는 엔티티의 각 필드를 타입 안전한 객체로 가지고 있어 QUser.user.username과 같은 코드를 사용해 쿼리를 작성할 수 있다
QueryDSL 기본 설정
1. 의존성 추가
// build.gradle dependencies { // QueryDSL JPA 라이브러리 implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' // QueryDSL 어노테이션 프로세서 (Q클래스 생성) annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" }2. Q클래스 생성을 위한 Gradle 설정
// build.gradle 파일 하단에 추가 tasks.named('compileJava') { options.annotationProcessorPath = configurations.annotationProcessor }QueryDSL 동적 쿼리 작성법
1. JPAQueryFactroy Bean 등록
@Configuration public class QueryDslConfig { @PersistenceContext private EntityManager entityManager; @Bean public JPAQueryFactory jpaQueryFactory() { return new JPAQueryFactory(entityManager); } }2. 동적 쿼리 작성
- 검색조건 (이름, 최소/최대 가격)이 null이 아닐 때만 where 조건을 추가하는 동적 쿼리 방법
2.1. where 절에 쉼표 사용하기
- where() 메서드는 null 값을 가진 조건은 자동으로 무시한다 따라서 삼항 연산자로 각 조건을 처리할 수 있다
@Repository @RequiredArgsConstructor public class ProductQueryRepository { private final JPAQueryFactory queryFactory; public List<Product> findProducts(String name, Double minPrice, Double maxPrice) { return queryFactory .selectFrom(product) .where( // 각 조건이 null이면 무시됨 name != null ? product.name.contains(name) : null, minPrice != null ? product.price.goe(minPrice) : null, // goe: >= maxPrice != null ? product.price.loe(maxPrice) : null // loe: <= ) .fetch(); } }2.2. BooleanExpression 활용하기 (가장 권장)
public List<Product> findProducts(String name, Double minPrice, Double maxPrice) { return queryFactory .selectFrom(product) .where( nameContains(name), // 메서드 호출 priceGoe(minPrice), priceLoe(maxPrice) ) .fetch(); } // 조건들을 메서드로 분리 private BooleanExpression nameContains(String name) { return name != null ? product.name.contains(name) : null; } private BooleanExpression priceGoe(Double minPrice) { return minPrice != null ? product.price.goe(minPrice) : null; } private BooleanExpression priceLoe(Double maxPrice) { return maxPrice != null ? product.price.loe(maxPrice) : null; }- 재사용성 : nameContains 같은 조건 메서드는 다른 쿼리에서도 재사용할 수 있다
- 가독성 : where 절이 비즈니스 요구사항처럼 읽혀 깔끔하다
- 조합 가능 : 조건들을 자유롭게 조합하여 새로운 조건을 만들 수 있어 매우 유연하다
QueryDSL 심화 : Join, 페이징 처리
Join
- Join은 여러 테이블에 흩어져 있는 데이터를 하나의 쿼리로 묶어 조회하는 핵심 기능
- join(대상 Q클래스).on(조건) : 표준 Inner Join을 수행
- JPA 엔티티 간에 연간관계가 명확히 매핑되어 있다면 .on() 조건절 없이도 자동으로 연관관계의 FK 기준으로 Join 이루어진다
// 특정 카테고리의 주문 요약 조회하기 public List<PurchaseSummaryDto> findPurchaseSummaries(Long categoryId) { return queryFactory .select(new QPurchaseSummaryDto( // DTO 프로젝션 사용 purchase.id, product.name, purchaseItem.quantity )) .from(purchase) .join(purchase.purchaseItems, purchaseItem) // purchase와 purchaseItem 조인 .join(purchaseItem.product, product) // purchaseItem과 product 조인 .where(product.category.id.eq(categoryId)) .fetch(); }페이징 처리
- QueryDSL은 Spring Data의 Pageable 객체와 결합하여 페이징을 쉽게 구현할 수 있다
- fetchResults()는 사용이 중단되었다
- 콘텐츠 조회 쿼리와 전체 개수 조회 쿼리를 분리하여 2번 실행하도록 작성한다
public Page<Product> findPagedProducts(String name, Pageable pageable) { // 1. 콘텐츠 조회 쿼리 (페이징 적용) List<Product> content = queryFactory .selectFrom(product) .where( name != null ? product.name.contains(name) : null ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .orderBy(product.createdAt.desc()) .fetch(); // 2. 전체 개수 조회 쿼리 (조건은 동일하게, 페이징은 제외) Long total = queryFactory .select(product.count()) .from(product) .where( name != null ? product.name.contains(name) : null ) .fetchOne(); // 3. PageImpl 객체로 조립하여 반환 return new PageImpl<>(content, pageable, total); }QueryDSL 프로젝션으로 DTO 조회
- JPA로 데이터를 조회할 때 엔티티 전체를 조회하는 것은 비효율적일 수 있다
- 프로젝션(Projection)이란 필요한 컬럼들만 선별하여 처음부터 DTO에 담아 조회하는 기술이다
- 불필요한 데이터 조회를 막아 성능을 최적화할 수 있다
@QueryProjection 동작 원리
- DTO 클래스의 생성자에 @QueryProjection 어노테이션을 붙인다
- 프로젝트를 컴파일하면 QueryDSL의 어노테이션 프로세서가 이 어노테이션을 발견하고 해당 DTO에 대한 Q-Type 클래스를 자동으로 생성한다
- 생성된 Q-DTO는 원본DTO의 생성자를 그대로 본뜬 새로운 생성자 메서드를 가지게 된다
- 개발자는 QueryDSL 쿼리의 select()절에서 Q-DTO의 생성자를 new 키워드로 직접 호출하여 어떤 필드를 DTO에 매핑할지 지정한다
@QueryProjection 사용법
- DTO 생성 및 생성자에 @QueryProjection 추가
@Getter public class ProductDTO { private final String name; private final Double price; private final Integer stock; @QueryProjection // QueryDSL은 이 생성자를 보고 QProductDTO를 생성 public ProductDTO(String name, Double price, Integer stock) { this.name = name; this.price = price; this.stock = stock; } }- 프로젝트 빌드 및 Q-DTO 생성 확인
- build/generated/querydsl 디렉토리안에 QProductDTO.java 파일 생성
- Repository에 Q-DTO 사용
@Repository @RequiredArgsConstructor public class ProductQueryRepository { private final JPAQueryFactory queryFactory; public List<ProductDTO> findProductDTOs(Double minPrice) { return queryFactory .select(new QProductDTO( // Q-DTO의 생성자를 호출 product.name, product.price, product.stock )) .from(product) .where(product.price.goe(minPrice)) .fetch(); } }- 장점
- 타입 안전성
- 간결한 쿼리
- 단점
- DTO의 QueryDSL 의존성
728x90'이커머스 devops > 국비교육' 카테고리의 다른 글
QueryDSL 중급 문법 (0) 2025.11.19 QueryDSL 기본 문법 (0) 2025.11.19 RESTful API 설계하기 (0) 2025.11.16 공통 응답 처리와 예외 핸들링 (0) 2025.11.16 ORM 심화 및 데이터 로딩 전략 (0) 2025.11.15 - 영속성 컨텍스트 : 엔티티를 영구 저장하는 환경