Spring 05: JPA 완전 정복
Spring Data JPA, Entity 설계, 연관관계, N+1 해결, QueryDSL, 성능 최적화까지
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 사용 가이드
| 상황 | 권장 방법 |
|---|---|
| 단순 CRUD | JpaRepository 메서드 |
| 단순 조건 조회 | 쿼리 메서드 (findByXxx) |
| 복잡한 정적 쿼리 | @Query + JPQL |
| 동적 쿼리 | QueryDSL + BooleanExpression |
| 복잡한 DTO 조회 | QueryDSL + @QueryProjection |
| 통계/집계 쿼리 | QueryDSL 또는 Native Query |
성능 최적화 체크리스트
모든 @ManyToOne, @OneToOne에 fetch = LAZY 설정
로그에서 반복 쿼리 확인, Fetch Join/Batch Size 적용
조회 메서드에 @Transactional(readOnly = true)
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