spring boot 3.3.2 기준

Spring Data Project

Common 모듈 분석

Spring Data Project

Spring Data

스프링 데이터는 기본 데이터 저장소의 특수성을 유지하면서 데이터 접근을 위한 일관성 있는 스프링 기반 프로그래밍 모델을 제공하여

관계형, 비관계형 데이터베이스, 데이터 접근 기술, 클라우드 기반 데이터 서비스, 맵 리듀스 프레임워크를 쉽게 사용할 수 있음

스프링 데이터 프로젝트는 특정 데이터베이스에 특화된 하위 프로젝트를 포함하는 포괄적인 프로젝트임

주요 기능

리포지토리 패턴, 커스텀 객체 매핑 추상화

리포지토리 메서드 이름에서 동적 쿼리 파생

기본 속성을 제공하는 구현 도메인 베이스 클래스

auditing 지원(created, last changed 등)

커스텀 리포지토리 코드 통합

JavaConfig 및 커스텀 XML 네임스페이스를 통한 스프링 통합

Spring 컨트롤러와의 통합

메인 모듈

Spring Data Commons

모든 스프링 데이터 모듈의 기반이 되는 핵심 스프링 개념

Spring Data JDBC

JDBC에 대한 스프링 데이터 리포지토리

Spring Data JPA

JPA에 대한 스프링 데이터 리포지토리

Spring Data Redis

스프링 애플리케이션에서 간단하게 설정하여 레디스에 접근할 수 있는 모듈

Common 모듈 분석

Annotation

Auditing

@CreatedBy: 엔티티가 처음 생성될 때, 해당 엔티티를 생성한 사용자를 기록하기 위해 사용됨

@LastModifiedBy: 엔티티가 마지막으로 수정될 때, 해당 엔티티를 수정한 사용자를 기록하기 위해 사용됨

@CreatedDate: 엔티티가 처음 저장될 때, 생성 시간을 자동으로 기록하기 위해 사용됨

@LastModifiedDate: 엔티티가 마지막으로 수정된 날짜와 시간을 자동으로 기록하기 위해 사용됨

Reference

@Reference: 데이터 저장소 간의 참조 관계를 나타내기 위해 사용

주로 Sprin Data REST 모듈에서 사용되며, 한 엔티티가 다른 엔티티를 참조할 때 해당 참조를 명시적으로 나타냄

Transient

@Transient: 특정 필드가 데이터베이스에 저장되지 않도록 표시

엔티티에 포함되어 있어도, 데이터베이스 테이블에 매핑되지 않음

Repository Abstraction

org.springframework.data.repository 패키지

Repository<T, ID>

Repository 인터페이스는 관리할 도메인 타입과 ID 타입을 캡처하는 마커 인터페이스임

@Indexed
public interface Repository<T, ID> {
}

@Indexed

CrudRepository<T, ID>

CrudRepository 인터페이스는 특정 타입에 대한 일반적인 CRUD 연산을 추상화함

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {

    // T의 하위 타입 허용
    <S extends T> S save(S entity);

    // T의 하위 타입 허용
    <S extends T> Iterable<S> saveAll(Iterable<S> entities);

    Optional<T> findById(ID id);

    boolean existsById(ID id);

    Iterable<T> findAll();

    Iterable<T> findAllById(Iterable<ID> ids);

    long count();

    void deleteById(ID id);

    void delete(T entity);

    void deleteAllById(Iterable<? extends ID> ids);

    void deleteAll(Iterable<? extends T> entities);

    void deleteAll();
}

@NoRepositoryBean

ListCrudRepository<T, ID>

ListCrudRepository는 CrudRepository를 확장한 인터페이스로, Iterable 대신 List로 반환하는 메서드를 정의함

빈 등록 방지를 위해 @NoRepositoryBean을 적용함

@NoRepositoryBean
public interface ListCrudRepository<T, ID> extends CrudRepository<T, ID> {

