Skip to content

Commit 1c478e1

Browse files
authored
[Feat]: Elasticsearch를 이용한 상품 목록 조회 기능 구현 (#134)
* [Feat]: Elasticsearch 설정 # Conflicts: # src/main/resources/application.yml * [Feat]: Document 설정 * [Feat]: ElasticRepository * [Feat]: service, repository * [Feat]: sync, controller * [Feat]: 초기 데이터 인덱싱 * [Feat]: SellerDto 응답 개선 * [Refactor]: Facade 패턴 적용 * [Refactor]: Facade 패턴 제거 * [Fix]: 기존 test 오류 해결 * [Test]: Elasticsearch repository test * [Test]: Elasticsearch service test * [Test]: Elasticsearch controller test * [Refactor]: bidderCount 추가 * [Feat]: 회원 기반 상품 목록 조회 Elasticsearch로 구현 * [Test]: 회원 기반 상품 목록 조회 테스트 작성 * [Refactor]: document 부분 업데이트 * [Refactor]: event를 통한 document 부분 업데이트 * [Test]: k6 test, async * [Docs]: ProductControllerDocs 작성 * [Feat]: 운영용 설정 * [Feat]: application-prod * [Feat]: main.tf에 elasticsearch 추가
1 parent 9a22aa6 commit 1c478e1

File tree

57 files changed

+2742
-77
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2742
-77
lines changed

.env.default

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
PG_TOSS_CLIENT_KEY=NEED_TO_SET
2-
PG_TOSS_SECRET_KEY=NEED_TO_SET
2+
PG_TOSS_SECRET_KEY=NEED_TO_SET
3+
SPRING__DATA__REDIS__PASSWORD=NEED_TO_SET
4+
SPRING__DATASOURCE__URL___DB_NAME=NEED_TO_SET
5+
JWT_SECRET=NEED_TO_SET
6+
JWT_ACCESS_TOKEN_EXPIRATION=NEED_TO_SET
7+
JWT_REFRESH_TOKEN_EXPIRATION=NEED_TO_SET

build.gradle.kts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import org.gradle.kotlin.dsl.implementation
2-
31
plugins {
42
java
53
id("org.springframework.boot") version "3.5.5"
@@ -32,8 +30,9 @@ dependencies {
3230
implementation("org.springframework.boot:spring-boot-starter-security")
3331
implementation("org.springframework.boot:spring-boot-starter-web")
3432
implementation("org.springframework.boot:spring-boot-starter-data-redis")
35-
implementation ("org.springframework.boot:spring-boot-starter-websocket")
36-
implementation ("org.springframework.boot:spring-boot-starter-webflux")
33+
implementation("org.springframework.boot:spring-boot-starter-websocket")
34+
implementation("org.springframework.boot:spring-boot-starter-webflux")
35+
implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
3736

3837

3938
// 스프링부트 추가 기능

docker-compose.yml

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,41 @@ services:
1111
command: >
1212
redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
1313
14+
elasticsearch:
15+
build:
16+
context: .
17+
dockerfile: elasticsearch.Dockerfile
18+
container_name: my-elasticsearch
19+
environment:
20+
- discovery.type=single-node
21+
- xpack.security.enabled=false # 보안 설정 (운영 모드에서는 true로 변경)
22+
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 운영 모드에서는 메모리 사용량 조정
23+
ports:
24+
- "9200:9200"
25+
- "9300:9300"
26+
volumes:
27+
- es_data:/usr/share/elasticsearch/data
28+
restart: unless-stopped
29+
networks:
30+
- elastic
31+
32+
kibana:
33+
image: docker.elastic.co/kibana/kibana:9.1.3
34+
container_name: my-kibana
35+
ports:
36+
- "5601:5601"
37+
restart: unless-stopped
38+
environment:
39+
- ELASTICSEARCH_URL=http://elasticsearch:9200
40+
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
41+
depends_on:
42+
- elasticsearch
43+
networks:
44+
- elastic
45+
1446
volumes:
15-
redis-data:
47+
redis-data:
48+
es_data:
49+
50+
networks:
51+
elastic:

elasticsearch.Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
FROM docker.elastic.co/elasticsearch/elasticsearch:9.1.3
2+
3+
# Nori 플러그인 설치
4+
RUN bin/elasticsearch-plugin install --batch analysis-nori

k6-tests/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ services:
99
- ./results:/results
1010
extra_hosts:
1111
- "host.docker.internal:host-gateway"
12-
command: run getProducts-test.js
12+
command: run getProductsByElasticsearch-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/es?${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/getProductsByElasticsearch-test-${timestamp}.json`]: JSON.stringify(data, null, 2),
102+
'results/getProductsByElasticsearch-test-latest.json': JSON.stringify(data, null, 2), // 항상 최신 결과
103+
};
104+
}

src/main/java/com/backend/domain/bid/service/BidService.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
import com.backend.domain.bid.dto.*;
44
import com.backend.domain.bid.entity.Bid;
55
import com.backend.domain.bid.repository.BidRepository;
6-
import com.backend.domain.cash.constant.RelatedType;
7-
import com.backend.domain.cash.entity.CashTransaction;
86
import com.backend.domain.cash.service.CashService;
97
import com.backend.domain.member.entity.Member;
8+
import com.backend.domain.notification.service.BidNotificationService;
109
import com.backend.domain.product.entity.Product;
1110
import com.backend.domain.product.enums.AuctionStatus;
11+
import com.backend.domain.product.event.helper.ProductChangeTracker;
1212
import com.backend.global.exception.ServiceException;
13-
import com.backend.domain.notification.service.BidNotificationService;
1413
import com.backend.global.response.RsData;
1514
import com.backend.global.websocket.service.WebSocketService;
1615
import jakarta.persistence.EntityManager;
1716
import lombok.RequiredArgsConstructor;
17+
import org.springframework.context.ApplicationEventPublisher;
1818
import org.springframework.data.domain.Page;
1919
import org.springframework.data.domain.PageRequest;
2020
import org.springframework.data.domain.Pageable;
@@ -38,6 +38,7 @@ public class BidService {
3838
private final WebSocketService webSocketService;
3939
private final BidNotificationService bidNotificationService;
4040
private final CashService cashService;
41+
private final ApplicationEventPublisher eventPublisher;
4142

4243
// 상품별 락
4344
private final Map<Long, Object> productLocks = new ConcurrentHashMap<>();
@@ -67,9 +68,8 @@ private RsData<BidResponseDto> createBidInternal(Long productId, Long bidderId,
6768
// paidAt / paidAmount 는 결제 전이므로 비워둠(null)
6869
.build();
6970
Bid savedBid = bidRepository.save(bid);
70-
product.addBid(savedBid);
71-
// 4. 입찰가 업데이트
72-
product.setCurrentPrice(request.price());
71+
// 4. 상품 업데이트 (입찰 추가, 현재가 업데이트)
72+
updateProduct(product, savedBid, request.price());
7373
// 5. 응답 데이터 생성
7474
BidResponseDto bidResponse = new BidResponseDto(
7575
savedBid.getId(),
@@ -237,6 +237,15 @@ private void validateBid(Product product,Member member, Long bidPrice){
237237
}
238238
}
239239

240+
private void updateProduct(Product product, Bid savedBid, Long newPrice) {
241+
ProductChangeTracker tracker = ProductChangeTracker.of(product);
242+
243+
product.addBid(savedBid);
244+
product.setCurrentPrice(newPrice);
245+
246+
tracker.publishChanges(eventPublisher, product);
247+
}
248+
240249
public RsData<BidPayResponseDto> payForBid(Long memberId, Long bidId) {
241250
// 1) 입찰 조회
242251
Bid bid = bidRepository.findById(bidId)

src/main/java/com/backend/domain/product/controller/ApiV1ProductController.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.backend.domain.member.entity.Member;
44
import com.backend.domain.member.service.MemberService;
5+
import com.backend.domain.product.document.ProductDocument;
56
import com.backend.domain.product.dto.ProductSearchDto;
67
import com.backend.domain.product.dto.request.ProductCreateRequest;
78
import com.backend.domain.product.dto.request.ProductModifyRequest;
@@ -15,6 +16,7 @@
1516
import com.backend.domain.product.enums.SaleStatus;
1617
import com.backend.domain.product.exception.ProductException;
1718
import com.backend.domain.product.mapper.ProductMapper;
19+
import com.backend.domain.product.service.ProductSearchService;
1820
import com.backend.domain.product.service.ProductService;
1921
import com.backend.global.page.dto.PageDto;
2022
import com.backend.global.response.RsData;
@@ -36,6 +38,7 @@ public class ApiV1ProductController implements ApiV1ProductControllerDocs {
3638
private final ProductService productService;
3739
private final MemberService memberService;
3840
private final ProductMapper productMapper;
41+
private final ProductSearchService productSearchService;
3942

4043
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
4144
@Transactional
@@ -70,6 +73,25 @@ public RsData<PageDto<ProductListItemDto>> getProducts(
7073
return RsData.ok("상품 목록이 조회되었습니다", response);
7174
}
7275

76+
@GetMapping("/es")
77+
@Transactional(readOnly = true)
78+
public RsData<PageDto<ProductListItemDto>> getProductsByElasticsearch(
79+
@RequestParam(defaultValue = "1") int page,
80+
@RequestParam(defaultValue = "20") int size,
81+
@RequestParam(required = false) String keyword,
82+
@RequestParam(required = false) Integer[] category,
83+
@RequestParam(required = false) String[] location,
84+
@RequestParam(required = false) Boolean isDelivery,
85+
@RequestParam(defaultValue = "BIDDING") AuctionStatus status,
86+
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort
87+
) {
88+
ProductSearchDto search = new ProductSearchDto(keyword, category, location, isDelivery, status);
89+
Page<ProductDocument> products = productSearchService.searchProducts(page, size, sort, search);
90+
91+
PageDto<ProductListItemDto> response = productMapper.toListResponseFromDocument(products);
92+
return RsData.ok("상품 목록이 조회되었습니다", response);
93+
}
94+
7395
@GetMapping("/{productId}")
7496
@Transactional(readOnly = true)
7597
public RsData<ProductResponse> getProduct(@PathVariable Long productId) {
@@ -131,6 +153,22 @@ public RsData<PageDto<MyProductListItemDto>> getMyProducts(
131153
return RsData.ok("내 상품 목록이 조회되었습니다", response);
132154
}
133155

156+
@GetMapping("/es/me")
157+
@Transactional(readOnly = true)
158+
public RsData<PageDto<MyProductListItemDto>> getMyProductsByElasticsearch(
159+
@RequestParam(defaultValue = "1") int page,
160+
@RequestParam(defaultValue = "20") int size,
161+
@RequestParam(defaultValue = "SELLING") SaleStatus status,
162+
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort,
163+
@AuthenticationPrincipal User user
164+
) {
165+
Member actor = memberService.findMemberByEmail(user.getUsername());
166+
Page<ProductDocument> products = productSearchService.searchProductsByMember(page, size, sort, actor, status);
167+
168+
PageDto<MyProductListItemDto> response = productMapper.toMyListResponseFromDocument(products);
169+
return RsData.ok("내 상품 목록이 조회되었습니다", response);
170+
}
171+
134172
@GetMapping("/members/{memberId}")
135173
@Transactional(readOnly = true)
136174
public RsData<PageDto<ProductListByMemberItemDto>> getProductsByMember(
@@ -147,4 +185,21 @@ public RsData<PageDto<ProductListByMemberItemDto>> getProductsByMember(
147185
PageDto<ProductListByMemberItemDto> response = productMapper.toListByMemberResponse(products);
148186
return RsData.ok("%d번 회원 상품 목록이 조회되었습니다".formatted(memberId), response);
149187
}
188+
189+
@GetMapping("/es/members/{memberId}")
190+
@Transactional(readOnly = true)
191+
public RsData<PageDto<ProductListByMemberItemDto>> getProductsByMemberAndElasticsearch(
192+
@PathVariable Long memberId,
193+
@RequestParam(defaultValue = "1") int page,
194+
@RequestParam(defaultValue = "20") int size,
195+
@RequestParam(defaultValue = "SELLING") SaleStatus status,
196+
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort
197+
) {
198+
Member actor = memberService.findById(memberId).orElseThrow(() -> new ServiceException("404", "존재하지 않는 회원입니다"));
199+
200+
Page<ProductDocument> products = productSearchService.searchProductsByMember(page, size, sort, actor, status);
201+
202+
PageDto<ProductListByMemberItemDto> response = productMapper.toListByMemberResponseFromDocument(products);
203+
return RsData.ok("%d번 회원 상품 목록이 조회되었습니다".formatted(memberId), response);
204+
}
150205
}

0 commit comments

Comments
 (0)