DB/MySQL

[MySQL/Java] DB 쿼리 성능개선 (1) test용 mock/bulk data 삽입 방법

erinh 2023. 6. 26. 22:09
반응형

Gotcha 프로젝트 리팩토링을 하면서 DB 성능 개선에 도전해보고 싶다는 목표!

굉장히 조그마한 프로젝트였기 때문에 쿼리를 신경 쓰지 않고 작성했는데,

데이터가 몇십, 몇백만 단위로 쌓이게 된다면 문제가 발생할 수 있을 것 같다는 생각이 들었다.

 

그래서 쿼리 성능 테스트를 진행해보기로 했다.

하지만.. 대량 데이터를 넣는 것부터 배울게 아주 많군요...!

 

우선 테스트하고자 하는 코드를 간략화 하면 아래와 같았다.

@CustomSpringBootTest
@AutoConfigureMockMvc
class ParticipantControllerTest {

    @Autowired
    EntityManager em;
    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @Autowired
    PasswordEncoder passwordEncoder;

    private Long ROOM_ID;
    private Long ROOM_HAS_NO_PROBLEM_ID;
    private final Integer USER_PASSWORD = 1234;
    private final LocalDateTime GAME_START_TIME = LocalDateTime.now();
    private final LocalDateTime GAME_END_TIME = LocalDateTime.now().plusDays(7);
    private final LocalDateTime USER_GAME_START_TIME = LocalDateTime.now().plusHours(1);
    private final LocalDateTime USER_GAME_END_TIME = LocalDateTime.now().plusHours(2);
    private final int NUMBER_OF_PROBLEMS = 5;
    private final String url = "http://localhost:8080/api/game/";

    @BeforeEach
    void setUp() {
        // 유저(출제자) 생성
        Member member = Member.builder()
                .email("yezi@naver.com")
                .organization("naver").build();
        em.persist(member);
        // 방 생성
        Room room = Room.builder()
                .title("ssafy")
                .code(101101)
                .hasReward(true)
                .member(member)
                .startTime(GAME_START_TIME)
                .endTime(GAME_END_TIME)
                .build();
        em.persist(room);
        ROOM_ID = room.getId();
        // 문제 없는 방 생성
        Room roomB= Room.builder()
                .title("임시")
                .build();
        em.persist(roomB);
        ROOM_HAS_NO_PROBLEM_ID = roomB.getId();
        // 보상 생성
        for (int i = 1; i <= 3; i++) {
            Reward reward = Reward.builder()
                    .name("상품" + i)
                    .grade(i)
                    .room(room)
                    .build();
            em.persist(reward);
        }
        // 문제 생성
        for (int i = 1; i <= NUMBER_OF_PROBLEMS; i++) {
            Problem problem = Problem.builder()
                    .name("문제" + i)
                    .hint("힌트입니다.")
                    .imageUrl("url")
                    .room(room)
                    .build();
            em.persist(problem);
        }
        // 참여자 생성(게임완료)
        String HASH_PWD = passwordEncoder.encode(USER_PASSWORD.toString());
        LocalDateTime startTime = LocalDateTime.now().plusHours(1);
        for (int i = 200000; i > 0; i--) {
            LocalDateTime endTime = LocalDateTime.now().plusHours((i % 10) + 1);
            Participant participant = Participant.builder()
                    .nickname("참여자" + i)
                    .password(HASH_PWD)
                    .startTime(startTime)
                    .endTime(endTime)
                    .duration(Duration.between(startTime, endTime))
                    .solvedCnt(i % 5)
                    .isFinished(true)
                    .room(room)
                    .build();
            em.persist(participant);
        }
        // 참여자 생성(게임완료)
        Participant participantA = Participant.builder()
                .nickname("YEZI")
                .password(HASH_PWD)
                .startTime(startTime)
                .endTime(startTime.plusDays(1))
                .duration(Duration.between(startTime, startTime.plusMinutes(1)))
                .solvedCnt(1)
                .isFinished(true)
                .room(room)
                .build();
        em.persist(participantA);
        // 참여자 생성(게임미완료)
        Participant participantB = Participant.builder()
                .nickname("TAEGYU")
                .password(HASH_PWD)
                .startTime(startTime)
                .solvedCnt(2)
                .isFinished(false)
                .room(room)
                .build();
        em.persist(participantB);
        // 참여자 생성(가입만 완료)
        Participant participantC = Participant.builder()
                .nickname("DASOM")
                .password(HASH_PWD)
                .isFinished(false)
                .room(room)
                .build();
        em.persist(participantC);
        // 참여자 생성(문제 없는 방)
        Participant participantTemp = Participant.builder()
                .nickname("YEZI")
                .password(HASH_PWD)
                .startTime(startTime)
                .endTime(startTime.plusDays(1))
                .duration(Duration.between(startTime, startTime.plusDays(1)))
                .solvedCnt(3)
                .isFinished(true)
                .room(roomB)
                .build();
        em.persist(participantTemp);
    }
    
