Spring

[Spring] Spring Data - JPA

kahnco 2025. 4. 18. 12:48
반응형

Spring Framework 생태계에서 데이터 접근 계층을 개발하는 것은 종종 반복적이고 상용구 코드가 많이 필요한 작업이었습니다. 하지만 Spring Data JPA는 이러한 과정을 혁신적으로 단순화하여 개발자가 비즈니스 로직에 더 집중할 수 있도록 돕습니다. 이 글에서는 Spring Data JPA 3.4.4 버전을 기준으로, 프로젝트 설정부터 엔티티 및 관계 매핑, CRUD 작업, 다양한 쿼리 방법, 그리고 페이징, 프로젝션, 감사, 트랜잭션 관리와 같은 고급 기능까지 포괄적으로 살펴보겠습니다.


Spring Data JPA 소개: 목표와 핵심 개념

Spring Data JPA는 Jakarta Persistence API (JPA) 사양 기반의 리포지토리를 쉽게 구현할 수 있도록 지원하는 Spring Data 프로젝트의 하위 모듈입니다. JPA는 Java 애플리케이션에서 관계형 데이터를 관리하기 위한 표준 명세이며, Hibernate, EclipseLink 등이 대표적인 구현체입니다. Spring Data JPA는 이러한 JPA 구현체 위에 추상화 계층을 제공하여 데이터 접근 로직 개발을 간소화하는 것을 목표로 합니다.

 

주요 목표:

  • 상용구 코드 감소: 반복적인 CRUD 코드, EntityManager 관리, 트랜잭션 처리 등의 상용구 코드를 대폭 줄여줍니다.  
  • 일관된 프로그래밍 모델: 다양한 JPA 구현체나 데이터 저장소 기술에 대해 일관된 방식으로 데이터 접근 계층을 개발할 수 있도록 지원합니다.  
  • 단순화된 데이터 접근: 리포지토리 인터페이스 정의만으로 구현체를 자동 생성하여 기본적인 데이터 조작을 쉽게 만듭니다.

 

핵심 개념:

  • ORM (Object-Relational Mapping): 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스의 테이블 간의 매핑을 관리하는 기술입니다. JPA는 ORM을 위한 표준 명세를 제공합니다.  
  • EntityManager: JPA의 핵심 인터페이스로, 엔티티의 영속성(Persistence)을 관리합니다. 엔티티의 저장, 조회, 수정, 삭제 등의 작업을 수행합니다.  
  • 영속성 컨텍스트 (Persistence Context): 엔티티 객체들을 관리하는 환경입니다. EntityManager를 통해 접근하며, 엔티티의 상태 변화를 추적하고 데이터베이스와 동기화하는 역할을 합니다. 1차 캐시, 쓰기 지연, 변경 감지(Dirty Checking), 지연 로딩 등의 기능을 제공합니다.
  • JPA와 Spring Data JPA의 관계: Spring Data JPA는 JPA 명세를 기반으로 만들어졌습니다. 개발자는 Spring Data JPA가 제공하는 리포지토리 인터페이스를 사용하여 데이터 접근 로직을 작성하고, Spring Data JPA는 내부적으로 JPA의 EntityManager를 사용하여 실제 데이터베이스 작업을 처리합니다. 즉, Spring Data JPA는 JPA를 더 쉽고 편리하게 사용하기 위한 추상화 계층입니다.

 

주요 인터페이스:

Spring Data는 여러 리포지토리 인터페이스를 제공하며, 필요에 따라 선택하여 확장할 수 있습니다.  

  • CrudRepository<T, ID>: 가장 기본적인 인터페이스로, 엔티티에 대한 기본적인 CRUD(Create, Read, Update, Delete) 연산을 위한 메서드(save, findById, findAll, count, delete, existsById 등)를 제공합니다.  
  • PagingAndSortingRepository<T, ID>: CrudRepository를 확장하며, 페이징(findAll(Pageable)) 및 정렬(findAll(Sort)) 기능을 위한 메서드를 추가로 제공합니다.  
  • JpaRepository<T, ID>: PagingAndSortingRepository를 확장하며, JPA에 특화된 기능(예: flush(), saveAndFlush(), deleteAllInBatch(), getReferenceById())을 추가로 제공합니다. 대부분의 JPA 기반 프로젝트에서는 JpaRepository를 사용하는 것이 권장됩니다.
     

프로젝트 설정: 의존성 및 구성

Spring Data JPA를 사용하기 위한 기본적인 프로젝트 설정 방법을 알아봅니다. Spring Boot를 사용하면 많은 부분이 자동 구성되어 설정이 매우 간편해집니다.

 

의존성 추가 (Maven/Gradle)

Spring Boot 프로젝트에서 Spring Data JPA를 사용하려면 spring-boot-starter-data-jpa 의존성을 추가해야 합니다. 이 스타터는 Spring Data JPA, JPA API(Jakarta Persistence), 기본 JPA 구현체인 Hibernate, JDBC, 트랜잭션 관리 등 필요한 라이브러리들을 포함하고 있습니다.

 

Spring Boot 3.4.4 버전에 해당하는 spring-boot-starter-data-jpa 3.4.4 버전을 사용합니다.

 

Maven (pom.xml):

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

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

 

Gradle (build.gradle - Groovy DSL):

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

repositories {
    mavenCentral()
}

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

    // 사용할 데이터베이스 드라이버 추가 (예: MySQL)
    runtimeOnly 'com.mysql:mysql-connector-j'

    // H2 데이터베이스 사용 시
    // runtimeOnly 'com.h2database:h2'
}

 

Spring Boot의 의존성 관리 기능을 사용하면 spring-boot-starter-data-jpa 의 버전을 명시하지 않아도 spring-boot-starter-parent 또는 spring-boot-dependencies BOM(Bill of Materials)에 정의된 호환 버전을 자동으로 가져옵니다.

 

데이터베이스 및 JPA 설정 (application.properties / application.yml)

Spring Boot는 application.properties 또는 application.yml 파일을 통해 데이터 소스 및 JPA 설정을 관리합니다.

 

데이터 소스 설정 (spring.datasource.*)

데이터베이스 연결을 위한 기본 정보(URL, 사용자 이름, 비밀번호, 드라이버 클래스)를 설정합니다.

# Example for MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/mydatabase?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=dbuser
spring.datasource.password=dbpass
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Example for H2 (In-Memory)
# spring.datasource.url=jdbc:h2:mem:testdb
# spring.datasource.driver-class-name=org.h2.Driver
# spring.datasource.username=sa
# spring.datasource.password=

 

Spring Boot는 클래스패스에 H2, HSQLDB, Derby와 같은 임베디드 데이터베이스 드라이버가 있고 명시적인 데이터 소스 설정이 없는 경우, 자동으로 인메모리 데이터베이스를 구성합니다. 또한, Spring Boot 2.x 버전부터는 HikariCP가 기본 커넥션 풀로 사용되며, spring.datasource.hikari.* 속성을 통해 커넥션 풀의 상세 설정을 조정할 수 있습니다.

 

JPA 프로바이더 설정 (spring.jpa.*)

JPA 동작 방식을 제어하는 속성들을 설정합니다. Spring Boot 는 Hibernate 를 기본 JPA 프로바이더로 사용합니다.

# Hibernate가 실행하는 SQL 문을 로그로 출력 (개발 시 유용)
# 콘솔 출력 대신 로깅 프레임워크를 통해 제어하려면 logging.level.org.hibernate.SQL=DEBUG 사용 권장
spring.jpa.show-sql=true

# Hibernate의 DDL(Data Definition Language) 자동 생성 전략 설정
# none: DDL 생성 안 함 (운영 환경 권장)
# validate: 엔티티와 테이블 스키마 검증
# update: 변경된 부분만 스키마 업데이트 (개발 시 편리하나 주의 필요)
# create: 애플리케이션 시작 시 스키마 삭제 후 재생성
# create-drop: 시작 시 생성, 종료 시 삭제 (테스트 또는 개발 초기 유용)
# 임베디드 DB가 아니고 스키마 관리 도구(Flyway, Liquibase)가 없으면 기본값은 none
spring.jpa.hibernate.ddl-auto=update

