Session 1JPA Workshop

ORM과 JPA 기초

JDBC의 한계를 이해하고, JPA의 핵심 개념인 영속성 컨텍스트와 엔티티 생명주기를 학습합니다.

학습 목표

  • JDBC의 한계와 ORM이 등장한 배경을 설명할 수 있다
  • 객체-관계 불일치(Impedance Mismatch) 문제를 이해한다
  • JPA, Hibernate, Spring Data JPA의 관계를 구분할 수 있다
  • Spring Data JPA의 동적 프록시 동작 원리를 이해한다
  • 영속성 컨텍스트의 개념과 이점을 설명할 수 있다
  • 엔티티의 4가지 생명주기 상태를 이해하고 구분할 수 있다

1. JDBC의 한계와 ORM의 등장

"객체지향 프로그래밍과 관계형 데이터베이스 사이에는 근본적인 불일치가 존재한다. 이 간극을 메우는 것이 ORM의 핵심 목표이다...."

— Martin Fowler, Patterns of Enterprise Application Architecture

1.1 PHP에서 Spring Boot로: 왜 변화가 필요한가?

기존 PHP 기반 이커머스 시스템에서 Spring Boot로 전환하는 것은 단순한 언어 변경이 아닙니다. 이는 데이터 접근 방식의 근본적인 패러다임 전환을 의미합니다. PHP의 PDO나 mysqli를 사용하던 방식에서 Java의 JPA/Hibernate로 전환하면서, 우리는 더 객체지향적이고 유지보수가 용이한 코드를 작성할 수 있게 됩니다.

전통적인 데이터 접근 방식 비교

PHP PDO 방식

<?php
// 상품 조회
$stmt = $pdo->prepare(
    "SELECT * FROM products WHERE id = ?"
);
$stmt->execute([$productId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

// 수동으로 객체에 매핑
$product = new Product();
$product->id = $row['id'];
$product->name = $row['name'];
$product->price = $row['price'];
$product->categoryId = $row['category_id'];
$product->createdAt = new DateTime($row['created_at']);

// 카테고리 정보가 필요하면 추가 쿼리
$stmt2 = $pdo->prepare(
    "SELECT * FROM categories WHERE id = ?"
);
$stmt2->execute([$product->categoryId]);
$category = $stmt2->fetch(PDO::FETCH_ASSOC);
$product->categoryName = $category['name'];

Java JDBC 방식

// 상품 조회
String sql = "SELECT * FROM products WHERE id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, productId);
ResultSet rs = stmt.executeQuery();

if (rs.next()) {
    Product product = new Product();
    product.setId(rs.getLong("id"));
    product.setName(rs.getString("name"));
    product.setPrice(rs.getBigDecimal("price"));
    product.setCategoryId(rs.getLong("category_id"));
    product.setCreatedAt(rs.getTimestamp("created_at")
        .toLocalDateTime());
}

// 자원 해제 필수!
rs.close();
stmt.close();
conn.close();

1.2 전통적 방식의 문제점

JDBC/PDO 방식의 근본적 문제

1. 반복적인 보일러플레이트

Connection 획득, Statement 생성, ResultSet 매핑, 자원 해제 코드가 모든 쿼리마다 반복됩니다.

실제 비즈니스 로직보다 인프라 코드가 더 많아지는 현상 발생

2. SQL 의존성

비즈니스 로직이 SQL 문자열에 묻혀버립니다. 데이터베이스 변경 시 전체 코드 수정이 필요합니다.

MySQL에서 PostgreSQL로 변경 시 수백 개의 쿼리 수정 필요

3. 수동 매핑의 위험성

ResultSet에서 객체로의 변환을 개발자가 직접 작성합니다. 컬럼명 오타 시 런타임 에러가 발생합니다.

rs.getString("naem") - 컴파일 시점에 발견 불가

4. 연관관계 처리의 복잡성

Product와 Category의 관계를 표현하려면 추가 쿼리와 복잡한 조인 로직이 필요합니다.

N+1 문제를 개발자가 직접 인지하고 해결해야 함

1.3 실제 코드로 보는 문제점

이커머스 주문 조회 - JDBC 방식
public Order findOrderWithItems(Long orderId) {
    Connection conn = null;
    PreparedStatement orderStmt = null;
    PreparedStatement itemStmt = null;
    ResultSet orderRs = null;
    ResultSet itemRs = null;
    
    try {
        conn = dataSource.getConnection();
        
        // 1. 주문 기본 정보 조회
        String orderSql = "SELECT o.*, c.name as customer_name, c.email " +
                         "FROM orders o " +
                         "JOIN customers c ON o.customer_id = c.id " +
                         "WHERE o.id = ?";
        orderStmt = conn.prepareStatement(orderSql);
        orderStmt.setLong(1, orderId);
        orderRs = orderStmt.executeQuery();
        
        Order order = null;
        if (orderRs.next()) {
            order = new Order();
            order.setId(orderRs.getLong("id"));
            order.setStatus(OrderStatus.valueOf(orderRs.getString("status")));
            order.setOrderDate(orderRs.getTimestamp("order_date").toLocalDateTime());
            order.setTotalAmount(orderRs.getBigDecimal("total_amount"));
            
            // Customer 정보 매핑
            Customer customer = new Customer();
            customer.setId(orderRs.getLong("customer_id"));
            customer.setName(orderRs.getString("customer_name"));
            customer.setEmail(orderRs.getString("email"));
            order.setCustomer(customer);
        }
        
        if (order == null) {
            return null;
        }
        
        // 2. 주문 상품 목록 조회 (추가 쿼리!)
        String itemSql = "SELECT oi.*, p.name as product_name " +
                        "FROM order_items oi " +
                        "JOIN products p ON oi.product_id = p.id " +
                        "WHERE oi.order_id = ?";
        itemStmt = conn.prepareStatement(itemSql);
        itemStmt.setLong(1, orderId);
        itemRs = itemStmt.executeQuery();
        
        List<OrderItem> items = new ArrayList<>();
        while (itemRs.next()) {
            OrderItem item = new OrderItem();
            item.setId(itemRs.getLong("id"));
            item.setQuantity(itemRs.getInt("quantity"));
            item.setPrice(itemRs.getBigDecimal("price"));
            
            Product product = new Product();
            product.setId(itemRs.getLong("product_id"));
            product.setName(itemRs.getString("product_name"));
            item.setProduct(product);
            
            items.add(item);
        }
        order.setOrderItems(items);
        
        return order;
        
    } catch (SQLException e) {
        throw new RuntimeException("주문 조회 실패", e);
    } finally {
        // 자원 해제 - 누락 시 커넥션 풀 고갈!
        closeQuietly(itemRs);
        closeQuietly(itemStmt);
        closeQuietly(orderRs);
        closeQuietly(orderStmt);
        closeQuietly(conn);
    }
}

이 코드의 문제점:

  • • 약 80줄의 코드 중 실제 비즈니스 로직은 10줄 미만
  • • SQL 문자열이 Java 코드에 하드코딩
  • • 자원 해제를 잊으면 심각한 메모리 누수 발생
  • • 테스트하기 매우 어려움 (DB 의존성)
  • • 컬럼 추가/변경 시 여러 곳 수정 필요

1.4 ORM이 해결하는 문제

같은 기능을 JPA로 구현하면
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer " +
           "JOIN FETCH o.orderItems oi " +
           "JOIN FETCH oi.product " +
           "WHERE o.id = :id")
    Optional<Order> findWithDetailsById(@Param("id") Long id);
}

