Theory
중급 2-3시간 이론 + 실습

Spring 05: JPA 완전 정복

Spring Data JPA, Entity 설계, 연관관계, N+1 해결, QueryDSL, 성능 최적화까지

JPAQueryDSLN+1EntityFetch Join성능 최적화
📚 목차

1. Spring Data JPA 기초

2. Entity 설계와 연관관계

3. N+1 문제와 해결

4. QueryDSL

5. 성능 최적화

6. 실전 패턴

7. 정리 및 베스트 프랙티스

🎯 학습 목표
  • • Spring Data JPA의 핵심 기능을 이해하고 활용한다
  • • Entity 설계와 연관관계 매핑을 올바르게 구현한다
  • • N+1 문제를 이해하고 다양한 해결 방법을 적용한다
  • • QueryDSL로 타입 안전한 동적 쿼리를 작성한다

Spring Data JPA 기초

학습 목표

  • • Spring Data JPA의 핵심 개념 이해
  • • Repository 인터페이스 활용법
  • • 쿼리 메서드와 JPQL 작성

JPA vs Spring Data JPA

순수 JPA

@Repository
public class ProductRepository {
    @PersistenceContext
    private EntityManager em;
    
    public void save(Product product) {
        em.persist(product);
    }
    
    public Product findById(Long id) {
        return em.find(Product.class, id);
    }
    
    public List<Product> findByName(String name) {
        return em.createQuery(
            "SELECT p FROM Product p WHERE p.name = :name", 
            Product.class)
            .setParameter("name", name)
            .getResultList();
    }
}

Spring Data JPA

public interface ProductRepository 
    extends JpaRepository<Product, Long> {
    
    // 메서드 이름으로 쿼리 자동 생성
    List<Product> findByName(String name);
    
    List<Product> findByPriceGreaterThan(int price);
    
    Optional<Product> findByNameAndCategory(
        String name, String category);
}

// 사용
@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;
    
    public Product save(Product product) {
        return productRepository.save(product);
    }
}

Repository 계층 구조

Repository (마커 인터페이스)
    └── CrudRepository<T, ID>
        - save(), findById(), findAll(), delete(), count()
        └── PagingAndSortingRepository<T, ID>
            - findAll(Sort), findAll(Pageable)
            └── JpaRepository<T, ID>
                - flush(), saveAndFlush()
                - deleteInBatch(), findAll(Example)

JpaRepository 주요 메서드

save(entity) - 저장/수정
findById(id) - ID로 조회
findAll() - 전체 조회
delete(entity) - 삭제
count() - 개수
existsById(id) - 존재 여부

쿼리 메서드

메서드 이름 규칙을 따르면 Spring Data JPA가 자동으로 쿼리를 생성합니다.

쿼리 메서드 키워드

// 조건 키워드
findByName(String name)                    // WHERE name = ?
findByNameAndPrice(String name, int price) // WHERE name = ? AND price = ?
findByNameOrCategory(String n, String c)   // WHERE name = ? OR category = ?

// 비교 키워드
findByPriceGreaterThan(int price)          // WHERE price > ?
findByPriceLessThanEqual(int price)        // WHERE price <= ?
findByPriceBetween(int min, int max)       // WHERE price BETWEEN ? AND ?

// 문자열 키워드
findByNameLike(String pattern)             // WHERE name LIKE ?
findByNameContaining(String keyword)       // WHERE name LIKE %?%
findByNameStartingWith(String prefix)      // WHERE name LIKE ?%

// NULL 체크
findByDescriptionIsNull()                  // WHERE description IS NULL
findByDescriptionIsNotNull()               // WHERE description IS NOT NULL

// 정렬과 제한
findByNameOrderByPriceDesc(String name)    // ORDER BY price DESC
findTop3ByOrderByPriceDesc()               // LIMIT 3 ORDER BY price DESC
findFirstByOrderByCreatedAtDesc()          // 가장 최근 1개

@Query 어노테이션

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // JPQL 쿼리
    @Query("SELECT p FROM Product p WHERE p.price > :price")
    List<Product> findExpensiveProducts(@Param("price") int price);
    
    // Native SQL
    @Query(value = "SELECT * FROM products WHERE category = ?1", 
           nativeQuery = true)
    List<Product> findByCategory(String category);
    
    // DTO Projection
    @Query("SELECT new com.example.dto.ProductDto(p.name, p.price) " +
           "FROM Product p WHERE p.category = :category")
    List<ProductDto> findProductDtos(@Param("category") String category);
    
    // 수정 쿼리
    @Modifying
    @Query("UPDATE Product p SET p.price = :price WHERE p.id = :id")
    int updatePrice(@Param("id") Long id, @Param("price") int price);
}

