[MySQL/Java] DB 쿼리 성능개선 (1) test용 mock/bulk data 삽입 방법
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;
이제 데이터 삽입은 완료 됐다!!