Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
50cb2ae
Merge pull request #5 from sylee6529/round7
sylee6529 Dec 19, 2025
4e82fc2
Merge branch 'main' of https://github.com/sylee6529/loopers-spring-jaโ€ฆ
sylee6529 Dec 19, 2025
7bbc64b
feat: Kafka ์„ค์ • ๋ฐ ์˜์กด์„ฑ ์ถ”๊ฐ€
sylee6529 Dec 19, 2025
cf0f11a
feat: ์ด๋ฒคํŠธ ์ •์˜ ๋ฐ Kafka ์ธํ”„๋ผ ๊ตฌ์กฐ ์ถ”๊ฐ€
sylee6529 Dec 19, 2025
8951175
feat: Transactional Outbox ํŒจํ„ด ๊ตฌํ˜„
sylee6529 Dec 19, 2025
f47104f
feat: ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ๋กœ์ง ์ ์šฉ
sylee6529 Dec 19, 2025
35e230c
feat: ์บ์‹œ ๋ฌดํšจํ™” ์„œ๋น„์Šค ๋ฐ Repository ๊ฐœ์„ 
sylee6529 Dec 19, 2025
cb43b85
feat: Consumer ๋„๋ฉ”์ธ ๋ฐ ์ธํ”„๋ผ ๊ณ„์ธต ๊ตฌํ˜„
sylee6529 Dec 19, 2025
819d86a
feat: Consumer ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ณ„์ธต ๊ตฌํ˜„
sylee6529 Dec 19, 2025
092eb59
feat: Kafka Consumer ๋ฐ ์„ค์ • ๊ตฌํ˜„
sylee6529 Dec 19, 2025
3174797
test: Kafka ์ด๋ฒคํŠธ ํŒŒ์ดํ”„๋ผ์ธ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
sylee6529 Dec 19, 2025
e40c4cd
refactor: ProductMetrics ๋‚™๊ด€์  ๋ฝ ๋ฐ ํƒ€์ž„์Šคํƒฌํ”„ ๊ฒ€์ฆ ์ œ๊ฑฐ
sylee6529 Dec 19, 2025
b5920db
refactor: RetryTracker ์ œ๊ฑฐ ๋ฐ Kafka ๊ธฐ๋ณธ ์žฌ์‹œ๋„ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ํ™œ์šฉ
sylee6529 Dec 19, 2025
b191947
fix: REQUIRES_NEW ํŠธ๋žœ์žญ์…˜ ์ถ”๊ฐ€
sylee6529 Dec 19, 2025
5193457
refactor: Like ์ค‘๋ณต ์ฒดํฌ ๋กœ์ง ์ œ๊ฑฐ
sylee6529 Dec 23, 2025
c67f50a
refactor: Repository๋ฅผ infrastructure ๋ ˆ์ด์–ด๋กœ ์ด๋™
sylee6529 Dec 23, 2025
d049e64
refactor: ์บ์‹œ ๋ฐ DLQ ํƒ€์ž… ๋ณ€ํ™˜ ๋กœ์ง ๊ฐœ์„ 
sylee6529 Dec 23, 2025
b67b119
chore: ํ…Œ์ŠคํŠธ ๋ฐ ์„ค์ • ์ฝ”๋“œ ์ •๋ฆฌ
sylee6529 Dec 23, 2025
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
1 change: 1 addition & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ dependencies {
// add-ons
implementation(project(":modules:jpa"))
implementation(project(":modules:redis"))
implementation(project(":modules:kafka"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public void handlePaymentCompleted(PaymentCompletedEvent event) {
order.getOrderNo(),
order.getMemberId(),
order.getTotalPrice(),
order.getItems().stream()
.map(item -> new OrderCompletedEvent.OrderItemInfo(
item.getProductId(),
item.getQuantity(),
item.getUnitPrice()
))
.toList(),
java.time.LocalDateTime.now()
));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@

import com.loopers.domain.common.vo.Money;
import java.time.LocalDateTime;
import java.util.List;

public record OrderCompletedEvent(
String orderNo,
Long memberId,
Money totalPrice,
List<OrderItemInfo> items,
LocalDateTime completedAt
) {
public record OrderItemInfo(
Long productId,
int quantity,
Money price
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.application.event.product;

import java.time.LocalDateTime;

public record ProductViewedEvent(
Long memberId, // nullable (๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋„ ์ถ”์ )
Long productId,
Long brandId,
LocalDateTime viewedAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import com.loopers.application.event.like.ProductLikedEvent;
import com.loopers.application.event.like.ProductUnlikedEvent;
import com.loopers.application.event.tracking.UserActionEvent;
import com.loopers.domain.like.repository.LikeRepository;
import com.loopers.domain.like.service.LikeService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
Expand All @@ -22,11 +22,17 @@
public class LikeFacade {

private final LikeService likeService;
private final ProductRepository productRepository;
private final LikeRepository likeRepository;
private final ApplicationEventPublisher eventPublisher;

public void likeProduct(Long memberId, Long productId) {
// 1. DB์— ์ข‹์•„์š” ์ €์žฅ (Redis ๋กœ์ง์€ LikeService์—์„œ ์ œ๊ฑฐ๋จ)
// ๋ฉฑ๋“ฑ์„ฑ: ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ๊ฒฝ์šฐ early return (์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜๋ฉด ์ƒํ’ˆ๋„ ์กด์žฌํ•จ)
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
log.debug("[LikeFacade] ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ - memberId: {}, productId: {}", memberId, productId);
return;
}

// 1. DB์— ์ข‹์•„์š” ์ €์žฅ
Product product = likeService.like(memberId, productId);

// 2. ์ด๋ฒคํŠธ ๋ฐœํ–‰ (Redis ์—…๋ฐ์ดํŠธ๋Š” ๋น„๋™๊ธฐ ๋ฆฌ์Šค๋„ˆ์—์„œ ์ฒ˜๋ฆฌ)
Expand All @@ -49,7 +55,13 @@ public void likeProduct(Long memberId, Long productId) {
}

public void unlikeProduct(Long memberId, Long productId) {
// 1. DB์—์„œ ์ข‹์•„์š” ์‚ญ์ œ (Redis ๋กœ์ง์€ LikeService์—์„œ ์ œ๊ฑฐ๋จ)
// ๋ฉฑ๋“ฑ์„ฑ: ์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ early return
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
log.debug("[LikeFacade] ์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ - memberId: {}, productId: {}", memberId, productId);
return;
}

// 1. DB์—์„œ ์ข‹์•„์š” ์‚ญ์ œ
Product product = likeService.unlike(memberId, productId);

// 2. ์ด๋ฒคํŠธ ๋ฐœํ–‰ (Redis ์—…๋ฐ์ดํŠธ๋Š” ๋น„๋™๊ธฐ ๋ฆฌ์Šค๋„ˆ์—์„œ ์ฒ˜๋ฆฌ)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
package com.loopers.application.product;

import com.loopers.application.event.product.ProductViewedEvent;
import com.loopers.domain.like.service.LikeReadService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.repository.ProductRepository;
import com.loopers.domain.product.service.ProductReadService;
import com.loopers.domain.product.command.ProductSearchFilter;
import com.loopers.domain.product.enums.ProductSortCondition;
import com.loopers.infrastructure.cache.ProductDetailCache;
import com.loopers.infrastructure.cache.ProductListCache;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@RequiredArgsConstructor
Expand All @@ -24,6 +29,8 @@ public class ProductFacade {
private final LikeReadService likeReadService;
private final ProductDetailCache productDetailCache;
private final ProductListCache productListCache;
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;

@Transactional(readOnly = true)
public Page<ProductSummaryInfo> getProducts(ProductSearchCommand command) {
Expand Down Expand Up @@ -93,16 +100,17 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
return result;
});

// 2. ๋กœ๊ทธ์ธํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋ฐ”๋กœ ๋ฐ˜ํ™˜
if (memberIdOrNull == null) {
return cachedInfo; // isLikedByMember=false ๊ทธ๋Œ€๋กœ
}
// 2. Product ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ (brandId ํš๋“์šฉ)
Product product = productRepository.findById(productId)
.orElseThrow(() -> new com.loopers.support.error.CoreException(
com.loopers.support.error.ErrorType.NOT_FOUND,
"์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));

// 3. isLikedByMember๋งŒ ๋™์  ๊ณ„์‚ฐ
boolean isLiked = likeReadService.isLikedBy(memberIdOrNull, productId);
// 3. isLikedByMember ๋™์  ๊ณ„์‚ฐ
boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId);

// 4. isLikedByMember ํ•„๋“œ๋งŒ ๊ต์ฒดํ•ด์„œ ๋ฐ˜ํ™˜
return ProductDetailInfo.builder()
ProductDetailInfo result = ProductDetailInfo.builder()
.id(cachedInfo.getId())
.name(cachedInfo.getName())
.description(cachedInfo.getDescription())
Expand All @@ -113,6 +121,16 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
.likeCount(cachedInfo.getLikeCount())
.isLikedByMember(isLiked) // โญ ๋™์  ๊ณ„์‚ฐ
.build();

// 5. ProductViewedEvent ๋ฐœํ–‰ (์กฐํšŒ์ˆ˜ ์ง‘๊ณ„)
eventPublisher.publishEvent(new ProductViewedEvent(
memberIdOrNull, // ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” null
productId,
product.getBrandId(),
LocalDateTime.now()
));

return result;
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

/**
Expand All @@ -35,7 +34,7 @@ public class AsyncConfig implements AsyncConfigurer {
*/
@Bean(name = "taskExecutor")
@Override
public Executor getAsyncExecutor() {
public ThreadPoolTaskExecutor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 5 โ†’ 10
executor.setMaxPoolSize(20); // 10 โ†’ 20
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,16 @@ public class LikeService {
* - DB์—๋Š” Like ๋ ˆ์ฝ”๋“œ๋งŒ ์ €์žฅ
* - ์ข‹์•„์š” ์นด์šดํŠธ๋Š” ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ์—์„œ Redis์— ์—…๋ฐ์ดํŠธ
* - ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ Redis โ†’ DB ๋™๊ธฐํ™”
* - ๋ฉฑ๋“ฑ์„ฑ์€ Facade์—์„œ ๋ณด์žฅ (์ค‘๋ณต ์ฒดํฌ๋Š” Facade ์ฑ…์ž„)
*
* @return ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ (์ด๋ฒคํŠธ ๋ฐœํ–‰์šฉ brandId ํฌํ•จ)
*/
public Product like(Long memberId, Long productId) {
// ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€ (๋ฉฑ๋“ฑ์„ฑ)
if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
log.debug("[LikeService] ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ - memberId: {}, productId: {}", memberId, productId);
return productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));
}

// 1. ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ (๋น„๊ด€์  ๋ฝ ์ œ๊ฑฐ)
// 1. ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));

// 2. DB์— Like ๋ ˆ์ฝ”๋“œ๋งŒ ์ €์žฅ (์นด์šดํŠธ๋Š” Redis์—์„œ ๊ด€๋ฆฌ)
// 2. DB์— Like ๋ ˆ์ฝ”๋“œ ์ €์žฅ (์นด์šดํŠธ๋Š” Redis์—์„œ ๊ด€๋ฆฌ)
likeRepository.save(new Like(memberId, productId));

log.info("[LikeService] ์ข‹์•„์š” ์ €์žฅ ์™„๋ฃŒ - memberId: {}, productId: {}", memberId, productId);
Expand All @@ -56,22 +50,16 @@ public Product like(Long memberId, Long productId) {
* - DB์—์„œ Like ๋ ˆ์ฝ”๋“œ๋งŒ ์‚ญ์ œ
* - ์ข‹์•„์š” ์นด์šดํŠธ๋Š” ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ์—์„œ Redis์— ์—…๋ฐ์ดํŠธ
* - ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ Redis โ†’ DB ๋™๊ธฐํ™”
* - ๋ฉฑ๋“ฑ์„ฑ์€ Facade์—์„œ ๋ณด์žฅ (์กด์žฌ ์—ฌ๋ถ€ ์ฒดํฌ๋Š” Facade ์ฑ…์ž„)
*
* @return ์ข‹์•„์š” ์ทจ์†Œํ•œ ์ƒํ’ˆ (์ด๋ฒคํŠธ ๋ฐœํ–‰์šฉ brandId ํฌํ•จ)
*/
public Product unlike(Long memberId, Long productId) {
// ์ข‹์•„์š” ์—†์œผ๋ฉด ์Šคํ‚ต (๋ฉฑ๋“ฑ์„ฑ)
if (!likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
log.debug("[LikeService] ์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ - memberId: {}, productId: {}", memberId, productId);
return productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));
}

// 1. ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ (๋น„๊ด€์  ๋ฝ ์ œ๊ฑฐ)
// 1. ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));

// 2. DB์—์„œ Like ๋ ˆ์ฝ”๋“œ๋งŒ ์‚ญ์ œ (์นด์šดํŠธ๋Š” Redis์—์„œ ๊ด€๋ฆฌ)
// 2. DB์—์„œ Like ๋ ˆ์ฝ”๋“œ ์‚ญ์ œ (์นด์šดํŠธ๋Š” Redis์—์„œ ๊ด€๋ฆฌ)
likeRepository.deleteByMemberIdAndProductId(memberId, productId);

log.info("[LikeService] ์ข‹์•„์š” ์ทจ์†Œ ์™„๋ฃŒ - memberId: {}, productId: {}", memberId, productId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@
import com.loopers.domain.order.repository.OrderRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.repository.ProductRepository;
import com.loopers.infrastructure.cache.CacheInvalidationService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class OrderPlacementService {
Expand All @@ -27,6 +30,7 @@ public class OrderPlacementService {
private final ProductRepository productRepository;
private final MemberRepository memberRepository;
private final MemberCouponRepository memberCouponRepository;
private final CacheInvalidationService cacheInvalidationService;

public Order placeOrder(OrderPlacementCommand command) {
validateMemberExists(command.getMemberId());
Expand Down Expand Up @@ -83,6 +87,13 @@ private List<OrderItem> processOrderLines(List<OrderLineCommand> orderLines) {
throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.");
}

// ์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์บ์‹œ ๋ฌดํšจํ™”
int remainingStock = productRepository.getStockQuantity(product.getId());
if (remainingStock == 0) {
log.info("[Order] Stock depleted for productId={}, invalidating cache", product.getId());
cacheInvalidationService.invalidateOnStockDepletion(product.getId());
}

items.add(new OrderItem(product.getId(), line.getQuantity(), product.getPrice()));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public interface ProductRepository {

int increaseStock(Long productId, int quantity);

int getStockQuantity(Long productId);

int incrementLikeCount(Long productId);

int decrementLikeCount(Long productId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,13 @@ public void invalidateOnProductUpdate(Long productId) {
log.info("[CacheInvalidation] Invalidating cache for product update, productId={}", productId);
productDetailCache.delete(productId);
}

/**
* Invalidate cache when product stock is depleted
*/
public void invalidateOnStockDepletion(Long productId) {
log.info("[CacheInvalidation] Invalidating cache for stock depletion, productId={}", productId);
productDetailCache.delete(productId);
// Note: Product list cache๋Š” TTL(60์ดˆ)์— ์˜์กดํ•˜์—ฌ ์ž๋™ ๋ฌดํšจํ™”
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ public Set<Long> findLikedProductIds(Long memberId, List<Long> productIds) {
return Set.of();
}

// productIds ์ค‘ ์ข‹์•„์š”ํ•œ ๊ฒƒ๋งŒ ํ•„ํ„ฐ๋ง
// productIds ์ค‘ ์ข‹์•„์š”ํ•œ ๊ฒƒ๋งŒ ํ•„ํ„ฐ๋ง (๋ณ€ํ™˜ ์‹คํŒจํ•œ ๊ฐ’์€ ์ œ์™ธ)
Set<Long> likedSet = allLiked.stream()
.map(obj -> (Long) obj)
.map(this::toLongOrNull)
.filter(v -> v != null)
.collect(Collectors.toSet());

return productIds.stream()
Expand All @@ -107,7 +108,8 @@ public Set<Long> findAll(Long memberId) {
}

return members.stream()
.map(obj -> (Long) obj)
.map(this::toLongOrNull)
.filter(v -> v != null)
.collect(Collectors.toSet());

} catch (Exception e) {
Expand Down Expand Up @@ -165,4 +167,29 @@ public void delete(Long memberId) {
log.warn("[MemberLikesCache] delete failed, error={}", e.getMessage());
}
}

/**
* Redis ๊ฐ’์„ Long์œผ๋กœ ๋ณ€ํ™˜ (๋ณ€ํ™˜ ์‹คํŒจ ์‹œ null ๋ฐ˜ํ™˜)
*/
private Long toLongOrNull(Object value) {
if (value == null) {
return null;
}
if (value instanceof Long) {
return (Long) value;
}
if (value instanceof Integer) {
return ((Integer) value).longValue();
}
if (value instanceof String) {
try {
return Long.parseLong((String) value);
} catch (NumberFormatException e) {
log.warn("[MemberLikesCache] Failed to parse String to Long: {}", value);
return null;
}
}
log.warn("[MemberLikesCache] Unsupported value type: {}", value.getClass().getSimpleName());
return null;
}
}
Loading