// Service
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    
    @Transactional(readOnly = true)
    public Order findOrderWithItems(Long orderId) {
        return orderRepository.findWithDetailsById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다"));
    }
}

개선된 점

  • • 코드량 90% 감소
  • • 자원 관리 자동화
  • • 타입 안전성 보장
  • • 테스트 용이성 향상
  • • 유지보수성 대폭 개선

JPA가 처리하는 것

  • • Connection 관리
  • • SQL 생성
  • • ResultSet → 객체 매핑
  • • 연관관계 로딩
  • • 트랜잭션 관리

2. 객체-관계 불일치 문제 (Object-Relational Impedance Mismatch)

"소프트웨어 구축에서 가장 어려운 부분은 무엇을 만들지 결정하는 것이다. 기술적 구현보다 요구사항을 이해하고 올바른 모델을 찾는 것이 더 어렵다."

— Fred Brooks, No Silver Bullet (1986)

2.1 패러다임의 근본적 차이

객체지향 언어(Java)와 관계형 데이터베이스(MySQL, PostgreSQL)는 데이터를 표현하는 방식이 근본적으로 다릅니다. 이를 "객체-관계 불일치" 또는"패러다임 불일치(Paradigm Mismatch)"라고 합니다.

두 세계의 철학적 차이

객체지향 세계

  • 캡슐화: 데이터와 행위를 하나로 묶음
  • 상속: 계층 구조로 코드 재사용
  • 다형성: 같은 인터페이스, 다른 구현
  • 참조: 객체 간 직접 참조로 탐색
  • 식별: == (동일성)과 equals() (동등성)

관계형 세계

  • 정규화: 데이터 중복 제거
  • 테이블: 평면적 구조, 상속 없음
  • SQL: 선언적 데이터 조작
  • 외래키: 테이블 간 관계 표현
  • 식별: Primary Key로만 식별

2.2 다섯 가지 주요 불일치

1. 상속 (Inheritance) 불일치

객체 세계

abstract class Product {
    Long id;
    String name;
    BigDecimal price;
}

class PhysicalProduct extends Product {
    Double weight;
    String dimensions;
}

class DigitalProduct extends Product {
    String downloadUrl;
    Long fileSize;
}

상속으로 공통 속성 재사용, 다형성 활용 가능

관계형 세계

-- 방법 1: 단일 테이블 (모든 컬럼 포함)
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    dtype VARCHAR(31),  -- 구분자
    name VARCHAR(200),
    price DECIMAL(10,2),
    weight DOUBLE,      -- Physical만
    dimensions VARCHAR(50),
    download_url VARCHAR(500), -- Digital만
    file_size BIGINT
);

상속 개념 없음. 전략 선택 필요 (단일/조인/구현클래스별)

2. 연관관계 (Association) 불일치

객체 세계

class Order {
    Customer customer;  // 객체 참조
    
    // 방향성 존재
    Customer getCustomer() {
        return this.customer;
    }
}

// 탐색
order.getCustomer().getName();

참조로 연결, 방향성 존재 (단방향/양방향)

관계형 세계

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT,  -- 외래키
    FOREIGN KEY (customer_id) 
        REFERENCES customers(id)
);

-- 방향성 없음, JOIN으로 양쪽 탐색
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id;

외래키로 연결, 방향성 없음 (항상 양방향)

3. 데이터 타입 (Data Type) 불일치

객체 세계

class Product {
    ProductStatus status;  // Enum
    Money price;           // 값 객체
    List<Tag> tags;        // 컬렉션
    Map<String, String> attributes;
}

enum ProductStatus {
    DRAFT, ACTIVE, DISCONTINUED
}

Enum, 사용자 정의 타입, 컬렉션 등 풍부한 타입

관계형 세계

CREATE TABLE products (
    status VARCHAR(20),  -- 문자열로 저장
    price_amount DECIMAL(10,2),
    price_currency VARCHAR(3),
    -- 컬렉션은 별도 테이블 필요
);

CREATE TABLE product_tags (
    product_id BIGINT,
    tag VARCHAR(50)
);

VARCHAR, INT, DATE 등 제한된 기본 타입

4. 식별자 (Identity) 불일치

객체 세계

Product p1 = findById(1L);
Product p2 = findById(1L);

// 동일성 (Identity) - 같은 메모리 주소?
p1 == p2  // false (다른 인스턴스)

// 동등성 (Equality) - 같은 값?
p1.equals(p2)  // true (같은 ID)

// 두 개념이 분리됨!

== (동일성)과 equals() (동등성) 구분

관계형 세계

-- Primary Key로만 식별
SELECT * FROM products WHERE id = 1;

-- 같은 PK = 같은 레코드
-- 동일성과 동등성 구분 없음

-- 복합키도 가능
PRIMARY KEY (order_id, product_id)

Primary Key로만 식별, 단순한 개념

5. 데이터 탐색 (Navigation) 불일치

객체 세계

// 그래프 탐색 - 자유롭게 이동
order.getCustomer()
     .getAddress()
     .getCity();

// 필요할 때 연관 객체에 접근
// 지연 로딩 가능

객체 그래프를 자유롭게 탐색

관계형 세계

-- 처음부터 필요한 데이터를 JOIN
SELECT o.*, c.*, a.city
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN addresses a ON c.address_id = a.id
WHERE o.id = ?;

-- 나중에 추가 데이터 필요하면 쿼리 수정

처음부터 JOIN으로 한번에 조회해야 효율적

2.3 이커머스 도메인에서의 불일치 예시

주문(Order) 도메인 모델링

이상적인 객체 모델

class Order {
    Long id;
    Customer customer;        // 객체 참조
    List<OrderItem> items;    // 컬렉션
    OrderStatus status;       // Enum
    Money totalAmount;        // 값 객체
    ShippingAddress address;  // 임베디드
    
    // 비즈니스 로직
    void addItem(Product p, int qty) {
        items.add(new OrderItem(p, qty));
        recalculateTotal();
    }
    
    void cancel() {
        if (status != PENDING) {
            throw new IllegalStateException();
        }
        status = CANCELLED;
        items.forEach(OrderItem::restoreStock);
    }
    
