Spring

[Spring] Spring Data - JDBC

kahnco 2025. 4. 17. 15:08
반응형

개요

관계형 데이터베이스와의 상호작용은 많은 애플리케이션 개발의 핵심 요소입니다. Spring 프레임워크 생태계 내에서 개발자들은 주로 Spring Data JPA를 사용하여 객체-관계 매핑(ORM)의 편리함을 누려왔습니다. 하지만 모든 상황에 완전한 ORM 프레임워크가 필요한 것은 아닙니다. 때로는 더 단순하고, 예측 가능하며, SQL에 대한 직접적인 제어를 제공하는 솔루션이 더 적합할 수 있습니다. 바로 이 지점에서 Spring Data JDBC가 등장합니다.

 

Spring Data JDBC는 Spring Data 프로젝트의 일부로, 도메인 주도 설계(Domain-Driven Design, DDD) 원칙에 맞춰 JDBC 기반 데이터 접근 솔루션을 개발하는 데 중점을 둡니다. 완전한 ORM 기능(캐싱, 지연 로딩, 쓰기 지연 등)을 제공하는 대신, 애그리거트(Aggregate)라는 핵심 개념을 중심으로 데이터베이스 테이블과 Java 객체 간의 간단한 매핑을 제공합니다. 이를 통해 개발자는 ORM의 복잡성 없이 관계형 데이터베이스와 상호작용할 수 있으며, 실행되는 SQL에 대한 더 나은 가시성과 제어권을 가질 수 있습니다.  

 

이 게시물에서는 Spring Data JDBC 3.4.4 버전을 심층적으로 살펴봅니다. 프로젝트 설정부터 애그리거트 및 리포지토리 정의, CRUD 작업 수행, 다양한 쿼리 방법, 생명주기 이벤트 처리, 그리고 Spring Data JPA와의 비교를 통해 언제 Spring Data JDBC를 선택하는 것이 유리한지 알아보겠습니다.

 


시작하기: 설정 및 구성

Spring Data JDBC를 사용하기 위한 첫 단계는 프로젝트 의존성을 설정하고 데이터베이스 연결을 구성하는 것입니다. Spring Boot를 사용하면 이 과정이 매우 간소화됩니다.

 

의존성 설정

Spring Data JDBC 3.4.4Spring Framework 6.2.4 이상 버전이 필요합니다.

 

Maven 사용 시 (pom.xml)

Spring Boot 프로젝트에서는 spring-boot-starter-data-jdbc 스타터를 추가하는 것이 가장 일반적입니다. 이 스타터는 Spring Data JDBC 코어, spring-jdbc, 트랜잭션 관리 등 필요한 의존성을 포함합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
    <version>3.4.4</version> </dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

 

spring-boot-starter-data-jdbc는 특정 JDBC 드라이버를 포함하지 않으므로, 사용하려는 데이터베이스에 맞는 드라이버 의존성을 별도로 추가해야 합니다. 이는 프레임워크가 특정 데이터베이스 기술에 종속되지 않고 개발자에게 선택권을 부여하려는 철학을 반영합니다. 기존 JDBC 설정과 유연하게 통합될 수 있도록 설계된 것입니다.

 

Spring Boot를 사용하지 않는 경우, spring-data-jdbc 아티팩트를 직접 추가해야 합니다.

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jdbc</artifactId>
    <version>3.4.4</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
    <version>6.2.4</version>
</dependency>

 

Gradle 사용 시 (build.gradle)

plugins {
    id 'org.springframework.boot' version '3.4.4' // Spring Boot 플러그인 버전
    id 'io.spring.dependency-management' version '1.1.7' // 의존성 관리 플러그인
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17) // Spring Boot 3.x는 Java 17 이상 필요 [13]
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'

    // 사용하는 데이터베이스의 JDBC 드라이버 추가
    runtimeOnly 'com.h2database:h2'
    // runtimeOnly 'org.postgresql:postgresql'
    // runtimeOnly 'com.mysql:mysql-connector-j'

    // Lombok (선택 사항)
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

 

Gradle에서도 implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'를 추가하고, 필요한 JDBC 드라이버를 runtimeOnly 스코프로 포함시킵니다.

 

데이터베이스 연결 설정

Spring Boot에서는 application.properties 또는 application.yml 파일을 통해 데이터베이스 연결 정보를 간단하게 설정할 수 있습니다.

 

application.properties 예시 (H2 인메모리 데이터베이스):

# Database Connection Settings
spring.datasource.url=jdbc:h2:mem:testdb # H2 인메모리 DB URL
spring.datasource.username=sa
spring.datasource.password=password
# spring.datasource.driver-class-name=org.h2.Driver # Boot가 URL 기반으로 자동 감지하는 경우가 많음 [6, 15]

