본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 6: Spring Boot에서 Repository 활용

들어가기

데이터베이스에서 데이터를 조회하거나 저장하기 위해서 연동해주는 객체가 필요하다. 이를 리포지토리라고 한다. 일반적인 리포지토리이면 실제 데이터베이스에 데이터를 저장하거나 조회하기 위한 JDBC를 사용하거나 라이브러리 사용할 경우 쿼리를 작성해 줘야한다. 그러나 JPA에서는 더 쉽게 자동으로 처리해준다. 어떻게 할 수 있는지 살펴보자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

리포지트리 구조

Spring Data에서 리포지토리를 위한 Repository 인터페이스를 제공한다. 이 인터페이스를 통해 데이터베이스와 연동된다. 아래는 Spring Data JPA에서 제공되는 Repository의 상속 트리 구조이다. Repository 인터페이스를 통해서 접근할 경우 구체적인 데이터 액세스 코드는 Spring Data에서 처리해준다.

Fig 01. Repository 상속트리

Repository 인터페이스를 정의를 보면 몸체는 비어 있다.

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

@Indexed에 의해서 해당 인터페이스를 확장하거나 구현된 경우 자동으로 인덱싱되서 스테레오 타입기반(정규화된 이름)으로 다른 컴포넌트 후보에 입력된다. Spring Data에서는 Repository를 상속한 경우 자동으로 인지해서 자동으로 구체화된 객체를 만들어서 빈객체로 등록해준다. Repository를 상속한 다른 인터페이스도 있지만 시작에는 Repository 인터페이스를 사용할 예정이다.

우리가 할일은 Repository 인터페이스를 상속하고 메소드만 추가하면 끝이다. 여기서 추가되는 메소드가 쿼리 메소드라고 하며 특정 형식으로 작성하면 된다.

사전 작업

테스트를 위한 사전 작업이다. 기본적인 프로젝트 구성은 “JPA 1: Spring Boot에서 간단한 CRUD”에서 기본으로 추가로 구성하였다. 아래는 사용할 Member 엔티티이다.

@Entity
open class Member(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private var id:Long?,
    private var name:String?,
    private var likes: Int,
) {
    constructor() : this(null, null, 0) {}
    open fun getId(): Long? = id
    open fun setId(id:Long?) {
        this.id = id
    }
    open fun getName(): String? = name
    open fun setName(name:String?) {
        this.name = name
    }
    open fun getLikes(): Int = likes
    open fun setLikes(likes:Int) {
        this.likes = likes
    }
}

해당 엔티티에 대한 초기화 데이터를 위한 data.sql 파일을 resource 디렉토리에 만들어보자.

insert into member(name, likes) values ('foo3', 1);
insert into member(name, likes) values ('foo1', 1);
insert into member(name, likes) values ('foo5', 2);
insert into member(name, likes) values ('foo2', 2);
insert into member(name, likes) values ('foo4', 2);

시퀀스를 이용해서 데이터를 추가해줬다. 그리고 초기화 데이터를 엔티티 테이블 초기화 후에 로딩하도록 설정해줘야 한다. application.properties 파일에 다음 설정을 추가한다.

spring.sql.init.mode=always
spring.jpa.defer-datasource-initialization=true

만약 Spring Data가 2.4 이전이면 "spring.datasource.initialization-mode=always”으로 사용해야 한다. 참고로 여기서는 schema.sql은 추가하지 않았다.

쿼리 메소드

퀘리 메소드는 특정 형식을 갖는 메소드로 형식에 맞게 작성하면 Spring Data에서 데이터 액세스 구현체를 자동으로 생성해준다.

데이터 조회는 데이터에 따라서 매우 다양한 조건에 의해 검색하고 정렬도 필요하다. 그렇기에 이런 다양한 조건에 고려하기 때문에 조회 관련 메소드에 파라미터가 복잡해진다. 또한 모든 상황을 고려한 쿼리를 작성할 수 없기 때문에 조회 쿼리가 필요할 때 마다 추가하거나 수정된다.

