ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [MySQL/Java] DB 쿼리 성능개선 (1) test용 mock/bulk data 삽입 방법
    DB/MySQL 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;

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

    반응형

    댓글

Designed by Tistory.