발단

본격적으로 성능 개선을 하기 위해 어느정도의 성능인지 테스트해야할 일이 생겼습니다. 테스트를 위해 더미데이터가 필요하였습니다. 대략 필요한 데이터의 수는 회원 100만건, 모임 100만건, 각 모임에 평균 5명의 참여자를 모이기 위해 참여 500만건 정도의 데이터가 필요하였습니다.

처음에는.. 데이터를 생성하려고 할 때 그냥 JPA를 이용하여 데이터를 저장하려고 하였습니다. 그냥저냥 무난할 줄 알았는데 100만건의 데이터를 넣기에는 너무나도 오랜 시간이 걸리더라구요. 끝이 보이지 않길래 이건 가망이 없다 싶어서 다른 방식을 찾아보다가, 코치와 크루에게 도움을 받아 JdbcTemplate의 BatchUpdate 방식을 사용하게 되었습니다.

 

BatchUpdate랑 일반 Update의 차이점

BatchUpdate를 사용하여 쿼리를 날려보니 생각보다 훨씬 훨씬 훨씬 더 더 더 많이 차이가 났습니다. 끝이 보이지 않는 100만건의 데이터 추가가 몇분안에 끝났습니다.

그렇다면 BatchUpdate는 어떻게 쿼리가 나가길래 빨리 끝나는 걸까요? 한번 직접 조회 로직을 비교해 보았습니다. 전체 테스트 코드는 다음과 같습니다.

@SpringBootTest
public class batchTest {

    private static final String INSERT_SQL = "insert into momo_member(user_id, password, name, deleted) values (?, ?, ?, ?)";

    @Autowired
    JdbcTemplate jdbcTemplate;

    @Autowired
    EntityManager entityManager;

    @Test
    void batchUpdate() {

        List<MemberDto> memberDtos = List.of(
                new MemberDto("momo1", "momo1234", "momokingppp"),
                new MemberDto("momo2", "momo1234", "momokingpppp")
        );

        System.out.println("데이터 출력 시작");
        jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                MemberDto memberDto = memberDtos.get(i);

                ps.setString(1, memberDto.userName);
                ps.setString(2, memberDto.password);
                ps.setString(3, memberDto.name);
                ps.setBoolean(4, false);
            }

            @Override
            public int getBatchSize() {
                return memberDtos.size();
            }
        });
    }

    class MemberDto {
        String userName;
        String password;
        String name;

        public MemberDto(String userName, String password, String name) {
            this.userName = userName;
            this.password = password;
            this.name = name;
        }
    }
}

 

로그가 잘 찍히는 것 같지 않아, MySQL WorkBench로 실제 데이터 추가 쿼리가 어떻게 추가되었나 살펴보겠습니다. 참고로, DB 로그를 테이블로 찍어보는 방법은 다음과 같습니다.

set global log_output = 'TABLE';
set global general_log = 'ON';

select * from mysql.general_log; // 로그 보기
truncate table mysql.general_log;

SET GLOBAL general_log = 'OFF'; // 이후 종료

 

실제 실행 결과를 캡쳐해 보았는데.. 너무 어두우므로.. 옮겨 써보겠습니다.

실행 화면.. 설정이 이상한지 폰트가 아주 어둡다.. 옮겨봐야 할 수준

insert into momo_member(user_id, password, name, deleted) 
	values ('momo1', 'momo1234', 'momokingppp', 0),('momo2', 'momo1234', 'momokingpppp', 0)

여러개의 추가 쿼리가 하나의 insert문으로 묶여서 가는 모습을 볼 수 있습니다. 여러개의 insert문보다 하나의 insert문으로 여러개의 데이터를 묶어서 보내는 것이 더 속도가 빠를까요? 결론적으로는, 하나의 insert문이 훨씬 빠르다고 합니다. 하나의 insert문이 실행되기까지는 연결, 서버로 쿼리 전송, 구문 분석, 행 삽입, 인덱스 삽입, 연결 종료 등의 절차가 모두 포함되기 때문입니다.

 