페이징과 정렬

// Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findByCategory(String category, Pageable pageable);
}

// Service
@Service
public class ProductService {
    
    public Page<Product> getProducts(String category, int page, int size) {
        Pageable pageable = PageRequest.of(page, size, 
            Sort.by("createdAt").descending());
        return productRepository.findByCategory(category, pageable);
    }
}

// Controller
@GetMapping("/products")
public Page<Product> list(
    @RequestParam String category,
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size) {
    
    return productService.getProducts(category, page, size);
}

// Page 응답 구조
{
  "content": [...],           // 실제 데이터
  "totalElements": 100,       // 전체 개수
  "totalPages": 10,           // 전체 페이지 수
  "number": 0,                // 현재 페이지 (0부터)
  "size": 10,                 // 페이지 크기
  "first": true,              // 첫 페이지 여부
  "last": false               // 마지막 페이지 여부
}

Entity 설계와 연관관계

Entity 기본 매핑

@Entity
@Table(name = "products")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 100)
    private String name;
    
    @Column(nullable = false)
    private int price;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ProductStatus status;
    
    @Lob
    private String description;
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // 생성 메서드
    public static Product create(String name, int price) {
        Product product = new Product();
        product.name = name;
        product.price = price;
        product.status = ProductStatus.ACTIVE;
        return product;
    }
    
    // 비즈니스 메서드
    public void changePrice(int newPrice) {
        if (newPrice < 0) {
            throw new IllegalArgumentException("가격은 0 이상이어야 합니다");
        }
        this.price = newPrice;
    }
}

연관관계 매핑

@ManyToOne (N:1)

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    // 다대일 - 외래키 주인
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    
    // 연관관계 편의 메서드
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }
}

@OneToMany (1:N)

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    // 일대다 - 읽기 전용
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
    
    // 주문 추가 편의 메서드
    public void addOrder(Order order) {
        orders.add(order);
        order.setMember(this);
    }
}

@OneToOne (1:1)

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    // 일대일 - 외래키 주인
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id")
    private Profile profile;
}

@Entity
public class Profile {
    @Id @GeneratedValue
    private Long id;
    
    // 일대일 양방향
    @OneToOne(mappedBy = "profile")
    private Member member;
}

@ManyToMany (N:M)

// 다대다는 중간 테이블 Entity로 풀어서 사용
@Entity
public class ProductCategory {
    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;
    
    private int displayOrder; // 추가 필드 가능
}

Cascade와 orphanRemoval

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    
    // CASCADE: 부모 저장/삭제 시 자식도 함께
    // orphanRemoval: 컬렉션에서 제거되면 DB에서도 삭제
    @OneToMany(mappedBy = "order", 
               cascade = CascadeType.ALL, 
               orphanRemoval = true)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    // 주문 항목 추가
    public void addOrderItem(OrderItem item) {
        orderItems.add(item);
        item.setOrder(this);
    }
    
    // 주문 항목 제거 - orphanRemoval로 DB에서도 삭제됨
    public void removeOrderItem(OrderItem item) {
        orderItems.remove(item);
        item.setOrder(null);
    }
}

// 사용 예시
Order order = new Order();
order.addOrderItem(new OrderItem(product1, 2));
order.addOrderItem(new OrderItem(product2, 1));
orderRepository.save(order); // Order + OrderItems 모두 저장

order.removeOrderItem(item); // DB에서도 삭제됨

⚠️ 연관관계 설계 원칙

  • 지연 로딩(LAZY) 기본: 모든 연관관계는 LAZY로 설정
  • 양방향은 필요할 때만: 단방향으로 충분하면 양방향 X
  • 외래키 주인: 외래키가 있는 쪽이 연관관계 주인
  • @ManyToMany 지양: 중간 테이블 Entity로 풀어서 사용

N+1 문제와 해결

N+1 문제란?

1개의 쿼리로 N개의 엔티티를 조회한 후, 연관된 엔티티를 조회하기 위해 N개의 추가 쿼리가 발생하는 문제입니다.

// 문제 상황
List<Order> orders = orderRepository.findAll(); // 1번 쿼리
for (Order order : orders) {
    order.getMember().getName(); // N번 쿼리 발생!
}