# 사용할 Hibernate Dialect 지정 (대부분 자동 감지됨)
# spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
# 또는 spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

# 기타 Hibernate 네이티브 속성 설정 (예: SQL 포맷팅)
spring.jpa.properties.hibernate.format_sql=true
# spring.jpa.properties.hibernate.default_schema=myschema

 

Spring Boot의 자동 구성(Auto-configuration) 덕분에, 대부분의 경우 개발자는 데이터 소스 연결 정보만 application.properties에 제공하면 됩니다. EntityManagerFactory, DataSource, TransactionManager와 같은 핵심 빈들은 Spring Boot가 자동으로 구성해주므로, 특별한 커스터마이징이 필요하지 않다면 수동으로 빈을 정의할 필요가 없습니다. 이는 Spring Boot가 설정의 복잡성을 크게 줄여주는 강력한 기능입니다.

 


데이터 모델링: 엔티티와 관계

JPA 를 사용하여 데이터베이스 테이블과 매핑될 Java 객체, 즉 엔티티(Entity)를 정의하고 엔티티 간의 관계를 설정하는 방법을 알아봅니다.

 

JPA 엔티티 정의

엔티티는 데이터베이스 테이블의 한 행(row)에 해당하며, 일반적인 POJO(Plain Old Java Object) 클래스에 JPA 어노테이션을 추가하여 정의합니다.

 

  • @Entity: 해당 클래스가 JPA 엔티티임을 나타냅니다. 엔티티 이름은 기본적으로 클래스 이름과 동일하지만 name 속성으로 변경할 수 있습니다. JPA 구현체가 프록시 객체를 생성할 수 있도록 final 클래스로 선언해서는 안 되며, 인자 없는(no-arg) 기본 생성자가 필요합니다.
  • @Table: 엔티티와 매핑될 데이터베이스 테이블의 이름(name), 스키마(schema), 유니크 제약조건(uniqueConstraints) 등을 지정합니다. 생략하면 엔티티 이름(클래스 이름)을 테이블 이름으로 사용합니다.
  • @Id: 엔티티의 기본 키(Primary Key) 필드를 지정합니다. 모든 엔티티는 @Id 필드를 가져야 합니다.
  • @GeneratedValue: 기본 키 값을 자동으로 생성하는 전략을 지정합니다.
    • GenerationType.IDENTITY: 데이터베이스의 자동 증가(auto-increment) 컬럼을 사용합니다 (MySQL, PostgreSQL, SQL Server 등 지원).
    • GenerationType.SEQUENCE: 데이터베이스 시퀀스 객체를 사용하여 키를 생성합니다 (Oracle, PostgreSQL 등 지원). @SequenceGenerator와 함께 사용하여 시퀀스 이름, 할당 크기 등을 지정할 수 있습니다. Hibernate와 함께 사용할 때 JDBC 배치 처리에 유리하여 성능상 이점이 있을 수 있습니다.
    • GenerationType.TABLE: 키 생성 전용 테이블을 사용합니다. 모든 데이터베이스에서 동작하지만 성능 오버헤드가 있을 수 있어 권장되지 않습니다.
    • GenerationType.AUTO: JPA 구현체(주로 Hibernate)가 데이터베이스 종류에 따라 IDENTITY, SEQUENCE, TABLE 중 적절한 전략을 자동으로 선택합니다.
    • GenerationType.UUID: UUID (Universally Unique Identifier)를 생성합니다. 
     
  • @Column: 엔티티 필드와 매핑될 데이터베이스 컬럼의 상세 정보(이름 name, 길이 length, null 허용 여부 nullable, 유니크 여부 unique, 정밀도 precision, 스케일 scale 등)를 지정합니다. 생략하면 필드 이름을 컬럼 이름으로 사용합니다.
  • @Transient: 해당 필드를 데이터베이스 컬럼에 매핑하지 않도록 지정합니다. 즉, 영속성 컨텍스트에서 관리되지 않습니다.
  • 기본 데이터 타입 매핑: String, Integer, Long, Double, Boolean 등 표준 Java 타입과 java.time 패키지의 날짜/시간 타입(LocalDate, LocalDateTime, Instant )은 대부분 자동으로 적절한 데이터베이스 컬럼 타입으로 매핑됩니다. 레거시 java.util.Date 타입에는 @Temporal 어노테이션을 사용하여 DATE, TIME, TIMESTAMP 중 어떤 타입으로 매핑할지 지정할 수 있습니다. Enum 타입은 @Enumerated를 사용하여 ORDINAL(순서, 기본값) 또는 STRING(이름)으로 저장 방식을 지정할 수 있습니다. 큰 데이터(CLOB, BLOB)는 @Lob 어노테이션을 사용합니다.

 

엔티티 예시:

import jakarta.persistence.*;
import java.time.LocalDate;
import java.math.BigDecimal; // 가격에 BigDecimal 사용 권장

@Entity // 이 클래스가 JPA 엔티티임을 선언
@Table(name = "products") // 데이터베이스 테이블 이름을 'products'로 지정
public class Product {

    @Id // 이 필드가 기본 키임을 나타냄
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq") // 시퀀스 전략 사용
    @SequenceGenerator(name = "product_seq", sequenceName = "product_id_seq", allocationSize = 1) // 시퀀스 생성기 설정
    private Long id;

    @Column(name = "product_name", nullable = false, length = 100) // 컬럼명, not null, 길이 100 지정
    private String name;

    @Column(precision = 10, scale = 2) // 총 10자리, 소수점 이하 2자리 정밀도
    private BigDecimal price; // 가격은 부동소수점 오류 방지를 위해 BigDecimal 권장

    private LocalDate manufacturingDate; // LocalDate 타입은 자동으로 매핑됨

    @Transient // 이 필드는 데이터베이스에 저장되지 않음
    private String derivedInfo;

    // JPA는 기본 생성자를 요구함 (protected 권장)
    protected Product() {}

    // 인스턴스 생성을 위한 생성자
    public Product(String name, BigDecimal price, LocalDate manufacturingDate) {
        this.name = name;
        this.price = price;
        this.manufacturingDate = manufacturingDate;
    }

    // Getters and setters...
    // (Lombok @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor 사용 가능)

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public LocalDate getManufacturingDate() { return manufacturingDate; }
    public void setManufacturingDate(LocalDate manufacturingDate) { this.manufacturingDate = manufacturingDate; }
    public String getDerivedInfo() { return derivedInfo; }
    public void setDerivedInfo(String derivedInfo) { this.derivedInfo = derivedInfo; }
}

 

 

관계 매핑

