본문 바로가기
JPA

Spring Data JPA

by 이상한나라의개발자 2024. 3. 8.

Spring Data JPA는 Java Persistence API(JPA) 위에 구축된 Spring Data의 일부로, 데이터 접근 계층보다 쉽고 효율적으로 구현할 수 있도록 도와주는 모듈입니다. Spring Data JPA는 JPA 기능을 확장하고, 더 쉬운 데이터 접근 방법을 제공하여 어플리케이션 개발의 생산성을 크게 향상시킵니다.

 

Spring Data JPA 주요 기능

  • 레포지토리 추상화 : Spring Data JPA는 Repository 인터페이스에 대한 구현체를 자등으로 생성하여 주입합니다. 개발자는 인터페이스만 정의하고, 이를 통해 CRUD 작업과 페이징 처리를 쉽게 구현할 수 있습니다.
  • 쿼리 메소드 : 메소드 이름만으로 쿼리를 생성하는 기능을 제공합니다. 메소드 이름을 분석하여 자동으로 SQL 쿼리를 생성해줌으로써, 간단한 쿼리는 별도의 쿼리 정의 없이도 사용할 수 있습니다.
  • @Query 어노테이션 : 복잡한 쿼리나 JPQL(Java Persistence Query Language), 네이티브 쿼리를 메소드에 직접 정의할 수 있도록 하는 어노테이션입니다. 이를 통해 레포지토리 메소드에 복잡한 쿼리를 쉽게 연결할 수 있습니다.
  • 동적 쿼리 : QueryDsl 을 사용하여 타입 세이프(type-safe) 쿼리를 구성하고, 동적으로 쿼리를 생성할 수 있는 기능을 제공합니다.
  • 페이징과 정렬 : Spring Data JPA를 사용하면, 페이징과 정렬 기능을 매우 간단하게 구현할 수 있습니다. Pageable 과 Sort 인터페이스를 메소드 파라미터로 전달하여, 요청에 따른 데이터 페이징과 정렬 처리를 쉽게할 수 있습니다.
  • 트랜잭션 관리 : Spring의 선언적 트랜잭션 관리를 지원합니다. @Transactional 어노테이션을 사용하여 메소드 또는 클래스 레벨에서 트랜잭션을 쉽게 관리할 수 있습니다.

 

레포지토리 추상화 

Spring Data JPA의 핵심 기능 중 하나로 개발자가 데이터 접근 계층을 구현하는 과정을 단순화 합니다. 이 추상화를 통해 개발자는 CRUD 작업과 같은 일반적인 데이터 접근 메서드를 매우 간단하게 구현할 수 있습니다.

 

레포지토리는 일반적으로 DAO(Data Access Object)와 유사한 개념으로, 엔티티에 대한 데이터베이스 접근 로직을 캡슐화 합니다. Spring Data JPA에서는 이러한 레포지토리를 인터페이스로 정의하고 Spring이 이 인터페이스 구현체를 자동으로 주입합니다. 이 과정에서 개발자는 직접 SQL을 작성하거나 EntityManager를 주입하여 SQL을 직접 구현할 필요가 없게 됩니다.

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

레포지토리 인터페이스를 정의할 때는 Repository, CrudRepository, PagingAndSortingRepository, JpaRepository 인터페이스를 확장하여 사용할 수 있습니다. 각 인터페이스는 순서대로 더 많은 기능을 제공하게 됩니다. JpaRepository는 가능 많은 기능을 제공하는 인터페이스로 CRUD 작업 뿐만아니라 페이징과 정렬, 배치 작업 등을 지원합니다.

아래는 JpaRepository 인터페이스이며, 다양한 인터페이스를 상속 받고 있습니다.  

