안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
스프링은 다양한 데이터 접근 기술을 제공합니다. 주로 사용하는 기술은 jpa, jdbc, mybatis 등이 있습니다. jpa를 사용하는 간단한 crud를 빠르게 사용할 수 있는 장점이 존재하지만 동적 쿼리를 해결하는 데는 많은 어려움이 있습니다.
스프링은 동적 쿼리를 해결하기 위한 수단으로 다양한 기능을 제공합니다. 가장 대표적인 기술은 queryDsl입니다.
queryDsl은 자바 언어로 SQL을 작성할 수 있도록 제공하여 컴파일 시점에 에러를 잡을 수 있고, 다양한 동적 쿼리를 빌더 형태로 작성할 수 있습니다.
이번 글은 제가 작성했던 queryDsl 조회 로직을 살펴보고 100만개의 더미데이터에서 성능을 분석하는 시간을 가져보도록 하겠습니다.
1. ERD 스키마와 Result-Server의 역할 정리하기
인텔리제이에서는 데이터베이스 스키마를 효과적으로 볼 수 있습니다. 인텔리 제이로 스키마를 분석해 보겠습니다.
Result-Server의 주된 비즈니스 로직은 AWS SQS로 보내진 메시지를 구독하여 데이터베이스에 값을 저장하고, 조회 로직을 구현하는 것입니다.
필요한 테이블은 member, authority, player, player_result, game_result, topic입니다.
MSA 아키텍처에서 member, authority는 member-server의 테이블에서 반드시 필요한 필드로 result-server만의 테이블을 사용하도록 구현하였습니다.
player는 member와 일 대 일 관계로, 한 명의 회원은 한 개의 플레이어를 선정한다라는 관계를 설정하였습니다.
player_result는 member와 다 대 일 관계로, 한 명의 회원은 다수의 게임 결과를 가질 수 있습니다.
game_result는 player_result와 일 대 다 관계로, 하나의 게임 결과는 다수의 플레이어 결과를 가질 수 있습니다.
topic은 game_result와 일 대 다의 관계로 하나의 토픽은 다수의 game_result에서 사용될 수 있습니다.
저는 game-server에서 AWS SQS로 메시지를 발행하면 result-server가 이를 구독하여, 전송된 게임 결과, 투표 결과 등을 조합하여 결과를 저장하는 시나리오를 생각했습니다. 따라서, game-server는 redis를 사용하여 저장된 다양한 게임 결과를 result-server로 위임하여 result-server에서 모든 결과를 저장하고 클라이언트의 결과 조회나 랭킹 조회 등의 결과 조회 서비스를 담당하는 역할을 부여하였습니다.
2. 100만 개의 더미 데이터 생성하기 (MySQL)
약 100만개의 데이터를 자바 코드로 INSERT문을 txt로 작성하였고, LOAD DATA INFILE로 생성된 데이터를 입력하였습니다.
- member 1만 명: member_id (vachar(255)) pk, userId (vachar(255)) (인덱스)
- topic 1만 개: 게임에 사용될 주제 topic_id (BigInt) pk
- game_result 10만 건: 게임 결과 저장 game_result_id (vachar(255)) pk, topic_id fk
- player_result 약 100만 건: 플레이어의 게임 결과 player_result_id (varchar(255)) pk, game_result_id fk, member_id fk
LOAD DATA INFILE '/var/lib/mysql-files/insert_player_result.txt'
INTO TABLE player_result
FIELDS TERMINATED BY ', '
LINES starting by '(' TERMINATED BY '),\n'
IGNORE 1 LINES;
select count(*) from player_result;
각 더미데이터는 최대한 현실과 비슷하게 작성하기 위해, UUID로 생성된 pk가 겹치지 않고, 실제 제가 저장하는 방식 대로 데이터를 입력하였습니다. player_result의 경우 LOAD DATA INFILE로 더미 데이터를 입력하는데 약 30분 정도 소요되었습니다.
3. 현재 비즈니스 로직 분석하기
회원은 최근 자신이 참여한 게임 결과를 조회할 수 있습니다.
- 참여한 gameId(게임 Id: 추가 조회용), gameName(게임 이름), topicName(라이어 게임 주제), winner (승리한 역할),
totalUsers (참여한 총 유저수), myRole (게임에서 내 역할), answer (라이어 투표에서 내 투표의 정답 여부)
조회한 내용을 클라이언트로 전달하기 위해 Dto를 선언하였습니다.
여기서 QueryProjection의 역할은 Q 타입은 기본적으로 Entity로 선언된 class가 Q타입으로 컴파일됩니다.
이때, Dto로 선언된 class로 조회 로직을 작성해야 하는 경우 QueryProjection으로 선언된 Dto는 Q타입을 사용할 수 있게 됩니다.
@Getter
@NoArgsConstructor
public class MyDetailGameResultDto {
private String gameId;
private String gameName;
private String topicName;
private GameRole winner;
private Integer totalUsers;
private GameRole myRole;
private Boolean answer;
@QueryProjection
public MyDetailGameResultDto(String gameId, String gameName, String topicName, GameRole winner, Integer totalUsers, GameRole myRole, Boolean answer) {
this.gameId = gameId;
this.gameName = gameName;
this.topicName = topicName;
this.winner = winner;
this.totalUsers = totalUsers;
this.myRole = myRole;
this.answer = answer;
}
}
하단의 MyDetailGameResultCond는 최신 순 조회, 이긴 게임 조회, 진 게임 조회, 게임 이름으로 조회를 선택할 수 있도록 한 Condition입니다. 클라이언트는 해당 조회 기능으로 원하는 기능으로 게임을 조회할 수 있습니다.
여기서, gameId 혹은 game_result_id를 넣지 않은 이유는, 클라이언트는 복잡한 숫자 혹은 UUID로 된 게임 id를 기억하지 않습니다. 보통 "우리 같이 게임합시다!" 등의 게임 이름을 기억하고 조회하는 경우가 많습니다.
따라서, searchGameName은 이러한 클라이언트의 특성을 분석하여 조회 조건으로 설정하였습니다.
userId는 header에서 가져오는 반드시 필요한 변수로 유저 정보 조회를 위한 필수 값으로 이는 유저가 클릭하는 정보가 아닌, 헤더에서 받아온 값을 인터셉터에서 처리하여 cond에 주입하고 있습니다.
@Getter
@NoArgsConstructor
public class MyDetailGameResultCond {
private String userId;
private Boolean viewLatest;
private Boolean viewOnlyWin;
private Boolean viewOnlyLose;
private String searchGameName;
@Builder
public MyDetailGameResultCond(String userId, Boolean viewLatest, Boolean viewOnlyWin, Boolean viewOnlyLose, String searchGameName) {
this.userId = userId;
this.viewLatest = viewLatest;
this.viewOnlyWin = viewOnlyWin;
this.viewOnlyLose = viewOnlyLose;
this.searchGameName = searchGameName;
}
}
해당 비즈니스 로직은 스크롤을 내리며 열람할 수 있도록 Slice로 구현되어 있습니다. 따라서, content용 쿼리와 count용 쿼리를 분리하여 작성하였습니다.
@Repository
@RequiredArgsConstructor
public class MyDetailGameResultQueryDslRepositoryImpl implements MyDetailGameResultQueryDslRepository{
private final JPAQueryFactory query;
@Override
public Slice<MyDetailGameResultDto> fetchMyDetailGameResult(MyDetailGameResultCond cond, Pageable pageable) {
List<MyDetailGameResultDto> content = selectMyDetailGameResultContent(cond, pageable);
JPAQuery<Long> countQuery = countMyDetailGameResult(cond);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
먼저, content용 쿼리는 다음과 같습니다.
앞서 정의한 MyDetailGameResultDto에 필요한 정보를 join 연산으로 가져오고 있습니다.
먼저 앞서 정의한 ERD 모델에 따라, userId는 player_result -> meber의 조인으로
userId에 접근하여 인덱스로 pk를 가져옵니다.
game_result -> topic 조인으로 topic.topic_name을 가져오고,
조인 관계로 game_result의 fk가 있는 player_result에서 필요한 gameRole, answers를 가져옵니다.
private List<MyDetailGameResultDto> selectMyDetailGameResultContent(MyDetailGameResultCond cond, Pageable pageable) {
return query
.select(
new QMyDetailGameResultDto(
gameResult.gameId,
gameResult.gameName,
gameResult.topic.topicName,
gameResult.winner,
gameResult.totalUsers,
playerResult.gameRole,
playerResult.answers
)
)
.from(playerResult)
.join(playerResult.member, member)
.join(playerResult.gameResult, gameResult)
.join(gameResult.topic, topic)
.where(
member.userId.eq(cond.getUserId()),
playerWinEq(cond.getViewOnlyWin()),
playerLoseEq(cond.getViewOnlyLose()),
gameNameContains(cond.getSearchGameName())
)
.orderBy(createOrderSpecifier(cond))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
BooleanExpression을 활용하여 각 각 동적쿼리를 구현하였습니다.
QueryDsl의 최대 장점은 동적쿼리를 BooleanExpression 형태로 구현할 수 있다는 점입니다.
따라서, 모든 경우의 수를 개발자가 고려하지 않아도 각 상황에 맞도록 쿼리가 조합되어서 나갈 수 있습니다.
이 부분에서 기억해야 할 부분은 contains 로직으로 like 역할을 수행하는 gameNameContains()입니다.
(하단에서 이 부분에 대한 성능 튜닝 과정을 진행하도록 하겠습니다.)
private BooleanExpression gameNameContains(String searchGameName) {
return searchGameName != null ? gameResult.gameName.contains(searchGameName) : null;
}
private BooleanExpression playerWinEq(Boolean viewOnlyWin) {
return viewOnlyWin != null ? playerResult.isWin.eq(true) : null;
}
private BooleanExpression playerLoseEq(Boolean viewOnlyLose) {
return viewOnlyLose != null ? playerResult.isWin.eq(false) : null;
}
private OrderSpecifier[] createOrderSpecifier(MyDetailGameResultCond cond) {
List<OrderSpecifier> orderSpecifiers = new ArrayList<>();
if (cond.getViewLatest() != null) {
orderSpecifiers.add(new OrderSpecifier(Order.DESC, gameResult.createdAt));
}
return orderSpecifiers.toArray(new OrderSpecifier[orderSpecifiers.size()]);
}
다음은 Slice에서 페이징 역할을 수행하는 count 로직입니다. 두 로직을 searchGameName()의 여부에 따라 분리한 이유는 불필요한 조인을 줄이기 위함입니다.
gameName은 gameResult에서 받아오고 있습니다. 이 경우 playerResult와 gameResult 간의 조인 연산과 where 연산이 필요합니다. 하지만, 이긴 게임 혹은 진 게임을 조회하는 경우는 직접적인 game_result에 대한 접근이 필요하지 않습니다. 왜냐하면, player_result 테이블은 game_result 테이블에 대한 외래키 제약조건이 걸려있습니다. 즉, 외래키가 존재하기 때문에 player_result는 game_result가 존재하는 한 유효한 row를 가질 수 있습니다.
카운트 쿼리는 실제 값의 여부가 아닌 페이징을 위한 row의 개수가 중요하므로, 불필요한 조인을 줄일 수 있는 상황이라면 줄이는 것이 성능에 유리하다고 판단하였습니다,
private JPAQuery<Long> countMyDetailGameResult(MyDetailGameResultCond cond) {
if (cond.getSearchGameName() == null) {
return query
.select(playerResult.count())
.from(playerResult)
.join(playerResult.member, member)
.where(
member.userId.eq(cond.getUserId()),
playerWinEq(cond.getViewOnlyWin()),
playerLoseEq(cond.getViewOnlyLose())
);
}
else {
return query
.select(playerResult.count())
.from(playerResult)
.join(playerResult.gameResult, gameResult)
.join(playerResult.member, member)
.where(
member.userId.eq(cond.getUserId()),
gameNameContains(cond.getSearchGameName())
);
}
}
4. 성능 분석하기
먼저 순수 SQL 실행 계획으로 제가 작성한 쿼리의 효율성을 분석하겠습니다.
count(*) 개수는 '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5'라는 userId를 가지고 있는 더미 회원의 개수 player_result 개수는 84개입니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5';
실행 계획을 살펴보면, index, fk, pk, pk로 해당 조건이 수행되어 연산이 계획되어 있습니다. 타입을 확인하면 ref , eq_ref로 인덱스 혹은 fk와 pk를 사용할 때 적용되는 결과가 작성되어 있습니다.
성능은 63ms로 기록되었습니다.
다음은 최신 순 조회입니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' order by gr.created_at desc;
여기서 한 가지 의문이 발생한 점이 있습니다. 분명 game_result는 created_at으로 인덱스를 생성했는데 order by 문에서 인덱스가 설정되지 않은 점입니다. 역시 데이터베이스는 정말 어려운 것 같습니다...!
(이 부분은 추후 다른 데이터를 토대로 다른 글을 작성하도록 하겠습니다.)
성능은 76ms가 나왔습니다.
동적 쿼리 부분에서 많은 문제를 야기할 수 있는 Like연산을 살펴보겠습니다.
like 연산은 많은 부하를 가할 수 있는 기능입니다. keyword%의 경우 인덱스를 활용할 수 있다는 장점이 있지만 와일드카드가 앞단에 위치하는 경우 인덱스를 적용할 수 없습니다. 현재의 경우 userId와 각각의 pk로 필터링되는 개수가 많으므로 적은 데이터가 남았지만, 만약 게임 이름으로 검색하는 비즈니스 로직이 추가된다면 많은 성능적 이슈를 발생시킬 수 있습니다.
이를 해결하는 방법으로 역 인덱스, 혹은 n-gram 인덱스 등을 활용할 수 있습니다.
역 인덱스는 입력되는 문자열을 기반으로 인덱스를 저장하는 검색 엔진입니다. 형태소 분석기 등과 함께 사용할 경우, 띄어쓰기 기준 혹은 특정 문자열 기준으로 파싱 한 문자열을 인덱스로 저장할 수 있습니다. 이 경우 검색 조건에서 like '%안녕%' 과 같은 데이터가 입력될 때 효율적으로 검색할 수 있습니다.
n-gram 인덱스는 '안녕하세요'와 같은 매칭하기 어려운 문자열을 n 단위로 잘라서 인덱스화하는 기법입니다.
'안녕' , '녕하 ', '하세', '세요'와 같이 2 단어로 잘라서 인덱스로 저장하기 때문에 단위별 검색 엔진을 구축할 때 효율적입니다.
하지만 꼭 장점만이 있는 것은 아닙니다. 만약 중복되는 이름의 인덱스가 많다면 상황에 따라서는 오히려 최적화가 되지 않을 수도 있습니다.
5. n_gram 인덱스 적용하여 성능 비교하기
저는, 추후 생길 수 있는 게임 검색 조건등을 고려하여, 2-gram 인덱스 적용을 고려해 보았습니다.
다음과 같이 'ㄱㄱ'이 있는 game_name을 검색할 수 있습니다.
이제 쿼리로 like 연산과 math 연산 간 유의미한 성능 차이가 있는지 파악해 보겠습니다.
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
gr.game_name like '%ㄱㄱ%';
explain
select sql_no_cache * from player_result p
join game_result gr on p.game_result_id = gr.game_result_id
join member m on m.member_id = p.member_id
join topic t on gr.topic_id = t.topic_id
where m.user_id = '0003c0c8-4af1-4bd0-bdb8-62ceb8308fa5' and
match (gr.game_name) against ('ㄱㄱ' in boolean mode);
오히려 이 경우 n_gram 인덱스를 사용했을 때, 2초 945로 성능이 더 하락한 것을 볼 수 있습니다.
실제, 실행계획을 살펴보면 eq_ref로 game_result 테이블에 대한 pk로 연산이 수행되는 것이 아니라 full_text 인덱스로 검색이 수행되고 있습니다. 즉 player_result의 game_result_id의 fk로 game_result의 pk를 찾아서 먼저 매칭한 후 개수를 줄인 것이 아니라, full text로 'ㄱㄱ'를 검색 후 pk를 찾아서 매칭한 결과입니다.
만약 ngram_token이 서로 다른 경우에는 이러한 검색이 효과적일 수 있지만, 중복 값이 많은 경우에는 성능이 더 하락하였습니다.
따라서, n_gram을 적용한다고 해서 성능 향상을 할 수 있었던 것은 아니었습니다. "게임 이름으로 검색"만 수행한다면 유의미한 결과를 도출할 수 있지만 조인이 복잡하게 연결된 상태에서는 성능 보장이 어려웠습니다.
6. QueryDsl 실행 쿼리 확인하고 성능 파악하기
이제 spring QueryDsl로 작성된 코드에서 test 환경으로 변경 후, 실제 쿼리의 동작을 확인하겠습니다.
먼저 like연산이 없는 최신순 조회 로직입니다.
@Test
@DisplayName("fetchMyDetailGameResult의 onlyLastViews를 테스트 한다.")
public void fetchMyDetailGameResult_onlyLastViews() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, true, null,
null, null);
long before = System.currentTimeMillis();
Slice<MyDetailGameResultDto> myDetailGameResultDtos =
myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before));
}
쿼리가 정상적으로 의도한 결과대로 작성되었습니다.
성능도 springBootTest를 기동하고 기타 작업이 수행된 과정으로 인해 741ms가 나왔지만 n_gram_idx를 사용했을 때보다 훨씬 빠른 성능을 보였습니다. 실제 커넥션 얻은 후 쿼리 작동 결과는 279ms입니다. 카운트 쿼리까지 작동이 되었음에도 279ms가 기록된 것은 괜찮은 성능이 유지된 것을 확인할 수 있습니다.
콘솔에서 확인하면, 84개의 행과 최신순 조회가 올바르게 설정된 것을 확인할 수 있었습니다.
다음은 like 연산에 대한 Spring QueryDsl의 결과입니다.
@Test
@DisplayName("fetchMyDetailGameResult의 searchGameName을 테스트 한다.")
public void fetchMyDetailGameResult_searchGameName() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
null, "ㄱㄱ");
long before = System.currentTimeMillis();
Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository
.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before));
}
성능이 338ms로 2s 보다는 더 빠른 성능이 유지되었습니다.
그리고, 앞서 카운트 쿼리에서 game_name가 cond에 설정되어 있는지 유무에 따라 다른 페이징 쿼리를 작성하였었습니다.
이게 유의미한 결과를 가져올 수 있는지 두 쿼리 또한 테스트를 진행해 보았습니다.
return query
.select(playerResult.count())
.from(playerResult)
.join(playerResult.member, member)
.join(playerResult.gameResult, gameResult)
.where(
member.userId.eq(cond.getUserId()),
gameNameContains(cond.getSearchGameName()),
playerWinEq(cond.getViewOnlyWin()),
playerLoseEq(cond.getViewOnlyLose())
);
< 조인문 구분 페이징 쿼리 >
@Test
@DisplayName("fetchMyDetailGameResult의 onlyLose을 테스트 한다.")
public void fetchMyDetailGameResult_onlyLose() throws Exception {
//given
Pageable page = PageRequest.of(0, 10);
//when
long before = System.currentTimeMillis();
MyDetailGameResultCond cond = new MyDetailGameResultCond(DEV_USER_ID, null, null,
true, null);
Slice<MyDetailGameResultDto> myDetailGameResultDtos = myDetailGameResultQueryDslRepository.fetchMyDetailGameResult(cond, page);
long after = System.currentTimeMillis();
//then
assertThat(myDetailGameResultDtos.getContent().get(0)).isNotNull();
System.out.println("Total Query Time = " + (after - before) + "ms");
}
약 100ms 차이가 있었지만 사실 다이나믹한 차이는 보이지 않아서 머쓱했습니다.. ㅎ!! 결과는 상황에 따라 바뀌는데 이 부분은 추후 다시 더미 대용량 데이터를 확보하여 재 테스트 해보겠습니다.!
+ 2023 11/1 추가 !
해당 테스트는 기본적으로 "원하는 검색어%" 형태의 결과를 체크하였습니다.
만약 "%원하는 검색어%"를 처리한다면, 다른 결과가 도출될 수 있습니다.
7. 정리하며...
평소에 스프링과 데이터베이스를 다루는 것을 좋아하기 때문에, 이렇게 QueryDsl로 작성된 코드의 실행 계획을 분석하는 것은 정말 즐거운 시간인 것 같습니다.
쿼리 실행 계획을 분석하며 제가 했던 계획이 올바르게 실행되지 않은 점도 확인할 수 있었습니다. created_at의 경우 인덱스로 설정하였지만, 인덱스가 활용되지 않은 이유가 쿼리의 문제인지 혹은 데이터 개수 타입, 분포 등의 문제인지 확실하지가 않아서 이 부분은 추후 다시 공부를 진행해야 할 것 같습니다.
또한, 언제든지 데이터의 특성에 따라 쿼리 실행 계획 변경은 불가피할 수 있습니다. 꾸준히 계속 테스트하며 상황에 맞는 최적의 쿼리가 설정될 수 있도록 공부하도록 하겠습니다.
아직 고려할 사항이 너무나도 많이 남아있습니다.
@Override
public void saveAllResultOfGame(SaveResultDto dto) {
GameResult gameResult = savePolicy.saveGameResult(dto);
dto.getPlayersInfo()
.stream()
.forEach(playerDto -> {
Long exp = calculateExp(gameResult, playerDto);
savePolicy.updatePlayer(gameResult, savePolicy.getPlayer(playerDto), playerDto.getGameRole(), exp);
savePolicy.savePlayerResult(gameResult.getId(), playerDto, exp);
});
}
"ERD에 인덱스로 설정된 gameId의 경우 SQS로 받을 때, uuid로 설정된 gameId를 그대로 uuid로 받아야 할까? atomicLong으로 변환 후 정수로 클러스터드 인덱스를 설정하면 추후 발생할 수 있는 검색 조건에 더 좋은 쿼리 향상을 할 수 있지 않을까?" 등입니다.
다음 편에서는 더욱 어려운 QueryDsl 쿼리를 작성하고 성능 분석하는 시간을 가지도록 하겠습니다.
부족하지만 오늘도 읽어주셔서 감사드립니다.!
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 의존성 주입과 Profile로 Filter 설정 동적 변경하기 (0) | 2023.04.01 |
---|---|
[SpringBoot] SpringWebSocket 활용한 실시간 대기실 기능 (2) | 2023.03.30 |
[SpringBoot] 중복 로그인 처리 (비즈니스 로직과 IP 차단) (1) | 2023.03.22 |
[SpringBoot] SpringRestDocs 활용하기 (1) | 2023.03.19 |
[SpringBoot] RedisTemplate 분산락/트랜잭션 ThreadLocal 활용하기 (5) | 2023.03.16 |