엔티티 간의 연관 관계(Association)를 매핑하는 방법을 알아봅니다.

 

  • @OneToOne (일대일): 하나의 엔티티가 다른 엔티티 하나와 관계를 맺습니다 (예: User-Address). 매핑 전략으로는 외래 키(Foreign Key), 공유 기본 키(Shared Primary Key), 조인 테이블(Join Table) 방식이 있습니다.
    • 외래 키 방식: 한 테이블이 다른 테이블의 기본 키를 참조하는 외래 키 컬럼을 가집니다. 가장 일반적인 방식입니다. 외래 키를 가진 쪽(Owning Side)에 @JoinColumn을 사용하여 외래 키 컬럼을 명시하고, 반대쪽(Non-owning Side)에는 @OneToOne(mappedBy = "...")을 사용하여 양방향 관계를 설정합니다.
    • 공유 기본 키 방식: 두 엔티티가 동일한 기본 키 값을 공유합니다. 한 엔티티의 기본 키가 다른 엔티티의 기본 키이자 외래 키 역할을 합니다. @MapsId와 @PrimaryKeyJoinColumn 어노테이션을 사용합니다.
    • 조인 테이블 방식: 중간의 조인 테이블을 사용하여 관계를 관리합니다. @JoinTable 어노테이션을 사용하여 조인 테이블과 관련 컬럼들을 명시합니다.
  • @OneToMany / @ManyToOne (일대다 / 다대일): 가장 흔한 관계 유형입니다 (예: Department-Employees). 단방향 또는 양방향으로 매핑할 수 있습니다.
    • 양방향 매핑 권장 사항: @ManyToOne 쪽(자식, '다' 쪽)이 관계의 주인(Owner)이 되어 외래 키 컬럼을 관리하도록 설정하는 것이 가장 효율적입니다. @ManyToOne 어노테이션과 함께 @JoinColumn을 사용하여 외래 키 컬럼을 명시합니다. @OneToMany 쪽(부모, '일' 쪽)에는 mappedBy 속성을 사용하여 관계의 주인이 아님을 명시합니다.
    • 양방향 관계 동기화: 양방향 관계에서는 양쪽의 상태를 일관성 있게 유지하는 것이 중요합니다. 부모 엔티티에서 자식 엔티티를 추가하거나 제거할 때, 자식 엔티티의 부모 참조도 함께 설정/해제하는 유틸리티 메서드(예: addEmployee, removeEmployee)를 부모 엔티티에 구현하는 것이 좋습니다.
  • @ManyToMany (다대다): 양쪽 엔티티 모두 여러 개의 상대 엔티티와 관계를 맺습니다 (예: Student-Course). 일반적으로 중간에 관계 테이블(Join Table)이 필요하며, @JoinTable 어노테이션을 사용하여 매핑합니다. 만약 관계 테이블에 추가적인 컬럼(예: 등록일)이 있다면, 관계 테이블 자체를 별도의 엔티티로 만들고 두 개의 @ManyToOne 관계로 매핑하는 것이 더 유연할 수 있습니다.

 

Fetch Types (데이터 로딩 전략):

연관된 엔티티를 언제 데이터베이스에서 로드할지 결정합니다.

  • FetchType.LAZY (지연 로딩): 연관된 엔티티는 실제로 접근(getter 호출 등) 될 때 데이터베이스에서 로드됩니다. 컬렉션(@OneToMany, @ManyToMany)의 기본값입니다.
  • FetchType.EAGER (즉시 로딩): 주 엔티티를 로드할 때 연관된 엔티티도 함께 데이터베이스에서 로드됩니다. 단일 연관 관계(@ManyToONe, @OneToOne)의 기본값 입니다.

즉시 로딩(Eager fetching)은 연관된 엔티티가 많거나 깊은 관계 그래프를 가질 경우, 필요하지 않은 데이터까지 모두 조회하여 성능 문제를 일으키는 N+1 쿼리 문제 등을 유발하기 쉽습니다. 따라서 지연 로딩(FetchType.LAZY)을 기본 전략으로 사용하는 것이 일반적으로 권장됩니다. 필요한 경우, JPQL의 JOIN FETCH나 JPA Entity Graph 기능을 사용하여 특정 쿼리에서 필요한 연관 엔티티만 명시적으로 함께 로드하는 것이 성능 관리에 유리합니다.

 

Cascade Types (영속성 전이):

부모 엔티티에 대한 영속성 작업(저장, 수정, 삭제 등)이 자식 엔티티에게 어떻게 전파될지를 정의합니다.

 

  • CascadeType.ALL: 모든 영속성 작업(PERSIST, MERGE, REMOVE, REFRESH, DETACH)을 전파합니다.
  • CascadeType.PERSIST: 부모 엔티티 저장 시 자식 엔티티도 함께 저장됩니다.
  • CascadeType.MERGE: 부모 엔티티 병합 시 자식 엔티티도 함께 병합됩니다.
  • CascadeType.REMOVE: 부모 엔티티 삭제 시 자식 엔티티도 함께 삭제됩니다.
  • orphanRemoval = true: @OneToMany 관계에서 사용되며, 부모 엔티티의 컬렉션에서 자식 엔티티가 제거되면 해당 자식 엔티티를 데이터베이스에서도 삭제합니다. CascadeType.REMOVE와 유사하지만, 참조가 끊어졌을 때 동작한다는 차이가 있습니다.

Cascade 옵션은 편리하지만, 특히 REMOVE나 ALL은 의도치 않은 데이터 삭제를 유발할 수 있으므로 신중하게 사용해야 합니다.

 

양방향 @OneToMany / @ManyToOne 예시:

// 부모 엔티티 (예: Department)
@Entity
public class Department {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // 'mappedBy'는 Employee 엔티티의 'department' 필드를 가리킴
    // cascade = CascadeType.ALL: Department 저장/수정/삭제 시 Employee도 영향 받음
    // orphanRemoval = true: Department의 employees 컬렉션에서 Employee 제거 시 DB에서도 삭제
    // fetch = FetchType.LAZY: employees는 필요할 때 로드 (권장)
    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private List<Employee> employees = new ArrayList<>();

    // 양방향 관계 동기화를 위한 유틸리티 메서드
    public void addEmployee(Employee employee) {
        employees.add(employee);
        employee.setDepartment(this); // Employee 쪽의 참조도 설정
    }

    public void removeEmployee(Employee employee) {
        employees.remove(employee);
        employee.setDepartment(null); // Employee 쪽의 참조도 제거
    }

    // 기본 생성자, 다른 getter/setter...
    protected Department() {}
    public Department(String name) { this.name = name; }
    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Employee> getEmployees() { return employees; }
}

// 자식 엔티티 (예: Employee)
@Entity
public class Employee {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // 관계의 주인 (Owning side)
    // fetch = FetchType.LAZY: Department는 필요할 때 로드 (ManyToOne 기본은 EAGER이나 LAZY 권장)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id") // Employee 테이블에 생성될 외래 키 컬럼명
    private Department department;

    // 기본 생성자, getter/setter...
    protected Employee() {}
    public Employee(String name) { this.name = name; }
    public Long getId() { return id; }
    public String getName() { return name; }
    public Department getDepartment() { return department; }
    public void setDepartment(Department department) { this.department = department; }

    // 양방향 관계 및 컬렉션 사용 시 equals() 및 hashCode() 재정의 권장 (ID 기반) [71]
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Employee )) return false;
        Employee employee = (Employee) o;
        // 아직 영속화되지 않은 엔티티는 ID가 null일 수 있으므로 주의
        return id!= null && id.equals(employee.getId());
    }

    @Override
    public int hashCode() {
        // ID 기반 해시코드 또는 클래스 기반 기본 해시코드
        return getClass().hashCode();
    }
}

 

 


리포지토리를 이용한 데이터 접근: CRUD 작업

Spring Data JPA의 핵심은 리포지토리 인터페이스를 통해 데이터 접근 로직을 추상화하는 것입니다. 개발자는 인터페이스만 정의하면, Spring이 런타임에 해당 인터페이스의 구현체를 자동으로 생성해줍니다.

 

리포지토리 인터페이스 정의

데이터 접근을 위한 리포지토리 인터페이스는 Spring Data가 제공하는 기본 인터페이스(CrudRepository, PagingAndSortingRepository, JpaRepository 등) 중 하나를 확장하여 정의합니다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
import java.time.LocalDate;

@Repository // Spring 컴포넌트 스캔 대상으로 지정 (선택 사항이지만 권장)
public interface ProductRepository extends JpaRepository<Product, Long> {
    // 여기에 커스텀 쿼리 메서드를 추가할 수 있습니다.
    // 예: 이름으로 상품 찾기 (대소문자 무시)
    List<Product> findByNameIgnoreCase(String name);

    // 예: 특정 제조일 이후 상품 찾기
    List<Product> findByManufacturingDateAfter(LocalDate date);
}

 

위 예시에서 ProductRepository는 JpaRepository를 확장하여 Product 엔티티(ID 타입은 Long)에 대한 모든 기본적인 CRUD, 페이징, 정렬 및 JPA 관련 메서드를 상속받습니다. @Repository 어노테이션은 이 인터페이스가 데이터 접근 계층의 컴포넌트임을 명시적으로 나타내며, 예외 변환 등의 추가적인 기능을 활성화할 수 있습니다.

 

