본문 바로가기

프로젝트/집안일 관리 시스템

[성능 개선] 유니온 쿼리를 개선해보자

버전

springboot 2.4.9

mysql 8.0.35

ngrinder 3.5.8

 

배경

공지와 투표는 별개의 도메인이지만

한 화면에서 최근 날짜 순으로 공지와 투표를 같이 보여줘야 하는 요구사항이 있었다.

 

이때 공지와 투표를 시간순으로 정렬해야 하고

데이터 형식을 하나로 맞춰서 보여줘야 한다는 점에서 쿼리에서 union을 사용했다.

 

하지만 데이터가 늘어나는 상황에서 2개의 테이블을 합치는 작업은 시간이 많이 걸리고

이는 사용자의 불편함으로 이어지게 된다.

 

 

성능 개선 방법 1 : Union에서 Union All로 변경

union은 테이블을 합치면서 중복제거 작업을 하는데 이를 위해 임시테이블을 만든다.

union all을 사용하면 이런 작업을 스킵할 수 있다.

 

아래는 쿼리를 추출하고

explain 명령어를 통해 optimizer가 실행 계획을 어떻게 수립했는지를 분석한 내용입니다.

# 쿼리문

select
    nv.type,
    nv.id,
    u.profile_img as profileImg,
    nv.content,
    u.users_id as userId,
    nv.created_time as createdAt  
from
   ((select
        vote_id as id,
        users_id,
        title as content,
        created_time,
        'V' as type  
    from
        Vote v  
    where
        v.room_id=? 
        and v.delete_status=?)  
	union
   (select
        notice_id as id,
        users_id,
        notice_item as content,
        created_time,
        'N' as type  
    from
        Notice n  
    where
        n.room_id=?
        and n.delete_status=?)) nv  
    inner join 
    	Users u 
        	on u.users_id = nv.users_id  
order by
nv.created_time desc  limit ?

 

아래 2개의 테이블에서 첫 번째는 union을 사용했을 때의 실행계획이고

두 번째는 union all을 사용했을 때의 실행계획이다.
두 번째를 보면 중복제거를 위해 임시 테이블을 만드는 작업을 스킵하는 것을 확인할 수 있다.

 

하지만 여전히 문제점을 확인할 수 있는 부분이 있다.

table full scan을 사용하고 있고 정렬 작업을 위해 임시 테이블을 사용하는 부분이다.

table type key extra
<derived2> ALL null Using temporary;
Using filesort
u eq_ref PRIMARY null
v ALL null Using where
n ALL null Using where
<union2,3> ALL null Using temporary

 

table type key extra
<derived2> ALL null Using temporary;
Using filesort
u eq_ref PRIMARY null
v ALL null Using where
n ALL null Using where

 

 

성능 개선 방법 2 : 조회 테이블 사용

조회용 테이블을 만들어서 사용하면 union을 사용하지 않을 수 있다.

notice 테이블과 vote 테이블의 정보를 합쳐놓은 notice_vote 테이블을 생성하였다.

# 쿼리문

select
    noticevote0_.type as col_0_0_,
    noticevote0_.notice_vote_id as col_1_0_,
    user1_.profile_img as col_2_0_,
    noticevote0_.content as col_3_0_,
    noticevote0_.users_id as col_4_0_,
    noticevote0_.created_time as col_5_0_ 
from
    notice_vote noticevote0_ 
inner join
    users user1_ 
        on (
            noticevote0_.users_id=user1_.users_id
        ) 
where
    noticevote0_.room_id=? 
    and noticevote0_.delete_status=? limit ?

 

조회용 테이블을 사용할 경우 기존 투표, 공지 테이블과 데이터를 동기화하는 작업이 필요하다.

투표, 공지 테이블에서 삽입/수정/삭제 작업이 발생할 경우 동기화가 필요하고

이때 이벤트를 사용해서 비동기로 조회용 테이블의 데이터를 갱신하였다.

// 공지를 생성하는 메서드

@Transactional
public void createNotice(String noticeItem, Long userId, Long roomId) {
    roomStore.getRoomEnteredByUser(userId, roomId);

    var notice = Notice.builder()
            .noticeItem(noticeItem)
            .roomId(roomId)
            .userId(userId)
            .build();
    var savedNotice = noticeStore.saveNotice(notice);

    publisher.publishEvent(
            SaveNoticeVoteEvent.builder()
                    .id(savedNotice.getId())
                    .type(NOTICE_TYPE)
                    .content(noticeItem)
                    .createdTime(savedNotice.getCreatedTime())
                    .roomId(roomId)
                    .userId(userId)
                    .build()
    );
}
// 투표를 생성하는 메서드

@Transactional
public void createVote(String title, List<String> choiceArr, Long userId, Long roomId) {
    roomStore.getRoomEnteredByUser(userId, roomId);

    var vote = Vote.builder()
            .title(title)
            .roomId(roomId)
            .userId(userId)
            .build();
    var voteItemList = choiceArr.stream()
            .map(VoteItem::new)
            .collect(Collectors.toList());
    var savedVote = voteStore.saveVote(vote, voteItemList);

    publisher.publishEvent(
            SaveNoticeVoteEvent.builder()
                    .content(title)
                    .createdTime(savedVote.getCreatedTime())
                    .userId(userId)
                    .roomId(roomId)
                    .type(VOTE_TYPE)
                    .id(savedVote.getId())
                    .build()
    );
}
// 조회용 테이블을 생성하는 메서드

@Async
@TransactionalEventListener
public void saveNoticeVote(SaveNoticeVoteEvent event){
    noticeVoteStore.saveNoticeVote(
            NoticeVote.builder()
                    .content(event.getContent())
                    .noticeVoteId(event.getId())
                    .createdTime(event.getCreatedTime())
                    .roomId(event.getRoomId())
                    .userId(event.getUserId())
                    .type(event.getType())
                    .build()
    );
}

 

 

테스트

300명의 가상유저를 가정하고 15분 동안 투표 생성 API , 공지 생성 API , 투표와 공지 조회 API를 테스트해 봤다.

시간이 흐르면서 데이터가 쌓일수록 union을 사용했을 때보다

조회용 테이블을 사용했을 때 TPS가 더 일정하게 나오는 것을 확인할 수 있다.

union 쿼리 사용
조회용 테이블 사용