Springboot-Data-JPA 명명법

JPA는 반복적인 SQL 작성과 객체를 SQL에 맵핑하는 것을 대신 수행해줍니다. 따라서 개발자는 JPA를 통해서 단순 코드 작성, 맵핑하는 것에 초점을 두지 않고, 비즈니스 개발에 집중할 수 있게 되었습니다. 그중에서 SQL을 직접 작성하지 않고 메서드의 명명으로 쿼리를 사용할 수 있게 해주는 명명법에 대해서 알아보겠습니다.

명명법이 동작하는 원리도 포스팅하려 했지만, Hibernate 구현체에 디버깅 걸어도 보고 구글링도 했지만 찾지 못했습니다.. 추후에 찾게 된다면 동작 원리에 대해서도 포스팅 하겠습니다.

2022.04.10 추가



명명법의 동작 과정을 살펴보겠습니다. 사용된 query는 아래와 같습니다.

Optional<Member> findByName(String name);

1. QueryExecutorMethodInterceptor

Repository들은 모두 AOP로 프록시가 등록됩니다. 이때 해당하는 쿼리를 호출한다면 첫 번째로 QueryExecutorMethodInterceptor의 invoke가 호출됩니다. 그 후 resultHandler의 postProcessInvocationResult()를 반환하는데 해당 결과가 쿼리의 결과입니다.

@Override
@Nullable
public Object invoke(@SuppressWarnings("null") MethodInvocation invocation) throws Throwable {

   Method method = invocation.getMethod();

   QueryExecutionConverters.ExecutionAdapter executionAdapter = QueryExecutionConverters //
         .getExecutionAdapter(method.getReturnType());

   if (executionAdapter == null) {
      return resultHandler.postProcessInvocationResult(doInvoke(invocation), method);
   }

   return executionAdapter //
         .apply(() -> resultHandler.postProcessInvocationResult(doInvoke(invocation), method));
}

 

이때 안에서 전달되는 doInvoke를 살펴보겠습니다. doInvoke는 실제로 해당 메서드를 실행해주는 Invoke입니다. 

@Nullable
private Object doInvoke(MethodInvocation invocation) throws Throwable {

   Method method = invocation.getMethod();

   if (hasQueryFor(method)) {

      RepositoryMethodInvoker invocationMetadata = invocationMetadataCache.get(method);

      if (invocationMetadata == null) {
         invocationMetadata = RepositoryMethodInvoker.forRepositoryQuery(method, queries.get(method));
         invocationMetadataCache.put(method, invocationMetadata);
      }

      return invocationMetadata.invoke(repositoryInformation.getRepositoryInterface(), invocationMulticaster,
            invocation.getArguments());
   }

   return invocation.proceed();
}

 

디버깅을 했을 때 아래와 같이 쿼리가 넘어온 것을 알 수 있습니다. 이때 invocationMetadata.invoke()가 실행돼 쿼리가 실행됩니다. 

2. RepositoryMethodInvoker

해당 메서드가 위에서 실행한 doInvoke입니다. 여기서 invocable.invoke()를 실행하고 결과를 반환받습니다. 여기서 invocable.invoke() 실행함으로써 AbstarctJpaQuery 클래스가 동작합니다. 

@Nullable
private Object doInvoke(Class<?> repositoryInterface, RepositoryInvocationMulticaster multicaster, Object[] args)
      throws Exception {

   RepositoryMethodInvocationCaptor invocationResultCaptor = RepositoryMethodInvocationCaptor
         .captureInvocationOn(repositoryInterface);

   try {

      Object result = invokable.invoke(args);

      if (result != null && ReactiveWrappers.supports(result.getClass())) {
         return new ReactiveInvocationListenerDecorator().decorate(repositoryInterface, multicaster, args, result);
      }

      if (result instanceof Stream) {
         return ((Stream<?>) result).onClose(
               () -> multicaster.notifyListeners(method, args, computeInvocationResult(invocationResultCaptor.success())));
      }

      multicaster.notifyListeners(method, args, computeInvocationResult(invocationResultCaptor.success()));

      return result;
   } catch (Exception e) {
      multicaster.notifyListeners(method, args, computeInvocationResult(invocationResultCaptor.error(e)));
      throw e;
   }
}

 

3. AbstractJpaQuery

execute로 name으로 입력된 명명법으로 만든 파라미터 "asd"가 들어오게 되고, doExecute를 호출합니다.

 

JpaQueryExecution의 execute 함수를 통해서 query결과를 받습니다.

@Nullable
public Object execute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {

   Assert.notNull(query, "AbstractJpaQuery must not be null!");
   Assert.notNull(accessor, "JpaParametersParameterAccessor must not be null!");

   Object result;

   try {
      result = doExecute(query, accessor);
   } catch (NoResultException e) {
      return null;
   }

   if (result == null) {
      return null;
   }

   JpaQueryMethod queryMethod = query.getQueryMethod();
   Class<?> requiredType = queryMethod.getReturnType();

   if (ClassUtils.isAssignable(requiredType, void.class) || ClassUtils.isAssignableValue(requiredType, result)) {
      return result;
   }

   return CONVERSION_SERVICE.canConvert(result.getClass(), requiredType) //
         ? CONVERSION_SERVICE.convert(result, requiredType) //
         : result;
}

 

그 후 ResultProcessor에 있는 processResult에 TupleConverter를 통해서 객체의 형태로 변환합니다.

@Nullable
@SuppressWarnings("unchecked")
public <T> T processResult(@Nullable Object source, Converter<Object, Object> preparingConverter) {

   if (source == null || type.isInstance(source) || !type.isProjecting()) {
      return (T) source;
   }

   Assert.notNull(preparingConverter, "Preparing converter must not be null!");

   ChainingConverter converter = ChainingConverter.of(type.getReturnedType(), preparingConverter).and(this.converter);

   if (source instanceof Slice && (method.isPageQuery() || method.isSliceQuery())) {
      return (T) ((Slice<?>) source).map(converter::convert);
   }

   if (source instanceof Collection && method.isCollectionQuery()) {

      Collection<?> collection = (Collection<?>) source;
      Collection<Object> target = createCollectionFor(collection);

      for (Object columns : collection) {
         target.add(type.isInstance(columns) ? columns : converter.convert(columns));
      }

      return (T) target;
   }

   if (source instanceof Stream && method.isStreamQuery()) {
      return (T) ((Stream<Object>) source).map(t -> type.isInstance(t) ? t : converter.convert(t));
   }

   if (ReactiveWrapperConverters.supports(source.getClass())) {
      return (T) ReactiveWrapperConverters.map(source, converter::convert);
   }

   return (T) converter.convert(source);
}

최종적으로 객체로 변한 값을 반환받습니다.

 

2022.04.10 추가




예시에 사용된 코드는 아래 링크에서 확인하실 수 있습니다.
https://github.com/rlaehdals/JpaQueryMethodName

 

GitHub - rlaehdals/JpaQueryMethodName

Contribute to rlaehdals/JpaQueryMethodName development by creating an account on GitHub.

github.com

1. find...By...

가장 먼저 살펴볼 것은 find입니다. find는 Entity를 조회할 때 사용합니다.
기본으로 JpaRepository의 구현체인 SimRepository에서 구현되어 있는 메서드들이 있습니다.

1-1. findById(T t)

Id를 이용해서 Entity를 조회하는 것은 별도로 함수를 정의하지 않아도 사용할 수 있습니다.

Optional<Member> byId = memberRepository.findById(1l);
@Test
@DisplayName("단일 멤버 찾기 Id")
void findById(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    Member member = memberRepository.findById(1l).get();

    assertThat(member.getName()).isEqualTo("m1");

}

정의되어 있는 메서드는 Wrapper 객체인 Optional에 감싸줘서 반환됩니다. 이것은 Null 값이 반환되는 것을 방지하고 Optional의 메서드들을 이용해서 쉽게 가공이 가능하기 때문입니다. 단일 객체를 조회하기 때문에 정렬과 페이징은 없습니다.

1-2. findBy...(T args [])