@NoRepositoryBean
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    void flush();

    <S extends T> S saveAndFlush(S entity);

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

    /** @deprecated */
    @Deprecated
    default void deleteInBatch(Iterable<T> entities) {
        this.deleteAllInBatch(entities);
    }

    void deleteAllInBatch(Iterable<T> entities);

    void deleteAllByIdInBatch(Iterable<ID> ids);

    void deleteAllInBatch();

    /** @deprecated */
    @Deprecated
    T getOne(ID id);

    /** @deprecated */
    @Deprecated
    T getById(ID id);

    T getReferenceById(ID id);

    <S extends T> List<S> findAll(Example<S> example);

    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}

 

예시

 

다음은 Member 엔티티에 대한 간단한 레포지토리 인터페이스 정의 입니다. 이 예시에서는 JpaRepository 인터페이스를 확정하여 Member 엔티티에 대한 기본적인 데이터 접근 메서드를 자동으로 사용할 수 있게 됩니다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}

 

파리미터 바인딩

Spring Data JPA에서 파라미터 바인딩은 쿼리에 동적으로 값을 주입하는 과정을 의미합니다. 파라미터 바인딩은 크게 두가지 방법이 있습니다. 이름 기반의 바인딩, 위치 기반 바인딩 입니다. 위치 기반 바인딩은 거의 사용하지 않으므로 이름 기반 바인딩에 대해서만 알아보겠습니다. 

 

이름 기반 바인딩

이 방법은 쿼리 내의 변수명을 통해 파리미터를 바인딩합니다. @Param 어너테이션을 사용하여 메소드 파라미터의 이름을 쿼리 내의 변수명과 매핑합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("SELECT m FROM Member m WHERE m.username = :username")
    Member findUsername(@Param("username") String username);
}

 

아래 코드는 멤버의 이름에 해당하는 전체 데이터를 가져오는 예시입니다. List<String> 형태로 바인딩 후 in 절로 쿼리를 조회하게 됩니다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("SELECT m FROM Member m WHERE m.username in :username")
    List<Member> findUsernames(@Param("usernames") List<String> usernames);
}

 

반환 타입

Spring Data JPA에서 메소드 반환 타입은 해당 쿼리의 결과가 어떻게 될 것인가를 결정합니다. 여기서 소개할 세 가지 반환 타입 각각의 특징과 사용 시 고려해야 할 점을 설명하겠습니다.

Member findByUsername(String username)

이 방식은 username을 기준으로 Member 엔티티를 검색하여 정확히 하나의 Member 객체를 반환합니다. 만약 쿼리 결과가 없을 경우 null을 반환하고, 결과가 둘 이상이면 IncorrectResultSizeDataAccessException을 발생시킵니다. 이 방식은 데이터가 반드시 존재 한다는 확신이 있을 때 사용해야 합니다.

 

Optional<Member> findByUsername(String username)

이 반환 타입은 자바8에서 도입된 Optional을 사용합니다. username으로 Member를 검색할 때, 해당 데이터가 존재하지 않을 경우 Optional.empty()를 반환하고 존재할 경우 Optional.of(entity)를 반환합니다. 이 방식은 결과의 존재 여부가 불확실할 때 사용하며, null 을 직접 다루는 것보다 안전한 방법으로 결과를 처리할 수 있습니다. 즉, 결과가 없을 경우 NullPointException을 방지하고 코드를 더 깔끔하게 관리할 수 있습니다.

 

List<Member> findAll()

List 반환의 경우는 반환되는 결과가 없을 경우 비어있는 리스트를 반환합니다.(size 0) 그러므로 해당 반환 타입에서 null 체크를 별도로 해주지 않아도 됩니다. 예를 들어 if ( list != null ) 같은 경우입니다. 결과가 없다는 것을 명시적으로 확인할 필요 없이 바로 반복문 등에서 처리할 수 있는 이점이 있습니다.

 

쿼리 메소드