# H2 Console (개발 시 유용)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Spring Data JDBC 설정 (선택 사항)
# spring.data.jdbc.repositories.enabled=true # 기본값 true

 

이 설정만으로 Spring Boot는 DataSource 빈을 자동으로 구성합니다. spring-boot-starter-data-jdbc가 클래스패스에 존재하고 DataSource 빈이 발견되면, Spring Boot는 NamedParameterJdbcOperations, TransactionManager 등 Spring Data JDBC에 필요한 핵심 컴포넌트들을 자동으로 구성하고 리포지토리를 활성화합니다. 따라서 대부분의 Spring Boot 애플리케이션에서는 명시적인 Java 구성 클래스가 필요하지 않습니다.  

 

이러한 강력한 자동 구성 기능은 Spring Boot 사용자의 진입 장벽을 크게 낮춰줍니다. 하지만 Spring Boot 없이 Spring Framework만 사용하는 경우, 개발자는 DataSource, NamedParameterJdbcOperations, PlatformTransactionManager 등의 빈을 수동으로 설정하고 @EnableJdbcRepositories 어노테이션을 사용하여 리포지토리를 활성화하는 방법을 이해해야 합니다. 이는 비-Boot 환경에서의 초기 설정 복잡성을 증가시킬 수 있습니다.

 

커넥션 풀 설정:

Spring Boot는 기본적으로 HikariCP를 커넥션 풀로 사용합니다. application.properties를 통해 HikariCP의 설정을 조정할 수 있습니다.

# HikariCP 설정 예시
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000 # 30초

 

Java 구성 (수동 설정 - 비-Boot 환경 또는 커스터마이징 시)

Spring Boot를 사용하지 않거나 더 세밀한 제어가 필요한 경우, Java 구성을 통해 Spring Data JDBC를 설정할 수 있습니다. @Configuration 클래스에서 AbstractJdbcConfiguration을 상속받고 필요한 빈들을 정의합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration;
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;

@Configuration
@EnableJdbcRepositories // (1) JDBC 리포지토리 활성화
public class ApplicationConfig extends AbstractJdbcConfiguration { // (2) 기본 빈 제공

    @Bean
    DataSource dataSource() { // (3) DataSource 빈 정의
        EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
        return builder.setType(EmbeddedDatabaseType.HSQL).build(); // 예: HSQL 임베디드 DB
    }

    @Bean
    NamedParameterJdbcOperations namedParameterJdbcOperations(DataSource dataSource) { // (4) NamedParameterJdbcOperations 빈 정의
        return new NamedParameterJdbcTemplate(dataSource);
    }

    @Bean
    PlatformTransactionManager transactionManager(DataSource dataSource) { // (5) 트랜잭션 관리자 빈 정의
        return new DataSourceTransactionManager(dataSource);
    }

    // 필요 시 Dialect 등 추가 빈 정의
}

 

데이터베이스 Dialect

Spring Data JDBC는 다양한 데이터베이스 벤더 간의 SQL 차이를 추상화하기 위해 Dialect 인터페이스 구현체를 사용합니다. AbstractJdbcConfiguration은 기본적으로 DataSource 연결을 통해 Dialect를 자동으로 감지하려고 시도합니다. 지원되는 데이터베이스에는 H2, HSQLDB, MySQL, PostgreSQL, Oracle, SQL Server, DB2 등이 있습니다.

 

자동 감지가 실패하거나 특정 Dialect를 강제해야 하는 경우, AbstractJdbcConfiguration의 jdbcDialect(NamedParameterJdbcOperations) 메서드를 오버라이드하여 커스텀 Dialect 빈을 등록하거나, 특정 프로퍼티를 통해 Dialect Provider를 지정할 수 있습니다.

 


엔티티와 리포지토리 정의: 애그리거트 모델링

Spring Data JDBC의 핵심 철학은 도메인 주도 설계(DDD)의 애그리거트(Aggregate) 개념에 기반합니다.

 

애그리거트 루트 (Aggregate Root)

애그리거트는 함께 로드되고 저장되는 관련된 객체들의 묶음입니다. 애그리거트 루트는 이 묶음의 대표 엔티티이며, 리포지토리를 통해 관리되는 기본 단위입니다. 모든 데이터 접근(저장, 조회, 삭제)은 애그리거트 루트를 위한 리포지토리를 통해 수행됩니다. 애그리거트 경계 내의 다른 엔티티들은 루트를 통해서만 접근하고 관리됩니다.

 

어노테이션을 이용한 엔티티 매핑

