BE/Spring

Spring DataJPA 쿼리메서드 생성 조건

E@st 2023. 7. 19. 02:53

Spring DataJPA 쿼리메서드는 어떤값을 사용할까?

문제 상황

Comment가 Post를 갖고 있고 Post는 Comment를 갖고 있지않습니다. 그래서 Post에 맞는 댓글을 조회해야하는 상황이고 Comment에서 ID가 1인 Post를 찾기위해 쿼리메소드를 아래처럼 작성했지만 오류가 발생했습니다.

List<Comment> findByPostId(Long postId)

Failed to create query for method public abstract java.util.List com.devboard.comment.repository.CommentRepository.findByPostId(java.lang.Long); No property 'id' found for type 'Post'; Traversed path: Comment.post

같은 미션을 진행하는 팀원은 되는 상황이였고 쿼리메소드도 동일했습니다.

목표

JPQL을 사용하지 않고 쿼리메소드로 외래키를 이용해 조회하기 하지만 쿼리메소드를 이용했을때 조인쿼리가 발생한다면 JPQL을 사용하려고 계획했습니다.

원인 파악

팀원은 되는 상황이였기에 차이점을 비교했고 하나씩 수정하면서 테스트를 돌려봤습니다.

  1. 팀원은 ID를 Primitive type 저는 Wrapper type을 사용하고 있어서 변경을 시도 했고 역시나 해결되지 않았습니다.
  2. 오류에서 타입을 찾을 수 없다는 메시지가 전달되서 Database에 컬럼명을 맞춰줬고 시도를 했으나 해결되지 않았습니다.
  3. 쿼리메서드의 대한 공식문서를 찾아봤고 공식문서에서 답을 찾을 수 있었습니다.

해결 방법

Spring Data JPA의 공식문서에 따르면, 속성 표현식은 관리되는 엔티티의 직접적인 속성만 참조 가능합니다.

예를 들어 List<Person> findByAddressZipCode(ZipCode zipCode);와 같은 메서드에서는, PersonZipCode를 가진 Address를 소유하고 있다고 가정하면, address.zipCode 속성을 통해 순회하게 됩니다.

 

메서드 분석 알고리즘은 처음에 전체 부분(AddressZipCode)을 속성으로 취급하고 도메인 클래스에서 해당 이름의 속성이 있는지 확인합니다. 만약 해당 이름의 속성을 찾지 못하면, AddressZipCode를 오른쪽에서부터 camel-case 기준으로 나누어 분석합니다.

 이 경우 AddressZipCode로 분리하고 해당하는 속성을 찾아봅니다. 이런 방식으로 알고리즘이 속성을 찾을 때까지 속성 이름을 나누어 분석을 반복합니다.

그러나 경우에 따라 알고리즘이 잘못된 속성을 선택할 수도 있습니다. 예를 들어 Person 클래스가 addressZip라는 속성을 가지고 있다면, 알고리즘은 첫 번째 분리에서 매칭되어 잘못된 속성을 선택하고, addressZipcode 속성을 가지고 있지 않다면 실패하게 됩니다.

 이런 모호성을 해결하기 위해, 메서드 이름에 _ (underscore)를 사용하여 속성의 순회 지점을 명시적으로 정의할 수 있습니다. 예를 들면, List<Person> findByAddress_ZipCode(ZipCode zipCode);와 같이 사용하는 것입니다. 공식문서에서는 underscore 문자를 예약된 문자로 취급하므로, 속성 이름에는 underscore 대신 camel case를 사용하는 것을 권장합니다.

 

@Entity
@Getter
@Table(name = "comments")
@NoArgsConstructor(access = PROTECTED)
public class Comment extends BaseEntity {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "userId")
    private User user;

    private String content;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "postId")
    private Post post;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "parentId")
    private Comment parent;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Comment> children = new ArrayList<>();
}

@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Post extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long postId;

    @ManyToOne(fetch = LAZY)
    private User user;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @Builder
    private Post(User user, String title, String content) {
        this.user = user;
        this.title = title;
        this.content = content;
    }
}

위에와 같은 상황에서 저는 List<Comment> findByPostId(Long postId); 쿼리 메소드를 사용했고 문제는 Post Class에 Id는 postId라고 되어 있기때문에 findByPost_PostId 로 변경하면서 해결되었습니다.

요약

  1. 속성 표현식은 관리되는 엔티티의 직접적인 속성만 참조할 수 있다. 쿼리 생성 시, 파싱된 속성이 도메인 클래스의 속성인지 확인한다.
  2. 중첩된 속성을 통해 제약사항을 정의할 수 있다. 예를 들어, findByAddressZipCode(ZipCode zipCode);와 같은 메서드는 x.address.zipCode 속성을 통해 순회한다.
  3. 해석 알고리즘은 첫 번째로 전체 부분(여기서는 AddressZipCode)을 속성으로 해석하고 도메인 클래스에 해당 이름의 속성이 있는지 확인한다.
  4. 해당 속성이 없다면, 알고리즘은 출처를 오른쪽에서 camel-case 부분으로 나누고 해당하는 속성을 찾는다. 첫 번째 분리가 매칭되지 않으면, 분리점을 왼쪽으로 이동시킨다. AddressCode.Code
  5. 잘못된 속성을 선택하는 경우도 있다. 예를 들어, 클래스가 addressZip 속성을 가지고 있다면, 알고리즘은 첫 번째 분리라운드에서 이미 매칭을 찾아 잘못된 속성을 선택하게 될 수 있다.
  6. 이러한 모호성을 해결하기 위해, 메서드 이름 내에 _ 를 사용하여 수동으로 순회 지점을 정의할 수 있다. 예를 들어, findByAddress_ZipCode(ZipCode zipCode);와 같이 사용할 수 있다.
  7. _ 문자는 예약된 문자로 취급되기 때문에, 표준 Java 네이밍 규칙을 따르는 것을 강력히 권장한다. 즉, 속성 이름에 _를 사용하지 않고, 대신 camel case를 사용한다


    즉 findBy + ClassName + _ + 식별클래스의 필드명 을 사용하면 된다.
    그리고 테스트 결과 외래키를 통해 조회하는 findByPostId 쿼리 메소드는 조인쿼리가 발생하지 않으며 외래키가 아닌 다른 필드값을 사용하게 되면 조인 쿼리가 발생하게 된다.

 

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-property-expressions