Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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