https://dev.mysql.com/doc/refman/8.0/en/insert-optimization.html

 

MySQL :: MySQL 8.0 Reference Manual :: 8.2.5.1 Optimizing INSERT Statements

8.2.5.1 Optimizing INSERT Statements To optimize insert speed, combine many small operations into a single large operation. Ideally, you make a single connection, send the data for many new rows at once, and delay all index updates and consistency checkin

dev.mysql.com

 

더..더 빠르게

전체 데이터를 넣는데까지 대략 20분정도의 시간이 걸렸습니다. 20분 정도면 충분히 만족 가능하지만.. 최근에 쓰레드를 배워서 멀티 쓰레드를 적용한다면 조금 더 시간이 빨라질 것 같습니다. 따라서 전략을 세웠습니다.

  1. 총 5개의 쓰레드를 동작시킵니다.
  2. 5개의 쓰레드가 개별적으로 유저를 저장합니다. 한 쓰레드당 200000명(memberBlockSize)의 유저를 저장합니다
    1. 1번 쓰레드가 1번~200000번 유저
    2. 2번 쓰레드가 200001번 유저 ~ 400000번 유저
    3. 3번 쓰레드가 400001번 유저 ~ 600000번 유저
    4. 4번 쓰레드가 600001번 유저 ~ 800000번 유저
    5. 5번 쓰레드가 800001번 유저 ~ 1000000번 유저
  3. 각 쓰레드가 저장할 땐 1000개의 데이터(BATCH_SIZE)씩 저장합니다.

위와 같은 구조로 한다면 이전보다는 더 빨라질 것 같았습니다. Member에 해당하는 구현 코드는 다음과 같습니다.

private void saveMembers() {
    final int memberBlockSize = TOTAL_MEMBER_SIZE / THREAD_COUNT; // 한 쓰레드가 맡아서 저장해야하는 유저 수

    for (int threadNumber = 0; threadNumber < THREAD_COUNT; threadNumber++) {
        saveMemberByThread(memberBlockSize, threadNumber);
    }
}

private void saveMemberByThread(int memberBlockSize, int threadNumber) {
    executorService.execute(() -> {
        log.info("Member 저장 시작-" + threadNumber);
        for (int i = 0; i < memberBlockSize / BATCH_SIZE; i++) {
            saveMemberBlock(memberBlockSize, threadNumber, i);
        }
        log.info("Member 저장 종료-" + threadNumber);
    });
}

private void saveMemberBlock(int memberBlockSize, int threadNumber, int i) {
    int currentMemberBlockStartId = memberBlockSize * threadNumber;
    List<MemberDto> generatedMember = makeMember(
            currentMemberBlockStartId + BATCH_SIZE * i + 1,
            currentMemberBlockStartId + BATCH_SIZE * (i + 1)
    );

    jdbcTemplate.batchUpdate(MEMBER_INSERT_SQL, new BatchPreparedStatementSetter() {
        @Override
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            MemberDto member = generatedMember.get(i);
            ps.setLong(1, member.getId());
            ps.setBoolean(2, false);
            ps.setString(3, member.getUserId());
            ps.setString(4, member.getUserName());
            ps.setString(5, member.getPassword());
        }

        @Override
        public int getBatchSize() {
            return generatedMember.size();
        }
    });
}

private List<MemberDto> makeMember(int startId, int endId) {
    List<MemberDto> members = new ArrayList<>();
    for (long i = startId; i <= endId; i++) {
        members.add(new MemberDto(i));
    }
    return members;
}

 

전체적으로 구현한 결과, 최종적으로 모든 데이터를 저장하기까지 대략 10분정도의 시간이 걸렸습니다. 초기 단계는 저장이 완료될 기미조차 보이지 않았는데, 최종적으로 10분정도면 괄목할 만한 성과가 아닐까 싶습니다.

저장 시작 시간
전체 쓰레드 종료 시간

 

+ Recent posts