Spring Data JPA의 쿼리 메소드 기능은 메소드 이름을으로 쿼리를 생성할 수 있게 해줍니다. 이로인해 개발자가 직접 쿼리를 작성할 필요 없이 메소드의 이름을 통해 의도하는 쿼리 작업을 명시적으로 표현할 수 있도록 합니다. 메소드 이름은 Spring Data JPA가 제공하는 명명 규칙에 따라 생성되며, 이 규칙을 바탕으로 실제 실행될 JPQL(Java Persistence Query Language) 쿼리가 자동 생성 됩니다.

 

쿼리 메소드 작동 원리

쿼리 메소드의 이름은 일반적으로 find...By, read...By, query...By, count...By 와 같이 접두어로 시작합니다. 이어서 엔티티의 속성 이름을 기반으로 조건을 명시할수 있으며, 필요에 따라 추가작인 키워드 (And, Or, Between, LessThen, GreaterThen 등)를 사용하여 쿼리 조건을 구성할 수 있습니다.

 

예시 

@Entity
@Getter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;
    private LocalDate credateDate;
}

 

Member 엔티티에 대해 이름(name)으로 회원을 찾는 쿼리 메소드를 만든다면 일반 JPQL 에서는 아래와 같이 개발자가 직접 쿼리를 작성해야 합니다.

SELECT m FROM Member m WHERE m.name = :name

 

하지만 Spring Data JPA의 쿼리 메소드를 사용하게 되면 아래와 같이 쿼리를 작성할 필요가 없어집니다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByName(String name); // 이름으로 회원 검색
}

 

주요 키워드 사용법

키워드 설명 쿼리 예시
And 두 조건을 만족하는 데이터 조회 findByNameAndAge(String name, int age)
Or 두 조건 중 하나라도 만족하는 데이터 조회 findByNameOrAge(String name, int age)
Between 특정 범위 내의 데이터 조회 findByAgeBetween(int startAget, int endAge)
LessThan 주어진 값보다 작인 데이터 조회 findByAgeLessThan(int age)
LessThanEqual 주어진 값보다 작거나 같은 데이터 조회 findByAgeLessThanEqual(int age)
GreateThan 주어진 값보다 큰 데이터를 조회 findByAgeGreaterThan(int age)
GreateThanEqual 주어진 값보다 크거나 같은 데이터 조회 findByAgeGreaterThanEqual(int age)
Not 주어진 값과 일치하지 않는 데이터 조회 findByNameNot(String name)
In 주어진 리스트 속에 속하는 데이터 조회 findByNameIn(List<String> names)
NotIn 주어진 리스트에 속하지 않는 데이터 조회 findByNameNotInt(List<String> names)
After 주어진 날짜 이후의 데이터 조회 findByCreateDateAfter(LocalDateTime date)
Before 주어진 날짜 이전의 데이터 조회 findByCreateDateBefore(LocalDateTime date)
IsNotNull, IsNull null이 아닌 데이터 혹인 null인 데이터를 조회 findByNameIsNotNull(), findByNameIsNull()
OrderBy 결과를 정렬하여 반환 findByAgeOrderByNameDesc(String int age)
StartingWith 특정 문자열로 시작하는 데이터 조회 findByNameStartingWith(String prefix)
EndingWith 특정 문자열로 끝나는 데이터 조회 findByNameEndingWith(String suffix)
Containing 특정 문자열을 포함하는 데이터 조회 findByNameContaining(String infix)

 

@Query 이노테이션 설명

@Query 어노테이션은 Spring Data JPA에서 제공하는 기능으로, 개발자가 직접 JPQL이나 네이티브 쿼리를 메소드에 작성할 수 있게 해줍니다. 쿼리 메소드의 이름으로 표현하기 어려운 복잡한 쿼리를 작성하는데 유용합니다.

 

예시

JPQL로 특정 나이 이상의 회원을 검색하는 쿼리 입니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("SELECT m FROM Member m WHERE m.age >= :age")
    List<Member> findMembersWithAgeGreaterThanOrEqual(@Param("age") Integer age);
}

 

