JDBC의 한계를 이해하고, JPA의 핵심 개념인 영속성 컨텍스트와 엔티티 생명주기를 학습합니다.
"객체지향 프로그래밍과 관계형 데이터베이스 사이에는 근본적인 불일치가 존재한다. 이 간극을 메우는 것이 ORM의 핵심 목표이다...."
— Martin Fowler, Patterns of Enterprise Application Architecture
기존 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();Connection 획득, Statement 생성, ResultSet 매핑, 자원 해제 코드가 모든 쿼리마다 반복됩니다.
실제 비즈니스 로직보다 인프라 코드가 더 많아지는 현상 발생
비즈니스 로직이 SQL 문자열에 묻혀버립니다. 데이터베이스 변경 시 전체 코드 수정이 필요합니다.
MySQL에서 PostgreSQL로 변경 시 수백 개의 쿼리 수정 필요
ResultSet에서 객체로의 변환을 개발자가 직접 작성합니다. 컬럼명 오타 시 런타임 에러가 발생합니다.
rs.getString("naem") - 컴파일 시점에 발견 불가
Product와 Category의 관계를 표현하려면 추가 쿼리와 복잡한 조인 로직이 필요합니다.
N+1 문제를 개발자가 직접 인지하고 해결해야 함
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);
}
}이 코드의 문제점:
// 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("주문을 찾을 수 없습니다"));
}
}"소프트웨어 구축에서 가장 어려운 부분은 무엇을 만들지 결정하는 것이다. 기술적 구현보다 요구사항을 이해하고 올바른 모델을 찾는 것이 더 어렵다."
— Fred Brooks, No Silver Bullet (1986)
객체지향 언어(Java)와 관계형 데이터베이스(MySQL, PostgreSQL)는 데이터를 표현하는 방식이 근본적으로 다릅니다. 이를 "객체-관계 불일치" 또는"패러다임 불일치(Paradigm Mismatch)"라고 합니다.
객체 세계
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
);상속 개념 없음. 전략 선택 필요 (단일/조인/구현클래스별)
객체 세계
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;외래키로 연결, 방향성 없음 (항상 양방향)
객체 세계
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 등 제한된 기본 타입
객체 세계
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로만 식별, 단순한 개념
객체 세계
// 그래프 탐색 - 자유롭게 이동
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으로 한번에 조회해야 효율적
이상적인 객체 모델
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)이 해결하는 것:
"ORM은 객체지향 프로그래밍 언어와 관계형 데이터베이스 사이의 데이터를 변환하는 프로그래밍 기법이다. 이를 통해 개발자는 SQL 대신 객체를 사용하여 데이터베이스를 조작할 수 있다."
— Wikipedia, Object-Relational Mapping
ORM(Object-Relational Mapping)은 객체와 관계형 데이터베이스 테이블 사이의 매핑을 자동화하는 기술입니다. 개발자가 SQL 대신 객체를 통해 데이터베이스를 조작할 수 있게 해주며, 앞서 살펴본 객체-관계 불일치 문제를 해결합니다.
Java 객체
Product, Order
Customer
ORM
자동 변환
DB 테이블
products, orders
customers
객체의 필드를 테이블 컬럼으로 변환하여 INSERT/UPDATE SQL 생성
SELECT 결과(ResultSet)를 자동으로 객체 인스턴스로 변환
객체 참조와 외래키 관계를 자동으로 매핑하고 로딩
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로 자동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);
// 자동 매핑, 타입 안전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() 같은 메서드 없음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);적합한 경우
신중해야 하는 경우
JPA를 처음 접하면 JPA, Hibernate, Spring Data JPA라는 용어가 혼란스러울 수 있습니다. 이 세 가지의 관계를 명확히 이해하는 것이 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);
}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 등자바 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 ...모든 Java DB 접근의 기반. Hibernate도 내부적으로 JDBC를 사용하여 실제 데이터베이스와 통신합니다. 최종적으로 SQL이 실행되는 계층입니다.
| 구분 | JPA | Hibernate | Spring Data JPA |
|---|---|---|---|
| 성격 | 표준 스펙 (인터페이스) | JPA 구현체 | Spring 모듈 |
| 패키지 | jakarta.persistence | org.hibernate | org.springframework.data.jpa |
| 핵심 클래스 | EntityManager | Session | JpaRepository |
| 쿼리 방식 | JPQL, Criteria API | HQL, Criteria (deprecated) | 메서드 이름, @Query |
| 사용 시점 | 표준 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() ...
}왜 이 구조를 이해해야 하는가?
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 (커넥션 풀)"인터페이스만 정의했는데 어떻게 실행이 되는 거죠?"
Spring Data JPA를 처음 접하면 가장 먼저 드는 의문입니다. 구현체 없이 인터페이스만으로 DB 작업이 가능한 마법의 비밀을 파헤쳐 봅니다.
프록시(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 모두 프록시 기반으로 동작합니다.
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();
}
}Repository 인터페이스 스캔
@EnableJpaRepositories 또는 Spring Boot 자동 설정이 JpaRepository를 상속한 인터페이스들을 찾습니다.
JpaRepositoryFactoryBean 생성
각 Repository 인터페이스마다 FactoryBean이 생성됩니다. 이 FactoryBean이 실제 프록시 객체를 만드는 역할을 합니다.
프록시 객체 생성
Java의 Proxy.newProxyInstance() 또는 CGLIB을 사용하여 인터페이스를 구현하는 프록시 객체를 동적으로 생성합니다.
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. 결과 반환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 프록시를 얻습니다.
@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
}
}
}| 메서드 이름 | 생성되는 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의 마법
결국 Spring Data JPA도 내부적으로는 EntityManager를 사용합니다. 개발자의 반복 작업을 줄여주는 추상화 계층일 뿐입니다.
"영속성 컨텍스트는 엔티티를 영구 저장하는 환경이다. 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 데이터베이스 역할을 한다."
— 김영한, 자바 ORM 표준 JPA 프로그래밍
영속성 컨텍스트(Persistence Context)는 JPA의 가장 핵심적인 개념입니다. Entity를 관리하는 논리적인 영역으로, EntityManager를 통해 접근합니다. "엔티티를 영구 저장하는 환경"이라고 이해하면 됩니다.
영속성 컨텍스트 (Persistence Context)
1차 캐시 (First Level Cache)
쓰기 지연 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 실행
같은 트랜잭션 내에서 동일한 엔티티 조회 시 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 (같은 인스턴스)같은 트랜잭션 내에서 같은 엔티티는 항상 같은 인스턴스입니다. == 비교가 가능합니다.
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 (매번 새 객체 생성)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)엔티티 수정 시 별도의 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연관된 엔티티를 실제 사용하는 시점에 조회합니다. (다음 세션에서 자세히 다룸)
// 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 = ?영속성 컨텍스트의 변경 내용을 DB에 동기화 (SQL 실행)
트랜잭션을 커밋 (내부적으로 flush 호출 후 커밋)
// 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 → 영속성 컨텍스트 종료
}
}JPA에서 엔티티는 4가지 상태를 가집니다. 각 상태에서 어떤 동작이 가능한지 이해하는 것이 JPA를 올바르게 사용하는 핵심입니다.
new Product()
영속성 컨텍스트가 관리
영속성 컨텍스트에서 분리
삭제 예정
Database
순수한 자바 객체 상태. 영속성 컨텍스트와 전혀 관계없는 상태입니다.
// 비영속 상태 - 그냥 자바 객체
Product product = new Product();
product.setName("노트북");
product.setPrice(new BigDecimal("1500000"));
// 아직 영속성 컨텍스트와 관계 없음
// DB에도 저장 안됨
// 변경 감지 안됨영속성 컨텍스트가 관리하는 상태. 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영속성 컨텍스트에서 분리된 상태. 영속성 컨텍스트가 제공하는 기능을 사용할 수 없습니다.
// 준영속 상태가 되는 경우
// 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 발생 가능!삭제하기로 표시된 상태. 트랜잭션 커밋 시 DELETE 쿼리가 실행됩니다.
// 삭제 상태
Product product = em.find(Product.class, 1L); // 영속 상태
em.remove(product); // 삭제 상태
// 아직 DB에서 삭제 안됨
// 트랜잭션 커밋 시 DELETE 실행
tx.commit(); // DELETE FROM products WHERE id = 1// 준영속 엔티티 수정 시나리오 (웹 애플리케이션에서 흔함)
// 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() 사용 시 주의사항
준영속 상태에서 지연 로딩된 연관 엔티티에 접근할 때 발생
@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) - 권장하지 않음핵심 정리
이론을 바탕으로 간단한 Spring Boot + JPA 프로젝트를 구성해봅니다. 이커머스 도메인의 Product 엔티티를 생성하고 CRUD 작업을 수행합니다.
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'
}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 # 바인딩 파라미터 출력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;
}
}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);
}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); // 변경 감지!
}
}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("재고가 부족합니다.");
}
}테스트 실행 시 콘솔에서 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=?실습 체크리스트
JDBC의 한계와 ORM의 필요성
반복적인 CRUD 코드, SQL 의존성, 객체-관계 불일치 문제를 ORM이 해결
객체-관계 불일치 (Impedance Mismatch)
상속, 연관관계, 데이터 타입, 식별자, 그래프 탐색의 5가지 불일치
JPA, Hibernate, Spring Data JPA의 관계
JPA(표준 스펙) → Hibernate(구현체) → Spring Data JPA(편의 기능)
영속성 컨텍스트의 이점
1차 캐시, 동일성 보장, 쓰기 지연, 변경 감지, 지연 로딩
엔티티 생명주기
비영속 → 영속 → 준영속/삭제, 각 상태에서의 동작 이해
다음 세션에서는 엔티티 클래스를 데이터베이스 테이블에 매핑하는 방법을 상세히 다룹니다.
@Entity, @Table
엔티티와 테이블 매핑의 기본
@Id, @GeneratedValue
기본 키 매핑 전략 (IDENTITY, SEQUENCE, TABLE, AUTO)
@Column
컬럼 매핑과 다양한 옵션
@Enumerated, @Temporal, @Lob
특수 타입 매핑
@Embedded, @Embeddable
값 타입과 임베디드 타입
Spring Initializr로 Spring Boot + JPA + H2 프로젝트를 생성하고, 오늘 실습한 Product 엔티티와 Repository를 직접 작성해보세요.
@Transactional이 있을 때와 없을 때 변경 감지가 어떻게 다르게 동작하는지 테스트 코드로 확인해보세요.
같은 엔티티를 두 번 조회할 때 SQL이 몇 번 실행되는지 확인하고, 1차 캐시의 동작을 직접 확인해보세요.