    Money getTotalAmount() {
        return items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

관계형 테이블 구조

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    status VARCHAR(20) NOT NULL,
    total_amount DECIMAL(12,2),
    total_currency VARCHAR(3),
    -- 임베디드 주소
    ship_city VARCHAR(100),
    ship_street VARCHAR(200),
    ship_zipcode VARCHAR(20),
    created_at DATETIME,
    FOREIGN KEY (customer_id) 
        REFERENCES customers(id)
);

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id) 
        REFERENCES orders(id),
    FOREIGN KEY (product_id) 
        REFERENCES products(id)
);

ORM(JPA)이 해결하는 것:

  • • Customer 참조 ↔ customer_id 외래키 자동 변환
  • • List<OrderItem> ↔ order_items 테이블 자동 매핑
  • • OrderStatus Enum ↔ VARCHAR 자동 변환
  • • Money 값 객체 ↔ amount + currency 컬럼 매핑
  • • ShippingAddress ↔ ship_* 컬럼들 임베디드 매핑

3. ORM이란 무엇인가

"ORM은 객체지향 프로그래밍 언어와 관계형 데이터베이스 사이의 데이터를 변환하는 프로그래밍 기법이다. 이를 통해 개발자는 SQL 대신 객체를 사용하여 데이터베이스를 조작할 수 있다."

— Wikipedia, Object-Relational Mapping

3.1 ORM의 정의와 역할

ORM(Object-Relational Mapping)은 객체와 관계형 데이터베이스 테이블 사이의 매핑을 자동화하는 기술입니다. 개발자가 SQL 대신 객체를 통해 데이터베이스를 조작할 수 있게 해주며, 앞서 살펴본 객체-관계 불일치 문제를 해결합니다.

ORM의 핵심 역할

Java 객체

Product, Order

Customer

ORM

자동 변환

DB 테이블

products, orders

customers

객체 → 테이블

객체의 필드를 테이블 컬럼으로 변환하여 INSERT/UPDATE SQL 생성

테이블 → 객체

SELECT 결과(ResultSet)를 자동으로 객체 인스턴스로 변환

연관관계 처리

객체 참조와 외래키 관계를 자동으로 매핑하고 로딩

3.2 ORM 사용 전후 비교

CRUD 작업 비교

CREATE (저장)

Before: JDBC

String sql = "INSERT INTO products " +
    "(name, price, status, created_at) " +
    "VALUES (?, ?, ?, ?)";
PreparedStatement stmt = conn.prepareStatement(
    sql, Statement.RETURN_GENERATED_KEYS);
stmt.setString(1, product.getName());
stmt.setBigDecimal(2, product.getPrice());
stmt.setString(3, product.getStatus().name());
stmt.setTimestamp(4, Timestamp.valueOf(
    LocalDateTime.now()));
stmt.executeUpdate();

ResultSet rs = stmt.getGeneratedKeys();
if (rs.next()) {
    product.setId(rs.getLong(1));
}

After: JPA

// 단 한 줄!
entityManager.persist(product);

// 또는 Spring Data JPA
productRepository.save(product);

// SQL 자동 생성, ID 자동 설정
// created_at도 @PrePersist로 자동

READ (조회)

Before: JDBC

String sql = "SELECT * FROM products WHERE id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, productId);
ResultSet rs = stmt.executeQuery();

Product product = null;
if (rs.next()) {
    product = new Product();
    product.setId(rs.getLong("id"));
    product.setName(rs.getString("name"));
    product.setPrice(rs.getBigDecimal("price"));
    product.setStatus(ProductStatus.valueOf(
        rs.getString("status")));
    // ... 모든 필드 수동 매핑
}

After: JPA

// 기본 조회
Product product = entityManager.find(
    Product.class, productId);

// Spring Data JPA
Optional<Product> product = 
    productRepository.findById(productId);

// 자동 매핑, 타입 안전

UPDATE (수정)

Before: JDBC

String sql = "UPDATE products SET " +
    "name = ?, price = ?, status = ?, " +
    "updated_at = ? WHERE id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, product.getName());
stmt.setBigDecimal(2, product.getPrice());
stmt.setString(3, product.getStatus().name());
stmt.setTimestamp(4, Timestamp.valueOf(
    LocalDateTime.now()));
stmt.setLong(5, product.getId());
stmt.executeUpdate();

After: JPA

// 변경 감지 (Dirty Checking)
Product product = entityManager.find(
    Product.class, productId);
product.setPrice(newPrice);  // 값만 변경

// 트랜잭션 커밋 시 자동 UPDATE!
// em.update() 같은 메서드 없음

DELETE (삭제)

Before: JDBC

String sql = "DELETE FROM products WHERE id = ?";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setLong(1, productId);
stmt.executeUpdate();

After: JPA

Product product = entityManager.find(
    Product.class, productId);
entityManager.remove(product);

// Spring Data JPA
productRepository.deleteById(productId);

3.3 ORM의 장단점

장점

  • 생산성 향상: 반복적인 CRUD 코드 90% 이상 감소
  • 유지보수성: 스키마 변경 시 Entity만 수정하면 됨
  • 객체지향 설계: 도메인 모델에 집중할 수 있음
  • DB 독립성: 방언(Dialect)으로 DB 변경 용이
  • 캐싱: 1차 캐시, 2차 캐시로 성능 최적화
  • 지연 로딩: 필요한 시점에 데이터 로딩

주의점 (단점이 아닌 주의점)

  • 학습 곡선: 영속성 컨텍스트, 지연 로딩 등 개념 이해 필요
  • 복잡한 쿼리: 통계, 리포트 등은 Native SQL 필요할 수 있음
  • N+1 문제: 잘못 사용하면 성능 이슈 발생 (해결 방법 있음)
  • 내부 동작 이해: 블랙박스로 사용하면 문제 해결 어려움
  • 대량 데이터: 배치 처리 시 별도 최적화 필요

ORM을 사용해야 하는 경우

적합한 경우

  • • 도메인 모델 중심의 애플리케이션
  • • CRUD 위주의 비즈니스 로직
  • • 객체지향 설계를 중시하는 프로젝트
  • • 팀의 생산성이 중요한 경우
  • • DB 변경 가능성이 있는 경우

신중해야 하는 경우

  • • 복잡한 통계/분석 쿼리가 많은 경우
  • • 초당 수만 건 이상의 대용량 처리
  • • 레거시 DB 스키마가 매우 복잡한 경우
  • • SQL 튜닝이 핵심인 프로젝트

4. JPA, Hibernate, Spring Data JPA의 관계

JPA를 처음 접하면 JPA, Hibernate, Spring Data JPA라는 용어가 혼란스러울 수 있습니다. 이 세 가지의 관계를 명확히 이해하는 것이 JPA 학습의 첫 걸음입니다.