네이티브 쿼리로 특정 나이 이상의 회원을 검색하는 쿼리 

네이티브 쿼리는 entity로 매핑하는게 아니고 실제 table을 매핑하여 작성하여야 합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query(value = "SELECT * FROM member WHERE age >= :age", nativeQuery = true)
    List<Member> findByAgeNative(@Param("age") Integer age);
}

 

동적 쿼리

동적 쿼리는 실행 시점에 쿼리의 조건을 동적으로 구성하여 실행할 수 있는 쿼리를 의미합니다. 즉 어플리케이션의 로직에 따라 쿼리의 조건이 변할수 있어 다양한 요구사항을 유연하게 대응할 수 있습니다. Spring Data JPA에서는 동적 쿼리를 구현하기 위해서 주로 QueryDsl을 사용합니다.

 

예시

public interface MemberRepository extends JpaRepository<Member, Long>, QuerydslPredicateExecutor<Member> {
}

 

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    // 생성자 주입
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Iterable<Member> searchMembers(String name, Integer minAge, Integer maxAge) {
        QMember member = QMember.member;
        
        BooleanBuilder builder = new BooleanBuilder();
        if (name != null) {
            builder.and(member.name.eq(name));
        }
        if (minAge != null) {
            builder.and(member.age.goe(minAge));
        }
        if (maxAge != null) {
            builder.and(member.age.loe(maxAge));
        }

        return memberRepository.findAll(builder);
    }
}

 

위 코드에서 BooleanBuilder는 QueryDsl 조건을 동적으로 구성하는데 사용됩니다. 사용자가 입력한 조건에 따라 BooleanBuilder에 조건을 추가하고 이를 findAll 메소드에 전달하여 조건에 맞는 데이터를 조회 합니다.

 

QueryDsl을 사용하면 복잡하고 다양한 쿼리를 처리할수 있으며, 타입 세이프한 쿼리 작성을 가능하게 합니다. 또한 개발자는 런타임 오류를 줄이고 안정적인 코드를 구현할 수 있습니다.

 

QueryDsl의 자세한 내용은 이어 QueryDsl chapter에서 진행하도록 하겠습니다.

 

페이징과 정렬

페이징은 전체 데이터에서 일정량의 데이터만 선택적으로 로딩하며, 한번에 보여줄 데이터의 양을 제한합니다. 정렬은 특정 기준에 따라 순서대로 배열하여 정렬하는 기능입니다.

 

Spring Data JPA는 페이징과 정렬을 쉽게 구현할 수 있는 기능을 제공합니다. Pageable과 Sort 인터페이스를 활용하여 간단한 파라미터 전달만으로 이 기능을 사용할 수 있습니다.

 

페이징의 반환 타입으로는 세 가지가 존재합니다.

  • org.springframework.data.domain.Page : count 쿼리 결과를 포함하는 페이징
  • org.springframework.data.domain.Slice : count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1 추가 )
  • List 자바 컬렉션 : 추가 count 쿼리 없이 결과만 반환 ( Pageable 객체를 넘겨서 offset, limit만 사용 ) 

 

예시

 아래는 Member 엔티티에 대해 나이에 따라 정렬하고 결과를 페이징하여 조회하는 레포지토리 메소의 예시입니다.

@Entity
@Getter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer age;
}

 

MemberRepository 인터페이스는 JpaRepository를 확장하고 페이징과 정렬을 위한 메소드를 정의합니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByAgeLessThanEqual(Integer age, Pageable pageable);
}

 

이제 서비스 계층에서 MemberRepository의 findByAgeGreaterThanEqual 메소드를 호출할 때, Pageable 객체를 생성하여 전달함으로써 페이징과 정렬을 적용할 수 있습니다.

@Service
public class MemberService {

    private final MemberRepository memberRepository;

