SharedMember와 MemberEntity는 N:1 (ManyToOne) 관계이다. SharedMember에서 MemberEntity의 nickName을 가져오려고 할 때 예상하지 못한 join문이 발생했다. 예상하지 못한 join이 왜 발생하는지, 어떻게 개선하면 좋을지 알아보자.
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;
}
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;
}
@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=?
별도의 join을 명시적으로 입력하지 않을 때 알아서 Cross Join을 생성한다.
묵시적 조인
이라고 한다.묵시적 조인은 Cross Join을 사용한다. Cross Join에 대해 알아보자.
Cartesian production(카테시안 곱)
은 행과 열을 전체 곱하는 튜플들의 집합이다. 한마디로, 두 테이블에서 컬럼과 컬럼의 곱이 된다.
MemberEntity 테이블
member_id | login_id | login_password | nickname | member_status | introduction |
---|---|---|---|---|---|
1 | test1 | test1 | test1 | NORMAL | BACKEND |
2 | test2 | test2 | test2 | NORMAL | FRONTEND |
SharedMember 테이블
shared_member_id | task_id | member_id |
---|---|---|
1 | 1 | 1 |
2 | 2 | 1 |
3 | 1 | 2 |
두 테이블의 Cross Join 결과는 다음과 같다.
member_id | login_id | login_password | nickname | member_status | introduction | shared_member_id | task_id | member_id |
---|---|---|---|---|---|---|---|---|
1 | test1 | test1 | test1 | NORMAL | BACKEND | 1 | 1 | 1 |
1 | test1 | test1 | test1 | NORMAL | BACKEND | 2 | 2 | 1 |
1 | test1 | test1 | test1 | NORMAL | BACKEND | 3 | 1 | 2 |
2 | test2 | test2 | test2 | NORMAL | FRONTEND | 1 | 1 | 1 |
2 | test2 | test2 | test2 | NORMAL | FRONTEND | 2 | 2 | 1 |
2 | test2 | test2 | test2 | NORMAL | FRONTEND | 3 | 1 | 2 |
이렇게 테이블이 만들어진 다음에 where절의 조건으로 member_id가 같은 결과를 도출하게 되어 불필요한 Join의 결과가 나온다.
join 키워드를 직접적으로 사용해
명시적으로 조인 여부를 나타내는 것일반 조인을 적용했다.
@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만개를 기준으로 쿼리문을 실행했다.
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으로 되어 있는걸 확인해서 쿼리 캐시는 아니라고 판단했다.
결국 여러 번 테스트해도 결과가 비슷하게 나와 테스트 방식에 문제가 있다고 판단하고 다른 방식을 찾아보았다.
테스트 코드를 하나씩 실행
시키니 결과가 달라졌다.
테스트 코드를 각각 실행한 상황이다.
Cross Join : 29ms, Inner Join : 23ms
여러 번 측정해본 결과, 측정 시간이 어느정도 일관적으로 나와야 하는데 뒤죽박죽이다.
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;
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 Join | Inner Join | |
---|---|---|
첫번째 테스트 | 0.00090 sec | 0.00070 sec |
두번째 테스트 | 0.00078 sec | 0.00083 sec |
세번째 테스트 | 0.00060 sec | 0.00092 sec |
MySQL 자체에 쿼리 캐시 기능이 있나싶어서 찾아보았다.
찾아보니, MySQL 8.x 버전부터 쿼리 캐시 기능을 따로 설정해줘야 한다.
혹시나 쿼리 캐시 기능이 있나 싶어서 확인해봤다.
SHOW VARIABLES LIKE '%query_cache%'
결과는 NO였다.
쿼리문을 자세히 살펴보니 마지막에 LIMIT 0, 200이 추가되어있었다. 아니 쿼리문에 이런거 넣은적 없는데 왜 이러지? 하고 찾아보니 DB GUI마다 디폴트 설정값이 있었다. LIMIT를 걸지 않고 쿼리문을 실행했다.
마찬가지로 홀수가 Cross Join, 짝수가 Inner Join이다. 역시 측정 시간이 뒤죽박죽이다.
Cross Join | Inner Join | |
---|---|---|
첫번째 테스트 | 0.00059 sec | 0.00067 sec |
두번째 테스트 | 0.00069 sec | 0.00069 sec |
세번째 테스트 | 0.00065 sec | 0.00069 sec |
이전에는 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;
이전과 다르지 않다.
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 Join | Inner Join | |
---|---|---|
첫번째 테스트 | 0.00078 sec | 0.00064 sec |
두번째 테스트 | 0.00080 sec | 0.00074 sec |
세번째 테스트 | 0.00054 sec | 0.00061 sec |
하나만 찾으면 더 이상 DB를 스캔할 필요가 없어서
쿼리 Cross Join, Inner Join에 상관없이 실행 시간이 일정하지 않다.유니크 키
라서 인덱스
를 타기 때문에 실행 시간이 뒤죽박죽이다.따라서 인덱스가 걸려있지 않고, 여러 쿼리를 찾는 풀 스캔 쿼리문
으로 테스트를 했다.
member_id가 같고 created_at이 2023-08-17 16:00:00 이하인 컬럼을 찾는 쿼리문이다.
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';
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 Join | Inner Join | |
---|---|---|
첫번째 테스트 | 0.0027 sec | 0.0010 sec |
두번째 테스트 | 0.0033 sec | 0.0011 sec |
세번째 테스트 | 0.0025 sec | 0.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/