계층 구조 이해하기

Spring Data JPA

최상위 추상화

Spring 프레임워크에서 제공하는 JPA 편의 기능. Repository 인터페이스만 정의하면 구현체를 자동 생성합니다. 메서드 이름으로 쿼리를 자동 생성하는 강력한 기능을 제공합니다.

// 인터페이스만 정의하면 구현체 자동 생성!
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByNameContaining(String keyword);
    List<Product> findByPriceGreaterThan(BigDecimal price);
    Optional<Product> findBySku(String sku);
}
↓ 내부적으로 사용

Hibernate

JPA 구현체

JPA 스펙을 실제로 구현한 라이브러리. 가장 널리 사용되는 JPA 구현체입니다. EclipseLink, OpenJPA 등 다른 구현체도 있지만, Hibernate가 사실상 표준입니다.

// Hibernate 고유 API (JPA 표준 아님)
Session session = sessionFactory.openSession();
session.createQuery("FROM Product p WHERE p.name LIKE :name", Product.class)
       .setParameter("name", "%" + keyword + "%")
       .list();

// Hibernate 고유 기능: @BatchSize, @Fetch, @Formula 등
↓ 구현

JPA (Java Persistence API)

표준 스펙 (인터페이스)

자바 ORM 기술의 표준 명세(인터페이스). 구현체가 아닌 스펙입니다. javax.persistence (Java EE) 또는 jakarta.persistence (Jakarta EE) 패키지에 정의되어 있습니다.

// JPA 표준 API
EntityManager em = entityManagerFactory.createEntityManager();
em.persist(product);
em.find(Product.class, productId);
em.createQuery("SELECT p FROM Product p", Product.class).getResultList();

// JPA 표준 어노테이션
@Entity, @Table, @Id, @Column, @ManyToOne, @OneToMany ...
↓ 내부적으로 사용

JDBC (Java Database Connectivity)

기반 기술

모든 Java DB 접근의 기반. Hibernate도 내부적으로 JDBC를 사용하여 실제 데이터베이스와 통신합니다. 최종적으로 SQL이 실행되는 계층입니다.

4.1 각 계층의 역할 비교

구분JPAHibernateSpring Data JPA
성격표준 스펙 (인터페이스)JPA 구현체Spring 모듈
패키지jakarta.persistenceorg.hibernateorg.springframework.data.jpa
핵심 클래스EntityManagerSessionJpaRepository
쿼리 방식JPQL, Criteria APIHQL, Criteria (deprecated)메서드 이름, @Query
사용 시점표준 API 필요 시고급 기능 필요 시일반적인 개발

4.2 실제 코드에서의 사용

같은 기능, 다른 API

1. JPA 표준 API

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

2. Hibernate 고유 API

@Repository
public class ProductRepositoryImpl {
    @PersistenceContext
    private EntityManager em;
    
    public List<Product> findByNameWithHibernate(String name) {
        // Hibernate Session 직접 사용
        Session session = em.unwrap(Session.class);
        
        return session.createQuery(
            "FROM Product p WHERE p.name LIKE :name", Product.class)
            .setParameter("name", "%" + name + "%")
            .setHint(QueryHints.HINT_CACHEABLE, true)  // Hibernate 고유
            .list();
    }
}

3. Spring Data JPA (권장)

// 인터페이스만 정의! 구현체 자동 생성
public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // 메서드 이름으로 쿼리 자동 생성
    List<Product> findByNameContaining(String name);
    
    // @Query로 직접 JPQL 작성
    @Query("SELECT p FROM Product p WHERE p.price > :price")
    List<Product> findExpensiveProducts(@Param("price") BigDecimal price);
    
    // 기본 CRUD는 이미 제공됨
    // save(), findById(), findAll(), delete(), count() ...
}

왜 이 구조를 이해해야 하는가?

  • 문제 해결: 에러 발생 시 어느 계층의 문제인지 파악 가능
    예: LazyInitializationException은 JPA 영속성 컨텍스트 문제
  • 단계적 접근: Spring Data JPA로 해결 안 되면 JPA API 직접 사용, 그래도 안 되면 Hibernate 고유 기능 또는 Native SQL 사용
  • 학습 방향: JPA 표준을 먼저 이해하고, Spring Data JPA 편의 기능 활용
  • 이식성: JPA 표준 API를 사용하면 Hibernate → EclipseLink 변경 가능

4.3 Spring Boot에서의 설정

build.gradle 의존성
dependencies {
    // Spring Data JPA (JPA + Hibernate 포함)
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // 데이터베이스 드라이버
    runtimeOnly 'com.mysql:mysql-connector-j'      // MySQL
    runtimeOnly 'org.postgresql:postgresql'         // PostgreSQL
    runtimeOnly 'com.h2database:h2'                 // H2 (개발/테스트용)
}

// spring-boot-starter-data-jpa가 포함하는 것:
// - spring-data-jpa (Spring Data JPA)
// - hibernate-core (Hibernate)
// - jakarta.persistence-api (JPA 스펙)
// - spring-jdbc, spring-tx (트랜잭션)
// - HikariCP (커넥션 풀)

4. Spring Data JPA는 어떻게 동작하는가?

"인터페이스만 정의했는데 어떻게 실행이 되는 거죠?"

Spring Data JPA를 처음 접하면 가장 먼저 드는 의문입니다. 구현체 없이 인터페이스만으로 DB 작업이 가능한 마법의 비밀을 파헤쳐 봅니다.

프록시(Proxy)란?

프록시(Proxy)는 "대리인"이라는 뜻입니다. 실제 객체를 직접 호출하는 대신, 중간에서 대신 처리해주는 객체를 말합니다.

🏠 일상 속 프록시

부동산 중개인 = 프록시
집주인(실제 객체)을 직접 만나지 않고,
중개인(프록시)이 대신 계약을 처리합니다.

💻 프로그래밍 속 프록시

ProductRepository 인터페이스를 호출하면,
실제로는 Spring이 만든 프록시 객체가
요청을 받아서 처리합니다.

// 프록시 패턴의 기본 구조
interface Repository {
    void save(Object entity);
}

// 실제 구현체 (우리가 직접 만들지 않음)
class RealRepository implements Repository {
    public void save(Object entity) {
        // 실제 DB 저장 로직
    }
}

// 프록시 (Spring이 자동 생성)
class RepositoryProxy implements Repository {
    private RealRepository target;  // 실제 객체 참조
    
    public void save(Object entity) {
        System.out.println("트랜잭션 시작");  // 부가 기능
        target.save(entity);                  // 실제 객체에 위임
        System.out.println("트랜잭션 커밋");  // 부가 기능
    }
}

// 사용하는 입장에서는 차이를 모름
Repository repo = new RepositoryProxy();
repo.save(product);  // 프록시가 대신 처리