Java 객체(POJO)를 데이터베이스 테이블에 매핑하기 위해 다음과 같은 어노테이션을 사용합니다.

  • @Table: 엔티티가 매핑될 테이블 이름을 명시적으로 지정합니다. 생략하면 클래스 이름을 기반으로 (기본적으로 snake_case 변환) 테이블 이름이 결정됩니다.
     
  • @Id: 엔티티의 기본 키(Primary Key)를 나타내는 필드를 지정합니다. 모든 애그리거트 루트는 @Id 필드를 가져야 합니다. 데이터베이스의 자동 증가(auto-increment) 컬럼과 연동될 수 있으며, ID 생성 전략에 대한 고려가 필요할 수 있습니다.
     
  • @Column: 필드가 매핑될 컬럼 이름을 명시적으로 지정합니다. 생략하면 필드 이름을 기반으로 (기본적으로 snake_case 변환) 컬럼 이름이 결정됩니다.
     
  • @MappedCollection: 일대다(one-to-many) 관계에서 자식 엔티티 컬렉션을 매핑할 때 사용됩니다. 자식 테이블의 외래 키 컬럼(idColumn)과 루트 테이블의 키 컬럼(keyColumn, 주로 ID)을 지정해야 합니다.

기본 엔티티 예시:

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.data.relational.core.mapping.Column;

@Table("CUSTOMERS") // 선택 사항, 기본값은 "customer" 테이블
public class Customer {

    @Id
    private Long id; // ID 필드는 필수

    @Column("first_name") // 선택 사항, 기본값은 "first_name" 컬럼
    private String firstName;

    private String lastName; // 기본적으로 "last_name" 컬럼에 매핑됨

