Spring Data JDBC란?
Spring Data JDBC는 JPA의 복잡성을 제거한 경량 ORM 프레임워크입니다. Lazy Loading, 1차 캐시, 변경 감지(Dirty Checking), 세션/영속성 컨텍스트가 없습니다. Aggregate Root 중심의 DDD 설계를 강제하며, SQL 실행이 예측 가능합니다. “JPA가 너무 마법적”이라면 Spring Data JDBC가 답입니다.
JPA vs Spring Data JDBC
| 비교 항목 | Spring Data JPA | Spring Data JDBC |
|---|---|---|
| Lazy Loading | ✅ (N+1 주의) | ❌ (항상 Eager) |
| 영속성 컨텍스트 | ✅ (1차 캐시, Dirty Check) | ❌ |
| 관계 매핑 | 양방향, ManyToMany | 단방향, Aggregate 내부만 |
| SQL 예측성 | 낮음 (프록시, 캐시) | 높음 (1:1 매핑) |
| 학습 곡선 | 높음 | 낮음 |
| 시작 시간 | 느림 (Hibernate 초기화) | 빠름 |
| 적합한 상황 | 복잡한 관계, 레거시 DB | DDD, 마이크로서비스, 신규 프로젝트 |
기본 설정
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
runtimeOnly("org.postgresql:postgresql")
// JPA 의존성 없음!
}
// application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: app
password: secret
Aggregate Root 설계
Spring Data JDBC의 핵심 원칙: Repository는 Aggregate Root에만 존재합니다. Aggregate 내부 엔티티는 Root를 통해서만 접근합니다.
// Order = Aggregate Root
public class Order {
@Id
private Long id;
private String customerId;
private OrderStatus status;
private Instant createdAt;
// Aggregate 내부 엔티티: 1:N (Set/List)
private Set<OrderItem> items = new HashSet<>();
// Embedded Value Object
private ShippingAddress shippingAddress;
// Aggregate 외부 참조: ID만 저장 (엔티티 참조 X)
private AggregateReference<Customer, Long> customerRef;
// 비즈니스 로직
public void addItem(String productId, int quantity, BigDecimal price) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("주문 확정 후 항목 추가 불가");
}
items.add(new OrderItem(productId, quantity, price));
}
public BigDecimal totalAmount() {
return items.stream()
.map(OrderItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public void confirm() {
if (items.isEmpty()) {
throw new IllegalStateException("항목이 없는 주문은 확정 불가");
}
this.status = OrderStatus.CONFIRMED;
// 도메인 이벤트 등록
registerEvent(new OrderConfirmed(this.id, totalAmount()));
}
}
// Aggregate 내부 엔티티 (별도 Repository 없음)
public class OrderItem {
@Id
private Long id;
private String productId;
private int quantity;
private BigDecimal unitPrice;
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
// Value Object (Embedded)
public record ShippingAddress(
String street,
String city,
String zipCode,
String country
) {}
테이블 매핑과 네이밍 전략
Spring Data JDBC는 컨벤션 기반 매핑을 사용합니다. 커스터마이징 방법입니다.
-- 테이블 구조
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
-- Embedded: 접두사 + 필드명
shipping_address_street VARCHAR(255),
shipping_address_city VARCHAR(100),
shipping_address_zip_code VARCHAR(20),
shipping_address_country VARCHAR(50),
-- AggregateReference
customer_ref BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 1:N 관계: 외래키는 자식 테이블에
CREATE TABLE order_item (
id BIGSERIAL PRIMARY KEY,
orders BIGINT NOT NULL REFERENCES orders(id), -- 부모 테이블명이 FK 컬럼
product_id VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
unit_price NUMERIC(10,2) NOT NULL
);
// 네이밍 전략 커스터마이징
@Configuration
public class JdbcConfig extends AbstractJdbcConfiguration {
@Bean
public NamingStrategy namingStrategy() {
return new NamingStrategy() {
@Override
public String getTableName(Class<?> type) {
// Order → orders (복수형)
return CaseFormat.UPPER_CAMEL.to(
CaseFormat.LOWER_UNDERSCORE, type.getSimpleName()) + "s";
}
@Override
public String getColumnName(RelationalPersistentProperty property) {
// camelCase → snake_case
return CaseFormat.LOWER_CAMEL.to(
CaseFormat.LOWER_UNDERSCORE, property.getName());
}
@Override
public String getReverseColumnName(
RelationalPersistentProperty property) {
// 1:N 관계의 FK 컬럼명
return getTableName(property.getOwner().getType())
.replaceAll("s$", "") + "_id";
}
};
}
}
Repository와 커스텀 쿼리
// Aggregate Root에만 Repository 생성
public interface OrderRepository extends CrudRepository<Order, Long>,
PagingAndSortingRepository<Order, Long> {
// 메서드 이름 기반 쿼리 자동 생성
List<Order> findByStatus(OrderStatus status);
List<Order> findByCustomerIdOrderByCreatedAtDesc(String customerId);
// @Query: 네이티브 SQL 직접 작성
@Query("SELECT o.* FROM orders o WHERE o.status = :status AND o.created_at > :since")
List<Order> findRecentByStatus(
@Param("status") String status,
@Param("since") Instant since
);
// 집계 쿼리
@Query("SELECT COUNT(*) FROM orders WHERE customer_id = :customerId AND status = 'CONFIRMED'")
long countConfirmedByCustomer(@Param("customerId") String customerId);
// DTO Projection
@Query("""
SELECT o.id, o.status, o.created_at,
SUM(oi.quantity * oi.unit_price) as total_amount
FROM orders o
JOIN order_item oi ON oi.order_id = o.id
WHERE o.customer_id = :customerId
GROUP BY o.id, o.status, o.created_at
ORDER BY o.created_at DESC
""")
List<OrderSummary> findOrderSummaries(@Param("customerId") String customerId);
}
// DTO Projection (record 사용)
public record OrderSummary(
Long id,
OrderStatus status,
Instant createdAt,
BigDecimal totalAmount
) {}
저장 동작 이해: DELETE + INSERT
Spring Data JDBC의 가장 중요한 특성: Aggregate를 저장할 때 내부 엔티티를 전부 삭제 후 다시 삽입합니다.
// order에 item 3개가 있는 상태에서 저장하면:
orderRepository.save(order);
// 실행되는 SQL:
// 1. UPDATE orders SET ... WHERE id = 1
// 2. DELETE FROM order_item WHERE order_id = 1 ← 전부 삭제!
// 3. INSERT INTO order_item (order_id, product_id, ...) VALUES (1, ...)
// 4. INSERT INTO order_item (order_id, product_id, ...) VALUES (1, ...)
// 5. INSERT INTO order_item (order_id, product_id, ...) VALUES (1, ...)
// 성능 영향을 줄이려면:
// 1. Aggregate를 작게 유지 (DDD 원칙)
// 2. 대량 항목은 별도 Aggregate로 분리
// 3. @Version으로 낙관적 잠금 사용
도메인 이벤트 발행
Spring Data JDBC는 AbstractAggregateRoot를 통해 도메인 이벤트를 자연스럽게 지원합니다.
public class Order extends AbstractAggregateRoot<Order> {
// ...
public void confirm() {
this.status = OrderStatus.CONFIRMED;
// save() 호출 시 이벤트 자동 발행
registerEvent(new OrderConfirmed(this.id, totalAmount()));
}
public void cancel(String reason) {
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelled(this.id, reason));
}
}
// 이벤트 리스너
@Component
public class OrderEventListener {
@EventListener
public void onOrderConfirmed(OrderConfirmed event) {
// 재고 차감, 결제 처리 등
log.info("주문 확정: {} (금액: {})", event.orderId(), event.amount());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCancelledAfterCommit(OrderCancelled event) {
// 트랜잭션 커밋 후 비동기 알림
notificationService.sendCancellationEmail(event.orderId());
}
}
// 이벤트 레코드
public record OrderConfirmed(Long orderId, BigDecimal amount) {}
public record OrderCancelled(Long orderId, String reason) {}
커스텀 Converter: 복잡한 타입 매핑
@Configuration
public class JdbcConverterConfig extends AbstractJdbcConfiguration {
@Override
protected List<Object> userConverters() {
return List.of(
new MoneyToLongConverter(),
new LongToMoneyConverter(),
new JsonToMetadataConverter(),
new MetadataToJsonConverter()
);
}
}
// Money Value Object ↔ Long (센트 단위)
@WritingConverter
public class MoneyToLongConverter implements Converter<Money, Long> {
@Override
public Long convert(Money source) {
return source.amountInCents();
}
}
@ReadingConverter
public class LongToMoneyConverter implements Converter<Long, Money> {
@Override
public Money convert(Long source) {
return Money.ofCents(source);
}
}
// JSON 컬럼 ↔ Java Object
@WritingConverter
public class MetadataToJsonConverter implements Converter<Metadata, PGobject> {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public PGobject convert(Metadata source) {
PGobject json = new PGobject();
json.setType("jsonb");
json.setValue(mapper.writeValueAsString(source));
return json;
}
}
낙관적 잠금과 감사
public class Order extends AbstractAggregateRoot<Order> {
@Id
private Long id;
@Version
private Long version; // 낙관적 잠금 (UPDATE ... WHERE version = ?)
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
// 감사 설정
@Configuration
@EnableJdbcAuditing
public class AuditConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(
SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
}
언제 Spring Data JDBC를 선택하는가?
- 선택: DDD Aggregate 설계, 예측 가능한 SQL, 빠른 시작 시간(GraalVM Native), 마이크로서비스 신규 개발
- JPA 유지: 복잡한 양방향 관계, 레거시 DB 매핑, Criteria API/QueryDSL 필요, 2차 캐시 활용
Spring Data JDBC는 “Simple is better” 철학의 구현체입니다. JPA의 N+1, LazyInitializationException, 프록시 문제를 원천 차단합니다. JPA와의 상세 비교는 Spring JPA N+1 해결 전략 가이드를, 도메인 이벤트 패턴은 Spring ApplicationEvent 이벤트 설계 글을 참고하세요.