기본 CRUD 작업 수행

정의된 리포지토리 인터페이스는 서비스 계층이나 테스트 코드에서 @Autowired 등을 통해 주입받아 사용할 수 있습니다.

  • 생성(Create) 및 수정(Update): save()
    • save() 메서드는 새로운 엔티티를 데이터베이스에 저장하거나 기존 엔티티의 상태를 업데이트합니다.
@Autowired
ProductRepository repository;

// 새로운 Product 생성 및 저장
Product newProduct = new Product("Smart TV", BigDecimal.valueOf(1500.00), LocalDate.now());
Product savedProduct = repository.save(newProduct); // 저장된 엔티티 반환 (ID 포함)
System.out.println("Saved Product ID: " + savedProduct.getId());

// 기존 Product 수정
Optional<Product> optionalProduct = repository.findById(savedProduct.getId());
if (optionalProduct.isPresent()) {
    Product existingProduct = optionalProduct.get();
    existingProduct.setPrice(BigDecimal.valueOf(1450.00));
    repository.save(existingProduct); // ID가 존재하므로 UPDATE 실행
    System.out.println("Product updated.");
}

 

 

Spring Data JPA의 save() 메서드는 내부적으로 엔티티의 ID 존재 여부(entityInformation.isNew(entity))를 확인하여 EntityManager.persist() (ID가 없을 때, 새로운 엔티티) 또는 EntityManager.merge() (ID가 있을 때, 기존 엔티티 또는 detached 엔티티)를 호출하는 "upsert" 방식으로 동작합니다. merge()는 데이터베이스에 해당 ID의 레코드가 있는지 확인하기 위해 SELECT 쿼리를 먼저 실행할 수 있으며, 이는 특히 ID를 직접 할당하는 경우나 대량 작업 시 성능에 영향을 줄 수 있습니다. 또한, 트랜잭션 내에서 이미 영속 상태(managed)인 엔티티의 필드를 변경한 경우, 명시적으로 save()를 호출하지 않아도 트랜잭션 커밋 시점에 Hibernate의 변경 감지(dirty checking) 메커니즘에 의해 자동으로 UPDATE 쿼리가 실행됩니다. 따라서 이러한 경우 save() 호출은 불필요할 수 있습니다.

 

  • 조회(Read): findById(), findAll()
    • findById()는 ID를 기준으로 특정 엔티티를 조회하며, Optional<T>을 반환하여 결과가 없을 경우를 안전하게 처리합니다. findAll()은 해당 타입의 모든 엔티티를 조회합니다 (JpaRepository는 List<T>를 반환).
// ID로 조회
Long productIdToFind = 1L;
Optional<Product> foundProduct = repository.findById(productIdToFind);
foundProduct.ifPresentOrElse(
    product -> System.out.println("Found Product: " + product.getName()),
    () -> System.out.println("Product with ID " + productIdToFind + " not found.")
);

// 전체 조회 (주의: 테이블 크기가 클 경우 성능 문제 발생 가능)
List<Product> allProducts = repository.findAll();
System.out.println("Total products found: " + allProducts.size());
allProducts.forEach(p -> System.out.println(" - " + p.getName()));

 

 

테이블에 데이터가 많을 경우 findAll() 은 성능 문제를 야기할 수 있으므로, 페이징 처리를 사용하는 것이 좋습니다.

 

  • 삭제(Delete): deleteById(), delete()
    • deleteById()는 ID를 기준으로 엔티티를 삭제하고, delete()는 엔티티 인스턴스를 받아 삭제합니다.
// ID로 삭제
Long productIdToDelete = 1L;
if (repository.existsById(productIdToDelete)) { // 삭제 전 존재 여부 확인 가능
    repository.deleteById(productIdToDelete);
    System.out.println("Product with ID " + productIdToDelete + " deleted.");
}

// 엔티티 인스턴스로 삭제
Optional<Product> productToDeleteOpt = repository.findById(2L);
productToDeleteOpt.ifPresent(product -> {
    repository.delete(product);
    System.out.println("Product '" + product.getName() + "' deleted.");
});

// 전체 삭제 (주의!)
// repository.deleteAll();

 

복잡한 연관 관계나 Cascade 설정이 있는 경우, 단순 delete() 호출이 예상대로 동작하지 않을 수 있습니다. 예를 들어, 관계의 무결성 제약 조건 위반이나 잘못된 Cascade 설정으로 인해 삭제가 실패하거나, 양방향 관계에서 반대쪽 참조를 제거하지 않아 문제가 발생할 수 있습니다. 이런 경우에는 관계의 주인을 명확히 하고 영속성 전이 설정을 확인하거나, @Modifying 어노테이션을 사용한 커스텀 삭제 쿼리를 고려해야 할 수 있습니다.

 


효과적인 데이터 쿼리 방법

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

 

파생 쿼리 (Derived Queries)

메서드 이름을 분석하여 JPQL 쿼리를 자동으로 생성하는 기능입니다. find...By, read...By, get...By, query...By, count...By 등의 접두사로 시작하고, 엔티티 속성 이름과 조건 키워드를 조합하여 메서드 이름을 작성합니다.  

  • 구조: <접두사>By<속성명><조건키워드>[<논리연산자><속성명><조건키워드>...]  
  • 속성 표현식: 점(.)이나 언더스코어(_)를 사용하여 연관된 엔티티의 속성에 접근할 수 있습니다 (예: findByAddressCity(...) 또는 findByAddress_City(...)). Spring Data는 이름 충돌이 발생할 경우 해결 규칙을 따릅니다.  
  • 주요 키워드:
    • 기본 조건: And, Or, Is, Equals, Not
    • 비교: Between, LessThan, GreaterThan, LessThanEqual, GreaterThanEqual, After, Before
    • Null 확인: IsNull, IsNotNull (또는 Null, NotNull)
    • 패턴 매칭: Like, NotLike, StartingWith, EndingWith, Containing
    • 컬렉션 조건: In, NotIn
    • Boolean: True, False
    • 수식어: IgnoreCase (문자열 비교 시 대소문자 무시), OrderBy...Asc/Desc (정렬), Distinct (중복 제거)
    • 결과 제한: findFirst..., findTop...

 

파생 쿼리 키워드 예시:

키워드 샘플 메서드 JPQL 조각 (예시)
And findByLastnameAndFirstname ... where x.lastname =?1 and x.firstname =?2
Or findByLastnameOrFirstname ... where x.lastname =?1 or x.firstname =?2
Is, Equals findByFirstname, findByFirstnameIs ... where x.firstname =?1 (null이면 IS NULL)
Between findByStartDateBetween ... where x.startDate between?1 and?2
LessThan findByAgeLessThan ... where x.age <?1
GreaterThanEqual findByAgeGreaterThanEqual ... where x.age >=?1
After findByStartDateAfter ... where x.startDate >?1
IsNull findByAgeIsNull ... where x.age is null
Like findByFirstnameLike ... where x.firstname like?1
StartingWith findByFirstnameStartingWith ... where x.firstname like?1 (?1 뒤에 % 추가)
EndingWith findByFirstnameEndingWith ... where x.firstname like?1 (?1 앞에 % 추가)
Containing findByFirstnameContaining ... where x.firstname like?1 (?1 양쪽에 % 추가)
OrderBy...Desc findByAgeOrderByLastnameDesc ... where x.age =?1 order by x.lastname desc
Not findByLastnameNot ... where x.lastname <>?1
In findByAgeIn(Collection<Age> ages) ... where x.age in?1
True findByActiveTrue() ... where x.active = true
IgnoreCase findByFirstnameIgnoreCase ... where UPPER(x.firstname) = UPPER(?1)
Distinct findDistinctByLastnameAndFirstname select distinct … where x.lastname =?1 and...
 
