본문 바로가기
백엔드(Back-End)/JPA(Java Persistence API)

[Spring Data JPA] - 쿼리 메서드 기능, JPA NamedQuery, Repository 사용자 정의 쿼리 메서드

by TwoJun 2023. 10. 11.

[Spring Data] - Spring Data JPA(Java Persistence API)

 

 

1. 메서드 이름으로 쿼리 생성

이전 포스팅에서 마지막에 남은 궁금증이 하나 존재했다. 공통 영역을 벗어난 도메인에 특화된 기능들은 어떻게 해결해야 할까?

 

(1) 스프링 데이터 JPA가 제공하는 강력한 쿼리 메서드 기능 3가지가 존재한다.

- 메서드 이름을 분석하여 자체적인 쿼리 생성

- 메서드 이름으로 JPA NamedQuery 호출

- @Query 어노테이션을 사용하여 Repository Interface 영역에 직접 쿼리 메서드 정의하기

 

 

 

 

1-1. 메서드 이름으로 쿼리 생성

(1) 저번 포스팅에서 스프링 데이터 JPA를 상속받지 않았던 MemberJpaRepository에 사용자의 이름을 대상으로 특정 나이보다 나이가 많은 사용자를 조회하는 기능 요구사항이 추가되었다고 가정해 보자.

@Repository
public class MemberJpaRepository {
    // ...

    public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
        return em.createQuery("select m from Member m where m.username = :username and m.age > :age")
                .setParameter("username", username)
                .setParameter("age", age)
                .getResultList();
    }
}

(2) 위와 같이 해당 Repository에 findByUsernameAndAgeGreaterThan() 메서드를 정의하고 JPQL을 작성할 수 있다.

 

 

class MemberJpaRepositoryTest {
    @Autowired MemberJpaRepository memberJpaRepository;
    
    // ...

    @Test
    public void findByUsernameAndAgeGreaterThan() {
        //Given
        Member member1 = new Member("AAA", 10);
        Member member2 = new Member("AAA", 20);
        memberJpaRepository.save(member1);
        memberJpaRepository.save(member2);

        // When
        List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThan("AAA", 15);

        // Then
        Assertions.assertThat(result.get(0).getUsername()).isEqualTo("AAA");
        Assertions.assertThat(result.get(0).getAge()).isEqualTo(20);
        Assertions.assertThat(result.size()).isEqualTo(1);
    }
}

(3) findByUsernameAndAgeGreaterThan() 메서드를 검증하기 위해 MemberJpaRepositoryTest 클래스에 테스트 케이스를 작성하고 위와 같이 검증을 진행했다.

 

테스트 케이스에 대해 테스트가 통과되었다.

(4) 작성한 메서드에 대해 기대했던 결과가 그대로 나오게 되어 테스트 케이스가 통과한 것을 확인할 수 있다.

 

(5) 스프링 데이터 JPA가 제공하는 기능을 사용해서 위의 상황을 다시 한 번 해결해 보자.

 

 

 

 

1-2. MemberRepository에서 스프링 데이터 JPA 쿼리 메서드 기능 사용하기

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}

(1) 위의 코드처럼 스프링 데이터 JPA를 상속받는 MemberRepository 인터페이스 내부에 메서드를 작성해 주면 스프링 데이터 JPA는 메서드 이름을 분석해서 그에 맞는 JPQL을 생성한다.

 

 

@SpringBootTest
@Transactional
@Rollback(false)
class MemberRepositoryTest {
    @Autowired MemberRepository memberRepository;

    @Test
    public void findByUsernameAndAgeGreaterThan() {
        //Given
        Member member1 = new Member("AAA", 10);
        Member member2 = new Member("AAA", 20);
        memberRepository.save(member1);
        memberRepository.save(member2);

        // When
        List<Member> result = memberRepository.findByUsernameAndAgeGreaterThan("AAA", 15);

        // Then
        Assertions.assertThat(result.get(0).getUsername()).isEqualTo("AAA");
        Assertions.assertThat(result.get(0).getAge()).isEqualTo(20);
        Assertions.assertThat(result.size()).isEqualTo(1);
    }
}

 

테스트 케이스 통과