프록시의 장점: 실제 객체의 코드를 수정하지 않고도 트랜잭션, 로깅, 보안 검사 등 부가 기능을 추가할 수 있습니다. Spring AOP, @Transactional 모두 프록시 기반으로 동작합니다.

4.1 핵심 원리: 동적 프록시 (Dynamic Proxy)

인터페이스만 있어도 되는 이유

Spring Data JPA는 런타임에 프록시 객체를 동적으로 생성합니다. 개발자가 정의한 인터페이스를 구현하는 클래스를 Spring이 자동으로 만들어주는 것입니다.

// 개발자가 작성하는 코드
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByName(String name);
}

// Spring이 런타임에 생성하는 프록시 (개념적 표현)
public class ProductRepositoryProxy implements ProductRepository {
    
    private final EntityManager em;
    private final JpaRepositoryImplementation<Product, Long> target;
    
    // JpaRepository 기본 메서드 위임
    @Override
    public Product save(Product entity) {
        return target.save(entity);
    }
    
    @Override
    public Optional<Product> findById(Long id) {
        return target.findById(id);
    }
    
    // 쿼리 메서드는 메서드 이름을 파싱하여 JPQL 생성
    @Override
    public List<Product> findByName(String name) {
        return em.createQuery(
            "SELECT p FROM Product p WHERE p.name = :name", Product.class)
            .setParameter("name", name)
            .getResultList();
    }
}

4.2 내부 동작 흐름

Spring 컨테이너 시작 시 일어나는 일
1

Repository 인터페이스 스캔

@EnableJpaRepositories 또는 Spring Boot 자동 설정이 JpaRepository를 상속한 인터페이스들을 찾습니다.

2

JpaRepositoryFactoryBean 생성

각 Repository 인터페이스마다 FactoryBean이 생성됩니다. 이 FactoryBean이 실제 프록시 객체를 만드는 역할을 합니다.

3

프록시 객체 생성

Java의 Proxy.newProxyInstance() 또는 CGLIB을 사용하여 인터페이스를 구현하는 프록시 객체를 동적으로 생성합니다.

4

Spring Bean 등록

생성된 프록시 객체가 Spring 컨테이너에 Bean으로 등록됩니다. 이제 @Autowired로 주입받아 사용할 수 있습니다.

메서드 호출 시 일어나는 일
// 호출 코드
List<Product> products = productRepository.findByName("노트북");

// 내부 동작 (SimpleJpaRepository + QueryExecutorMethodInterceptor)

1. 프록시의 invoke() 메서드 호출
   ↓
2. 메서드 종류 판별
   - 기본 메서드 (save, findById 등) → SimpleJpaRepository로 위임
   - 쿼리 메서드 (findByName 등) → 쿼리 생성 로직 실행
   ↓
3. 쿼리 메서드인 경우: PartTree 파싱
   "findByName" 분석:
   - find: 조회 (SELECT)
   - By: 조건 시작
   - Name: 필드명 (Product.name)
   ↓
4. JPQL 생성
   "SELECT p FROM Product p WHERE p.name = ?1"
   ↓
5. EntityManager로 쿼리 실행
   em.createQuery(jpql).setParameter(1, "노트북").getResultList()
   ↓
6. 결과 반환

4.3 핵심 클래스 살펴보기

핵심

SimpleJpaRepository

JpaRepository의 기본 구현체. save(), findById(), findAll(), delete() 등 기본 CRUD 메서드가 여기에 구현되어 있습니다.

// SimpleJpaRepository 내부 (간략화)
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepository<T, ID> {
    
    private final EntityManager em;
    private final JpaEntityInformation<T, ?> entityInformation;
    
    @Override
    @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }
    
    @Override
    public Optional<T> findById(ID id) {
        return Optional.ofNullable(em.find(getDomainClass(), id));
    }
}
쿼리 생성

PartTree

메서드 이름을 파싱하여 쿼리 구조를 분석하는 클래스. findByNameAndPriceGreaterThan 같은 메서드명을 트리 구조로 분해합니다.

// "findByNameContainingAndPriceGreaterThan" 파싱 결과
PartTree {
    subject: "find"
    predicate: [
        Part { property: "name", type: CONTAINING },
        Part { property: "price", type: GREATER_THAN }
    ]
}
프록시

JpaRepositoryFactoryBean

Repository 프록시를 생성하는 FactoryBean. Spring 컨테이너가 이 Bean을 통해 실제 Repository 프록시를 얻습니다.

4.4 직접 확인해보기

프록시 객체 확인
@SpringBootTest
class RepositoryProxyTest {
    
    @Autowired
    private ProductRepository productRepository;
    
    @Test
    void 프록시_객체_확인() {
        // 실제 클래스 출력
        System.out.println("Class: " + productRepository.getClass());
        // 출력: Class: class jdk.proxy3.$Proxy123
        // 또는: Class: class com.sun.proxy.$Proxy123
        
        // 인터페이스 확인
        System.out.println("Interfaces: " + 
            Arrays.toString(productRepository.getClass().getInterfaces()));
        // 출력: [interface com.example.repository.ProductRepository, ...]
        
        // 실제 타겟 확인 (AOP 프록시인 경우)
        if (AopUtils.isAopProxy(productRepository)) {
            Object target = AopProxyUtils.getSingletonTarget(productRepository);
            System.out.println("Target: " + target.getClass());
            // 출력: Target: class org.springframework.data.jpa.repository.support.SimpleJpaRepository
        }
    }
}

4.5 쿼리 메서드 키워드 → JPQL 변환

메서드 이름생성되는 JPQL
findByName(String name)SELECT p FROM Product p WHERE p.name = ?1
findByNameAndPrice(String, BigDecimal)... WHERE p.name = ?1 AND p.price = ?2
findByNameContaining(String)... WHERE p.name LIKE %?1%
findByPriceGreaterThan(BigDecimal)... WHERE p.price > ?1
findByStatusOrderByCreatedAtDesc(Status)... WHERE p.status = ?1 ORDER BY p.createdAt DESC
countByStatus(Status)SELECT COUNT(p) FROM Product p WHERE p.status = ?1

정리: Spring Data JPA의 마법

  • 1. 동적 프록시: 런타임에 인터페이스 구현체를 자동 생성
  • 2. SimpleJpaRepository: 기본 CRUD 메서드의 실제 구현체
  • 3. PartTree: 메서드 이름을 파싱하여 쿼리 구조 분석
  • 4. 쿼리 생성: 분석된 구조를 바탕으로 JPQL 자동 생성
  • 5. EntityManager: 최종적으로 JPA 표준 API로 쿼리 실행

결국 Spring Data JPA도 내부적으로는 EntityManager를 사용합니다. 개발자의 반복 작업을 줄여주는 추상화 계층일 뿐입니다.

5. 영속성 컨텍스트 (Persistence Context)

"영속성 컨텍스트는 엔티티를 영구 저장하는 환경이다. 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 데이터베이스 역할을 한다."

— 김영한, 자바 ORM 표준 JPA 프로그래밍

5.1 영속성 컨텍스트란?

영속성 컨텍스트(Persistence Context)는 JPA의 가장 핵심적인 개념입니다. Entity를 관리하는 논리적인 영역으로, EntityManager를 통해 접근합니다. "엔티티를 영구 저장하는 환경"이라고 이해하면 됩니다.

영속성 컨텍스트의 구조

영속성 컨텍스트 (Persistence Context)

1차 캐시 (First Level Cache)

@IdEntity
1LProduct(id=1, name="노트북")
2LProduct(id=2, name="키보드")
3LProduct(id=3, name="마우스")

쓰기 지연 SQL 저장소

INSERT INTO products (name, price) VALUES ('노트북', 1500000)

UPDATE products SET price = 1400000 WHERE id = 2

DELETE FROM products WHERE id = 5

flush() 또는 트랜잭션 커밋 시

Database

실제 SQL 실행

5.2 영속성 컨텍스트의 이점

1. 1차 캐시 (First Level Cache)

같은 트랜잭션 내에서 동일한 엔티티 조회 시 DB 접근 없이 캐시에서 반환합니다.

// 첫 번째 조회 - DB에서 가져와서 1차 캐시에 저장
Product p1 = em.find(Product.class, 1L);  // SELECT 쿼리 실행

// 두 번째 조회 - 1차 캐시에서 반환 (DB 접근 X)
Product p2 = em.find(Product.class, 1L);  // 쿼리 실행 안함!

System.out.println(p1 == p2);  // true (같은 인스턴스)
이점: 같은 엔티티를 여러 번 조회해도 DB 쿼리는 1번만 실행

2. 동일성(Identity) 보장

같은 트랜잭션 내에서 같은 엔티티는 항상 같은 인스턴스입니다. == 비교가 가능합니다.

Product p1 = em.find(Product.class, 1L);
Product p2 = em.find(Product.class, 1L);

// 동일성 비교 가능 (같은 메모리 주소)
if (p1 == p2) {
    System.out.println("같은 인스턴스!");  // 출력됨
}

// JDBC에서는 불가능했던 것
// JDBC: p1 == p2 → false (매번 새 객체 생성)

3. 트랜잭션을 지원하는 쓰기 지연 (Write-Behind)

persist() 호출 시 바로 INSERT 하지 않고, 트랜잭션 커밋 시점에 모아서 실행합니다.

EntityTransaction tx = em.getTransaction();
tx.begin();

em.persist(product1);  // INSERT SQL을 쓰기 지연 저장소에 저장
em.persist(product2);  // INSERT SQL을 쓰기 지연 저장소에 저장
em.persist(product3);  // INSERT SQL을 쓰기 지연 저장소에 저장
// 여기까지 DB에 쿼리 실행 안됨!

tx.commit();  // 이 시점에 INSERT 3개 한번에 실행 (flush)
// INSERT INTO products ... (product1)
// INSERT INTO products ... (product2)
// INSERT INTO products ... (product3)
이점: 네트워크 왕복 최소화, 배치 INSERT 가능, 트랜잭션 롤백 시 쿼리 실행 안함

4. 변경 감지 (Dirty Checking)

엔티티 수정 시 별도의 update() 호출 없이 자동으로 UPDATE 쿼리가 생성됩니다. JPA에서 가장 마법 같은 기능입니다.

EntityTransaction tx = em.getTransaction();
tx.begin();

// 1. 엔티티 조회 (영속 상태)
Product product = em.find(Product.class, 1L);

// 2. 값 변경 (setter 호출)
product.setPrice(new BigDecimal("15000"));
product.setName("무선 키보드 (신형)");

// em.update(product);  ← 이런 메서드 없음! 호출 불필요!

tx.commit();  // 변경 감지 → 자동 UPDATE 쿼리 생성
// UPDATE products SET price = 15000, name = '무선 키보드 (신형)' WHERE id = 1
동작 원리: 1차 캐시에 저장할 때 스냅샷도 함께 저장. 커밋 시점에 스냅샷과 현재 엔티티를 비교하여 변경된 필드만 UPDATE

5. 지연 로딩 (Lazy Loading)

연관된 엔티티를 실제 사용하는 시점에 조회합니다. (다음 세션에서 자세히 다룸)

// Order 조회 시 Customer는 아직 로딩 안됨
Order order = em.find(Order.class, 1L);  // SELECT * FROM orders WHERE id = 1

// Customer를 실제 사용할 때 로딩
String customerName = order.getCustomer().getName();  
// 이 시점에 SELECT * FROM customers WHERE id = ?

5.3 flush()와 commit()의 차이

flush()

영속성 컨텍스트의 변경 내용을 DB에 동기화 (SQL 실행)

  • • 쓰기 지연 SQL 저장소의 쿼리를 DB에 전송
  • • 영속성 컨텍스트는 유지됨
  • • 트랜잭션은 아직 커밋 안됨
  • • 롤백 가능

commit()

트랜잭션을 커밋 (내부적으로 flush 호출 후 커밋)

  • • flush() 자동 호출
  • • DB 트랜잭션 커밋
  • • 변경 내용 확정
  • • 롤백 불가
// flush가 호출되는 시점
// 1. em.flush() 직접 호출
// 2. 트랜잭션 커밋 시 자동 호출
// 3. JPQL 쿼리 실행 전 자동 호출 (쿼리 결과에 반영되어야 하므로)

em.persist(product);  // 아직 INSERT 안됨

// JPQL 실행 전 자동 flush
List<Product> products = em.createQuery("SELECT p FROM Product p", Product.class)
    .getResultList();  // flush 후 SELECT 실행

Spring에서의 영속성 컨텍스트

Spring에서는 @Transactional이 붙은 메서드 범위가 영속성 컨텍스트의 생존 범위입니다.

@Service
public class ProductService {
    @Transactional  // 영속성 컨텍스트 시작
    public void updateProduct(Long id, String newName) {
        Product product = productRepository.findById(id).orElseThrow();
        product.setName(newName);  // 변경 감지
        // 메서드 종료 시 트랜잭션 커밋 → flush → 영속성 컨텍스트 종료
    }
}

6. 엔티티 생명주기 (Entity Lifecycle)

JPA에서 엔티티는 4가지 상태를 가집니다. 각 상태에서 어떤 동작이 가능한지 이해하는 것이 JPA를 올바르게 사용하는 핵심입니다.

엔티티 생명주기 다이어그램
비영속 (New/Transient)

new Product()

persist()
영속 (Managed)

영속성 컨텍스트가 관리

detach() / clear() / close()
remove()
준영속 (Detached)

