Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
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
@@ -1,12 +1,12 @@
package com.loopers;

import jakarta.annotation.PostConstruct;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;

@EnableFeignClients
@EnableScheduling
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
import com.loopers.infrastructure.dataplatform.DataPlatformClient;
import com.loopers.infrastructure.dataplatform.DataPlatformOrderRequest;
import com.loopers.infrastructure.dataplatform.DataPlatformPaymentRequest;
import java.time.ZoneOffset;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.time.ZoneOffset;
import java.util.stream.Collectors;

/**
* ๋ฐ์ดํ„ฐ ํ”Œ๋žซํผ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
* - ์ฃผ๋ฌธ/๊ฒฐ์ œ ๋ฐ์ดํ„ฐ๋ฅผ ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ํ”Œ๋žซํผ์œผ๋กœ ๋น„๋™๊ธฐ ์ „์†ก
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.loopers.application.kafka;

import com.loopers.domain.outbox.EventOutbox;
import java.util.concurrent.CompletableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;

/**
* Kafka ์ด๋ฒคํŠธ ๋ฐœํ–‰
* - Outbox ์ด๋ฒคํŠธ๋ฅผ Kafka๋กœ ์ „์†ก
* - Topic์€ aggregateType์— ๋”ฐ๋ผ ๊ฒฐ์ •
* - PartitionKey๋Š” aggregateId๋กœ ์„ค์ • (์ˆœ์„œ ๋ณด์žฅ)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventKafkaProducer {

private final KafkaTemplate<Object, Object> kafkaTemplate;

@Value("${kafka.topics.catalog-events}")
private String catalogEventsTopic;

@Value("${kafka.topics.order-events}")
private String orderEventsTopic;

/**
* Outbox ์ด๋ฒคํŠธ๋ฅผ Kafka๋กœ ๋ฐœํ–‰
*
* @param outbox Outbox ์ด๋ฒคํŠธ
* @return CompletableFuture<SendResult>
*/
public CompletableFuture<SendResult<Object, Object>> publish(EventOutbox outbox) {
String topic = getTopicByAggregateType(outbox.getAggregateType());
String partitionKey = outbox.getAggregateId(); // ์ˆœ์„œ ๋ณด์žฅ์„ ์œ„ํ•œ Partition Key

log.info("Kafka ๋ฐœํ–‰ ์‹œ์ž‘ - topic: {}, key: {}, eventType: {}",
topic, partitionKey, outbox.getEventType());

return kafkaTemplate.send(topic, partitionKey, outbox.getPayload())
.thenApply(result -> {
log.info("Kafka ๋ฐœํ–‰ ์„ฑ๊ณต - topic: {}, partition: {}, offset: {}, eventId: {}",
topic,
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset(),
outbox.getId());
return result;
})
.exceptionally(ex -> {
log.error("Kafka ๋ฐœํ–‰ ์‹คํŒจ - topic: {}, key: {}, eventId: {}, error: {}",
topic, partitionKey, outbox.getId(), ex.getMessage(), ex);
throw new RuntimeException("Kafka ๋ฐœํ–‰ ์‹คํŒจ", ex);
});
}