(2) MemberRepositoryTest 클래스에서 동일하게  findByUsernameAndAgeGreaterThan() 메서드에 대한 테스트 케이스를 작성해서 테스트를 진행해봐도 앞선 테스트와 동일한 결과를 얻을 수 있다.

 

 

 

 

1-3. 스프링 데이터 JPA가 제공하는 쿼리 메소드 기능 

(1) 조회

- find...By, read...By, query...By, get...By

 

(2) COUNT

- count...By 반환타입 long

 

(3) EXISTS

- exists...By 반환타입 boolean

 

(4) 삭제(DELETE)

- delete...By, remove...By 반환타입 long

 

(5) DISTINCT

- findDistinct, findMemberDistinctBy

 

(6) LIMIT

- findFirst3, findFirst, findTop, findTop3 ...

 

 

 

 

1-4. 기본적인 쿼리 메서드의 한계와 장점

(1) 쿼리 메서드의 이름만으로 정해진 관례에 따라 원하는 데이터에 대한 작업을 진행할 수 있다는 점이 쿼리 메서드가 가지는 강력한 기능이다.

 

(2) 하지만 쿼리나 비즈니스 요구사항이 조금이라도 복잡해지면 단순한 쿼리 메서드만으로는 문제를 해결하기 어렵고  다른 방법을 통해 해결해야 한다.

 

(3) 쿼리 메서드 기능은 엔티티의 필드가 변경되면 인터페이스에 정의된 메서드 이름도 반드시 변경되어야 한다. 변경하지 않을 경우 애플리케이션 로딩 시점에 컴파일 오류가 발생하게 된다. 이렇게 애플리케이션 로딩 시점에 오류를 체크할 수 있다는 부분이 스프링 데이터 JPA의 큰 장점으로 볼 수 있다.

 

 

 

 

 

2. JPA NamedQuery

(1) Named Query 기능은 사용할 수도 있겠지만 대부분 실무에서 사용되지 않는다.

 

 

2-1. NamedQuery

@NamedQuery(
        name = "Member.findByUsername",
        query  = "select m from Member m where m.username = :username"
)
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;
    
    // ...
}

(1) 엔티티 클래스에 @NamedQuery 어노테이션을 적용하여 NamedQuery를 작성할 수 있다.

 

 

public List<Member> findByUsername(String username) {
        return em.createNamedQuery("Member.findByUsername", Member.class)
                .setParameter("username", username)
                .getResultList();
}

(2) 위와 같이 직접 JPA를 사용해서 NamedQuery를 호출할 수 있다.

 

 

 

 

2-2. 스프링 데이터 JPA로 NamedQuery 사용

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query(name = "Member.findByUsername")
    List<Member> findByUsername(@Param("username") String username);
}

 

 

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsername(@Param("username") String username);
}

(1) 스프링 데이터 JPA를 사용할 경우 메서드 상단의 @Query 어노테이션을 생략할 수도 있다.

 

(2) 스프링 데이터 JPA는 선언한 "도메인 클래스.메서드 이름"으로 NamedQuery를 우선적으로 찾아서 실행한다. 만약 NamedQuery가 존재하지 않으면 메서드 이름 기반 쿼리 생성으로 전략을 변경하여 쿼리를 생성한다.

 

(3) 필요에 따라 전략의 순서를 변경할 수도 있지만 권장되지 않는 방법이다.

 

 

 

 

2-2. NamedQuery가 갖고 있는 가장 큰 장점

(1) NamedQuery가 갖고 있는 강력한 장점은, 애플리케이션 로딩 시점에 쿼리(정적 타입)을 한 번 파싱해서 오류가 존재한다면 오류 체크를 진행해준다.

 

(2) 기본적으로 정적 쿼리이기 때문에 애플리케이션 로딩 시점에 쿼리를 모두 파싱하고 이후 JPQL을 SQL로 변환한다. 그런 과정을 거치면서 문법 오류를 미리 방지할 수 있는 기능을 제공하고 있다. 

 

 

 

 

2-3. NamedQuery의 한계 극복, Repository 메서드에 쿼리 정의하기