영속성 컨텍스트에서 분리

삭제 (Removed)

삭제 예정

merge()
commit/flush

Database

6.1 각 상태 상세 설명

비영속 (New/Transient)

순수한 자바 객체 상태. 영속성 컨텍스트와 전혀 관계없는 상태입니다.

// 비영속 상태 - 그냥 자바 객체
Product product = new Product();
product.setName("노트북");
product.setPrice(new BigDecimal("1500000"));

// 아직 영속성 컨텍스트와 관계 없음
// DB에도 저장 안됨
// 변경 감지 안됨
영속 (Managed)가장 중요한 상태

영속성 컨텍스트가 관리하는 상태. 1차 캐시에 저장되고, 변경 감지가 동작합니다.

// 영속 상태가 되는 경우

// 1. persist()로 저장
em.persist(product);  // 비영속 → 영속

// 2. find()로 조회
Product p = em.find(Product.class, 1L);  // DB에서 조회 → 영속

// 3. JPQL로 조회
List<Product> products = em.createQuery("SELECT p FROM Product p", Product.class)
    .getResultList();  // 조회된 모든 엔티티가 영속 상태

// 영속 상태의 특징
p.setName("새 이름");  // 변경 감지 동작!
// 트랜잭션 커밋 시 자동 UPDATE
준영속 (Detached)

영속성 컨텍스트에서 분리된 상태. 영속성 컨텍스트가 제공하는 기능을 사용할 수 없습니다.

// 준영속 상태가 되는 경우

// 1. detach() - 특정 엔티티만 분리
em.detach(product);

// 2. clear() - 영속성 컨텍스트 초기화
em.clear();

// 3. close() - 영속성 컨텍스트 종료
em.close();

// 4. 트랜잭션 종료 후 (Spring에서 가장 흔한 케이스)
@Transactional
public Product findProduct(Long id) {
    return productRepository.findById(id).orElseThrow();
}  // 메서드 종료 → 트랜잭션 종료 → 반환된 Product는 준영속 상태

// 준영속 상태의 특징
product.setName("변경");  // 변경 감지 안됨!
product.getCategory();    // LazyInitializationException 발생 가능!
삭제 (Removed)

삭제하기로 표시된 상태. 트랜잭션 커밋 시 DELETE 쿼리가 실행됩니다.

// 삭제 상태
Product product = em.find(Product.class, 1L);  // 영속 상태
em.remove(product);  // 삭제 상태

// 아직 DB에서 삭제 안됨
// 트랜잭션 커밋 시 DELETE 실행

tx.commit();  // DELETE FROM products WHERE id = 1

6.2 merge() - 준영속 엔티티 병합

준영속 엔티티를 다시 영속 상태로
// 준영속 엔티티 수정 시나리오 (웹 애플리케이션에서 흔함)

// 1. 조회 (트랜잭션 A)
@Transactional
public ProductDto getProduct(Long id) {
    Product product = productRepository.findById(id).orElseThrow();
    return new ProductDto(product);  // DTO로 변환
}  // 트랜잭션 종료 → product는 준영속 상태

// 2. 화면에서 수정 후 저장 요청

// 3. 수정 (트랜잭션 B)
@Transactional
public void updateProduct(Long id, ProductUpdateRequest request) {
    // 방법 1: 변경 감지 사용 (권장)
    Product product = productRepository.findById(id).orElseThrow();
    product.update(request.getName(), request.getPrice());
    // 변경 감지로 자동 UPDATE
    
    // 방법 2: merge() 사용 (비권장)
    // Product detachedProduct = new Product(id, request.getName(), request.getPrice());
    // Product mergedProduct = em.merge(detachedProduct);
    // 주의: detachedProduct는 여전히 준영속, mergedProduct가 영속 상태
}

merge() 사용 시 주의사항

  • • merge()는 새로운 영속 엔티티를 반환합니다 (원본은 준영속 유지)
  • • 모든 필드를 교체하므로 null 값도 덮어씁니다
  • • 변경 감지 방식이 더 안전하고 명확합니다

6.3 실무에서 자주 만나는 상황

LazyInitializationException

준영속 상태에서 지연 로딩된 연관 엔티티에 접근할 때 발생

@Transactional
public Order getOrder(Long id) {
    return orderRepository.findById(id).orElseThrow();
}  // 트랜잭션 종료 → Order는 준영속 상태

// Controller에서
Order order = orderService.getOrder(1L);
order.getCustomer().getName();  // LazyInitializationException!
// Customer가 지연 로딩인데, 영속성 컨텍스트가 이미 종료됨

// 해결 방법
// 1. Fetch Join 사용
// 2. @EntityGraph 사용
// 3. DTO로 필요한 데이터만 조회
// 4. OSIV (Open Session In View) - 권장하지 않음

핵심 정리

  • 영속 상태: 변경 감지, 1차 캐시, 지연 로딩 모두 동작
  • 준영속 상태: 아무것도 동작 안함. 그냥 자바 객체
  • Spring에서: @Transactional 메서드 범위 = 영속성 컨텍스트 범위
  • 실무 팁: 트랜잭션 안에서 필요한 데이터를 모두 로딩하거나 DTO로 변환

7. 실습: 첫 번째 JPA 프로젝트

이론을 바탕으로 간단한 Spring Boot + JPA 프로젝트를 구성해봅니다. 이커머스 도메인의 Product 엔티티를 생성하고 CRUD 작업을 수행합니다.

7.1 프로젝트 설정

build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.h2database:h2'
    
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
application.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  
  h2:
    console:
      enabled: true
      path: /h2-console
  
  jpa:
    hibernate:
      ddl-auto: create  # 개발 환경에서만! 운영에서는 validate 또는 none
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace  # 바인딩 파라미터 출력

7.2 Entity 클래스

Product.java
package com.example.demo.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(length = 2000)
    private String description;

    @Column(nullable = false)
    private Integer stockQuantity;

    @Column(updatable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;

    // 생성 메서드
    public static Product create(String name, BigDecimal price, 
                                  String description, Integer stockQuantity) {
        Product product = new Product();
        product.name = name;
        product.price = price;
        product.description = description;
        product.stockQuantity = stockQuantity;
        product.createdAt = LocalDateTime.now();
        return product;
    }

    // 비즈니스 메서드
    public void update(String name, BigDecimal price, String description) {
        this.name = name;
        this.price = price;
        this.description = description;
        this.updatedAt = LocalDateTime.now();
    }

    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0) {
            throw new IllegalStateException("재고가 부족합니다.");
        }
        this.stockQuantity = restStock;
    }
}

7.3 Repository

ProductRepository.java
package com.example.demo.repository;