    // 생성자, Getter, Setter 등
    // 불변성(Immutability) 고려 가능 [3] - 생성자 주입과 final 필드 사용
    public Customer(Long id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    // Getters... (Setter는 불변 객체일 경우 불필요)
    public Long getId() { return id; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
}

 

관계 매핑 (애그리거트 내부)

Spring Data JDBC 는 애그리거트 내부의 관계와 애그리거트 간의 참조를 구분합니다.

  • 일대다 (One-to-Many): 애그리거트 루트가 소유한 자식 엔티티들의 컬렉션(주로 Set<RelatedEntity> 또는 List<RelatedEntity>)으로 표현됩니다. 이때 @MappedCollection 어노테이션이 필수적이며, 자식 테이블에서 부모를 참조하는 외래 키 컬럼(idColumn)과 부모 테이블의 ID 컬럼(keyColumn)을 명시해야 합니다. 이는 JPA의 @OneToMany가 종종 컬럼을 추론하는 것과 달리, Spring Data JDBC는 더 명시적인 설정을 요구함을 보여줍니다. 개발자는 데이터베이스 스키마에 대한 정확한 이해를 바탕으로 매핑해야 하며, 설정 오류는 런타임 에러나 예상치 못한 데이터 불일치를 초래할 수 있습니다.
// Order (Aggregate Root) 내부
@Table("ORDERS")
public class Order {
    @Id private Long id;
    private String orderNumber;

    // OrderLineItem 테이블의 "order_id" 컬럼이 이 Order의 "id" 컬럼을 참조함
    @MappedCollection(idColumn = "ORDER_ID", keyColumn = "ID")
    private Set<OrderLineItem> items = new HashSet<>();

    // 생성자, Getter 등
}

// OrderLineItem (Order 애그리거트의 일부)
@Table("ORDER_LINE_ITEMS")
public class OrderLineItem {
    // 자식 엔티티는 독립적인 @Id를 가질 수도, 안 가질 수도 있음 (복합 키 등)
    private String productCode;
    private int quantity;
    // ORDER_ID 컬럼이 DB에 존재해야 함 (idColumn에 명시됨)

    // 생성자, Getter 등
}
  • 일대일 (One-to-One): 애그리거트 내의 다른 엔티티 타입 필드로 직접 참조하여 모델링할 수 있습니다. 만약 참조된 엔티티가 별도의 테이블을 가진다면 외래 키 관계가 암시됩니다. 또는 @Embedded 어노테이션을 사용하여 값 객체(Value Object)를 루트 엔티티와 동일한 테이블의 컬럼들에 매핑할 수도 있습니다.
// Customer (Aggregate Root) 내부
public class Customer {
    @Id private Long id;
    private String name;

    // Address가 Customer 애그리거트의 일부라고 가정
    private Address shippingAddress;

    // 생성자, Getter 등
}

// Address 클래스 (별도 테이블 또는 @Embeddable 가능)
public class Address {
    private String street;
    private String city;
    //...
}
  • 다른 애그리거트에 대한 참조: 서로 다른 애그리거트 간의 직접적인 객체 참조는 권장되지 않습니다. 대신, 참조하려는 다른 애그리거트 루트의 ID 값만 저장합니다. 해당 애그리거트의 전체 정보가 필요하면, 그 애그리거트를 담당하는 별도의 리포지토리를 사용하여 ID로 조회해야 합니다. 이 방식은 애그리거트 경계를 명확히 하고, 관련 없는 큰 객체 그래프가 실수로 로드되는 것을 방지합니다. 이는 DDD의 핵심 원칙을 강제하며, JPA의 지연/즉시 로딩을 통해 객체 그래프를 자유롭게 탐색하던 개발자에게는 새로운 제약 조건이 될 수 있습니다.

 

Naming Strategy

기본적으로 Spring Data JDBC는 Java의 camelCase 필드/클래스 이름을 데이터베이스의 snake_case 컬럼/테이블 이름으로 변환하는 NamingStrategy를 사용합니다. 필요한 경우, 커스텀 NamingStrategy 빈을 등록하여 이 동작을 변경할 수 있습니다.

 

리포지토리 인터페이스 생성

데이터 접근 로직을 추상화하기 위해 리포지토리 인터페이스를 정의합니다. Spring Data는 이 인터페이스의 구현체를 자동으로 생성해줍니다.

  • CrudRepository<EntityType, IdType>: 기본적인 CRUD(Create, Read, Update, Delete) 메서드를 제공합니다. findAll()은 Iterable<T>을 반환합니다.  
     
  • ListCrudRepository<EntityType, IdType>: CrudRepository와 유사하지만, findAll(), findAllById() 등이 Iterable 대신 List<T>를 반환하여 사용 편의성을 높입니다. (Spring Data Commons의 일부로 3.4.4에서도 사용 가능)
  • PagingAndSortingRepository<EntityType, IdType>: 페이징 및 정렬 기능을 위한 메서드(findAll(Pageable), findAll(Sort))를 추가로 제공합니다.

기본 리포지토리 예시:

import org.springframework.data.repository.CrudRepository;
// 또는 import org.springframework.data.repository.ListCrudRepository;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository // 선택 사항이지만 가독성을 위해 권장
public interface CustomerRepository extends CrudRepository<Customer, Long> {
// public interface CustomerRepository extends ListCrudRepository<Customer, Long> { // List 반환을 원할 경우

    // 사용자 정의 쿼리 메서드 추가 가능 (5번 섹션 참조)
    List<Customer> findByLastName(String lastName);
}

 

 

 


CRUD 작업 수행: 데이터 상호작용

리포지토리 인터페이스를 정의했다면, 이를 서비스나 컨트롤러 계층에 주입하여 데이터베이스 작업을 수행할 수 있습니다.

 

리포지토리 주입

@Autowired 어노테이션을 사용하여 리포지토리 인스턴스를 주입받습니다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CustomerService {

    private final CustomerRepository customerRepository;

    @Autowired
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    // CRUD 메서드 구현...
}

 

생성/수정 (Save)

CrudRepository의 save(AggregateRoot entity) 메서드는 엔티티 생성과 수정을 모두 처리합니다.

  • 엔티티의 ID가 null이거나, Persistable 인터페이스 구현을 통해 '새로운 엔티티'로 판단되면 INSERT SQL이 실행됩니다.
  • 엔티티의 ID가 존재하면 UPDATE SQL이 실행됩니다.

중요한 점은 save 연산이 애그리거트 루트에 대해 호출되면, 해당 애그리거트에 속한 모든 엔티티(예: @MappedCollection으로 매핑된 컬렉션 내 아이템)에 대한 변경 사항이 함께 반영된다는 것입니다. Spring Data JDBC는 전달된 애그리거트 객체의 상태를 분석하여 관련된 자식 엔티티에 대한 INSERT, UPDATE, DELETE 문을 자동으로 결정하고 실행합니다.

// CustomerService 내부

public Customer createCustomer(String firstName, String lastName) {
    Customer newCustomer = new Customer(null, firstName, lastName); // ID가 null이면 INSERT
    // 필요 시 관련된 자식 엔티티 추가 (예: newCustomer.addItem(newItem))
    return customerRepository.save(newCustomer);
}

public Customer updateCustomerName(Long id, String newFirstName, String newLastName) {
    // 기존 고객 조회 (Optional 처리 필요)
    Customer existingCustomer = customerRepository.findById(id)
           .orElseThrow(() -> new RuntimeException("Customer not found"));

    // 수정된 정보로 새 객체 생성 (불변 객체 스타일) 또는 기존 객체 수정
    Customer updatedCustomer = new Customer(existingCustomer.getId(), newFirstName, newLastName);
    // 자식 엔티티 컬렉션 등도 필요 시 업데이트

    return customerRepository.save(updatedCustomer); // ID가 존재하므로 UPDATE
}

 

이러한 애그리거트 수준의 영속성 작업(save, delete)은 복잡한 객체 구조를 한 번에 저장하거나 삭제할 때 매우 편리합니다. 하지만 큰 애그리거트의 일부만 변경하는 경우에도 전체 애그리거트를 로드하고 저장하는 방식으로 동작할 수 있어 잠재적인 비효율이 발생할 수 있습니다. 이는 불필요한 데이터 전송을 유발하고, 낙관적 락킹(Optimistic Locking) 을 사용하지 않으면 동시성 문제로 인해 다른 변경 사항을 덮어쓸 위험도 내포합니다. 따라서 부분 업데이트가 빈번하다면, 애그리거트 전체 save 대신 @Query를 이용한 맞춤형 UPDATE 문 사용을 고려해야 할 수 있습니다.  

 

또한, save 시 애그리거트 경계 내의 모든 변경이 자동으로 전파되므로, 개발자는 애그리거트 경계를 명확하게 정의하는 것이 중요합니다. 실수로 관련 없는 엔티티를 애그리거트에 포함시키면, 루트 저장 시 의도치 않은 데이터 수정이나 삭제가 발생할 수 있습니다.

 

조회 (Read / Find)

 

  • findById(ID id): 주어진 ID에 해당하는 애그리거트 루트를 Optional<AggregateRoot> 형태로 반환합니다.
  • findAll(): 리포지토리가 관리하는 모든 애그리거트 루트를 Iterable<AggregateRoot> (또는 ListCrudRepository 사용 시 List<AggregateRoot>) 형태로 반환합니다.
  • findAllById(Iterable<ID> ids): 여러 ID에 해당하는 엔티티들을 조회합니다.
// CustomerService 내부

public Optional<Customer> getCustomerById(Long id) {
    return customerRepository.findById(id);
}

public List<Customer> getAllCustomers() {
    // CrudRepository 사용 시 캐스팅 필요, ListCrudRepository는 불필요
    // return (List<Customer>) customerRepository.findAll();
    return customerRepository.findAll(); // ListCrudRepository 사용 시
}

 

 

삭제 (Delete)

 

  • deleteById(ID id): 주어진 ID의 애그리거트 루트와 소유된 관련 엔티티들을 함께 삭제합니다.
  • delete(AggregateRoot entity): 주어진 엔티티 객체의 ID를 사용하여 삭제합니다.
  • deleteAll(): 리포지토리가 관리하는 모든 애그리거트를 삭제합니다.
// CustomerService 내부

public void deleteCustomer(Long id) {
    customerRepository.deleteById(id); // 관련 자식 엔티티도 함께 삭제됨
}

 

 


데이터 쿼리: 기본 CRUD 를 넘어서

Spring Data JDBC 는 기본적인 CRUD 외에도 다양한 데이터 조회 방법을 제공합니다.

 

파생 쿼리 (Derived Queries / Query Creation)

리포지토리 인터페이스에 특정 명명 규칙을 따르는 메서드를 선언하면, Spring Data 가 메서드 이름을 분석하여 자동으로 SQL 쿼리를 생성해줍니다. 이는 간단한 조회 조건을 위한 코드를 크게 줄여줍니다.

 

예시:

public interface CustomerRepository extends ListCrudRepository<Customer, Long> { // List 반환으로 변경

    // SELECT * FROM customers WHERE last_name =?
    List<Customer> findByLastName(String lastName);

    // SELECT * FROM customers WHERE first_name =? AND last_name =?
    Optional<Customer> findByFirstNameAndLastName(String firstName, String lastName);

    // SELECT COUNT(*) FROM customers WHERE last_name =?
    long countByLastName(String lastName); // [2] 예시

    // DELETE FROM customers WHERE last_name =? (반환값: 삭제된 행 수)
    long deleteByLastName(String lastName); // [2] 예시

    // DELETE FROM customers WHERE last_name =? (반환값: 삭제된 엔티티 목록)
    List<Customer> removeByLastName(String lastName); // [2] 예시 (대안적 삭제)

    // SELECT * FROM customers WHERE last_name =? ORDER BY first_name ASC
    List<Customer> findByLastNameOrderByFirstNameAsc(String lastName);

    // SELECT * FROM customers WHERE last_name LIKE?
    List<Customer> findByLastNameStartingWith(String prefix);
}

 

IgnoreCase, Containing, Between, In, NotNull 등 다양한 키워드를 조합하여 복잡한 쿼리 조건을 메서드 이름으로 표현할 수 있습니다.

 

파생 쿼리는 편리하지만, 생성되는 실제 SQL을 직접 제어하기 어렵다는 단점이 있습니다. 복잡한 조인이나 데이터베이스 특정 함수 사용, 성능 최적화가 필요한 경우에는 명시적인 쿼리 작성이 더 적합합니다.

 

@Query를 이용한 사용자 정의 쿼리

메서드 이름만으로 표현하기 어렵거나, 특정 SQL을 직접 작성하고 싶을 때 @Query 어노테이션을 사용합니다. 값으로는 실행될 SQL 문을 직접 작성합니다. 파라미터 바인딩은 메서드 파라미터 이름과 일치하는 콜론(:) 접두사(예: :paramName)를 사용하거나 @Param 어노테이션으로 명시적으로 지정할 수 있습니다.  

import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;

public interface CustomerRepository extends ListCrudRepository<Customer, Long> {

    @Query("SELECT * FROM customers WHERE last_name = :lastName ORDER BY first_name ASC")
    List<Customer> findCustomersByLastNameSorted(@Param("lastName") String name);

    @Query("SELECT c.* FROM customers c JOIN customer_orders co ON c.id = co.customer_id WHERE co.order_date > :cutoffDate")
    List<Customer> findCustomersWithRecentOrders(@Param("cutoffDate") LocalDate cutoffDate);

    // 네이티브 SQL 사용 가능 (데이터베이스 특정 기능 활용)
    @Query("SELECT * FROM customers WHERE UPPER(last_name) = UPPER(:lastName)")
    List<Customer> findByLastNameIgnoreCaseCustom(@Param("lastName") String lastName);
}
 

@Query를 사용하면 SQL에 대한 완전한 제어권을 가지므로, 복잡한 로직 구현이나 성능 튜닝에 유리합니다. 이는 Spring Data JDBC가 지향하는 'SQL에 더 가까운' 철학과도 일맥상통합니다.

 

@Modifying을 이용한 수정 쿼리

@Query를 사용하여 UPDATE 또는 DELETE 문을 실행하는 경우, 해당 메서드에 @Modifying 어노테이션을 추가해야 합니다. 이는 해당 메서드가 데이터를 변경하는 작업임을 명시적으로 나타냅니다.

import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.query.Param;

public interface CustomerRepository extends ListCrudRepository<Customer, Long> {

    @Modifying
    @Query("UPDATE customers SET last_name = :newLastName WHERE id = :id")
    boolean updateLastName(@Param("id") Long id, @Param("newLastName") String newLastName); // [4] 예시 기반

    @Modifying
    @Query("DELETE FROM customers WHERE last_login_date < :cutoffDate")
    int deleteInactiveCustomers(@Param("cutoffDate") LocalDate cutoffDate); // 삭제된 행 수 반환
}

 

반환 타입은 주로 변경된 행의 수를 나타내는 int 또는 long, 혹은 변경 성공 여부를 나타내는 boolean입니다. @Modifying 어노테이션은 데이터를 읽기만 하는 쿼리와 변경하는 쿼리를 명확히 구분하여, 실수로 데이터가 변경되는 것을 방지하는 안전장치 역할을 합니다. 프레임워크는 이 어노테이션을 통해 트랜잭션 처리 등 실행 컨텍스트를 적절히 설정할 수 있습니다.

 

페이징 및 정렬

PagingAndSortingRepository 인터페이스를 확장하면, Pageable (페이지 번호, 페이지 크기, 정렬 정보 포함) 및 Sort 객체를 파라미터로 받는 메서드를 사용할 수 있습니다.

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;

public interface CustomerRepository extends PagingAndSortingRepository<Customer, Long> {

    // last_name으로 검색하고 결과를 페이징 처리하여 반환
    Page<Customer> findByLastName(String lastName, Pageable pageable);
}

 


생명주기 이벤트 처리: 영속성 과정 개입

Spring Data JDBC는 애그리거트의 영속성 생명주기(저장, 삭제, 로드 등) 동안 특정 시점에 개입하여 사용자 정의 로직을 실행할 수 있는 메커니즘을 제공합니다. 이는 Spring의 ApplicationEvent 또는 특정 Callback 인터페이스를 구현하는 빈(Bean)을 통해 이루어집니다.

 

주요 콜백 인터페이스

 

  • BeforeConvertCallback<T>: 엔티티가 영속성을 위해 내부 표현으로 변환되기 전에 호출됩니다. 기본값 설정 등에 유용합니다.
  • BeforeSaveCallback<T>: 애그리거트 루트가 저장(INSERT 또는 UPDATE)되기 직전에 호출됩니다. 유효성 검사나 최종 수정에 사용할 수 있습니다.
  • AfterSaveCallback<T>: 애그리거트 루트가 성공적으로 저장된 후에 호출됩니다.
  • BeforeDeleteCallback<T, ID>: 애그리거트 루트가 삭제되기 전에 호출됩니다.
  • AfterDeleteCallback<T, ID>: 애그리거트 루트가 성공적으로 삭제된 후에 호출됩니다.
  • AfterLoadCallback<T>: 애그리거트 루트가 데이터베이스에서 로드된 후에 호출됩니다.

 

구현 예시 (개념)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.relational.core.mapping.event.BeforeSaveCallback;
import java.time.LocalDateTime;

@Configuration
public class JdbcCallbackConfig {

    // Customer 엔티티 저장 전 호출될 콜백 빈 등록
    @Bean
    BeforeSaveCallback<Customer> customerBeforeSaveCallback() {
        return (customer, aggregateChange) -> {
            // 예: 생성/수정 시간 설정 (Auditing [3] 구현의 일부)
            // if (customer.getCreatedAt() == null) {
            //     customer.setCreatedAt(LocalDateTime.now());
            // }
            // customer.setUpdatedAt(LocalDateTime.now());

            System.out.println("Saving customer: " + customer.getId());
            return customer; // 변경된 엔티티 또는 원본 엔티티 반환 필수
        };
    }
}

 

 

이러한 콜백 메커니즘은 감사(Auditing) 정보 기록, 타임스탬프 설정 등 영속성과 관련된 횡단 관심사(cross-cutting concerns)를 엔티티나 리포지토리 코드와 분리하여 깔끔하게 구현할 수 있게 해줍니다.

 

JPA의 @EntityListeners나 @PrePersist, @PostLoad 같은 엔티티 직접 어노테이션 방식과 달리, Spring Data JDBC는 Spring의 이벤트/콜백 빈 메커니즘을 사용합니다. 이는 생명주기 로직이 도메인 객체 자체보다는 Spring 컨테이너 구성에 더 가깝게 연결됨을 의미합니다. 이는 관심사 분리 측면에서는 장점일 수 있으나, 엔티티 코드만 봐서는 관련 로직을 파악하기 어려울 수 있다는 단점도 가집니다.

 


Spring Data JDBC vs Spring Data JPA: 올바른 도구 선택

Spring 생태계에서 관계형 데이터 접근을 위한 두 가지 주요 선택지인 Spring Data JDBC와 Spring Data JPA는 근본적인 철학과 기능 범위에서 차이가 있습니다.

 

핵심 차이점: ORM vs 간결한 매퍼

 

  • Spring Data JPA: JPA(Java Persistence API) 명세를 구현한 ORM(Object-Relational Mapper) 프레임워크(주로 Hibernate 사용)입니다. 엔티티 관리, 1차/2차 캐시, 지연 로딩(Lazy Loading), 변경 감지(Dirty Checking), JPQL/Criteria API 등 풍부한 기능을 제공하여 개발 생산성을 높여줍니다.  
     
  • Spring Data JDBC: ORM이 아닙니다. 애그리거트 중심의 단순한 객체 매퍼로, ORM의 복잡한 기능들을 제공하지 않는 대신 , SQL에 대한 직접적인 제어와 예측 가능한 동작을 강조합니다.

 

주요 차이점 비교표

기능 Spring Data JDBC 3.4.4 Spring Data JPA (Hibernate) 중요성
패러다임 간결한 객체 매퍼 (애그리거트 중심) 완전한 ORM (엔티티 중심) 복잡성, 추상화 수준, 제공 기능 범위 결정
핵심 추상화 애그리거트 루트 (DDD) 엔티티 데이터 모델링 및 관계 처리 방식에 영향
캐싱 내장 캐시 없음 1차 캐시(필수), 2차 캐시(선택) 성능 영향 (예측 가능성 vs 잠재적 속도 향상), 복잡성 증가 가능성
지연 로딩 없음 지원 (설정 가능) 성능 (N+1 문제 가능성 vs 필요한 데이터만 로딩), 복잡성 증가 가능성
쓰기 지연 없음 지원 (세션 플러시 시 변경 사항 반영) SQL 업데이트 실행 시점 및 방식, 트랜잭션 동작 방식 차이
변경 감지 없음 지원 (자동 변경 감지) 단순성 vs 자동 업데이트 편의성. JDBC는 명시적 save 호출 필요
쿼리 언어 SQL (@Query), 파생 쿼리 JPQL, Criteria API, Native SQL, 파생 쿼리 이식성(JPQL/Criteria) vs DB 특정 기능 활용(SQL) 용이성
스키마 관리 없음 (Flyway/Liquibase 등 외부 도구 필요) 지원 (예: hibernate.hbm2ddl.auto) 편의성 vs 스키마 변경에 대한 명시적 제어
복잡성 낮음 높음 학습 곡선, 프레임워크 내부 동작의 "마법" 같은 요소 존재 가능성
제어권 더 직접적인 SQL 제어 더 높은 추상화 수준 투명성 vs 프레임워크가 세부 사항 처리
성능 예측 가능성 높음, 수동 최적화 필요 가능성 캐싱 활용 시 고성능 가능, 부주의 시 N+1 등 이슈 발생 위험 사용 사례 및 구성에 따라 크게 달라짐

 

Spring Data JDBC 선택 시점

다음과 같은 상황에서 Spring Data JDBC가 좋은 선택이 될 수 있습니다.

  • ORM의 복잡성을 피하고 단순함을 선호할 때.  
     
  • 도메인 모델에 명확한 애그리거트 경계가 존재하고 DDD 원칙을 따르고자 할 때. Spring Data JDBC의 핵심 추상화인 애그리거트는 DDD 패턴과 직접적으로 일치하여, DDD를 명시적으로 따르는 프로젝트에 개념적으로 더 잘 맞을 수 있습니다.  
     
  • 실행되는 SQL에 대한 직접적인 제어가 중요할 때.  
     
  • 캐싱이나 지연 로딩으로 인한 부작용 없이 예측 가능한 성능 특성을 선호할 때 (수동 최적화가 필요할 수 있음).
  • 레거시 데이터베이스 스키마와 작업하거나 ORM 매핑이 복잡한 경우.
  • 팀이 프레임워크의 암묵적인 동작보다는 명시적인 작업을 선호할 때.

 

Spring Data JPA 선택 시점

반면, 다음과 같은 경우에는 Spring Data JPA가 더 적합할 수 있습니다.

  • 복잡한 엔티티 그래프를 자주 탐색해야 하는 경우.
  • 읽기 작업이 많은 시나리오에서 내장 캐시 및 지연 로딩을 통한 성능 향상이 필요한 경우.
  • 초기 개발 단계에서의 자동 스키마 생성이나 자동 변경 감지 기능이 개발 속도에 도움이 되는 경우.
  • JPQL 또는 Criteria API를 통한 데이터베이스 이식성이 중요한 요구사항인 경우.
  • 팀이 이미 JPA/Hibernate에 익숙하고 관련 경험이 풍부한 경우.

궁극적으로 Spring Data JDBC와 JPA 사이의 선택은 원하는 추상화 수준과 제어 수준 사이의 트레이드오프입니다. JDBC는 낮은 추상화 수준에서 더 많은 제어권과 예측 가능성을 제공하는 반면, JPA는 높은 추상화 수준에서 다양한 편의 기능을 제공하지만 복잡성이 증가하고 내부 동작을 이해해야 하는 부담이 따릅니다.

 


결론: Spring Data JDBC로 단순성 수용하기

 

Spring Data JDBC 3.4.4는 Spring 생태계 내에서 관계형 데이터베이스와 상호작용하는 강력하면서도 간결한 방법을 제공합니다. ORM의 복잡성을 제거하고 애그리거트라는 명확한 개념에 집중함으로써, 개발자는 실행되는 SQL에 대한 더 나은 제어권과 예측 가능한 동작을 확보할 수 있습니다.

 

특히 도메인 주도 설계를 적용하거나, 단순성을 중시하거나, SQL에 대한 직접적인 제어가 필요한 프로젝트에서 Spring Data JDBC는 Spring Data JPA의 훌륭한 대안이 될 수 있습니다. 설정의 용이성(특히 Spring Boot 환경에서), 명시적인 관계 매핑, 사용자 정의 쿼리 지원 등은 개발자가 데이터 접근 계층을 효과적으로 구축하는 데 도움을 줍니다.

 

물론 캐싱, 지연 로딩과 같은 고급 ORM 기능이 필요한 경우에는 여전히 Spring Data JPA가 더 적합할 수 있습니다. 하지만 프로젝트의 요구사항과 팀의 선호도에 따라 Spring Data JDBC 3.4.4가 제공하는 단순성과 제어의 균형이 더 매력적인 선택지가 될 수 있음을 기억해야 합니다. Spring Data 프로젝트는 지속적으로 발전하고 있으며 , Spring Data JDBC는 현대적인 애플리케이션 개발에서 중요한 역할을 계속 수행할 것입니다.

 

 

반응형

'Spring' 카테고리의 다른 글

[Spring] Spring Data - MongoDB  (0) 2025.04.18
[Spring] Spring Data - JPA  (0) 2025.04.18
[Spring] Annotation 기반 Bean 생명주기  (0) 2025.04.17