테이블 목록 : [Runner, Supporter, RunnerPost, RunnerPostTags, SupporterRunnerPost]
시나리오 :
Runner가 Supporter에게 리뷰 요청을 하기 위해 RunnerPost를 작성한다.
Supporter는 RunnerPost에 리뷰 해준다고 신청할 수 있다.
이 때 Supporter가 지원한 RunnerPost 목록을 페이징하여 조회한다.
추가적으로 RunnerPost에 지원한 Supporter 수를 ApplicantCount라 부르고 이것은 테이블 컬럼에 저장되어 있지 않다.
즉, 하나의 RunnerPost에 관련된 Supporter가 몇 명이 있는지 카운트해서 찾아야 한다.
이번 포스팅은 ApplicantCount를 RunnerPost 내부에 @Transient 어노테이션을 이용하여 테이블에 매핑하지 않고 가지고 있게하면서 나오는 트러블 슈팅과 페이징, 일급 컬렉션 이야기 까지 있다.
🟩 값객체(VO)인 지원자수(ApplicantCount)를 테이블에 저장되지 않게 만든다.
서브 쿼리를 이용해서 지원자수(applicantCount)를 받아오려고 한다.
먼저 값 객체인 ApplicantCount를 만든다.
이 때 @Transient 어노테이션을 등록하여 테이블에 저장되지 않도록 했다.
@EqualsAndHashCode
@Getter
@NoArgsConstructor(access = PROTECTED)
@Embeddable
public class ApplicantCount {
@Transient
private int value;
public ApplicantCount(final int value) {
validateNegative(value);
this.value = value;
}
private void validateNegative(final int value) {
if (value < 0) {
throw new IllegalArgumentException("ApplicantCount 객체 내부에 applicant count 는 음수일 수 없습니다.");
}
}
public static ApplicantCount zero() {
return new ApplicantCount(0);
}
}
🟩 지원자수가 필요한 러너 게시글(RunnerPost)에 추가하자.
이제 ApplicantCount를 사용할 러너 게시글(RunnerPost)의 필드로 추가하자.
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class RunnerPost extends BaseEntity {
@GeneratedValue(strategy = IDENTITY)
@Id
private Long id;
@Embedded
private Title title;
@Embedded
private Contents contents;
@Embedded
private PullRequestUrl pullRequestUrl;
@Embedded
private Deadline deadline;
@Embedded
private WatchedCount watchedCount;
@Enumerated(STRING)
@Column(nullable = false)
private ReviewStatus reviewStatus = ReviewStatus.NOT_STARTED;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "runner_id", foreignKey = @ForeignKey(name = "fk_runner_post_to_runner"), nullable = false)
private Runner runner;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "supporter_id", foreignKey = @ForeignKey(name = "fk_runner_post_to_supporter"), nullable = true)
private Supporter supporter;
@Embedded
private RunnerPostTags runnerPostTags;
// === 값객체 추가 (@Transient) ===
@Embedded
private ApplicantCount applicantCount;
// ...
}
🟩 러너 게시글(RunnerPost)과 함께 가져오는 지원자수(ApplicantCount) 테스트 상황
이제 Repository에서 서브 쿼리를 이용해서 지원자 수를 같이 받아올 수 있도록 하자.
먼저 테스트에 대한 상황을 설명하면서 어떤 식으로 진행되는지 알아보자.
🚀 테스트 상황
러너(Runner)가 등록한 러너 게시글(RunnerPost)에 여러 서포터(Supporter)는 리뷰를 해주겠다고 신청할 수 있다.
서포터가 러너 게시글에 리뷰 신청을 한 정보는 서포터_러너_게시글(SupporterRunnerPost)에 저장된다.
이제 서포터가 리뷰를 완료한(ReviewStatus.DONE) 러너 게시글(RunnerPost) 목록을 가져오려고 한다.
이때 러너 게시글마다 다른 서포터들을 포함해서 몇 명의 서포터가 리뷰를 해준다고 신청했는지 지원자수(ApplicantCount)를 같이 가져오려 한다.
🚀 테스트 진행
사용자(Member), 러너(Runner), 서포터(Supporter)를 저장한다.
러너 게시글(RunnerPost)를 두 개 저장한다.
각각의 러너 게시글에 서포터를 할당한다.
러너 게시글의 리뷰 상태를 완료(ReviewStatus.DONE)로 수정한다.
RunnerPostRepository의 findBySupporterIdAndReviewStatus() 메서드를 통해서 페이징된 러너 게시글 목록을 가져온다.
이 때 가져온 러너 게시글 목록은 서포터 식별자값(SupporterId), 리뷰 상태(ReviewStatus), 페이징 정보(Pageable)이 필요하다.
@DisplayName("Supporter 식별자값과 ReviewStatus 로 연관된 RunnerPost 를 페이징하여 조회한다.")
@Test
void findBySupporterIdAndReviewStatus() {
// given
final Member savedMemberDitoo = memberRepository.save(MemberFixture.createDitoo());
final Runner savedRunnerDitoo = runnerRepository.save(RunnerFixture.createRunner(savedMemberDitoo));
final Member savedMemberHyena = memberRepository.save(MemberFixture.createDitoo());
final Supporter savedSupporterHyena = supporterRepository.save(SupporterFixture.create(savedMemberHyena));
final RunnerPost runnerPostOne = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100)));
final RunnerPost savedRunnerPostOne = runnerPostRepository.save(runnerPostOne);
final RunnerPost runnerPostTwo = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100)));
final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(runnerPostTwo);
savedRunnerPostOne.assignSupporter(savedSupporterHyena);
savedRunnerPostOne.updateReviewStatus(ReviewStatus.DONE);
savedRunnerPostTwo.assignSupporter(savedSupporterHyena);
savedRunnerPostTwo.updateReviewStatus(ReviewStatus.DONE);
supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostOne, savedSupporterHyena));
supporterRunnerPostRepository.save(SupporterRunnerPostFixture.create(savedRunnerPostTwo, savedSupporterHyena));
// when
final PageRequest pageOne = PageRequest.of(0, 10);
final Page<RunnerPost> pageOneRunnerPosts
= runnerPostRepository.findBySupporterIdAndReviewStatus(pageOne, savedSupporterHyena.getId(), ReviewStatus.DONE);
// then
assertSoftly(softly -> {
softly.assertThat(pageOneRunnerPosts.getContent()).containsExactly(runnerPostOne);
});
}
🟧 [실패 - V1] JOIN + Group By로 count하기
RunnerPostRepository에서 SupporterRunnerPost를 조인하고 Group By를 이용하여 ApplicantCount를 받아올 수 있도록 하자.
페이징 정보를 제외하고 쿼리를 작성하면 다음과 같다.
select rp.*, count(1) as applicantCount
from runner_post rp
left join supporter_runner_post srp on srp.runner_post_id = rp.id
where rp.supporter_id = 1
and rp.review_status = 'DONE'
group by rp.id;
DataGrip을 이용해서 확인해봤을 때 원하는데로 결과가 잘 나오는 것을 확인할 수 있다.
안보여서 확대 ㅎㅎ
그러면 이제 JPQL로 옮기기만 하면 된다.
ApplicantCount는 값 객체이고 alias로 applicantCount라는 이름을 주었다.
비록 테이블과 매핑되지 않은 ApplicantCount 이지만 한 번 시도해봤다.
@Query(value = """
select rp, new touch.baton.domain.runnerpost.vo.ApplicantCount(count(1)) as applicantCount
from RunnerPost rp
left join fetch SupporterRunnerPost srp on srp.runnerPost.id = rp.id
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
group by rp.id
""",
countQuery = """
select count(1)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
""")
Page<RunnerPost> findBySupporterIdAndReviewStatusV1(final Pageable pageable,
@Param("supporterId") final Long supporterId,
@Param("reviewStatus") final ReviewStatus reviewStatus);
잘 나오는 듯 싶었지만 ApplicantCount를 동적으로 초기화하는데 실패했다.
이는 JPA로 매핑되지 않은 상태에서 RunnerPost를 동적으로 넣으려하다가 실패한 것이다.
즉, 컬럼에 new ApplicantCount()를 넣다고 한들 RunnerPost에 매핑되지 않은 필드인 ApplicantCount를 곧바로 만들 수는 없다.
Error performing dynamic instantiation : touch.baton.domain.runnerpost.vo.ApplicantCount
org.springframework.orm.jpa.JpaSystemException: Error performing dynamic instantiation : touch.baton.domain.runnerpost.vo.ApplicantCount
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:320)
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:229)
at app//org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)
at app//org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
at app//org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
at app//org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
🟧 [실패 - V2] JPQL 반환할 객체 내부 객체 생성
JPQL에서 RunnerPost 객체를 생성하면서 ApplicantCount도 내부에서 생성하려고 했지만 컴파일 에러가 발생한다.
🟧 [실패 - V3] JPQL 반환 객체 + 생성자 기본 타입으로 수정
RunnerPost 생성자에 ApplicantCount 인자를 받을 때 int로 받은 후 내부에서 생성하도록 만들었다.
public RunnerPost(final Long id,
final Title title,
final Contents contents,
final PullRequestUrl pullRequestUrl,
final Deadline deadline,
final WatchedCount watchedCount,
final ReviewStatus reviewStatus,
final Runner runner,
final Supporter supporter,
final RunnerPostTags runnerPostTags,
final int applicantCount
) {
validateNotNull(title, contents, pullRequestUrl, deadline, watchedCount, reviewStatus, runner, runnerPostTags);
this.id = id;
this.title = title;
this.contents = contents;
this.pullRequestUrl = pullRequestUrl;
this.deadline = deadline;
this.watchedCount = watchedCount;
this.reviewStatus = reviewStatus;
this.runner = runner;
this.supporter = supporter;
this.runnerPostTags = runnerPostTags;
this.applicantCount = new ApplicantCount(applicantCount);
}
이제 아래와 같이 RunnerPost 생성 내부에 ApplicantCount를 생성할 필요가 없고 컴파일 에러도 발생하지 않는다.
또한 JPA에서 자동으로 엔티티를 생성해주는 것이 아닌 직접 객체를 생성하도록 작성했다.
@Query(value = """
select
new touch.baton.domain.runnerpost.RunnerPost(
rp.id,
rp.title,
rp.contents,
rp.pullRequestUrl,
rp.deadline,
rp.watchedCount,
rp.reviewStatus,
rp.runner,
rp.supporter,
rp.runnerPostTags,
count(1)
)
from RunnerPost rp
left join fetch SupporterRunnerPost srp on srp.runnerPost.id = rp.id
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
group by rp.id
""",
countQuery = """
select count(1)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
""")
Page<RunnerPost> findBySupporterIdAndReviewStatusV3(final Pageable pageable,
@Param("supporterId") final Long supporterId,
@Param("reviewStatus") final ReviewStatus reviewStatus);
하지만 이것도 안된다.
Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
org.springframework.orm.jpa.JpaSystemException: Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:320)
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:229)
at app//org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)
at app//org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
at app//org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
at app//org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at app//org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:135)
🟧 [실패 - V4] 서브 쿼리를 이용한 count + JPQL 반환 객체
사실 아까까지 서브 쿼리는 등장하지 않았다.
조인으로 진행해도 가져올 수 있었고 먼저 해봤었다. 이제 서브쿼리에 도전해보자.
@Query(value = """
select
new touch.baton.domain.runnerpost.RunnerPost(
rp.id,
rp.title,
rp.contents,
rp.pullRequestUrl,
rp.deadline,
rp.watchedCount,
rp.reviewStatus,
rp.runner,
rp.supporter,
rp.runnerPostTags,
(select count(1) as applicantCount
from SupporterRunnerPost srp
where srp.runnerPost.id = rp.id)
)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
group by rp.id
""",
countQuery = """
select count(1)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
""")
Page<RunnerPost> findBySupporterIdAndReviewStatusV4(final Pageable pageable,
@Param("supporterId") final Long supporterId,
@Param("reviewStatus") final ReviewStatus reviewStatus);
땡 안됐다.
RuunerPost를 테이블과 매핑한 상태에서 내부에서 생성자로 생성하여 가져오려고 해도 자동 생성으로 진행이 되고 이 때 테이블과 매핑된 ApplicantCount가 없으니 안되는 것임이 증명되기 시작하고 있다.
Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
org.springframework.orm.jpa.JpaSystemException: Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:320)
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:229)
at app//org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)
at app//org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
at app//org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
at app//org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at app//org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:135)
at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
🟧 [실패 - V5] 서브 쿼리를 이용한 count + 자동 생성
위 V4에서 실패하면서 테이블과 매핑된 RunnerPost는 자동으로 컬럼에 매핑하여 생성해주기 때문에 안되는 것이라고 얘기했다.
JPQL 내부에서 new 연산자로 직접 생성을 선언했었지만 안됐으니 직접 생성하지 않아도 되지 않을 것이다.
@Query(value = """
select
rp.id,
rp.title,
rp.contents,
rp.pullRequestUrl,
rp.deadline,
rp.watchedCount,
rp.reviewStatus,
rp.runner,
rp.supporter,
rp.runnerPostTags,
(select count(1) as applicantCount
from SupporterRunnerPost srp
where srp.runnerPost.id = rp.id)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
group by rp.id
""",
countQuery = """
select count(1)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
""")
Page<RunnerPost> findBySupporterIdAndReviewStatusV5(final Pageable pageable,
@Param("supporterId") final Long supporterId,
@Param("reviewStatus") final ReviewStatus reviewStatus);
🐛 ApplicantCount의 예상한 값이 다르다.
실패했지만 예외 메시지가 바뀌었다!
바로 ApplicantCount 예상 값은 1인데 실제 값은 0이라는 오류였다.
아마 ApplicantCount의 내부 값 필드 타입을 int로 두어 발생하는거 같다.
Multiple Failures (2 failures)
-- failure 1 --
Expecting value to be false but was true
at RunnerPostRepositoryTest.lambda$findBySupporterIdAndReviewStatusV5$1(RunnerPostRepositoryTest.java:124)
-- failure 2 --
Expecting actual:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@fa64c7d7,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@38dd5c3a,
0L],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@fa64c7d7,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@533c7a52,
0L]]
to contain exactly (and in same order):
[touch.baton.domain.runnerpost.RunnerPost@20]
but some elements were not found:
[touch.baton.domain.runnerpost.RunnerPost@20]
and others were not expected:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@fa64c7d7,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@38dd5c3a,
0L],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@fa64c7d7,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@533c7a52,
0L]]
🤔 그렇다면 ApplicantCount 내부 필드 값을 Integer로 바꿔서 시도해보자.
이게 만약 틀리게 나온 것이라면 null이 나오지 않겠는가?
@EqualsAndHashCode
@Getter
@NoArgsConstructor(access = PROTECTED)
@Embeddable
public class ApplicantCount {
@Transient
private Integer value;
public ApplicantCount(final Integer value) {
validateNegative(value);
this.value = value;
}
private void validateNegative(final Integer value) {
if (value < 0) {
throw new IllegalArgumentException("ApplicantCount 객체 내부에 applicant count 는 음수일 수 없습니다.");
}
}
public static ApplicantCount zero() {
return new ApplicantCount(0);
}
}
public RunnerPost(final Long id,
final Title title,
final Contents contents,
final PullRequestUrl pullRequestUrl,
final Deadline deadline,
final WatchedCount watchedCount,
final ReviewStatus reviewStatus,
final Runner runner,
final Supporter supporter,
final RunnerPostTags runnerPostTags,
final Integer applicantCount
) {
validateNotNull(title, contents, pullRequestUrl, deadline, watchedCount, reviewStatus, runner, runnerPostTags);
this.id = id;
this.title = title;
this.contents = contents;
this.pullRequestUrl = pullRequestUrl;
this.deadline = deadline;
this.watchedCount = watchedCount;
this.reviewStatus = reviewStatus;
this.runner = runner;
this.supporter = supporter;
this.runnerPostTags = runnerPostTags;
this.applicantCount = new ApplicantCount(applicantCount);
}
이제 다시 돌려보자
그리고 이전과 같은 에러가 발생했다.
ApplicantCount가 예상한 1L이 아니라 0L이 나온것이다.
Multiple Failures (2 failures)
-- failure 1 --
Expecting value to be false but was true
at RunnerPostRepositoryTest.lambda$findBySupporterIdAndReviewStatusV5$1(RunnerPostRepositoryTest.java:124)
-- failure 2 --
Expecting actual:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@c4703039,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@190c2bbf,
0L],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@c4703039,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@38dd5c3a,
0L]]
to contain exactly (and in same order):
[touch.baton.domain.runnerpost.RunnerPost@20]
but some elements were not found:
[touch.baton.domain.runnerpost.RunnerPost@20]
and others were not expected:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@c4703039,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@190c2bbf,
0L],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@c4703039,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@38dd5c3a,
0L]]
🤔 그렇다면 작성한 JPQL이 잘못된건지 확인해봐야 한다.
바로 DataGrip으로 넘어가서 로그에 나온 SQL을 실행해보자.
🐛 작성한 JQPL이 잘못되었는지 DataGrip에서 확인해보자.
페이징을 뺀 V5 쿼리는 아래와 같다.
select
rp.id,
rp.title,
rp.contents,
rp.pull_request_url,
rp.deadline,
rp.watch_count,
rp.review_status,
r.id,
r.created_at,
r.deleted_at,
r.introduction,
r.member_id,
r.updated_at,
s.id,
s.created_at,
s.deleted_at,
s.introduction,
s.member_id,
s.review_count,
s.updated_at,
(select
count(1)
from
supporter_runner_post srp
where
srp.runner_post_id=rp.id)
as applicant_count
from
runner_post rp
join
runner r
on r.id=rp.runner_id
join
supporter s
on s.id=rp.supporter_id
where
rp.supporter_id=1
and rp.review_status='DONE';
그리고 결과도 생각처럼 잘 나오고 있다.
JPQL도 잘 나갔고
그에 대한 SQL도 잘 만들어졌었고
그러면 결국 RunnerPost를 생성할 때 ApplicantCount를 제대로 인식하지 못해서 그런게 맞다.
그러면 서브 쿼리로 찾아온 count를 ApplicantCount 객체로 만들어서 반환해보자.
🟧 [실패 - V6] 서브 쿼리를 이용한 count + 자동 생성 두 번째
🐛 실제 나올 수 있는 count의 타입
먼저 다시 RunnerPost 생성자에 ApplicantCount 타입이 인자로 들어오도록 수정했다.
그리고 V6 쿼리를 다시 아래와 같이 만들어줬다.
@Query(value = """
select
rp.id,
rp.title,
rp.contents,
rp.pullRequestUrl,
rp.deadline,
rp.watchedCount,
rp.reviewStatus,
rp.runner,
rp.supporter,
rp.runnerPostTags,
new touch.baton.domain.runnerpost.vo.ApplicantCount(
(select count(1)
from SupporterRunnerPost srp
where srp.runnerPost.id = rp.id)
)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
group by rp.id
""",
countQuery = """
select count(1)
from RunnerPost rp
where rp.supporter.id = :supporterId
and rp.reviewStatus = :reviewStatus
""")
Page<RunnerPost> findBySupporterIdAndReviewStatusV6(final Pageable pageable,
@Param("supporterId") final Long supporterId,
@Param("reviewStatus") final ReviewStatus reviewStatus);
실패했다.
아래와 같은 오류가 발생하였고 이유가 궁금했다.
Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
org.springframework.orm.jpa.JpaSystemException: Error performing dynamic instantiation : touch.baton.domain.runnerpost.RunnerPost
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:320)
at app//org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:229)
at app//org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)
at app//org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
at app//org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242)
at app//org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
at app//org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:135)
전체적인 문제를 해결한 것은 아니지만 위와 같은 오류에 대한 이유는 ApplicantCount가 Long 이기 때문이다.
이유는 count할 때 대상은 Long 만큼 존재할 수 있는데 Java에서 int 타입으로 받으려 하니 타입 오류가 발생한 것이다.
그래서 ApplicantCount 내부 값 타입을 Long으로 수정했다.
그리고 다시 테스트를 돌렸을 때는 아래와 같이 다른 이유로 실패하게 된다.
Expecting actual:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@70ad3282,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@34d5d5f7,
ApplicantCount(value=1)],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@70ad3282,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@17eac1b5,
ApplicantCount(value=1)]]
to contain exactly (and in same order):
[RunnerPost(id=1, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@70ad3282, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@6a3044cb, applicantCount=ApplicantCount(value=1))]
but some elements were not found:
[RunnerPost(id=1, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@70ad3282, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@6a3044cb, applicantCount=ApplicantCount(value=1))]
and others were not expected:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@70ad3282,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@34d5d5f7,
ApplicantCount(value=1)],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@70ad3282,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@17eac1b5,
ApplicantCount(value=1)]]
🟧 [실패 - V6-2] 일급 컬렉션 주소를 조심하라.
여기서 V7이지만 사실 V7이 아니다. (V20 정도 된다.)
아무튼 위 V6에서 힌트를 얻었다. 바로 일급 컬렉션에 대한 문제였다.
여기서 이런 문제가 나올지 몰랐지만 에러 메시지를 자세히 보면 다음과 같다.
🐛 JPA와 일급 컬렉션 주소값 문제
그렇다. 다른 문제들을 다 해결했어도 에러가 발생한 이유는 일급 컬렉션에 대한 주소가 달랐기 때문이다.
// 예상된 RunnerPostTags 객체(일급 컬렉션) 주소 @34d5d5f7
touch.baton.domain.tag.RunnerPostTags@34d5d5f7
// 실제 RunnerPostTags 객체(일급 컬렉션) 주소 @6a3044cb
touch.baton.domain.tag.RunnerPostTags@6a3044cb
RunnerPost 객체를 보면 아래에 RunnerPostTags라는 일급 컬렉션을 볼 수 있다.
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class RunnerPost extends BaseEntity {
// ...
@Embedded
private RunnerPostTags runnerPostTags;
@Embedded
private ApplicantCount applicantCount;
}
@Getter
@NoArgsConstructor(access = PROTECTED)
@Embeddable
public class RunnerPostTags {
@OneToMany(mappedBy = "runnerPost", cascade = PERSIST, orphanRemoval = true)
private List<RunnerPostTag> runnerPostTags = new ArrayList<>();
public RunnerPostTags(final List<RunnerPostTag> runnerPostTags) {
this.runnerPostTags = runnerPostTags;
}
}
🤔 일급 컬렉션 주소가 달라진 이유?
먼저 테스트 코드를 보자.
@DisplayName("Supporter 식별자값과 ReviewStatus 로 연관된 RunnerPost 를 페이징여 조회한다.")
@Test
void findBySupporterIdAndReviewStatusV6() {
final RunnerPost runnerPostOne = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100)), new ApplicantCount(1L));
final RunnerPost savedRunnerPostOne = runnerPostRepository.save(runnerPostOne);
final RunnerPost runnerPostTwo = RunnerPostFixture.create(savedRunnerDitoo, new Deadline(LocalDateTime.now().plusHours(100)), new ApplicantCount(1L));
final RunnerPost savedRunnerPostTwo = runnerPostRepository.save(runnerPostTwo);
// ...
}
RunnerPostFixture를 설명하자면 테스트를 위한 픽스처이고 아래에서 RunnerPostTags를 빈 리스트를 넣어 생성하고 있다.
public abstract class RunnerPostFixture {
private RunnerPostFixture() {
}
public static RunnerPost create(final Runner runner, final Deadline deadline, final ApplicantCount applicantCount) {
return RunnerPost.builder()
.title(new Title("테스트 제목"))
.contents(new Contents("테스트 내용"))
.pullRequestUrl(new PullRequestUrl("https://테스트"))
.deadline(deadline)
.watchedCount(new WatchedCount(0))
.reviewStatus(ReviewStatus.NOT_STARTED)
.runner(runner)
.supporter(null)
// 빈 리스트를 넣어 생성하는 중
.runnerPostTags(new RunnerPostTags(new ArrayList<>()))
.applicantCount(applicantCount)
.build();
}
}
일급 컬렉션인 RunnerPostTags 이렇게 내부에서 미리 만들어져서 나오게 된다.
그리고 runnerPostRepository에서 findBySupporterIdAndReviewStatusV6를 통해서 Page<RunnerPost>를 가져오는데 이 때 내부의 RunnerPostTags는 가져오지 않아 내부 값이 비어있는 상태로 조회되어 올 것이다.
@DisplayName("Supporter 식별자값과 ReviewStatus 로 연관된 RunnerPost 를 페이징여 조회한다.")
@Test
void findBySupporterIdAndReviewStatusV6() {
// ...
final PageRequest pageOne = PageRequest.of(0, 10);
final Page<RunnerPost> pageOneRunnerPosts
= runnerPostRepository.findBySupporterIdAndReviewStatusV6(pageOne, savedSupporterHyena.getId(), ReviewStatus.DONE);
}
즉, RunnerPostFixture로 생성한 RunnerPost 내부에 일급 컬렉션인 RunnerPostTags는 비어있다.
마찬가지로 RunnerPostRepository 에서 조회하여 가져온 RunnerPost 내부에 일급 컬렉션인 RunnerPostTags도 똑같이 비어있다.
그렇다면 두 일급 컬렉션이 서로 동등성 검사를 했을 때 true가 나올거라고 생각했다.
하지만 그러한 결과가 실제로 나오지 않아 디버깅 해보았다.
먼저 RunnerPostFixture로 생성한 후 저장한 RunnerPost(savedRunnerPostOne)이다.
RunnerPostTags 주소는 @12681 이고 내부 List 주소는 @12689 인 것을 확인할 수 있다.
그래도 equals & hashcode를 재정의 했으니 서로 같다고 나와야하지 않는가?
이상해서 RunnerPostTags에 들어가봤을 때 equals & hashcode가 재정의 되어 있지 않은 것을 보고 놀랐다.
바로 재정의 해주도록 하자.
@Getter
@NoArgsConstructor(access = PROTECTED)
@Embeddable
public class RunnerPostTags {
@OneToMany(mappedBy = "runnerPost", cascade = PERSIST, orphanRemoval = true)
private List<RunnerPostTag> runnerPostTags = new ArrayList<>();
public RunnerPostTags(final List<RunnerPostTag> runnerPostTags) {
this.runnerPostTags = runnerPostTags;
}
// ...
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final RunnerPostTags that = (RunnerPostTags) o;
return Objects.equals(runnerPostTags, that.runnerPostTags);
}
@Override
public int hashCode() {
return Objects.hash(runnerPostTags);
}
}
이제 테스트만 돌리면 끝이네요. 감사합니다. 🙇♂️
그만..
✅ [성공 - V6-3] Page.getContent()는 UnmodifiableList 이다.
유심히 예외 메시지를 보면 모두가 다 같은거 같다.
다만 자세히 보면 실제값은 [[]] 을 반환하고 예상값은 []을 반환한다.
xpecting actual:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@a17591c8,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@43da060d,
ApplicantCount(value=1)],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@a17591c8,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@140db665,
ApplicantCount(value=1)]]
to contain exactly (and in same order):
[RunnerPost(id=1, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@a17591c8, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@43da060d, applicantCount=ApplicantCount(value=1)),
RunnerPost(id=2, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@a17591c8, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@140db665, applicantCount=ApplicantCount(value=1))]
but some elements were not found:
[RunnerPost(id=1, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@a17591c8, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@43da060d, applicantCount=ApplicantCount(value=1)),
RunnerPost(id=2, title=touch.baton.domain.common.vo.Title@3d43ef88, contents=touch.baton.domain.common.vo.Contents@3d4125f0, pullRequestUrl=touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec, deadline=touch.baton.domain.runnerpost.vo.Deadline@a17591c8, watchedCount=touch.baton.domain.common.vo.WatchedCount@3b, reviewStatus=DONE, runner=touch.baton.domain.runner.Runner@20, supporter=touch.baton.domain.supporter.Supporter@20, runnerPostTags=touch.baton.domain.tag.RunnerPostTags@140db665, applicantCount=ApplicantCount(value=1))]
and others were not expected:
[[1L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@a17591c8,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@43da060d,
ApplicantCount(value=1)],
[2L,
touch.baton.domain.common.vo.Title@3d43ef88,
touch.baton.domain.common.vo.Contents@3d4125f0,
touch.baton.domain.runnerpost.vo.PullRequestUrl@fbf141ec,
touch.baton.domain.runnerpost.vo.Deadline@a17591c8,
touch.baton.domain.common.vo.WatchedCount@3b,
DONE,
touch.baton.domain.runner.Runner@20,
touch.baton.domain.supporter.Supporter@20,
touch.baton.domain.tag.RunnerPostTags@140db665,
ApplicantCount(value=1)]]
Page.getContent()는 List를 반환하는데 왜 [[]]가 나왔을지 생각해봤다.
그리고 우아한테크코스에서 배웠던 UnmodifiableList가 생각이 났다.
UnmodifiableList로 반환할 경우 List를 한 번 더 감싸기 때문이다.
실제로 Page의 getContent() 내부를 보니 UnmodifiableList로 반환하는 것을 확인할 수 있었다.
그래서 테스트 코드를 다음과 같이 수정해서 리스트 내부 객체가 필드끼리 비교하도록 하면서 테스트가 통과하였다.
// then
assertSoftly(softly -> {
softly.assertThat(pageOneRunnerPosts.getContent())
.usingRecursiveFieldByFieldElementComparator()
.containsExactly(savedRunnerPostOne, savedRunnerPostTwo);
});
🚀 다 같이 주의해요.
- JPA를 써도 일급 컬렉션 객체 주소는 다르다.
- count 한 값이 Long인지 Integer인지 타입에 대해 확실하게 해야한다.
- 페이징하여 나온 정보 Page<T> 내부에 데이터를 꺼내는 getContent()가 List<T>를 반환하는데 이 때 List는 UnmodifiableList 이다.
'😋 JPA' 카테고리의 다른 글
[JPA] 기본값 타입, 임베디드 타입, 컬렉션 값 타입 - (@Embeddable, @Embedded) (0) | 2023.06.17 |
---|---|
[JPA] 영속성 전이, 고아 객체 (Cascade, Orphan) (0) | 2023.06.16 |
[JPA] 프록시, 즉시 로딩, 지연 로딩 - (FetchType.EAGER, FetchType.LAZY) (0) | 2023.06.16 |
[JPA] 고급 매핑 - 테이블 매핑 정보 상속, 엔티티 매핑 정보 상속, 복합키, 식별 관계, 비식별 관계 (@Inheritance, @MappedSuperclass, @IdClass, @EmbeddableId) (0) | 2023.06.16 |
[JPA] 다양한 연관관계 매핑 - 다대일, 일대다, 일대일, 다대다 연관관계 (0) | 2023.06.15 |