import com.example.demo.domain.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.math.BigDecimal;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 메서드 이름으로 쿼리 생성
    List<Product> findByNameContaining(String keyword);
    
    List<Product> findByPriceGreaterThan(BigDecimal price);
    
    List<Product> findByStockQuantityLessThan(Integer quantity);

    // @Query로 JPQL 직접 작성
    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
    List<Product> findByPriceRange(@Param("min") BigDecimal min, 
                                    @Param("max") BigDecimal max);
}

7.4 Service

ProductService.java
package com.example.demo.service;

import com.example.demo.domain.Product;
import com.example.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 기본은 읽기 전용
public class ProductService {

    private final ProductRepository productRepository;

    // 상품 등록
    @Transactional  // 쓰기 작업은 readOnly = false
    public Long createProduct(String name, BigDecimal price, 
                               String description, Integer stockQuantity) {
        Product product = Product.create(name, price, description, stockQuantity);
        productRepository.save(product);
        return product.getId();
    }

    // 상품 조회
    public Product findProduct(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. id=" + id));
    }

    // 상품 목록 조회
    public List<Product> findAllProducts() {
        return productRepository.findAll();
    }

    // 상품 수정 - 변경 감지 사용
    @Transactional
    public void updateProduct(Long id, String name, BigDecimal price, String description) {
        Product product = findProduct(id);
        product.update(name, price, description);  // 변경 감지!
        // save() 호출 불필요
    }

    // 상품 삭제
    @Transactional
    public void deleteProduct(Long id) {
        Product product = findProduct(id);
        productRepository.delete(product);
    }

    // 재고 추가
    @Transactional
    public void addStock(Long id, int quantity) {
        Product product = findProduct(id);
        product.addStock(quantity);  // 변경 감지!
    }
}

7.5 테스트 코드

ProductServiceTest.java
package com.example.demo.service;

import com.example.demo.domain.Product;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;

@SpringBootTest
@Transactional  // 테스트 후 롤백
class ProductServiceTest {

    @Autowired
    ProductService productService;

    @Test
    void 상품_등록() {
        // given
        String name = "테스트 상품";
        BigDecimal price = new BigDecimal("10000");

        // when
        Long productId = productService.createProduct(name, price, "설명", 100);

        // then
        Product found = productService.findProduct(productId);
        assertThat(found.getName()).isEqualTo(name);
        assertThat(found.getPrice()).isEqualByComparingTo(price);
    }

    @Test
    void 상품_수정_변경감지() {
        // given
        Long productId = productService.createProduct("원래 이름", 
            new BigDecimal("10000"), "설명", 100);

        // when
        productService.updateProduct(productId, "변경된 이름", 
            new BigDecimal("20000"), "변경된 설명");

        // then
        Product found = productService.findProduct(productId);
        assertThat(found.getName()).isEqualTo("변경된 이름");
        assertThat(found.getPrice()).isEqualByComparingTo(new BigDecimal("20000"));
    }

    @Test
    void 재고_부족_예외() {
        // given
        Long productId = productService.createProduct("상품", 
            new BigDecimal("10000"), "설명", 10);

        // when & then
        assertThatThrownBy(() -> {
            Product product = productService.findProduct(productId);
            product.removeStock(11);  // 재고보다 많이 차감
        }).isInstanceOf(IllegalStateException.class)
          .hasMessage("재고가 부족합니다.");
    }
}

7.6 실행 결과 확인

테스트 실행 시 콘솔에서 JPA가 생성하는 SQL을 확인할 수 있습니다.

-- 상품 등록 시
Hibernate: 
    insert 
    into
        products
        (created_at, description, name, price, stock_quantity, updated_at) 
    values
        (?, ?, ?, ?, ?, ?)

-- 상품 수정 시 (변경 감지)
Hibernate: 
    update
        products 
    set
        description=?,
        name=?,
        price=?,
        stock_quantity=?,
        updated_at=? 
    where
        id=?

-- 상품 조회 시
Hibernate: 
    select
        p1_0.id,
        p1_0.created_at,
        p1_0.description,
        p1_0.name,
        p1_0.price,
        p1_0.stock_quantity,
        p1_0.updated_at 
    from
        products p1_0 
    where
        p1_0.id=?

실습 체크리스트

  • 확인H2 콘솔 (http://localhost:8080/h2-console)에서 테이블 생성 확인
  • 확인콘솔에서 Hibernate SQL 로그 확인
  • 확인변경 감지로 UPDATE 쿼리가 자동 생성되는지 확인
  • 확인@Transactional 없이 수정 시 변경 감지가 동작하지 않는지 확인

8. 정리 및 다음 세션 예고

8.1 오늘 배운 내용 정리

JDBC의 한계와 ORM의 필요성

반복적인 CRUD 코드, SQL 의존성, 객체-관계 불일치 문제를 ORM이 해결

객체-관계 불일치 (Impedance Mismatch)

상속, 연관관계, 데이터 타입, 식별자, 그래프 탐색의 5가지 불일치

JPA, Hibernate, Spring Data JPA의 관계

JPA(표준 스펙) → Hibernate(구현체) → Spring Data JPA(편의 기능)

영속성 컨텍스트의 이점

1차 캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩

엔티티 생명주기

비영속 → 영속 → 준영속/삭제, 각 상태에서의 동작 이해

8.2 핵심 키워드

ORMJPAHibernateSpring Data JPA영속성 컨텍스트1차 캐시변경 감지쓰기 지연엔티티 생명주기영속/준영속EntityManager@Transactional

8.3 다음 세션 예고

Session 2: 기본 엔티티 매핑

다음 세션에서는 엔티티 클래스를 데이터베이스 테이블에 매핑하는 방법을 상세히 다룹니다.

@Entity, @Table

엔티티와 테이블 매핑의 기본

@Id, @GeneratedValue

기본 키 매핑 전략 (IDENTITY, SEQUENCE, TABLE, AUTO)

@Column

컬럼 매핑과 다양한 옵션

@Enumerated, @Temporal, @Lob

특수 타입 매핑

@Embedded, @Embeddable

값 타입과 임베디드 타입

8.4 과제

과제 1프로젝트 세팅

Spring Initializr로 Spring Boot + JPA + H2 프로젝트를 생성하고, 오늘 실습한 Product 엔티티와 Repository를 직접 작성해보세요.

과제 2변경 감지 실험

@Transactional이 있을 때와 없을 때 변경 감지가 어떻게 다르게 동작하는지 테스트 코드로 확인해보세요.

과제 3SQL 로그 분석

같은 엔티티를 두 번 조회할 때 SQL이 몇 번 실행되는지 확인하고, 1차 캐시의 동작을 직접 확인해보세요.

8.5 참고 자료

  • 도서자바 ORM 표준 JPA 프로그래밍 - 김영한
  • 강의인프런 - 자바 ORM 표준 JPA 프로그래밍 (김영한)
  • 문서Spring Data JPA Reference Documentation
  • 문서Hibernate ORM User Guide