JPA에서는 쿼리 메소드를 통해 이런 작업을 좀더 쉽게 사용할 수 있게 해준다. 시작하기 전에 맞보기로 간단한 예제를 보자. 먼저 Repository을 구현해보자.

interface MemberRepository : Repository<Member, Long> {
}

Repository는 제너릭 인터페이스로 첫번째 타입인자는 도메인 타입이고 두번째 타입인자는 식별자 타입이다. 현재 Repository에는 아무런 메소드가 없기에 작동하지 않는다. 이럴 경우 아무것도 하지 못한다. 아래와 같이 조회 메소드 하나를 추가해보자.

interface MemberRepository : Repository<Member, Long> {
    fun findById(id: Long): Optional<Member>
}

findById는 어디서 많이 본 메소드이다. 이전에 사용했던 JpaRepository을 사용한 예제에서 보았다. 재미 있는 부분은 Repository에는 단순히 메소드를 추가했다. 그런 후에 빌드하고 실행하면 에러 없이 잘 작동한다. Spring Data에서 MemberRepository 인터페이스의 구현부를 자동으로 추가했기에 에러 없이 실행된다. 여기서 메소드 이름을 잘 살펴보면 “findBy” 요청에 의해서 “Id”인 기본키 식별자를 조회하는 메소드이다. 즉, findById 메소드 이름에서 알 수 있듯이 기본키로 Member 엔티티를 찾는 메소드가 된다.

메소드 이름으로 가지고 쿼리를 생성하는 형태라서 쿼리 메소드라라고 한다. 이런 쿼리 메소드는 아래과 같은 형식으로 정의한다.

{Subject}{Predicate}

Subject 키워드는 어떤 작업할 쿼리인지를 표현한다. Subject를 좀 더 쉽게 이해하기위해서 아래처럼 세부적으로 구분해보았다.

{Verb}{Qualifier}{Preposition}{Predicate}

Subject 키워드는 Verb/Preposition과 Qualifier으로 분리할 수 있다. Verb/Preposition는 작업 종류를 구분하고 Qualifier에서 작업 결과에 대한 제한자 역할을 한다. Verb/Preposition에 해당하는 키워드는 다음과 같다.

  • find…by, read..by, get..by, query…by, search…by, stream…by: 일반적인 쿼리로 도메인 타입이 리턴된다. Page, GeoResults나 다른 래퍼 타입에서는 Collection이나 Streamable 서브타입이 리턴될 수도 있다.
  • exists..by: 존재 여부를 확인한다. boolean 형으로 리턴한다.
  • count…by: 개수를 반영한다. 숫자로 리턴한다.
  • delete..by, remove..by: 삭제 쿼리를 실행한다. 결과는 void 또는 삭제된 개수가 리턴된다.

이름에서 직관적으로 알 수 있듯이 조회, 삭제 작업임을 알 수 있다.

Qualifier에 해당하는 키워드는 선택적으로 사용되며 지원되는 키워드는 다음과 같다.

  • …First{number}…, …Top{number}…: 쿼리 개수를 {number} 만큼 제한한다. 이 키워드는 {subject} 중간에 위치된다. 예를 들어 find와 by 사이에 있다.
  • …Distinct…: 중복제거(distinct) 쿼리 사용해서 유니크한 결과 리턴한다. 이는 벤더마다 지원하지 않을 수 있다. 이 키워드도 {subject} 중간에 위치한다.

다음으로 마지막에 Predicate 부분은 조금 복잡하다. 여러 조건과 엔티티 속성들이 결합되면서 다양한 형태가 구성된다. 즉 한개 Predicate 키워드만 사용하는게 아니라 여러 개가 같이 사용되면서 다양한 조건을 구성할 수 있게 한다.

다음은 쿼리 메소드 조건 키워드(predicate keyword)와 수정자(modifier)이다.