    @Nested
    @DisplayName("랭킹 확인하기 API TEST")
    class GetRankList {

        @Test
        @DisplayName("해당하는 방 없음")
        void invalidRoom() throws Exception {
            RankInfoRequest request = RankInfoRequest.builder()
                    .roomId(100000000L)
                    .nickname("YEZI")
                    .build();
            mockMvc
                    .perform(post(url + "rank")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsBytes(request))
                    )
                    .andExpect(jsonPath("$.status", is(404)))
                    .andExpect(jsonPath("$.code", is("R100")));
        }

        @Test
        @DisplayName("해당 유저를 찾을 수 없음")
        void invalidParticipant() throws Exception {
            RankInfoRequest request = RankInfoRequest.builder()
                    .roomId(ROOM_ID)
                    .nickname("MINSU")
                    .build();
            mockMvc
                    .perform(post(url + "rank")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsBytes(request))
                    )
                    .andExpect(jsonPath("$.status", is(404)))
                    .andExpect(jsonPath("$.code", is("P200")));
        }

        @Test
        @DisplayName("랭킹 목록 불러오기 성공")
        void getRank() throws Exception {
            RankInfoRequest request = RankInfoRequest.builder()
                    .roomId(ROOM_ID)
                    .nickname("YEZI")
                    .build();
            MockHttpServletResponse response = mockMvc.perform(post(url + "rank")
                            .content(objectMapper.writeValueAsBytes(request))
                            .contentType(MediaType.APPLICATION_JSON))
                    .andDo(print())
                    .andReturn()
                    .getResponse();
            BaseResponse<List<ParticipantRankListResponse>> arrayList = objectMapper.readValue(response.getContentAsString(), BaseResponse.class);
            assertEquals(4, arrayList.getResult().size());
        }
    }
}

 

기존 테스트 방법 : @BeforeEach 사용하여 테스트코드에서 Mock 데이터 삽입

기존에는 데이터를 삽입할 때 @BeforeEach를 사용하여 각 테스트를 돌기 전 데이터를 삽입했다.
그러다보니 테스트 개수가 많아질수록 똑같은 데이터를 지속적으로 삽입해야 해서 테스트 시간이 오래 걸렸다.
특히 몇십, 몇백만개 단위로 데이터를 넣어야 하니 데이터 넣는데만 20초가 넘게 걸리는 상황 발생;

그래서 다른 테스트 방법을 찾아야겠다고 판단했다.

첫 번째 시도 : @BeforeAll을 사용하여 데이터 삽입

시행착오 1. @BeforeEach ➡️ @BeforeAll로 단순 변경

- 될 거라 생각 안했고, 당연히 에러 발생!
- 아래와 같은 에러가 발생하고, 하위의 테스트들은 모두 무시되었다.