예시:
public interface ProductRepository extends JpaRepository<Product, Long> {
    // 이름으로 상품 찾기 (대소문자 무시)
    List<Product> findByNameIgnoreCase(String name);

    // 최소, 최대 가격 사이의 상품을 이름 내림차순으로 정렬하여 찾기
    List<Product> findByPriceBetweenOrderByNameDesc(BigDecimal minPrice, BigDecimal maxPrice);

    // 특정 제조일 이후 생산된 상위 5개 상품 찾기
    List<Product> findTop5ByManufacturingDateAfter(LocalDate date);

    // 이름에 특정 문자열을 포함하는 상품 수 세기
    long countByNameContaining(String searchTerm);
}

 


JPQL 을 이용한 커스텀 쿼리 (@Query)

파생 쿼리만으로 표현하기 어렵거나 복잡한 쿼리는 JPQL(Java Persistence Query Language)을 사용하여 직접 작성할 수 있습니다. JPQL은 SQL과 유사하지만 테이블과 컬럼 대신 엔티티와 속성을 대상으로 쿼리합니다.  

  • 사용법: 리포지토리 메서드에 @Query 어노테이션을 추가하고 value 속성에 JPQL 쿼리 문자열을 작성합니다.  
  • 파라미터 바인딩:
    • 위치 기반 파라미터: ?1, ?2 와 같이 1부터 시작하는 인덱스를 사용하며, 메서드 파라미터 순서대로 바인딩됩니다.
    • 이름 기반 파라미터: :paramName 형식을 사용하며, 메서드 파라미터에 @Param("paramName") 어노테이션을 붙여 바인딩합니다. 가독성이 높아 권장됩니다.
// 위치 기반 파라미터 예시
@Query("SELECT p FROM Product p WHERE p.price >?1 AND p.manufacturingDate <?2")
List<Product> findByPriceGreaterThanAndDateBefore(BigDecimal minPrice, LocalDate maxDate);

// 이름 기반 파라미터 예시
@Query("SELECT p FROM Product p WHERE p.name = :name AND p.price < :maxPrice")
List<Product> findByNameAndPriceLessThan(@Param("name") String productName, @Param("maxPrice") BigDecimal priceLimit);
  • 수정 쿼리 (@Modifying): UPDATE 또는 DELETE JPQL 쿼리를 실행하려면 @Modifying 어노테이션을 함께 사용해야 합니다. 이 메서드는 보통 int (영향받은 행 수) 또는 void를 반환하며, 트랜잭션 컨텍스트 내에서 실행되어야 합니다.
@Modifying
@Transactional // 서비스 계층 또는 여기에 @Transactional 추가 필요
@Query("UPDATE Product p SET p.price = p.price * :discountRate WHERE p.manufacturingDate < :cutoffDate")
int applyDiscountForOldProducts(@Param("discountRate") BigDecimal rate, @Param("cutoffDate") LocalDate date);

@Modifying
@Transactional
@Query("DELETE FROM Product p WHERE p.id = :productId")
void deleteProductByIdCustom(@Param("productId") Long id);

 

 

네이티브 SQL 쿼리 (@Query(nativeQuery = true))

데이터베이스 고유의 기능을 사용하거나 JPQL로 표현하기 매우 복잡한 쿼리는 네이티브 SQL을 직접 사용할 수 있습니다.

  • 사용법: @Query 어노테이션에 nativeQuery = true 속성을 추가하고 value 속성에 네이티브 SQL 쿼리 문자열을 작성합니다.  
  • 파라미터 바인딩: JPQL과 동일하게 위치 기반(?1) 또는 이름 기반(:paramName + @Param) 파라미터를 사용할 수 있습니다.
// MySQL의 FIND_IN_SET 함수 사용 예시
@Query(value = "SELECT * FROM products p WHERE FIND_IN_SET(p.id, :ids) > 0", nativeQuery = true)
List<Product> findProductsByIdsNative(@Param("ids") String idList); // idList는 "1,2,3" 형태의 문자열
  • 수정 쿼리 (@Modifying): 네이티브 SQL을 이용한 INSERT, UPDATE, DELETE 문에도 @Modifying 어노테이션을 사용합니다. 
  • 제한 사항:
    • 데이터베이스 종속성: 작성된 SQL은 특정 데이터베이스 벤더에 종속되어 이식성이 떨어집니다.  
    • 페이징: Pageable 파라미터를 사용한 자동 페이징을 위해서는 별도의 countQuery 속성을 통해 전체 개수를 반환하는 네이티브 SQL 카운트 쿼리를 명시적으로 제공해야 합니다.  
    • 동적 정렬: Sort 파라미터를 이용한 동적 정렬 기능은 네이티브 쿼리에서 지원되지 않습니다. 정렬이 필요하면 SQL 쿼리 내에 ORDER BY 절을 직접 포함해야 합니다.

네이티브 쿼리는 강력한 기능을 제공하지만, JPA의 추상화 이점을 활용하지 못하고 특정 데이터베이스에 종속되며 페이징/정렬에 제약이 따릅니다. 따라서 JPQL이나 파생 쿼리로 해결할 수 없는 경우에 한해 신중하게 사용해야 합니다.

 

Criteria API 소개

Criteria API는 JPQL 문자열 대신 Java 코드를 사용하여 프로그래밍 방식으로 쿼리를 구축하는 타입-세이프(type-safe)한 방법입니다. 동적 쿼리(조건이 런타임에 변경되는 쿼리)를 구축하는 데 유용합니다.

 

  • 주요 인터페이스/클래스: CriteriaBuilder, CriteriaQuery, Root, Predicate.  
  • Spring Data JPA 통합: JpaSpecificationExecutor<T> 인터페이스를 리포지토리에 추가하고 Specification<T> 인터페이스를 구현하여 Criteria API 기반의 조건을 정의하고 조합할 수 있습니다.
// 1. Repository에 JpaSpecificationExecutor 확장
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {}

// 2. Specification 정의 (별도 클래스 또는 람다 사용)
public class ProductSpecifications {
    public static Specification<Product> hasName(String name) {
        // root: 쿼리 대상 엔티티(Product)
        // query: 쿼리 자체 (여기서는 사용 안 함)
        // criteriaBuilder: 조건(Predicate) 생성 도구
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.equal(root.get("name"), name); // Product 엔티티의 'name' 속성 사용
    }

    public static Specification<Product> priceGreaterThan(BigDecimal price) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.greaterThan(root.get("price"), price); // 'price' 속성 사용
    }

    public static Specification<Product> manufacturedBefore(LocalDate date) {
         return (root, query, criteriaBuilder) ->
            criteriaBuilder.lessThan(root.get("manufacturingDate"), date); // 'manufacturingDate' 속성 사용
    }
}

// 3. Service에서 Specification 사용
@Service
public class ProductSearchService {
    @Autowired ProductRepository repository;

    public List<Product> findProductsDynamically(String name, BigDecimal minPrice, LocalDate maxDate) {
        Specification<Product> spec = Specification.where(null); // 초기 Specification

        if (name!= null &&!name.isEmpty()) {
            spec = spec.and(ProductSpecifications.hasName(name));
        }
        if (minPrice!= null) {
            spec = spec.and(ProductSpecifications.priceGreaterThan(minPrice));
        }
        if (maxDate!= null) {
            spec = spec.and(ProductSpecifications.manufacturedBefore(maxDate));
        }

        return repository.findAll(spec); // Specification을 사용하여 쿼리 실행
    }
}

  

Criteria API 는 타입 안정성과 동적 쿼리 구성 능력을 제공하지만, 코드가 JPQL 이나 파생 쿼리에 비해 장황해질 수 있습니다.

 


고급 기능 및 모범 사례

Spring Data JPA는 기본적인 CRUD와 쿼리 기능 외에도 다양한 고급 기능을 제공하여 개발 생산성을 높여줍니다.

 

페이징 및 정렬

