[Springboot] JPA 묵시적 조인과 명시적 조인

일단 해볼게·2023년 8월 11일
0

Springboot

목록 보기
24/26
post-thumbnail

문제가 발생한 배경

SharedMember와 MemberEntity는 N:1 (ManyToOne) 관계이다. SharedMember에서 MemberEntity의 nickName을 가져오려고 할 때 예상하지 못한 join문이 발생했다. 예상하지 못한 join이 왜 발생하는지, 어떻게 개선하면 좋을지 알아보자.

  • MemberEntity
public class MemberEntity extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @NotBlank
    @Column(name = "login_id")
    private String loginId;

    @NotBlank
    @Column(name = "login_password")
    private String loginPassword;

    @NotBlank
    @Column(name = "nickname", unique=true)
    private String nickname;

    @Column(name = "member_status")
    private MemberStatus memberStatus;

    @NotNull
    @Column(name = "introduction")
    private String introduction;
}
  • SharedMember
public class SharedMember extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "shared_member_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "task_id", nullable = false)
    private Task task;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private MemberEntity memberEntity;
}

실행한 JPQL

@Query("select s from SharedMember s where s.memberEntity.nickname = :nickName and s.isActive = true")
Optional<SharedMember> findSharedMemberByMemberEntityNickName(@Param("nickName") String nickName);

발생한 쿼리

Hibernate: 
    select
        sharedmemb0_.shared_member_id as shared_m1_6_,
        sharedmemb0_.created_at as created_2_6_,
        sharedmemb0_.is_active as is_activ3_6_,
        sharedmemb0_.updated_at as updated_4_6_,
        sharedmemb0_.member_id as member_i5_6_,
        sharedmemb0_.task_id as task_id6_6_ 
    from
        shared_member sharedmemb0_ **cross** 
    **join**
        member memberenti1_ 
    where
        sharedmemb0_.member_id=memberenti1_.member_id 
        and memberenti1_.nickname=?

원인

  • JPA는 JPA Parser가 JPQL을 파싱하면서 연관된 엔티티를 참조하는 경우 별도의 join을 명시적으로 입력하지 않을 때 알아서 Cross Join을 생성한다.
  • @OneToOne, @OneToMany, @ManyToOne 혹은 그에 반대에 해당하는 사항도 포함된다.
  • 이를 묵시적 조인이라고 한다.

묵시적 조인은 사용하면 안되나?

묵시적 조인은 Cross Join을 사용한다. Cross Join에 대해 알아보자.

Cross Join과 카테시안 곱

Cartesian production(카테시안 곱)은 행과 열을 전체 곱하는 튜플들의 집합이다. 한마디로, 두 테이블에서 컬럼과 컬럼의 곱이 된다.

MemberEntity 테이블

member_idlogin_idlogin_passwordnicknamemember_statusintroduction
1test1test1test1NORMALBACKEND
2test2test2test2NORMALFRONTEND

SharedMember 테이블

shared_member_idtask_idmember_id
111
221
312

두 테이블의 Cross Join 결과는 다음과 같다.

member_idlogin_idlogin_passwordnicknamemember_statusintroductionshared_member_idtask_idmember_id
1test1test1test1NORMALBACKEND111
1test1test1test1NORMALBACKEND221
1test1test1test1NORMALBACKEND312
2test2test2test2NORMALFRONTEND111
2test2test2test2NORMALFRONTEND221
2test2test2test2NORMALFRONTEND312

이렇게 테이블이 만들어진 다음에 where절의 조건으로 member_id가 같은 결과를 도출하게 되어 불필요한 Join의 결과가 나온다.


해결 과정

명시적 조인을 사용한다.

  • 일반적인 SQL문 처럼 join 키워드를 직접적으로 사용해 명시적으로 조인 여부를 나타내는 것

실행한 JPQL

일반 조인을 적용했다.

@Query("select s from SharedMember s join s.memberEntity where s.memberEntity.nickname = :nickName and s.isActive = true")
Optional<SharedMember> findSharedMemberByMemberEntityNickName(@Param("nickName") String nickName);

발생한 쿼리

Hibernate: 
    select
        sharedmemb0_.shared_member_id as shared_m1_6_,
        sharedmemb0_.created_at as created_2_6_,
        sharedmemb0_.is_active as is_activ3_6_,
        sharedmemb0_.updated_at as updated_4_6_,
        sharedmemb0_.member_id as member_i5_6_,
        sharedmemb0_.task_id as task_id6_6_ 
    from
        shared_member sharedmemb0_ 
    **inner join**
        member memberenti1_ 
            on sharedmemb0_.member_id=memberenti1_.member_id 
    where
        memberenti1_.nickname=? 
        and sharedmemb0_.is_active=1

성능 측정 트러블 슈팅

데이터 약 100만개를 기준으로 쿼리문을 실행했다.


@DataJpaTest 테스트 코드로 시간 측정

java의 System.currentTimeMillis()으로 시간을 측정했다.

long startTime = System.currentTimeMillis();

sharedMemberRepository.findSharedMemberByMemberEntityNickName("member-786211");

long stopTime = System.currentTimeMillis();

long elapsedTime = stopTime - startTime;
log.info("실행 시간 : " + elapsedTime);

테스트 코드를 동시에 실행시킨 상황이다.

Cross Join : 3ms, Inner Join : 21ms

예상하지 못한 변수

두 쿼리문을 실행할 때마다 실행 시간이 빨라져서 뭔가 했더니 쿼리 캐시기능이 있나싶었다.

@DataJpaTest의 어노테이션을 파고 들어가면 @AutoConfigureCache 가 있어서 확인해보니

디폴트 타입이 NONE으로 되어 있는걸 확인해서 쿼리 캐시는 아니라고 판단했다.

결국 여러 번 테스트해도 결과가 비슷하게 나와 테스트 방식에 문제가 있다고 판단하고 다른 방식을 찾아보았다.

-> 첫번째 메소드는 class를 초기화하는 시간이 포함되어있어 더 오래걸린다고 결론지었다.

테스트 코드를 하나씩 실행시키니 결과가 달라졌다.

테스트 코드를 각각 실행한 상황이다.

Cross Join 쿼리

Inner Join 쿼리

Cross Join : 29ms, Inner Join : 23ms

여러 번 측정해본 결과, 측정 시간이 어느정도 일관적으로 나와야 하는데 뒤죽박죽이다.


Hibernate에서 발생한 쿼리문을 MySQL 쿼리문으로 변경 후 DB에서 시간 측정

Cross Join 쿼리

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        CROSS JOIN
    member memberenti1_
WHERE
        sharedmemb0_.member_id = memberenti1_.member_id
  AND memberenti1_.nickname = 'member-786211'
  AND sharedmemb0_.is_active = true;

Inner Join 쿼리

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        INNER JOIN
    member memberenti1_
    ON sharedmemb0_.member_id = memberenti1_.member_id
WHERE
        memberenti1_.nickname = 'member-786211'
  AND sharedmemb0_.is_active = true;

결과

홀수가 Cross Join, 짝수가 Inner Join이다. 측정 시간이 어느정도 일관적으로 나와야 하는데 뒤죽박죽이다.

Cross JoinInner Join
첫번째 테스트0.00090 sec0.00070 sec
두번째 테스트0.00078 sec0.00083 sec
세번째 테스트0.00060 sec0.00092 sec

첫 번째 의심

MySQL 자체에 쿼리 캐시 기능이 있나싶어서 찾아보았다.

찾아보니, MySQL 8.x 버전부터 쿼리 캐시 기능을 따로 설정해줘야 한다.

혹시나 쿼리 캐시 기능이 있나 싶어서 확인해봤다.

SHOW VARIABLES LIKE '%query_cache%'

결과는 NO였다.

두 번째 의심