키워드 쿼리 메소드 예 쿼리 예
And findByVar1AndVar2 where x.var1 = ? and x.var2=?
Or findByVar1OrVar2 where x.var1 = ? or x.var2=?
Is, Equals findByVar1, findByVar1Is, findByVar1Equals where x.var1 = ?
Between findByVar1Between where x.var1 between ? and ?
LessThan findByVar1LessThan where x.var1 < ?
LessThenEquan findByVar1LessThanEqual where x.var1 <= ?
GreaterThan findByVar1GreaterThan where x.var1 > ?
GreaterThanEqual findByVar1GreaterThanEqual where x.var1 >= ?
After findByVar1After where x.var1 > ?
Before findByVar1Before where x.var1 < ?
IsNull, Null findByVar1(Is)Null where x.var1 is null
IsNotNull, NotNull findByVar1(Is)NotNull where x.var1 not null
Like findByVar1Like where x.var1 like ?
NotLike findByVar1NotLike where x.var1 not like ?
StartingWith findByVar1StartingWith where x.var1 like ?‘%’
EndingWith findByVar1EndingWith where x.var1 like ‘%’?
Containing findByVar1Containing where x.var1 like ‘%’?’%’
OrderBy findByVar1OrderByVar2Desc where x.var1 = ? order by x.var2 desc
Not findByVar1Not where x.var1 <> ?
In findByVar1In where x.var1 in ?
NotIn findByVar1NotIn where x.var1 not in ?
True, False findByVar1True were x.var1 = true
IgnoreCase, IgnoringCase findByVar1IgnoreCase where UPPER(x.var1) = UPPER(?)

쿼리 메소드를 예를 보면 알 수 있지만 And나 Or로 여러 조건이 조합될 수 있다. 실제 쿼리 생성에서도 where 구문에 포함된다. 이를 사용해 다양한 조건으로 데이터 처리를 할 수 있다.

페이징과 정렬

페이징과 정렬 기능에 대해서 살펴보자.

페이징

JPA에서 페이징을 처리하기 위한 객체가 org.springframework.data.domain.Pageable이 있다. 이를 통해서 페이징 요청을 처리할 수 있다. 먼저 컨트롤 단에서 Pageable을 입력 받을 수 있도록 정의하자.

@RestController
@RequestMapping("/api/members")
class MemberController {
    @GetMapping
    fun search(pageable: Pageable) : List<Member> {
        return memberRepo.findAll(pageable)
    }
    //...
}

요청 데이터가 Pageable 객체로 넘겨지게 된다. 요청 파라미터로는 다음과 같다.

  • page: 현재 페이지로 0에서 시작(기본: 0)
  • size: 페이지당 데이터 개수(기본: 20)
  • sort: 정렬 조건

만약 Pageable의 기본값을 변경할려고 할 경우 @PageableDefault 어노테이션을 사용할 수 있다.

@GetMapping
fun search(@PageableDefault(size=30) pageable: Pageable) : List<Member> {
   return memberRepo.findAll(pageable)
}

다음으로 MemberRepository 언터페이스에 findAll 쿼리 메소드를 추가한다. 인자는 Pageable 객체로 한다. 그리고 리턴은 List 객체로 한다.

open interface MemberRepository() : Repository<Member, Long> {
  fun findAll(pageable: Pageable): List<Member>
}

실제 호출해보자. GET 호출할 경우 요청 파라미터이다.

/api/members?page=2&size=3

쿼리 스트링으로 page, size을 입력 받고 있다. 이 입력 값이 Pageable 객체로 findAll 메소드로 전달한다.

만약 페이징 정보가 두개 이상이 포함되는 경우 접두사를 사용해 구분할 수 있다. 예를 들어 member와 post에 대한 페이징 정보가 포함된다면 컨트롤은 다음과 같은 형태가 된다.