Id는 이미 정의되어 있는 메서드이고, 사용자가 명명법을 이용해서 만들 때는 findBy필드명 작성해주시면 됩니다. Member의 Name으로 찾을 때는 아래와 같이 작성하시면 됩니다. (find... By 사이에는 구분을 위해서 문자들을 추가하셔도 무방합니다. ex: findMemberByName(String name)

@Test
@DisplayName("단일 멤버 찾기 Name")
void findByName(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    Member member = memberRepository.findByName("m1").get();

    assertThat(member.getName()).isEqualTo("m1");

}

여러 가지 필드명으로 찾길 원하신다면, 메서드 명명법에 추가하시면 됩니다. Name과 age를 이용해서 찾을 때는 아래와 같이 필드명을 작성해주시면 됩니다.

Optional<Member> findByNameAndAge(String name, int age);

이때 주의하실 점이 있습니다. 명명법의 필드명 순서와 인자들의 순서가 일치해야 합니다. (변수명은 달라도 무방)

// 변수명 필드명 일치 정상작동
Optional<Member> findByNameAndAge(String name, int age);
// 변수명 필드명 불일치 정상작동
Optional<Member> findByNameAndAge(String n, int a);
// 변수명 일치, 인자 순서 불일치 오류 발생
Optional<Member> findByNameAndAge(int age, String name);
@Test
@DisplayName("단일 멤버 찾기 Name과 나이")
void findByNameAndAge(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    Member member = memberRepository.findByNameAndAge("m1",10).get();

    assertThat(member.getName()).isEqualTo("m1");

}
  • 변수명과 필드명은 달라도 되지만, 순서는 다르면 안 된다.
  • 명명법은 여러 개의 인자를 전달해서 조회를 할 수 있다.

2. findByAll(), findByAll(Sort sort), findByAll(Pageable pageable)

Member Entity를 모두 불러오는 메서드입니다.

List<Member> all = memberRepository.findAll();

List <Member>로 반환됩니다. 배열이 아닌 List로 반환되는 것은 Optional과 마찬가지로 Null처리에 용이하고, 가공이 편리합니다.
findAll()은 Sort(정렬), Pageable(페이징)을 인자로 넘겨줄 수 있습니다.

Sort는 조회된 Entity들에 대해서 필드 값에 따른 오름차순, 내림차순으로 정렬을 실시해줍니다.
Sort를 안 줬을 경우

@Test
@DisplayName("멤버 리스트 찾기")
void findByAllBasic(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> results = memberRepository.findAll();

    for(Member member: results){
        System.out.println(member);
    }

}

  • Member의 저장된 순서대로 반환받습니다.

Sort를 Name 내림 차수로 정렬 조건을 줬을 경우

@Test
@DisplayName("멤버 리스트 찾기 정렬")
void findByAllSort(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    Sort sort = Sort.by(Sort.Direction.DESC, "name");
    List<Member> results = memberRepository.findAll(sort);

    for(Member member: results){
        System.out.println(member);
    }
}

  • Sort.by(정렬 방향, 필드명) -> Sort 완성
  • 저장된 순서가 아닌 Name의 내림차순으로 조회되는 것을 확인할 수 있습니다.

명명법으로도 정렬이 가능합니다.

List<Member> findByNameOrderByNameDesc(String name);

Name과 Age에 따른 정렬 조건으로 조회

@Test
@DisplayName("멤버 리스트 찾기 정렬")
void findByAllSortMany(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Sort.Order> orders = List.of(Sort.Order.desc("name"),Sort.Order.asc("age"));
    List<Member> results = memberRepository.findAll(Sort.by(orders));

    for(Member member: results){
        System.out.println(member);
    }

    assertThat(results.size()).isEqualTo(4);
}
  • Sort.order를 List형태로 만들어서 넘겨주면 됩니다.

Pageable은 일반적으로 볼 수 있는 페이징입니다. 아래와 같이 정해진 개수로 쪼개어서 조회하는 것입니다.

// 0페이지 size=2 
PageRequest.of(0,2);

// 0페이지 size=2 Name으로 내림차순 정렬
PageRequest.of(0,2,Sort.by(Sort.Direction.DESC,"name"));

// 0페이지 size=2 Name으로 내림차순 정렬
PageRequest.of(0,2,Sort.Direction.DESC,"name");
  • Page는 0부터 시작합니다.

Pageable을 넘겼을 때 총 2가지로 반환받을 수 있습니다. Slice <>, Page <>

Slice: Pageable이 넘겨준 Page만 조회를 합니다. 따라서 조회된 전체를 가지고 있지 않고, 카운트하지 않습니다.
Page: 조회된 전체 가지고 있고, 카운트합니다.
count를 진행하는 데 있어서 많은 비용이 발생합니다. 따라서 전체 페이지가 필요한 것이 아니라면 Slice를 사용합니다.

// 0페이지 size=2개씩
PageRequest pageRequest1 = PageRequest.of(0, 2);

// 0페이지에 해당하는 것을 반환하고, 카운트하지 않는다.
Slice<Member> result1 = memberRepository.findAll(pageRequest1);

// 0페이지에 해당하는 것을 반환하고, 카운트 한다. 
Page<Member> result2 = memberRepository.findAll(pageRequest1);

List<Member> content1 = result1.getContent();
List<Member> content2 = result2.getContent(); // result2.getTotalElements();
  • getContent(): 페이징에 해당하는 Entity들을 반환합니다.
  • Page의 경우 카운트를 함으로 getTotalElements()를 통해서 전체 카운트를 알 수 있습니다.

3. Top, Firts, Distinct

모두 find와 By사이에 사용할 수 있는 조건입니다.
Top, First: 상위 데이터 조회

// 상위 3개의 데이터를 반환
List<Member> findTop3By();
List<Member> findFirst3By();
  • Top과 First모두 뒤에 숫자를 이용해서 반환하는 개수를 조절할 수 있습니다.
@Test
@DisplayName("멤버리스트 조회 개수 정하기")
void findTopAndFirst(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList = memberRepository.findFirst3By();

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(3);

}

아래와 같이 멤버 Name을 이용해서 정렬하여 사용할 수 있습니다.

List<Member> findFirst3ByOrderByNameDesc();

  • Name 내림차순 정렬된 것을 확인할 수 있습니다.

Distinct는 중복된 데이터를 제거해줍니다.

List<Member> findDistinctBy();

4. 연산자

find.. By뒤에 올 수 있는 연산자들입니다. 필드명+연산자로 구성됩니다.

4-1. IgnoreCase, AllIgnoreCase

대소 문자를 구분하지 않고 조회하는 것입니다.

IgnoreCase의 경우 단일 필드에 대해서 대소문 구분하지 않습니다.
AllIngnoreCase의 경우 주어진 필드들에 대해서 대소 문을 구분하지 않습니다.

아래의 경우 Name에 대해서 대소문자 구분하지 않고 조회합니다.

Optional<Member> findByNameIgnoreCase(String name);
@Test
@DisplayName("대소문자 구분하지 않고 조회")
void findIgnoreCase(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }
	
    // IgnoreCase가 없었다면 조회된 Member가 없으므로 NullPointerException발생
    Member result = memberRepository.findByNameIgnoreCase("M1").get();

    System.out.println(result);

    assertThat(result.getName()).isEqualTo("m1");
}

4-2. Before, After

시간을 기준으로 할 경우 이전과 이후, 숫자와 문자열일 경우 크기와 사전 순으로 해석됩니다.

List<Member> findByAgeAfter(int age);
@Test
@DisplayName("나이가 더 큰 것")
void findAfter(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByAgeAfter(11);

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(3);

}

따라서 age보다 큰 Member의 List를 반환하게 됩니다.

4-3. GreaterThan, GreaterThanEqual, LessThan, LessThanEqual

이름에서 쉽게 알 수 있듯이 대소 비교입니다.

List<Member> findByAgeGreaterThan(int age);
@Test
@DisplayName("나이가 더 큰 것 대소 비교 이용")
void findOperator(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByAgeGreaterThan(11);

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(4);

}

age보다 나이가 더 많은 Member를 반환하게 됩니다.

4-4. Between

from ~ to 범위에 속하는 값을 반환합니다.

List<Member> findByAgeBetween(int from, int to);
@Test
@DisplayName("사이 값 Between 사용")
void findBetween(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByAgeBetween(11,14);

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(3);

}
  • 11살 <= age <= 14살으로 해석할 수 있습니다.

4-5. In, NotIN

Collection안에 데이터가 포함되어 있거나, 포함되어 있지 않은지 확인합니다.

List<Member> findByAgeIn(List<Integer> list);
@Test
@DisplayName("값들에 포함되는지 in 사용")
void findIn(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByAgeIn(List.of(11,14));

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(1);

}
  • 11살 혹은 14살의 Member를 반환합니다.

4-6 Null, IsNull, NotNull, IsNotNull

값에 대해 Null인지 판별하는 연산자입니다.