// 실행되는 쿼리
SELECT * FROM orders;                    // 1번
SELECT * FROM members WHERE id = 1;      // +1번
SELECT * FROM members WHERE id = 2;      // +1번
SELECT * FROM members WHERE id = 3;      // +1번
// ... 총 N+1번의 쿼리 실행

해결 방법 1: Fetch Join

public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // JPQL Fetch Join
    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();
    
    // 컬렉션 Fetch Join
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.member " +
           "JOIN FETCH o.orderItems")
    List<Order> findAllWithMemberAndItems();
}

// 실행되는 쿼리 - 단 1번!
SELECT o.*, m.*, oi.* 
FROM orders o
JOIN members m ON o.member_id = m.id
JOIN order_items oi ON o.id = oi.order_id

주의: 컬렉션 Fetch Join은 페이징 불가! 데이터를 모두 메모리에 로드 후 페이징 처리됨.

해결 방법 2: @EntityGraph

public interface OrderRepository extends JpaRepository<Order, Long> {
    
    // attributePaths로 함께 조회할 연관관계 지정
    @EntityGraph(attributePaths = {"member"})
    List<Order> findAll();
    
    // 여러 연관관계 지정
    @EntityGraph(attributePaths = {"member", "orderItems"})
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithGraph();
    
    // 조건과 함께 사용
    @EntityGraph(attributePaths = {"member"})
    List<Order> findByStatus(OrderStatus status);
}

// Entity에 정의하는 방식
@Entity
@NamedEntityGraph(
    name = "Order.withMember",
    attributeNodes = @NamedAttributeNode("member")
)
public class Order {
    // ...
}

// 사용
@EntityGraph("Order.withMember")
List<Order> findByMemberId(Long memberId);

해결 방법 3: Batch Size

# application.yml - 글로벌 설정
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

# 또는 Entity에 개별 설정
@Entity
public class Order {
    
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems;
}

// 동작 방식
// N+1 대신 IN 쿼리로 묶어서 조회
SELECT * FROM orders;
SELECT * FROM members WHERE id IN (1, 2, 3, ... 100);
SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ... 100);

장점: 페이징과 함께 사용 가능! 컬렉션 조회 시 가장 실용적인 해결책.

해결 방법 비교

방법장점단점사용 시점
Fetch Join쿼리 1번컬렉션 페이징 불가ToOne 관계
@EntityGraph선언적, 간편복잡한 조건 어려움단순 조회
Batch Size페이징 가능쿼리 N/size번컬렉션 + 페이징

QueryDSL

QueryDSL이란?

타입 안전한 쿼리를 Java 코드로 작성할 수 있게 해주는 프레임워크입니다. 컴파일 시점에 오류를 잡을 수 있고, IDE 자동완성을 활용할 수 있습니다.

설정 (Gradle)

// build.gradle
dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

// Q클래스 생성 위치 설정
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
    main.java.srcDirs += querydslDir
}