...

    @BeforeAll
    void setUp() {
        // 유저(출제자) 생성
        Member member = Member.builder()
                .email("yezi@naver.com")
                .organization("naver").build();
        em.persist(member);
        
 ...
javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call

우선은 @BeforeAll 사용 방식이 잘못됐다는 것을 발견했다.
- Test 객체는 기본적으로 "PER-METHOD" 라이프사이클을 갖고 있다. 즉, 테스트 메소드를 실행할 때마다 테스트 객체를 새로 생성하게 되는데, 테스트 메소드를 실행하기에 앞서 @BeforeEach 어노테이션이 있는 메서드를 호출하여 설정 작업을 수행한다. 그러다보니 각 테스트 메서드마다 새로운 테스틑 객체가 생성되어, @BeforeAll에서 초기화한 값을 사용할 수 없게 된다. 
- 따라서 @BeforeAll 어노테이션을 쓰기 위해서는 테스트 객체를 static으로 선언하거나, 테스트 객체의 라이프 스타일을 "PER-CLASS"로 변경해주어야 한다. 

시행착오 2. @TestInstance(TestInstance.Lifecycle.PER_CLASS)과 @Transactional 추가

- 그렇다면 이제 되어야 하지 않을까..? 했는데 똑같은 실패가 떴다. em이 계속 없다고 뜨는 것..!

- @BeforeAll의 경우 Spring Test에서 자체적으로 트랜젝션을 자동으로 시작하고 관리하지 않음. 그렇기 때문에 어디서 트랜잭션을 확성화해야 하는지에 대한 정보가 없어 엔티티의 영속화가 불가능한 것
- 그래서 아래와 같이 @Transactional을 붙여보았지만 똑같이 문제가 해결되지는 않았다.

@CustomSpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Transactional
class ParticipantControllerTest {

    ...

    @BeforeAll
    void setUp() {
        // 유저(출제자) 생성
        Member member = Member.builder()
                .email("yezi@naver.com")
                .organization("naver").build();
        em.persist(member);
        // 방 생성
        Room room = Room.builder()
        
...

- 추가적인 방법으로는 명시적으로 트랜젝션을 시작하고 커밋해야 하는데.. 과연 이렇게 코드의 복잡성을 증가시키는게 좋은 방법인가에 대한 의문이 들었다. 따라서 아예 테스트가 아닌 디비에 데이터를 넣는 방식으로 재접근을 하기로 했다.
- 이 부분에 대해서는 좀 더 간단한 테스트코드로 몇 번 더 연습해 보아야겠다.

두 번째 시도 : MySQL DB에 삽입

다음으로는 MySql로 쿼리를 한 번에 삽입하는 방법이다.
Procedure를 활용하여 함수를 짜고, 데이터를 삽입해줬다.

-- Member 데이터 삽입 (게임룸 생성을 위한 서비스 가입 유저)
INSERT INTO member
VALUE (1, 'erin@gmail.com', 'erin', 'tistory', '1234', 'url', 'normal');

-- Room 데이터 삽입 (게임룸 생성)
insert into room
value (1, 0402, 'purple', date_add(now(), interval 12 day), 'Object를 찾아라',' eventUrl', false, 'logoUrl', date_add(now(), interval 2 day), 'title', '1');

-- Participant 데이터 삽입을 위한 함수 선언 (게임에 단순 참여한 유저)
DELIMITER $$ 
CREATE PROCEDURE createParticipant() -- ⓐ createParticipant이라는 이름의 프로시져
BEGIN
    DECLARE i INT DEFAULT 1; -- ⓑ i변수 선언, default값으로 1설정
    WHILE (i <= 200000) DO -- ⓒ for문 작성(i가 200_000이 될 때까지 반복)
        INSERT INTO participant (id, duration, end_time, is_finished, nickname, password, phone_number, solved_cnt, start_time, room_id)
        VALUES (i, ceiling(rand() * 10), date_add(now(), interval 12 day), true, concat('ssafy',i), '1234','010-7183-0996',ceiling(rand() * 10), date_add(now(), interval 2 day), 1);
        SET i = i + 1; -- ⓔ i값에 1더해주고 WHILE문 처음으로 이동
    END WHILE;
END$$
DELIMITER ; -- ⓕ구분 기호를 다시 ;로 바꿔주기

-- Participant 데이터 삽입
call createParticipant;

이제 데이터 삽입은 완료 됐다!!

반응형