(1) NamedQuery의 장점을 보유하고 있으면서도 Repository 영역에 쿼리를 작성할 수 있는 기능이 존재하는데 이 기능이 실무에서 많이 사용되고 있다.

 

 

 

 

 

 

3. @Query, Repository 메서드에 쿼리 정의

3-1. Repository 메서드에 쿼리 정의

public interface MemberRepository extends JpaRepository<Member, Long> {

    // Repository 메서드에 쿼리 정의
    @Query("select m from Member m where m.username = :username and m.age = :age")
    List<Member> findUser(@Param("username") String username, @Param("age") int age);
}

(1) @Query 어노테이션을 사용하고, @Query의 경우 이름이 없는 NamedQuery로 볼 수 있다.

 

(2) 실무에서는 파라미터의 개수가 증가하면 이에 따라 메서드 이름이 매우 복잡해지기 때문에 메서드 이름으로 쿼리를 생성하는 것보다 해당 방식을 많이 사용하게 된다.

 

 

 

 

3-2. Repository 메서드 쿼리의 장점 - 애플리케이션 로딩 시점에서의 문법 오류 체크 기능

(1) @Query 어노테이션 영역에 정의된 쿼리는 정적 쿼리이므로 마찬가지로 파싱 과정을 거치고 SQL로 변환하는 과정에서 문법 오류를 체크할 수 있는 기능이 존재한다.

 

(2) 실무에서 쿼리 메서드 기능은 단순한 기능을 구현할 때, Repository 메서드 쿼리의 경우 조금 기능이나 요구사항이 복잡해질 경우 사용한다. 

 

(3) 동적 쿼리의 경우 다른 여러 가지 방법도 있지만 QueryDSL을 사용해서 해결할 수 있다.

 

 

 

 

3-3. @Query - 단순 값 조회하기

(1) 엔티티의 단순 필드를 조회하기 위해 MemberRepository 인터페이스에 @Query 어노테이션을 적용하여 메서드를 작성한다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m.username from Member m")
    List<String> findUsernameList();
}

 

 

@Test
public void findUsernameList() {
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("BBB", 20);
    memberRepository.save(member1);
    memberRepository.save(member2);

    List<String> usernameList = memberRepository.findUsernameList();
    for (String s : usernameList) {
        System.out.println("s = " + s);
    }
}

(2) MemberRepositoryTest 클래스에 단순 필드를 조회하기 위한 테스트 케이스를 작성하고 테스트를 진행한다.

 

 

테스트 케이스 통과

(3) 정상적으로 테스트 케이스가 통과한 것을 확인할 수 있다.

 

 

 

 

3-4. @Query, DTO(Data Transfer Object) 값 조회하기

(1) DTO 객체를 직접 조회해 볼 수 있다. 새로운 MemberDto 클래스를 생성하자.

@Data
public class MemberDto {
    private Long id;
    private String username;
    private String teamName;

    public MemberDto(Long id, String username, String teamName) {
        this.id = id;
        this.username = username;
        this.teamName = teamName;
    }
}

 

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    // DTO 객체 조회하기
    @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
    List<MemberDto> findMemberDto();
}

(2) 위와 같이 MemberRepository 인터페이스에 DTO 객체를 조회하기 위한 메서드를 @Query 어노테이션을 통해 생성한다.

 

(3) 여기서 주의할 점은 DTO 같은 객체를 직접 조회하려면 JPQL이 지원하는 new 연산자를 사용해야 한다. 또한 연산자 사용을 위해 위와 같이 클래스의 생성자 순서와 맞는 DTO가 필요하다.

 

 

@Test
public void findMemberDto() {
    Team team = new Team("teamA");
    teamRepository.save(team);

    Member member1 = new Member("AAA", 10);
    member1.setTeam(team);
    memberRepository.save(member1);

    List<MemberDto> memberDto = memberRepository.findMemberDto();
    for (MemberDto dto : memberDto) {
        System.out.println("dto = " + dto);
    }
}

(4) MemberRepositoryTest 테스트 클래스에 DTO 객체를 조회하기 위한 findMemberDto() 메서드가 정상적으로 동작하는지 확인하기 위한 테스트 케이스를 작성한다.

 

 

테스트 케이스 통과 성공

