Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
dependencies {
// add-ons
implementation(project(":core:infra:database:mysql:mysql-config"))
implementation(project(":modules:redis"))
implementation(project(":core:infra:database:redis:redis-config"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))
Expand All @@ -16,11 +16,13 @@ dependencies {

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework:spring-tx")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")

// test-fixtures
testImplementation(project(":core:infra:database:mysql:mysql-config"))
testImplementation(testFixtures(project(":core:infra:database:mysql:mysql-core")))
testImplementation(testFixtures(project(":core:infra:database:redis:redis-config")))
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

import java.util.TimeZone;

@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceApiApplication {

public static void main(String[] args) {
SpringApplication.run(CommerceApiApplication.class, args);
}

@PostConstruct
public void started() {
// set timezone
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}

public static void main(String[] args) {
SpringApplication.run(CommerceApiApplication.class, args);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import com.loopers.application.api.common.dto.ApiResponse;
import com.loopers.core.domain.product.Product;
import com.loopers.core.domain.product.ProductDetail;
import com.loopers.core.domain.product.ProductListView;
import com.loopers.core.service.product.ProductQueryService;
import com.loopers.core.service.product.query.GetProductDetailQuery;
import com.loopers.core.service.product.query.GetProductListQuery;
import com.loopers.core.service.product.query.GetProductQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import static com.loopers.application.api.product.ProductV1Dto.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/products")
Expand All @@ -18,24 +22,34 @@ public class ProductV1Api implements ProductV1ApiSpec {

@Override
@GetMapping("/{productId}")
public ApiResponse<ProductV1Dto.GetProductResponse> getProduct(@PathVariable String productId) {
public ApiResponse<GetProductResponse> getProduct(@PathVariable String productId) {
Product product = queryService.getProductBy(new GetProductQuery(productId));
return ApiResponse.success(ProductV1Dto.GetProductResponse.from(product));
return ApiResponse.success(GetProductResponse.from(product));
}

@Override
@GetMapping
public ApiResponse<ProductV1Dto.GetProductListResponse> getProductList(
@RequestParam String brandId,
@RequestParam String createdAtSort,
@RequestParam String priceSort,
@RequestParam String likeCountSort,
@RequestParam int pageNo,
@RequestParam int pageSize
public ApiResponse<GetProductListResponse> getProductList(
@RequestParam(required = false) String brandId,
@RequestParam(required = false) String createdAtSort,
@RequestParam(required = false) String priceSort,
@RequestParam(required = false) String likeCountSort,
@RequestParam(required = false, defaultValue = "0") int pageNo,
@RequestParam(required = false, defaultValue = "10") int pageSize
) {
ProductListView productList = queryService.getProductList(new GetProductListQuery(
brandId, createdAtSort, priceSort, likeCountSort, pageNo, pageSize
));
return ApiResponse.success(ProductV1Dto.GetProductListResponse.from(productList));
return ApiResponse.success(GetProductListResponse.from(productList));
}

@Override
@GetMapping("/{productId}/detail")
public ApiResponse<GetProductDetailResponse> getProductDetail(
@PathVariable String productId
) {
ProductDetail productDetail = queryService.getProductDetail(new GetProductDetailQuery(productId));

return ApiResponse.success(GetProductDetailResponse.from(productDetail));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ ApiResponse<ProductV1Dto.GetProductListResponse> getProductList(
int pageSize
);

@Operation(
summary = "상품 상세 조회",
description = "상품 상세 정보를 조회합니다."
)
ApiResponse<ProductV1Dto.GetProductDetailResponse> getProductDetail(String productId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.loopers.application.api.product;

import com.loopers.core.domain.brand.Brand;
import com.loopers.core.domain.product.Product;
import com.loopers.core.domain.product.ProductDetail;
import com.loopers.core.domain.product.ProductListItem;
import com.loopers.core.domain.product.ProductListView;

Expand Down Expand Up @@ -68,4 +70,40 @@ public static GetProductResponse from(Product product) {
);
}
}

public record GetProductDetailResponse(
String id,
GetProductDetailBrand brand,
String name,
BigDecimal price,
Long stock,
Long likeCount
) {

public static GetProductDetailResponse from(ProductDetail detail) {
return new GetProductDetailResponse(
detail.getProduct().getId().value(),
GetProductDetailBrand.from(detail.getBrand()),
detail.getProduct().getName().value(),
detail.getProduct().getPrice().value(),
detail.getProduct().getStock().value(),
detail.getProduct().getLikeCount().value()
);
}

public record GetProductDetailBrand(
String id,
String name,
String description
) {

public static GetProductDetailBrand from(Brand brand) {
return new GetProductDetailBrand(
brand.getId().value(),
brand.getName().value(),
brand.getDescription().value()
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.loopers.application.api.productlike;

import com.loopers.core.service.productlike.ProductLikeSyncService;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class ProductLikeSyncScheduler {

private final ProductLikeSyncService productLikeSyncService;

@Scheduled(fixedDelay = 1000)
public void sync() {
productLikeSyncService.sync();
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
### 상품 상세 조회
GET {{commerce-api}}/api/v1/products/1
GET {{commerce-api}}/api/v1/products/13/detail
Content-Type: application/json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ GET {{commerce-api}}/api/v1/products?pageNo=0&pageSize=20
Content-Type: application/json

### 상품 목록 조회 (조합된 필터)
GET {{commerce-api}}/api/v1/products?brandId=1&priceSort=ASC&pageNo=0&pageSize=20
GET {{commerce-api}}/api/v1/products?createdAtSort=DESC&pageNo=0&pageSize=20
Content-Type: application/json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### 상품 조회
GET {{commerce-api}}/api/v1/products/1
Content-Type: application/json
27 changes: 27 additions & 0 deletions apps/commerce-api/src/main/resources/k6/product-detail-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import http from 'k6/http';
import {check, group} from 'k6';

export const options = {
stages: [
{duration: '10s', target: 1000}, // 20명까지 10초에 증가
{duration: '30s', target: 1000}, // 20명 유지 30초
{duration: '10s', target: 0}, // 10초에 감소
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.1'],
},
};

const BASE_URL = 'http://localhost:8080';

export default function () {
group('기본 상품 상세 조회', () => {
const randomId = Math.floor(Math.random() * 1001);
const response = http.get(`${BASE_URL}/api/v1/products/${randomId}/detail`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
95 changes: 95 additions & 0 deletions apps/commerce-api/src/main/resources/k6/product-list-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import http from 'k6/http';
import {check, group, sleep} from 'k6';

export const options = {
stages: [
{duration: '10s', target: 20}, // 20명까지 10초에 증가
{duration: '30s', target: 20}, // 20명 유지 30초
{duration: '10s', target: 0}, // 10초에 감소
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.1'],
},
};

const BASE_URL = 'http://localhost:8080';

export default function () {
// 1. 기본 상품 목록 조회
group('기본 상품 목록 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2); // 0.2초 (더 빠른 테스트)

// 2. 브랜드별 필터 조회
group('브랜드별 필터 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?brandId=1`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);

// 3. 생성일자 정렬 조회
group('생성일자 정렬 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?createdAtSort=DESC`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);

// 4. 가격 정렬 조회
group('가격 정렬 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?priceSort=ASC`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);

// 5. 좋아요 수 정렬 조회
group('좋아요 수 정렬 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?likeCountSort=DESC`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);

// 6. 페이지네이션 조회
group('페이지네이션 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?pageNo=0&pageSize=20`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);

// 7. 조합된 필터 조회
group('조합된 필터 조회', () => {
const response = http.get(`${BASE_URL}/api/v1/products?brandId=10&priceSort=ASC&pageNo=0&pageSize=20`);
check(response, {
'status는 200': (r) => r.status === 200,
'응답 시간 < 500ms': (r) => r.timings.duration < 500,
});
});

sleep(0.2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,6 @@ void status200() {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();

// 좋아요 수 확인
Product likedProduct = productRepository.getById(new ProductId(productId));
assertThat(likedProduct.getLikeCount().value()).isEqualTo(1);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
Expand Down
2 changes: 0 additions & 2 deletions apps/commerce-streamer/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
dependencies {
// add-ons
implementation(project(":modules:redis"))
implementation(project(":modules:kafka"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
Expand All @@ -16,6 +15,5 @@ dependencies {
annotationProcessor("jakarta.annotation:jakarta.annotation-api")

// test-fixtures
testImplementation(testFixtures(project(":modules:redis")))
testImplementation(testFixtures(project(":modules:kafka")))
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ public Product decreaseLikeCount() {
.build();
}

public Product withLikeCount(ProductLikeCount newLikeCount) {
return this.toBuilder()
.likeCount(newLikeCount)
.updatedAt(UpdatedAt.now())
.build();
}

public BigDecimal getTotalPrice(Quantity quantity) {
return this.price.multiply(quantity);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.loopers.core.domain.product.repository;

import com.loopers.core.domain.product.ProductDetail;
import com.loopers.core.domain.product.vo.ProductId;

import java.util.Optional;

public interface ProductCacheRepository {

Optional<ProductDetail> findDetailBy(ProductId productId);

void save(ProductDetail productDetail);
}
Loading