Spring Data JDBC Aggregate 심화

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 이벤트 설계 글을 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux