[Spring Data JPA] findByXXXId 는 불필요한 join을 유발한다

Jihoon Oh·2022년 9월 16일
9
post-thumbnail
post-custom-banner

프로젝트에서 JPA를 사용하던 도중 이상한 부분을 발견했습니다. 엔티티 끼리 연관관계가 있을 때 어떤 곳에서는 findByXXX 형태의 쿼리 메서드를, 어떤 곳에서는 findByXXXId 형태의 쿼리 메서드를 사용하고 있는데요, findByXXX를 사용했을 때는 생각한 대로 쿼리가 나가지만, findByXXXId를 사용했을 때는 join이 걸려서 나가는 것을 확인할 수 있었습니다.

테스트를 통해 확인해보도록 하겠습니다. 실험 대상인 엔티티는 다음과 같습니다.

@Entity
@Getter
public class Team {

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

    @Column(name = "name")
    private String name;

    protected Team() {
    }

    public Team(final String name) {
        this.name = name;
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Team team = (Team) o;
        return Objects.equals(id, team.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
@Entity
@Getter
public class Member {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    protected Member() {
    }

    public Member(final String name, final Team team) {
        this.name = name;
        this.team = team;
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Member member = (Member) o;
        return Objects.equals(id, member.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Member와 Team은 N:1 연관관계를 맺고 있습니다. JPQL은 엔티티를 직접 사용해도 외래키로 조회하는 것과 같기 때문에 이 상태에서 Member에 대해 findByMember로 조회하든 findByMemberId로 조회하든 똑같이 다음 쿼리가 실행 될 것이라고 생각했습니다.

select * from member where member.team_id = ?

하지만 결과는 그렇지 않았습니다. 테스트 코드를 통해 findByMemberfindByMemberId를 모두 호출해 보겠습니다.

Team team = teamRepository.save(new Team("팀"));
memberRepository.save(new Member("회원", team));

memberRepository.findByTeam(team);
System.out.println("======================");
memberRepository.findByTeamId(team.getId());

"======================"를 기준으로 쿼리가 어떻게 달라지는지 확인해보도록 하겠습니다.

Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    where
        member0_.team_id=?
======================
Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    left outer join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.id=?

위가 findByMember, 아래가 findByMemberId 인데요, findByMember는 예상한 대로 쿼리가 나왔지만 findByMemberIdMember 테이블의 컬럼들만 조회하는데도 불구하고 left outer join을 걸어서 조회를 해 오는 것을 확인할 수 있었습니다.

혹시 optional = true이기 때문에, null의 가능성 때문일까요? 실제로 Stack Overflow 글을 찾아보았는데, 그런 의견을 제시한 답변이 있었습니다. 그래서 이번엔 @ManyToOne(optional = false)@Column(nullable = false)를 걸고 테스트해보도록 하겠습니다.

Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    inner join
        team team1_ 
            on member0_.team_id=team1_.id 
    where
        team1_.id=?

left outer join이 inner join으로 바뀌었을 뿐(연관관계의 optional이 불가능하므로 null 값이 들어올 상황이 없기 때문에 굳이 outer를 걸 필요가 없기 때문에 inner로 바뀌는 것이죠), 조인은 그대로 걸리는 것을 확인할 수 있었습니다.

대체 왜 조인이 걸리는 것일까요?

조인이 걸린다는 것은 결국 연관된 엔티티의 값을 조회할 필요가 있다는 의미입니다. 그렇다면 조회 조건이 team_id라는 컬럼이 아니라 team.id인 것이라는 의미가 되는 것이지 않을까요?

생각해보면, Data Jpa가 제공하는 쿼리 메서드 기능은 엔티티의 필드를 조건으로 사용합니다. 그런데 저희가 만든 Member 엔티티에는 teamId라는 필드가 존재하지 않습니다. team_id라는 컬럼과 매칭된 team 필드가 있을 뿐이죠.

이렇게 생각하니 findByTeamIdteam_id 컬럼을 조회 조건으로 사용한다는 것 자체가 이상하게 느껴졌습니다. 그래서 필드를 조금 바꿔서 테스트 해보았습니다.

Team의 식별자인 id 필드를 teamId로 이름을 바꿔서 다시 테스트 해보겠습니다. teamId로 바꾸니 findByTeamId라는 쿼리 메서드 기능 자체가 동작하지 않았습니다. findByTeamTeamId 라는 이름으로 작성해야 기능이 동작합니다. 이를 통해 우리는 이러한 사실을 알 수 있습니다.

findByXXXId는 XXX_id라는 외래 키를 가지고 조회하는 것이 아닌, XXX의 식별자를 가지고 조회하는 것

조회에 조건으로 사용하는 것이 조회 대상인 Member의 필드가 아니라 연관된 엔티티인 Team의 필드이므로, 당연히 team 테이블을 조인으로 가져와서 조건을 걸어줄 수 밖에 없는 것입니다.

왜 이렇게 될까요? 공식 문서를 통해 이유를 찾아볼 수 있었습니다.

Property expressions can refer only to a direct property of the managed entity, as shown in the preceding example. At query creation time, you already make sure that the parsed property is a property of the managed domain class. However, you can also define constraints by traversing nested properties. Consider the following method signature:

List<Person> findByAddressZipCode(ZipCode zipCode);

Assume a Person has an Address with a ZipCode. In that case, the method creates the x.address.zipCode property traversal. The resolution algorithm starts by interpreting the entire part (AddressZipCode) as the property and checks the domain class for a property with that name (uncapitalized). If the algorithm succeeds, it uses that property. If not, the algorithm splits up the source at the camel-case parts from the right side into a head and a tail and tries to find the corresponding property — in our example, AddressZip and Code. If the algorithm finds a property with that head, it takes the tail and continues building the tree down from there, splitting the tail up in the way just described. If the first split does not match, the algorithm moves the split point to the left (Address, ZipCode) and continues.

쿼리 메서드 기능은 메서드 네이밍을 분석할 때 엔티티의 프로퍼티, 즉 필드만 참조할 수 있다고 되어 있습니다. 또한, nested properties, 즉 필드의 필드도 조회에 사용할 수 있다고 되어 있습니다. 공식 문서에서 예시로 들어주는 List<Person> findByAddressZipCode(ZipCode zipCode); 같은 경우, 실제 Person 엔티티에 있는 값은 Address이고, ZipCodeAddress 내부의 필드이기 때문에 AddressZipCode와 같은 조건으로 조회가 가능한 것이죠. 앞서 제가 예로 들었던 findByTeamId의 경우와 비슷하다고 볼 수 있습니다.

그렇다면 id만 사용하면서 원래 의도한 쿼리를 만드는 방법은 없을까요? Spring Data Jpa의 쿼리 메서드 기능을 쓰면서 의도하지 않은 JPQL과 쿼리가 만들어 진 것이지, JPQL 자체는 조회 조건에 연관된 엔티티를 넣는 것과 외래 키를 넣는 것이 똑같이 동작합니다. 즉,

select m from Member m where m.team = :team
select m from Member m where m.team.id = :teamId

두 JPQL은 같은 JPQL이라는 뜻입니다. 따라서 @Query 메서드를 통해 JPQL을 직접 작성해주면 문제를 해결할 수 있습니다.

@Query("select m from Member m where m.team.id = :teamId")
List<Member> findByTeamId(final Long teamId);
Hibernate: 
    select
        member0_.id as id1_1_,
        member0_.name as name2_1_,
        member0_.team_id as team_id3_1_ 
    from
        member member0_ 
    where
        member0_.team_id=?
profile
Backend Developeer
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 9월 16일

유익하네요

답글 달기