@RestController
@RequestMapping("/api/members")
class MemberController {
    @GetMapping("/post")
    fun get(
        @Qualifier("member")
        memberPageable: Pageable,
        @Qualifier("post")
        postPageable: Pageable)
  : List<Member> {
        return memberRepo.findAllByMemberAndPost(memberPageable, postPageable)
    }
    //...
}

GET 호출은 다음과 같다.

/api/members/post?member_size=2

@Qualifier 어노테이션으로 쿼리 스트링의 밑줄 앞에 “member”로 구분해서 값이 매핑된다.

정렬

정렬은 org.springframework.data.domain.Sort 객체를 사용한다. 정렬 요청 데이터가 Sort에 저장되고 Pageable 객체에 포함해서 컨트롤로 전달된다. 기존에 컨트롤러와 리포지토리는 크게 변경이 없다. 그럼 어떻게 호출할 수 있는지 확인해보자.

/api/members/post?size=3&sort=name,asc

위에 인자로 sort을 사용하고 값에 정렬할 속성 이름과 정렬 방식을 넘겨준다. 정렬 방식은 asc(오름차순), desc(내림차순)으로 지정할 수 있다. 만약 정렬할 속성이 여러개 일 경우 sort 인자를 여러가 사용하면 된다.

/api/members/post?size=3&sort=name,asc&sort=likes,desc

위의 요청은 가져올 데이터 개수는 3이고 이름은 오름 차순, 좋아요는 내림 차순으로 정렬해서 가져온다.

PageRequest 클래스

Pagable 인터페이스는 Spring boot에서 내부에서는 org.springframework.data.domain.PageRequest를 사용해서 인스탄스화한다. 내부에서 페이징과 정렬 정보를 생성해서 요청할 경우 직접 PageRequest 객체를 생성해야 한다. PageRequest 객체 생성은 of()를 사용해서 손쉽게 인스탄스화할 수 있다. 예를 보자.

val pageable:Pageable = PageRequest.of(0, 10)

페이지 위치는 0번이고 한 페이지에 10개 데이터를 요청하는 PageRequest 객체를 생성한다.

또한 PageRequest에 정렬 요청 정보를 Sort 객체로 생성해서 PageRequest로 넘긴다.

val pageable1: Pageable = PageRequest.of(0, 10, Sort.by("name"))
val pageable2: Pageable = PageRequest.of(0, 10,
    Sort.by("name").and(Sort.by("likes").descending())
)

Sort 클래스의 by()을 사용해서 정렬 정보를 생성한다. 또한 Sort 객체의 and()을 통해 여러 정렬 조건을 나열 할 수 있다. 그리고 기본 정렬 순서는 오름 차순이다. 만약 내림 차순으로 하고 싶다면 descending()를 호출하면 된다. 이제 만들어진 Pageable 객체를 리포지토리로 넘겨서 호출하면 된다.

Repository 서브타입

앞에서는 Repository 인터페이스를 상속받아서 쿼리 메소드를 정의해서 사용했다. Spring Data에서는 다양한 Repository 서브타입이 있다.

먼저 CrudRepository 인터페이스이다. 이름 그대로 생성, 조회, 수정, 삭제 관련 메소드가 추가되었다.

public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S entity);
    <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();
}

앞에 쿼리 메소드 기준으로 보면 각 메소드마다 어떤 기능을 제공하는지 쉽게 이해된다. 이미 CRUD 관련해서 기본적인 쿼리 메소드를 추가해놓은 인터페이스이다. 이전에 보지못한 save도 보인다.

ListCrudRepository 인터페이스는 CrudRepository에서 List 객체에 대한 처리가 더 추가되었다.

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);
}

기존에는 Interable 개체로 가져와서 데이터를 순회해서 가져와야 했지만, ListCrudRepository에 의해 List 형태로 목록 데이터를 가져올 수 있게 되었다. 의미 그대로 기존 CRUD에 List 관련한 쿼리 메소드가 더 추가되었다.