쿼리문을 자세히 살펴보니 마지막에 LIMIT 0, 200이 추가되어있었다. 아니 쿼리문에 이런거 넣은적 없는데 왜 이러지? 하고 찾아보니 DB GUI마다 디폴트 설정값이 있었다. LIMIT를 걸지 않고 쿼리문을 실행했다.

마찬가지로 홀수가 Cross Join, 짝수가 Inner Join이다. 역시 측정 시간이 뒤죽박죽이다.

Cross JoinInner Join
첫번째 테스트0.00059 sec0.00067 sec
두번째 테스트0.00069 sec0.00069 sec
세번째 테스트0.00065 sec0.00069 sec

세 번째 의심

Cross Join 쿼리

이전에는 where에서 member_id가 같은 것을 체크했으나, 이번엔 on에서 member_id가 같은 것을 체크했다.

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        CROSS JOIN
    member memberenti1_
    ON sharedmemb0_.member_id = memberenti1_.member_id
WHERE
  memberenti1_.nickname = 'member-786221'
  AND sharedmemb0_.is_active = true;

Inner Join 쿼리

이전과 다르지 않다.

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        INNER JOIN
    member memberenti1_
    ON sharedmemb0_.member_id = memberenti1_.member_id
WHERE
        memberenti1_.nickname = 'member-786221'
  AND sharedmemb0_.is_active = true;

마찬가지로 Cross Join이 빠른 경우도 있고, Inner Join이 빠른 경우가 있다.

Cross JoinInner Join
첫번째 테스트0.00078 sec0.00064 sec
두번째 테스트0.00080 sec0.00074 sec
세번째 테스트0.00054 sec0.00061 sec

원인 및 최종 분석

  • Optional라서 조건에 맞는 닉네임 하나만 찾으면 더 이상 DB를 스캔할 필요가 없어서 쿼리 Cross Join, Inner Join에 상관없이 실행 시간이 일정하지 않다.
  • 또한 Member의 nickname이 유니크 키라서 인덱스를 타기 때문에 실행 시간이 뒤죽박죽이다.

따라서 인덱스가 걸려있지 않고, 여러 쿼리를 찾는 풀 스캔 쿼리문으로 테스트를 했다.

member_id가 같고 created_at이 2023-08-17 16:00:00 이하인 컬럼을 찾는 쿼리문이다.

Cross Join 쿼리

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        CROSS JOIN
    member memberenti1_
WHERE
        sharedmemb0_.member_id = memberenti1_.member_id
  AND sharedmemb0_.is_active = true
  AND sharedmemb0_.created_at <= '2023-08-17 16:00:00';

Inner Join 쿼리

SELECT 
    sharedmemb0_.shared_member_id AS shared_m1_6_,
    sharedmemb0_.created_at AS created_2_6_,
    sharedmemb0_.is_active AS is_activ3_6_,
    sharedmemb0_.updated_at AS updated_4_6_,
    sharedmemb0_.member_id AS member_i5_6_,
    sharedmemb0_.task_id AS task_id6_6_
FROM
    shared_member sharedmemb0_
        INNER JOIN
    member memberenti1_
    ON sharedmemb0_.member_id = memberenti1_.member_id
WHERE
	sharedmemb0_.created_at <= '2023-08-17 16:00:00'
  AND sharedmemb0_.is_active = true;

풀 스캔을 하니 Inner Join이 약 2~3배가량 빨랐다.

Cross JoinInner Join
첫번째 테스트0.0027 sec0.0010 sec
두번째 테스트0.0033 sec0.0011 sec
세번째 테스트0.0025 sec0.0010 sec

데이터를 풀 스캔한다는 조건에서 Inner Join이 Cross Join보다 빠른걸 알 수 있다.

참고

https://jojoldu.tistory.com/533

https://fordevelop.tistory.com/123

https://dev.mysql.com/blog-archive/mysql-8-0-retiring-support-for-the-query-cache/

profile
시도하고 More Do하는 백엔드 개발자입니다.

0개의 댓글