    // 생성자를 통한 의존성 주입
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Page<Member> getMembersWithPaginationAndSorting(int page, int size, String sortDirection, String sortBy) {
        Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
        Pageable pageable = PageRequest.of(page, size, sort);
        return memberRepository.findByAgeLessThanEqual(18, pageable);
    }
}

 

위 메소드는 나이가 18세 이상인 멤버를 조회하되, 사용자로부터 받은 페이지 번호(page), 페이지 크기(size) 정렬방향 (sortDirection), 정렬 기준(sortBy)을 바탕으로 페이징과 정렬을 적용합니다. 결과는 Page<Member> 객체로 변환하고, 이 객체는 요청된 페이지에 데이터 뿐만 아니라 데이터 수, 전체 페이지 수 등 페이징 처리에 필요한 추가 정보가 포함 됩니다.

 

주의 사항은 Spring Data JPA의 페이징 처리는 기본적으로 페이지 번호 0 부터 시작합니다. 따라서, 직관적으로 페이지 번호가 1이 첫페이로 하고 싶다면 서비스 계층에서 페이지 번호를 처리할 때 사용자로 부터 받은 페이지 번호에서 1을 빼주어, Spring Data JPA가 이해할 수 있는 0 기반 페이지 번호로 변환해 주어야 합니다.

public Page<Member> getMembersWithPaginationAndSorting(int page, int size, String sortDirection, String sortBy) {
    // 사용자가 페이지 1을 첫 번째 페이지로 요청했다고 가정할 때, Spring Data JPA의 0 기반 페이지 인덱스에 맞추기 위해 1을 빼줌
    page = page - 1;

    Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);
    return memberRepository.findByAgeLessThanEqual(18, pageable);
}

 

이제 서비스를 호출하는 controller을 구현하도록 하겠습니다.

@GetMapping("/members")
public Page<MemberResponseDto> getMembersWithPaginationAndSorting(
        @RequestParam(value = "page", defaultValue = "0") int page,
        @RequestParam(value = "size", defaultValue = "10") int size,
        @RequestParam(value = "sortDirection", defaultValue = "ASC") String sortDirection,
        @RequestParam(value = "sortBy", defaultValue = "username") String sortBy) {
    // 서비스 메소드 호출
    return memberService.getMembersWithPaginationAndSorting(page, size, sortDirection, sortBy);
}

 

 

하지만 위 코드는 Member 엔티티를 바로 반환하는 문제점을 가지고 있습니다. 이렇게 되면 여러가지 문제가 발생할 수 있으므로 Dto객체를 만들어 반환하도록 하겠습니다.

@Data
@Builder
public class MemberResponseDto {

    private Long id;
    private String username;
    private int age;
    private String teamName;
}
public Page<MemberResponseDto> getMembersWithPaginationAndSorting(int page, int size, String sortDirection, String sortBy) {
    Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Member> members = memberRepository.findByAgeLessThanEqual(18, pageable);

    List<MemberResponseDto> dtoList = members.stream()
            .map(member -> MemberResponseDto.builder()
                    .id(member.getId())
                    .username(member.getUsername())
                    .age(member.getAge())
                    .teamName(member.getTeam() != null ? member.getTeam().getName() : null) // 팀이 null이 아니면 팀 이름을, null이면 null을 설정
                    .build())
            .collect(Collectors.toList());
    return new PageImpl<>(dtoList, pageable, members.getTotalElements());
}

 

위 코드를 아래와 같이 변환할 수 있습니다. Page 객체는 기본적으로 stream map를 지원함으로써 쉽게 변환 가능 합니다.

public Page<MemberResponseDto> getMembersWithPaginationAndSorting(int page, int size, String sortDirection, String sortBy) {
    Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Member> members = memberRepository.findByAgeLessThanEqual(40, pageable);

    return members.map(member -> MemberResponseDto.builder()
            .id(member.getId())
            .username(member.getUsername())
            .age(member.getAge())
            .teamName(member.getTeam() != null ? member.getTeam().getName() : null) // 팀이 null이 아니면 팀 이름을, null이면 null을 설정
            .build());
}

 