다음은 페이지 처리관련된 PagingAndSortingRepository인터페이스이다.

public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {
    Page<T> findAll(Pageable pageable);
    Iterable<T> findAll(Sort sort);
}

기본 Repository 인터페이스를 상속하고 페이징 처리위한 Pageable를 사용한 쿼리 메소드가 추가되었다. findAll()에 대해 Pageable으로 호출하기도 하지만 Sort으로도 호출할 수 있다. 재미 있는 부분은 리턴 타입으로 org.springframework.data.domain.Page를 사용하고 있다. List 대신 Page을 사용할 경우 모든 목록에서 현재 조회된 데이터의 위치를 알 수 있다.

public interface Page<T> extends Slice<T> {
    int getTotalPages(); // 전체 페이지 개수
    long getTotalElements(); // 전체 데이터 개수
    //...
}

Page 의 각 메소드의 의미는 주석을 참조하기 바란다.

public interface Slice<T> extends Streamable<T> {
    List<T> getContent(); // 슬라이스에 있는 데이터 조회
    int getNumber(); // 현재 슬라이스 순번
    int getNumberOfElements(); // 현재  슬라이스에 있는 데이터 개수
    int getSize(); // 슬라이스 크기
    boolean hasContent(); // 데이터 존재 여부
    Sort getSort(); // 정렬 정보 요청
    //...
}

그리고 Slice 인터페이스를 통해 현재 위치 정보를 알 수 있다. 각 메소드에 대한 설명은 주석을 참조하기 바란다.

또한 CrudRepsotiry와 비슷하게 데이터 목록을 처리하기 위해 PagingAndSortingRepository를 상속한 ListPagingAndSortingRepository 인터페이스를 정의한다.

public interface ListPagingAndSortingRepository<T, ID> extends PagingAndSortingRepository<T, ID> {
    List<T> findAll(Sort sort);
}

List 객체에 대한 findAll()을 추가했다. Pageable 인자를 갖는 findAll()인 경우 Page에 getContent()에 의해서 이미 List 객체형태로 가져올 수 있다.

그리고 마지막으로 모두 추가한 JpaRepository 인터페이스가 있다.

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);
    void deleteAllInBatch(Iterable<T> entities);
    void deleteAllByIdInBatch(Iterable<ID> ids);
    void deleteAllInBatch();
    T getReferenceById(ID id);
    <S extends T> List<S> findAll(Example<S> example);
    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}

결국 리포지토리를 정의한다면 JpaRepository를 상속하면 되고, 새로운 쿼리 메소드와 기존 쿼리 메소드가 이미 추가되었다. 그렇기 때문에 별도 쿼리 메소드 정의없이 바로 사용할 수 있다. 물론 특정 조건에 의한 처리를 할 경우 추가할 수 있다. 그렇지 않다면 가급적 기존 메소드를 사용하기를 추천한다. 추가로 deprecated된 메소드는 생략했다.

마무리

Spring Data에서 Repository을 활용한 쿼리 메소드 및 다양한 Repository 인터페이스를 살펴보았다.

혹시나 해서 말씀드리는건데 여기서 예제는 간단하게 JPA 기술을 이해하고 활용 위해 최대한 단순화된 예제입니다. 여기 내용을 그대로 실무에 적용하는 것을 권장하지 않습니다. 각자 자신의 환경에 맞게 JPA를 적용하시기 바랍니다.

부족한 글이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활되세요. ospace.

참고

[1] 최범균, JPA 프로그래밍 입문, 가메출판사, 2017

[2] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[3] Jan92, Spring Boot 초기 데이터 설정 방법 정리(data.sql, schema.sql), https://wildeveloperetrain.tistory.com/228, 2024/04/17

[4] Interface Repository, https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/repository/Repository.html, 2024/04/17

[5] JPA Query Methods, https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html, 2024/04/17

[6] Interface Page, https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Page.html, 2024/04/17

반응형