Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
@@ -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()
));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return result;
}

@Transactional(readOnly = true)
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
@@ -0,0 +1,48 @@
package com.loopers.infrastructure.kafka;

import org.springframework.stereotype.Component;

/**
* Kafka Topic Router
* - ์ด๋ฒคํŠธ ํƒ€์ž…์— ๋”ฐ๋ผ ์ ์ ˆํ•œ Kafka ํ† ํ”ฝ์„ ๋ฐ˜ํ™˜
* - ํ† ํ”ฝ ๋„ค์ด๋ฐ ๊ทœ์น™: loopers.commerce.{event-name}-v1
*/
@Component
public class KafkaTopicRouter {

private static final String TOPIC_PREFIX = "loopers.commerce.";
private static final String TOPIC_VERSION = "-v1";

/**
* ์ด๋ฒคํŠธ ํƒ€์ž…์— ๋งž๋Š” ํ† ํ”ฝ ์ด๋ฆ„ ๋ฐ˜ํ™˜
*
* @param eventType ์ด๋ฒคํŠธ ํƒ€์ž… (ORDER_PLACED, PRODUCT_LIKED ๋“ฑ)
* @return Kafka ํ† ํ”ฝ ์ด๋ฆ„
*/
public String getTopicName(String eventType) {
return switch (eventType) {
// Order Events
case "ORDER_PLACED" -> TOPIC_PREFIX + "order-placed" + TOPIC_VERSION;
case "ORDER_COMPLETED" -> TOPIC_PREFIX + "order-completed" + TOPIC_VERSION;

// Payment Events
case "PAYMENT_COMPLETED" -> TOPIC_PREFIX + "payment-completed" + TOPIC_VERSION;

// Product Events
case "PRODUCT_LIKED" -> TOPIC_PREFIX + "product-liked" + TOPIC_VERSION;
case "PRODUCT_UNLIKED" -> TOPIC_PREFIX + "product-unliked" + TOPIC_VERSION;
case "PRODUCT_VIEWED" -> TOPIC_PREFIX + "product-viewed" + TOPIC_VERSION;

default -> throw new IllegalArgumentException("Unknown event type: " + eventType);
};
}

/**
* ํ† ํ”ฝ ์ด๋ฆ„์ด ์œ ํšจํ•œ์ง€ ๊ฒ€์ฆ
*/
public boolean isValidTopic(String topicName) {
return topicName != null &&
topicName.startsWith(TOPIC_PREFIX) &&
topicName.endsWith(TOPIC_VERSION);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸก Minor

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Description: isValidTopic ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ์ฒ˜ ํ™•์ธ
rg -n "isValidTopic" --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 229


isValidTopic() ๋ฉ”์„œ๋“œ๊ฐ€ ์‚ฌ์šฉ๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

ํ˜„์žฌ ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ ์ด ๋ฉ”์„œ๋“œ์˜ ํ˜ธ์ถœ ์ง€์ ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ๋ฉ”์„œ๋“œ์ด๋ฏ€๋กœ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜, ํ–ฅํ›„ ์‚ฌ์šฉ ๊ณ„ํš์ด ์žˆ๋‹ค๋ฉด TODO ์ฃผ์„์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicRouter.java
around lines 43-47, the isValidTopic(String) method is unused; either remove the
method entirely (delete its declaration and any now-unused imports or
references, then run mvn/test to ensure no compile breakage) or, if you intend
to keep it for future use, add a clear TODO comment above the method explaining
expected future usage and add @SuppressWarnings("unused") to avoid linter noise.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.loopers.infrastructure.kafka.event;

import java.time.ZonedDateTime;

/**
* Kafka ์ด๋ฒคํŠธ Envelope
* - Application Event๋ฅผ Kafka๋กœ ์ „์†กํ•  ๋•Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ํฌํ•จํ•˜๊ธฐ ์œ„ํ•œ ๋ž˜ํผ
* - ๋ฉฑ๋“ฑ์„ฑ ์ฒดํฌ ๋ฐ ์ˆœ์„œ ๋ณด์žฅ์„ ์œ„ํ•œ ์ •๋ณด ํฌํ•จ
*
* @param <T> Application Event ํƒ€์ž… (OrderPlacedEvent, ProductLikedEvent ๋“ฑ)
*/
public record KafkaEventEnvelope<T>(
/**
* ์ด๋ฒคํŠธ ID (Outbox Event์˜ ID)
* - Consumer์—์„œ ๋ฉฑ๋“ฑ์„ฑ ์ฒดํฌ์— ์‚ฌ์šฉ
*/
String eventId,

/**
* ์ด๋ฒคํŠธ ํƒ€์ž…
* - ์˜ˆ: ORDER_PLACED, PRODUCT_LIKED, PAYMENT_COMPLETED
*/
String eventType,

/**
* Partition Key
* - Kafka ํŒŒํ‹ฐ์…˜ ๋ถ„๋ฐฐ ๊ธฐ์ค€
* - ๊ฐ™์€ ๊ฐ’์€ ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์œผ๋กœ ์ „์†ก๋˜์–ด ์ˆœ์„œ ๋ณด์žฅ
*/
String partitionKey,

/**
* ์‹ค์ œ ์ด๋ฒคํŠธ Payload
* - Application Event ๊ฐ์ฒด (OrderPlacedEvent, ProductLikedEvent ๋“ฑ)
*/
T payload,

/**
* ์ด๋ฒคํŠธ ๋ฐœ์ƒ ์‹œ๊ฐ
*/
ZonedDateTime occurredAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.loopers.infrastructure.outbox;

import com.loopers.application.event.like.ProductLikedEvent;
import com.loopers.application.event.like.ProductUnlikedEvent;
import com.loopers.application.event.order.OrderCompletedEvent;
import com.loopers.application.event.order.OrderPlacedEvent;
import com.loopers.application.event.payment.PaymentCompletedEvent;
import com.loopers.application.event.product.ProductViewedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* Kafka Outbox Event Listener
* - Application Event๋ฅผ ๋ฐ›์•„์„œ Outbox ํ…Œ์ด๋ธ”์— ์ €์žฅ
* - BEFORE_COMMIT: ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์ฒ˜๋ฆฌ๋˜์–ด ์›์ž์„ฑ ๋ณด์žฅ
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KafkaOutboxEventListener {

private final OutboxEventWriter outboxWriter;

/**
* ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOrderPlaced(OrderPlacedEvent event) {
log.debug("[Outbox] OrderPlacedEvent ์ˆ˜์‹  - orderNo: {}", event.orderNo());

outboxWriter.write(
event.orderNo(), // partition key
"ORDER_PLACED", // event type
event // payload
);
}

/**
* ์ฃผ๋ฌธ ์™„๋ฃŒ ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
log.debug("[Outbox] OrderCompletedEvent ์ˆ˜์‹  - orderNo: {}", event.orderNo());

outboxWriter.write(
event.orderNo(),
"ORDER_COMPLETED",
event
);
}

/**
* ๊ฒฐ์ œ ์™„๋ฃŒ ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handlePaymentCompleted(PaymentCompletedEvent event) {
log.debug("[Outbox] PaymentCompletedEvent ์ˆ˜์‹  - orderNo: {}", event.orderNo());

outboxWriter.write(
event.orderNo(),
"PAYMENT_COMPLETED",
event
);
}

/**
* ์ƒํ’ˆ ์ข‹์•„์š” ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleProductLiked(ProductLikedEvent event) {
log.debug("[Outbox] ProductLikedEvent ์ˆ˜์‹  - productId: {}", event.productId());

outboxWriter.write(
String.valueOf(event.productId()), // partition key
"PRODUCT_LIKED",
event
);
}

/**
* ์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleProductUnliked(ProductUnlikedEvent event) {
log.debug("[Outbox] ProductUnlikedEvent ์ˆ˜์‹  - productId: {}", event.productId());

outboxWriter.write(
String.valueOf(event.productId()),
"PRODUCT_UNLIKED",
event
);
}

/**
* ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ โ†’ Outbox ์ €์žฅ
*/
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleProductViewed(ProductViewedEvent event) {
log.debug("[Outbox] ProductViewedEvent ์ˆ˜์‹  - productId: {}", event.productId());

outboxWriter.write(
String.valueOf(event.productId()), // partition key
"PRODUCT_VIEWED",
event
);
}
}
Loading