    <S extends T> List<S> saveAll(Iterable<S> entities);

    List<T> findAll();

    List<T> findAllById(Iterable<ID> ids);
}

PagingAndSortingRepository<T, ID>

페이징과 정렬을 추상화한 리포지토리

PagingAndSortingRepository 인터페이스는 Repository를 확장하므로 기본 crud 연산을 지원하지 않음

이를 확장할 인터페이스나 구현체는 기본적인 crud 연산을 위해 CrudRepository도 포함해야 됨

빈 등록 방지를 위해 @NoRepositoryBean을 적용함

@NoRepositoryBean
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {

    // 주어진 정렬 조건에 따른 엔티티 반환
    Iterable<T> findAll(Sort sort);

    // 주어진 페이징 조건에 따른 엔티티 반환
    Page<T> findAll(Pageable pageable);
}

ListPagingAndSortingRepository<T, ID>

ListCrudRepository처럼 Iterable 대신 List를 반환하는 PagingAndSortingRepository 확장 인터페이스

빈 등록 방지를 위해 @NoRepositoryBean을 적용함

@NoRepositoryBean
public interface ListPagingAndSortingRepository<T, ID> extends PagingAndSortingRepository<T, ID> {

    List<T> findAll(Sort sort);
}

@RepositoryDefinition

스프링 데이터 표준 리포지토리를 상속받지 않고, 특정한 리포지토리 기능을 직접 정의할 수 있도록 하는 어노테이션

Repository처럼 @Indexed가 포함되어 있으며, T, ID를 어노테이션 속성으로 지정함

@Indexed
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
public @interface RepositoryDefinition {

    Class<?> domainClass();

    Class<?> idClass();
}

아래처럼 표준 리포지토리 인터페이스를 상속받지 않고, 인터페이스 메서드를 직접 정의할 수 있음

@RepositoryDefinition(domainClass = User.class, idClass = Long.class)
public interface UserRepository {
    User findByEmail(String email);
    List<User> findAll();
}

Domain

org.springframework.data.domain 패키지

Slice

페이징된 데이터 결과를 추상화한 인터페이스

현재 페이지에 해당하는 데이터만 포함하며, 전체 데이터의 개수나 총 페이지에 수에 대한 정보는 제공하지 않음

주요 특징

현재 페이지의 데이터와 다음 페이지가 있는지 여부만을 제공하여 성능을 최적화하는 데 중점을 둠

전체 데이터 개수를 알 필요가 없고 단순히 다음 페이지가 있는지 여부만 확인하는 경우에 Slice가 적합함

public interface Slice<T> extends Streamable<T> {

    /* ======= getter =======  */
    
    // 현재 Slice의 번호
    int getNumber();

    // Slice 크기
    int getSize();

    // 현재 Slice의 요소 개수
    int getNumberOfElements();

    // 현재 Slice에 포함된 데이터 반환
    List<T> getContent();

    Sort getSort();
    
    /* ======= 상태 확인 메서드 =======  */
    
    boolean hasContent();

    boolean isFirst();

    boolean isLast();

    boolean hasNext();

    boolean hasPrevious();

    // 다음 Slice를 요청하기 위한 Pageable  
    Pageable nextPageable();

    // 이전 Slice를 요청하기 위한 Pageable
    Pageable previousPageable();
    
    <U> Slice<U> map(Function<? super T, ? extends U> converter);

    /* ======= default 메서드 =======  */
    
    // 현재 Slice를 요청하는 데 사용된 Pageable 반환
    default Pageable getPageable() {
        return PageRequest.of(getNumber(), getSize(), getSort());
    }
    
    // 현재 Slice가 마지막이라면 현재 Pageable, 아니라면 nextPageable() 반환
    default Pageable nextOrLastPageable() {
        return hasNext() ? nextPageable() : getPageable();
    }
    // 현재 Slice가 처음이라면 현재 Pageable, 아니라면 previousPageable() 반환
    default Pageable previousOrFirstPageable() {
        return hasPrevious() ? previousPageable() : getPageable();
    }
}