tasks.withType(JavaCompile) {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

기본 사용법

@Repository
@RequiredArgsConstructor
public class ProductQueryRepository {
    
    private final JPAQueryFactory queryFactory;
    
    // 기본 조회
    public List<Product> findByName(String name) {
        QProduct product = QProduct.product;
        
        return queryFactory
            .selectFrom(product)
            .where(product.name.eq(name))
            .fetch();
    }
    
    // 여러 조건
    public List<Product> findByConditions(String name, Integer minPrice) {
        QProduct product = QProduct.product;
        
        return queryFactory
            .selectFrom(product)
            .where(
                product.name.contains(name),
                product.price.goe(minPrice),
                product.status.eq(ProductStatus.ACTIVE)
            )
            .orderBy(product.price.desc())
            .fetch();
    }
    
    // 페이징
    public Page<Product> findWithPaging(String category, Pageable pageable) {
        QProduct product = QProduct.product;
        
        List<Product> content = queryFactory
            .selectFrom(product)
            .where(product.category.eq(category))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
        
        Long total = queryFactory
            .select(product.count())
            .from(product)
            .where(product.category.eq(category))
            .fetchOne();
        
        return new PageImpl<>(content, pageable, total);
    }
}

동적 쿼리 - BooleanBuilder

public List<Product> searchProducts(ProductSearchCondition condition) {
    QProduct product = QProduct.product;
    BooleanBuilder builder = new BooleanBuilder();
    
    // 조건이 있을 때만 추가
    if (hasText(condition.getName())) {
        builder.and(product.name.contains(condition.getName()));
    }
    if (condition.getMinPrice() != null) {
        builder.and(product.price.goe(condition.getMinPrice()));
    }
    if (condition.getMaxPrice() != null) {
        builder.and(product.price.loe(condition.getMaxPrice()));
    }
    if (condition.getCategory() != null) {
        builder.and(product.category.eq(condition.getCategory()));
    }
    
    return queryFactory
        .selectFrom(product)
        .where(builder)
        .fetch();
}

// 또는 BooleanExpression 메서드 분리 (권장)
public List<Product> searchProducts(ProductSearchCondition cond) {
    return queryFactory
        .selectFrom(product)
        .where(
            nameContains(cond.getName()),
            priceBetween(cond.getMinPrice(), cond.getMaxPrice()),
            categoryEq(cond.getCategory())
        )
        .fetch();
}

private BooleanExpression nameContains(String name) {
    return hasText(name) ? product.name.contains(name) : null;
}

private BooleanExpression priceBetween(Integer min, Integer max) {
    if (min != null && max != null) {
        return product.price.between(min, max);
    }
    if (min != null) return product.price.goe(min);
    if (max != null) return product.price.loe(max);
    return null;
}

private BooleanExpression categoryEq(String category) {
    return hasText(category) ? product.category.eq(category) : null;
}

DTO Projection

// DTO
@Data
public class ProductDto {
    private Long id;
    private String name;
    private int price;
    private String categoryName;
}

// Projections.constructor 사용
public List<ProductDto> findProductDtos() {
    return queryFactory
        .select(Projections.constructor(ProductDto.class,
            product.id,
            product.name,
            product.price,
            product.category.name
        ))
        .from(product)
        .fetch();
}

// @QueryProjection 사용 (권장)
@Data
public class ProductDto {
    private Long id;
    private String name;
    private int price;
    
    @QueryProjection
    public ProductDto(Long id, String name, int price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }
}

// 사용
public List<ProductDto> findProductDtos() {
    return queryFactory
        .select(new QProductDto(product.id, product.name, product.price))
        .from(product)
        .fetch();
}

Join과 서브쿼리

// Join
public List<Order> findOrdersWithMember() {
    QOrder order = QOrder.order;
    QMember member = QMember.member;
    
    return queryFactory
        .selectFrom(order)
        .join(order.member, member).fetchJoin()
        .where(member.status.eq(MemberStatus.ACTIVE))
        .fetch();
}

// 서브쿼리
public List<Product> findExpensiveProducts() {
    QProduct product = QProduct.product;
    QProduct subProduct = new QProduct("subProduct");
    
    return queryFactory
        .selectFrom(product)
        .where(product.price.gt(
            JPAExpressions
                .select(subProduct.price.avg())
                .from(subProduct)
        ))
        .fetch();
}

성능 최적화

DTO 직접 조회

Entity 대신 필요한 필드만 DTO로 직접 조회하면 성능이 향상됩니다.

// JPQL DTO 조회
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
       "o.id, m.name, o.totalPrice, o.status) " +
       "FROM Order o JOIN o.member m " +
       "WHERE o.status = :status")
List<OrderSummaryDto> findOrderSummaries(@Param("status") OrderStatus status);

// QueryDSL DTO 조회
public List<OrderSummaryDto> findOrderSummaries(OrderStatus status) {
    return queryFactory
        .select(new QOrderSummaryDto(
            order.id,
            order.member.name,
            order.totalPrice,
            order.status
        ))
        .from(order)
        .join(order.member, member)
        .where(order.status.eq(status))
        .fetch();
}

// Interface Projection
public interface OrderSummary {
    Long getId();
    String getMemberName();
    int getTotalPrice();
}

List<OrderSummary> findByStatus(OrderStatus status);

읽기 전용 쿼리 최적화

// 읽기 전용 트랜잭션 - 스냅샷 저장 안함
@Transactional(readOnly = true)
public List<Product> findAllProducts() {
    return productRepository.findAll();
}

// QueryHint 사용
@QueryHints(value = @QueryHint(
    name = "org.hibernate.readOnly", value = "true"))
List<Product> findByCategory(String category);

// 플러시 모드 설정
@QueryHints(value = {
    @QueryHint(name = "org.hibernate.readOnly", value = "true"),
    @QueryHint(name = "org.hibernate.flushMode", value = "MANUAL")
})
List<Product> findAllReadOnly();