아래는 결과 화면 입니다.

{
    "content": [
        {
            "id": 1,
            "username": "member1",
            "age": 10,
            "teamName": "teamA"
        },
        {
            "id": 2,
            "username": "member2",
            "age": 20,
            "teamName": "teamA"
        },
        {
            "id": 3,
            "username": "member3",
            "age": 30,
            "teamName": "teamB"
        },
        {
            "id": 4,
            "username": "member4",
            "age": 40,
            "teamName": "teamB"
        }
    ],
    "pageable": {
        "pageNumber": 0,
        "pageSize": 10,
        "sort": {
            "empty": false,
            "unsorted": false,
            "sorted": true
        },
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "totalPages": 1,
    "totalElements": 4,
    "last": true,
    "size": 10,
    "number": 0,
    "sort": {
        "empty": false,
        "unsorted": false,
        "sorted": true
    },
    "numberOfElements": 4,
    "first": true,
    "empty": false
}

 

벌크성 수정 쿼리

벌크성 수정 쿼리는 영속성 컨텍스트를 무시하고 대량의 데이터를 한 번에 수정 및 반영이 일어납니다. 이로 인해 성능상 이점이 있을 수 있으나 연산 후 영속성 컨텍스트는 변경사항을 인지하지 못하므로 필요한 경우 영속성 컨텍스트를 수동으로 동기화 해야 합니다.

 

벌크 수정 쿼리 사용시 주의 사항

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 적용됩니다.
  • 연산 후, 영속성 컨텍스트(1차캐시)에는 변경 사항이 반영되지 않으므로 연산을 수행한 후 entityManager.flush(), entityManager.clear()을 호출하여 영속성 컨택스트를 수동으로 동기화 해야 할 수 있습니다.

예시

회원 나이를 + 1 하는 벌크 연산을 진행하도록 하겠습니다 

@Transactional
@Modifying
@Query(" update Member m " +
        " set m.age = m.age + 1 " +
        " where m.age >= :age")
int bulkAgePlus(int age);

 

  • @Transactional 어노테이션을 사용하여 트랜잭션 관리를 활성화 합니다.
  • @Modifying : 이 어노테이션은 수정, 삭제 등의 벌크 연산에 사용되며, 이를 통해 Spring Data JPA는 해당 메소드가 벌크 연산임을 인지하고 처리합니다.

만약, 연산 후 다른 작업이 필요하다면 영속성 컨텍스트를 수동으로 동기화 해야 합니다. Test코드, Service 레이어 또는 Repository을 호출하는 클래스에서 아래와 같이 작업을 해줘야 합니다.  그럼 영속성 컨텍스트는 초기화 하고 findByUsername으로 새롭게 조회하여 영속성 컨텍스트를 구성합니다.

@Autowired
private EntityManager em;

@Test
void bulkAgePlus() {

    memberRepository.bulkAgePlus(10);
    em.flush();
    em.clear();
    Member member = memberRepository.findByUsername("member1");
    Assertions.assertThat(member.getAge()).isEqualTo(11);
}

 

위 방법 말고 Repository에서 쿼리 실행시 영속성 컨텍스트를 초기화 하는 옵션을 추가할 수 있습니다. 그럼 위 코드는 사용하지 않아도 됩니다.  @Modifying(clearAutomatically = true) 옵션 추가

@Transactional
@Modifying(clearAutomatically = true)
@Query(" update Member m " +
        " set m.age = m.age + 1 " +
        " where m.age >= :age")
int bulkAgePlus(int age);

 

'JPA' 카테고리의 다른 글

JPA 조인&서브쿼리  (2) 2024.03.12
JPA 조회 전략  (0) 2024.03.07
JPA Entity 설계시 베스트 프랙티스  (0) 2024.02.22
JPA OSIV  (0) 2024.01.31
JPA Auditing  (0) 2024.01.30