Page

전체 페이지 수와 전체 데이터 개수 정보를 제공하는 Slice 확장 인터페이스

public interface Page<T> extends Slice<T> {

    /* ======= Page 생성 static 메서드 ======= */
    
    // 빈 Page 생성
    static <T> Page<T> empty() {
        return empty(Pageable.unpaged());
    }

    // 주어진 pageable에 따른 빈 Page 생성
    static <T> Page<T> empty(Pageable pageable) {
        return new PageImpl<>(Collections.emptyList(), pageable, 0);
    }

    /* ======= getter =======*/
    
    // 전체 페이지 개수
    int getTotalPage();

    // 전체 데이터 개수
    long getTotalElements();

    <U> Page<U> map(Function<? super T, ? extends U> converter);
}

Window

스크롤링을 처리하는 데 중점을 둔 페이징 처리 인터페이스

Window<T>는 특정 크기의 데이터 조각(슬라이스)를 나타냄

슬라이싱된 데이터를 페이지처럼 취급하면서 각각의 데이터에 대해 위치를 제공하고 다음 페이지가 있는지 여부를 확인하는 기능을 제공함

public interface Window<T> extends Streamable<T> {

    int size();
    
    boolean isEmpty();
    
    List<T> getContent();
    
    boolean hasNext();
    
    ScrollPosition positionAt(int index);
    
    /* ====== default 메서드 ====== */
    
    default boolean isLast() {
        return !hasNext();
    }
    
    default boolean hasPosition(int index) {
        try {
            return positionAt(index) != null;
        } catch (IllegalStateException e) {
            return false;
        }
    }
    
    default ScrollPosition positionAt(T object) {
        
        int index = getContent().indexOf(object);
        
        if (index == -1) {
            throw new NoSuchElementException();
        }
        
        return positionAt(index);
    }

    /* ===== Window 생성 static 메서드 ===== */
    
    static <T> Window<T> from(List<T> items, IntFunction<? extends ScrollPosition> positionFunction) {
        return new WindowImpl<>(items, positionFunction, false);
    }

    static <T> Window<T> from(List<T> items, IntFunction<? extends ScrollPosition> positionFunction, boolean hasNext) {
        return new WindowImpl<>(items, positionFunction, hasNext);
    }
    
    <U> Window<U> map(Function<? super T, ? extends U> converter);
}

Slice vs Page vs Window

Slice

Page

Window

Pageable

페이징과 정렬을 위한 요청 정보를 추상화한 인터페이스

클라이언트가 요청하는 페이지 번호, 페이지 크기, 정렬 옵션 등을 포함함

데이터베이스 페이징 쿼리를 생성할 때 특정 페이지의 데이터를 가져오는 데 필요한 limit와 offset 절을 지정하는 데 정보를 Pageable 객체가 제공함

limit와 offset

public interface Pageable {

    /* ======== Pageable 생성 메서드 ========= */

    static Pageable unpaged() {
        return unpaged(Sort.unsorted());
    }

    static Pageable unpaged(Sort sort) {
        return Unpaged.sorted(sort);
    }

    static Pageable ofSize(int pageSize) {
        return PageRequest.of(0, pageSize);
    }

    /* ============= 상태 확인 메서드 ============ */

    boolean hasPrevious();

    default boolean isPaged() {
        return true;
    }

    default boolean isUnpaged() {
        return !isPaged();
    }

    /* 값 조회 메서드  */

    int getPageNumber();

    int getPageSize();

    long getOffset();

    Sort getSort();

    /* 페이지 이동 메서드  */

    Pageable next();

    Pageable previousOrFirst();

    Pageable first();

    Pageable withPage(int pageNumber);

    /* default 메서드  */