효과: 변경 감지(Dirty Checking) 비활성화로 메모리 절약 및 성능 향상

벌크 연산

// 벌크 UPDATE
@Modifying(clearAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * 1.1 " +
       "WHERE p.category = :category")
int bulkPriceIncrease(@Param("category") String category);

// 벌크 DELETE
@Modifying(clearAutomatically = true)
@Query("DELETE FROM Product p WHERE p.status = :status")
int bulkDelete(@Param("status") ProductStatus status);

// QueryDSL 벌크 연산
public long bulkUpdatePrice(String category, double rate) {
    return queryFactory
        .update(product)
        .set(product.price, product.price.multiply(rate))
        .where(product.category.eq(category))
        .execute();
}

주의: 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 실행됩니다.clearAutomatically = true로 영속성 컨텍스트를 초기화하세요.

인덱스 활용

@Entity
@Table(name = "products", indexes = {
    @Index(name = "idx_product_category", columnList = "category"),
    @Index(name = "idx_product_name", columnList = "name"),
    @Index(name = "idx_product_category_status", 
           columnList = "category, status")
})
public class Product {
    @Id @GeneratedValue
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private String category;
    
    @Enumerated(EnumType.STRING)
    private ProductStatus status;
}

// 복합 인덱스 활용 쿼리
// idx_product_category_status 인덱스 사용
List<Product> findByCategoryAndStatus(String category, ProductStatus status);

성능 최적화 체크리스트

✅ DO

  • • 모든 연관관계 LAZY 로딩
  • • Fetch Join / Batch Size 활용
  • • 필요한 필드만 DTO 조회
  • • 읽기 전용 트랜잭션 사용
  • • 적절한 인덱스 설정
  • • 페이징 처리

❌ DON'T

  • • EAGER 로딩 사용
  • • 루프 안에서 연관 엔티티 접근
  • • 불필요한 양방향 관계
  • • Entity 전체 조회 후 필터링
  • • 대량 데이터 한번에 조회
  • • 인덱스 없는 조건 검색

실전 패턴

Repository 계층 구조

// 1. Spring Data JPA Repository (기본 CRUD)
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(String category);
    Optional<Product> findByName(String name);
}

// 2. Custom Repository 인터페이스 (복잡한 쿼리)
public interface ProductRepositoryCustom {
    List<ProductDto> searchProducts(ProductSearchCondition condition);
    Page<ProductDto> searchProductsWithPaging(ProductSearchCondition cond, Pageable pageable);
}

// 3. Custom Repository 구현체
@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {
    
    private final JPAQueryFactory queryFactory;
    
    @Override
    public List<ProductDto> searchProducts(ProductSearchCondition condition) {
        return queryFactory
            .select(new QProductDto(product.id, product.name, product.price))
            .from(product)
            .where(
                nameContains(condition.getName()),
                categoryEq(condition.getCategory()),
                priceBetween(condition.getMinPrice(), condition.getMaxPrice())
            )
            .fetch();
    }
    
    // BooleanExpression 메서드들...
}

// 4. 통합 Repository (JpaRepository + Custom)
public interface ProductRepository extends 
    JpaRepository<Product, Long>, 
    ProductRepositoryCustom {
    
    List<Product> findByCategory(String category);
}

Auditing 설정

// 1. 설정 활성화
@Configuration
@EnableJpaAuditing
public class JpaConfig {
    
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName);
    }
}

// 2. BaseEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
    
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String updatedBy;
}

// 3. Entity에서 상속
@Entity
public class Product extends BaseEntity {
    @Id @GeneratedValue
    private Long id;
    private String name;
    // ...
}

Soft Delete 패턴

@Entity
@Where(clause = "deleted = false")  // 조회 시 자동 필터
@SQLDelete(sql = "UPDATE products SET deleted = true WHERE id = ?")
public class Product extends BaseEntity {
    
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    private boolean deleted = false;
    
    // 실제 삭제가 필요한 경우
    public void hardDelete(EntityManager em) {
        em.createNativeQuery("DELETE FROM products WHERE id = :id")
            .setParameter("id", this.id)
            .executeUpdate();
    }
}

// 삭제된 데이터도 조회해야 할 때
@Query("SELECT p FROM Product p WHERE p.id = :id")
@Where(clause = "")  // 필터 무시
Optional<Product> findByIdIncludeDeleted(@Param("id") Long id);