대용량 데이터를 효율적으로 처리하고 사용자 인터페이스에 표시하기 위해 페이징과 정렬 기능은 필수적입니다.

 

  • 핵심 인터페이스:
    • Pageable: 페이징 정보(페이지 번호, 페이지 크기)와 정렬 정보를 함께 담는 인터페이스입니다.  
    • Sort: 정렬 기준(속성명, 정렬 방향 - 오름차순/내림차순)을 정의하는 인터페이스입니다.  
    • Page<T>: 페이징된 데이터 조각(현재 페이지의 엔티티 목록)과 함께 전체 데이터 개수, 전체 페이지 수 등의 추가 정보를 포함하는 인터페이스입니다. Page 객체를 얻기 위해서는 추가적인 count 쿼리가 실행될 수 있습니다.  
    • Slice<T>: Page<T>와 유사하지만 전체 개수 정보를 포함하지 않고, 다음 페이지 존재 여부만 알려줍니다. Count 쿼리 오버헤드를 피하고 싶을 때 사용합니다.  
  • 사용법:
    • 리포지토리 메서드(기본 findAll 또는 커스텀 메서드)의 파라미터로 Pageable 또는 Sort 객체를 전달합니다.  
    • PageRequest.of(int page, int size, Sort sort)를 사용하여 Pageable 객체를 생성합니다. 페이지 번호는 0부터 시작합니다. 
    • Sort.by("propertyName").ascending() 또는 .descending()을 사용하여 Sort 객체를 생성합니다. 여러 속성을 기준으로 정렬하려면 .and()를 사용하여 연결합니다.

예시:

@Service
public class ProductPagingService {
    @Autowired ProductRepository repository;

    public Page<Product> findProductsPaginatedAndSorted(int pageNum, int pageSize, String sortBy, String sortDir) {
        // 정렬 방향 결정
        Sort.Direction direction = sortDir.equalsIgnoreCase("desc")? Sort.Direction.DESC : Sort.Direction.ASC;
        // 정렬 객체 생성 (예: 'price' 필드 기준)
        Sort sort = Sort.by(direction, sortBy);

        // Pageable 객체 생성 (페이지 번호는 0부터 시작)
        Pageable pageable = PageRequest.of(pageNum, pageSize, sort);

        // 페이징 및 정렬 적용하여 조회
        Page<Product> productPage = repository.findAll(pageable);

        // 결과 사용
        System.out.println("Total Elements: " + productPage.getTotalElements());
        System.out.println("Total Pages: " + productPage.getTotalPages());
        System.out.println("Products on Page " + pageNum + ":");
        productPage.getContent().forEach(p -> System.out.println(" - " + p.getName() + " (" + p.getPrice() + ")"));

        return productPage;
    }
}

 

 

 

Spring MVC 환경에서는 Spring Data Web 지원을 통해 컨트롤러 메서드의 파라미터로 Pageable 또는 Sort를 선언하면, HTTP 요청 파라미터(예: page, size, sort=property,direction)로부터 자동으로 해당 객체를 생성하여 주입받을 수 있습니다.

 

프로젝션 (Projections)

데이터베이스에서 엔티티의 전체 필드가 아닌 필요한 일부 필드만 선택적으로 조회하는 기능입니다. 이는 네트워크 트래픽을 줄이고, 애플리케이션 메모리 사용량을 최적화하며, 성능을 향상시키는 데 도움이 됩니다.

  • 인터페이스 기반 프로젝션 (Interface-based Projections):
    • 조회하려는 속성에 대한 getter 메서드만 포함하는 인터페이스를 정의합니다. Spring Data JPA는 이 인터페이스의 프록시 구현체를 런타임에 생성합니다.  
    • Closed Projections: 인터페이스의 getter 메서드 이름이 엔티티 속성 이름과 정확히 일치하는 경우입니다. JPA는 필요한 컬럼만 선택하는 최적화된 쿼리를 생성할 수 있습니다.  
    • Open Projections: @Value 어노테이션(SpEL 표현식 사용)이나 default 메서드를 사용하여 계산된 값을 반환하는 메서드를 인터페이스에 정의할 수 있습니다. 이 경우 Spring Data는 전체 엔티티를 로드한 후 값을 계산해야 할 수 있어 쿼리 최적화가 제한될 수 있으므로 주의해야 합니다.  
    • 중첩 프로젝션(Nested Projections)도 가능합니다. 즉, 프로젝션 인터페이스의 getter가 다른 프로젝션 인터페이스 타입을 반환할 수 있습니다.
// 프로젝션 인터페이스 정의
public interface ProductNameAndPrice {
    String getName(); // Closed projection getter
    BigDecimal getPrice(); // Closed projection getter

    // Open projection getter (SpEL 사용)
    @Value("#{target.name + ' - Price: $' + target.price}")
    String getDisplayInfo();

    // 중첩 프로젝션 예시 (Address 엔티티가 있다고 가정)
    // AddressSummary getAddress();
}
/*
public interface AddressSummary {
    String getCity();
    String getZipCode();
}
*/

// 리포지토리 메서드에서 프로젝션 인터페이스 사용
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<ProductNameAndPrice> findByManufacturingDateBefore(LocalDate date);
}
  •  클래스 기반 프로젝션 (DTOs - Data Transfer Objects):
    • 조회하려는 속성을 필드로 가지고, 해당 속성들을 초기화하는 생성자를 가진 클래스(DTO)를 정의합니다. 생성자의 파라미터 이름이 엔티티 속성 이름과 일치해야 합니다. Java Record는 불변 DTO를 정의하는 데 이상적입니다.
    • 인터페이스 기반과 달리 프록시를 사용하지 않으며, DTO 구조를 통한 직접적인 중첩 프로젝션은 지원하지 않습니다 (쿼리에서 중첩 구조를 만들어야 함).  
    • 파생 쿼리에서 DTO 타입을 반환 타입으로 지정하면 Spring Data JPA가 자동으로 생성자 표현식(Constructor Expression)을 사용하는 JPQL 쿼리를 생성합니다. @Query를 사용할 경우, JPQL 쿼리 내에서 SELECT new com.example.YourDto(p.name, p.price)...와 같이 생성자 표현식을 명시적으로 사용해야 합니다.
// DTO 클래스 정의 (Java Record 사용)
public record ProductSummary(String name, BigDecimal price) {}

// 리포지토리 메서드 (파생 쿼리)
public interface ProductRepository extends JpaRepository<Product, Long> {
    List<ProductSummary> findByPriceGreaterThan(BigDecimal minPrice);
}

// 리포지토리 메서드 (@Query 사용)
public interface ProductRepository extends JpaRepository<Product, Long> {
    @Query("SELECT new com.example.yourpackage.ProductSummary(p.name, p.price) FROM Product p WHERE p.id = :id")
    Optional<ProductSummary> findSummaryById(@Param("id") Long id);
}
 
  • 동적 프로젝션 (Dynamic Projections):
    • 하나의 리포지토리 메서드가 런타임에 지정된 타입(엔티티 원본 또는 특정 프로젝션 타입)으로 결과를 반환하도록 할 수 있습니다. 메서드 시그니처에 제네릭 타입 파라미터 <T>와 Class<T> type 파라미터를 추가합니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
    <T> List<T> findByNameContainingIgnoreCase(String namePart, Class<T> type);
}

// 서비스 계층에서의 사용 예시
@Service
public class ProductFinderService {
    @Autowired ProductRepository repository;

    public void searchProducts(String query) {
        // 전체 Product 엔티티 조회
        List<Product> fullProducts = repository.findByNameContainingIgnoreCase(query, Product.class);

        // ProductNameAndPrice 프로젝션으로 조회
        List<ProductNameAndPrice> nameAndPriceList = repository.findByNameContainingIgnoreCase(query, ProductNameAndPrice.class);

        // ProductSummary DTO로 조회
        List<ProductSummary> summaryList = repository.findByNameContainingIgnoreCase(query, ProductSummary.class);
    }
}

 

