From 6c57b2f5d794bc3ba88374aea793b69e19b0aa27 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:36:18 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20=EC=A7=80=EC=97=B0=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=97=B0=EA=B4=80=EC=9D=84=20=EB=B0=B0=EC=B9=98=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BD=EC=96=B4=20=ED=9A=8C=EC=9B=90=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=98=20N+1=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EC=97=86?= =?UTF-8?q?=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hibernate.default_batch_fetch_size가 없어, 회원 명부처럼 목록을 돌며 직책(@ManyToMany)·트랙을 읽는 곳이 회원마다 쿼리를 하나씩 더 보냈다. 회원 21명 관리자 명부 조회가 SQL 40개에서 10개로 준다. --- src/main/resources/application.yml | 2 + ...berDirectoryQueryCountIntegrationTest.java | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5dbaf9a..7d483b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,8 @@ spring: properties: hibernate: format_sql: true + # 지연 로딩 연관(회원의 직책·트랙 등)을 목록 크기만큼 한 번에 읽는다(N+1 방지). + default_batch_fetch_size: 100 open-in-view: false flyway: diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java new file mode 100644 index 0000000..606390d --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java @@ -0,0 +1,105 @@ +package com.bcsdlab.bcsdinternalapiv2.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.model.Position; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.member.repository.PositionRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; + +/** 명부 목록은 회원 수와 무관하게 일정한 쿼리 수로 조회한다(회원마다 직책·트랙을 따로 읽지 않는다). */ +@TestPropertySource(properties = "spring.jpa.properties.hibernate.generate_statistics=true") +class MemberDirectoryQueryCountIntegrationTest extends IntegrationTestSupport { + + private static final int MEMBER_COUNT = 20; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PositionRepository positionRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + void 명부_조회_쿼리_수는_회원_수에_비례하지_않는다() throws Exception { + List tracks = trackMasterRepository.findAll(); + List positions = positionRepository.findAll(); + for (int i = 0; i < MEMBER_COUNT; i++) { + memberRepository.save(Member.builder() + .studentNumber("2030%04d".formatted(i)) + .name("명부" + i) + .track(tracks.get(i % tracks.size())) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("한국기술교육대학교") + .department("컴퓨터공학부") + .positions(positions.isEmpty() ? List.of() : List.of(positions.get(i % positions.size()))) + .email("dir" + i + "@bcsd.club") + .phoneNumber("0100000%04d".formatted(i)) + .status(MemberStatus.ACTIVE) + .role(MemberRole.MEMBER) + .build()); + } + String token = adminToken(); + + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + mockMvc.perform(get("/v1/admin/members").param("size", "50") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members.length()").value(MEMBER_COUNT + 1)); + + assertThat(statistics.getPrepareStatementCount()).isLessThan(15); + } + + private String adminToken() throws Exception { + memberRepository.save(Member.builder() + .studentNumber("20309999") + .password(passwordEncoder.encode("Temp1234")) + .name("관리자") + .track(trackMasterRepository.findByCode("BACKEND").orElseThrow()) + .generation("16") + .memberType(MemberType.MENTOR) + .university("한국기술교육대학교") + .department("컴퓨터공학부") + .email("admin@bcsd.club") + .phoneNumber("01099999999") + .status(MemberStatus.ACTIVE) + .role(MemberRole.ADMIN) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20309999\",\"password\":\"Temp1234\",\"rememberMe\":false}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return new ObjectMapper().readTree(body).get("accessToken").asText(); + } +}