낙관적 락 (Optimistic Lock)

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    private int stock;
    
    @Version
    private Long version;  // 버전 관리
    
    public void decreaseStock(int quantity) {
        if (this.stock < quantity) {
            throw new IllegalStateException("재고 부족");
        }
        this.stock -= quantity;
    }
}

// Service
@Service
@RequiredArgsConstructor
public class ProductService {
    
    private final ProductRepository productRepository;
    
    @Transactional
    @Retryable(value = OptimisticLockingFailureException.class, 
               maxAttempts = 3)
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow();
        product.decreaseStock(quantity);
        // 동시 수정 시 OptimisticLockingFailureException 발생
    }
}

비관적 락 (Pessimistic Lock)

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // 쓰기 락 - SELECT ... FOR UPDATE
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);
    
    // 읽기 락 - SELECT ... FOR SHARE
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<Product> findWithReadLockById(Long id);
}

// Service
@Transactional
public void decreaseStockWithLock(Long productId, int quantity) {
    // 락 획득 - 다른 트랜잭션은 대기
    Product product = productRepository.findByIdWithLock(productId)
        .orElseThrow();
    product.decreaseStock(quantity);
}

// 락 타임아웃 설정
@QueryHints({
    @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findByIdWithLockTimeout(Long id);

정리 및 베스트 프랙티스

JPA 핵심 요약

Spring Data JPA

  • • JpaRepository 상속으로 기본 CRUD
  • • 쿼리 메서드로 간단한 조회
  • • @Query로 JPQL/Native 쿼리
  • • Pageable로 페이징 처리

Entity 설계

  • • 기본 생성자 protected
  • • 정적 팩토리 메서드 사용
  • • 비즈니스 로직은 Entity에
  • • 연관관계 편의 메서드

연관관계

  • • 모든 연관관계 LAZY 로딩
  • • 양방향은 필요할 때만
  • • 외래키 있는 쪽이 주인
  • • @ManyToMany 지양

N+1 해결

  • • ToOne: Fetch Join
  • • ToMany: Batch Size
  • • 단순 조회: @EntityGraph
  • • 복잡한 조회: DTO 직접

QueryDSL 사용 가이드

상황권장 방법
단순 CRUDJpaRepository 메서드
단순 조건 조회쿼리 메서드 (findByXxx)
복잡한 정적 쿼리@Query + JPQL
동적 쿼리QueryDSL + BooleanExpression
복잡한 DTO 조회QueryDSL + @QueryProjection
통계/집계 쿼리QueryDSL 또는 Native Query

성능 최적화 체크리스트

LAZY 로딩 확인

모든 @ManyToOne, @OneToOne에 fetch = LAZY 설정

N+1 쿼리 확인

로그에서 반복 쿼리 확인, Fetch Join/Batch Size 적용

읽기 전용 트랜잭션

조회 메서드에 @Transactional(readOnly = true)

DTO 직접 조회

API 응답용 데이터는 Entity 대신 DTO로 조회

인덱스 설정

자주 검색하는 컬럼에 @Index 설정

페이징 처리

대량 데이터 조회 시 반드시 페이징

⚠️ 흔한 실수

  • EAGER 로딩: 불필요한 데이터까지 항상 조회
  • 양방향 무분별 사용: 순환 참조, JSON 직렬화 문제
  • Entity 직접 반환: 불필요한 데이터 노출, 지연 로딩 문제
  • 벌크 연산 후 조회: 영속성 컨텍스트와 DB 불일치
  • 트랜잭션 밖 지연 로딩: LazyInitializationException

권장 프로젝트 구조

src/main/java/com/example/
├── domain/
│   ├── product/
│   │   ├── Product.java              # Entity
│   │   ├── ProductStatus.java        # Enum
│   │   ├── ProductRepository.java    # JpaRepository + Custom
│   │   └── ProductRepositoryImpl.java # QueryDSL 구현
│   └── order/
│       ├── Order.java
│       ├── OrderItem.java
│       └── OrderRepository.java
├── dto/
│   ├── ProductDto.java
│   ├── ProductSearchCondition.java
│   └── OrderSummaryDto.java
├── service/
│   ├── ProductService.java
│   └── OrderService.java
└── config/
    ├── JpaConfig.java                # Auditing, QueryDSL 설정
    └── QuerydslConfig.java           # JPAQueryFactory Bean