(5) 테스트가 정상적으로 동작하는 것을 확인할 수 있다.

 

 

 

 

3-5. Parameter Binding

(1) @Param 어노테이션을 사용하여 Repository 사용자 정의 메서드에 파라미터 바인딩이 가능하다.

 

(2) Parameter binding에는 위치 기반, 이름 기반 Parameter binding이 존재하는데 코드의 가독성과 유지보수성을 위해 실무에서는 가급적이면 이름 기반 파라미터 바인딩을 사용하도록 한다.

 

select m from Member m where m.username = ?0
select m from Member m where m.username = :username

(3) 상단의 JPQL이 위치 기반 파라미터 바인딩, 하단의 JPQL이 이름 기반 파라미터 바인딩이다.

 

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    // parameter 바인딩
    @Query("select m from Member m where m.username in :names")
    List<String> findByNames(@Param("names") Collection<String> names);
}

(4) MemberRepository 인터페이스에 엔티티의 필드인 username을 바인딩하기 위해 findByNames() 메서드에 names에 대해 파라미터 바인딩이 되어 있다.

 

(5) Collection에 대해 JPQL에서 in 절을 지원하고 있다

 

 

@Test
public void findByNames() {
    Member member1 = new Member("AAA", 10);
    Member member2 = new Member("BBB", 20);
    memberRepository.save(member1);
    memberRepository.save(member2);

    List<String> result = memberRepository.findByNames(Arrays.asList("AAA", "BBB"));
    for (String s : result) {
        System.out.println("s = " + s);
    }
}

(6) MemberRepositoryTest에 파라미터 바인딩을 위한 테스트 케이스를 작성하고 테스트를 진행했다.

 

 

 

테스트 케이스가 동과되었다.

(7) 테스트 케이스가 통과한 것을 확인할 수 있다.

 

 

 

 

3-6. 반환 타입

(1)  스프링 데이터 JPA는 유연한 반환 타입을 제공하고 있다.

List<Member> findListByUsername(String username);  // Collection 조회
Member findMemberByUsername(String username);     // 단건 조회
Optional<Member> findOptionalByUsername(String username);  // 단건 조회  : Optional

 

 

(2) 더 자세한 반환 타입을 확인하려면 스프링 데이터 JPA 공식 문서를 참고해 보는 것도 좋다.

.https://docs.spring.io/spring-data/jpa/docs/current/reference/

 

Index of /spring-data/jpa/docs/current/reference

 

docs.spring.io

 

 

(3) 결과는 Collection 형태 또는 단건 형태로 조회될 수 있다. 만약 Collection 타입에서 결과가 없다면 Empty collection이 조회되고 단건에서는 null이 조회된다. 그러나 단건에서 조회 결과가 2건 이상이라면 javax.persistence.NonUniqueResultException 예외가 발생하게 된다.

 

(4) 단건 형태로 지정한 메서드를 호출하게 되면 스프링 데이터 JPA는 내부에서 JPQL의 Query.getSingleResult() 메서드를 호출하게 된다. 해당 메서드를 호출했을 때 조회 결과가 존재하지 않으면 javax.persistence.NoResultException 예외가 발생하는데 이러한 예외는 개발자 입장에서 다루기가 까다로운 편이다. 이러한 이유 때문에 스프링 데이터 JPA는 단건 형태를 조회할 때 Exception이 발생하면 예외를 무시하고 대신에 Null을 반환하게 된다.

 

 

 

 

 

 

4. Reference

※ 해당 포스팅은 InFlearn에서 (전) 우아한형제들, 배달의 민족 서비스 개발팀장(기술이사)으로 재직하셨던 김영한님 "실전! 스프링 데이터 JPA" 강의를 듣고 공부한 내용을 정리하였습니다.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84/dashboard

 

실전! 스프링 데이터 JPA - 인프런 | 강의

스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제공합니다.

www.inflearn.com

※ 해당 포스팅에 대해 내용 추가가 필요하다고 생각되면 기존 포스팅 내용에 다른 내용이 추가될 수 있습니다.

개인적으로 공부하며 정리한 내용이기에 오타나 틀린 부분이 있을 수 있으며, 이에 대해 댓글로 알려주시면 감사하겠습니다!

댓글