프로젝션은 특히 읽기 전용(read-only) 작업에서 성능을 최적화하는 데 매우 유용합니다. 인터페이스 기반은 간단한 필드 매핑에 편리하고, 클래스 기반 DTO는 커스텀 로직 추가나 불변 객체 구현에 유리합니다. 동적 프로젝션은 여러 뷰가 필요할 때 리포지토리 메서드의 중복을 줄여줍니다. 단, 클래스 기반 DTO 프로젝션은 네이티브 쿼리와 함께 사용할 때 @SqlResultSetMapping 같은 추가적인 매핑 설정 없이는 자동으로 동작하지 않는다는 점에 유의해야 합니다.

 

감사 (Auditing)

엔티티가 언제, 누구에 의해 생성되고 수정되었는지 자동으로 추적하는 기능입니다.  

  • 주요 어노테이션:
    • @CreatedBy: 엔티티 생성자 정보 필드에 사용.
    • @LastModifiedBy: 엔티티 최종 수정자 정보 필드에 사용.
    • @CreatedDate: 엔티티 생성 시각 정보 필드에 사용.
    • @LastModifiedDate: 엔티티 최종 수정 시각 정보 필드에 사용. 이 어노테이션들은 엔티티 클래스 또는 @MappedSuperclass로 지정된 공통 부모 클래스의 필드에 적용할 수 있습니다. 사용자 정보 필드는 String, Long 또는 커스텀 User 타입 등을 사용할 수 있고, 시각 정보 필드는 JDK 8의 java.time 타입, long, Date, Calendar 등을 지원합니다.  
       
  • 설정:
    1. Auditing 활성화: Spring 설정 클래스(보통 메인 애플리케이션 클래스)에 @EnableJpaAuditing 어노테이션을 추가합니다.  
    2. AuditorAware 빈 등록: @CreatedBy, @LastModifiedBy를 사용하려면 현재 사용자 정보를 제공하는 AuditorAware<T> 인터페이스 구현체를 Spring 빈으로 등록해야 합니다. Spring Security와 통합하여 현재 인증된 사용자 정보를 반환하도록 구현하는 것이 일반적입니다. Spring Data JPA는 자동으로 이 빈을 감지하여 사용합니다.  
    3. AuditingEntityListener 등록: 감사 대상 엔티티 클래스 또는 @MappedSuperclass에 @EntityListeners(AuditingEntityListener.class) 어노테이션을 추가하여 JPA 생명주기 이벤트 발생 시 감사 정보가 캡처되도록 합니다.

 

AuditorAware 구현 예시 (Spring Security 사용):

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider") // 빈 이름 명시 (선택 사항)
public class AuditingConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {
        // Spring Security 컨텍스트에서 현재 사용자 이름(String)을 반환하는 람다식
        return () -> {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null ||!authentication.isAuthenticated() |
| "anonymousUser".equals(authentication.getPrincipal())) {
                // 인증되지 않았거나 익명 사용자인 경우 "SYSTEM" 또는 null 반환
                return Optional.of("SYSTEM");
            }
            // 인증된 사용자 이름 반환
            return Optional.of(authentication.getName());
        };
    }
}

 

@MappedSuperclass 를 이용한 공통 감사 필드 정의 예시:

import jakarta.persistence.*;
import org.springframework.data.annotation.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;

@MappedSuperclass // 이 클래스는 엔티티가 아니지만, 속성 매핑 정보는 하위 엔티티에 상속됨
@EntityListeners(AuditingEntityListener.class) // 감사 리스너 등록
public abstract class Auditable<U> { // U는 감사자(Auditor)의 타입 (예: String, Long)

    @CreatedBy // 생성자 정보
    @Column(updatable = false) // 생성자는 수정되지 않도록 설정
    protected U createdBy;

    @CreatedDate // 생성 시각 정보
    @Column(updatable = false) // 생성 시각은 수정되지 않도록 설정
    protected LocalDateTime createdDate;

    @LastModifiedBy // 최종 수정자 정보
    protected U lastModifiedBy;

    @LastModifiedDate // 최종 수정 시각 정보
    protected LocalDateTime lastModifiedDate;

    // Getters and setters...
    public U getCreatedBy() { return createdBy; }
    public LocalDateTime getCreatedDate() { return createdDate; }
    public U getLastModifiedBy() { return lastModifiedBy; }
    public LocalDateTime getLastModifiedDate() { return lastModifiedDate; }
}

// Auditable을 상속받는 Product 엔티티
@Entity
public class Product extends Auditable<String> { // 감사자 타입을 String으로 지정

    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
    private LocalDate manufacturingDate;

    // 기본 생성자, 다른 필드, getter/setter...
    protected Product() {}
    public Product(String name, BigDecimal price, LocalDate manufacturingDate) {
        this.name = name;
        this.price = price;
        this.manufacturingDate = manufacturingDate;
    }
    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    //...
}

 

트랜젝션 관리 (@Transactional)

데이터베이스 작업의 원자성(Atomicity), 일관성(Consistency), 고립성(Isolation), 지속성(Durability) - ACID 속성을 보장하기 위해 트랜잭션 관리는 필수적입니다. Spring은 @Transactional 어노테이션을 통해 선언적 트랜잭션 관리를 지원합니다.  

  • 적용 위치: @Transactional은 서비스 계층(Service Layer)의 메서드에 적용하는 것이 가장 일반적인 모범 사례입니다. 서비스 계층은 일반적으로 비즈니스 로직의 단위(Unit of Work)를 정의하며, 이 단위는 여러 리포지토리 호출을 포함할 수 있습니다. 컨트롤러 계층에 적용하는 것은 관심사의 분리 원칙에 어긋나고 , 리포지토리 계층에만 적용하는 것은 전체 비즈니스 트랜잭션을 포괄하지 못할 수 있습니다.  
  • Spring Boot 자동 설정: spring-boot-starter-data-jpa 의존성이 있으면 Spring Boot가 자동으로 트랜잭션 관리자를 설정하므로, 별도로 @EnableTransactionManagement를 명시할 필요는 거의 없습니다.  
  • 주요 속성:
    • readOnly: true로 설정하면 해당 트랜잭션이 읽기 전용임을 나타내는 힌트를 JPA 프로바이더에게 전달합니다. Hibernate의 경우, Flush 모드를 NEVER로 설정하여 변경 감지(dirty checking)를 생략하는 등 성능 최적화를 수행할 수 있습니다. 조회 메서드에는 readOnly = true를 사용하는 것이 좋습니다. 기본값은 false입니다.  
    • propagation: 트랜잭션 전파 속성을 정의합니다. 예를 들어, 이미 진행 중인 트랜잭션이 있을 때 어떻게 동작할지 결정합니다 (REQUIRED(기본값), REQUIRES_NEW, SUPPORTS, NEVER 등).  
    • isolation: 트랜잭션 격리 수준을 설정합니다 (READ_COMMITTED, SERIALIZABLE 등). 기본값은 데이터베이스의 기본 격리 수준을 따릅니다.  
    • rollbackFor / noRollbackFor: 특정 예외 타입이 발생했을 때 롤백을 수행할지(rollbackFor) 또는 수행하지 않을지(noRollbackFor) 지정합니다. 기본적으로 Spring은 RuntimeException과 Error에 대해서만 롤백을 수행하고, 체크 예외(Checked Exception)에 대해서는 롤백하지 않습니다.  
  • 주의사항 (프록시 기반 동작): @Transactional은 Spring AOP 프록시를 통해 동작합니다. 따라서 같은 클래스 내의 다른 @Transactional 메서드를 this 참조로 호출하는 **자기 호출(self-invocation)**의 경우, 기본적으로 프록시를 거치지 않기 때문에 호출된 메서드의 트랜잭션 설정(예: propagation = REQUIRES_NEW)이 적용되지 않을 수 있습니다. 이는 트랜잭션 경계를 설계할 때 고려해야 할 중요한 점이며, 필요하다면 별도의 서비스 빈으로 분리하거나 TransactionTemplate을 사용하는 등의 대안을 고려해야 합니다.

