Skip to content

Commit 7b0b22e

Browse files
authored
[Refactor]: 인덱싱으로 검색 최적화 (#109)
* [Feat]: TestGenerator # Conflicts: # src/main/java/com/backend/global/security/SecurityConfig.java * [Test]: 상품 목록 조회 성능 테스트 * [Test]: K6 연동 * [Feat]: 인덱싱 적용 * [Docs]: .env.default 추가 * [Feat]: h2-console 설정
1 parent 87ec92f commit 7b0b22e

File tree

15 files changed

+428
-12
lines changed

15 files changed

+428
-12
lines changed

.env.default

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
PG_TOSS_CLIENT_KEY=NEED_TO_SET
2+
PG_TOSS_SECRET_KEY=NEED_TO_SET

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ out/
4040
db_dev.mv.db
4141
db_dev.trace.db
4242
src/main/generated/
43+
k6-tests/results
4344

4445
### env ###
4546
.env
46-
*.env
47-
.env.*
47+
*.env

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dependencies {
6363
testImplementation("org.springframework.security:spring-security-test")
6464
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
6565
testImplementation("com.github.codemonstur:embedded-redis:1.4.2")
66+
implementation("net.datafaker:datafaker:2.1.0")
6667
}
6768

6869
tasks.withType<Test> {

k6-tests/docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
version: "3.8"
2+
services:
3+
k6:
4+
image: grafana/k6:latest
5+
container_name: k6-load-test
6+
working_dir: /scripts
7+
volumes:
8+
- ./:/scripts
9+
- ./results:/results
10+
extra_hosts:
11+
- "host.docker.internal:host-gateway"
12+
command: run getProducts-test.js

k6-tests/getProducts-test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import http from 'k6/http';
2+
import { check, sleep } from 'k6';
3+
import { Rate, Trend } from 'k6/metrics';
4+
5+
// 커스텀 메트릭
6+
const errorRate = new Rate('errors');
7+
const searchDuration = new Trend('search_duration');
8+
9+
// 테스트 설정
10+
export const options = {
11+
stages: [
12+
{ duration: '30s', target: 10 }, // 워밍업: 10명
13+
{ duration: '1m', target: 30 }, // 30명으로 증가
14+
{ duration: '2m', target: 30 }, // 30명 유지
15+
{ duration: '30s', target: 0 }, // 종료
16+
],
17+
thresholds: {
18+
'http_req_duration': ['p(95)<1000', 'p(99)<2000'],
19+
'errors': ['rate<0.05'],
20+
},
21+
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
22+
};
23+
24+
const BASE_URL = 'http://host.docker.internal:8080'; // Docker에서 로컬 접근
25+
26+
// 다양한 검색 시나리오
27+
const scenarios = [
28+
{ name: '키워드_일반', params: `keyword=${encodeURIComponent('아이폰')}` },
29+
{ name: '키워드_희소', params: `keyword=${encodeURIComponent('MacBook Pro M3')}` },
30+
{ name: '카테고리', params: 'category=1' },
31+
{ name: '지역', params: `location=${encodeURIComponent('서울')}` },
32+
{ name: '복합검색', params: `keyword=${encodeURIComponent('아이폰')}&category=1&location=${encodeURIComponent('서울')}` },
33+
{ name: '페이징', params: 'page=5&size=20' },
34+
];
35+
36+
export default function() {
37+
// 랜덤하게 시나리오 선택
38+
const scenario = scenarios[Math.floor(Math.random() * scenarios.length)];
39+
const url = `${BASE_URL}/api/v1/products?${scenario.params}`;
40+
41+
const startTime = new Date();
42+
const response = http.get(url, {
43+
tags: { scenario: scenario.name },
44+
timeout: '10s',
45+
});
46+
const duration = new Date() - startTime;
47+
48+
// 400 에러 상세 정보 출력
49+
if (response.status === 400) {
50+
console.log(`\n❌ 400 에러 상세 정보:`);
51+
console.log(`시나리오: ${scenario.name}`);
52+
console.log(`URL: ${url}`);
53+
console.log(`응답 본문: ${response.body}`);
54+
console.log(`응답 헤더: ${JSON.stringify(response.headers)}\n`);
55+
}
56+
57+
// 응답 검증
58+
const success = check(response, {
59+
'상태 200': (r) => r.status === 200,
60+
'응답시간 < 2초': (r) => r.timings.duration < 2000,
61+
'데이터 존재': (r) => {
62+
try {
63+
const body = JSON.parse(r.body);
64+
return body.data && body.data.content !== undefined;
65+
} catch {
66+
return false;
67+
}
68+
},
69+
});
70+
71+
if (!success) {
72+
console.log(`❌ 실패: ${scenario.name} - Status: ${response.status}`);
73+
}
74+
75+
// 메트릭 기록
76+
errorRate.add(!success);
77+
searchDuration.add(duration);
78+
79+
// 사용자 행동 시뮬레이션
80+
sleep(Math.random() * 2 + 1); // 1~3초 대기
81+
}
82+
83+
// 테스트 종료 후 요약
84+
export function handleSummary(data) {
85+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
86+
87+
const duration = data.metrics.http_req_duration?.values || {};
88+
const reqs = data.metrics.http_reqs?.values || {};
89+
const errors = data.metrics.errors?.values || {};
90+
91+
console.log('\n=== 현재 상태 성능 테스트 결과 ===');
92+
console.log(`평균 응답시간: ${duration.avg?.toFixed(2) || 'N/A'}ms`);
93+
console.log(`P95 응답시간: ${duration['p(95)']?.toFixed(2) || 'N/A'}ms`);
94+
console.log(`P99 응답시간: ${duration['p(99)']?.toFixed(2) || 'N/A'}ms`);
95+
console.log(`최대 응답시간: ${duration.max?.toFixed(2) || 'N/A'}ms`);
96+
console.log(`총 요청 수: ${reqs.count || 0}`);
97+
console.log(`에러율: ${errors.rate ? (errors.rate * 100).toFixed(2) : '0.00'}%`);
98+
99+
return {
100+
'stdout': JSON.stringify(data, null, 2),
101+
[`results/getProducts-test-${timestamp}.json`]: JSON.stringify(data, null, 2),
102+
'results/getProducts-test-latest.json': JSON.stringify(data, null, 2), // 항상 최신 결과
103+
};
104+
}

src/main/java/com/backend/domain/product/entity/Product.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,16 @@
2222
import static java.time.LocalDateTime.now;
2323

2424
@Entity
25-
@Table(name = "products")
25+
@Table(name = "products", indexes = {
26+
// 기본 목록 조회 (상태, 최신순)
27+
@Index(name = "idx_status_create", columnList = "status, create_date DESC"),
28+
// 판매자 목록 조회 (회원, 상태, 최신순)
29+
@Index(name = "idx_seller_status_create", columnList = "seller_id, status, create_date DESC"),
30+
// 카테고리 목록 조회 (카테고리, 상태, 최신순)
31+
@Index(name = "idx_category_status_create", columnList = "category, status, create_date DESC"),
32+
// 지역 목록 조회 (지역, 상태, 최신순)
33+
@Index(name = "idx_location_status_create", columnList = "location, status, create_date DESC")
34+
})
2635
@Getter
2736
@NoArgsConstructor
2837
public class Product extends BaseEntity {

src/main/java/com/backend/global/security/SecurityConfig.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.springframework.http.HttpMethod;
88
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
99
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
10+
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
1011
import org.springframework.security.config.http.SessionCreationPolicy;
1112
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1213
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -26,7 +27,7 @@ public class SecurityConfig {
2627
@Bean
2728
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
2829
http.csrf(AbstractHttpConfigurer::disable)
29-
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
30+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
3031
.authorizeHttpRequests(auth -> auth
3132
// 정적 리소스(/static, /public, /resources, /META-INF/resources)..
3233
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
@@ -35,22 +36,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
3536
.requestMatchers("/billing.html", "/payments/**", "/toss/**").permitAll()
3637

3738
// 공개 API (기존)..
39+
.requestMatchers("/favicon.ico", "/h2-console/**").permitAll()
3840
.requestMatchers("/api/v1/auth/**", "/swagger-ui/**", "/v3/api-docs/**",
3941
"/swagger-ui.html", "/webjars/**", "/notifications/**", "/ws/**",
4042
"/api/test/**", "/bid-test.html", "/websocket-test.html").permitAll()
4143
.requestMatchers(HttpMethod.GET,
42-
"/api/*/products",
43-
"/api/*/products/{productId:\\d+}",
44-
"/api/*/products/members/{memberId:\\d+}"
45-
).permitAll()
44+
"/api/*/products", "/api/*/products/{productId:\\d+}",
45+
"/api/*/products/members/{memberId:\\d+}").permitAll()
46+
.requestMatchers("/api/*/test-data/**").permitAll()
4647

4748
.anyRequest().authenticated()
4849
)
50+
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
51+
.csrf(AbstractHttpConfigurer::disable)
4952
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
5053
.formLogin(AbstractHttpConfigurer::disable)
5154
.httpBasic(AbstractHttpConfigurer::disable)
5255
.logout(AbstractHttpConfigurer::disable);
53-
5456
return http.build();
5557
}
5658

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.backend.global.testdata.controller;
2+
3+
import com.backend.domain.member.repository.MemberRepository;
4+
import com.backend.domain.product.repository.ProductRepository;
5+
import com.backend.global.testdata.generator.TestDataGenerator;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import lombok.Builder;
9+
import lombok.Data;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.context.annotation.Profile;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
@RestController
16+
@RequestMapping("/api/v1/test-data")
17+
@Profile("dev")
18+
@Tag(name = "TestData", description = "테스트 데이터 생성 API (개발용)")
19+
@RequiredArgsConstructor
20+
public class TestDataController {
21+
22+
private final TestDataGenerator testDataGenerator;
23+
private final ProductRepository productRepository;
24+
private final MemberRepository memberRepository;
25+
26+
@PostMapping("/generate")
27+
@Operation(summary = "테스트 데이터 생성")
28+
public ResponseEntity<TestDataGenerationResult> generateTestData(
29+
@RequestParam(defaultValue = "1000") int count
30+
) {
31+
if (count > 10000) {
32+
return ResponseEntity.badRequest()
33+
.body(TestDataGenerationResult.fail("최대 10000개까지만 생성 가능"));
34+
}
35+
36+
long startTime = System.currentTimeMillis();
37+
38+
try {
39+
testDataGenerator.generateTestData(count);
40+
41+
long duration = System.currentTimeMillis() - startTime;
42+
43+
return ResponseEntity.ok(TestDataGenerationResult.success(
44+
count,
45+
duration,
46+
productRepository.count(),
47+
memberRepository.count()
48+
));
49+
50+
} catch (Exception e) {
51+
return ResponseEntity.status(500)
52+
.body(TestDataGenerationResult.fail(e.getMessage()));
53+
}
54+
}
55+
56+
@GetMapping("/stats")
57+
@Operation(summary = "현재 데이터 통계")
58+
public ResponseEntity<DataStats> getDataStats() {
59+
return ResponseEntity.ok(DataStats.builder()
60+
.productCount(productRepository.count())
61+
.memberCount(memberRepository.count())
62+
.build());
63+
}
64+
65+
@DeleteMapping("/cleanup")
66+
@Operation(summary = "테스트 데이터 정리")
67+
public ResponseEntity<String> cleanupTestData() {
68+
// 테스트 데이터만 삭제하는 로직
69+
return ResponseEntity.ok("테스트 데이터 정리 완료");
70+
}
71+
}
72+
73+
@Data
74+
@Builder
75+
class TestDataGenerationResult {
76+
private boolean success;
77+
private String message;
78+
private Integer generatedCount;
79+
private Long durationMs;
80+
private Long totalProducts;
81+
private Long totalMembers;
82+
83+
static TestDataGenerationResult success(int count, long duration, long products, long members) {
84+
return TestDataGenerationResult.builder()
85+
.success(true)
86+
.message("테스트 데이터 생성 완료")
87+
.generatedCount(count)
88+
.durationMs(duration)
89+
.totalProducts(products)
90+
.totalMembers(members)
91+
.build();
92+
}
93+
94+
static TestDataGenerationResult fail(String message) {
95+
return TestDataGenerationResult.builder()
96+
.success(false)
97+
.message(message)
98+
.build();
99+
}
100+
}
101+
102+
@Data
103+
@Builder
104+
class DataStats {
105+
private Long productCount;
106+
private Long memberCount;
107+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.backend.global.testdata.generator;
2+
3+
import com.backend.domain.member.dto.MemberSignUpRequestDto;
4+
import com.backend.domain.member.dto.MemberSignUpResponseDto;
5+
import com.backend.domain.member.entity.Member;
6+
import com.backend.domain.member.service.MemberService;
7+
import com.backend.global.response.RsData;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import net.datafaker.Faker;
11+
import org.springframework.context.annotation.Profile;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.util.ArrayList;
15+
import java.util.List;
16+
import java.util.Locale;
17+
18+
@Component
19+
@Profile({"dev", "test", "local"})
20+
@RequiredArgsConstructor
21+
@Slf4j
22+
public class MemberTestDataGenerator {
23+
24+
private final MemberService memberService;
25+
private final Faker faker = new Faker(new Locale("ko"));
26+
27+
public List<Member> generate(int count) {
28+
log.info("회원 데이터 생성 시작: {}개", count);
29+
30+
List<Member> members = new ArrayList<>();
31+
32+
for (int i = 0; i < count; i++) {
33+
MemberSignUpRequestDto request = new MemberSignUpRequestDto(
34+
faker.internet().emailAddress(),
35+
"password123",
36+
faker.name().name(),
37+
faker.phoneNumber().cellPhone(),
38+
faker.address().fullAddress()
39+
);
40+
41+
try {
42+
RsData<MemberSignUpResponseDto> result = memberService.signup(request);
43+
if ("200-1".equals(result.resultCode())) {
44+
memberService.findById(result.data().memberId())
45+
.ifPresent(members::add);
46+
}
47+
48+
if ((i + 1) % 100 == 0) {
49+
log.info("진행률: {}/{}", i + 1, count);
50+
}
51+
52+
} catch (Exception e) {
53+
log.warn("회원 생성 실패 ({}번째): {}", i + 1, e.getMessage());
54+
}
55+
}
56+
57+
return members;
58+
}
59+
}

0 commit comments

Comments
 (0)