Skip to content

Commit 197dda2

Browse files
minor7295LenKIM
andauthored
[volume-4] API 구현 보강 및 트랜잭션을 활용한 동시성 문제 해결 (#99)
* Add GitHub Actions workflow for PR Agent * Feature/misc api (#15) * refactor: product 도메인의 likeCount 집계 방식을 배치 방식으로 변경 * refactor: CatalogProductFacade에서 발생하는 n+1 쿼리 수정 * refactor: SignUpFacade의 NPE 가능성 제거 * refactor: Brand 도메인 name 필드의 입력 검증 로직 추가 * refactor: Order도메인 내의 OrderItem을 불변 객체로 설정 * feat: 브랜드 정보 조회 API 추가 * feat: 상품 조회 API 추가 * feat: 좋아요 수 집계 로직 추가 * feat: 좋아요 API 추가 * feat: 구매 API 추가 * Feature/concurrency like (#16) * test: 좋아요 동시성 테스트 로직 추가 * feat: like에 unique constraint 적용해서 동시성 이슈 발생하지 않도록 함 * Feature/coupon (#17) * test: 쿠폰 관련 테스트 코드 추가 * feat: coupon 구현 * Feature/concurrency purchasing (#18) * test: 주문 동시성 테스트 로직 추가 * test: 주문 흐름의 원자성을 검증하는 테스트 코드 추가 * feat: 비관적 락 적용하여 주문 동시성 이슈 발생하지 않도록 함 * refactor: deadlock 문제 수정 --------- Co-authored-by: simplify-len <joenggyu0@gmail.com>
1 parent 89723cb commit 197dda2

File tree

54 files changed

+3471
-81
lines changed

Some content is hidden

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

54 files changed

+3471
-81
lines changed

.github/workflows/main.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: PR Agent
2+
on:
3+
pull_request:
4+
types: [opened, synchronize]
5+
jobs:
6+
pr_agent_job:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- name: PR Agent action step
10+
uses: Codium-ai/pr-agent@main
11+
env:
12+
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
13+
GITHUB_TOKEN: ${{ secrets.G_TOKEN }}

apps/commerce-api/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ dependencies {
1111
implementation("org.springframework.boot:spring-boot-starter-actuator")
1212
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
1313

14+
// batch
15+
implementation("org.springframework.boot:spring-boot-starter-batch")
16+
1417
// querydsl
1518
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
1619
annotationProcessor("jakarta.persistence:jakarta.persistence-api")

apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import org.springframework.boot.SpringApplication;
55
import org.springframework.boot.autoconfigure.SpringBootApplication;
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
7+
import org.springframework.scheduling.annotation.EnableScheduling;
78
import java.util.TimeZone;
89

910
@ConfigurationPropertiesScan
1011
@SpringBootApplication
12+
@EnableScheduling
1113
public class CommerceApiApplication {
1214

1315
@PostConstruct
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.loopers.application.catalog;
2+
3+
import com.loopers.domain.brand.Brand;
4+
import com.loopers.domain.brand.BrandRepository;
5+
import com.loopers.support.error.CoreException;
6+
import com.loopers.support.error.ErrorType;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.stereotype.Component;
9+
10+
/**
11+
* 브랜드 조회 파사드.
12+
* <p>
13+
* 브랜드 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다.
14+
* </p>
15+
*
16+
* @author Loopers
17+
* @version 1.0
18+
*/
19+
@RequiredArgsConstructor
20+
@Component
21+
public class CatalogBrandFacade {
22+
private final BrandRepository brandRepository;
23+
24+
/**
25+
* 브랜드 정보를 조회합니다.
26+
*
27+
* @param brandId 브랜드 ID
28+
* @return 브랜드 정보
29+
* @throws CoreException 브랜드를 찾을 수 없는 경우
30+
*/
31+
public BrandInfo getBrand(Long brandId) {
32+
Brand brand = brandRepository.findById(brandId)
33+
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
34+
return BrandInfo.from(brand);
35+
}
36+
37+
/**
38+
* 브랜드 정보를 담는 레코드.
39+
*
40+
* @param id 브랜드 ID
41+
* @param name 브랜드 이름
42+
*/
43+
public record BrandInfo(Long id, String name) {
44+
/**
45+
* Brand 엔티티로부터 BrandInfo를 생성합니다.
46+
*
47+
* @param brand 브랜드 엔티티
48+
* @return 생성된 BrandInfo
49+
*/
50+
public static BrandInfo from(Brand brand) {
51+
return new BrandInfo(brand.getId(), brand.getName());
52+
}
53+
}
54+
}
55+

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,14 @@
22

33
import com.loopers.domain.brand.Brand;
44
import com.loopers.domain.brand.BrandRepository;
5-
import com.loopers.domain.like.LikeRepository;
65
import com.loopers.domain.product.Product;
76
import com.loopers.domain.product.ProductDetail;
8-
import com.loopers.domain.product.ProductDetailService;
97
import com.loopers.domain.product.ProductRepository;
108
import com.loopers.support.error.CoreException;
119
import com.loopers.support.error.ErrorType;
1210
import lombok.RequiredArgsConstructor;
1311
import org.springframework.stereotype.Component;
1412

15-
import java.util.Comparator;
1613
import java.util.List;
1714
import java.util.Map;
1815
import java.util.stream.Collectors;
@@ -31,11 +28,12 @@
3128
public class CatalogProductFacade {
3229
private final ProductRepository productRepository;
3330
private final BrandRepository brandRepository;
34-
private final LikeRepository likeRepository;
35-
private final ProductDetailService productDetailService;
3631

3732
/**
3833
* 상품 목록을 조회합니다.
34+
* <p>
35+
* 배치 조회를 통해 N+1 쿼리 문제를 해결합니다.
36+
* </p>
3937
*
4038
* @param brandId 브랜드 ID (선택)
4139
* @param sort 정렬 기준 (latest, price_asc, likes_desc)
@@ -46,9 +44,36 @@ public class CatalogProductFacade {
4644
public ProductInfoList getProducts(Long brandId, String sort, int page, int size) {
4745
long totalCount = productRepository.countAll(brandId);
4846
List<Product> products = productRepository.findAll(brandId, sort, page, size);
47+
48+
if (products.isEmpty()) {
49+
return new ProductInfoList(List.of(), totalCount, page, size);
50+
}
51+
52+
// ✅ 배치 조회로 N+1 쿼리 문제 해결
53+
// 브랜드 ID 수집
54+
List<Long> brandIds = products.stream()
55+
.map(Product::getBrandId)
56+
.distinct()
57+
.toList();
58+
59+
// 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해)
60+
Map<Long, Brand> brandMap = brandRepository.findAllById(brandIds).stream()
61+
.collect(Collectors.toMap(Brand::getId, brand -> brand));
62+
63+
// 상품 정보 변환 (이미 조회한 Product 재사용)
4964
List<ProductInfo> productsInfo = products.stream()
50-
.map(product -> getProduct(product.getId()))
65+
.map(product -> {
66+
Brand brand = brandMap.get(product.getBrandId());
67+
if (brand == null) {
68+
throw new CoreException(ErrorType.NOT_FOUND,
69+
String.format("브랜드를 찾을 수 없습니다. (브랜드 ID: %d)", product.getBrandId()));
70+
}
71+
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
72+
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount());
73+
return new ProductInfo(productDetail);
74+
})
5175
.toList();
76+
5277
return new ProductInfoList(productsInfo, totalCount, page, size);
5378
}
5479

@@ -67,12 +92,11 @@ public ProductInfo getProduct(Long productId) {
6792
Brand brand = brandRepository.findById(product.getBrandId())
6893
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
6994

70-
// 좋아요 수 조회
71-
Map<Long, Long> likesCountMap = likeRepository.countByProductIds(List.of(productId));
72-
Long likesCount = likesCountMap.getOrDefault(productId, 0L);
95+
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
96+
Long likesCount = product.getLikeCount();
7397

74-
// 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력)
75-
ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount);
98+
// ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달)
99+
ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount);
76100

77101
return new ProductInfo(productDetail);
78102
}

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
import com.loopers.domain.user.UserRepository;
99
import com.loopers.support.error.CoreException;
1010
import com.loopers.support.error.ErrorType;
11-
import jakarta.transaction.Transactional;
11+
import org.springframework.transaction.annotation.Transactional;
1212
import lombok.RequiredArgsConstructor;
1313
import org.springframework.stereotype.Component;
1414

1515
import java.util.List;
1616
import java.util.Map;
1717
import java.util.Optional;
18+
import java.util.stream.Collectors;
1819

1920
/**
2021
* 좋아요 관리 파사드.
@@ -37,23 +38,52 @@ public class LikeFacade {
3738
* <p>
3839
* 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다.
3940
* </p>
41+
* <p>
42+
* <b>동시성 제어 전략:</b>
43+
* <ul>
44+
* <li><b>UNIQUE 제약조건 사용:</b> 데이터베이스 레벨에서 중복 삽입을 물리적으로 방지</li>
45+
* <li><b>애플리케이션 레벨 한계:</b> 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없음</li>
46+
* <li><b>예외 처리:</b> UNIQUE 제약조건 위반 시 DataIntegrityViolationException 처리하여 멱등성 보장</li>
47+
* </ul>
48+
* </p>
49+
* <p>
50+
* <b>DBA 설득 근거 (유니크 인덱스 사용):</b>
51+
* <ul>
52+
* <li><b>트래픽 패턴:</b> 좋아요는 고 QPS write-heavy 테이블이 아니며, 전체 서비스에서 차지하는 비중이 낮음</li>
53+
* <li><b>애플리케이션 레벨 한계:</b> 동일 시점 동시 요청 시 select 시점엔 중복 없음 → insert 2번 발생 가능</li>
54+
* <li><b>데이터 무결성:</b> DB만이 강한 무결성(Strong Consistency)을 제공할 수 있음</li>
55+
* <li><b>비즈니스 데이터 보호:</b> 중복 좋아요로 인한 비즈니스 데이터 오염 방지</li>
56+
* </ul>
57+
* </p>
4058
*
4159
* @param userId 사용자 ID (String)
4260
* @param productId 상품 ID
4361
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
4462
*/
45-
@Transactional
4663
public void addLike(String userId, Long productId) {
4764
User user = loadUser(userId);
4865
loadProduct(productId);
4966

67+
// 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리)
68+
// ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음
69+
// 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능
5070
Optional<Like> existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
5171
if (existingLike.isPresent()) {
5272
return;
5373
}
5474

75+
// 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능)
76+
// ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지
77+
// @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음
5578
Like like = Like.of(user.getId(), productId);
56-
likeRepository.save(like);
79+
try {
80+
likeRepository.save(like);
81+
} catch (org.springframework.dao.DataIntegrityViolationException e) {
82+
// UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장)
83+
// 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때,
84+
// 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생
85+
// 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주
86+
}
5787
}
5888

5989
/**
@@ -66,7 +96,6 @@ public void addLike(String userId, Long productId) {
6696
* @param productId 상품 ID
6797
* @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
6898
*/
69-
@Transactional
7099
public void removeLike(String userId, Long productId) {
71100
User user = loadUser(userId);
72101
loadProduct(productId);
@@ -76,16 +105,33 @@ public void removeLike(String userId, Long productId) {
76105
return;
77106
}
78107

79-
likeRepository.delete(like.get());
108+
try {
109+
likeRepository.delete(like.get());
110+
} catch (Exception e) {
111+
// 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능
112+
// 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주
113+
}
80114
}
81115

82116
/**
83117
* 사용자가 좋아요한 상품 목록을 조회합니다.
118+
* <p>
119+
* 상품 정보 조회를 병렬로 처리하여 성능을 최적화합니다.
120+
* </p>
121+
* <p>
122+
* <b>좋아요 수 조회 전략:</b>
123+
* <ul>
124+
* <li><b>비동기 집계:</b> Product.likeCount 필드 사용 (스케줄러로 주기적 동기화)</li>
125+
* <li><b>Eventually Consistent:</b> 약간의 지연 허용 (최대 5초)</li>
126+
* <li><b>성능 최적화:</b> COUNT(*) 쿼리 없이 컬럼만 읽으면 됨</li>
127+
* </ul>
128+
* </p>
84129
*
85130
* @param userId 사용자 ID (String)
86131
* @return 좋아요한 상품 목록
87132
* @throws CoreException 사용자를 찾을 수 없는 경우
88133
*/
134+
@Transactional(readOnly = true)
89135
public List<LikedProduct> getLikedProducts(String userId) {
90136
User user = loadUser(userId);
91137

@@ -101,26 +147,26 @@ public List<LikedProduct> getLikedProducts(String userId) {
101147
.map(Like::getProductId)
102148
.toList();
103149

104-
// 상품 정보 조회
105-
List<Product> products = productIds.stream()
106-
.map(productId -> productRepository.findById(productId)
107-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
108-
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))))
109-
.toList();
150+
// ✅ 배치 조회로 N+1 쿼리 문제 해결
151+
Map<Long, Product> productMap = productRepository.findAllById(productIds).stream()
152+
.collect(Collectors.toMap(Product::getId, product -> product));
110153

111-
// 좋아요 수 집계
112-
Map<Long, Long> likesCountMap = likeRepository.countByProductIds(productIds);
154+
// 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인
155+
if (productMap.size() != productIds.size()) {
156+
throw new CoreException(ErrorType.NOT_FOUND, "일부 상품을 찾을 수 없습니다.");
157+
}
113158

114159
// 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환
160+
// ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
115161
return likes.stream()
116162
.map(like -> {
117-
Product product = products.stream()
118-
.filter(p -> p.getId().equals(like.getProductId()))
119-
.findFirst()
120-
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
121-
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())));
122-
Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L);
123-
return LikedProduct.from(product, like, likesCount);
163+
Product product = productMap.get(like.getProductId());
164+
if (product == null) {
165+
throw new CoreException(ErrorType.NOT_FOUND,
166+
String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId()));
167+
}
168+
// Product 엔티티의 likeCount 필드를 내부에서 사용
169+
return LikedProduct.from(product);
124170
})
125171
.toList();
126172
}
@@ -158,21 +204,26 @@ public record LikedProduct(
158204
Long likesCount
159205
) {
160206
/**
161-
* Product와 Like로부터 LikedProduct를 생성합니다.
207+
* Product로부터 LikedProduct를 생성합니다.
208+
* <p>
209+
* Product.likeCount 필드를 사용하여 좋아요 수를 가져옵니다.
210+
* </p>
162211
*
163212
* @param product 상품 엔티티
164-
* @param like 좋아요 엔티티
165-
* @param likesCount 좋아요 수
166213
* @return 생성된 LikedProduct
214+
* @throws IllegalArgumentException product가 null인 경우
167215
*/
168-
public static LikedProduct from(Product product, Like like, Long likesCount) {
216+
public static LikedProduct from(Product product) {
217+
if (product == null) {
218+
throw new IllegalArgumentException("상품은 null일 수 없습니다.");
219+
}
169220
return new LikedProduct(
170221
product.getId(),
171222
product.getName(),
172223
product.getPrice(),
173224
product.getStock(),
174225
product.getBrandId(),
175-
likesCount
226+
product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값)
176227
);
177228
}
178229
}

apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
*
99
* @param productId 상품 ID
1010
* @param quantity 수량
11+
* @param couponCode 쿠폰 코드 (선택)
1112
*/
12-
public record OrderItemCommand(Long productId, Integer quantity) {
13+
public record OrderItemCommand(Long productId, Integer quantity, String couponCode) {
1314
public OrderItemCommand {
1415
if (productId == null) {
1516
throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다.");
@@ -18,5 +19,16 @@ public record OrderItemCommand(Long productId, Integer quantity) {
1819
throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다.");
1920
}
2021
}
22+
23+
/**
24+
* 쿠폰 코드 없이 OrderItemCommand를 생성합니다.
25+
*
26+
* @param productId 상품 ID
27+
* @param quantity 수량
28+
* @return 생성된 OrderItemCommand
29+
*/
30+
public static OrderItemCommand of(Long productId, Integer quantity) {
31+
return new OrderItemCommand(productId, quantity, null);
32+
}
2133
}
2234

0 commit comments

Comments
 (0)