예시:

@Service
public class ProductService {
    @Autowired private ProductRepository productRepository;
    @Autowired private InventoryService inventoryService; // 다른 서비스

    // 쓰기 트랜잭션 (기본 설정: REQUIRED, readOnly=false)
    @Transactional
    public Product createProductAndUpdateInventory(Product product, int initialStock) {
        Product savedProduct = productRepository.save(product);
        // inventoryService의 updateStock 메서드가 실패하면(RuntimeException 발생 시)
        // productRepository.save() 작업도 롤백됨
        inventoryService.updateStock(savedProduct.getId(), initialStock);
        return savedProduct;
    }

    // 읽기 전용 트랜잭션 (최적화 가능)
    @Transactional(readOnly = true)
    public Optional<Product> findProductById(Long id) {
        return productRepository.findById(id);
    }

    // 특정 체크 예외 발생 시 롤백하도록 설정
    @Transactional(rollbackFor = InventoryUpdateException.class)
    public void updateProductPrice(Long id, BigDecimal newPrice) throws InventoryUpdateException {
        Optional<Product> productOpt = productRepository.findById(id);
        if (productOpt.isPresent()) {
            Product product = productOpt.get();
            product.setPrice(newPrice);
            productRepository.save(product);
            // 재고 관련 로직 수행 중 예외 발생 가능성
            inventoryService.checkAndUpdateRelatedInventory(product);
        }
    }
}

// 사용자 정의 체크 예외
public class InventoryUpdateException extends Exception {
    public InventoryUpdateException(String message) {
        super(message);
    }
}

 

락 (Locking)

동시에 여러 트랜잭션이 같은 데이터에 접근하여 수정하려고 할 때 발생할 수 있는 문제(예: Lost Update)를 방지하기 위해 락 메커니즘을 사용할 수 있습니다.  

 
  • 낙관적 락 (Optimistic Locking):
    • 개념: 데이터베이스 레코드 자체에 락을 걸지 않고, 엔티티에 버전(version) 컬럼을 두어 데이터 변경 시 버전을 확인하는 방식입니다. 충돌이 드물게 발생할 것이라고 가정합니다.  
       
    • 구현: 엔티티 클래스에 @Version 어노테이션이 붙은 필드(주로 Long, Integer, Timestamp 타입)를 추가합니다. Hibernate는 엔티티 업데이트 시 이 버전 필드를 자동으로 증가시키고, UPDATE 쿼리의 WHERE 절에 version = <읽었던 버전> 조건을 추가합니다.  
       
    • 동작: 만약 다른 트랜잭션이 먼저 커밋하여 버전이 변경되었다면, 현재 트랜잭션의 UPDATE 문은 0개의 행에 영향을 미치게 됩니다. 이때 JPA는 OptimisticLockException을 발생시켜 충돌이 감지되었음을 알립니다. 애플리케이션은 이 예외를 처리하여 재시도하거나 사용자에게 알려야 합니다.  
       
    • 장점: 데이터베이스 락을 사용하지 않으므로 동시성이 높고 성능 및 확장성 면에서 유리합니다. 특히 읽기 작업이 많은 시스템에 적합합니다.  
       
    • 단점: 충돌 발생 시 애플리케이션 레벨에서 예외 처리 및 재시도 로직이 필요합니다.
@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    private String name;
    private BigDecimal price;

    @Version // 낙관적 락을 위한 버전 필드
    private Long version;

    // Constructors, getters, setters...
}

 

  • 비관적 락 (Pessimistic Locking):
    • 개념: 데이터베이스 레벨의 실제 락(예: SELECT... FOR UPDATE 또는 FOR SHARE)을 사용하여 데이터에 대한 동시 접근을 제어합니다. 충돌이 자주 발생할 것이라고 가정합니다.  
    • 구현: 리포지토리 메서드에 @Lock 어노테이션과 LockModeType을 지정하여 사용합니다. EntityManager의 lock() 또는 find() 메서드를 통해서도 직접 락을 설정할 수 있습니다.  
      • LockModeType.PESSIMISTIC_READ: 공유 락(Shared Lock). 다른 트랜잭션이 읽는 것은 허용하지만, 수정/삭제는 방지합니다.  
      • LockModeType.PESSIMISTIC_WRITE: 배타 락(Exclusive Lock). 다른 트랜잭션의 읽기, 수정, 삭제를 모두 방지합니다.  
      • LockModeType.PESSIMISTIC_FORCE_INCREMENT: PESSIMISTIC_WRITE와 유사하게 배타 락을 걸지만, 추가로 @Version 필드의 값을 강제로 증가시킵니다.  
    • 장점: 데이터 충돌을 원천적으로 방지하여 데이터 무결성을 강력하게 보장합니다. 충돌이 빈번한 쓰기 중심의 시스템에 적합할 수 있습니다.
    • 단점: 데이터베이스 락으로 인해 트랜잭션이 대기(blocking) 상태에 빠질 수 있어 동시성 및 성능 저하를 유발할 수 있습니다. 데드락(Deadlock) 발생 가능성도 존재합니다.
public interface ProductRepository extends JpaRepository<Product, Long> {

    // ID로 조회 시 쓰기 락(배타 락) 획득
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);

    // 기본 findById 메서드를 오버라이드하여 읽기 락(공유 락) 적용
    @Override
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<Product> findById(Long id);
}

 

일반적으로 JPA/Hibernate 환경에서는 낙관적 락이 확장성 측면에서 더 선호되는 방식입니다. 비관적 락은 강력한 일관성을 보장하지만 성능 저하와 데드락 위험을 감수해야 하므로, 충돌 가능성이 매우 높고 즉각적인 일관성이 반드시 필요한 특정 유스케이스에 한해 사용하는 것이 좋습니다.

 


결론

Spring Data JPA 3.4.4는 Java 애플리케이션에서 데이터 접근 계층을 구축하는 작업을 놀랍도록 간소화시켜주는 강력한 프레임워크입니다. 리포지토리 인터페이스 기반의 프로그래밍 모델, 파생 쿼리, @Query 어노테이션을 통한 유연한 쿼리 작성 기능은 개발자가 상용구 코드 작성에서 벗어나 핵심 비즈니스 로직에 집중할 수 있게 해줍니다.

 

이번 글에서는 기본적인 설정과 의존성 관리부터 시작하여, @Entity, @Id, @Table, @Column 등의 어노테이션을 사용한 엔티티 및 관계 매핑 방법을 살펴보았습니다. 특히, 양방향 @OneToMany 관계 매핑 시 @ManyToOne 쪽에서 관계를 관리하고 유틸리티 메서드를 사용하는 모범 사례와 FetchType 선택의 중요성을 강조했습니다.

 

또한, JpaRepository를 활용한 기본적인 CRUD 작업 수행 방법과 save(), delete() 메서드의 내부 동작 및 주의점에 대해 알아보았습니다. 파생 쿼리, JPQL, 네이티브 SQL, 그리고 Criteria API까지 다양한 쿼리 작성 방법을 비교하며 각각의 장단점과 사용 시기를 이해하는 데 중점을 두었습니다.

 

마지막으로 페이징 및 정렬, 프로젝션(인터페이스 기반, 클래스 기반 DTO, 동적), 감사 기능, @Transactional을 이용한 선언적 트랜잭션 관리, 그리고 낙관적/비관적 락과 같은 고급 기능들을 통해 Spring Data JPA가 제공하는 풍부한 기능을 활용하여 더욱 견고하고 효율적인 애플리케이션을 개발할 수 있음을 확인했습니다.

 

더 자세한 정보는 아래의 공식 문서를 참고하시기 바랍니다.

https://docs.spring.io/spring-data/jpa/reference/index.html

 

Spring Data JPA :: Spring Data JPA

Oliver Gierke, Thomas Darimont, Christoph Strobl, Mark Paluch, Jay Bryant, Greg Turnquist Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that

docs.spring.io

 

반응형

'Spring' 카테고리의 다른 글

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