/**
* AggregateType์— ๋”ฐ๋ผ Topic ๊ฒฐ์ •
*/
private String getTopicByAggregateType(String aggregateType) {
return switch (aggregateType.toUpperCase()) {
case "ORDER", "PAYMENT" -> orderEventsTopic;
case "PRODUCT", "LIKE" -> catalogEventsTopic;
default -> throw new IllegalArgumentException("์•Œ ์ˆ˜ ์—†๋Š” AggregateType: " + aggregateType);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.loopers.application.coupon.CouponService;
import com.loopers.application.order.OrderCommand.OrderItemRequest;
import com.loopers.application.outbox.OutboxEventService;
import com.loopers.application.product.ProductCacheService;
import com.loopers.domain.coupon.UserCoupon;
import com.loopers.domain.order.Order;
import com.loopers.domain.order.OrderItem;
Expand Down Expand Up @@ -38,6 +39,7 @@ public class OrderFacade {
private final PointService pointService;
private final CouponService couponService;
private final OutboxEventService outboxEventService;
private final ProductCacheService productCacheService;

@Transactional
public OrderInfo createOrder(String userId, OrderCommand.Create command) {
Expand Down Expand Up @@ -92,6 +94,12 @@ private Order createOrderWithItems(String userId, List<OrderItemRequest> orderIt
Product product = productMap.get(request.productId());
product.deductStock(request.quantity());
order.addOrderItem(OrderItem.from(product, request.quantity()));

// ์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์บ์‹œ ๊ฐฑ์‹ 
if (product.getStock() == 0) {
log.info("์žฌ๊ณ  ์†Œ์ง„ - ์บ์‹œ ๊ฐฑ์‹  - productId: {}", product.getId());
productCacheService.evictCache(product.getId());
}
}

order.calculateTotalAmount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

Expand All @@ -16,6 +17,7 @@
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "outbox.publisher.enabled", havingValue = "true", matchIfMissing = true)
public class OutboxEventPublisher {

private final EventOutboxRepository outboxRepository;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.kafka.EventKafkaProducer;
import com.loopers.domain.outbox.EventOutbox;
import com.loopers.domain.outbox.EventOutboxRepository;
import lombok.RequiredArgsConstructor;
Expand All @@ -23,6 +24,7 @@ public class OutboxEventService {

private final EventOutboxRepository outboxRepository;
private final ApplicationEventPublisher eventPublisher;
private final EventKafkaProducer kafkaProducer;
private final ObjectMapper objectMapper;

/**
Expand Down Expand Up @@ -55,24 +57,36 @@ public void saveEvent(String aggregateType, String aggregateId,
}

/**
* Outbox์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฝ์–ด ์‹ค์ œ ๋ฐœํ–‰
* Outbox์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฝ์–ด Kafka๋กœ ๋ฐœํ–‰
* - ๋ณ„๋„ ํŠธ๋žœ์žญ์…˜์—์„œ ์‹คํ–‰
* - Kafka ๋ฐœํ–‰ ์„ฑ๊ณต ์‹œ Outbox ์ƒํƒœ๋ฅผ PUBLISHED๋กœ ๋ณ€๊ฒฝ
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void publishEvent(EventOutbox outbox) {
try {
// ์ด๋ฒคํŠธ ํƒ€์ž…์— ๋”ฐ๋ผ ์‹ค์ œ ์ด๋ฒคํŠธ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜
Object event = deserializeEvent(outbox);

// Spring Event ๋ฐœํ–‰
eventPublisher.publishEvent(event);

// ๋ฐœํ–‰ ์„ฑ๊ณต ํ‘œ์‹œ
outbox.markAsPublished();
outboxRepository.save(outbox);

log.info("Outbox ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์™„๋ฃŒ - id: {}, type: {}",
outbox.getId(), outbox.getEventType());
// Kafka๋กœ ๋ฐœํ–‰
kafkaProducer.publish(outbox)
.thenAccept(result -> {
// ๋ฐœํ–‰ ์„ฑ๊ณต ์‹œ Outbox ์ƒํƒœ ์—…๋ฐ์ดํŠธ
outbox.markAsPublished();
outboxRepository.save(outbox);

log.info("Outbox โ†’ Kafka ๋ฐœํ–‰ ์™„๋ฃŒ - outboxId: {}, eventType: {}, offset: {}",
outbox.getId(),
outbox.getEventType(),
result.getRecordMetadata().offset());
})
.exceptionally(ex -> {
// ๋ฐœํ–‰ ์‹คํŒจ ์‹œ Outbox ์ƒํƒœ ์—…๋ฐ์ดํŠธ
outbox.markAsFailed(ex.getMessage());
outboxRepository.save(outbox);

log.error("Outbox โ†’ Kafka ๋ฐœํ–‰ ์‹คํŒจ - outboxId: {}, error: {}",
outbox.getId(), ex.getMessage(), ex);

return null;
})
.join(); // ๋™๊ธฐ ๋Œ€๊ธฐ (ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์™„๋ฃŒ ๋ณด์žฅ)
Comment on lines +68 to +89

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 | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

find . -type f -name "OutboxEventService.java" -o -name "*EventKafkaProducer*" 2>/dev/null | head -20

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

Length of output: 264


๐Ÿ Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java | sed -n '60,105p'

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

Length of output: 2150


๐Ÿ Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/kafka/EventKafkaProducer.java | head -80

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

Length of output: 3056


Kafka ๋ฐœํ–‰ ๋กœ์ง๊ณผ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ์— ์ฃผ์˜๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

join()์ด ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ๋‚ด ๋™๊ธฐ ์™„๋ฃŒ๋ฅผ ๋ณด์žฅํ•˜๋Š” ๊ฒƒ์€ ์˜ฌ๋ฐ”๋ฅด์ง€๋งŒ, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ตฌ์กฐ์— ์ค‘๋ณต ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค. EventKafkaProducer์˜ exceptionally() ๋ธ”๋ก(line 56)์—์„œ RuntimeException์„ ๋˜์ง€๊ณ , OutboxEventService์˜ exceptionally() ๋ธ”๋ก(lines 79-88)์—์„œ ์ด๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ฉฐ markAsFailed()์™€ save()๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์ด save() ํ˜ธ์ถœ์ด ์‹คํŒจํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ์ „ํŒŒ๋˜์–ด join()์ด CompletionException์„ ๋˜์ง€๊ณ , ์™ธ๋ถ€ catch ๋ธ”๋ก(lines 91-99)์ด ๋‹ค์‹œ markAsFailed()์™€ save()๋ฅผ ์‹œ๋„ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ์ด๋Š” ์ค‘๋ณต ์ƒํƒœ ์—…๋ฐ์ดํŠธ์™€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ œ์•ฝ ์กฐ๊ฑด ์œ„๋ฐ˜์„ ์ดˆ๋ž˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

exceptionally() ๋ธ”๋ก์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์˜ˆ์™ธ๋ฅผ ์™ธ๋ถ€ catch์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋„๋ก ๋ถ„๋ฆฌํ•˜๊ฑฐ๋‚˜, ์ค‘๋ณต ํ˜ธ์ถœ์„ ๋ฐฉ์ง€ํ•˜๋Š” ํ”Œ๋ž˜๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
around lines 68 to 89, avoid double-updating Outbox status when
kafkaProducer.exceptionally already marks/saves failures: either stop rethrowing
from EventKafkaProducer.exceptionally (so it handles markAsFailed/save and
returns a completed result) or add a guard here before calling markAsFailed/save
(e.g., check outbox status/isFinalState and only update if not already
failed/published). Update the code so only one place performs markAsFailed/save
(remove the duplicate update or add the status check) and ensure kafka publish
exceptions are not rethrown into join() to prevent the outer catch from
repeating the save.


} catch (Exception e) {
log.error("Outbox ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ - id: {}, error: {}",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.loopers.application.product;

import java.time.Duration;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -119,4 +119,27 @@ public CacheStats getCacheStats(Long productId) {
}

public record CacheStats(boolean viewCountCached, boolean likeCountCached) {}

/**
* ํŠน์ • ์ƒํ’ˆ์˜ Spring Cache๋ฅผ ์‚ญ์ œ
*
* ์‚ฌ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค:
* - ์žฌ๊ณ ๊ฐ€ ์†Œ์ง„๋˜์—ˆ์„ ๋•Œ ํ˜ธ์ถœ
* - ๋‹ค์Œ ์กฐํšŒ ์‹œ DB์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€ ์บ์‹œ์— ์ €์žฅ
*
* @param productId ์บ์‹œ๋ฅผ ์‚ญ์ œํ•  ์ƒํ’ˆ ID
*/
@CacheEvict(value = "product", key = "#productId")
public void evictCache(Long productId) {
log.info("์ƒํ’ˆ ์บ์‹œ ์‚ญ์ œ (์žฌ๊ณ  ์†Œ์ง„) - productId: {}", productId);
}

/**
* ๋ชจ๋“  ์ƒํ’ˆ ์บ์‹œ๋ฅผ ์‚ญ์ œ
* - ๋Œ€๋Ÿ‰ ์—…๋ฐ์ดํŠธ ๋“ฑ ํŠน์ˆ˜ํ•œ ๊ฒฝ์šฐ์— ์‚ฌ์šฉ
*/
@CacheEvict(value = "product", allEntries = true)
public void evictAllCache() {
log.info("์ „์ฒด ์ƒํ’ˆ ์บ์‹œ ์‚ญ์ œ");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

import com.loopers.domain.user.User;
import com.loopers.domain.user.UserService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
Expand All @@ -14,10 +17,6 @@
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class CacheConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
import feign.Logger;
import feign.Request;
import feign.Retryer;
import org.springframework.context.annotation.Bean;

import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;

/**
* DataPlatform Client ์„ค์ •
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import com.loopers.domain.example.ExampleModel;
import com.loopers.domain.example.ExampleRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Optional;

@RequiredArgsConstructor
@Component
public class ExampleRepositoryImpl implements ExampleRepository {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.loopers.infrastructure.payment;

import java.math.BigDecimal;
import lombok.Builder;

@Builder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
Expand All @@ -15,11 +19,6 @@
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
public class ApiControllerAdvice {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.loopers.domain.payment.Payment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
Expand Down
12 changes: 12 additions & 0 deletions apps/commerce-api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ spring:
import:
- jpa.yml
- redis.yml
- kafka.yml
- logging.yml
- monitoring.yml
- feign.yml
- resilience4j.yml

# Kafka Topics
kafka:
topics:
catalog-events: catalog-events
order-events: order-events

springdoc:
use-fqn: true
swagger-ui:
Expand All @@ -37,6 +44,11 @@ spring:
activate:
on-profile: local, test

# Outbox Publisher ๋น„ํ™œ์„ฑํ™” (ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ)
outbox:
publisher:
enabled: false

---
spring:
config:
Expand Down
Loading