    default Optional<Pageable> toOptional() {
        return isUnpaged() ? Optional.empty() : Optional.of(this);
    }

    default Limit toLimit() {

        if (isUnpaged()) {
            return Limit.unlimited();
        }

        return Limit.of(getPageSize());
    }

    default OffsetScrollPosition toScrollPosition() {

        if (isUnpaged()) {
            throw new IllegalStateException("Cannot create OffsetScrollPosition from an unpaged instance");
        }

        return getOffset() > 0 ? ScrollPosition.offset(getOffset() - 1) : ScrollPosition.offset();
    }
}

Pageable 구현체로 추상 클래스인 AbstractPageRequest와 일반적으로 사용되는 PageRequest, Querydsl용 QPageaRequest가 있음

구현체의 동작 방식은 Pageable의 메서드명에서 기대할 수 있는 그대로 동작함

Limit

쿼리의 결과 개수를 제한할 수 있는 기능을 제공하는 sealed 인터페이스임

기존의 Pageable 인터페이스와 달리 Limit는 페이지네이션을 위해 사용되지 않고, 단순히 데이터베이스 쿼리 결과의 최대 개수(행)를 설정하는 데 초점을 맞춤

즉, 페이지 번호와 무관하게 특정 쿼리의 제한된 레코드 수의 결과만을 반환함

public sealed interface Limit permits Limited, UnLimited {

    // 최대 개수가 지정되지 않은 경우
    static Limit unlimited() {
        return UnLimited.INSTANCE;
    }

    // 최대 개수를 지정한 경우
    static Limit limit(int max) {
        return new Limited(max);
    }

    int max();

    boolean isLimited();

    final class Limited implements Limit {

        private final int max;

        Limited(int max) {
            this.max = max;
        }

        @Override
        public int max() {
            return max;
        }

        @Override
        public boolean isLimited() {
            return true;
        }
    }

    // 싱글톤 패턴 사용
    final class Unlimited implements Limit {

        static final Limit INSTANCE = new Unlimited();

        Unlimited() {}

        @Override
        public int max() {
            throw new IllegalStateException(
                    "Unlimited does not define 'max'. Please check 'isLimited' before attempting to read 'max'");
        }

        @Override
        public boolean isLimited() {
            return false;
        }
    }
}

ScrollPosition

스크롤 방식의 페이징 처리를 지원하는 인터페이스

스크롤 방식은 두 가지로 나뉨

offset 방식

데이터베이스에서 조회할 데이터의 시작 지점을 정하는 방식임

시작 지점까지 N개의 데이터를 모두 순서대로 읽는 과정을 거침 -> DB 부하

N개의 결과를 조회하다가 새로운 행이 추가될 시 이전 페이지와 중복 데이터 발생 가능성 -> 사이드 이펙트 발생

SELECT *
FROM product
LIMIT 1000000, 1000;

keyset 방식

특정 id를 기준으로 WHERE 절을 사용하여 데이터를 조회하는 방법임

키셋 방식은 오프셋이 가진 DB 부하와 사이드 이펙트 발생 문제점을 해결할 수 있음

SELECT *
FROM product
WHERE id > 1000000
LIMIT 1000
ORDER BY id;

ScrollPosition은 전체 쿼리 결과 내에서 위치를 지정하는 인터페이스로, 스크롤 위치는 쿼리 결과의 시작 부분부터 스크롤을 시작하거나 쿼리 결과 내의 지정된 위치에서 스크롤을 재개하는 데 사용됨

public interface ScrollPosition {


    /* ============= 상태 확인 메서드 ========== */
    boolean isInitial();

    /* KeysetScrollPosition, OffsetScrollPosition 생성 static 메서드*/
    static KeysetScrollPosition keyset() {
        return KeysetScrollPosition.initial();
    }

    static KeysetScrollPosition of(Map<String, ?> keys, Direction direction) {
        return KeysetScrollPosition.of(keys, direction);
    }

