JPA와 Querydsl로 게임 가계부를 구현하던 중 API 테스트를 위해 프로젝트를 빌드를 했습니다. 무난하게 성공할 거라는 기대와 달리 프로젝트는 뻥 터지고 말았습니다.
여기서 눈 여겨볼 에러 구문은 3개로 Caused by: org.springframework.data.repository.query.QueryCreationException
, Caused by: java.lang.IllegalArgumentException
, Caused by: org.springframework.data.mapping.PropertyReferenceException
이 녀석들이었습니다.
본격적으로 문제를 해결하기에 앞서 현재 폭발한 프로젝트는 무슨 기능을 추가하려고 했는지, 현재 터진 프로젝트의 상태는 어떤지 간략히 말씀드리겠습니다.
구현하려고 했던 기능은 아래와 같습니다.
프로젝트의 상태는 이러했습니다.
@Entity(name = "account")
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Account {
@Id
@GeneratedValue
private Long id;
private Integer price;
private LocalDate purchaseDate;
private LocalDateTime createDate;
private String note;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "product_id")
private Product product;
//...
}
@Entity(name = "product")
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Product {
@Id
@GeneratedValue
private Long id;
private String productName;
private LocalDateTime createDate;
private boolean isActivated;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "game_id")
private Game game;
@OneToMany(mappedBy = "product")
private List<Account> accounts = new ArrayList<>();
//...
}
@Entity(name = "game")
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Game {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "platform_id")
private Platform platform;
@OneToMany
private List<Product> products = new ArrayList<>();
//...
}
public interface StaticsRepositoryCustom {
List<MonthlyStaticsDTO> findMonthlyStaticsByGameId(long gameId);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndStartDate(long gameId, LocalDate startDate);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndEndDate(long gameId, LocalDate endDate);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdBetweenDate(long gameId, LocalDate startDate, LocalDate endDate);
}
public interface AccountRepository extends JpaRepository<Account, Long>, AccountRepositoryCustom, StaticsRepositoryCustom {
}
전체적인 구조를 놓고 보았을 때 빌드 과정에서 에러가 잡힐만한 부분은 딱히 보이지 않았습니다. 도대체 빌드하다가 터져버리는 이 문제의 원인은 무엇이었을까요?
프로젝트의 현재 상태를 놓고 보았을 때, 가장 의심이 가는 부분은 AccountRepository가 두 개 이상의 RepositoryCustom을 상속 받는 부분이었습니다. 혹시 JPARepository로 사용할 때 2개 이상의 사용자 정의 레포지토리를 상속 받으면 문제가 되는 게 아닐까 싶었지만….
(이미지 출처 : 스프링 데이터 JPA 공식 문서)
Spring Data JPA의 공식 문서에 따르면 사용자 정의 레포지토리는 상속 받는 갯수는 아무 상관 없다고 합니다.
문제는 다시 원점으로 돌아왔습니다. 에러 메세지를 쭉 보던 중 눈에 띄는 에러 메세지가 하나 있었습니다.
Caused by: org.springframework.data.mapping.PropertyReferenceException: No property 'gameId' found for type 'Account’
PropertyReferenceException은 사용자 정의 레포지토리의 메소드를 작성할 때 By라는 조건에 해당하는 프로퍼티가 엔티티 내부의 프로퍼티와 일치하지 않아 매핑에 실패하는 경우에 발생하는 에러입니다.
(Spring Data JPA의 메소드 명명 규칙은 공식문서에서 확인할 수 있습니다.)
에러 메세지를 살펴보면 gameId는 Account 엔티티에서 찾을 수 없는 프로퍼티라고 합니다. 이쯤에서 다시 AccountRepository와 StaticsRepositoryCustom을 살펴봅시다.
public interface AccountRepository extends JpaRepository<Account, Long>, AccountRepositoryCustom, StaticsRepositoryCustom {
}
public interface StaticsRepositoryCustom {
List<MonthlyStaticsDTO> findMonthlyStaticsByGameId(long gameId);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndStartDate(long gameId, LocalDate startDate);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndEndDate(long gameId, LocalDate endDate);
List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdBetweenDate(long gameId, LocalDate startDate, LocalDate endDate);
}
AccountRepository
는 Account라는 엔티티에 종속되어 있는 JPARepository입니다. StaticsRepositoryCustom
의 각 메소드의 By 뒤에 오는 녀석들은 쿼리의 검색 조건으로 실행되는 프로퍼티를 가리킵니다.
이러한 AccountRepository
가 StaticsRepositoryCustom
을 상속 받고 쿼리 프로젝션에 의해 결과를 추론하려고 할 때 Account라는 엔티티에 By 뒤에 오는 gameId와 startDate, endDate라는 프로퍼티가 없기 때문에 Account 엔티티와 매핑할 수 없는 문제를 발생시킵니다.
자, 그렇다면 문제의 해결책은 생각보다 쉽게 나올 것 같습니다. 이제 이 문제를 해결해봅시다.
문제는 간단합니다. StaticsRepositoryCustom
의 메소드를 Account 엔티티에 맞게 메소드명을 다시 짓는 것입니다.(물론 그렇다고 이름 짓는 것이 쉽다는 게 아닙니다)
그런데, 다시 한 번 생각해봅시다. 우리가 이 문제를 해결하기 위해 접근해야할 것은 에러 메세지를 지우는 목표가 아니라 구조에 맞게 문제를 해결했는가라는 관점입니다.
StaticsRepositoryCustom
에서 구하고자 하는 값이 Account라는 엔티티에 종속적이어야 하는 걸까요? 분명 통계를 구해오는 기반은 Account가 맞습니다.(저도 이 관점으로 AccountRepository에 StaticsRepositoryCustom을 상속했습니다)
Account를 기반으로 하지만 Account 엔티티와는 무관한 gameId값을 기준으로 삼아 값을 가져오고 grouping을 통해 생성한 새로운 결과 테이블을 List로 반환하고 있습니다. 최종적으로 쿼리해서 가져오는 결과는 Account 엔티티와는 다른 형태의 값을 가져오고 있는 것입니다.
그래서 제가 내린 결론은 그 어떤 엔티티에도 의존하지 않는 새로운 레포지토리를 생성하자는 것이었습니다. 어차피 사용의 목적은 querydsl을 통해 entity만 뽑아오면 되기 때문에 굳이 사용자 정의 레포지토리의 인터페이스를 만들고 상속해줄 필요가 없다고 생각했습니다.
이미 StaticsRepositoryCustom
의 상세 구현은 구현체인 StaticsRepositoryImpl
에 이미 다 만들어져 있었습니다.
public class StaticsRepositoryImpl implements StaticsRepositoryCustom {
private final JPAQueryFactory queryFactory;
public StaticsRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameId(long gameId) {
//...
}
@Override
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndStartDate(long gameId, LocalDate startDate) {
//...
}
@Override
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndEndDate(long gameId, LocalDate endDate) {
//...
}
@Override
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdBetweenDate(long gameId, LocalDate startDate, LocalDate endDate) {
//...
}
}
이미 우리는 AccountRepository
에 StaticsRepositoryCustom
을 상속하지 않고 독립적으로 사용할 레포지토리로 만들 것이기 때문에 인터페이스도 상속 받지 않고 구현체를 뜻하는 Impl이라는 이름을 붙일 필요도 없게 됩니다.
@Repository
public class StaticsRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public StaticsRepository(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameId(long gameId) {
//...
}
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndStartDate(long gameId, LocalDate startDate) {
//...
}
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdAndEndDate(long gameId, LocalDate endDate) {
//...
}
public List<MonthlyStaticsDTO> findMonthlyStaticsByGameIdBetweenDate(long gameId, LocalDate startDate, LocalDate endDate) {
//...
}
}
이렇게 구현하고 나면 빌드할 때 겪었던 에러를 말끔하게 해결할 수 있게 됩니다.
PropertyReferenceException 에러는 근본적으로 Spring Data JPA가 이해할 수 없는 메소드명이 엔티티와 매핑될 때 문제가 발생합니다. 이름을 무조건 엔티티에 적합하게 짓는 것도 중요하지만, 현재 우리가 사용하고자 하는 레포지토리가 해당 엔티티와 매핑을 해야 하는 것인지에 대해 조금 더 고민해볼 수 있으면 좋겠습니다.
- [우아콘2020] 수십억건에서 QUERYDSL 사용하기
- https://recordsoflife.tistory.com/1243
- https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl
- https://stackoverflow.com/questions/36701358/how-to-use-projection-interfaces-with-pagination-in-spring-data-jpa
- https://dev-j.tistory.com/11
- https://docs.spring.io/spring-data/data-jpa/docs/current/reference/html/#jpa.query-methods.named-queries
- https://docs.spring.io/spring-data/data-jpa/docs/current/reference/html/#repositories.custom-implementations
- https://stackoverflow.com/questions/19583540/spring-data-jpa-no-property-found-for-type-exception