List<Member> findByAgeNotNull();
@Test
@DisplayName("age가 Null 아닌 것 찾기")
void findNotNull(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByAgeNotNull();

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(4);

}
  • age가 Null이 아닌 Member들을 반환합니다.

4-7 Like, NotLike

sql의 Like 연산자와 동일합니다 m%의 경우 m으로 시작하는 것들을 반환합니다. NotLike는 반대로 m%의 경우 m으로 시작하지 않는 것들을 반환합니다.

List<Member> findByNameLike(String name);
@Test
@DisplayName("Like 사용해서 문자열 포함하는 것 찾기")
void findLike(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByNameLike("m%");

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(4);

}

4-8 StartingWith, EndingWith

해석 그대로 시작하는지 끝나는지 확인하는 연산자입니다.

List<Member> findByNameEndingWith(String name);
@Test
@DisplayName("EndingWith 사용해서 끝 문자열을 포함하는 것 찾기")
void findWith(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByNameEndingWith("3");

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(1);

}
  • Name이 3으로 끝나는 멤버를 반환합니다.

4-9 Contains, NotContaining

문자열들을 포함하는지 포함하지 않는지 판별하는 연산자입니다.

List<Member> findByNameContains(String name);
@Test
@DisplayName("Contains 사용해서 문자열을 포함하는 것 찾기")
void findContain(){
    List<Member> members = createMember();

    for(Member member: members){
        memberRepository.save(member);
    }

    List<Member> memberList =  memberRepository.findByNameContains("m");

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(4);

}
  • Name이 m문자열 포함한다면, 반환됩니다.

5. Delete

find가 Entity를 조회하는 목적으로 사용했다면, Delete는 Entity를 삭제하는 목적으로 사용합니다. 따라서 삭제하는데 반환형이 존재하지 않습니다. find에서 알아봤던 연산자들을 사용할 수 있습니다.

5-1. 기존 메서드인 deleteById(id)먼저 살펴보겠습니다.

memberRepository.deleteById(1l);
  • deleteById(): id에 해당하는 Entity를 삭제합니다.
  • 삭제연산을 하는 것이므로 반환형은 존재하지 않습니다.
@Test
@DisplayName("기존 메서드인 deleteById 사용해보기")
void deleteById(){
    List<Member> members = createMember();
	
    // 4명의 Member 저장
    for(Member member: members){
        memberRepository.save(member);
    }
	
    // 1l에 해당하는 Member 삭제
    memberRepository.deleteById(1l);

    // 삭제했으므로 조회되는 Member의 수는 3
    List<Member> memberList = memberRepository.findAll();
    for(Member member: memberList){
        System.out.println(member);
    }
    
    // 테스트 통과
    assertThat(memberList.size()).isEqualTo(3);

}

 

5-2. Like연산자를 사용해서 Name끝이 1로 끝나는 Member 삭제하는 쿼리

void deleteByNameLike(String s);
@Test
@DisplayName("연산자를 이용해서 삭제")
void deleteByOperator(){
    List<Member> members = createMember();
	
    // Member들의 name은 m1, m2,m3,m4로 들어감
    for(Member member: members){
        memberRepository.save(member);
    }

	// name이 1로 끝나는 객체만 삭제
    memberRepository.deleteByNameLike("%1");

	// m1의 이름을 가진 Member객체가 삭제되어 3명의 Member가 조회된다.
    List<Member> memberList = memberRepository.findAll();

    for(Member member: memberList){
        System.out.println(member);
    }

    assertThat(memberList.size()).isEqualTo(3);

}
  • Like를 사용해서 끝이 1로 끝나는 Member를 삭제하는 연산을 수행

정리하기

Jpa는 명명법을 통해서 sql과 반복적인 코드를 줄일 수 있었습니다.
find, delete에서 사용되는 명명법들을 이용해서 보다 간결하게 조회와 삭제를 할 수 있습니다.
다음은 Update 쿼리에 대해서 알아보겠습니다. 감사합니다.

'SpringBoot > JPA' 카테고리의 다른 글

Spring-Data-JPA [5] Fetch Join  (0) 2022.03.24
Spring-data-JPA [4] Update와 @Query  (0) 2022.03.23
Spring-data-JPA(2) 개념  (0) 2022.01.06
Spring-data-JPA(1) [엔티티] Entity  (0) 2022.01.06
Spring-data-JPA(0) JPA란??  (0) 2022.01.06