ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 영속성 컨텍스트와 트랜잭션 & 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
Designed by Tistory.