From c7efc2d95a2aa8339e716e326b12c1db319daddd Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 26 Mar 2026 19:38:06 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B5=AC=EC=B6=95=20=E2=80=94=20?= =?UTF-8?q?event-contract=20=EA=B3=B5=EC=9C=A0=20=EB=AA=A8=EB=93=88=20+=20?= =?UTF-8?q?Outbox=20=ED=8C=A8=ED=84=B4=20+=20Kafka=20Producer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + apps/commerce-api/build.gradle.kts | 2 + .../application/outbox/MessageRelay.java | 64 +++++++++++++++++++ .../outbox/OutboxEventPublisher.java | 27 ++++++++ .../java/com/loopers/config/OutboxConfig.java | 49 ++++++++++++++ .../com/loopers/domain/outbox/Outbox.java | 42 ++++++++++++ .../loopers/domain/outbox/OutboxEvent.java | 10 +++ .../domain/outbox/OutboxRepository.java | 13 ++++ apps/commerce-streamer/build.gradle.kts | 1 + .../com/loopers/event/DataSerializer.java | 43 +++++++++++++ .../main/java/com/loopers/event/Event.java | 46 +++++++++++++ .../java/com/loopers/event/EventPayload.java | 4 ++ .../java/com/loopers/event/EventType.java | 30 +++++++++ .../java/com/loopers/event/Snowflake.java | 45 +++++++++++++ .../main/java/com/loopers/event/Topic.java | 10 +++ .../CouponIssueRequestedEventPayload.java | 15 +++++ .../payload/OrderCompletedEventPayload.java | 17 +++++ .../payload/PaymentCompletedEventPayload.java | 15 +++++ .../payload/ProductLikedEventPayload.java | 14 ++++ .../payload/ProductUnlikedEventPayload.java | 14 ++++ .../payload/ProductViewedEventPayload.java | 14 ++++ .../java/com/loopers/event/EventTest.java | 31 +++++++++ .../java/com/loopers/event/SnowflakeTest.java | 57 +++++++++++++++++ settings.gradle.kts | 1 + 24 files changed, 567 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/Event.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/EventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/EventType.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/Snowflake.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/Topic.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java create mode 100644 modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java create mode 100644 modules/event-contract/src/test/java/com/loopers/event/EventTest.java create mode 100644 modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java diff --git a/.gitignore b/.gitignore index 5a979af6f..0dafe803d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +### QueryDSL ### +**/generated/ diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 32f0b9229..dd397224f 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,8 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) + implementation(project(":modules:event-contract")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java new file mode 100644 index 000000000..8b71257cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java @@ -0,0 +1,64 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MessageRelay { + + private final OutboxRepository outboxRepository; + private final KafkaTemplate kafkaTemplate; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void createOutbox(OutboxEvent event) { + outboxRepository.save(event.getOutbox()); + } + + @Async("outboxPublishExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void publishEvent(OutboxEvent event) { + publishToKafka(event.getOutbox()); + } + + private void publishToKafka(Outbox outbox) { + try { + kafkaTemplate.send( + outbox.getEventType().getTopic(), + String.valueOf(outbox.getPartitionKey()), + outbox.getPayload() + ).get(1, TimeUnit.SECONDS); + outboxRepository.delete(outbox); + } catch (Exception e) { + log.error("[MessageRelay] Kafka 발행 실패, outboxId={}", outbox.getOutboxId(), e); + } + } + + @Scheduled(fixedDelay = 10, timeUnit = TimeUnit.SECONDS) + public void publishPendingEvents() { + List pending = outboxRepository + .findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( + LocalDateTime.now().minusSeconds(10), + Pageable.ofSize(100) + ); + + for (Outbox outbox : pending) { + publishToKafka(outbox); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..6cd49e8a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventPublisher.java @@ -0,0 +1,27 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.event.*; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private final Snowflake outboxIdSnowflake = new Snowflake(); + private final Snowflake eventIdSnowflake = new Snowflake(); + private final ApplicationEventPublisher applicationEventPublisher; + + public void publish(EventType type, EventPayload payload, Long partitionKey) { + Outbox outbox = Outbox.create( + outboxIdSnowflake.nextId(), + type, + Event.of(eventIdSnowflake.nextId(), type, payload).toJson(), + partitionKey + ); + applicationEventPublisher.publishEvent(OutboxEvent.of(outbox)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java new file mode 100644 index 000000000..668f1fae9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java @@ -0,0 +1,49 @@ +package com.loopers.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +@EnableScheduling +public class OutboxConfig { + + @Bean + public ProducerFactory outboxProducerFactory(KafkaProperties kafkaProperties) { + Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory outboxProducerFactory) { + return new KafkaTemplate<>(outboxProducerFactory); + } + + @Bean("outboxPublishExecutor") + public Executor outboxPublishExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("outbox-publish-"); + executor.initialize(); + return executor; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java new file mode 100644 index 000000000..b63383769 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/Outbox.java @@ -0,0 +1,42 @@ +package com.loopers.domain.outbox; + +import com.loopers.event.EventType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "outbox") +public class Outbox { + + @Id + private Long outboxId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventType eventType; + + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(nullable = false) + private Long partitionKey; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public static Outbox create(Long outboxId, EventType eventType, String payload, Long partitionKey) { + Outbox outbox = new Outbox(); + outbox.outboxId = outboxId; + outbox.eventType = eventType; + outbox.payload = payload; + outbox.partitionKey = partitionKey; + outbox.createdAt = LocalDateTime.now(); + return outbox; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..c4a7c08ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,10 @@ +package com.loopers.domain.outbox; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(staticName = "of") +public class OutboxEvent { + private final Outbox outbox; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java new file mode 100644 index 000000000..8299df96a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.outbox; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface OutboxRepository extends JpaRepository { + + List findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( + LocalDateTime createdAt, Pageable pageable); +} diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index ba710e6eb..55a8304d9 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { implementation(project(":modules:jpa")) implementation(project(":modules:redis")) implementation(project(":modules:kafka")) + implementation(project(":modules:event-contract")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java b/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java new file mode 100644 index 000000000..1fd9c34a6 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/DataSerializer.java @@ -0,0 +1,43 @@ +package com.loopers.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.io.IOException; + +public final class DataSerializer { + + private static final ObjectMapper objectMapper = initialize(); + + private DataSerializer() { + } + + private static ObjectMapper initialize() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } + + public static String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new RuntimeException("[DataSerializer] serialize failed", e); + } + } + + public static T deserialize(String json, Class clazz) { + try { + return objectMapper.readValue(json, clazz); + } catch (IOException e) { + throw new RuntimeException("[DataSerializer] deserialize failed", e); + } + } + + public static T deserialize(Object object, Class clazz) { + return objectMapper.convertValue(object, clazz); + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Event.java b/modules/event-contract/src/main/java/com/loopers/event/Event.java new file mode 100644 index 000000000..1bd4dca20 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Event.java @@ -0,0 +1,46 @@ +package com.loopers.event; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class Event { + + private Long eventId; + private EventType type; + private T payload; + + public static Event of(Long eventId, EventType type, T payload) { + Event event = new Event<>(); + event.eventId = eventId; + event.type = type; + event.payload = payload; + return event; + } + + public String toJson() { + return DataSerializer.serialize(this); + } + + @SuppressWarnings("unchecked") + public static Event fromJson(String json) { + EventRaw raw = DataSerializer.deserialize(json, EventRaw.class); + if (raw == null) { + return null; + } + Event event = new Event<>(); + event.eventId = raw.getEventId(); + event.type = EventType.from(raw.getType()); + event.payload = DataSerializer.deserialize(raw.getPayload(), event.type.getPayloadClass()); + return event; + } + + @Getter + @NoArgsConstructor + private static class EventRaw { + private Long eventId; + private String type; + private Object payload; + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java new file mode 100644 index 000000000..69e56d551 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/EventPayload.java @@ -0,0 +1,4 @@ +package com.loopers.event; + +public interface EventPayload { +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/EventType.java b/modules/event-contract/src/main/java/com/loopers/event/EventType.java new file mode 100644 index 000000000..82c17a134 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/EventType.java @@ -0,0 +1,30 @@ +package com.loopers.event; + +import com.loopers.event.payload.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum EventType { + + PRODUCT_LIKED(ProductLikedEventPayload.class, Topic.CATALOG_EVENTS), + PRODUCT_UNLIKED(ProductUnlikedEventPayload.class, Topic.CATALOG_EVENTS), + PRODUCT_VIEWED(ProductViewedEventPayload.class, Topic.CATALOG_EVENTS), + ORDER_COMPLETED(OrderCompletedEventPayload.class, Topic.ORDER_EVENTS), + PAYMENT_COMPLETED(PaymentCompletedEventPayload.class, Topic.ORDER_EVENTS), + COUPON_ISSUE_REQUESTED(CouponIssueRequestedEventPayload.class, Topic.COUPON_ISSUE_REQUESTS), + ; + + private final Class payloadClass; + private final String topic; + + public static EventType from(String type) { + return Arrays.stream(values()) + .filter(e -> e.name().equals(type)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown EventType: " + type)); + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java b/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java new file mode 100644 index 000000000..4b583047a --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Snowflake.java @@ -0,0 +1,45 @@ +package com.loopers.event; + +import java.util.random.RandomGenerator; + +public class Snowflake { + + private static final long EPOCH = 1704067200000L; // 2024-01-01T00:00:00Z + private static final int MACHINE_ID_BITS = 10; + private static final int SEQUENCE_BITS = 12; + + private static final long MAX_MACHINE_ID = (1L << MACHINE_ID_BITS) - 1; + private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1; + + private final long machineId; + private long lastTimestamp = -1L; + private long sequence = 0L; + + public Snowflake() { + this.machineId = RandomGenerator.getDefault().nextLong(MAX_MACHINE_ID + 1); + } + + public synchronized long nextId() { + long timestamp = System.currentTimeMillis() - EPOCH; + if (timestamp == lastTimestamp) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + timestamp = waitNextMillis(lastTimestamp); + } + } else { + sequence = 0; + } + lastTimestamp = timestamp; + return (timestamp << (MACHINE_ID_BITS + SEQUENCE_BITS)) + | (machineId << SEQUENCE_BITS) + | sequence; + } + + private long waitNextMillis(long lastTimestamp) { + long timestamp = System.currentTimeMillis() - EPOCH; + while (timestamp <= lastTimestamp) { + timestamp = System.currentTimeMillis() - EPOCH; + } + return timestamp; + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/Topic.java b/modules/event-contract/src/main/java/com/loopers/event/Topic.java new file mode 100644 index 000000000..fde90a8f7 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/Topic.java @@ -0,0 +1,10 @@ +package com.loopers.event; + +public final class Topic { + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; + + private Topic() {} +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java new file mode 100644 index 000000000..ed7dd2cc0 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/CouponIssueRequestedEventPayload.java @@ -0,0 +1,15 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class CouponIssueRequestedEventPayload implements EventPayload { + private Long couponIssueRequestId; + private Long couponId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java new file mode 100644 index 000000000..702b0cd0c --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/OrderCompletedEventPayload.java @@ -0,0 +1,17 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class OrderCompletedEventPayload implements EventPayload { + private Long orderId; + private Long userId; + private List productIds; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java new file mode 100644 index 000000000..8274b6ef0 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java @@ -0,0 +1,15 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class PaymentCompletedEventPayload implements EventPayload { + private Long paymentId; + private Long orderId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java new file mode 100644 index 000000000..e08ef3546 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductLikedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductLikedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java new file mode 100644 index 000000000..08f901ce5 --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductUnlikedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductUnlikedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java new file mode 100644 index 000000000..c5f3040fa --- /dev/null +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/ProductViewedEventPayload.java @@ -0,0 +1,14 @@ +package com.loopers.event.payload; + +import com.loopers.event.EventPayload; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(staticName = "of") +public class ProductViewedEventPayload implements EventPayload { + private Long productId; + private Long userId; +} diff --git a/modules/event-contract/src/test/java/com/loopers/event/EventTest.java b/modules/event-contract/src/test/java/com/loopers/event/EventTest.java new file mode 100644 index 000000000..41c526cef --- /dev/null +++ b/modules/event-contract/src/test/java/com/loopers/event/EventTest.java @@ -0,0 +1,31 @@ +package com.loopers.event; + +import com.loopers.event.payload.ProductLikedEventPayload; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventTest { + + @Test + @DisplayName("Event를 JSON으로 직렬화 후 역직렬화하면 eventId, type, payload가 보존된다") + void serializeAndDeserializePreservesAllFields() { + // Arrange + ProductLikedEventPayload payload = ProductLikedEventPayload.of(100L, 1L); + Event event = Event.of(1234L, EventType.PRODUCT_LIKED, payload); + + // Act + String json = event.toJson(); + Event result = Event.fromJson(json); + + // Assert + assertThat(result.getEventId()).isEqualTo(event.getEventId()); + assertThat(result.getType()).isEqualTo(event.getType()); + assertThat(result.getPayload()).isInstanceOf(ProductLikedEventPayload.class); + + ProductLikedEventPayload resultPayload = (ProductLikedEventPayload) result.getPayload(); + assertThat(resultPayload.getProductId()).isEqualTo(payload.getProductId()); + assertThat(resultPayload.getUserId()).isEqualTo(payload.getUserId()); + } +} diff --git a/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java b/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java new file mode 100644 index 000000000..b5a6d2d60 --- /dev/null +++ b/modules/event-contract/src/test/java/com/loopers/event/SnowflakeTest.java @@ -0,0 +1,57 @@ +package com.loopers.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; + +class SnowflakeTest { + + Snowflake snowflake = new Snowflake(); + + @Test + @DisplayName("멀티스레드 환경에서 생성된 ID는 유니크하고 각 작업 내에서는 증가한다") + void nextIdIsUniqueAndIncreasingWithinEachTaskUnderConcurrency() throws ExecutionException, InterruptedException { + // Arrange + ExecutorService executorService = Executors.newFixedThreadPool(10); + try { + List>> futures = new ArrayList<>(); + int repeatCount = 1000; + int idCount = 1000; + + // Act + for (int i = 0; i < repeatCount; i++) { + futures.add(executorService.submit(() -> generateIdList(snowflake, idCount))); + } + + // Assert + List result = new ArrayList<>(); + for (Future> future : futures) { + List idList = future.get(); + for (int i = 1; i < idList.size(); i++) { + assertThat(idList.get(i)).isGreaterThan(idList.get(i - 1)); + } + result.addAll(idList); + } + + assertThat(result.stream().distinct().count()).isEqualTo((long) repeatCount * idCount); + } finally { + executorService.shutdown(); + } + } + + private List generateIdList(Snowflake snowflake, int count) { + List ids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ids.add(snowflake.nextId()); + } + return ids; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 240be71d2..411852a99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,6 +8,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:event-contract", ":supports:jackson", ":supports:logging", ":supports:monitoring", From 270bb0d32c5929933eeb2b28a83f534d36c1c5aa Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 26 Mar 2026 20:21:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94/?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=A3=BC=EB=AC=B8/=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20=E2=80=94=20Product.likeCount?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 18 +++++- .../application/outbox/MessageRelay.java | 8 +-- .../application/payment/PaymentFacade.java | 12 ++++ .../application/product/ProductInfo.java | 2 +- .../application/product/ProductService.java | 20 ------ .../product/ProductViewEventPublisher.java | 32 ++++++++++ .../com/loopers/domain/product/Product.java | 3 - .../domain/product/ProductRepository.java | 2 - .../product/ProductJpaRepository.java | 8 --- .../product/ProductRepositoryImpl.java | 11 +--- .../api/product/ProductV1Controller.java | 3 + .../java/com/loopers/DataInitializer.java | 14 +---- .../application/like/LikeFacadeTest.java | 56 ++++++++++++++--- .../payment/PaymentFacadeTest.java | 5 +- .../product/InMemoryProductRepository.java | 9 --- .../loopers/domain/product/ProductTest.java | 16 ----- .../interfaces/api/ConcurrencyE2ETest.java | 62 ------------------- .../interfaces/api/ProductV1ApiE2ETest.java | 39 ------------ modules/event-contract/build.gradle.kts | 8 +++ 19 files changed, 131 insertions(+), 197 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java create mode 100644 modules/event-contract/build.gradle.kts diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 09570baba..04bc11db4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,7 +1,11 @@ package com.loopers.application.like; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import com.loopers.event.payload.ProductUnlikedEventPayload; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,18 +19,28 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; + private final OutboxEventPublisher outboxEventPublisher; @Transactional public LikeInfo register(Long userId, Long productId) { LikeInfo like = likeService.register(userId, productId); - productService.increaseLikeCount(productId); + outboxEventPublisher.publish( + EventType.PRODUCT_LIKED, + ProductLikedEventPayload.of(productId, userId), + productId + ); + return like; } @Transactional public void cancel(Long userId, Long productId) { if (likeService.cancel(userId, productId)) { - productService.decreaseLikeCount(productId); + outboxEventPublisher.publish( + EventType.PRODUCT_UNLIKED, + ProductUnlikedEventPayload.of(productId, userId), + productId + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java index 8b71257cf..fe19022ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java @@ -52,10 +52,10 @@ private void publishToKafka(Outbox outbox) { @Scheduled(fixedDelay = 10, timeUnit = TimeUnit.SECONDS) public void publishPendingEvents() { List pending = outboxRepository - .findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( - LocalDateTime.now().minusSeconds(10), - Pageable.ofSize(100) - ); + .findAllByCreatedAtLessThanEqualOrderByCreatedAtAsc( + LocalDateTime.now().minusSeconds(10), + Pageable.ofSize(100) + ); for (Outbox outbox : pending) { publishToKafka(outbox); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index bd0b061be..a0b277ee3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -3,10 +3,13 @@ import com.loopers.application.order.OrderCompensationService; import com.loopers.application.order.OrderInfo; import com.loopers.application.order.OrderService; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.domain.order.Order; import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentRepository; import com.loopers.domain.payment.PaymentStatus; +import com.loopers.event.EventType; +import com.loopers.event.payload.PaymentCompletedEventPayload; import com.loopers.infrastructure.client.PgDeclinedException; import com.loopers.infrastructure.client.PgPaymentDto; import com.loopers.infrastructure.client.PgPaymentException; @@ -27,6 +30,7 @@ public class PaymentFacade { private final PaymentRepository paymentRepository; private final OrderService orderService; private final OrderCompensationService orderCompensationService; + private final OutboxEventPublisher outboxEventPublisher; private final PgPaymentGateway pgPaymentGateway; private final String callbackUrl; @@ -34,12 +38,14 @@ public PaymentFacade( PaymentRepository paymentRepository, OrderService orderService, OrderCompensationService orderCompensationService, + OutboxEventPublisher outboxEventPublisher, PgPaymentGateway pgPaymentGateway, @Value("${payment.callback-url}") String callbackUrl ) { this.paymentRepository = paymentRepository; this.orderService = orderService; this.orderCompensationService = orderCompensationService; + this.outboxEventPublisher = outboxEventPublisher; this.pgPaymentGateway = pgPaymentGateway; this.callbackUrl = callbackUrl; } @@ -122,8 +128,14 @@ private void applyPgResult(Payment payment, PgTransactionStatus status, String r int affected = paymentRepository.completeIfPending(payment.getId()); if (affected > 0) { orderService.markOrderPaid(payment.getOrderId()); + outboxEventPublisher.publish( + EventType.PAYMENT_COMPLETED, + PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null), + payment.getOrderId() + ); } } + case FAILED -> { int affected = paymentRepository.failIfPending(payment.getId(), reason); if (affected > 0) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 49c5d8496..6f201db82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -27,7 +27,7 @@ public static ProductInfo from(Product product) { product.getDescription(), product.getPrice(), product.getStockQuantity(), - product.getLikeCount(), + 0, product.getVisibility(), product.getCreatedAt(), product.getUpdatedAt(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 451580852..26a84faa7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -98,26 +98,6 @@ public void deleteAllByBrandId(Long brandId) { products.forEach(Product::delete); } - @Transactional - public void increaseLikeCount(Long productId) { - Product product = findById(productId); - if (!product.isActive()) { - throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); - } - - productRepository.increaseLikeCount(productId); - } - - @Transactional - public void decreaseLikeCount(Long productId) { - Product product = findById(productId); - if (!product.isActive()) { - throw new CoreException(ErrorType.NOT_FOUND, "[productId = " + productId + "] 를 찾을 수 없습니다."); - } - - productRepository.decreaseLikeCount(productId); - } - @Transactional public void decreaseStock(List items) { List sorted = items.stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java new file mode 100644 index 000000000..7a6095dc7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.event.Event; +import com.loopers.event.EventType; +import com.loopers.event.Snowflake; +import com.loopers.event.Topic; +import com.loopers.event.payload.ProductViewedEventPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductViewEventPublisher { + private final KafkaTemplate kafkaTemplate; + private final Snowflake eventIdSnowflake = new Snowflake(); + + public void publish(Long productId) { + try { + String json = Event.of( + eventIdSnowflake.nextId(), + EventType.PRODUCT_VIEWED, + ProductViewedEventPayload.of(productId, null) + ).toJson(); + kafkaTemplate.send(Topic.CATALOG_EVENTS, String.valueOf(productId), json); + } catch (Exception e) { + log.warn("[ProductViewEventPublisher] 조회 이벤트 발행 실패, productId={}", productId, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 71e015e21..e356f3589 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -29,9 +29,6 @@ public class Product extends BaseEntity { @Column(nullable = false) private Integer stockQuantity; - @Column(nullable = false) - private Integer likeCount = 0; - @Enumerated(EnumType.STRING) @Column(nullable = false) private Visibility visibility; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 8d1db9002..6b9f35914 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -15,6 +15,4 @@ public interface ProductRepository { List findAllByIdInAndDeletedAtIsNull(List ids); boolean decreaseStockIfEnough(Long productId, Integer quantity); int increaseStock(Long productId, Integer quantity); - void increaseLikeCount(Long productId); - void decreaseLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 41c6a4113..56dc72df4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -29,12 +29,4 @@ int decreaseStockIfEnough( @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE Product p SET p.stockQuantity = p.stockQuantity + :quantity WHERE p.id = :productId") int increaseStock(@Param("productId") Long productId, @Param("quantity") Integer quantity); - - @Modifying(clearAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId") - int increaseLikeCount(@Param("productId") Long productId); - - @Modifying(clearAutomatically = true) - @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0") - int decreaseLikeCount(@Param("productId") Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 0ec497814..cd7fb1710 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -46,7 +46,7 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab OrderSpecifier orderSpecifier = switch (order) { case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> product.likeCount.desc(); + case LIKES_DESC -> product.id.desc(); // TODO: Phase 3에서 product_metrics LEFT JOIN으로 교체 case LATEST -> product.id.desc(); }; @@ -115,13 +115,4 @@ public int increaseStock(Long productId, Integer quantity) { return productJpaRepository.increaseStock(productId, quantity); } - @Override - public void increaseLikeCount(Long productId) { - productJpaRepository.increaseLikeCount(productId); - } - - @Override - public void decreaseLikeCount(Long productId) { - productJpaRepository.decreaseLikeCount(productId); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index ee0f50dab..3b8e196c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -4,6 +4,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductSort; +import com.loopers.application.product.ProductViewEventPublisher; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.PageResponse; import com.loopers.interfaces.api.product.dto.ProductV1Dto; @@ -22,10 +23,12 @@ public class ProductV1Controller { private final ProductFacade productFacade; + private final ProductViewEventPublisher productViewEventPublisher; @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { ProductInfo product = productFacade.getActiveProduct(productId); + productViewEventPublisher.publish(productId); return ApiResponse.success(ProductV1Dto.ProductResponse.from(product)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java index 6ad90adea..8aff908c3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java +++ b/apps/commerce-api/src/test/java/com/loopers/DataInitializer.java @@ -45,13 +45,10 @@ void initialize() { for (int i = 0; i < brandIds.size(); i++) { Long brandId = brandIds.get(i); - boolean isTopBrand = i < 10; // 앞 10개 브랜드만 top + boolean isTopBrand = i < 10; int productCount = isTopBrand ? 10_000 : 2_500; - int likeMin = isTopBrand ? 300 : 0; - int likeMax = isTopBrand ? 3_000 : 2_000; insertProducts(brandId, productCount); - updateLikeCount(brandId, likeMin, likeMax); } } @@ -84,13 +81,4 @@ void insertProducts(Long brandId, int count) { }); } - void updateLikeCount(Long brandId, int min, int max) { - transactionTemplate.executeWithoutResult(status -> - entityManager.createNativeQuery("UPDATE products SET like_count = FLOOR(:min + RAND() * (:max - :min + 1)) WHERE brand_id = :brandId") - .setParameter("min", min) - .setParameter("max", max) - .setParameter("brandId", brandId) - .executeUpdate() - ); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 733089fb3..847f76013 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.application.product.ProductCreateCommand; import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductService; @@ -7,6 +8,9 @@ import com.loopers.domain.like.InMemoryLikeRepository; import com.loopers.domain.product.InMemoryProductRepository; import com.loopers.domain.product.Product; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import com.loopers.event.payload.ProductUnlikedEventPayload; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; public class LikeFacadeTest { @@ -25,6 +30,7 @@ public class LikeFacadeTest { private InMemoryProductRepository productRepository; private LikeService likeService; private ProductService productService; + private OutboxEventPublisher outboxEventPublisher; private LikeFacade likeFacade; @BeforeEach @@ -33,7 +39,8 @@ void setUp() { productRepository = new InMemoryProductRepository(); likeService = new LikeService(likeRepository); productService = new ProductService(productRepository); - likeFacade = new LikeFacade(likeService, productService); + outboxEventPublisher = mock(OutboxEventPublisher.class); + likeFacade = new LikeFacade(likeService, productService, outboxEventPublisher); } @DisplayName("좋아요 등록 시, ") @@ -56,13 +63,31 @@ void throws_when_already_liked() { // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.ALREADY_LIKED); } + + @DisplayName("좋아요 등록 시 PRODUCT_LIKED 이벤트가 발행된다.") + @Test + void publishes_product_liked_event() { + // arrange + long userId = 1L; + ProductInfo product = productService.register(new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10)); + + // act + likeFacade.register(userId, product.id()); + + // assert + verify(outboxEventPublisher).publish( + eq(EventType.PRODUCT_LIKED), + any(ProductLikedEventPayload.class), + eq(product.id()) + ); + } } @DisplayName("좋아요 취소 시, ") @Nested class Cancel { - @DisplayName("좋아요가 없을 때 취소하면 예외 없이 처리되고 likeCount는 감소하지 않는다.") + @DisplayName("좋아요가 없을 때 취소하면 예외 없이 처리되고 이벤트가 발행되지 않는다.") @Test void noop_when_like_does_not_exist() { // arrange @@ -71,15 +96,32 @@ void noop_when_like_does_not_exist() { new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10) ); - long productId = product.id(); - int before = productService.getProduct(productId).likeCount(); + // act + likeFacade.cancel(userId, product.id()); + + // assert + verify(outboxEventPublisher, never()).publish(any(), any(), any()); + } + + @DisplayName("좋아요 취소 시 PRODUCT_UNLIKED 이벤트가 발행된다.") + @Test + void publishes_product_unliked_event() { + // arrange + long userId = 1L; + ProductInfo product = productService.register( + new ProductCreateCommand(1L, "에어맥스", "신발", 150000, 10) + ); + likeService.register(userId, product.id()); // act - likeFacade.cancel(userId, productId); + likeFacade.cancel(userId, product.id()); // assert - int after = productService.getProduct(productId).likeCount(); - assertThat(after).isEqualTo(before); + verify(outboxEventPublisher).publish( + eq(EventType.PRODUCT_UNLIKED), + any(ProductUnlikedEventPayload.class), + eq(product.id()) + ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java index 58e45a411..14395199e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -1,5 +1,6 @@ package com.loopers.application.payment; +import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.domain.order.InMemoryOrderItemRepository; import com.loopers.domain.order.InMemoryOrderRepository; import com.loopers.application.order.OrderCompensationService; @@ -38,6 +39,7 @@ class PaymentFacadeTest { private InMemoryOrderRepository orderRepository; private OrderService orderService; private OrderCompensationService orderCompensationService; + private OutboxEventPublisher outboxEventPublisher; private PgPaymentGateway pgPaymentGateway; private PaymentFacade paymentFacade; @@ -47,8 +49,9 @@ void setUp() { orderRepository = new InMemoryOrderRepository(); orderService = new OrderService(orderRepository, new InMemoryOrderItemRepository()); orderCompensationService = mock(OrderCompensationService.class); + outboxEventPublisher = mock(OutboxEventPublisher.class); pgPaymentGateway = mock(PgPaymentGateway.class); - paymentFacade = new PaymentFacade(paymentRepository, orderService, orderCompensationService, pgPaymentGateway, "http://localhost:8080/api/v1/payments/callback"); + paymentFacade = new PaymentFacade(paymentRepository, orderService, orderCompensationService, outboxEventPublisher, pgPaymentGateway, "http://localhost:8080/api/v1/payments/callback"); } @DisplayName("결제 요청 시, ") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java index 3f5818303..4af37ae01 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/InMemoryProductRepository.java @@ -95,13 +95,4 @@ public int increaseStock(Long productId, Integer quantity) { throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); } - @Override - public void increaseLikeCount(Long productId) { - throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); - } - - @Override - public void decreaseLikeCount(Long productId) { - throw new UnsupportedOperationException("Atomic UPDATE는 DB에 의존하므로 통합테스트에서 커버합니다."); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 33afb2881..92035af91 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -304,20 +304,4 @@ void changesVisibility_fromHiddenToVisible() { } } - @DisplayName("좋아요 수 변경 시, ") - @Nested - class LikeCount { - - @DisplayName("기본값은 0이다.") - @Test - void likeCount_기본값은_0이다() { - // act - Product product = Product.create(1L, "나이키 에어맥스", "신발", 150000, 10); - - // assert - assertThat(product.getLikeCount()).isEqualTo(0); - } - - } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java index a40251096..a82f6b26b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java @@ -9,7 +9,6 @@ import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.coupon.CouponJpaRepository; import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; -import com.loopers.infrastructure.like.LikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.interfaces.api.order.OrderV1Dto; @@ -44,7 +43,6 @@ class ConcurrencyE2ETest { private static final String ORDERS_ENDPOINT = "/api/v1/orders"; - private static final String PRODUCTS_ENDPOINT = "/api/v1/products"; private static final String RAW_PASSWORD = "TestPass1!"; private final TestRestTemplate testRestTemplate; @@ -53,7 +51,6 @@ class ConcurrencyE2ETest { private final ProductJpaRepository productJpaRepository; private final CouponJpaRepository couponJpaRepository; private final IssuedCouponJpaRepository issuedCouponJpaRepository; - private final LikeJpaRepository likeJpaRepository; private final DatabaseCleanUp databaseCleanUp; private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); @@ -65,7 +62,6 @@ public ConcurrencyE2ETest( ProductJpaRepository productJpaRepository, CouponJpaRepository couponJpaRepository, IssuedCouponJpaRepository issuedCouponJpaRepository, - LikeJpaRepository likeJpaRepository, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; @@ -74,7 +70,6 @@ public ConcurrencyE2ETest( this.productJpaRepository = productJpaRepository; this.couponJpaRepository = couponJpaRepository; this.issuedCouponJpaRepository = issuedCouponJpaRepository; - this.likeJpaRepository = likeJpaRepository; this.databaseCleanUp = databaseCleanUp; } @@ -210,61 +205,4 @@ private HttpHeaders headersFor(String loginId) { assertThat(usedCoupon.getUsedAt()).isNotNull(); } - @DisplayName("N명이 동시에 좋아요를 등록하면, likeCount는 정확히 N이 된다.") - @Test - void likeCount_동시성_테스트() throws InterruptedException { - // arrange - int threadCount = 10; - - String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); - Brand brand = brandJpaRepository.save(Brand.create("나이키", "스포츠")); - Product product = productJpaRepository.save(Product.create(brand.getId(), "에어맥스", null, 10000, 100)); - String likeUrl = PRODUCTS_ENDPOINT + "/" + product.getId() + "/likes"; - - List users = new ArrayList<>(); - for (int i = 0; i < threadCount; i++) { - users.add(userJpaRepository.save( - UserFixture.builder() - .loginId("likeUser" + i) - .password(encodedPassword) - .build())); - } - - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(threadCount); - AtomicInteger successCount = new AtomicInteger(0); - - for (int i = 0; i < threadCount; i++) { - final User user = users.get(i); - executor.submit(() -> { - try { - startLatch.await(); - ResponseEntity> response = testRestTemplate.exchange( - likeUrl, HttpMethod.POST, - new HttpEntity<>(headersFor(user.getLoginId())), - new ParameterizedTypeReference<>() {}); - if (response.getStatusCode() == HttpStatus.CREATED) { - successCount.incrementAndGet(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - doneLatch.countDown(); - } - }); - } - - // act - startLatch.countDown(); - boolean completed = doneLatch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // assert - Product finalProduct = productJpaRepository.findById(product.getId()).orElseThrow(); - assertThat(completed).isTrue(); - assertThat(successCount.get()).isEqualTo(threadCount); - assertThat(finalProduct.getLikeCount()).isEqualTo(threadCount); - assertThat(likeJpaRepository.findAll()).hasSize(threadCount); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index d026d7398..469b239b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -1,6 +1,5 @@ package com.loopers.interfaces.api; -import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.infrastructure.brand.BrandJpaRepository; @@ -33,7 +32,6 @@ class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; - private final ProductService productService; private final DatabaseCleanUp databaseCleanUp; private final RedisCleanUp redisCleanUp; @@ -42,14 +40,12 @@ public ProductV1ApiE2ETest( TestRestTemplate testRestTemplate, BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, - ProductService productService, DatabaseCleanUp databaseCleanUp, RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; - this.productService = productService; this.databaseCleanUp = databaseCleanUp; this.redisCleanUp = redisCleanUp; } @@ -304,41 +300,6 @@ void returnsSortedByPriceAsc() { ); } - @DisplayName("sort=LIKES_DESC로 조회하면, 좋아요 많은순으로 반환한다.") - @Test - void returnsSortedByLikesDesc() { - // arrange - Brand brand = saveBrand("TEST_BRAND"); - Product lowLikes = saveProduct(brand.getId(), "좋아요적은상품", 100000, 10); - Product middleLikes = saveProduct(brand.getId(), "좋아요중간상품", 120000, 10); - Product highLikes = saveProduct(brand.getId(), "좋아요많은상품", 140000, 10); - - productService.increaseLikeCount(lowLikes.getId()); - productService.increaseLikeCount(middleLikes.getId()); - productService.increaseLikeCount(middleLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - productService.increaseLikeCount(highLikes.getId()); - - // act - ResponseEntity>> response = - testRestTemplate.exchange( - ENDPOINT + "?sort=LIKES_DESC", - HttpMethod.GET, null, new ParameterizedTypeReference<>() {} - ); - - List ids = response.getBody().data().content().stream() - .map(ProductV1Dto.ProductResponse::id) - .toList(); - // assert - assertAll( - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(ids.indexOf(highLikes.getId())) - .isLessThan(ids.indexOf(middleLikes.getId())) - .isLessThan(ids.indexOf(lowLikes.getId())) - ); - } - @DisplayName("삭제된 상품은 목록에서 제외된다.") @Test void excludesDeletedProducts() { diff --git a/modules/event-contract/build.gradle.kts b/modules/event-contract/build.gradle.kts new file mode 100644 index 000000000..cf93f274b --- /dev/null +++ b/modules/event-contract/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + api("com.fasterxml.jackson.core:jackson-databind") + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") +} From 5c8c3a86589b741d0cf54786437aacd57c15c8d0 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 08:34:48 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20commerce-streamer=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=86=8C=EB=B9=84=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=E2=80=94=20=EC=A2=8B=EC=95=84=EC=9A=94/?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=A3=BC=EB=AC=B8/=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=8A=A4=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 1 + .../application/outbox/MessageRelay.java | 11 ++- .../application/payment/PaymentFacade.java | 9 ++- .../product/ProductViewEventPublisher.java | 7 +- .../java/com/loopers/config/OutboxConfig.java | 4 +- .../domain/metrics/ProductMetrics.java | 26 +++++++ .../outbox/OutboxRepository.java | 3 +- .../product/ProductRepositoryImpl.java | 45 +++++++++++- .../com/loopers/application/EventHandler.java | 9 +++ .../application/EventProcessingService.java | 39 ++++++++++ .../handler/PaymentCompletedEventHandler.java | 28 +++++++ .../handler/ProductLikedEventHandler.java | 26 +++++++ .../handler/ProductUnlikedEventHandler.java | 26 +++++++ .../handler/ProductViewedEventHandler.java | 26 +++++++ .../java/com/loopers/domain/EventHandled.java | 27 +++++++ .../domain/EventHandledRepository.java | 6 ++ .../com/loopers/domain/ProductMetrics.java | 26 +++++++ .../domain/ProductMetricsRepository.java | 41 +++++++++++ .../consumer/MetricsEventConsumer.java | 43 +++++++++++ .../EventProcessingServiceTest.java | 73 +++++++++++++++++++ .../payload/PaymentCompletedEventPayload.java | 3 + .../com/loopers/confg/kafka/KafkaConfig.java | 18 +++++ 22 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java rename apps/commerce-api/src/main/java/com/loopers/{domain => infrastructure}/outbox/OutboxRepository.java (81%) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 04bc11db4..43be518e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -23,6 +23,7 @@ public class LikeFacade { @Transactional public LikeInfo register(Long userId, Long productId) { + productService.getActiveProduct(productId); LikeInfo like = likeService.register(userId, productId); outboxEventPublisher.publish( EventType.PRODUCT_LIKED, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java index fe19022ad..620345ecb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java @@ -2,9 +2,9 @@ import com.loopers.domain.outbox.Outbox; import com.loopers.domain.outbox.OutboxEvent; -import com.loopers.domain.outbox.OutboxRepository; -import lombok.RequiredArgsConstructor; +import com.loopers.infrastructure.outbox.OutboxRepository; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Pageable; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.scheduling.annotation.Async; @@ -19,12 +19,17 @@ @Slf4j @Component -@RequiredArgsConstructor public class MessageRelay { private final OutboxRepository outboxRepository; private final KafkaTemplate kafkaTemplate; + public MessageRelay(OutboxRepository outboxRepository, + @Qualifier("outboxKafkaTemplate") KafkaTemplate kafkaTemplate) { + this.outboxRepository = outboxRepository; + this.kafkaTemplate = kafkaTemplate; + } + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void createOutbox(OutboxEvent event) { outboxRepository.save(event.getOutbox()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index a0b277ee3..96940ab45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -2,6 +2,7 @@ import com.loopers.application.order.OrderCompensationService; import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; import com.loopers.application.order.OrderService; import com.loopers.application.outbox.OutboxEventPublisher; import com.loopers.domain.order.Order; @@ -17,6 +18,8 @@ import com.loopers.infrastructure.client.PgTransactionStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; + +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -128,9 +131,13 @@ private void applyPgResult(Payment payment, PgTransactionStatus status, String r int affected = paymentRepository.completeIfPending(payment.getId()); if (affected > 0) { orderService.markOrderPaid(payment.getOrderId()); + List productIds = orderService.getOrderItems(payment.getOrderId()).stream() + .map(OrderItemInfo::productId) + .toList(); + outboxEventPublisher.publish( EventType.PAYMENT_COMPLETED, - PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null), + PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds), payment.getOrderId() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java index 7a6095dc7..0d91a10a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java @@ -5,16 +5,19 @@ import com.loopers.event.Snowflake; import com.loopers.event.Topic; import com.loopers.event.payload.ProductViewedEventPayload; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @Slf4j @Component -@RequiredArgsConstructor public class ProductViewEventPublisher { private final KafkaTemplate kafkaTemplate; + + public ProductViewEventPublisher(@Qualifier("outboxKafkaTemplate") KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } private final Snowflake eventIdSnowflake = new Snowflake(); public void publish(Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java index 668f1fae9..12fa27505 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java @@ -31,8 +31,8 @@ public ProducerFactory outboxProducerFactory(KafkaProperties kaf return new DefaultKafkaProducerFactory<>(props); } - @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory outboxProducerFactory) { + @Bean("outboxKafkaTemplate") + public KafkaTemplate outboxKafkaTemplate(ProducerFactory outboxProducerFactory) { return new KafkaTemplate<>(outboxProducerFactory); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..a08e6470e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,26 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + @Id + private Long productId; + + @Column(nullable = false) + private Long likeCount = 0L; + + @Column(nullable = false) + private Long viewCount = 0L; + + @Column(nullable = false) + private Long orderCount = 0L; + + protected ProductMetrics() {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java similarity index 81% rename from apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java index 8299df96a..f9ce838f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepository.java @@ -1,5 +1,6 @@ -package com.loopers.domain.outbox; +package com.loopers.infrastructure.outbox; +import com.loopers.domain.outbox.Outbox; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index cd7fb1710..993aadf4f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -7,6 +7,9 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -21,6 +24,8 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; private final JPAQueryFactory queryFactory; + @PersistenceContext + private EntityManager entityManager; @Override public Product save(Product product) { @@ -34,6 +39,10 @@ public Optional findById(Long id) { @Override public Page findActiveProducts(Long brandId, ProductOrder order, Pageable pageable) { + if (order == ProductOrder.LIKES_DESC) { + return findActiveProductsOrderByLikes(brandId, pageable); + } + QProduct product = QProduct.product; BooleanBuilder builder = new BooleanBuilder(); @@ -46,8 +55,8 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab OrderSpecifier orderSpecifier = switch (order) { case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> product.id.desc(); // TODO: Phase 3에서 product_metrics LEFT JOIN으로 교체 case LATEST -> product.id.desc(); + case LIKES_DESC -> throw new IllegalStateException("LIKES_DESC는 별도 메서드로 처리"); }; List content = queryFactory @@ -67,6 +76,40 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab return new PageImpl<>(content, pageable, total != null ? total : 0); } + private Page findActiveProductsOrderByLikes(Long brandId, Pageable pageable) { + String where = "WHERE p.deleted_at IS NULL AND p.visibility = 'VISIBLE'"; + if (brandId != null) { + where += " AND p.brand_id = :brandId"; + } + + String sql = """ + SELECT p.* FROM product p + LEFT JOIN product_metrics pm ON pm.product_id = p.id + %s + ORDER BY COALESCE(pm.like_count, 0) DESC + LIMIT :limit OFFSET :offset + """.formatted(where); + + String countSql = "SELECT COUNT(*) FROM product p " + where; + + Query query = entityManager.createNativeQuery(sql, Product.class) + .setParameter("limit", pageable.getPageSize()) + .setParameter("offset", (int) pageable.getOffset()); + + Query countQuery = entityManager.createNativeQuery(countSql); + + if (brandId != null) { + query.setParameter("brandId", brandId); + countQuery.setParameter("brandId", brandId); + } + + @SuppressWarnings("unchecked") + List content = query.getResultList(); + long total = ((Number) countQuery.getSingleResult()).longValue(); + + return new PageImpl<>(content, pageable, total); + } + @Override public Page findAllProducts(Long brandId, Pageable pageable) { QProduct product = QProduct.product; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java new file mode 100644 index 000000000..7cd3ce58a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java @@ -0,0 +1,9 @@ +package com.loopers.application; + +import com.loopers.event.Event; +import com.loopers.event.EventPayload; + +public interface EventHandler { + boolean supports(Event event); + void handle(Event event); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java new file mode 100644 index 000000000..25103a602 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java @@ -0,0 +1,39 @@ +package com.loopers.application; + +import com.loopers.domain.EventHandled; +import com.loopers.domain.EventHandledRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Service +public class EventProcessingService { + private final List eventHandlers; + private final EventHandledRepository eventHandledRepository; + + @Transactional + @SuppressWarnings("unchecked") + public void process(Event event) { + try { + eventHandledRepository.save(new EventHandled(event.getEventId(), LocalDateTime.now())); + } catch (DataIntegrityViolationException e) { + log.info("[EventProcessingService] 이미 처리된 이벤트, eventId={}", event.getEventId()); + return; + } + + for (EventHandler handler : eventHandlers) { + if (handler.supports(event)) { + handler.handle(event); + } + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java new file mode 100644 index 000000000..debb349db --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java @@ -0,0 +1,28 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.domain.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.PaymentCompletedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentCompletedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PAYMENT_COMPLETED; + } + + @Override + public void handle(Event event) { + for (Long productId : event.getPayload().getProductIds()) { + productMetricsRepository.incrementOrderCount(productId); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java new file mode 100644 index 000000000..1ff01ab2d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.domain.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductLikedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_LIKED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.incrementLikeCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java new file mode 100644 index 000000000..374e2a879 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.domain.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductUnlikedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductUnlikedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_UNLIKED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.decrementLikeCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java new file mode 100644 index 000000000..c9f5cb4c0 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java @@ -0,0 +1,26 @@ +package com.loopers.application.handler; + +import com.loopers.application.EventHandler; +import com.loopers.domain.ProductMetricsRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductViewedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductViewedEventHandler implements EventHandler { + private final ProductMetricsRepository productMetricsRepository; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_VIEWED; + } + + @Override + public void handle(Event event) { + productMetricsRepository.incrementViewCount(event.getPayload().getProductId()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java new file mode 100644 index 000000000..7aa739165 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandled.java @@ -0,0 +1,27 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "event_handled") +public class EventHandled { + @Id + private Long eventId; + + @Column(nullable = false) + private LocalDateTime handledAt; + + protected EventHandled() {} + + public EventHandled(Long eventId, LocalDateTime handledAt) { + this.eventId = eventId; + this.handledAt = handledAt; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java new file mode 100644 index 000000000..6ca195a14 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java new file mode 100644 index 000000000..8accb3b54 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java @@ -0,0 +1,26 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + @Id + private Long productId; + + @Column(nullable = false) + private Long likeCount = 0L; + + @Column(nullable = false) + private Long viewCount = 0L; + + @Column(nullable = false) + private Long orderCount = 0L; + + protected ProductMetrics() {} +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java new file mode 100644 index 000000000..34d7be202 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java @@ -0,0 +1,41 @@ +package com.loopers.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductMetricsRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 1, 0, 0) + ON DUPLICATE KEY UPDATE like_count = like_count + 1 + """, nativeQuery = true) + void incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 0, 0) + ON DUPLICATE KEY UPDATE like_count = GREATEST(like_count - 1, 0) + """, nativeQuery = true) + void decrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 1, 0) + ON DUPLICATE KEY UPDATE view_count = view_count + 1 + """, nativeQuery = true) + void incrementViewCount(@Param("productId") Long productId); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics (product_id, like_count, view_count, order_count) + VALUES (:productId, 0, 0, 1) + ON DUPLICATE KEY UPDATE order_count = order_count + 1 + """, nativeQuery = true) + void incrementOrderCount(@Param("productId") Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java new file mode 100644 index 000000000..99cde13ad --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.EventProcessingService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.Topic; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MetricsEventConsumer { + private final EventProcessingService eventProcessingService; + + @KafkaListener( + topics = {Topic.CATALOG_EVENTS, Topic.ORDER_EVENTS}, + groupId = "commerce-streamer-metrics", + containerFactory = KafkaConfig.SINGLE_LISTENER + ) + public void listen(String message, Acknowledgment ack) { + try { + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message); + ack.acknowledge(); + return; + } + + eventProcessingService.process(event); + ack.acknowledge(); + } catch (Exception e) { + log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); + ack.nack(Duration.ofSeconds(1)); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java new file mode 100644 index 000000000..b2337f5de --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java @@ -0,0 +1,73 @@ +package com.loopers.application; + +import com.loopers.domain.EventHandledRepository; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.ProductLikedEventPayload; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventProcessingServiceTest { + + private EventHandledRepository eventHandledRepository; + private EventProcessingService eventProcessingService; + private AtomicInteger handleCount; + + @BeforeEach + void setUp() { + eventHandledRepository = mock(EventHandledRepository.class); + handleCount = new AtomicInteger(0); + + EventHandler handler = new EventHandler<>() { + @Override + public boolean supports(Event event) { + return event.getType() == EventType.PRODUCT_LIKED; + } + + @Override + @SuppressWarnings("unchecked") + public void handle(Event event) { + handleCount.incrementAndGet(); + } + }; + + eventProcessingService = new EventProcessingService(List.of(handler), eventHandledRepository); + } + + @DisplayName("이벤트를 처리하면 핸들러가 실행된다") + @Test + void executes_handler_for_matching_event() { + // arrange + Event event = Event.of(1L, EventType.PRODUCT_LIKED, ProductLikedEventPayload.of(100L, 1L)); + + // act + eventProcessingService.process(event); + + // assert + assertThat(handleCount.get()).isEqualTo(1); + verify(eventHandledRepository).save(any()); + } + + @DisplayName("이미 처리된 이벤트는 핸들러를 실행하지 않는다") + @Test + void skips_already_handled_event() { + // arrange + Event event = Event.of(1L, EventType.PRODUCT_LIKED, ProductLikedEventPayload.of(100L, 1L)); + when(eventHandledRepository.save(any())).thenThrow(new DataIntegrityViolationException("duplicate")); + + // act + eventProcessingService.process(event); + + // assert + assertThat(handleCount.get()).isEqualTo(0); + } +} diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java index 8274b6ef0..005838374 100644 --- a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java @@ -5,6 +5,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + @Getter @NoArgsConstructor @AllArgsConstructor(staticName = "of") @@ -12,4 +14,5 @@ public class PaymentCompletedEventPayload implements EventPayload { private Long paymentId; private Long orderId; private Long userId; + private List productIds; } diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..24e49ebfa 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -20,6 +20,7 @@ @Configuration @EnableConfigurationProperties(KafkaProperties.class) public class KafkaConfig { + public static final String SINGLE_LISTENER = "SINGLE_LISTENER_DEFAULT"; public static final String BATCH_LISTENER = "BATCH_LISTENER_DEFAULT"; public static final int MAX_POLLING_SIZE = 3000; // read 3000 msg @@ -51,6 +52,23 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap return new ByteArrayJsonMessageConverter(objectMapper); } + @Bean(name = SINGLE_LISTENER) + public ConcurrentKafkaListenerContainerFactory defaultSingleListenerContainerFactory( + KafkaProperties kafkaProperties + ) { + Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); + consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + org.apache.kafka.common.serialization.StringDeserializer.class); + consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + org.apache.kafka.common.serialization.StringDeserializer.class); + + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + factory.setBatchListener(false); + return factory; + } + @Bean(name = BATCH_LISTENER) public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, From 47d661625ee53cfc2c3714201eba51875d37a5bb Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 08:49:17 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=EC=88=9C=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?ProductMetricsReadModel=20+=20QueryDSL=20left=20join=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rics.java => ProductMetricsReadModel.java} | 14 +++--- .../product/ProductRepositoryImpl.java | 48 ++----------------- 2 files changed, 12 insertions(+), 50 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/domain/metrics/{ProductMetrics.java => ProductMetricsReadModel.java} (62%) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java similarity index 62% rename from apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java rename to apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java index a08e6470e..303095961 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java @@ -5,22 +5,24 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; +import org.hibernate.annotations.Immutable; @Getter @Entity +@Immutable @Table(name = "product_metrics") -public class ProductMetrics { +public class ProductMetricsReadModel { @Id private Long productId; @Column(nullable = false) - private Long likeCount = 0L; + private Long likeCount; @Column(nullable = false) - private Long viewCount = 0L; + private Long viewCount; @Column(nullable = false) - private Long orderCount = 0L; + private Long orderCount; - protected ProductMetrics() {} -} + protected ProductMetricsReadModel() {} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 993aadf4f..56fd4fed6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,5 +1,6 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.metrics.QProductMetricsReadModel; import com.loopers.domain.product.ProductOrder; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -7,9 +8,6 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.impl.JPAQueryFactory; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -24,8 +22,6 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; private final JPAQueryFactory queryFactory; - @PersistenceContext - private EntityManager entityManager; @Override public Product save(Product product) { @@ -39,11 +35,8 @@ public Optional findById(Long id) { @Override public Page findActiveProducts(Long brandId, ProductOrder order, Pageable pageable) { - if (order == ProductOrder.LIKES_DESC) { - return findActiveProductsOrderByLikes(brandId, pageable); - } - QProduct product = QProduct.product; + QProductMetricsReadModel metrics = QProductMetricsReadModel.productMetricsReadModel; BooleanBuilder builder = new BooleanBuilder(); builder.and(product.deletedAt.isNull()); @@ -56,11 +49,12 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab OrderSpecifier orderSpecifier = switch (order) { case PRICE_ASC -> product.price.asc(); case LATEST -> product.id.desc(); - case LIKES_DESC -> throw new IllegalStateException("LIKES_DESC는 별도 메서드로 처리"); + case LIKES_DESC -> metrics.likeCount.coalesce(0L).desc(); }; List content = queryFactory .selectFrom(product) + .leftJoin(metrics).on(metrics.productId.eq(product.id)) .where(builder) .orderBy(orderSpecifier) .offset(pageable.getOffset()) @@ -76,40 +70,6 @@ public Page findActiveProducts(Long brandId, ProductOrder order, Pageab return new PageImpl<>(content, pageable, total != null ? total : 0); } - private Page findActiveProductsOrderByLikes(Long brandId, Pageable pageable) { - String where = "WHERE p.deleted_at IS NULL AND p.visibility = 'VISIBLE'"; - if (brandId != null) { - where += " AND p.brand_id = :brandId"; - } - - String sql = """ - SELECT p.* FROM product p - LEFT JOIN product_metrics pm ON pm.product_id = p.id - %s - ORDER BY COALESCE(pm.like_count, 0) DESC - LIMIT :limit OFFSET :offset - """.formatted(where); - - String countSql = "SELECT COUNT(*) FROM product p " + where; - - Query query = entityManager.createNativeQuery(sql, Product.class) - .setParameter("limit", pageable.getPageSize()) - .setParameter("offset", (int) pageable.getOffset()); - - Query countQuery = entityManager.createNativeQuery(countSql); - - if (brandId != null) { - query.setParameter("brandId", brandId); - countQuery.setParameter("brandId", brandId); - } - - @SuppressWarnings("unchecked") - List content = query.getResultList(); - long total = ((Number) countQuery.getSingleResult()).longValue(); - - return new PageImpl<>(content, pageable, total); - } - @Override public Page findAllProducts(Long brandId, Pageable pageable) { QProduct product = QProduct.product; From c69916529262e55df7b89229fcbf56c5beecffa6 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 08:57:07 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20KafkaTemplate=20=EB=B9=88=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20outboxKafkaTemplate=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=B9=88=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/outbox/MessageRelay.java | 9 ++------- .../product/ProductViewEventPublisher.java | 7 ++----- .../main/java/com/loopers/config/OutboxConfig.java | 4 ++-- .../java/com/loopers/confg/kafka/KafkaConfig.java | 14 ++------------ 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java index 620345ecb..144e1f354 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/MessageRelay.java @@ -3,8 +3,8 @@ import com.loopers.domain.outbox.Outbox; import com.loopers.domain.outbox.OutboxEvent; import com.loopers.infrastructure.outbox.OutboxRepository; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Pageable; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.scheduling.annotation.Async; @@ -19,17 +19,12 @@ @Slf4j @Component +@RequiredArgsConstructor public class MessageRelay { private final OutboxRepository outboxRepository; private final KafkaTemplate kafkaTemplate; - public MessageRelay(OutboxRepository outboxRepository, - @Qualifier("outboxKafkaTemplate") KafkaTemplate kafkaTemplate) { - this.outboxRepository = outboxRepository; - this.kafkaTemplate = kafkaTemplate; - } - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void createOutbox(OutboxEvent event) { outboxRepository.save(event.getOutbox()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java index 0d91a10a5..7a6095dc7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductViewEventPublisher.java @@ -5,19 +5,16 @@ import com.loopers.event.Snowflake; import com.loopers.event.Topic; import com.loopers.event.payload.ProductViewedEventPayload; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @Slf4j @Component +@RequiredArgsConstructor public class ProductViewEventPublisher { private final KafkaTemplate kafkaTemplate; - - public ProductViewEventPublisher(@Qualifier("outboxKafkaTemplate") KafkaTemplate kafkaTemplate) { - this.kafkaTemplate = kafkaTemplate; - } private final Snowflake eventIdSnowflake = new Snowflake(); public void publish(Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java index 12fa27505..668f1fae9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/OutboxConfig.java @@ -31,8 +31,8 @@ public ProducerFactory outboxProducerFactory(KafkaProperties kaf return new DefaultKafkaProducerFactory<>(props); } - @Bean("outboxKafkaTemplate") - public KafkaTemplate outboxKafkaTemplate(ProducerFactory outboxProducerFactory) { + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory outboxProducerFactory) { return new KafkaTemplate<>(outboxProducerFactory); } diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index 24e49ebfa..ce3ccb994 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -8,7 +8,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; -import org.springframework.kafka.core.*; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; @@ -30,23 +31,12 @@ public class KafkaConfig { public static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; // heartbeat interval = 20s ( 1/3 of session_timeout ) public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m - @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { - Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); - return new DefaultKafkaProducerFactory<>(props); - } - @Bean public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); return new DefaultKafkaConsumerFactory<>(props); } - @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { - return new KafkaTemplate<>(producerFactory); - } - @Bean public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMapper) { return new ByteArrayJsonMessageConverter(objectMapper); From dfe093c238bc652f635b10f717454afcb625af41 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 09:08:44 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=20=20refactor:=20KafkaConfig=EC=9D=98=20?= =?UTF-8?q?StringDeserializer=20FQCN=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/confg/kafka/KafkaConfig.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index ce3ccb994..0e1336a43 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -47,10 +48,8 @@ public ConcurrentKafkaListenerContainerFactory defaultSingleList KafkaProperties kafkaProperties ) { Map consumerConfig = new HashMap<>(kafkaProperties.buildConsumerProperties()); - consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, - org.apache.kafka.common.serialization.StringDeserializer.class); - consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, - org.apache.kafka.common.serialization.StringDeserializer.class); + consumerConfig.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerConfig.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); From 07328b60605320979a13340888eeb96652bf8743 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 16:03:30 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EC=84=A0=EC=B0=A9=EC=88=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84(ka?= =?UTF-8?q?fka,=20redis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponIssueCountManager.java | 6 + .../coupon/CouponIssueRequestFacade.java | 62 +++++ .../coupon/CouponIssueRequestInfo.java | 21 ++ .../domain/coupon/CouponIssueRequest.java | 56 +++++ .../coupon/CouponIssueRequestRepository.java | 9 + .../domain/coupon/CouponPromotion.java | 52 +++++ .../coupon/CouponPromotionRepository.java | 7 + .../CouponIssueRequestJpaRepository.java | 8 + .../CouponIssueRequestRepositoryImpl.java | 30 +++ .../coupon/CouponPromotionJpaRepository.java | 10 + .../coupon/CouponPromotionRepositoryImpl.java | 20 ++ .../coupon/RedisCouponIssueCountManager.java | 46 ++++ .../api/coupon/CouponV1Controller.java | 23 ++ .../interfaces/api/coupon/CouponV1Dto.java | 7 + .../coupon/CouponIssueRequestFacadeTest.java | 214 ++++++++++++++++++ .../domain/coupon/CouponIssueRequestTest.java | 71 ++++++ .../domain/coupon/CouponPromotionTest.java | 91 ++++++++ .../InMemoryCouponIssueRequestRepository.java | 45 ++++ .../InMemoryCouponPromotionRepository.java | 33 +++ apps/commerce-streamer/build.gradle.kts | 3 + .../application/CouponIssueProcessor.java | 58 +++++ .../application/EventProcessingService.java | 2 +- .../CouponIssueRequestedEventHandler.java | 32 +++ .../handler/PaymentCompletedEventHandler.java | 2 +- .../handler/ProductLikedEventHandler.java | 2 +- .../handler/ProductUnlikedEventHandler.java | 2 +- .../handler/ProductViewedEventHandler.java | 2 +- .../com/loopers/domain/coupon/Coupon.java | 52 +++++ .../domain/coupon/CouponIssueRequest.java | 68 ++++++ .../coupon/CouponIssueRequestRepository.java | 7 + .../domain/coupon/CouponRepository.java | 7 + .../loopers/domain/coupon/IssuedCoupon.java | 53 +++++ .../domain/coupon/IssuedCouponRepository.java | 6 + .../EventHandledRepository.java | 4 +- .../ProductMetricsRepository.java | 4 +- .../CouponIssueRequestJpaRepository.java | 7 + .../CouponIssueRequestRepositoryImpl.java | 20 ++ .../coupon/CouponJpaRepository.java | 11 + .../coupon/CouponRepositoryImpl.java | 21 ++ .../coupon/IssuedCouponJpaRepository.java | 8 + .../coupon/IssuedCouponRepositoryImpl.java | 23 ++ .../consumer/CouponIssueConsumer.java | 43 ++++ .../consumer/MetricsEventConsumer.java | 2 +- .../application/CouponIssueProcessorTest.java | 118 ++++++++++ .../EventProcessingServiceTest.java | 2 +- 45 files changed, 1361 insertions(+), 9 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java rename apps/commerce-streamer/src/main/java/com/loopers/{domain => infrastructure}/EventHandledRepository.java (66%) rename apps/commerce-streamer/src/main/java/com/loopers/{domain => infrastructure}/ProductMetricsRepository.java (95%) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java new file mode 100644 index 000000000..e9c491eb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueCountManager.java @@ -0,0 +1,6 @@ +package com.loopers.application.coupon; + +public interface CouponIssueCountManager { + long increment(Long couponId); + void decrement(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java new file mode 100644 index 000000000..76f520e39 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestFacade.java @@ -0,0 +1,62 @@ +package com.loopers.application.coupon; + +import com.loopers.application.outbox.OutboxEventPublisher; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.CouponPromotionRepository; +import com.loopers.event.EventType; +import com.loopers.event.payload.CouponIssueRequestedEventPayload; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CouponIssueRequestFacade { + + private final CouponPromotionRepository couponPromotionRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + private final CouponIssueCountManager couponIssueCountManager; + private final OutboxEventPublisher outboxEventPublisher; + + @Transactional + public CouponIssueRequestInfo issueRequest(Long userId, Long couponId) { + CouponPromotion promotion = couponPromotionRepository.findByCouponId(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "선착순 프로모션 대상 쿠폰이 아닙니다.")); + + promotion.validateIssuable(); + + long issuedCount = couponIssueCountManager.increment(couponId); + if (issuedCount > promotion.getMaxQuantity()) { + couponIssueCountManager.decrement(couponId); + throw new CoreException(ErrorType.BAD_REQUEST, "선착순 쿠폰이 모두 소진되었습니다."); + } + + CouponIssueRequest request; + try { + request = couponIssueRequestRepository.save(CouponIssueRequest.create(couponId, userId)); + } catch (DataIntegrityViolationException e) { + couponIssueCountManager.decrement(couponId); + throw new CoreException(ErrorType.CONFLICT, "이미 발급 요청한 쿠폰입니다."); + } + + outboxEventPublisher.publish( + EventType.COUPON_ISSUE_REQUESTED, + CouponIssueRequestedEventPayload.of(request.getId(), couponId, userId), + couponId + ); + + return CouponIssueRequestInfo.from(request); + } + + @Transactional(readOnly = true) + public CouponIssueRequestInfo getIssueRequest(Long userId, Long requestId) { + CouponIssueRequest request = couponIssueRequestRepository.findByIdAndUserId(requestId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "발급 요청을 찾을 수 없습니다.")); + return CouponIssueRequestInfo.from(request); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java new file mode 100644 index 000000000..b972c9a1d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueRequestInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; + +public record CouponIssueRequestInfo( + Long requestId, + Long couponId, + Long userId, + String status, + String reason +) { + public static CouponIssueRequestInfo from(CouponIssueRequest request) { + return new CouponIssueRequestInfo( + request.getId(), + request.getCouponId(), + request.getUserId(), + request.getStatus().name(), + request.getReason() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..ab8659060 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "coupon_issue_requests", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueRequest extends BaseEntity { + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column + private String reason; + + protected CouponIssueRequest() {} + + private CouponIssueRequest(Long couponId, Long userId) { + this.couponId = couponId; + this.userId = userId; + this.status = Status.PENDING; + } + + public static CouponIssueRequest create(Long couponId, Long userId) { + return new CouponIssueRequest(couponId, userId); + } + + public void succeed() { + this.status = Status.SUCCESS; + } + + public void fail(String reason) { + this.status = Status.FAILED; + this.reason = reason; + } + + public enum Status { + PENDING, SUCCESS, FAILED + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..c91359dfc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + CouponIssueRequest save(CouponIssueRequest request); + Optional findById(Long id); + Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java new file mode 100644 index 000000000..d8e14cad5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotion.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupon_promotions") +public class CouponPromotion extends BaseEntity { + + @Column(name = "coupon_id", nullable = false, unique = true) + private Long couponId; + + @Column(name = "max_quantity", nullable = false) + private int maxQuantity; + + @Column(name = "started_at", nullable = false) + private ZonedDateTime startedAt; + + @Column(name = "ended_at", nullable = false) + private ZonedDateTime endedAt; + + protected CouponPromotion() {} + + private CouponPromotion(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) { + this.couponId = couponId; + this.maxQuantity = maxQuantity; + this.startedAt = startedAt; + this.endedAt = endedAt; + } + + public static CouponPromotion create(Long couponId, int maxQuantity, ZonedDateTime startedAt, ZonedDateTime endedAt) { + return new CouponPromotion(couponId, maxQuantity, startedAt, endedAt); + } + + public void validateIssuable() { + ZonedDateTime now = ZonedDateTime.now(); + if (now.isBefore(startedAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "아직 시작되지 않은 프로모션입니다."); + } + if (now.isAfter(endedAt)) { + throw new CoreException(ErrorType.BAD_REQUEST, "종료된 프로모션입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java new file mode 100644 index 000000000..0fc5195ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponPromotionRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponPromotionRepository { + Optional findByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..ef4e7500f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { + java.util.Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..ea677823d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + return couponIssueRequestJpaRepository.save(request); + } + + @Override + public Optional findById(Long id) { + return couponIssueRequestJpaRepository.findById(id); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return couponIssueRequestJpaRepository.findByIdAndUserId(id, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java new file mode 100644 index 000000000..0cfd391a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponPromotion; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponPromotionJpaRepository extends JpaRepository { + Optional findByCouponId(Long couponId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java new file mode 100644 index 000000000..a11836f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponPromotionRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.CouponPromotionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponPromotionRepositoryImpl implements CouponPromotionRepository { + + private final CouponPromotionJpaRepository couponPromotionJpaRepository; + + @Override + public Optional findByCouponId(Long couponId) { + return couponPromotionJpaRepository.findByCouponId(couponId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java new file mode 100644 index 000000000..bca285994 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/RedisCouponIssueCountManager.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.application.coupon.CouponIssueCountManager; +import com.loopers.config.redis.RedisConfig; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisCouponIssueCountManager implements CouponIssueCountManager { + + private static final String KEY_PREFIX = "coupon-promotion:issued-count:"; + + private final RedisTemplate redisTemplate; + + public RedisCouponIssueCountManager( + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate + ) { + this.redisTemplate = redisTemplate; + } + + /** + * 발급 카운트를 1 증가시키고 증가 후 값을 반환한다. + * Redis INCR은 atomic하므로 동시 요청에도 정확한 순서가 보장된다. + */ + @Override + public long increment(Long couponId) { + Long count = redisTemplate.opsForValue().increment(key(couponId)); + if (count == null) { + throw new IllegalStateException("Redis INCR 실패: couponId=" + couponId); + } + return count; + } + + /** + * 컷오프 실패(거절) 시 또는 발급 실패 시 카운트를 되돌린다. + */ + @Override + public void decrement(Long couponId) { + redisTemplate.opsForValue().decrement(key(couponId)); + } + + private String key(Long couponId) { + return KEY_PREFIX + couponId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index b4f106cb8..7c7c913e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -1,11 +1,14 @@ package com.loopers.interfaces.api.coupon; import com.loopers.application.coupon.CouponIssueFacade; +import com.loopers.application.coupon.CouponIssueRequestFacade; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +21,7 @@ public class CouponV1Controller { private final CouponIssueFacade couponIssueFacade; + private final CouponIssueRequestFacade couponIssueRequestFacade; @PostMapping("/{couponId}/issue") @ResponseStatus(HttpStatus.CREATED) @@ -28,4 +32,23 @@ public ApiResponse issueCoupon( IssuedCouponInfo info = couponIssueFacade.issue(userId, couponId); return ApiResponse.success(CouponV1Dto.IssuedCouponResponse.from(info)); } + + @PostMapping("/{couponId}/issue-request") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse issueRequest( + @LoginUser Long userId, + @PathVariable Long couponId + ) { + CouponIssueRequestInfo info = couponIssueRequestFacade.issueRequest(userId, couponId); + return ApiResponse.success(CouponV1Dto.IssueRequestResponse.from(info)); + } + + @GetMapping("/issue-requests/{requestId}") + public ApiResponse getIssueRequest( + @LoginUser Long userId, + @PathVariable Long requestId + ) { + CouponIssueRequestInfo info = couponIssueRequestFacade.getIssueRequest(userId, requestId); + return ApiResponse.success(CouponV1Dto.IssueRequestResponse.from(info)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java index 435ac7edf..7f6cf8af8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.coupon; +import com.loopers.application.coupon.CouponIssueRequestInfo; import com.loopers.application.coupon.IssuedCouponInfo; public class CouponV1Dto { @@ -9,4 +10,10 @@ public static IssuedCouponResponse from(IssuedCouponInfo info) { return new IssuedCouponResponse(info.couponId(), info.status()); } } + + public record IssueRequestResponse(Long requestId, Long couponId, String status) { + public static IssueRequestResponse from(CouponIssueRequestInfo info) { + return new IssueRequestResponse(info.requestId(), info.couponId(), info.status()); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java new file mode 100644 index 000000000..148b657ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponIssueRequestFacadeTest.java @@ -0,0 +1,214 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponPromotion; +import com.loopers.domain.coupon.InMemoryCouponIssueRequestRepository; +import com.loopers.domain.coupon.InMemoryCouponPromotionRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CouponIssueRequestFacadeTest { + + private InMemoryCouponPromotionRepository promotionRepository; + private InMemoryCouponIssueRequestRepository issueRequestRepository; + private FakeCouponIssueCountManager issueCountManager; + private FakeOutboxEventPublisher outboxEventPublisher; + private CouponIssueRequestFacade facade; + + @BeforeEach + void setUp() { + promotionRepository = new InMemoryCouponPromotionRepository(); + issueRequestRepository = new InMemoryCouponIssueRequestRepository(); + issueCountManager = new FakeCouponIssueCountManager(); + outboxEventPublisher = new FakeOutboxEventPublisher(); + facade = new CouponIssueRequestFacade( + promotionRepository, issueRequestRepository, issueCountManager, outboxEventPublisher + ); + } + + @DisplayName("선착순 쿠폰 발급 요청 시,") + @Nested + class IssueRequest { + + @DisplayName("유효한 프로모션이면 PENDING 상태의 요청을 생성하고 이벤트를 발행한다.") + @Test + void createsPendingRequest_whenPromotionIsActive() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + + // act + CouponIssueRequestInfo result = facade.issueRequest(100L, promotion.getCouponId()); + + // assert + assertAll( + () -> assertThat(result.couponId()).isEqualTo(1L), + () -> assertThat(result.userId()).isEqualTo(100L), + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(outboxEventPublisher.getPublishedCount()).isEqualTo(1) + ); + } + + @DisplayName("선착순 프로모션이 아닌 쿠폰이면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenPromotionNotExists() { + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, 999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("아직 시작되지 않은 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionNotStarted() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.plusHours(1), now.plusDays(1)) + ); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 초과되면 BAD_REQUEST 예외가 발생하고 카운터가 되돌려진다.") + @Test + void throwsBadRequest_whenQuantityExceeded() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 2, now.minusHours(1), now.plusDays(1)) + ); + // 이미 2개 발급됨 + issueCountManager.setCount(1L, 2); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST), + () -> assertThat(issueCountManager.getCount(1L)).isEqualTo(2) // 되돌려짐 + ); + } + + @DisplayName("동일 사용자가 중복 요청하면 CONFLICT 예외가 발생하고 카운터가 되돌려진다.") + @Test + void throwsConflict_whenDuplicateRequest() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + facade.issueRequest(100L, promotion.getCouponId()); + + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.issueRequest(100L, promotion.getCouponId())); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT), + () -> assertThat(issueCountManager.getCount(1L)).isEqualTo(1) // 되돌려짐 + ); + } + } + + @DisplayName("발급 요청 조회 시,") + @Nested + class GetIssueRequest { + + @DisplayName("존재하는 요청이면 정보를 반환한다.") + @Test + void returnsInfo_whenRequestExists() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + promotionRepository.save( + CouponPromotion.create(1L, 100, now.minusHours(1), now.plusDays(1)) + ); + CouponIssueRequestInfo created = facade.issueRequest(100L, 1L); + + // act + CouponIssueRequestInfo result = facade.getIssueRequest(100L, created.requestId()); + + // assert + assertAll( + () -> assertThat(result.requestId()).isEqualTo(created.requestId()), + () -> assertThat(result.status()).isEqualTo("PENDING") + ); + } + + @DisplayName("존재하지 않는 요청이면 NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenRequestNotExists() { + // act + CoreException result = assertThrows(CoreException.class, + () -> facade.getIssueRequest(100L, 999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + // === Test Doubles === + + static class FakeCouponIssueCountManager implements CouponIssueCountManager { + private final Map counts = new HashMap<>(); + + @Override + public long increment(Long couponId) { + return counts.merge(couponId, 1L, Long::sum); + } + + @Override + public void decrement(Long couponId) { + counts.computeIfPresent(couponId, (k, v) -> v - 1); + } + + void setCount(Long couponId, long count) { + counts.put(couponId, count); + } + + long getCount(Long couponId) { + return counts.getOrDefault(couponId, 0L); + } + } + + static class FakeOutboxEventPublisher extends com.loopers.application.outbox.OutboxEventPublisher { + private int publishedCount = 0; + + FakeOutboxEventPublisher() { + super(null); + } + + @Override + public void publish(com.loopers.event.EventType type, com.loopers.event.EventPayload payload, Long partitionKey) { + publishedCount++; + } + + int getPublishedCount() { + return publishedCount; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java new file mode 100644 index 000000000..964b18764 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueRequestTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.coupon; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +public class CouponIssueRequestTest { + + @DisplayName("발급 요청 생성 시,") + @Nested + class Create { + + @DisplayName("PENDING 상태로 생성된다.") + @Test + void createsWithPendingStatus() { + // act + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + + // assert + assertAll( + () -> assertThat(request.getCouponId()).isEqualTo(1L), + () -> assertThat(request.getUserId()).isEqualTo(100L), + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.PENDING), + () -> assertThat(request.getReason()).isNull() + ); + } + } + + @DisplayName("발급 성공 시,") + @Nested + class Succeed { + + @DisplayName("SUCCESS 상태로 변경된다.") + @Test + void changesStatusToSuccess() { + // arrange + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + + // act + request.succeed(); + + // assert + assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.SUCCESS); + } + } + + @DisplayName("발급 실패 시,") + @Nested + class Fail { + + @DisplayName("FAILED 상태와 실패 사유가 설정된다.") + @Test + void changesStatusToFailedWithReason() { + // arrange + CouponIssueRequest request = CouponIssueRequest.create(1L, 100L); + String reason = "선착순 쿠폰이 모두 소진되었습니다."; + + // act + request.fail(reason); + + // assert + assertAll( + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.FAILED), + () -> assertThat(request.getReason()).isEqualTo(reason) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java new file mode 100644 index 000000000..e0a3f3a07 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponPromotionTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CouponPromotionTest { + + @DisplayName("프로모션 생성 시,") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면 정상적으로 생성된다.") + @Test + void createsPromotion_whenFieldsAreValid() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + + // act + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusHours(1), now.plusDays(1) + ); + + // assert + assertAll( + () -> assertThat(promotion.getCouponId()).isEqualTo(1L), + () -> assertThat(promotion.getMaxQuantity()).isEqualTo(100), + () -> assertThat(promotion.getStartedAt()).isEqualTo(now.minusHours(1)), + () -> assertThat(promotion.getEndedAt()).isEqualTo(now.plusDays(1)) + ); + } + } + + @DisplayName("발급 가능 여부 검증 시,") + @Nested + class ValidateIssuable { + + @DisplayName("진행 중인 프로모션이면 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenPromotionIsActive() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusHours(1), now.plusDays(1) + ); + + // act & assert + promotion.validateIssuable(); + } + + @DisplayName("아직 시작되지 않은 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionNotStarted() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.plusHours(1), now.plusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, promotion::validateIssuable); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("종료된 프로모션이면 BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPromotionEnded() { + // arrange + ZonedDateTime now = ZonedDateTime.now(); + CouponPromotion promotion = CouponPromotion.create( + 1L, 100, now.minusDays(2), now.minusDays(1) + ); + + // act + CoreException result = assertThrows(CoreException.class, promotion::validateIssuable); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java new file mode 100644 index 000000000..a826e74e2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponIssueRequestRepository.java @@ -0,0 +1,45 @@ +package com.loopers.domain.coupon; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryCouponIssueRequestRepository implements CouponIssueRequestRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public CouponIssueRequest save(CouponIssueRequest request) { + if (request.getId() == 0L) { + boolean duplicate = store.values().stream() + .anyMatch(r -> r.getUserId().equals(request.getUserId()) + && r.getCouponId().equals(request.getCouponId())); + if (duplicate) { + throw new org.springframework.dao.DataIntegrityViolationException( + "UNIQUE constraint violated: (user_id, coupon_id)"); + } + + try { + var idField = request.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(request, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(request.getId(), request); + return request; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return findById(id).filter(request -> request.getUserId().equals(userId)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java new file mode 100644 index 000000000..daaef6561 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/InMemoryCouponPromotionRepository.java @@ -0,0 +1,33 @@ +package com.loopers.domain.coupon; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class InMemoryCouponPromotionRepository implements CouponPromotionRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + public CouponPromotion save(CouponPromotion promotion) { + if (promotion.getId() == 0L) { + try { + var idField = promotion.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(promotion, idGenerator.getAndIncrement()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + store.put(promotion.getId(), promotion); + return promotion; + } + + @Override + public Optional findByCouponId(Long couponId) { + return store.values().stream() + .filter(p -> p.getCouponId().equals(couponId)) + .findFirst(); + } +} diff --git a/apps/commerce-streamer/build.gradle.kts b/apps/commerce-streamer/build.gradle.kts index 55a8304d9..30b26e409 100644 --- a/apps/commerce-streamer/build.gradle.kts +++ b/apps/commerce-streamer/build.gradle.kts @@ -21,4 +21,7 @@ dependencies { testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) testImplementation(testFixtures(project(":modules:kafka"))) + + // kafka test + testImplementation("org.springframework.kafka:spring-kafka-test") } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java new file mode 100644 index 000000000..f55a603ae --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/CouponIssueProcessor.java @@ -0,0 +1,58 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class CouponIssueProcessor { + + private final CouponRepository couponRepository; + private final IssuedCouponRepository issuedCouponRepository; + private final CouponIssueRequestRepository couponIssueRequestRepository; + + @Transactional + public void process(Long requestId, Long couponId, Long userId) { + CouponIssueRequest request = couponIssueRequestRepository.findById(requestId).orElse(null); + if (request == null) { + log.warn("[CouponIssueProcessor] 발급 요청을 찾을 수 없음, requestId={}", requestId); + return; + } + + if (!request.isPending()) { + log.info("[CouponIssueProcessor] 이미 처리된 요청, requestId={}", requestId); + return; + } + + if (issuedCouponRepository.existsByUserIdAndCouponId(userId, couponId)) { + request.fail("이미 발급된 쿠폰입니다."); + return; + } + + Coupon coupon = couponRepository.findIssuableCoupon(couponId).orElse(null); + if (coupon == null) { + request.fail("발급 가능한 쿠폰이 아닙니다."); + return; + } + + try { + issuedCouponRepository.save(IssuedCoupon.create(userId, couponId, coupon.getExpiresAt())); + } catch (DataIntegrityViolationException e) { + log.warn("[CouponIssueProcessor] 중복 발급 감지, requestId={}, couponId={}, userId={}", requestId, couponId, userId); + request.fail("이미 발급된 쿠폰입니다."); + return; + } + + request.succeed(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java index 25103a602..ac085d913 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java @@ -1,7 +1,7 @@ package com.loopers.application; import com.loopers.domain.EventHandled; -import com.loopers.domain.EventHandledRepository; +import com.loopers.infrastructure.EventHandledRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java new file mode 100644 index 000000000..507f07608 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/CouponIssueRequestedEventHandler.java @@ -0,0 +1,32 @@ +package com.loopers.application.handler; + +import com.loopers.application.CouponIssueProcessor; +import com.loopers.application.EventHandler; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.EventType; +import com.loopers.event.payload.CouponIssueRequestedEventPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CouponIssueRequestedEventHandler implements EventHandler { + + private final CouponIssueProcessor couponIssueProcessor; + + @Override + public boolean supports(Event event) { + return event.getType() == EventType.COUPON_ISSUE_REQUESTED; + } + + @Override + public void handle(Event event) { + CouponIssueRequestedEventPayload payload = event.getPayload(); + couponIssueProcessor.process( + payload.getCouponIssueRequestId(), + payload.getCouponId(), + payload.getUserId() + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java index debb349db..eb0bbf6b2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java @@ -1,7 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; -import com.loopers.domain.ProductMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java index 1ff01ab2d..e61854bfb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java @@ -1,7 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; -import com.loopers.domain.ProductMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java index 374e2a879..bd46d3a40 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java @@ -1,7 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; -import com.loopers.domain.ProductMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java index c9f5cb4c0..b3e3f2beb 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java @@ -1,7 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; -import com.loopers.domain.ProductMetricsRepository; +import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..5f34439cd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupons") +public class Coupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String discountType; + + @Column(nullable = false) + private Long discountValue; + + @Column(nullable = false) + private Long minOrderAmount; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private ZonedDateTime createdAt; + + @Column(nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected Coupon() {} + + public boolean isIssuable() { + return deletedAt == null && expiresAt.isAfter(LocalDateTime.now()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java new file mode 100644 index 000000000..57d8a7af1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java @@ -0,0 +1,68 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "coupon_issue_requests", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + + @Column + private String reason; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected CouponIssueRequest() {} + + public boolean isPending() { + return status == Status.PENDING; + } + + public void succeed() { + this.status = Status.SUCCESS; + this.reason = null; + } + + public void fail(String reason) { + this.status = Status.FAILED; + this.reason = reason; + } + + public enum Status { + PENDING, SUCCESS, FAILED + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java new file mode 100644 index 000000000..3c9082cf5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponIssueRequestRepository { + Optional findById(Long id); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..e8e7ed037 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponRepository { + Optional findIssuableCoupon(Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java new file mode 100644 index 000000000..f54810f02 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCoupon.java @@ -0,0 +1,53 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "issued_coupons", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class IssuedCoupon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + private ZonedDateTime createdAt; + + @Column(nullable = false) + private ZonedDateTime updatedAt; + + protected IssuedCoupon() {} + + public static IssuedCoupon create(Long userId, Long couponId, LocalDateTime expiresAt) { + IssuedCoupon issuedCoupon = new IssuedCoupon(); + issuedCoupon.userId = userId; + issuedCoupon.couponId = couponId; + issuedCoupon.expiresAt = expiresAt; + ZonedDateTime now = ZonedDateTime.now(); + issuedCoupon.createdAt = now; + issuedCoupon.updatedAt = now; + return issuedCoupon; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java new file mode 100644 index 000000000..6915ee8e3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/IssuedCouponRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.coupon; + +public interface IssuedCouponRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); + IssuedCoupon save(IssuedCoupon issuedCoupon); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java similarity index 66% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java index 6ca195a14..7c98bc42b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/EventHandledRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java @@ -1,4 +1,6 @@ -package com.loopers.domain; +package com.loopers.infrastructure; + +import com.loopers.domain.EventHandled; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java similarity index 95% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java rename to apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java index 34d7be202..e98b54be6 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -1,4 +1,6 @@ -package com.loopers.domain; +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetrics; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java new file mode 100644 index 000000000..0b1c9918f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponIssueRequestJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java new file mode 100644 index 000000000..2ea79787c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponIssueRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponIssueRequestRepositoryImpl implements CouponIssueRequestRepository { + + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @Override + public Optional findById(Long id) { + return couponIssueRequestJpaRepository.findById(id); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..77f6bdec2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface CouponJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNullAndExpiresAtAfter(Long id, LocalDateTime now); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..05a31d550 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Optional findIssuableCoupon(Long couponId) { + return couponJpaRepository.findByIdAndDeletedAtIsNullAndExpiresAtAfter(couponId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java new file mode 100644 index 000000000..81c49032d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IssuedCouponJpaRepository extends JpaRepository { + boolean existsByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java new file mode 100644 index 000000000..4698fad4f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponRepositoryImpl.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.domain.coupon.IssuedCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class IssuedCouponRepositoryImpl implements IssuedCouponRepository { + + private final IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Override + public boolean existsByUserIdAndCouponId(Long userId, Long couponId) { + return issuedCouponJpaRepository.existsByUserIdAndCouponId(userId, couponId); + } + + @Override + public IssuedCoupon save(IssuedCoupon issuedCoupon) { + return issuedCouponJpaRepository.save(issuedCoupon); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..ecbfc558d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.application.EventProcessingService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.event.Event; +import com.loopers.event.EventPayload; +import com.loopers.event.Topic; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponIssueConsumer { + private final EventProcessingService eventProcessingService; + + @KafkaListener( + topics = Topic.COUPON_ISSUE_REQUESTS, + groupId = "commerce-streamer-coupon-issue", + containerFactory = KafkaConfig.SINGLE_LISTENER + ) + public void consume(String message, Acknowledgment ack) { + try { + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message); + ack.acknowledge(); + return; + } + + eventProcessingService.process(event); + ack.acknowledge(); + } catch (Exception e) { + log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); + ack.nack(Duration.ofSeconds(1)); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java index 99cde13ad..5d5efcaef 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -24,7 +24,7 @@ public class MetricsEventConsumer { groupId = "commerce-streamer-metrics", containerFactory = KafkaConfig.SINGLE_LISTENER ) - public void listen(String message, Acknowledgment ack) { + public void consume(String message, Acknowledgment ack) { try { Event event = Event.fromJson(message); if (event == null) { diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java new file mode 100644 index 000000000..aa90bde03 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/CouponIssueProcessorTest.java @@ -0,0 +1,118 @@ +package com.loopers.application; + +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.IssuedCoupon; +import com.loopers.infrastructure.coupon.CouponIssueRequestJpaRepository; +import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@EmbeddedKafka( + partitions = 1, + brokerProperties = {"listeners=PLAINTEXT://localhost:0"}, + topics = {"catalog-events", "order-events", "coupon-issue-requests"} +) +class CouponIssueProcessorTest { + + @Autowired + private CouponIssueProcessor couponIssueProcessor; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private IssuedCouponJpaRepository issuedCouponJpaRepository; + + @Autowired + private CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("유효한 발급 요청이면 issued_coupon을 생성하고 요청 상태를 SUCCESS로 변경한다.") + @Test + void processesIssueRequest() { + // arrange + Long couponId = insertCoupon(); + Long requestId = insertPendingRequest(couponId, 1L); + + // act + couponIssueProcessor.process(requestId, couponId, 1L); + + // assert + CouponIssueRequest request = couponIssueRequestJpaRepository.findById(requestId).orElseThrow(); + assertAll( + () -> assertThat(issuedCouponJpaRepository.existsByUserIdAndCouponId(1L, couponId)).isTrue(), + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.SUCCESS), + () -> assertThat(request.getReason()).isNull() + ); + } + + @DisplayName("이미 발급된 쿠폰이면 요청 상태를 FAILED로 변경한다.") + @Test + void failsWhenCouponAlreadyIssued() { + // arrange + Long couponId = insertCoupon(); + insertIssuedCoupon(couponId, 1L); + Long requestId = insertPendingRequest(couponId, 1L); + + // act + couponIssueProcessor.process(requestId, couponId, 1L); + + // assert + CouponIssueRequest request = couponIssueRequestJpaRepository.findById(requestId).orElseThrow(); + assertAll( + () -> assertThat(request.getStatus()).isEqualTo(CouponIssueRequest.Status.FAILED), + () -> assertThat(request.getReason()).isEqualTo("이미 발급된 쿠폰입니다.") + ); + } + + private Long insertCoupon() { + return transactionTemplate.execute(status -> { + entityManager.createNativeQuery(""" + INSERT INTO coupons (name, discount_type, discount_value, min_order_amount, expires_at, created_at, updated_at) + VALUES ('이벤트 쿠폰', 'FIXED', 1000, 1000, DATE_ADD(NOW(), INTERVAL 30 DAY), NOW(6), NOW(6)) + """) + .executeUpdate(); + return ((Number) entityManager.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + }); + } + + private Long insertPendingRequest(Long couponId, Long userId) { + return transactionTemplate.execute(status -> { + entityManager.createNativeQuery(""" + INSERT INTO coupon_issue_requests (coupon_id, user_id, status, created_at, updated_at) + VALUES (:couponId, :userId, 'PENDING', NOW(6), NOW(6)) + """) + .setParameter("couponId", couponId) + .setParameter("userId", userId) + .executeUpdate(); + return ((Number) entityManager.createNativeQuery("SELECT LAST_INSERT_ID()").getSingleResult()).longValue(); + }); + } + + private void insertIssuedCoupon(Long couponId, Long userId) { + issuedCouponJpaRepository.save(IssuedCoupon.create(userId, couponId, LocalDateTime.now().plusDays(30))); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java index b2337f5de..1ea8b13bb 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/EventProcessingServiceTest.java @@ -1,6 +1,6 @@ package com.loopers.application; -import com.loopers.domain.EventHandledRepository; +import com.loopers.infrastructure.EventHandledRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; From 5586b487c6e73f2fb17722c43eb69b29e2ab44e7 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 16:09:59 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EC=84=A0=EC=B0=A9=EC=88=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=8F=B0=20=EB=8F=99=EC=8B=9C=EC=84=B1=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/ConcurrencyE2ETest.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java index a82f6b26b..7b01f2849 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ConcurrencyE2ETest.java @@ -1,25 +1,32 @@ package com.loopers.interfaces.api; +import com.loopers.config.redis.RedisConfig; import com.loopers.domain.brand.Brand; import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponPromotion; import com.loopers.domain.coupon.IssuedCoupon; import com.loopers.domain.product.Product; import com.loopers.domain.user.User; import com.loopers.domain.user.UserFixture; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.coupon.CouponIssueRequestJpaRepository; import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.infrastructure.coupon.CouponPromotionJpaRepository; import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.coupon.CouponV1Dto; import com.loopers.interfaces.api.order.OrderV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -28,6 +35,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -50,7 +58,10 @@ class ConcurrencyE2ETest { private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; private final CouponJpaRepository couponJpaRepository; + private final CouponPromotionJpaRepository couponPromotionJpaRepository; + private final CouponIssueRequestJpaRepository couponIssueRequestJpaRepository; private final IssuedCouponJpaRepository issuedCouponJpaRepository; + private final RedisTemplate redisTemplate; private final DatabaseCleanUp databaseCleanUp; private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); @@ -61,7 +72,10 @@ public ConcurrencyE2ETest( BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, CouponJpaRepository couponJpaRepository, + CouponPromotionJpaRepository couponPromotionJpaRepository, + CouponIssueRequestJpaRepository couponIssueRequestJpaRepository, IssuedCouponJpaRepository issuedCouponJpaRepository, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; @@ -69,13 +83,17 @@ public ConcurrencyE2ETest( this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; this.couponJpaRepository = couponJpaRepository; + this.couponPromotionJpaRepository = couponPromotionJpaRepository; + this.couponIssueRequestJpaRepository = couponIssueRequestJpaRepository; this.issuedCouponJpaRepository = issuedCouponJpaRepository; + this.redisTemplate = redisTemplate; this.databaseCleanUp = databaseCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisTemplate.delete(redisTemplate.keys("coupon-promotion:issued-count:*")); } private HttpHeaders headersFor(String loginId) { @@ -146,6 +164,66 @@ private HttpHeaders headersFor(String loginId) { assertThat(finalProduct.getStockQuantity()).isEqualTo(0); } + @DisplayName("선착순 쿠폰 N장에 M명(M>N)이 동시 요청하면, 정확히 N건만 ACCEPTED되고 나머지는 거절된다.") + @Test + void 선착순_쿠폰_동시_발급_요청_테스트() throws InterruptedException { + // arrange + int maxQuantity = 5; + int threadCount = 20; + + String encodedPassword = bCryptPasswordEncoder.encode(RAW_PASSWORD); + Coupon coupon = couponJpaRepository.save( + Coupon.create("선착순 쿠폰", Coupon.DiscountType.FIXED, 1000L, 1000L, LocalDateTime.now().plusDays(30))); + couponPromotionJpaRepository.save( + CouponPromotion.create(coupon.getId(), maxQuantity, ZonedDateTime.now().minusHours(1), ZonedDateTime.now().plusDays(1))); + + List users = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + users.add(userJpaRepository.save( + UserFixture.builder().loginId("flashUser" + i).password(encodedPassword).build())); + } + + String endpoint = "/api/v1/coupons/" + coupon.getId() + "/issue-request"; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger acceptedCount = new AtomicInteger(0); + AtomicInteger rejectedCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + final User user = users.get(i); + executor.submit(() -> { + try { + startLatch.await(); + HttpEntity entity = new HttpEntity<>(headersFor(user.getLoginId())); + ResponseEntity> response = + testRestTemplate.exchange(endpoint, HttpMethod.POST, entity, new ParameterizedTypeReference<>() {}); + if (response.getStatusCode() == HttpStatus.ACCEPTED) { + acceptedCount.incrementAndGet(); + } else { + rejectedCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + // act + startLatch.countDown(); + boolean completed = doneLatch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + // assert + long issueRequestCount = couponIssueRequestJpaRepository.count(); + assertThat(completed).isTrue(); + assertThat(acceptedCount.get()).isEqualTo(maxQuantity); + assertThat(rejectedCount.get()).isEqualTo(threadCount - maxQuantity); + assertThat(issueRequestCount).isEqualTo(maxQuantity); + } + @DisplayName("동일 쿠폰으로 N번 동시 주문하면, 정확히 1건만 성공하고 쿠폰은 사용 처리된다.") @Test void 쿠폰_동시_사용_테스트() throws InterruptedException { From fd754ee3c6d7088470b62b64bfdff55951302c8a Mon Sep 17 00:00:00 2001 From: hey-sion Date: Fri, 27 Mar 2026 16:34:50 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/UserBehaviorLoggingListener.java | 22 +++++++++++++++++++ modules/kafka/src/main/resources/kafka.yml | 3 +++ 2 files changed, 25 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java b/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java new file mode 100644 index 000000000..275cd1880 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/UserBehaviorLoggingListener.java @@ -0,0 +1,22 @@ +package com.loopers.application.event; + +import com.loopers.domain.outbox.Outbox; +import com.loopers.domain.outbox.OutboxEvent; +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; + +@Slf4j +@Component +public class UserBehaviorLoggingListener { + + @Async("outboxPublishExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOutboxEvent(OutboxEvent event) { + Outbox outbox = event.getOutbox(); + log.info("[UserBehavior] type={}, partitionKey={}, payload={}", + outbox.getEventType(), outbox.getPartitionKey(), outbox.getPayload()); + } +} diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..59b3c91e8 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -12,9 +12,12 @@ spring: offset.reset: latest use.latest.version: true producer: + acks: all key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + enable.idempotence: true consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer From fefa12800c24b5b0d9041c55f005432a8cfae7ff Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 31 Mar 2026 09:52:44 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20Kafka=20Consumer=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20DefaultErrorHandler=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/CouponIssueConsumer.java | 21 +++++++------------ .../consumer/MetricsEventConsumer.java | 21 +++++++------------ .../com/loopers/confg/kafka/KafkaConfig.java | 6 ++++++ 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java index ecbfc558d..513e0b45a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -11,8 +11,6 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; -import java.time.Duration; - @Slf4j @Component @RequiredArgsConstructor @@ -25,19 +23,14 @@ public class CouponIssueConsumer { containerFactory = KafkaConfig.SINGLE_LISTENER ) public void consume(String message, Acknowledgment ack) { - try { - Event event = Event.fromJson(message); - if (event == null) { - log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message); - ack.acknowledge(); - return; - } - - eventProcessingService.process(event); + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[CouponIssueConsumer] 이벤트 파싱 실패, message={}", message); ack.acknowledge(); - } catch (Exception e) { - log.error("[CouponIssueConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); - ack.nack(Duration.ofSeconds(1)); + return; } + + eventProcessingService.process(event); + ack.acknowledge(); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java index 5d5efcaef..b19af8ce7 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -11,8 +11,6 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; -import java.time.Duration; - @Slf4j @Component @RequiredArgsConstructor @@ -25,19 +23,14 @@ public class MetricsEventConsumer { containerFactory = KafkaConfig.SINGLE_LISTENER ) public void consume(String message, Acknowledgment ack) { - try { - Event event = Event.fromJson(message); - if (event == null) { - log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message); - ack.acknowledge(); - return; - } - - eventProcessingService.process(event); + Event event = Event.fromJson(message); + if (event == null) { + log.warn("[MetricsEventConsumer] 이벤트 파싱 실패, message={}", message); ack.acknowledge(); - } catch (Exception e) { - log.error("[MetricsEventConsumer] 이벤트 처리 실패 — nack 후 재처리 대기, message={}", message, e); - ack.nack(Duration.ofSeconds(1)); + return; } + + eventProcessingService.process(event); + ack.acknowledge(); } } diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index 0e1336a43..1878b9e7a 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -12,8 +12,10 @@ import org.springframework.kafka.core.ConsumerFactory; import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.DefaultErrorHandler; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; import java.util.Map; @@ -32,6 +34,9 @@ public class KafkaConfig { public static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; // heartbeat interval = 20s ( 1/3 of session_timeout ) public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m + private static final long RETRY_INTERVAL_MS = 1000L; + private static final long MAX_RETRY_ATTEMPTS = 3L; + @Bean public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); @@ -55,6 +60,7 @@ public ConcurrentKafkaListenerContainerFactory defaultSingleList factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); factory.setBatchListener(false); + factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(RETRY_INTERVAL_MS, MAX_RETRY_ATTEMPTS))); return factory; }