    static OffsetScrollPosition offset() {
        return OffsetScrollPosition.initial();
    }

    static OffsetScrollPosition offset(long offset) {
        return OffsetScrollPosition.of(offset);
    }

    /* ========== 이동 static 메서드 ========== */
    static KeysetScrollPosition forward(Map<String, ?> keys) {
        return of(keys, Direction.FORWARD);
    }

    static KeysetScrollPosition backward(Map<String, ?> keys) {
        return of(keys, Direction.BACKWARD);
    }

    /* ========= 스크롤 방향 ======== */
    enum Direction {

        FORWARD,

        BACKWARD;

        Direction reverse() {
            return this == FORWARD ? BACKWARD : FORWARD;
        }
    }
}

OffsetScrollPosition

초기 OffsetScrollPosition은 특정 요소나 위치를 가리키지 않음

public final class OffsetScrollPosition implements ScrollPosition {

    // 초기 OffsetScrollPosition의 값으로 -1 지정
    private static final OffsetScrollPosition INITIAL = new OffsetScrollPosition(-1);

    private final long offset;

    private OffsetScrollPosition(long offset) {
        this.offset = offset;
    }

    static OffsetScrollPosition initial() {
        return INITIAL;
    }

    static OffsetScrollPosition of(long position) {
        Assert.isTrue(offset >= 0, "Offset must not be negative");
        return new OffsetScrollPosition(offset);
    }

    // 주어진 시작 오프셋을 기반으로 IntFunction<OffsetPositionFunction>을 반환하는 메서드
    public static IntFunction<OffsetScrollPosition> positionFunction(long startOffset) {
        Assert.isTrue(startOffset >= 0, "Start offset must not be negative");
        return startOffset == 0 ? OffsetPositionFunction.ZERO : new OffsetPositionFunction(startOffset);
    }


    public IntFunction<OffsetScrollPosition> positionFunction() {
        return positionFunction(offset + 1);
    }

    // offset getter
    public long getOffset() {

        Assert.state(offset >= 0, "Initial state does not have an offset. Make sure to check #isInitial()");
        return offset;
    }

    // 주어진 delta 값과 현재 오프셋 값을 더한 새로운 OffsetScrollPosition 반환
    public OffsetScrollPosition advanceBy(long delta) {

        long value = isInitial() ? delta : offset + delta;
        return new OffsetScrollPosition(value < 0 ? 0 : value);
    }

    @Override
    public boolean isInitial() {
        return offset == -1;
    }

    /*
        시작 오프셋을 필드로 가지고, apply(int) 호출 시 시작 오프셋과 주어진 오프셋을 더한 새로운 OffsetScrollPosition 반환
     */
    private record OffsetPositionFunction(long startOffset) implements IntFunction<OffsetScrollPosition> {

        static final OffsetPositionFunction ZERO = new OffsetPositionFunction(0);

        @Override
        public OffsetScrollPosition apply(int offset) {

            if (offset < 0) {
                throw new IndexOutOfBoundsException(offset);
            }

            return of(startOffset + offset);
        }
    }

} 

Sort

쿼리의 정렬 정보를 다루는 클래스

여러 개의 Order 중첩 클래스를 가짐 (각 Order 클래스는 Direction과 NullHandling을 가짐)

데이터베이스나 다른 저장소에서 데이터를 쿼리할 때 정렬 기준을 정의함

중첩 클래스

Example

QBE (Query by Example)

개발자가 직접 쿼리를 직접 작성하지 않고 동적으로 쿼리를 생성할 수 있는 기술임

데이터베이스 쿼리를 생성할 때 엔티티 인스턴스를 기반으로 쿼리를 생성하며, 필터링할 조건을 객체 자체로 표현하고

해당 객체의 필드 값을 기반으로 동적으로 쿼리를 생성함

QBE 구성 요소

QBE 유즈케이스

QBE 한계

Querydsl