From bd810f23fa3ca8e051fa946d3befb0388dc25c54 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Tue, 16 Dec 2025 09:28:14 +0900
Subject: [PATCH 01/28] =?UTF-8?q?feat(config):=20Kafka=20=EC=84=A4?=
 =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC?=
 =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=ED=8F=AC=ED=8A=B8=20=EC=A7=80=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/commerce-api/build.gradle.kts             |  2 ++
 apps/commerce-api/docker-compose.yml           | 18 ------------------
 .../src/main/resources/application.yml         |  1 +
 .../src/main/resources/application.yml         |  5 +++++
 4 files changed, 8 insertions(+), 18 deletions(-)
 delete mode 100644 apps/commerce-api/docker-compose.yml

diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 53feb3cfa..7848bd950 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -2,6 +2,7 @@ dependencies {
     // add-ons
     implementation(project(":modules:jpa"))
     implementation(project(":modules:redis"))
+    implementation(project(":modules:kafka"))
     implementation(project(":supports:jackson"))
     implementation(project(":supports:logging"))
     implementation(project(":supports:monitoring"))
@@ -25,6 +26,7 @@ dependencies {
     // test-fixtures
     testImplementation(testFixtures(project(":modules:jpa")))
     testImplementation(testFixtures(project(":modules:redis")))
+    testImplementation(testFixtures(project(":modules:kafka")))
 
     // awaitility (비동기 테스트)
     testImplementation("org.awaitility:awaitility:4.2.0")
diff --git a/apps/commerce-api/docker-compose.yml b/apps/commerce-api/docker-compose.yml
deleted file mode 100644
index 0e1310f0b..000000000
--- a/apps/commerce-api/docker-compose.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-version: '3.8'
-services:
-  app:
-    build: .
-    ports:
-      - "8080:8080"
-    environment:
-      - SPRING_PROFILES_ACTIVE=local
-      - product.data.generate=true
-    deploy:
-      resources:
-        limits:
-          cpus: '0.5'
-          memory: 512M
-        reservations:
-          cpus: '0.25'
-          memory: 256M
-    restart: unless-stopped
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index a49d7c762..2461727d4 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -21,6 +21,7 @@ spring:
     import:
       - jpa.yml
       - redis.yml
+      - kafka.yml
       - logging.yml
       - monitoring.yml
   cloud:
diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml
index 0651bc2bd..4a5e2af8a 100644
--- a/apps/commerce-streamer/src/main/resources/application.yml
+++ b/apps/commerce-streamer/src/main/resources/application.yml
@@ -9,6 +9,11 @@ server:
     accept-count: 100 # 대기 큐 크기 (default : 100)
     keep-alive-timeout: 60s # 60s
   max-http-request-header-size: 8KB
+  port: 8090
+
+management:
+  server:
+    port: 8091
 
 spring:
   main:

From d72fec0c360a1b5e5ca47a9879d6abc6dfd3fd23 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Tue, 16 Dec 2025 09:28:26 +0900
Subject: [PATCH 02/28] =?UTF-8?q?feat(event):=20=EC=9D=B4=EB=B2=A4?=
 =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=A9=94=ED=8A=B8?=
 =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 이벤트 처리 엔티티 및 JPA 리포지토리 구현
- 제품 메트릭스 엔티티 및 JPA 리포지토리 추가
- 메트릭스 서비스 및 이벤트 핸들러 클래스 생성
---
 .../metrics/MetricsEventHandler.java          |  9 ++++
 .../com/loopers/domain/event/EventEntity.java | 38 ++++++++++++++++
 .../loopers/domain/event/EventRepository.java |  9 ++++
 .../domain/metrics/MetricsService.java        | 15 +++++++
 .../domain/metrics/ProductMetricsEntity.java  | 43 +++++++++++++++++++
 .../metrics/ProductMetricsRepository.java     |  9 ++++
 .../event/EventJpaRepository.java             | 13 ++++++
 .../event/EventRepositoryImpl.java            | 14 ++++++
 .../metrics/ProductMetricsJpaRepository.java  | 13 ++++++
 .../metrics/ProductMetricsRepositoryImpl.java | 17 ++++++++
 10 files changed, 180 insertions(+)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventEntity.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
new file mode 100644
index 000000000..473dcd411
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
@@ -0,0 +1,9 @@
+package com.loopers.application.metrics;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public class MetricsEventHandler {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventEntity.java
new file mode 100644
index 000000000..f780d0255
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventEntity.java
@@ -0,0 +1,38 @@
+package com.loopers.domain.event;
+
+import java.time.ZonedDateTime;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+@Entity
+@Table(name = "event_handled")
+@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@Getter
+public class EventEntity {
+    @Id
+    @Column(name = "event_id", length = 36, nullable = false)
+    private String eventId;
+
+    @Column(name = "handled_at", nullable = false)
+    private ZonedDateTime handledAt;
+
+    private EventEntity(final String eventId, final ZonedDateTime handledAt) {
+        this.eventId = eventId;
+        this.handledAt = handledAt;
+    }
+
+    public static EventEntity create(final String eventId) {
+        return new EventEntity(eventId, ZonedDateTime.now());
+    }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
new file mode 100644
index 000000000..a913f7339
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
@@ -0,0 +1,9 @@
+package com.loopers.domain.event;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public interface EventRepository {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
new file mode 100644
index 000000000..36ee8d1ad
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.metrics;
+
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+@Component
+@RequiredArgsConstructor
+public class MetricsService {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
new file mode 100644
index 000000000..6a1c152e2
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -0,0 +1,43 @@
+package com.loopers.domain.metrics;
+
+import java.time.ZonedDateTime;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+
+@Entity
+@Getter
+@Table(name = "product_metrics")
+@AllArgsConstructor
+@NoArgsConstructor
+public class ProductMetricsEntity {
+    @Id
+    @Column(name = "product_id", nullable = false)
+    private Long id;
+
+    @Column(name = "view_count", nullable = false)
+    private long viewCount;
+
+    @Column(name = "like_count", nullable = false)
+    private long likeCount;
+
+    @Column(name = "sales_count", nullable = false)
+    private long salesCount;
+
+    @Column(name = "last_event_at")
+    private ZonedDateTime lastEventAt;
+
+
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
new file mode 100644
index 000000000..f3a80e63e
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -0,0 +1,9 @@
+package com.loopers.domain.metrics;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public interface ProductMetricsRepository {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
new file mode 100644
index 000000000..c14780ed7
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.event;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.loopers.domain.event.EventRepository;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public interface EventJpaRepository extends JpaRepository<EventRepository, Long> {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
new file mode 100644
index 000000000..a3faf3139
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
@@ -0,0 +1,14 @@
+package com.loopers.infrastructure.event;
+
+import org.w3c.dom.events.Event;
+
+import com.loopers.domain.event.EventRepository;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public class EventRepositoryImpl implements EventRepository {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
new file mode 100644
index 000000000..6615061fa
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.metrics;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.loopers.domain.metrics.ProductMetricsEntity;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public interface ProductMetricsJpaRepository extends JpaRepository<ProductMetricsEntity, Long> {
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
new file mode 100644
index 000000000..ae668070f
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -0,0 +1,17 @@
+package com.loopers.infrastructure.metrics;
+
+import org.springframework.stereotype.Component;
+
+import com.loopers.domain.metrics.ProductMetricsRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+@Component
+@RequiredArgsConstructor
+public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+}

From 84c9ee0d158935f01e66e72a6ab62965e571cd59 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Tue, 16 Dec 2025 18:51:53 +0900
Subject: [PATCH 03/28] =?UTF-8?q?feat(metrics):=20=EB=A9=94=ED=8A=B8?=
 =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Kafka를 통한 이벤트 수신 및 처리 로직 구현
- 상품 조회 및 좋아요 수 증가 기능 추가
- 판매 수 증가 기능 추가
- 이벤트 처리 로직 개선 및 데이터베이스 연동
---
 .../loopers/CommerceStreamerApplication.java  |   5 +-
 .../loopers/domain/event/EventRepository.java |   1 +
 .../domain/metrics/MetricsService.java        |  46 +++++
 .../domain/metrics/ProductMetricsEntity.java  |  36 +++-
 .../metrics/ProductMetricsRepository.java     |   5 +
 .../event/EventJpaRepository.java             |   4 +-
 .../event/EventRepositoryImpl.java            |  14 +-
 .../metrics/ProductMetricsRepositoryImpl.java |  14 ++
 .../consumer/DemoKafkaConsumer.java           |  15 +-
 .../consumer/MetricsKafkaConsumer.java        | 157 ++++++++++++++++++
 10 files changed, 281 insertions(+), 16 deletions(-)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
index ea4b4d15a..4ef7cfc68 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
@@ -1,11 +1,12 @@
 package com.loopers;
 
-import jakarta.annotation.PostConstruct;
+import java.util.TimeZone;
+
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
 
-import java.util.TimeZone;
+import jakarta.annotation.PostConstruct;
 
 @ConfigurationPropertiesScan
 @SpringBootApplication
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
index a913f7339..01d8a9993 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
@@ -6,4 +6,5 @@
  * @since 2025. 12. 16.
  */
 public interface EventRepository {
+    EventEntity save(EventEntity eventEntity);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 36ee8d1ad..b537857a8 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -1,9 +1,17 @@
 package com.loopers.domain.metrics;
 
+import java.util.Optional;
+
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Component;
 
+import com.loopers.domain.event.EventEntity;
+import com.loopers.domain.event.EventRepository;
+
 import lombok.RequiredArgsConstructor;
 
+import jakarta.transaction.Transactional;
+
 /**
  *
  * @author hyunjikoh
@@ -12,4 +20,42 @@
 @Component
 @RequiredArgsConstructor
 public class MetricsService {
+    private final EventRepository eventHandledRepository;
+    private final ProductMetricsRepository productMetricsRepository;
+
+    @Transactional
+    public boolean tryMarkHandled(String eventId) {
+        try {
+            eventHandledRepository.save(EventEntity.create(eventId));
+            return true;
+        } catch (DataIntegrityViolationException e) {
+            return false; // 이미 처리됨
+        }
+    }
+
+    @Transactional
+    public void incrementView(Long productId) {
+        final ProductMetricsEntity metrics = getOrCreate(productId);
+        metrics.incrementView();
+        productMetricsRepository.save(metrics);
+    }
+
+    @Transactional
+    public void applyLikeDelta(final Long productId, final int delta) {
+        final ProductMetricsEntity metrics = getOrCreate(productId);
+        metrics.applyLikeDelta(delta);
+        productMetricsRepository.save(metrics);
+    }
+
+    @Transactional
+    public void addSales(final Long productId, final int quantity) {
+        final ProductMetricsEntity metrics = getOrCreate(productId);
+        metrics.addSales(quantity);
+        productMetricsRepository.save(metrics);
+    }
+
+    private ProductMetricsEntity getOrCreate(final Long productId) {
+        final Optional<ProductMetricsEntity> found = productMetricsRepository.findById(productId);
+        return found.orElseGet(() -> ProductMetricsEntity.create(productId));
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
index 6a1c152e2..23e3a626c 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -28,16 +28,46 @@ public class ProductMetricsEntity {
     private Long id;
 
     @Column(name = "view_count", nullable = false)
-    private long viewCount;
+    private long viewCount = 0L;
 
     @Column(name = "like_count", nullable = false)
-    private long likeCount;
+    private long likeCount = 0L;
 
     @Column(name = "sales_count", nullable = false)
-    private long salesCount;
+    private long salesCount = 0L;
 
     @Column(name = "last_event_at")
     private ZonedDateTime lastEventAt;
 
 
+    private ProductMetricsEntity(final Long productId) {
+        this.id = productId;
+    }
+
+    public static ProductMetricsEntity create(final Long productId) {
+        return new ProductMetricsEntity(productId);
+    }
+
+    public void incrementView() {
+        this.viewCount += 1;
+        this.lastEventAt = ZonedDateTime.now();
+    }
+
+    public void applyLikeDelta(final int delta) {
+        final long next = this.likeCount + delta;
+
+        //TODO : 검토 필요
+        this.likeCount = Math.max(0, next);
+
+        this.lastEventAt = ZonedDateTime.now();
+    }
+
+    public void addSales(final int quantity) {
+        if (quantity <= 0) {
+            return;
+        }
+        this.salesCount += quantity;
+        this.lastEventAt = ZonedDateTime.now();
+    }
+
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
index f3a80e63e..2860da673 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -1,9 +1,14 @@
 package com.loopers.domain.metrics;
 
+import java.util.Optional;
+
 /**
  *
  * @author hyunjikoh
  * @since 2025. 12. 16.
  */
 public interface ProductMetricsRepository {
+    ProductMetricsEntity save(ProductMetricsEntity metrics);
+
+    Optional<ProductMetricsEntity> findById(Long productId);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
index c14780ed7..072ae9aef 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventJpaRepository.java
@@ -2,12 +2,12 @@
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.loopers.domain.event.EventRepository;
+import com.loopers.domain.event.EventEntity;
 
 /**
  *
  * @author hyunjikoh
  * @since 2025. 12. 16.
  */
-public interface EventJpaRepository extends JpaRepository<EventRepository, Long> {
+public interface EventJpaRepository extends JpaRepository<EventEntity, String> {
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
index a3faf3139..c9eb22596 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
@@ -1,14 +1,24 @@
 package com.loopers.infrastructure.event;
 
-import org.w3c.dom.events.Event;
+import org.springframework.stereotype.Component;
 
+import com.loopers.domain.event.EventEntity;
 import com.loopers.domain.event.EventRepository;
-import com.loopers.domain.metrics.ProductMetricsRepository;
+
+import lombok.RequiredArgsConstructor;
 
 /**
  *
  * @author hyunjikoh
  * @since 2025. 12. 16.
  */
+@Component
+@RequiredArgsConstructor
 public class EventRepositoryImpl implements EventRepository {
+    private final EventJpaRepository eventJpaRepository;
+
+    @Override
+    public EventEntity save(EventEntity eventEntity) {
+        return eventJpaRepository.save(eventEntity);
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
index ae668070f..633220ac4 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -1,7 +1,10 @@
 package com.loopers.infrastructure.metrics;
 
+import java.util.Optional;
+
 import org.springframework.stereotype.Component;
 
+import com.loopers.domain.metrics.ProductMetricsEntity;
 import com.loopers.domain.metrics.ProductMetricsRepository;
 
 import lombok.RequiredArgsConstructor;
@@ -14,4 +17,15 @@
 @Component
 @RequiredArgsConstructor
 public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+    private final ProductMetricsJpaRepository productMetricsJpaRepository;
+
+    @Override
+    public ProductMetricsEntity save(ProductMetricsEntity metrics) {
+        return productMetricsJpaRepository.save(metrics);
+    }
+
+    @Override
+    public Optional<ProductMetricsEntity> findById(Long productId) {
+        return productMetricsJpaRepository.findById(productId);
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
index ba862cec6..e513f7f7b 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
@@ -1,23 +1,24 @@
 package com.loopers.interfaces.consumer;
 
-import com.loopers.confg.kafka.KafkaConfig;
+import java.util.List;
+
 import org.apache.kafka.clients.consumer.ConsumerRecord;
 import org.springframework.kafka.annotation.KafkaListener;
 import org.springframework.kafka.support.Acknowledgment;
 import org.springframework.stereotype.Component;
 
-import java.util.List;
+import com.loopers.confg.kafka.KafkaConfig;
 
 @Component
 public class DemoKafkaConsumer {
     @KafkaListener(
-        topics = {"${demo-kafka.test.topic-name}"},
-        containerFactory = KafkaConfig.BATCH_LISTENER
+            topics = {"${demo-kafka.test.topic-name}"},
+            containerFactory = KafkaConfig.BATCH_LISTENER
     )
     public void demoListener(
-        List<ConsumerRecord<Object,Object>> messages,
-        Acknowledgment acknowledgment
-    ){
+            List<ConsumerRecord<Object, Object>> messages,
+            Acknowledgment acknowledgment
+    ) {
         System.out.println(messages);
         acknowledgment.acknowledge();
     }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
new file mode 100644
index 000000000..b8ad47c2b
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
@@ -0,0 +1,157 @@
+package com.loopers.interfaces.consumer;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.stereotype.Component;
+
+import com.loopers.confg.kafka.KafkaConfig;
+import com.loopers.domain.metrics.MetricsService;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+
+@Component
+@RequiredArgsConstructor
+public class MetricsKafkaConsumer {
+
+    private final MetricsService metricsService;
+
+    @KafkaListener(
+            topics = {"catalog-events"},
+            containerFactory = KafkaConfig.BATCH_LISTENER
+    )
+    public void onCatalogEvents(
+            List<ConsumerRecord<Object, Object>> records,
+            Acknowledgment ack) {
+        for (ConsumerRecord<Object, Object> record : records) {
+            final Map<String, Object> event = asMap(record.value());
+            final String eventId = str(event.get("eventId"));
+            if (eventId == null) {
+                continue;
+            }
+
+            final boolean first = metricsService.tryMarkHandled(eventId);
+            if (!first) {
+                continue;
+            }
+
+            final String eventType = str(event.get("eventType"));
+            final Map<String, Object> payload = asMap(event.get("payload"));
+
+            if ("PRODUCT_VIEW".equals(eventType)) {
+                final Long productId = longVal(payload.get("productId"));
+                if (productId != null) {
+                    metricsService.incrementView(productId);
+                }
+            }
+
+            if ("LIKE_ACTION".equals(eventType)) {
+                final Long productId = longVal(payload.get("productId"));
+                final String action = str(payload.get("action")); // LIKE / UNLIKE
+                if (productId != null && action != null) {
+                    final int delta = "LIKE".equals(action) ? 1 : -1;
+                    metricsService.applyLikeDelta(productId, delta);
+                }
+            }
+        }
+
+        ack.acknowledge();
+    }
+
+    @KafkaListener(
+            topics = {"order-events"},
+            containerFactory = KafkaConfig.BATCH_LISTENER
+    )
+    public void onOrderEvents(
+            final List<ConsumerRecord<Object, Object>> records,
+            final Acknowledgment ack
+    ) {
+        for (ConsumerRecord<Object, Object> record : records) {
+            final Map<String, Object> event = asMap(record.value());
+            final String eventId = str(event.get("eventId"));
+            if (eventId == null) {
+                continue;
+            }
+
+            final boolean first = metricsService.tryMarkHandled(eventId);
+            if (!first) {
+                continue;
+            }
+
+            final String eventType = str(event.get("eventType"));
+
+            //
+            if (!"PAYMENT_SUCCESS".equals(eventType)) {
+                continue;
+            }
+
+            // 기대 payload:
+            // { "items": [ { "productId": 1, "quantity": 2 }, ... ] }
+            final Map<String, Object> payload = asMap(event.get("payload"));
+            final List<Map<String, Object>> items = listOfMap(payload.get("items"));
+            for (Map<String, Object> item : items) {
+                final Long productId = longVal(item.get("productId"));
+                final Integer quantity = intVal(item.get("quantity"));
+                if (productId != null && quantity != null) {
+                    metricsService.addSales(productId, quantity);
+                }
+            }
+        }
+
+        ack.acknowledge();
+    }
+
+    private Map<String, Object> asMap(final Object value) {
+        if (value instanceof Map<?, ?> m) {
+            return (Map<String, Object>) m;
+        }
+        return Map.of();
+    }
+
+    private List<Map<String, Object>> listOfMap(final Object value) {
+        if (value instanceof List<?> list) {
+            return list.stream()
+                    .filter(it -> it instanceof Map<?, ?>)
+                    .map(it -> (Map<String, Object>) it)
+                    .toList();
+        }
+        return List.of();
+    }
+
+    private String str(final Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private Long longVal(final Object value) {
+        if (value instanceof Number n) {
+            return n.longValue();
+        }
+        try {
+            return value == null ? null : Long.parseLong(String.valueOf(value));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private Integer intVal(final Object value) {
+        if (value instanceof Number n) {
+            return n.intValue();
+        }
+        try {
+            return value == null ? null : Integer.parseInt(String.valueOf(value));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+
+}

From 41339a036c23a515884e4186179d80e8c56e6254 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Wed, 17 Dec 2025 01:13:05 +0900
Subject: [PATCH 04/28] =?UTF-8?q?feat(metrics):=20=EB=A9=94=ED=8A=B8?=
 =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Kafka를 통한 이벤트 수신 및 처리 로직 구현
- 상품 조회 및 좋아요 수 증가 기능 추가
- 판매 수 증가 기능 추가
- 이벤트 처리 로직 개선 및 데이터베이스 연동
---
 .../application/order/OrderEventHandler.java  | 10 +--
 .../application/payment/PaymentFacade.java    |  8 ++
 .../com/loopers/domain/event/EventTypes.java  | 15 ++++
 .../loopers/domain/event/EventVersions.java   | 12 +++
 .../PaymentKafkaPublishEventHandler.java      | 75 +++++++++++++++++++
 .../payment/event/PaymentCompletedEvent.java  |  2 +-
 .../domain/tracking/UserBehaviorTracker.java  |  4 +-
 .../event/DomainEventEnvelope.java            | 15 ++++
 .../event/DomainEventEnvelopeFactory.java     | 39 ++++++++++
 .../event/DomainEventPublisher.java           | 27 +++++++
 .../payloads/PaymentSuccessPayloadV1.java     | 18 +++++
 11 files changed, 217 insertions(+), 8 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelopeFactory.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisher.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
index e31cd3599..b1e0cb7f5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
@@ -24,22 +24,22 @@ public class OrderEventHandler {
 
     private void executeSafely(String action, Long orderId, Long userId, Runnable task) {
         if (orderId == null || userId == null) {
-            log.warn("이벤트 무시 - 필수 값 누락 action={}, orderId={}, userId={}", action, orderId, userId);
+            log.warn("이벤트 무시 - 필수 값 누락 action={}, orderNumber={}, userId={}", action, orderId, userId);
             return;
         }
         try {
-            log.debug("이벤트 처리 시작 action={}, orderId={}, userId={}", action, orderId, userId);
+            log.debug("이벤트 처리 시작 action={}, orderNumber={}, userId={}", action, orderId, userId);
             task.run();
-            log.info("이벤트 처리 성공 action={}, orderId={}, userId={}", action, orderId, userId);
+            log.info("이벤트 처리 성공 action={}, orderNumber={}, userId={}", action, orderId, userId);
         } catch (Exception e) {
-            log.error("이벤트 처리 실패 action={}, orderId={}, userId={}", action, orderId, userId, e);
+            log.error("이벤트 처리 실패 action={}, orderNumber={}, userId={}", action, orderId, userId, e);
         }
     }
 
     @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void handlePaymentCompleted(PaymentCompletedEvent event) {
-        Long orderId = event.orderId();
+        Long orderId = event.orderNumber();
         Long userId = event.userId();
         executeSafely("PAYMENT_COMPLETED", orderId, userId,
                 () -> orderFacade.confirmOrderByPayment(orderId, userId));
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 26c6c295b..55fc17f68 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
@@ -1,13 +1,21 @@
 package com.loopers.application.payment;
 
+import java.util.List;
+
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
 import com.loopers.domain.order.OrderEntity;
+import com.loopers.domain.order.OrderItemEntity;
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.*;
 import com.loopers.domain.user.UserEntity;
 import com.loopers.domain.user.UserService;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 import com.loopers.infrastructure.payment.client.dto.PgPaymentResponse;
 import com.loopers.interfaces.api.payment.PaymentV1Dtos;
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
new file mode 100644
index 000000000..2d44ffb62
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.event;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public final class EventTypes {
+    private EventTypes() {}
+
+    public static final String PAYMENT_SUCCESS = "PAYMENT_SUCCESS";
+    public static final String PRODUCT_VIEW = "PRODUCT_VIEW";
+    public static final String LIKE_ACTION = "LIKE_ACTION";
+    public static final String ORDER_CREATE = "ORDER_CREATE";
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
new file mode 100644
index 000000000..693c25632
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.event;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 16.
+ */
+public final class EventVersions {
+    private EventVersions() {}
+
+    public static final String V1 = "v1";
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
new file mode 100644
index 000000000..e34522aae
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
@@ -0,0 +1,75 @@
+package com.loopers.domain.event;
+
+import java.util.List;
+
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import com.loopers.domain.order.OrderEntity;
+import com.loopers.domain.order.OrderItemEntity;
+import com.loopers.domain.order.OrderService;
+import com.loopers.domain.payment.event.PaymentCompletedEvent;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class PaymentKafkaPublishEventHandler {
+    private static final String ORDER_EVENTS_TOPIC = "order-events";
+
+    private final OrderService orderService;
+    private final DomainEventPublisher domainEventPublisher;
+    private final DomainEventEnvelopeFactory envelopeFactory;
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handlePaymentCompleted(final PaymentCompletedEvent event) {
+        // PaymentCompletedEvent의 orderNumber 는 주문 번호
+        final Long orderNumber = event.orderNumber();
+        final Long userId = event.userId();
+
+        if (orderNumber == null || userId == null) {
+            log.warn("Kafka 발행 스킵 - 필수값 누락 orderNumber={}, userId={}", orderNumber, userId);
+            return;
+        }
+
+        try {
+            final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
+            final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
+
+            final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
+                    .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
+                    .toList();
+
+            final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
+
+            final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
+
+            domainEventPublisher.publish(
+                    ORDER_EVENTS_TOPIC,
+                    String.valueOf(order.getId()), // partition key = orderNumber
+                    envelope
+            );
+
+            log.info("Kafka 발행 완료 - type={}, orderNumber={}, itemCount={}",
+                    EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
+        } catch (Exception e) {
+            // NOTE: AFTER_COMMIT 이후라서 여기 실패는 DB 롤백이 안 됩니다.
+            // 수요일 Outbox로 승격할 때 이 부분을 "Outbox 적재"로 바꾸면 At-Least-Once가 완성됩니다.
+            log.error("Kafka 발행 실패 - type={}, orderNumber={}, userId={}",
+                    EventTypes.PAYMENT_SUCCESS, orderNumber, userId, e);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java
index 368ef80c4..e29f5aad8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java
@@ -8,7 +8,7 @@
  */
 public record PaymentCompletedEvent(
         String transactionKey,
-        Long orderId,
+        Long orderNumber,
         Long userId,
         BigDecimal amount,
         String cardType
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/tracking/UserBehaviorTracker.java b/apps/commerce-api/src/main/java/com/loopers/domain/tracking/UserBehaviorTracker.java
index 5695ba993..f4d08333e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/tracking/UserBehaviorTracker.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/tracking/UserBehaviorTracker.java
@@ -98,10 +98,10 @@ public void trackOrderCreate(
             );
 
             eventPublisher.publishEvent(event);
-            log.debug("주문 생성 추적 - userId: {}, orderId: {}", userId, orderId);
+            log.debug("주문 생성 추적 - userId: {}, orderNumber: {}", userId, orderId);
 
         } catch (Exception e) {
-            log.warn("주문 생성 추적 실패 - userId: {}, orderId: {}, error: {}",
+            log.warn("주문 생성 추적 실패 - userId: {}, orderNumber: {}, error: {}",
                     userId, orderId, e.getMessage());
         }
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
new file mode 100644
index 000000000..19114903f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
@@ -0,0 +1,15 @@
+package com.loopers.infrastructure.event;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+public record DomainEventEnvelope(
+        String eventId,
+        String eventType,
+        String version,
+        long occurredAtEpochMillis,
+        String payloadJson
+) {
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelopeFactory.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelopeFactory.java
new file mode 100644
index 000000000..7b3a22591
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelopeFactory.java
@@ -0,0 +1,39 @@
+package com.loopers.infrastructure.event;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import org.springframework.stereotype.Component;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+@Component
+@RequiredArgsConstructor
+public class DomainEventEnvelopeFactory {
+    private final ObjectMapper objectMapper;
+
+    public DomainEventEnvelope create(final String eventType, final String version, final Object payload) {
+        final String payloadJson;
+        try {
+            payloadJson = objectMapper.writeValueAsString(payload);
+        } catch (Exception e) {
+            throw new IllegalStateException("이벤트 payload 직렬화에 실패했습니다.", e);
+        }
+
+        return new DomainEventEnvelope(
+                UUID.randomUUID().toString(),
+                eventType,
+                version,
+                Instant.now().toEpochMilli(),
+                payloadJson
+        );
+    }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisher.java
new file mode 100644
index 000000000..f80c6d9c3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisher.java
@@ -0,0 +1,27 @@
+package com.loopers.infrastructure.event;
+
+import java.util.Objects;
+
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+
+@Component
+@RequiredArgsConstructor
+public class DomainEventPublisher {
+    private final KafkaTemplate<Object, Object> kafkaTemplate;
+
+    public void publish(final String topic, final String key, final DomainEventEnvelope envelope) {
+        Objects.requireNonNull(topic, "topic은 필수입니다.");
+        Objects.requireNonNull(key, "key는 필수입니다.");
+        Objects.requireNonNull(envelope, "envelope은 필수입니다.");
+        kafkaTemplate.send(topic, key, envelope);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
new file mode 100644
index 000000000..c67fe91df
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
@@ -0,0 +1,18 @@
+package com.loopers.infrastructure.event.payloads;
+
+import java.util.List;
+
+import com.loopers.domain.order.OrderItemEntity;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+public record PaymentSuccessPayloadV1(
+        Long orderNumber,
+        List<Item> items
+) {
+    public record Item(Long productId, Integer quantity) {
+    }
+}

From 0bf6f96a1f478232bbc34e24cb78c4f46b896f49 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Wed, 17 Dec 2025 01:13:17 +0900
Subject: [PATCH 05/28] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?=
 =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EA=B7=B8=20=EB=A9=94=EC=8B=9C?=
 =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20orderId=EB=A5=BC=20orderNumber?=
 =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 주문 생성, 쿠폰 사용, 주문 확정 및 취소 관련 로그 메시지에서
  orderId를 orderNumber로 변경하여 일관성 있는 용어 사용
---
 .../domain/coupon/CouponEventHandler.java      |  9 ++++-----
 .../coupon/event/CouponConsumeEvent.java       |  2 +-
 .../dataplatform/DataPlatformClient.java       |  8 ++++----
 .../dataplatform/DataPlatformEventHandler.java | 18 +++++++++---------
 .../tracking/client/AnalyticsClient.java       |  2 +-
 5 files changed, 19 insertions(+), 20 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventHandler.java
index c2cf2c3c0..dc4402b24 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventHandler.java
@@ -8,7 +8,6 @@
 
 import java.util.Objects;
 
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
@@ -41,12 +40,12 @@ public void handleCouponConsume(CouponConsumeEvent event) {
         Objects.requireNonNull(event, "쿠폰 이벤트가 null 입니다.");
 
         CouponEntity coupon = couponService.getCouponByIdAndUserId(event.couponId(), event.userId());
-        log.debug("쿠폰 사용 처리 시작 - orderId={}, userId={}, couponId={}",
-                event.orderId(), coupon.getUserId(), coupon.getId());
+        log.debug("쿠폰 사용 처리 시작 - orderNumber={}, userId={}, couponId={}",
+                event.orderNumber(), coupon.getUserId(), coupon.getId());
 
         couponService.consumeCoupon(coupon);
 
-        log.debug("쿠폰 사용 처리 완료 - orderId={}",
-                event.orderId());
+        log.debug("쿠폰 사용 처리 완료 - orderNumber={}",
+                event.orderNumber());
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponConsumeEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponConsumeEvent.java
index 7941d9d1e..7411d3805 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponConsumeEvent.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponConsumeEvent.java
@@ -7,6 +7,6 @@
 public record CouponConsumeEvent(
         Long couponId,
         Long userId,
-        Long orderId
+        Long orderNumber
 ) {
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformClient.java
index 3f32364de..3b27cf64f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformClient.java
@@ -31,8 +31,8 @@ public class DataPlatformClient {
     public boolean sendOrderData(OrderDataDto orderData) {
         try {
             // Fake API 호출 시뮬레이션
-            log.info("[DATA PLATFORM] 주문 데이터 전송 시작 - orderId: {}, eventType: {}",
-                    orderData.orderId(), orderData.eventType());
+            log.info("[DATA PLATFORM] 주문 데이터 전송 시작 - orderNumber: {}, eventType: {}",
+                    orderData.orderNumber(), orderData.eventType());
 
             // 실제로는 HTTP 요청을 보냄
             // restTemplate.postForObject("https://data-platform.api/orders", orderData, String.class);
@@ -47,7 +47,7 @@ public boolean sendOrderData(OrderDataDto orderData) {
             }
 
         } catch (Exception e) {
-            log.error(" [DATA PLATFORM] 주문 데이터 전송 실패 - orderId: {}, error: {}",
+            log.error(" [DATA PLATFORM] 주문 데이터 전송 실패 - orderNumber: {}, error: {}",
                     orderData.orderId(), e.getMessage());
             return false;
         }
@@ -70,7 +70,7 @@ public boolean sendPaymentData(PaymentDataDto paymentData) {
 
             // 성공 시뮬레이션 (90% 성공률)
             if (Math.random() < 0.9) {
-                log.info("[DATA PLATFORM] 결제 데이터 전송 성공 - transactionKey: {}, orderId: {}",
+                log.info("[DATA PLATFORM] 결제 데이터 전송 성공 - transactionKey: {}, orderNumber: {}",
                         paymentData.transactionKey(), paymentData.orderId());
                 return true;
             } else {
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
index a9cd98da3..c5cfdcb22 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
@@ -43,7 +43,7 @@ public class DataPlatformEventHandler {
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void handleOrderConfirmed(OrderConfirmedEvent event) {
         try {
-            log.debug("주문 확정 데이터 플랫폼 전송 시작 - orderId: {}", event.orderId());
+            log.debug("주문 확정 데이터 플랫폼 전송 시작 - orderNumber: {}", event.orderNumber());
 
             OrderDataDto orderData = new OrderDataDto(
                 event.orderId(),
@@ -60,13 +60,13 @@ public void handleOrderConfirmed(OrderConfirmedEvent event) {
             boolean success = dataPlatformClient.sendOrderData(orderData);
 
             if (success) {
-                log.info("주문 확정 데이터 플랫폼 전송 성공 - orderId: {}", event.orderId());
+                log.info("주문 확정 데이터 플랫폼 전송 성공 - orderNumber: {}", event.orderNumber());
             } else {
-                log.warn("주문 확정 데이터 플랫폼 전송 실패 - orderId: {}", event.orderId());
+                log.warn("주문 확정 데이터 플랫폼 전송 실패 - orderNumber: {}", event.orderNumber());
             }
 
         } catch (Exception e) {
-            log.error("주문 확정 데이터 플랫폼 전송 중 예외 발생 - orderId: {}", event.orderId(), e);
+            log.error("주문 확정 데이터 플랫폼 전송 중 예외 발생 - orderNumber: {}", event.orderNumber(), e);
         }
     }
 
@@ -77,7 +77,7 @@ public void handleOrderConfirmed(OrderConfirmedEvent event) {
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void handleOrderCancelled(OrderCancelledEvent event) {
         try {
-            log.debug("주문 취소 데이터 플랫폼 전송 시작 - orderId: {}", event.orderId());
+            log.debug("주문 취소 데이터 플랫폼 전송 시작 - orderNumber: {}", event.orderId());
 
             OrderDataDto orderData = new OrderDataDto(
                 event.orderId(),
@@ -94,13 +94,13 @@ public void handleOrderCancelled(OrderCancelledEvent event) {
             boolean success = dataPlatformClient.sendOrderData(orderData);
 
             if (success) {
-                log.info("주문 취소 데이터 플랫폼 전송 성공 - orderId: {}", event.orderId());
+                log.info("주문 취소 데이터 플랫폼 전송 성공 - orderNumber: {}", event.orderId());
             } else {
-                log.warn("주문 취소 데이터 플랫폼 전송 실패 - orderId: {}", event.orderId());
+                log.warn("주문 취소 데이터 플랫폼 전송 실패 - orderNumber: {}", event.orderId());
             }
 
         } catch (Exception e) {
-            log.error("주문 취소 데이터 플랫폼 전송 중 예외 발생 - orderId: {}", event.orderId(), e);
+            log.error("주문 취소 데이터 플랫폼 전송 중 예외 발생 - orderNumber: {}", event.orderId(), e);
         }
     }
 
@@ -115,7 +115,7 @@ public void handlePaymentCompleted(PaymentCompletedEvent event) {
 
             PaymentDataDto paymentData = new PaymentDataDto(
                 event.transactionKey(),
-                event.orderId(),
+                event.orderNumber(),
                 event.userId(),
                 PaymentStatus.COMPLETED,
                 event.amount(),
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/client/AnalyticsClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/client/AnalyticsClient.java
index 50ccc4a04..4adc80187 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/client/AnalyticsClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/client/AnalyticsClient.java
@@ -48,7 +48,7 @@ public boolean sendBehaviorData(UserBehaviorEvent event) {
                             event.targetId(), event.userId());
                     case "LIKE_ACTION" -> log.info("[ANALYTICS] 좋아요 액션 추적 완료 - productId: {}, userId: {}, action: {}",
                             event.targetId(), event.userId(), props.get("action"));
-                    case "ORDER_CREATE" -> log.info("[ANALYTICS] 주문 생성 추적 완료 - orderId: {}, userId: {}, amount: {}",
+                    case "ORDER_CREATE" -> log.info("[ANALYTICS] 주문 생성 추적 완료 - orderNumber: {}, userId: {}, amount: {}",
                             event.targetId(), event.userId(), props.get("totalAmount"));
                     default -> log.info("[ANALYTICS] 유저 행동 추적 완료 - eventType: {}, userId: {}",
                             event.eventType(), event.userId());

From 9c44315bef22931999a8b24be33963638eaf2e73 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Wed, 17 Dec 2025 01:34:13 +0900
Subject: [PATCH 06/28] =?UTF-8?q?refactor(payment):=20=EB=B6=88=ED=95=84?=
 =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Kafka 발행 실패 시 DB 롤백 불가에 대한 주석을 제거하여 코드 가독성 향상
---
 .../loopers/domain/event/PaymentKafkaPublishEventHandler.java    | 1 -
 1 file changed, 1 deletion(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
index e34522aae..26078da31 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
@@ -66,7 +66,6 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
             log.info("Kafka 발행 완료 - type={}, orderNumber={}, itemCount={}",
                     EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
         } catch (Exception e) {
-            // NOTE: AFTER_COMMIT 이후라서 여기 실패는 DB 롤백이 안 됩니다.
             // 수요일 Outbox로 승격할 때 이 부분을 "Outbox 적재"로 바꾸면 At-Least-Once가 완성됩니다.
             log.error("Kafka 발행 실패 - type={}, orderNumber={}, userId={}",
                     EventTypes.PAYMENT_SUCCESS, orderNumber, userId, e);

From d084885a882ca475b23469443b3cbb586964affd Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Thu, 18 Dec 2025 00:50:57 +0900
Subject: [PATCH 07/28] =?UTF-8?q?feat(outbox):=20Outbox=20=EC=9D=B4?=
 =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- OutboxEventEntity 클래스 추가
- OutboxEventJpaRepository 인터페이스 추가
- OutboxEventRepositoryImpl 구현체 추가
- OutboxRelay 클래스 추가하여 이벤트 전송 로직 구현
- OutboxStatus 열거형 추가
---
 .../PaymentKafkaPublishEventHandler.java      |  25 ++--
 .../event/outbox/OutboxEventEntity.java       | 133 ++++++++++++++++++
 .../domain/event/outbox/OutboxRelay.java      |  48 +++++++
 .../domain/event/outbox/OutboxRepository.java |  14 ++
 .../domain/event/outbox/OutboxStatus.java     |   7 +
 .../outbox/OutboxEventJpaRepository.java      |  13 ++
 .../outbox/OutboxEventRepositoryImpl.java     |  33 +++++
 7 files changed, 264 insertions(+), 9 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxEventEntity.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
index 26078da31..052885bbb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
@@ -7,12 +7,14 @@
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
 
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
 import com.loopers.domain.order.OrderEntity;
 import com.loopers.domain.order.OrderItemEntity;
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.event.PaymentCompletedEvent;
 import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.DomainEventPublisher;
+import com.loopers.infrastructure.event.outbox.OutboxEventJpaRepository;
 import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 
 import lombok.RequiredArgsConstructor;
@@ -30,8 +32,8 @@ public class PaymentKafkaPublishEventHandler {
     private static final String ORDER_EVENTS_TOPIC = "order-events";
 
     private final OrderService orderService;
-    private final DomainEventPublisher domainEventPublisher;
     private final DomainEventEnvelopeFactory envelopeFactory;
+    private final OutboxRepository outboxRepository;
 
     @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@@ -41,7 +43,7 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
         final Long userId = event.userId();
 
         if (orderNumber == null || userId == null) {
-            log.warn("Kafka 발행 스킵 - 필수값 누락 orderNumber={}, userId={}", orderNumber, userId);
+            log.warn("Outbox 적재 스킵 - 필수값 누락 orderNumber={}, userId={}", orderNumber, userId);
             return;
         }
 
@@ -57,17 +59,22 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
 
             final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
 
-            domainEventPublisher.publish(
+            OutboxEventEntity ready = OutboxEventEntity.ready(
+                    envelope.eventId(),
                     ORDER_EVENTS_TOPIC,
-                    String.valueOf(order.getId()), // partition key = orderNumber
-                    envelope
+                    String.valueOf(order.getId()), // partition key = orderId(PK)
+                    envelope.eventType(),
+                    envelope.version(),
+                    envelope.occurredAtEpochMillis(),
+                    envelope.payloadJson()
             );
 
-            log.info("Kafka 발행 완료 - type={}, orderNumber={}, itemCount={}",
+            outboxRepository.save(ready);
+
+            log.info("Outbox 적재 완료 - type={}, orderId={}, itemCount={}",
                     EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
         } catch (Exception e) {
-            // 수요일 Outbox로 승격할 때 이 부분을 "Outbox 적재"로 바꾸면 At-Least-Once가 완성됩니다.
-            log.error("Kafka 발행 실패 - type={}, orderNumber={}, userId={}",
+            log.error("Outbox 적재 실패 - type={}, orderNumber={}, userId={}",
                     EventTypes.PAYMENT_SUCCESS, orderNumber, userId, e);
         }
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxEventEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxEventEntity.java
new file mode 100644
index 000000000..657358cf3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxEventEntity.java
@@ -0,0 +1,133 @@
+package com.loopers.domain.event.outbox;
+
+import java.time.ZonedDateTime;
+import java.util.Objects;
+
+import lombok.Getter;
+
+import jakarta.persistence.*;
+
+@Entity
+@Getter
+@Table(name = "outbox_event")
+public class OutboxEventEntity {
+
+    @Id
+    @Column(name = "event_id", length = 36, nullable = false)
+    private String eventId;
+
+    @Column(name = "topic", nullable = false, length = 200)
+    private String topic;
+
+    @Column(name = "message_key", nullable = false, length = 200)
+    private String messageKey;
+
+    @Column(name = "event_type", nullable = false, length = 100)
+    private String eventType;
+
+    @Column(name = "version", nullable = false, length = 20)
+    private String version;
+
+    @Column(name = "occurred_at_epoch_millis", nullable = false)
+    private long occurredAtEpochMillis;
+
+    @Lob
+    @Column(name = "payload_json", nullable = false)
+    private String payloadJson;
+
+    @Enumerated(EnumType.STRING)
+    @Column(name = "status", nullable = false, length = 20)
+    private OutboxStatus status;
+
+    @Column(name = "retry_count", nullable = false)
+    private int retryCount;
+
+    @Column(name = "created_at", nullable = false)
+    private ZonedDateTime createdAt;
+
+    @Column(name = "published_at")
+    private ZonedDateTime publishedAt;
+
+    protected OutboxEventEntity() {
+    }
+
+    private OutboxEventEntity(
+            final String eventId,
+            final String topic,
+            final String messageKey,
+            final String eventType,
+            final String version,
+            final long occurredAtEpochMillis,
+            final String payloadJson
+    ) {
+        // 유효성 검증
+        Objects.requireNonNull(eventId, "이벤트 ID는 필수입니다.");
+        if (eventId.trim().isEmpty() || eventId.length() != 36) {
+            throw new IllegalArgumentException("이벤트 ID는 36자리 UUID 형식이어야 합니다.");
+        }
+
+        Objects.requireNonNull(topic, "토픽은 필수입니다.");
+        if (topic.trim().isEmpty() || topic.length() > 200) {
+            throw new IllegalArgumentException("토픽은 필수이며 200자를 초과할 수 없습니다.");
+        }
+
+        Objects.requireNonNull(messageKey, "메시지 키는 필수입니다.");
+        if (messageKey.trim().isEmpty() || messageKey.length() > 200) {
+            throw new IllegalArgumentException("메시지 키는 필수이며 200자를 초과할 수 없습니다.");
+        }
+
+        Objects.requireNonNull(eventType, "이벤트 타입은 필수입니다.");
+        if (eventType.trim().isEmpty() || eventType.length() > 100) {
+            throw new IllegalArgumentException("이벤트 타입은 필수이며 100자를 초과할 수 없습니다.");
+        }
+
+        Objects.requireNonNull(version, "버전은 필수입니다.");
+        if (version.trim().isEmpty() || version.length() > 20) {
+            throw new IllegalArgumentException("버전은 필수이며 20자를 초과할 수 없습니다.");
+        }
+
+        if (occurredAtEpochMillis <= 0) {
+            throw new IllegalArgumentException("발생 시간은 0보다 커야 합니다.");
+        }
+
+        Objects.requireNonNull(payloadJson, "페이로드 JSON은 필수입니다.");
+        if (payloadJson.trim().isEmpty()) {
+            throw new IllegalArgumentException("페이로드 JSON은 필수입니다.");
+        }
+
+        this.eventId = eventId;
+        this.topic = topic;
+        this.messageKey = messageKey;
+        this.eventType = eventType;
+        this.version = version;
+        this.occurredAtEpochMillis = occurredAtEpochMillis;
+        this.payloadJson = payloadJson;
+
+        this.status = OutboxStatus.READY;
+        this.retryCount = 0;
+        this.createdAt = ZonedDateTime.now();
+    }
+
+    public static OutboxEventEntity ready(
+            final String eventId,
+            final String topic,
+            final String messageKey,
+            final String eventType,
+            final String version,
+            final long occurredAtEpochMillis,
+            final String payloadJson
+    ) {
+        return new OutboxEventEntity(eventId, topic, messageKey, eventType, version, occurredAtEpochMillis, payloadJson);
+    }
+
+    public void markSent() {
+        this.status = OutboxStatus.SENT;
+        this.publishedAt = ZonedDateTime.now();
+    }
+
+    public void markFailed() {
+        this.status = OutboxStatus.FAILED;
+        this.retryCount += 1;
+    }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
new file mode 100644
index 000000000..ca16abed6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
@@ -0,0 +1,48 @@
+package com.loopers.domain.event.outbox;
+
+import java.util.List;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.loopers.infrastructure.event.DomainEventEnvelope;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OutboxRelay {
+
+    private final OutboxRepository outboxRepository;
+    private final DomainEventPublisher domainEventPublisher;
+
+    @Scheduled(fixedDelay = 1000)
+    @Transactional
+    public void relay() {
+        final List<OutboxEventEntity> readyEvents =
+                outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.READY);
+
+        for (OutboxEventEntity e : readyEvents) {
+            try {
+                final DomainEventEnvelope envelope = new DomainEventEnvelope(
+                        e.getEventId(),
+                        e.getEventType(),
+                        e.getVersion(),
+                        e.getOccurredAtEpochMillis(),
+                        e.getPayloadJson()
+                );
+
+                domainEventPublisher.publish(e.getTopic(), e.getMessageKey(), envelope);
+
+                e.markSent();
+            } catch (Exception ex) {
+                e.markFailed();
+                log.warn("Outbox relay 실패 - eventId={}, retryCount={}", e.getEventId(), e.getRetryCount(), ex);
+            }
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
new file mode 100644
index 000000000..2a51d9764
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.event.outbox;
+
+import java.util.List;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+public interface OutboxRepository {
+    List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus);
+
+    OutboxEventEntity save(OutboxEventEntity ready);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxStatus.java
new file mode 100644
index 000000000..22a1283fc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxStatus.java
@@ -0,0 +1,7 @@
+package com.loopers.domain.event.outbox;
+
+public enum OutboxStatus {
+    READY,
+    SENT,
+    FAILED
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
new file mode 100644
index 000000000..5028456c7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.event.outbox;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxStatus;
+
+public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> {
+
+    List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
new file mode 100644
index 000000000..72b5ad513
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
@@ -0,0 +1,33 @@
+package com.loopers.infrastructure.event.outbox;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
+import com.loopers.domain.event.outbox.OutboxStatus;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+
+@Component
+@RequiredArgsConstructor
+public class OutboxEventRepositoryImpl implements OutboxRepository {
+    private final OutboxEventJpaRepository outboxEventJpaRepository;
+
+    @Override
+    public List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus) {
+        return outboxEventJpaRepository.findTop100ByStatusOrderByCreatedAtAsc(outboxStatus);
+    }
+
+    @Override
+    public OutboxEventEntity save(OutboxEventEntity ready) {
+        return outboxEventJpaRepository.save(ready);
+    }
+}

From 0a828f0b347389a96ee565e7ff0b9f3f99404b30 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Thu, 18 Dec 2025 22:19:39 +0900
Subject: [PATCH 08/28] =?UTF-8?q?feat(config):=20=EA=B4=80=EB=A6=AC=20?=
 =?UTF-8?q?=EC=84=9C=EB=B2=84=20=ED=8F=AC=ED=8A=B8=20=EC=84=A4=EC=A0=95=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AA=A8=EB=8B=88=ED=84=B0?=
 =?UTF-8?q?=EB=A7=81=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=A0=95?=
 =?UTF-8?q?=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/commerce-api/src/main/resources/application.yml      | 4 +++-
 apps/commerce-streamer/src/main/resources/application.yml | 8 ++++----
 supports/monitoring/src/main/resources/monitoring.yml     | 2 --
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 2461727d4..8f3c1fbdf 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -23,7 +23,6 @@ spring:
       - redis.yml
       - kafka.yml
       - logging.yml
-      - monitoring.yml
   cloud:
     openfeign:
       client:
@@ -32,6 +31,9 @@ spring:
             connect-timeout: 500    # 연결 타임아웃 500ms
             read-timeout: 500       # 읽기 타임아웃 500ms
             logger-level: full
+management:
+  server:
+    port: 8099
 
 pg:
   simulator:
diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml
index 4a5e2af8a..fa72b64c8 100644
--- a/apps/commerce-streamer/src/main/resources/application.yml
+++ b/apps/commerce-streamer/src/main/resources/application.yml
@@ -1,3 +1,7 @@
+management:
+  server:
+    port: 8091
+
 server:
   shutdown: graceful
   tomcat:
@@ -11,10 +15,6 @@ server:
   max-http-request-header-size: 8KB
   port: 8090
 
-management:
-  server:
-    port: 8091
-
 spring:
   main:
     web-application-type: servlet
diff --git a/supports/monitoring/src/main/resources/monitoring.yml b/supports/monitoring/src/main/resources/monitoring.yml
index c6a87a9cf..be9eb4f52 100644
--- a/supports/monitoring/src/main/resources/monitoring.yml
+++ b/supports/monitoring/src/main/resources/monitoring.yml
@@ -30,8 +30,6 @@ management:
       enabled: true
     readinessState:
       enabled: true
-  server:
-    port: 8081
   observations:
     annotations:
       enabled: true

From c8f105b6f725f53ab20bf29e019479d05a9f0e96 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Thu, 18 Dec 2025 22:20:06 +0900
Subject: [PATCH 09/28] =?UTF-8?q?feat(event):=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20?=
 =?UTF-8?q?=EB=B0=8F=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 도메인 이벤트 봉투 클래스 추가
- 이벤트 역직렬화 유틸리티 구현
- JSON 직렬화/역직렬화 설정 추가
- 메트릭스 Kafka 컨슈머에서 이벤트 처리 로직 개선
- 상품 조회, 좋아요, 결제 성공 이벤트 처리 로직 추가
---
 .../loopers/domain/event/EventRepository.java |   4 +
 .../domain/metrics/MetricsService.java        |  66 +++++-
 .../domain/metrics/ProductMetricsEntity.java  |  22 ++
 .../metrics/ProductMetricsRepository.java     |   2 +
 .../cache/ProductCacheService.java            |  92 +++++++++
 .../infrastructure/config/JsonConfig.java     |  34 ++++
 .../event/DomainEventEnvelope.java            |  16 ++
 .../event/EventDeserializer.java              |  65 ++++++
 .../event/EventRepositoryImpl.java            |  10 +
 .../event/payloads/LikeActionPayloadV1.java   |  14 ++
 .../payloads/PaymentSuccessPayloadV1.java     |  17 ++
 .../event/payloads/ProductViewPayloadV1.java  |  13 ++
 .../metrics/ProductMetricsRepositoryImpl.java |   5 +
 .../consumer/MetricsKafkaConsumer.java        | 188 +++++++++---------
 modules/kafka/src/main/resources/kafka.yml    |   6 +
 15 files changed, 458 insertions(+), 96 deletions(-)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
index 01d8a9993..7aada55e4 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
@@ -7,4 +7,8 @@
  */
 public interface EventRepository {
     EventEntity save(EventEntity eventEntity);
+    
+    void deleteAll();
+    
+    boolean existsById(String eventId);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index b537857a8..a26fb87c6 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -1,5 +1,8 @@
 package com.loopers.domain.metrics;
 
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
 import java.util.Optional;
 
 import org.springframework.dao.DataIntegrityViolationException;
@@ -7,8 +10,10 @@
 
 import com.loopers.domain.event.EventEntity;
 import com.loopers.domain.event.EventRepository;
+import com.loopers.infrastructure.cache.ProductCacheService;
 
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
 import jakarta.transaction.Transactional;
 
@@ -19,9 +24,11 @@
  */
 @Component
 @RequiredArgsConstructor
+@Slf4j
 public class MetricsService {
     private final EventRepository eventHandledRepository;
     private final ProductMetricsRepository productMetricsRepository;
+    private final ProductCacheService productCacheService;
 
     @Transactional
     public boolean tryMarkHandled(String eventId) {
@@ -34,28 +41,75 @@ public boolean tryMarkHandled(String eventId) {
     }
 
     @Transactional
-    public void incrementView(Long productId) {
+    public void incrementView(Long productId, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
-        metrics.incrementView();
+        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
+        
+        if (isEventTooOld(metrics, eventTime)) {
+            log.debug("Ignoring old PRODUCT_VIEW event for productId: {}, eventTime: {}, lastEventAt: {}", 
+                     productId, eventTime, metrics.getLastEventAt());
+            return;
+        }
+        
+        metrics.incrementView(eventTime);
         productMetricsRepository.save(metrics);
+        
+        // 캐시 무효화 (조회수 임계값 체크)
+        productCacheService.onViewCountChanged(productId, metrics.getViewCount());
     }
 
     @Transactional
-    public void applyLikeDelta(final Long productId, final int delta) {
+    public void applyLikeDelta(final Long productId, final int delta, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
-        metrics.applyLikeDelta(delta);
+        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
+        
+        if (isEventTooOld(metrics, eventTime)) {
+            log.debug("Ignoring old LIKE_ACTION event for productId: {}, eventTime: {}, lastEventAt: {}", 
+                     productId, eventTime, metrics.getLastEventAt());
+            return;
+        }
+        
+        metrics.applyLikeDelta(delta, eventTime);
         productMetricsRepository.save(metrics);
+        
+        // 캐시 무효화 (좋아요 수 변경)
+        productCacheService.onLikeCountChanged(productId);
     }
 
     @Transactional
-    public void addSales(final Long productId, final int quantity) {
+    public void addSales(final Long productId, final int quantity, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
-        metrics.addSales(quantity);
+        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
+        
+        if (isEventTooOld(metrics, eventTime)) {
+            log.debug("Ignoring old PAYMENT_SUCCESS event for productId: {}, eventTime: {}, lastEventAt: {}", 
+                     productId, eventTime, metrics.getLastEventAt());
+            return;
+        }
+        
+        metrics.addSales(quantity, eventTime);
         productMetricsRepository.save(metrics);
+        
+        // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향)
+        productCacheService.onSalesCountChanged(productId);
     }
 
     private ProductMetricsEntity getOrCreate(final Long productId) {
         final Optional<ProductMetricsEntity> found = productMetricsRepository.findById(productId);
         return found.orElseGet(() -> ProductMetricsEntity.create(productId));
     }
+    
+    private ZonedDateTime toZonedDateTime(long epochMillis) {
+        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault());
+    }
+    
+    private boolean isEventTooOld(ProductMetricsEntity metrics, ZonedDateTime eventTime) {
+        // 첫 번째 이벤트인 경우 항상 처리
+        if (metrics.getLastEventAt() == null) {
+            return false;
+        }
+        
+        // 이벤트 시간이 마지막 처리 시간보다 이전인 경우 무시
+        return eventTime.isBefore(metrics.getLastEventAt());
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
index 23e3a626c..03b967e32 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -52,6 +52,11 @@ public void incrementView() {
         this.viewCount += 1;
         this.lastEventAt = ZonedDateTime.now();
     }
+    
+    public void incrementView(ZonedDateTime eventTime) {
+        this.viewCount += 1;
+        this.lastEventAt = eventTime;
+    }
 
     public void applyLikeDelta(final int delta) {
         final long next = this.likeCount + delta;
@@ -61,6 +66,15 @@ public void applyLikeDelta(final int delta) {
 
         this.lastEventAt = ZonedDateTime.now();
     }
+    
+    public void applyLikeDelta(final int delta, ZonedDateTime eventTime) {
+        final long next = this.likeCount + delta;
+
+        // 좋아요 수는 0 미만으로 내려가지 않도록 보장
+        this.likeCount = Math.max(0, next);
+
+        this.lastEventAt = eventTime;
+    }
 
     public void addSales(final int quantity) {
         if (quantity <= 0) {
@@ -69,5 +83,13 @@ public void addSales(final int quantity) {
         this.salesCount += quantity;
         this.lastEventAt = ZonedDateTime.now();
     }
+    
+    public void addSales(final int quantity, ZonedDateTime eventTime) {
+        if (quantity <= 0) {
+            return;
+        }
+        this.salesCount += quantity;
+        this.lastEventAt = eventTime;
+    }
 
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
index 2860da673..947a50632 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -11,4 +11,6 @@ public interface ProductMetricsRepository {
     ProductMetricsEntity save(ProductMetricsEntity metrics);
 
     Optional<ProductMetricsEntity> findById(Long productId);
+    
+    void deleteAll();
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
new file mode 100644
index 000000000..c776212da
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
@@ -0,0 +1,92 @@
+package com.loopers.infrastructure.cache;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 상품 캐시 관리 서비스
+ * 메트릭 변화 시 관련 캐시를 무효화
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class ProductCacheService {
+    
+    private final RedisTemplate<String, String> redisTemplate;
+    
+    private static final String PRODUCT_CACHE_PREFIX = "product:";
+    private static final String PRODUCT_LIST_CACHE_PREFIX = "product-list:";
+    private static final String POPULAR_PRODUCTS_KEY = "popular-products";
+    
+    /**
+     * 특정 상품의 캐시를 무효화
+     */
+    public void evictProductCache(Long productId) {
+        try {
+            String productKey = PRODUCT_CACHE_PREFIX + productId;
+            Boolean deleted = redisTemplate.delete(productKey);
+            
+            if (deleted) {
+                log.debug("Evicted product cache for productId: {}", productId);
+            }
+        } catch (Exception e) {
+            log.warn("Failed to evict product cache for productId: {}", productId, e);
+        }
+    }
+    
+    /**
+     * 상품 목록 관련 캐시들을 무효화
+     * 인기 상품, 추천 상품 등의 목록이 변경될 수 있음
+     */
+    public void evictProductListCaches() {
+        try {
+            // 인기 상품 목록 캐시 삭제
+            redisTemplate.delete(POPULAR_PRODUCTS_KEY);
+            
+            // 상품 목록 관련 캐시들 패턴 매칭으로 삭제
+            var keys = redisTemplate.keys(PRODUCT_LIST_CACHE_PREFIX + "*");
+            if (keys != null && !keys.isEmpty()) {
+                redisTemplate.delete(keys);
+                log.debug("Evicted {} product list cache entries", keys.size());
+            }
+        } catch (Exception e) {
+            log.warn("Failed to evict product list caches", e);
+        }
+    }
+    
+    /**
+     * 판매량 변화 시 호출 - 인기 상품 순위가 변경될 수 있음
+     */
+    public void onSalesCountChanged(Long productId) {
+        evictProductCache(productId);
+        evictProductListCaches();
+        log.debug("Evicted caches due to sales count change for productId: {}", productId);
+    }
+    
+    /**
+     * 좋아요 수 변화 시 호출 - 상품 상세 정보 갱신 필요
+     */
+    public void onLikeCountChanged(Long productId) {
+        evictProductCache(productId);
+        log.debug("Evicted product cache due to like count change for productId: {}", productId);
+    }
+    
+    /**
+     * 조회수 변화 시 호출 - 일반적으로 캐시 무효화하지 않음 (성능상 이유)
+     * 하지만 특정 임계값을 넘으면 인기 상품 목록 갱신
+     */
+    public void onViewCountChanged(Long productId, long newViewCount) {
+        // 조회수가 특정 임계값(예: 1000의 배수)을 넘으면 인기 상품 목록 갱신
+        if (newViewCount > 0 && newViewCount % 1000 == 0) {
+            evictProductListCaches();
+            log.debug("Evicted product list caches due to view milestone for productId: {} (views: {})", 
+                     productId, newViewCount);
+        }
+    }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
new file mode 100644
index 000000000..83194093d
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
@@ -0,0 +1,34 @@
+package com.loopers.infrastructure.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+/**
+ * JSON 직렬화/역직렬화 설정
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@Configuration
+public class JsonConfig {
+    
+    @Bean
+    public ObjectMapper objectMapper() {
+        ObjectMapper mapper = new ObjectMapper();
+        
+        // Java 8 시간 API 지원
+        mapper.registerModule(new JavaTimeModule());
+        
+        // 카멜케이스 사용
+        mapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
+        
+        // 알 수 없는 속성 무시 (하위 호환성)
+        mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+        
+        return mapper;
+    }
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
new file mode 100644
index 000000000..e3fb05df1
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/DomainEventEnvelope.java
@@ -0,0 +1,16 @@
+package com.loopers.infrastructure.event;
+
+/**
+ * 도메인 이벤트 봉투 - commerce-api와 동일한 구조
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record DomainEventEnvelope(
+        String eventId,
+        String eventType,
+        String version,
+        long occurredAtEpochMillis,
+        String payloadJson
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
new file mode 100644
index 000000000..b6dd3cab9
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
@@ -0,0 +1,65 @@
+package com.loopers.infrastructure.event;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 이벤트 역직렬화 유틸리티
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class EventDeserializer {
+    
+    private final ObjectMapper objectMapper;
+    
+    public DomainEventEnvelope deserializeEnvelope(Object kafkaValue) {
+        try {
+            if (kafkaValue instanceof String json) {
+                return objectMapper.readValue(json, DomainEventEnvelope.class);
+            }
+            // Map 형태로 온 경우 JSON으로 변환 후 역직렬화
+            String json = objectMapper.writeValueAsString(kafkaValue);
+            return objectMapper.readValue(json, DomainEventEnvelope.class);
+        } catch (JsonProcessingException e) {
+            log.error("Failed to deserialize event envelope: {}", kafkaValue, e);
+            return null;
+        }
+    }
+    
+    public ProductViewPayloadV1 deserializeProductView(String payloadJson) {
+        try {
+            return objectMapper.readValue(payloadJson, ProductViewPayloadV1.class);
+        } catch (JsonProcessingException e) {
+            log.error("Failed to deserialize ProductViewPayloadV1: {}", payloadJson, e);
+            return null;
+        }
+    }
+    
+    public LikeActionPayloadV1 deserializeLikeAction(String payloadJson) {
+        try {
+            return objectMapper.readValue(payloadJson, LikeActionPayloadV1.class);
+        } catch (JsonProcessingException e) {
+            log.error("Failed to deserialize LikeActionPayloadV1: {}", payloadJson, e);
+            return null;
+        }
+    }
+    
+    public PaymentSuccessPayloadV1 deserializePaymentSuccess(String payloadJson) {
+        try {
+            return objectMapper.readValue(payloadJson, PaymentSuccessPayloadV1.class);
+        } catch (JsonProcessingException e) {
+            log.error("Failed to deserialize PaymentSuccessPayloadV1: {}", payloadJson, e);
+            return null;
+        }
+    }
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
index c9eb22596..ec6aecfcf 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
@@ -21,4 +21,14 @@ public class EventRepositoryImpl implements EventRepository {
     public EventEntity save(EventEntity eventEntity) {
         return eventJpaRepository.save(eventEntity);
     }
+    
+    @Override
+    public void deleteAll() {
+        eventJpaRepository.deleteAll();
+    }
+    
+    @Override
+    public boolean existsById(String eventId) {
+        return eventJpaRepository.existsById(eventId);
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
new file mode 100644
index 000000000..4599c3dba
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
@@ -0,0 +1,14 @@
+package com.loopers.infrastructure.event.payloads;
+
+/**
+ * 좋아요 액션 이벤트 페이로드 V1
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record LikeActionPayloadV1(
+        Long productId,
+        Long userId,
+        String action  // "LIKE" or "UNLIKE"
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
new file mode 100644
index 000000000..0142c0a26
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
@@ -0,0 +1,17 @@
+package com.loopers.infrastructure.event.payloads;
+
+import java.util.List;
+
+/**
+ * 결제 성공 이벤트 페이로드 V1
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record PaymentSuccessPayloadV1(
+        Long orderNumber,
+        List<Item> items
+) {
+    public record Item(Long productId, Integer quantity) {
+    }
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java
new file mode 100644
index 000000000..fba41ebe0
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.event.payloads;
+
+/**
+ * 상품 조회 이벤트 페이로드 V1
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record ProductViewPayloadV1(
+        Long productId,
+        Long userId
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
index 633220ac4..16df85776 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -28,4 +28,9 @@ public ProductMetricsEntity save(ProductMetricsEntity metrics) {
     public Optional<ProductMetricsEntity> findById(Long productId) {
         return productMetricsJpaRepository.findById(productId);
     }
+    
+    @Override
+    public void deleteAll() {
+        productMetricsJpaRepository.deleteAll();
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
index b8ad47c2b..40d4ba4c9 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
@@ -1,7 +1,6 @@
 package com.loopers.interfaces.consumer;
 
 import java.util.List;
-import java.util.Map;
 
 import org.apache.kafka.clients.consumer.ConsumerRecord;
 import org.springframework.kafka.annotation.KafkaListener;
@@ -10,20 +9,28 @@
 
 import com.loopers.confg.kafka.KafkaConfig;
 import com.loopers.domain.metrics.MetricsService;
+import com.loopers.infrastructure.event.DomainEventEnvelope;
+import com.loopers.infrastructure.event.EventDeserializer;
+import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
 
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 
 /**
+ * 메트릭스 Kafka 컨슈머 - 멱등성과 최신성을 보장하는 안전한 이벤트 처리
  *
  * @author hyunjikoh
  * @since 2025. 12. 16.
  */
-
 @Component
 @RequiredArgsConstructor
+@Slf4j
 public class MetricsKafkaConsumer {
 
     private final MetricsService metricsService;
+    private final EventDeserializer eventDeserializer;
 
     @KafkaListener(
             topics = {"catalog-events"},
@@ -32,39 +39,21 @@ public class MetricsKafkaConsumer {
     public void onCatalogEvents(
             List<ConsumerRecord<Object, Object>> records,
             Acknowledgment ack) {
+        
+        log.debug("Processing {} catalog events", records.size());
+        
         for (ConsumerRecord<Object, Object> record : records) {
-            final Map<String, Object> event = asMap(record.value());
-            final String eventId = str(event.get("eventId"));
-            if (eventId == null) {
-                continue;
-            }
-
-            final boolean first = metricsService.tryMarkHandled(eventId);
-            if (!first) {
-                continue;
-            }
-
-            final String eventType = str(event.get("eventType"));
-            final Map<String, Object> payload = asMap(event.get("payload"));
-
-            if ("PRODUCT_VIEW".equals(eventType)) {
-                final Long productId = longVal(payload.get("productId"));
-                if (productId != null) {
-                    metricsService.incrementView(productId);
-                }
-            }
-
-            if ("LIKE_ACTION".equals(eventType)) {
-                final Long productId = longVal(payload.get("productId"));
-                final String action = str(payload.get("action")); // LIKE / UNLIKE
-                if (productId != null && action != null) {
-                    final int delta = "LIKE".equals(action) ? 1 : -1;
-                    metricsService.applyLikeDelta(productId, delta);
-                }
+            try {
+                processCatalogEvent(record);
+            } catch (Exception e) {
+                log.error("Failed to process catalog event: {}", record.value(), e);
+                // 개별 메시지 실패는 로그만 남기고 계속 진행
+                // 전체 배치를 실패시키지 않음
             }
         }
 
         ack.acknowledge();
+        log.debug("Acknowledged {} catalog events", records.size());
     }
 
     @KafkaListener(
@@ -75,83 +64,102 @@ public void onOrderEvents(
             final List<ConsumerRecord<Object, Object>> records,
             final Acknowledgment ack
     ) {
+        
+        log.debug("Processing {} order events", records.size());
+        
         for (ConsumerRecord<Object, Object> record : records) {
-            final Map<String, Object> event = asMap(record.value());
-            final String eventId = str(event.get("eventId"));
-            if (eventId == null) {
-                continue;
-            }
-
-            final boolean first = metricsService.tryMarkHandled(eventId);
-            if (!first) {
-                continue;
-            }
-
-            final String eventType = str(event.get("eventType"));
-
-            //
-            if (!"PAYMENT_SUCCESS".equals(eventType)) {
-                continue;
-            }
-
-            // 기대 payload:
-            // { "items": [ { "productId": 1, "quantity": 2 }, ... ] }
-            final Map<String, Object> payload = asMap(event.get("payload"));
-            final List<Map<String, Object>> items = listOfMap(payload.get("items"));
-            for (Map<String, Object> item : items) {
-                final Long productId = longVal(item.get("productId"));
-                final Integer quantity = intVal(item.get("quantity"));
-                if (productId != null && quantity != null) {
-                    metricsService.addSales(productId, quantity);
-                }
+            try {
+                processOrderEvent(record);
+            } catch (Exception e) {
+                log.error("Failed to process order event: {}", record.value(), e);
+                // 개별 메시지 실패는 로그만 남기고 계속 진행
             }
         }
 
         ack.acknowledge();
+        log.debug("Acknowledged {} order events", records.size());
     }
+    
+    private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
+        final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value());
+        if (envelope == null || envelope.eventId() == null) {
+            log.warn("Invalid event envelope: {}", record.value());
+            return;
+        }
 
-    private Map<String, Object> asMap(final Object value) {
-        if (value instanceof Map<?, ?> m) {
-            return (Map<String, Object>) m;
+        // 멱등성 체크 - 이미 처리된 이벤트는 무시
+        final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId());
+        if (!isFirstTime) {
+            log.debug("Event already processed: {}", envelope.eventId());
+            return;
         }
-        return Map.of();
-    }
 
-    private List<Map<String, Object>> listOfMap(final Object value) {
-        if (value instanceof List<?> list) {
-            return list.stream()
-                    .filter(it -> it instanceof Map<?, ?>)
-                    .map(it -> (Map<String, Object>) it)
-                    .toList();
+        // 이벤트 타입별 처리
+        switch (envelope.eventType()) {
+            case "PRODUCT_VIEW" -> handleProductView(envelope);
+            case "LIKE_ACTION" -> handleLikeAction(envelope);
+            default -> log.debug("Unhandled catalog event type: {}", envelope.eventType());
         }
-        return List.of();
     }
+    
+    private void processOrderEvent(ConsumerRecord<Object, Object> record) {
+        final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value());
+        if (envelope == null || envelope.eventId() == null) {
+            log.warn("Invalid event envelope: {}", record.value());
+            return;
+        }
 
-    private String str(final Object value) {
-        return value == null ? null : String.valueOf(value);
-    }
+        // 멱등성 체크
+        final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId());
+        if (!isFirstTime) {
+            log.debug("Event already processed: {}", envelope.eventId());
+            return;
+        }
 
-    private Long longVal(final Object value) {
-        if (value instanceof Number n) {
-            return n.longValue();
+        // PAYMENT_SUCCESS 이벤트만 처리
+        if ("PAYMENT_SUCCESS".equals(envelope.eventType())) {
+            handlePaymentSuccess(envelope);
+        } else {
+            log.debug("Unhandled order event type: {}", envelope.eventType());
         }
-        try {
-            return value == null ? null : Long.parseLong(String.valueOf(value));
-        } catch (Exception e) {
-            return null;
+    }
+    
+    private void handleProductView(DomainEventEnvelope envelope) {
+        final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson());
+        if (payload == null || payload.productId() == null) {
+            log.warn("Invalid ProductView payload: {}", envelope.payloadJson());
+            return;
         }
+        
+        metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis());
+        log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId());
     }
-
-    private Integer intVal(final Object value) {
-        if (value instanceof Number n) {
-            return n.intValue();
+    
+    private void handleLikeAction(DomainEventEnvelope envelope) {
+        final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson());
+        if (payload == null || payload.productId() == null || payload.action() == null) {
+            log.warn("Invalid LikeAction payload: {}", envelope.payloadJson());
+            return;
+        }
+        
+        final int delta = "LIKE".equals(payload.action()) ? 1 : -1;
+        metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis());
+        log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action());
+    }
+    
+    private void handlePaymentSuccess(DomainEventEnvelope envelope) {
+        final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson());
+        if (payload == null || payload.items() == null) {
+            log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson());
+            return;
         }
-        try {
-            return value == null ? null : Integer.parseInt(String.valueOf(value));
-        } catch (Exception e) {
-            return null;
+        
+        for (PaymentSuccessPayloadV1.Item item : payload.items()) {
+            if (item.productId() != null && item.quantity() != null && item.quantity() > 0) {
+                metricsService.addSales(item.productId(), item.quantity(), envelope.occurredAtEpochMillis());
+                log.debug("Processed PAYMENT_SUCCESS for productId: {}, quantity: {}", 
+                         item.productId(), item.quantity());
+            }
         }
     }
-
-
 }
diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml
index 9609dbf85..d2a5cf33d 100644
--- a/modules/kafka/src/main/resources/kafka.yml
+++ b/modules/kafka/src/main/resources/kafka.yml
@@ -15,6 +15,12 @@ spring:
       key-serializer: org.apache.kafka.common.serialization.StringSerializer
       value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
       retries: 3
+      acks: all
+      properties:
+        enable.idempotence: true
+        max.in.flight.requests.per.connection: 5
+        delivery.timeout.ms: 120000
+        request.timeout.ms: 30000
     consumer:
       group-id: loopers-default-consumer
       key-deserializer: org.apache.kafka.common.serialization.StringDeserializer

From 6037783db05b7bb088c489124cbd3fa0c714586e Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Thu, 18 Dec 2025 23:20:35 +0900
Subject: [PATCH 10/28] =?UTF-8?q?feat(kafka):=20Kafka=20=EC=9D=B4=EB=B2=A4?=
 =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?=
 =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?=
 =?UTF-8?q?=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

- Kafka 기반 이벤트 파이프라인 진행 문서 작성
- Producer와 Consumer의 흐름 및 아키텍처 설명
- E2E 테스트 시나리오 추가로 아웃박스 패턴 및 멱등성 검증
---
 docs/week8/todoList             | 136 +++++++++++++++
 stress/event-driven-e2e-test.js | 284 ++++++++++++++++++++++++++++++++
 2 files changed, 420 insertions(+)
 create mode 100644 docs/week8/todoList
 create mode 100755 stress/event-driven-e2e-test.js

diff --git a/docs/week8/todoList b/docs/week8/todoList
new file mode 100644
index 000000000..4e4ebc950
--- /dev/null
+++ b/docs/week8/todoList
@@ -0,0 +1,136 @@
+# Week8 Kafka Event Pipeline 진행 문서 (현황 + 앞으로 할 일)
+
+## TL;DR
+- **commerce-api(Producer)**는 도메인 이벤트를 발생시키고(결제 완료 등), 트랜잭션 커밋 이후 리스너가 **Kafka로 이벤트를 발행**한다.
+- **commerce-streamer(Consumer)**는 Kafka 메시지를 받아 `event_handled`로 **멱등 처리**하고, `product_metrics`에 **집계(view/like/sales)** 를 반영한다.
+- 다음 단계는 **Consumer가 Producer의 Envelope 포맷을 제대로 파싱**하게 맞추고, 이후 **Outbox 패턴으로 Producer를 At-Least-Once로 승격**하는 것이다.
+
+---
+
+## 1) 목표 (Week8 과제 관점)
+### Must-Have
+- Kafka 기반 이벤트 파이프라인 구축
+- Producer: 이벤트를 “반드시(At Least Once)” 발행할 수 있게 설계
+- Consumer: 중복 메시지에도 결과는 “한 번만(At Most Once처럼 보이게)” 반영
+- manual ack + 멱등 처리(`event_handled(event_id PK)`)
+- 집계 테이블에 upsert (`product_metrics`: view/like/sales)
+
+### 우리 팀의 결정 사항
+- **판매량(sales_count)은 결제 성공(PG 콜백 SUCCESS) 시점 기준**으로 반영한다.
+- Streamer는 두 토픽을 모두 처리한다:
+  - `catalog-events`: 상품 이벤트
+  - `order-events`: 주문/결제 이벤트
+
+---
+
+## 2) 현재까지 구현한 아키텍처 (관심사 분리 포인트 포함)
+
+### 2.1 Producer (commerce-api) 흐름
+#### 핵심 아이디어
+- 유스케이스(Facade)는 “상태 변경”까지만 책임진다.
+- 외부 전송(Kafka/DataPlatform)은 `@TransactionalEventListener(AFTER_COMMIT)`에서 수행한다.
+- 도메인 엔티티는 “이벤트 발생 사실”만 기록(registerEvent).
+
+#### 결제 성공 시 흐름 (정상 시나리오)
+1) PG 콜백 요청 수신 → `PaymentFacade.handlePaymentCallback()`
+2) 결제 엔티티 상태 변경 → `PaymentService.processPaymentResult()` → `PaymentEntity.processCallbackResult()`
+3) 성공이면 엔티티 내부에서 도메인 이벤트 등록:
+   - `PaymentEntity.completeWithEvent()` → `registerEvent(new PaymentCompletedEvent(...))`
+4) 트랜잭션 커밋
+5) 커밋 이후 이벤트 리스너 실행:
+   - (기존) `DataPlatformEventHandler.handlePaymentCompleted(...)` → 데이터 플랫폼 전송
+   - (추가) `PaymentKafkaPublishEventHandler.handlePaymentCompleted(...)` → Kafka 발행
+
+#### Kafka 발행 로직 위치
+- `PaymentKafkaPublishEventHandler` (AFTER_COMMIT + @Async)
+  - 주문/주문항목을 조회하여 판매량 집계에 필요한 payload 구성
+  - Envelope 생성 (`DomainEventEnvelopeFactory`)
+  - Kafka publish (`DomainEventPublisher`)
+
+> NOTE:
+> AFTER_COMMIT 방식은 “DB 실패했는데 Kafka 발행”을 방지하는데 유리하지만,
+> “DB는 커밋됐는데 Kafka 발행 실패”는 완전히 막지 못한다.
+> 이 문제는 Outbox 패턴으로 해결한다(3단계 목표).
+
+---
+
+### 2.2 Consumer (commerce-streamer) 흐름
+#### 핵심 아이디어
+- Kafka 메시지는 중복될 수 있다(At Least Once 전제).
+- 따라서 Consumer는 `event_handled(event_id PK)`로 멱등 처리한다.
+- 성공적으로 DB 반영 후에만 manual ack 한다.
+
+#### Streamer에서 만든 구성요소
+- `event_handled` 테이블(엔티티): 이미 처리된 eventId 저장
+- `product_metrics` 테이블(엔티티): product_id 기준 집계
+  - view_count, like_count, sales_count, last_event_at
+- `MetricsService`:
+  - `tryMarkHandled(eventId)` → 중복이면 skip
+  - `incrementView`, `applyLikeDelta`, `addSales` 등 upsert
+- `MetricsKafkaConsumer`:
+  - `catalog-events`: PRODUCT_VIEW, LIKE_ACTION 처리
+  - `order-events`: PAYMENT_SUCCESS 처리 (items 기반 sales_count 증가)
+  - 처리 후 ack
+
+---
+
+## 3) 현재 상태에서 확인해야 하는 체크 포인트 (2단계 마감용)
+
+### Producer 체크
+- [ ] PG 콜백 SUCCESS 발생 시 `PaymentCompletedEvent`가 실제로 발행되는지
+- [ ] `PaymentKafkaPublishEventHandler` 로그에 “Kafka 발행 완료”가 찍히는지
+- [ ] `order-events` 토픽에 메시지가 실제로 적재되는지
+- [ ] partition key가 의도대로 `orderId(PK)`인지 (`String.valueOf(order.getId())`)
+
+### Consumer 체크
+- [ ] Streamer가 `order-events` 메시지를 읽는지
+- [ ] eventId가 event_handled에 먼저 저장되는지(멱등)
+- [ ] 성공적으로 sales_count 반영 후 ack 하는지
+- [ ] 같은 eventId를 재전송해도 sales_count가 2번 증가하지 않는지
+
+---
+
+## 4) 앞으로 해야 할 작업 (우선순위 순)
+
+## 4.1 (즉시) Producer ↔ Consumer 메시지 계약(Contract) 맞추기
+현재 streamer는 Map 기반/또는 payload 형태에 의존한다.
+Producer가 보내는 Envelope 구조와 Consumer가 파싱하는 구조를 “하나의 계약”으로 고정해야 한다.
+
+- [ ] Envelope 스펙 확정:
+  - eventId, eventType, version, occurredAtEpochMillis, payloadJson
+- [ ] PAYMENT_SUCCESS(v1) payloadJson 스펙 확정:
+  - orderId
+  - items[{productId, quantity}]
+- [ ] Streamer의 `MetricsKafkaConsumer`가 `payloadJson`을 DTO로 파싱하도록 정리
+  - PaymentSuccessPayloadV1 DTO를 streamer 쪽에도 동일 의미로 유지
+
+## 4.2 (테스트) 최소 E2E 확인
+- [ ] PG 콜백 SUCCESS 1회 → Kafka 발행 → Streamer consume → DB sales_count 증가
+- [ ] 동일 메시지(동일 eventId) 2회 → DB는 1회만 반영(event_handled로 차단)
+
+## 4.3 (수요일 핵심) Outbox 패턴으로 Producer 승격 (At Least Once 달성)
+AFTER_COMMIT에서 Kafka send 실패 시 유실 가능성을 제거하기 위해 Outbox로 승격한다.
+
+- [ ] `outbox_event` 테이블 추가 (event_id PK, topic, key, payload_json, status, retry_count, created_at, published_at)
+- [ ] `PaymentKafkaPublishEventHandler`의 “Kafka send”를 “Outbox insert”로 변경
+- [ ] Outbox Relay(스케줄러/워커) 구현:
+  - READY 조회 → Kafka publish → SENT 업데이트
+  - 실패 시 retry/backoff
+- [ ] 중복 발행 가능성은 consumer 멱등(event_handled)로 처리
+
+## 4.4 (목요일) 최신성/정합성 보강 + 문서/PR 정리
+- [ ] product_metrics.last_event_at 기반으로 “오래된 이벤트 무시” 로직 반영(필요 시)
+- [ ] 실패/재시도 전략(DLQ는 Nice-to-have) 간단 정리
+- [ ] PR Review Points 작성
+  - 왜 sales_count는 결제 성공 기준인가?
+  - 왜 도메인 이벤트 + AFTER_COMMIT + outbox로 구성했는가?
+  - 왜 streamer는 event_handled로 멱등 처리하는가?
+
+---
+
+## 5) 운영/리스크 메모 (짧게)
+- AFTER_COMMIT + Async는 “DB 정합성”에는 유리하지만 “발행 보장”은 약함 → Outbox로 해결
+- Consumer는 At Least Once를 전제로 설계해야 함 → event_handled 멱등은 필수
+- 이벤트 타입은 클래스명 기반이 아니라 “계약된 eventType + version”으로 관리하는 것이 안전
+
+---
\ No newline at end of file
diff --git a/stress/event-driven-e2e-test.js b/stress/event-driven-e2e-test.js
new file mode 100755
index 000000000..ea7cb80c1
--- /dev/null
+++ b/stress/event-driven-e2e-test.js
@@ -0,0 +1,284 @@
+// event-driven-e2e-test.js
+// 이벤트 기반 E2E 테스트: 아웃박스 패턴과 컨슈머 멱등성 검증
+import http from 'k6/http';
+import { check, sleep } from 'k6';
+import { Counter, Rate, Trend } from 'k6/metrics';
+
+export const options = {
+    scenarios: {
+        // 시나리오 1: 상품 조회 이벤트 대량 발생
+        product_view_load: {
+            executor: 'ramping-vus',
+            startVUs: 0,
+            stages: [
+                { duration: '10s', target: 50 },   // 50명이 동시에 상품 조회
+                { duration: '20s', target: 100 },  // 100명으로 증가
+                { duration: '10s', target: 0 },    // 종료
+            ],
+            exec: 'productViewScenario',
+        },
+        
+        // 시나리오 2: 좋아요 액션 이벤트
+        like_action_load: {
+            executor: 'ramping-vus',
+            startVUs: 0,
+            stages: [
+                { duration: '15s', target: 30 },   // 30명이 좋아요 액션
+                { duration: '15s', target: 60 },   // 60명으로 증가
+                { duration: '10s', target: 0 },    // 종료
+            ],
+            exec: 'likeActionScenario',
+            startTime: '5s', // 5초 후 시작
+        },
+        
+        // 시나리오 3: 결제 완료 이벤트
+        payment_success_load: {
+            executor: 'ramping-vus',
+            startVUs: 0,
+            stages: [
+                { duration: '20s', target: 20 },   // 20명이 결제
+                { duration: '10s', target: 0 },    // 종료
+            ],
+            exec: 'paymentSuccessScenario',
+            startTime: '10s', // 10초 후 시작
+        },
+        
+        // 시나리오 4: 멱등성 테스트 (동일한 결제 콜백 중복 전송)
+        idempotency_test: {
+            executor: 'constant-vus',
+            vus: 10,
+            duration: '30s',
+            exec: 'idempotencyScenario',
+            startTime: '20s', // 20초 후 시작
+        }
+    },
+    thresholds: {
+        'http_req_duration': ['p(95)<2000'],  // 95% 요청이 2초 이내
+        'http_req_failed': ['rate<0.05'],     // 에러율 5% 미만
+        'product_view_success': ['rate>0.95'], // 상품 조회 성공률 95% 이상
+        'like_action_success': ['rate>0.95'],  // 좋아요 성공률 95% 이상
+        'payment_success': ['rate>0.95'],      // 결제 성공률 95% 이상
+    },
+};
+
+// 메트릭 정의
+const productViewSuccess = new Rate('product_view_success');
+const likeActionSuccess = new Rate('like_action_success');
+const paymentSuccess = new Rate('payment_success');
+const idempotencyCheck = new Rate('idempotency_check');
+const eventCounter = new Counter('events_generated');
+
+const BASE_URL = 'http://localhost:8080/api/v1';
+const USERS = ['testuser1', 'testuser2', 'testuser3', 'testuser4', 'testuser5'];
+const PRODUCT_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+// 랜덤 사용자 선택
+function getRandomUser() {
+    return USERS[Math.floor(Math.random() * USERS.length)];
+}
+
+// 랜덤 상품 ID 선택
+function getRandomProductId() {
+    return PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
+}
+
+// 시나리오 1: 상품 조회 (PRODUCT_VIEW 이벤트 발생)
+export function productViewScenario() {
+    const user = getRandomUser();
+    const productId = getRandomProductId();
+    
+    const response = http.get(`${BASE_URL}/products/${productId}`, {
+        headers: { 'X-USER-ID': user }
+    });
+    
+    const success = check(response, {
+        'product view status 200': (r) => r.status === 200,
+        'product view has data': (r) => r.json('data') !== null,
+    });
+    
+    productViewSuccess.add(success);
+    eventCounter.add(1, { event_type: 'PRODUCT_VIEW' });
+    
+    sleep(0.1); // 100ms 간격
+}
+
+// 시나리오 2: 좋아요 액션 (LIKE_ACTION 이벤트 발생)
+export function likeActionScenario() {
+    const user = getRandomUser();
+    const productId = getRandomProductId();
+    const action = Math.random() > 0.7 ? 'UNLIKE' : 'LIKE'; // 70% LIKE, 30% UNLIKE
+    
+    const response = http.post(`${BASE_URL}/likes/products/${productId}`, 
+        JSON.stringify({ action: action }), 
+        {
+            headers: { 
+                'Content-Type': 'application/json',
+                'X-USER-ID': user 
+            }
+        }
+    );
+    
+    const success = check(response, {
+        'like action status 200': (r) => r.status === 200,
+        'like action success': (r) => r.json('success') === true,
+    });
+    
+    likeActionSuccess.add(success);
+    eventCounter.add(1, { event_type: 'LIKE_ACTION' });
+    
+    sleep(0.2); // 200ms 간격
+}
+
+// 시나리오 3: 결제 완료 (PAYMENT_SUCCESS 이벤트 발생)
+export function paymentSuccessScenario() {
+    const user = getRandomUser();
+    const productId1 = getRandomProductId();
+    const productId2 = getRandomProductId();
+    
+    // 1단계: 카드 결제 주문 생성
+    const orderPayload = {
+        items: [
+            { productId: productId1, quantity: Math.floor(Math.random() * 3) + 1 },
+            { productId: productId2, quantity: Math.floor(Math.random() * 2) + 1 }
+        ],
+        cardInfo: {
+            cardType: 'SAMSUNG',
+            cardNo: '1234-5678-9012-3456',
+            callbackUrl: `${BASE_URL}/payments/callback`
+        }
+    };
+    
+    const orderResponse = http.post(`${BASE_URL}/orders/card`, 
+        JSON.stringify(orderPayload), 
+        {
+            headers: { 
+                'Content-Type': 'application/json',
+                'X-USER-ID': user 
+            }
+        }
+    );
+    
+    const orderSuccess = check(orderResponse, {
+        'order creation status 200': (r) => r.status === 200,
+        'order has payment info': (r) => r.json('data.paymentInfo') !== null,
+    });
+    
+    if (orderSuccess) {
+        const orderData = orderResponse.json('data');
+        const transactionKey = orderData.paymentInfo.transactionKey;
+        const orderId = orderData.orderId.toString();
+        
+        // 2단계: PG 결제 완료 콜백 (PAYMENT_SUCCESS 이벤트 발생)
+        sleep(0.5); // PG 처리 시간 시뮬레이션
+        
+        const callbackPayload = {
+            transactionKey: transactionKey,
+            orderId: orderId,
+            cardType: 'SAMSUNG',
+            cardNo: '1234-5678-9012-3456',
+            amount: Math.floor(orderData.finalTotalAmount),
+            status: 'SUCCESS',
+            reason: null
+        };
+        
+        const callbackResponse = http.post(`${BASE_URL}/payments/callback`, 
+            JSON.stringify(callbackPayload), 
+            {
+                headers: { 'Content-Type': 'application/json' }
+            }
+        );
+        
+        const callbackSuccess = check(callbackResponse, {
+            'payment callback status 200': (r) => r.status === 200,
+            'payment callback success': (r) => r.json('success') === true,
+        });
+        
+        paymentSuccess.add(callbackSuccess);
+        eventCounter.add(1, { event_type: 'PAYMENT_SUCCESS' });
+    }
+    
+    sleep(1); // 1초 간격
+}
+
+// 시나리오 4: 멱등성 테스트 (동일한 콜백 중복 전송)
+export function idempotencyScenario() {
+    const user = getRandomUser();
+    const productId = getRandomProductId();
+    
+    // 고정된 거래 키로 중복 콜백 테스트
+    const fixedTransactionKey = `IDEMPOTENCY_TEST_${__VU}_${Math.floor(Date.now() / 10000)}`;
+    const fixedOrderId = `${__VU}${Math.floor(Math.random() * 1000)}`;
+    
+    const callbackPayload = {
+        transactionKey: fixedTransactionKey,
+        orderId: fixedOrderId,
+        cardType: 'SAMSUNG',
+        cardNo: '1234-5678-9012-3456',
+        amount: 25000,
+        status: 'SUCCESS',
+        reason: null
+    };
+    
+    // 동일한 콜백을 3번 연속 전송 (멱등성 테스트)
+    let firstSuccess = false;
+    let subsequentResponses = [];
+    
+    for (let i = 0; i < 3; i++) {
+        const response = http.post(`${BASE_URL}/payments/callback`, 
+            JSON.stringify(callbackPayload), 
+            {
+                headers: { 'Content-Type': 'application/json' }
+            }
+        );
+        
+        if (i === 0) {
+            firstSuccess = response.status === 200;
+        } else {
+            subsequentResponses.push(response.status === 200);
+        }
+        
+        sleep(0.1); // 100ms 간격으로 중복 전송
+    }
+    
+    // 첫 번째는 성공, 나머지도 성공해야 함 (멱등성)
+    const idempotent = firstSuccess && subsequentResponses.every(success => success);
+    idempotencyCheck.add(idempotent);
+    
+    if (idempotent) {
+        eventCounter.add(1, { event_type: 'IDEMPOTENCY_TEST' });
+    }
+    
+    sleep(2); // 2초 간격
+}
+
+// 테스트 완료 후 요약 출력
+export function handleSummary(data) {
+    const summary = {
+        'test_duration': data.metrics.iteration_duration ? data.metrics.iteration_duration.avg : 0,
+        'total_requests': data.metrics.http_reqs ? data.metrics.http_reqs.count : 0,
+        'failed_requests': data.metrics.http_req_failed ? data.metrics.http_req_failed.count : 0,
+        'events_generated': data.metrics.events_generated ? data.metrics.events_generated.count : 0,
+        'product_view_success_rate': data.metrics.product_view_success ? data.metrics.product_view_success.rate : 0,
+        'like_action_success_rate': data.metrics.like_action_success ? data.metrics.like_action_success.rate : 0,
+        'payment_success_rate': data.metrics.payment_success ? data.metrics.payment_success.rate : 0,
+        'idempotency_success_rate': data.metrics.idempotency_check ? data.metrics.idempotency_check.rate : 0,
+    };
+    
+    console.log('\n=== 이벤트 기반 E2E 테스트 결과 ===');
+    console.log(`총 요청 수: ${summary.total_requests}`);
+    console.log(`실패 요청 수: ${summary.failed_requests}`);
+    console.log(`생성된 이벤트 수: ${summary.events_generated}`);
+    console.log(`상품 조회 성공률: ${(summary.product_view_success_rate * 100).toFixed(2)}%`);
+    console.log(`좋아요 액션 성공률: ${(summary.like_action_success_rate * 100).toFixed(2)}%`);
+    console.log(`결제 성공률: ${(summary.payment_success_rate * 100).toFixed(2)}%`);
+    console.log(`멱등성 테스트 성공률: ${(summary.idempotency_success_rate * 100).toFixed(2)}%`);
+    
+    // 디버깅을 위한 전체 메트릭 출력
+    console.log('\n=== 디버깅 정보 ===');
+    console.log('Available metrics:', Object.keys(data.metrics));
+    
+    return {
+        'stdout': JSON.stringify(summary, null, 2),
+        'summary.json': JSON.stringify(data, null, 2),
+    };
+}
\ No newline at end of file

From 34f7b1d2dfc5421c48db661051cc427127309874 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 09:40:03 +0900
Subject: [PATCH 11/28] =?UTF-8?q?feat(kafka):=20Kafka=20=ED=86=A0=ED=94=BD?=
 =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=B6=84=EC=82=B0=EB=9D=BD?=
 =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Kafka 토픽 자동 생성 설정 추가
- 다양한 환경(dev, qa, prd)에 따른 토픽 파티션 및 복제 수 조정
- 메트릭 서비스에 Redis 분산락 적용하여 동시성 안전성 강화
- 여러 메서드에 분산락 적용하여 데이터 무결성 확보
---
 .../loopers/application/like/LikeFacade.java  |  22 +-
 .../application/payment/PaymentFacade.java    |   8 -
 .../application/product/ProductFacade.java    |  47 +--
 .../com/loopers/domain/event/EventTypes.java  |   3 +-
 .../loopers/domain/event/EventVersions.java   |   3 +-
 ...ler.java => PaymentKafkaEventHandler.java} |  36 +-
 .../domain/event/outbox/OutboxRelay.java      |  48 ---
 .../event/outbox/OutboxRelayScheduler.java    |  71 ++++
 .../domain/event/outbox/OutboxRepository.java |  10 +
 .../domain/event/outbox/OutboxService.java    |  69 ++++
 .../like/event/LikeKafkaEventHandler.java     |  99 ++++++
 .../loopers/domain/payment/PaymentEntity.java |   1 -
 .../ProductMaterializedViewEntity.java        |   6 +-
 .../ProductViewKafkaEventHandler.java         | 104 ++++++
 .../DataPlatformEventHandler.java             |  90 ++---
 .../outbox/OutboxEventJpaRepository.java      |  10 +
 .../outbox/OutboxEventRepositoryImpl.java     |   6 +
 .../event/payloads/LikeActionPayloadV1.java   |  14 +
 .../payloads/PaymentSuccessPayloadV1.java     |   2 -
 .../event/payloads/ProductViewPayloadV1.java  |  13 +
 .../product/ProductIntegrationTest.java       |  20 +-
 .../domain/metrics/MetricsService.java        |  98 +++++-
 docs/week8/lecture.md                         | 324 ++++++++++++++++++
 docs/week8/quest.md                           | 127 +++++++
 .../com/loopers/confg/kafka/KafkaConfig.java  |  73 ++++
 modules/kafka/src/main/resources/kafka.yml    |  47 +++
 26 files changed, 1176 insertions(+), 175 deletions(-)
 rename apps/commerce-api/src/main/java/com/loopers/domain/event/{PaymentKafkaPublishEventHandler.java => PaymentKafkaEventHandler.java} (66%)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java
 create mode 100644 docs/week8/lecture.md
 create mode 100644 docs/week8/quest.md

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 ee54b238f..fe3e9ce66 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
@@ -9,7 +9,6 @@
 import com.loopers.domain.product.ProductCacheService;
 import com.loopers.domain.product.ProductEntity;
 import com.loopers.domain.product.ProductService;
-import com.loopers.domain.tracking.UserBehaviorTracker;
 import com.loopers.domain.user.UserEntity;
 import com.loopers.domain.user.UserService;
 import com.loopers.support.error.CoreException;
@@ -34,7 +33,6 @@ public class LikeFacade {
     private final UserService userService;
     private final LikeService likeService;
     private final ProductCacheService cacheService;
-    private final UserBehaviorTracker behaviorTracker;
 
 
     /**
@@ -58,14 +56,8 @@ public LikeInfo upsertLike(String username, Long productId) {
         // 3. 좋아요 등록/복원 (도메인 엔티티에서 이벤트 발행)
         LikeResult result = likeService.upsertLike(user, product);
 
-        // 4. 유저 행동 추적 (좋아요 액션)
-        if (result.changed()) {
-            behaviorTracker.trackLikeAction(
-                    user.getId(),
-                    productId,
-                    "LIKE"
-            );
-        }
+        // 4. 유저 행동 추적은 도메인 이벤트(LikeChangedEvent)에서 처리됨
+        // 중복 이벤트 방지를 위해 UserBehaviorTracker 호출 제거
 
         // 5. DTO 변환 후 반환
         return LikeInfo.of(result.entity(), product, user);
@@ -93,13 +85,7 @@ public void unlikeProduct(String username, Long productId) {
         // 3. 좋아요 취소 (도메인 엔티티에서 이벤트 발행)
         boolean isChanged = likeService.unlikeProduct(user, product);
 
-        // 4. 유저 행동 추적 (좋아요 취소 액션)
-        if (isChanged) {
-            behaviorTracker.trackLikeAction(
-                    user.getId(),
-                    productId,
-                    "UNLIKE"
-            );
-        }
+        // 4. 유저 행동 추적은 도메인 이벤트(LikeChangedEvent)에서 처리됨
+        // 중복 이벤트 방지를 위해 UserBehaviorTracker 호출 제거
     }
 }
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 55fc17f68..26c6c295b 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
@@ -1,21 +1,13 @@
 package com.loopers.application.payment;
 
-import java.util.List;
-
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.loopers.domain.event.EventTypes;
-import com.loopers.domain.event.EventVersions;
 import com.loopers.domain.order.OrderEntity;
-import com.loopers.domain.order.OrderItemEntity;
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.*;
 import com.loopers.domain.user.UserEntity;
 import com.loopers.domain.user.UserService;
-import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.DomainEventPublisher;
-import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 import com.loopers.infrastructure.payment.client.dto.PgPaymentResponse;
 import com.loopers.interfaces.api.payment.PaymentV1Dtos;
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
index 33757aca6..609ee0d85 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -66,34 +66,38 @@ public Page<ProductInfo> getProducts(ProductSearchFilter productSearchFilter) {
      */
     @Transactional(readOnly = true)
     public ProductDetailInfo getProductDetail(Long productId, String username) {
-        // 1. 캐시 조회
+        // 1. 사용자 정보 및 좋아요 상태 조회
+        Long userId = null;
+        Boolean isLiked = false;
+
+        if (username != null) {
+            userId = userService.getUserByUsername(username).getId();
+            isLiked = likeService.findLike(userId, productId)
+                    .map(like -> like.getDeletedAt() == null)
+                    .orElse(false);
+        }
+
+        // 2. 캐시 조회
         Optional<ProductDetailInfo> cachedDetail = productCacheService.getProductDetailFromCache(productId);
 
-        Boolean isLiked = username != null
-                ? likeService.findLike(userService.getUserByUsername(username).getId(), productId)
-                .map(like -> like.getDeletedAt() == null)
-                .orElse(false)
-                : false;
+        ProductDetailInfo result;
 
-        // 캐시 히트 시 사용자 좋아요 상태 동기화 후 반환
         if (cachedDetail.isPresent()) {
             log.debug("상품 상세 캐시 히트 - productId: {}", productId);
-            return ProductDetailInfo.fromWithSyncLike(cachedDetail.get(), isLiked);
-        }
+            result = ProductDetailInfo.fromWithSyncLike(cachedDetail.get(), isLiked);
+        } else {
+            log.debug("상품 상세 캐시 미스 - productId: {}", productId);
 
-        log.debug("상품 상세 캐시 미스 - productId: {}", productId);
+            // 3. MV 엔티티 조회
+            ProductMaterializedViewEntity productMaterializedViewEntity = mvService.getById(productId);
+            result = ProductDetailInfo.from(productMaterializedViewEntity, isLiked);
 
-        // 2. MV 엔티티 조회
-        ProductMaterializedViewEntity productMaterializedViewEntity = mvService.getById(productId);
-
-        ProductDetailInfo productDetail = ProductDetailInfo.from(productMaterializedViewEntity, isLiked);
-
-        // 3. 캐시 저장
-        productCacheService.cacheProductDetail(productId, productDetail);
+            // 4. 캐시 저장
+            productCacheService.cacheProductDetail(productId, result);
+        }
 
-        // 4. 유저 행동 추적 (상품 조회)
-        if (username != null) {
-            Long userId = userService.getUserByUsername(username).getId();
+        // 5. 유저 행동 추적 (상품 조회) - 캐시 히트/미스와 관계없이 항상 이벤트 발행
+        if (userId != null) {
             behaviorTracker.trackProductView(
                     userId,
                     productId,
@@ -101,8 +105,7 @@ public ProductDetailInfo getProductDetail(Long productId, String username) {
             );
         }
 
-        // 5. 사용자 좋아요 상태 적용
-        return productDetail;
+        return result;
     }
 
     /**
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
index 2d44ffb62..6590b5280 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventTypes.java
@@ -6,7 +6,8 @@
  * @since 2025. 12. 16.
  */
 public final class EventTypes {
-    private EventTypes() {}
+    private EventTypes() {
+    }
 
     public static final String PAYMENT_SUCCESS = "PAYMENT_SUCCESS";
     public static final String PRODUCT_VIEW = "PRODUCT_VIEW";
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
index 693c25632..828bd9503 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/EventVersions.java
@@ -6,7 +6,8 @@
  * @since 2025. 12. 16.
  */
 public final class EventVersions {
-    private EventVersions() {}
+    private EventVersions() {
+    }
 
     public static final String V1 = "v1";
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
similarity index 66%
rename from apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
rename to apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
index 052885bbb..481ef2fc1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaPublishEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
@@ -14,13 +14,16 @@
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.event.PaymentCompletedEvent;
 import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.outbox.OutboxEventJpaRepository;
+import com.loopers.infrastructure.event.DomainEventPublisher;
 import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
 /**
+ * 결제 관련 Kafka 이벤트 발행 핸들러
+ * - 결제 완료 이벤트를 Kafka로 발행
+ * - 다른 도메인 이벤트는 각각의 전용 핸들러에서 처리
  *
  * @author hyunjikoh
  * @since 2025. 12. 17.
@@ -28,17 +31,20 @@
 @Component
 @RequiredArgsConstructor
 @Slf4j
-public class PaymentKafkaPublishEventHandler {
+public class PaymentKafkaEventHandler {
     private static final String ORDER_EVENTS_TOPIC = "order-events";
 
     private final OrderService orderService;
     private final DomainEventEnvelopeFactory envelopeFactory;
     private final OutboxRepository outboxRepository;
+    private final DomainEventPublisher domainEventPublisher;
 
+    /**
+     * 결제 완료 이벤트 처리
+     */
     @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void handlePaymentCompleted(final PaymentCompletedEvent event) {
-        // PaymentCompletedEvent의 orderNumber 는 주문 번호
         final Long orderNumber = event.orderNumber();
         final Long userId = event.userId();
 
@@ -47,17 +53,22 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
             return;
         }
 
-        try {
-            final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
-            final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
 
-            final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
-                    .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
-                    .toList();
+        final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
+        final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
+
+        final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
+                .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
+                .toList();
+
+        final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
 
-            final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
+        final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
+        final String partitionKey = String.valueOf(order.getId());
 
-            final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
+        try {
+            // 1. 즉시 Kafka 발송 시도
+            domainEventPublisher.publish(ORDER_EVENTS_TOPIC, partitionKey, envelope);
 
             OutboxEventEntity ready = OutboxEventEntity.ready(
                     envelope.eventId(),
@@ -68,6 +79,7 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
                     envelope.occurredAtEpochMillis(),
                     envelope.payloadJson()
             );
+            ready.markSent(); // SENT 상태로 변경
 
             outboxRepository.save(ready);
 
@@ -78,4 +90,6 @@ public void handlePaymentCompleted(final PaymentCompletedEvent event) {
                     EventTypes.PAYMENT_SUCCESS, orderNumber, userId, e);
         }
     }
+
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
deleted file mode 100644
index ca16abed6..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelay.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.loopers.domain.event.outbox;
-
-import java.util.List;
-
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import com.loopers.infrastructure.event.DomainEventEnvelope;
-import com.loopers.infrastructure.event.DomainEventPublisher;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class OutboxRelay {
-
-    private final OutboxRepository outboxRepository;
-    private final DomainEventPublisher domainEventPublisher;
-
-    @Scheduled(fixedDelay = 1000)
-    @Transactional
-    public void relay() {
-        final List<OutboxEventEntity> readyEvents =
-                outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.READY);
-
-        for (OutboxEventEntity e : readyEvents) {
-            try {
-                final DomainEventEnvelope envelope = new DomainEventEnvelope(
-                        e.getEventId(),
-                        e.getEventType(),
-                        e.getVersion(),
-                        e.getOccurredAtEpochMillis(),
-                        e.getPayloadJson()
-                );
-
-                domainEventPublisher.publish(e.getTopic(), e.getMessageKey(), envelope);
-
-                e.markSent();
-            } catch (Exception ex) {
-                e.markFailed();
-                log.warn("Outbox relay 실패 - eventId={}, retryCount={}", e.getEventId(), e.getRetryCount(), ex);
-            }
-        }
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
new file mode 100644
index 000000000..bce9830c7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
@@ -0,0 +1,71 @@
+package com.loopers.domain.event.outbox;
+
+import java.util.List;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 실패한 Outbox 이벤트 재시도 스케줄러
+ * <p>
+ * Event-Driven + Outbox Fallback 패턴에서:
+ * - 즉시 발송은 이벤트 핸들러에서 담당
+ * - 실패한 이벤트만 이 스케줄러에서 재시도
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 17.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OutboxRelayScheduler {
+
+    private final OutboxService outboxService;
+    private static final int MAX_RETRY_COUNT = 5;
+
+    /**
+     * 실패한 이벤트만 재시도 (FAILED 상태)
+     * 30초마다 실행 - 즉시 발송이 주 방식이므로 재시도는 여유롭게
+     */
+    @Scheduled(fixedDelay = 30000)
+    public void retryFailedEvents() {
+        final List<OutboxEventEntity> failedEvents =
+                outboxService.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+                        OutboxStatus.FAILED, MAX_RETRY_COUNT);
+
+        if (failedEvents.isEmpty()) {
+            log.debug("재시도할 실패 이벤트 없음");
+            return;
+        }
+
+        log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {}", failedEvents.size());
+
+        int retrySuccessCount = 0;
+        int retryFailCount = 0;
+        int maxRetryReachedCount = 0;
+
+        for (OutboxEventEntity event : failedEvents) {
+            if (event.getRetryCount() >= MAX_RETRY_COUNT) {
+                maxRetryReachedCount++;
+                log.warn("최대 재시도 횟수 초과 - eventId: {}, retryCount: {}",
+                        event.getEventId(), event.getRetryCount());
+                continue;
+            }
+
+            boolean isSuccess = outboxService.processEvent(event);
+            if (isSuccess) {
+                retrySuccessCount++;
+                log.info("재시도 성공 - eventId: {}, retryCount: {}",
+                        event.getEventId(), event.getRetryCount());
+            } else {
+                retryFailCount++;
+            }
+        }
+
+        log.info("실패 이벤트 재시도 완료 - 성공: {}, 실패: {}, 최대재시도초과: {}",
+                retrySuccessCount, retryFailCount, maxRetryReachedCount);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
index 2a51d9764..a47f79447 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -10,5 +10,15 @@
 public interface OutboxRepository {
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus);
 
+    /**
+     * 재시도 가능한 실패 이벤트 조회
+     *
+     * @param status        상태 (FAILED)
+     * @param maxRetryCount 최대 재시도 횟수
+     * @return 재시도 가능한 이벤트 목록
+     */
+    List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            OutboxStatus status, int maxRetryCount);
+
     OutboxEventEntity save(OutboxEventEntity ready);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
new file mode 100644
index 000000000..07f1b570a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
@@ -0,0 +1,69 @@
+package com.loopers.domain.event.outbox;
+
+import java.util.List;
+
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.loopers.infrastructure.event.DomainEventEnvelope;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OutboxService {
+    private final OutboxRepository outboxRepository;
+    private final DomainEventPublisher domainEventPublisher;
+
+    /**
+     * 개별 이벤트 처리 (독립적인 트랜잭션)
+     *
+     * @param event 처리할 이벤트
+     * @return 성공 여부
+     */
+    @Transactional
+    public boolean processEvent(OutboxEventEntity event) {
+        try {
+            final DomainEventEnvelope envelope = new DomainEventEnvelope(
+                    event.getEventId(),
+                    event.getEventType(),
+                    event.getVersion(),
+                    event.getOccurredAtEpochMillis(),
+                    event.getPayloadJson()
+            );
+
+            // Kafka 발송
+            domainEventPublisher.publish(event.getTopic(), event.getMessageKey(), envelope);
+
+            // 성공 시 상태 업데이트
+            event.markSent();
+            outboxRepository.save(event);
+
+            log.debug("이벤트 발송 성공 - eventId: {}, topic: {}", event.getEventId(), event.getTopic());
+            return true;
+
+        } catch (Exception ex) {
+            // 실패 시 상태 업데이트
+            event.markFailed();
+            outboxRepository.save(event);
+
+            log.warn("이벤트 발송 실패 - eventId: {}, topic: {}, retryCount: {}, error: {}",
+                    event.getEventId(), event.getTopic(), event.getRetryCount(), ex.getMessage());
+            return false;
+        }
+    }
+
+    public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(OutboxStatus outboxStatus,
+                                                                                             int maxRetryCount) {
+        return outboxRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+                outboxStatus, maxRetryCount);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
new file mode 100644
index 000000000..8644dcfcc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
@@ -0,0 +1,99 @@
+package com.loopers.domain.like.event;
+
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 좋아요 도메인 이벤트를 Kafka로 즉시 발송하는 핸들러
+ * <p>
+ * Event-Driven + Outbox Fallback 패턴:
+ * 1. 트랜잭션 커밋 후 즉시 Kafka 발송 시도
+ * 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
+ * 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class LikeKafkaEventHandler {
+    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
+
+    private final DomainEventEnvelopeFactory envelopeFactory;
+    private final DomainEventPublisher domainEventPublisher;
+    private final OutboxRepository outboxRepository;
+
+    /**
+     * 좋아요 변경 도메인 이벤트를 Kafka로 즉시 발송
+     * 실패 시 Outbox에 저장하여 나중에 재시도
+     */
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleLikeChanged(final LikeChangedEvent event) {
+        final Long productId = event.productId();
+        final Long userId = event.userId();
+        final String action = event.action().name(); // LIKE or UNLIKE
+
+        if (productId == null || userId == null || action == null) {
+            log.warn("이벤트 발송 스킵 - 필수값 누락 productId={}, userId={}, action={}",
+                    productId, userId, action);
+            return;
+        }
+
+        final LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, userId, action);
+        final var envelope = envelopeFactory.create(EventTypes.LIKE_ACTION, EventVersions.V1, payload);
+        final String partitionKey = String.valueOf(productId);
+
+        try {
+            // 1. 즉시 Kafka 발송 시도
+            domainEventPublisher.publish(CATALOG_EVENTS_TOPIC, partitionKey, envelope);
+
+            // 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
+            OutboxEventEntity sent = OutboxEventEntity.ready(
+                    envelope.eventId(),
+                    CATALOG_EVENTS_TOPIC,
+                    partitionKey,
+                    envelope.eventType(),
+                    envelope.version(),
+                    envelope.occurredAtEpochMillis(),
+                    envelope.payloadJson()
+            );
+            sent.markSent(); // SENT 상태로 변경
+            outboxRepository.save(sent);
+
+            log.info("이벤트 즉시 발송 성공 - type={}, productId={}, userId={}, action={}",
+                    EventTypes.LIKE_ACTION, productId, userId, action);
+
+        } catch (Exception e) {
+            // 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
+            OutboxEventEntity failed = OutboxEventEntity.ready(
+                    envelope.eventId(),
+                    CATALOG_EVENTS_TOPIC,
+                    partitionKey,
+                    envelope.eventType(),
+                    envelope.version(),
+                    envelope.occurredAtEpochMillis(),
+                    envelope.payloadJson()
+            );
+            failed.markFailed(); // FAILED 상태로 변경
+            outboxRepository.save(failed);
+
+            log.warn("이벤트 즉시 발송 실패, Outbox에 저장 - type={}, productId={}, userId={}, action={}, error={}",
+                    EventTypes.LIKE_ACTION, productId, userId, action, e.getMessage());
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEntity.java
index 4850c94cb..a1bc15fa2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEntity.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEntity.java
@@ -178,7 +178,6 @@ public void completeWithEvent() {
     }
 
 
-
     /**
      * 결제 실패 처리 (도메인 이벤트 발행)
      */
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java
index 993e05462..04fec63f8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMaterializedViewEntity.java
@@ -359,10 +359,6 @@ public static boolean hasActualChangesFromDto(ProductMaterializedViewEntity mv,
 
         // 좋아요 수 변경
         Long dtoLikeCount = dto.getLikeCount() != null ? dto.getLikeCount() : 0L;
-        if (!mv.getLikeCount().equals(dtoLikeCount)) {
-            return true;
-        }
-
-        return false;
+        return !mv.getLikeCount().equals(dtoLikeCount);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java
new file mode 100644
index 000000000..b106a0545
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java
@@ -0,0 +1,104 @@
+package com.loopers.domain.tracking;
+
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
+import com.loopers.domain.tracking.event.UserBehaviorEvent;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.DomainEventPublisher;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 상품 조회 행동 추적 이벤트를 Kafka로 즉시 발송하는 핸들러
+ * <p>
+ * Event-Driven + Outbox Fallback 패턴:
+ * 1. 트랜잭션 커밋 후 즉시 Kafka 발송 시도
+ * 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
+ * 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class ProductViewKafkaEventHandler {
+    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
+
+    private final DomainEventEnvelopeFactory envelopeFactory;
+    private final DomainEventPublisher domainEventPublisher;
+    private final OutboxRepository outboxRepository;
+
+    /**
+     * 상품 조회 행동 추적 이벤트를 Kafka로 즉시 발송
+     * PRODUCT_VIEW 타입의 UserBehaviorEvent만 처리
+     * 실패 시 Outbox에 저장하여 나중에 재시도
+     */
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleProductViewBehavior(final UserBehaviorEvent event) {
+        // PRODUCT_VIEW 타입만 처리
+        if (!"PRODUCT_VIEW".equals(event.eventType())) {
+            return;
+        }
+
+        final Long productId = event.targetId();
+        final Long userId = event.userId();
+
+        if (productId == null || userId == null) {
+            log.warn("이벤트 발송 스킵 - 필수값 누락 productId={}, userId={}", productId, userId);
+            return;
+        }
+
+        final ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, userId);
+        final var envelope = envelopeFactory.create(EventTypes.PRODUCT_VIEW, EventVersions.V1, payload);
+        final String partitionKey = String.valueOf(productId);
+
+        try {
+            // 1. 즉시 Kafka 발송 시도
+            domainEventPublisher.publish(CATALOG_EVENTS_TOPIC, partitionKey, envelope);
+
+            // 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
+            OutboxEventEntity sent = OutboxEventEntity.ready(
+                    envelope.eventId(),
+                    CATALOG_EVENTS_TOPIC,
+                    partitionKey,
+                    envelope.eventType(),
+                    envelope.version(),
+                    envelope.occurredAtEpochMillis(),
+                    envelope.payloadJson()
+            );
+            sent.markSent(); // SENT 상태로 변경
+            outboxRepository.save(sent);
+
+            log.info("상품 조회 이벤트 즉시 발송 성공 - type={}, productId={}, userId={}",
+                    EventTypes.PRODUCT_VIEW, productId, userId);
+
+        } catch (Exception e) {
+            // 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
+            OutboxEventEntity failed = OutboxEventEntity.ready(
+                    envelope.eventId(),
+                    CATALOG_EVENTS_TOPIC,
+                    partitionKey,
+                    envelope.eventType(),
+                    envelope.version(),
+                    envelope.occurredAtEpochMillis(),
+                    envelope.payloadJson()
+            );
+            failed.markFailed(); // FAILED 상태로 변경
+            outboxRepository.save(failed);
+
+            log.warn("상품 조회 이벤트 즉시 발송 실패, Outbox에 저장 - type={}, productId={}, userId={}, error={}",
+                    EventTypes.PRODUCT_VIEW, productId, userId, e.getMessage());
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
index c5cfdcb22..d39eb1dbb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DataPlatformEventHandler.java
@@ -46,15 +46,15 @@ public void handleOrderConfirmed(OrderConfirmedEvent event) {
             log.debug("주문 확정 데이터 플랫폼 전송 시작 - orderNumber: {}", event.orderNumber());
 
             OrderDataDto orderData = new OrderDataDto(
-                event.orderId(),
-                event.orderNumber(),
-                event.userId(),
-                OrderStatus.CONFIRMED,
-                event.originalTotalAmount(),
-                event.discountAmount(),
-                event.finalTotalAmount(),
-                ZonedDateTime.now(),
-                "ORDER_CONFIRMED"
+                    event.orderId(),
+                    event.orderNumber(),
+                    event.userId(),
+                    OrderStatus.CONFIRMED,
+                    event.originalTotalAmount(),
+                    event.discountAmount(),
+                    event.finalTotalAmount(),
+                    ZonedDateTime.now(),
+                    "ORDER_CONFIRMED"
             );
 
             boolean success = dataPlatformClient.sendOrderData(orderData);
@@ -80,15 +80,15 @@ public void handleOrderCancelled(OrderCancelledEvent event) {
             log.debug("주문 취소 데이터 플랫폼 전송 시작 - orderNumber: {}", event.orderId());
 
             OrderDataDto orderData = new OrderDataDto(
-                event.orderId(),
-                event.orderNumber(),
-                event.userId(),
-                OrderStatus.CANCELLED,
-                event.originalTotalAmount(),
-                event.discountAmount(),
-                event.finalTotalAmount(),
-                ZonedDateTime.now(),
-                "ORDER_CANCELLED"
+                    event.orderId(),
+                    event.orderNumber(),
+                    event.userId(),
+                    OrderStatus.CANCELLED,
+                    event.originalTotalAmount(),
+                    event.discountAmount(),
+                    event.finalTotalAmount(),
+                    ZonedDateTime.now(),
+                    "ORDER_CANCELLED"
             );
 
             boolean success = dataPlatformClient.sendOrderData(orderData);
@@ -114,15 +114,15 @@ public void handlePaymentCompleted(PaymentCompletedEvent event) {
             log.debug("결제 완료 데이터 플랫폼 전송 시작 - transactionKey: {}", event.transactionKey());
 
             PaymentDataDto paymentData = new PaymentDataDto(
-                event.transactionKey(),
-                event.orderNumber(),
-                event.userId(),
-                PaymentStatus.COMPLETED,
-                event.amount(),
-                event.cardType(),
-                ZonedDateTime.now(),
-                "PAYMENT_COMPLETED",
-                null
+                    event.transactionKey(),
+                    event.orderNumber(),
+                    event.userId(),
+                    PaymentStatus.COMPLETED,
+                    event.amount(),
+                    event.cardType(),
+                    ZonedDateTime.now(),
+                    "PAYMENT_COMPLETED",
+                    null
             );
 
             boolean success = dataPlatformClient.sendPaymentData(paymentData);
@@ -148,15 +148,15 @@ public void handlePaymentFailed(PaymentFailedEvent event) {
             log.debug("결제 실패 데이터 플랫폼 전송 시작 - transactionKey: {}", event.transactionKey());
 
             PaymentDataDto paymentData = new PaymentDataDto(
-                event.transactionKey(),
-                event.orderId(),
-                event.userId(),
-                PaymentStatus.FAILED,
-                event.amount(),
-                event.cardType(),
-                ZonedDateTime.now(),
-                "PAYMENT_FAILED",
-                event.reason()
+                    event.transactionKey(),
+                    event.orderId(),
+                    event.userId(),
+                    PaymentStatus.FAILED,
+                    event.amount(),
+                    event.cardType(),
+                    ZonedDateTime.now(),
+                    "PAYMENT_FAILED",
+                    event.reason()
             );
 
             boolean success = dataPlatformClient.sendPaymentData(paymentData);
@@ -182,15 +182,15 @@ public void handlePaymentTimeout(PaymentTimeoutEvent event) {
             log.debug("결제 타임아웃 데이터 플랫폼 전송 시작 - transactionKey: {}", event.transactionKey());
 
             PaymentDataDto paymentData = new PaymentDataDto(
-                event.transactionKey(),
-                event.orderId(),
-                event.userId(),
-                PaymentStatus.TIMEOUT,
-                event.amount(),
-                event.cardType(),
-                ZonedDateTime.now(),
-                "PAYMENT_TIMEOUT",
-                "결제 콜백 타임아웃 (10분 초과)"
+                    event.transactionKey(),
+                    event.orderId(),
+                    event.userId(),
+                    PaymentStatus.TIMEOUT,
+                    event.amount(),
+                    event.cardType(),
+                    ZonedDateTime.now(),
+                    "PAYMENT_TIMEOUT",
+                    "결제 콜백 타임아웃 (10분 초과)"
             );
 
             boolean success = dataPlatformClient.sendPaymentData(paymentData);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
index 5028456c7..77489df5c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
@@ -10,4 +10,14 @@
 public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> {
 
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status);
+
+    /**
+     * 재시도 가능한 실패 이벤트 조회
+     *
+     * @param status        상태 (FAILED)
+     * @param maxRetryCount 최대 재시도 횟수
+     * @return 재시도 가능한 이벤트 목록 (최대 50개)
+     */
+    List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            OutboxStatus status, int maxRetryCount);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
index 72b5ad513..ab7f48048 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
@@ -26,6 +26,12 @@ public List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatu
         return outboxEventJpaRepository.findTop100ByStatusOrderByCreatedAtAsc(outboxStatus);
     }
 
+    @Override
+    public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            OutboxStatus status, int maxRetryCount) {
+        return outboxEventJpaRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(status, maxRetryCount);
+    }
+
     @Override
     public OutboxEventEntity save(OutboxEventEntity ready) {
         return outboxEventJpaRepository.save(ready);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
new file mode 100644
index 000000000..4599c3dba
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/LikeActionPayloadV1.java
@@ -0,0 +1,14 @@
+package com.loopers.infrastructure.event.payloads;
+
+/**
+ * 좋아요 액션 이벤트 페이로드 V1
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record LikeActionPayloadV1(
+        Long productId,
+        Long userId,
+        String action  // "LIKE" or "UNLIKE"
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
index c67fe91df..531d8881c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
@@ -2,8 +2,6 @@
 
 import java.util.List;
 
-import com.loopers.domain.order.OrderItemEntity;
-
 /**
  *
  * @author hyunjikoh
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java
new file mode 100644
index 000000000..fba41ebe0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/ProductViewPayloadV1.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.event.payloads;
+
+/**
+ * 상품 조회 이벤트 페이로드 V1
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+public record ProductViewPayloadV1(
+        Long productId,
+        Long userId
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java
index 016385d9b..eccbad169 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java
@@ -120,16 +120,16 @@ void get_product_pagination() {
             // given
             // given
             BrandEntity brand = BrandTestFixture.createAndSave(brandRepository, "Test Brand", "Test Description");
-           for(int i=0; i<10; i++) {
-               ProductTestFixture.createAndSave(
-                       productRepository,
-                       brand,
-                       "Test Product " + i,
-                       "Product Description " + i,
-                       new BigDecimal("10000"),
-                       100
-               );
-           }
+            for (int i = 0; i < 10; i++) {
+                ProductTestFixture.createAndSave(
+                        productRepository,
+                        brand,
+                        "Test Product " + i,
+                        "Product Description " + i,
+                        new BigDecimal("10000"),
+                        100
+                );
+            }
 
             productMVService.syncMaterializedView();
 
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index a26fb87c6..5e088aa4c 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -1,9 +1,11 @@
 package com.loopers.domain.metrics;
 
+import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Optional;
+import java.util.UUID;
 
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Component;
@@ -11,6 +13,7 @@
 import com.loopers.domain.event.EventEntity;
 import com.loopers.domain.event.EventRepository;
 import com.loopers.infrastructure.cache.ProductCacheService;
+import com.loopers.infrastructure.lock.RedisDistributedLock;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -18,6 +21,10 @@
 import jakarta.transaction.Transactional;
 
 /**
+ * Redis 분산락을 이용한 동시성 안전한 메트릭 서비스
+ * <p>
+ * 동일한 상품에 대한 동시 업데이트를 분산락으로 제어하여
+ * 메트릭 누락 없이 원자적으로 처리합니다.
  *
  * @author hyunjikoh
  * @since 2025. 12. 16.
@@ -29,6 +36,11 @@ public class MetricsService {
     private final EventRepository eventHandledRepository;
     private final ProductMetricsRepository productMetricsRepository;
     private final ProductCacheService productCacheService;
+    private final RedisDistributedLock distributedLock;
+    
+    // 분산락 설정
+    private static final Duration LOCK_EXPIRE_TIME = Duration.ofSeconds(10); // 락 만료 시간
+    private static final Duration MAX_WAIT_TIME = Duration.ofSeconds(5);     // 최대 대기 시간
 
     @Transactional
     public boolean tryMarkHandled(String eventId) {
@@ -40,8 +52,29 @@ public boolean tryMarkHandled(String eventId) {
         }
     }
 
-    @Transactional
+    /**
+     * 조회수 증가 (분산락 적용)
+     */
     public void incrementView(Long productId, long occurredAtEpochMillis) {
+        String lockKey = "metrics:view:" + productId;
+        String lockValue = generateLockValue();
+        
+        boolean success = distributedLock.executeWithLock(
+            lockKey, 
+            lockValue, 
+            LOCK_EXPIRE_TIME, 
+            MAX_WAIT_TIME,
+            () -> incrementViewWithLock(productId, occurredAtEpochMillis)
+        );
+        
+        if (!success) {
+            log.error("조회수 업데이트 실패 - 분산락 획득 실패: productId={}", productId);
+            throw new RuntimeException("조회수 업데이트 실패: 분산락 획득 실패");
+        }
+    }
+    
+    @Transactional
+    protected void incrementViewWithLock(Long productId, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
         final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
         
@@ -54,12 +87,35 @@ public void incrementView(Long productId, long occurredAtEpochMillis) {
         metrics.incrementView(eventTime);
         productMetricsRepository.save(metrics);
         
+        log.debug("조회수 업데이트 완료 - productId: {}, newViewCount: {}", productId, metrics.getViewCount());
+        
         // 캐시 무효화 (조회수 임계값 체크)
         productCacheService.onViewCountChanged(productId, metrics.getViewCount());
     }
 
-    @Transactional
+    /**
+     * 좋아요 수 변경 (분산락 적용)
+     */
     public void applyLikeDelta(final Long productId, final int delta, long occurredAtEpochMillis) {
+        String lockKey = "metrics:like:" + productId;
+        String lockValue = generateLockValue();
+        
+        boolean success = distributedLock.executeWithLock(
+            lockKey, 
+            lockValue, 
+            LOCK_EXPIRE_TIME, 
+            MAX_WAIT_TIME,
+            () -> applyLikeDeltaWithLock(productId, delta, occurredAtEpochMillis)
+        );
+        
+        if (!success) {
+            log.error("좋아요 수 업데이트 실패 - 분산락 획득 실패: productId={}, delta={}", productId, delta);
+            throw new RuntimeException("좋아요 수 업데이트 실패: 분산락 획득 실패");
+        }
+    }
+    
+    @Transactional
+    protected void applyLikeDeltaWithLock(final Long productId, final int delta, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
         final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
         
@@ -69,15 +125,40 @@ public void applyLikeDelta(final Long productId, final int delta, long occurredA
             return;
         }
         
+        long oldLikeCount = metrics.getLikeCount();
         metrics.applyLikeDelta(delta, eventTime);
         productMetricsRepository.save(metrics);
         
+        log.debug("좋아요 수 업데이트 완료 - productId: {}, delta: {}, oldCount: {}, newCount: {}", 
+                 productId, delta, oldLikeCount, metrics.getLikeCount());
+        
         // 캐시 무효화 (좋아요 수 변경)
         productCacheService.onLikeCountChanged(productId);
     }
 
-    @Transactional
+    /**
+     * 판매량 증가 (분산락 적용)
+     */
     public void addSales(final Long productId, final int quantity, long occurredAtEpochMillis) {
+        String lockKey = "metrics:sales:" + productId;
+        String lockValue = generateLockValue();
+        
+        boolean success = distributedLock.executeWithLock(
+            lockKey, 
+            lockValue, 
+            LOCK_EXPIRE_TIME, 
+            MAX_WAIT_TIME,
+            () -> addSalesWithLock(productId, quantity, occurredAtEpochMillis)
+        );
+        
+        if (!success) {
+            log.error("판매량 업데이트 실패 - 분산락 획득 실패: productId={}, quantity={}", productId, quantity);
+            throw new RuntimeException("판매량 업데이트 실패: 분산락 획득 실패");
+        }
+    }
+    
+    @Transactional
+    protected void addSalesWithLock(final Long productId, final int quantity, long occurredAtEpochMillis) {
         final ProductMetricsEntity metrics = getOrCreate(productId);
         final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
         
@@ -87,9 +168,13 @@ public void addSales(final Long productId, final int quantity, long occurredAtEp
             return;
         }
         
+        long oldSalesCount = metrics.getSalesCount();
         metrics.addSales(quantity, eventTime);
         productMetricsRepository.save(metrics);
         
+        log.debug("판매량 업데이트 완료 - productId: {}, quantity: {}, oldCount: {}, newCount: {}", 
+                 productId, quantity, oldSalesCount, metrics.getSalesCount());
+        
         // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향)
         productCacheService.onSalesCountChanged(productId);
     }
@@ -112,4 +197,11 @@ private boolean isEventTooOld(ProductMetricsEntity metrics, ZonedDateTime eventT
         // 이벤트 시간이 마지막 처리 시간보다 이전인 경우 무시
         return eventTime.isBefore(metrics.getLastEventAt());
     }
+    
+    /**
+     * 분산락용 고유 값 생성
+     */
+    private String generateLockValue() {
+        return Thread.currentThread().getName() + ":" + UUID.randomUUID().toString();
+    }
 }
diff --git a/docs/week8/lecture.md b/docs/week8/lecture.md
new file mode 100644
index 000000000..ce3808a47
--- /dev/null
+++ b/docs/week8/lecture.md
@@ -0,0 +1,324 @@
+## 🧭 루프팩 BE L2 - Round 8
+
+> 누가 기침을 하였는가 ~
+>
+>
+> Kafka 기반으로 **서비스 경계 밖으로 이벤트를 발행**하고, 별도 Consumer 앱이 **후속 처리와 운영 책임**을 담당합니다.
+>
+> 메시지 전달 보장(At Least Once, Idempotency, DLQ)을 학습하며 **신뢰 가능한 이벤트 파이프라인**을 구현합니다.
+>
+
+<aside>
+🎯
+
+**Summary**
+
+</aside>
+
+지난 라운드에서는 **메세지에 대한 이해**와 애플리케이션 **이벤트를 통한 결합**을 느슨하게 만들어보았습니다. 하지만 애플리케이션 안에서만 이벤트가 머물러 있어, **서비스 경계를 넘는 확장성은 부족**했습니다.
+
+이번 라운드에서는 이를 해결하기 위해 **Kafka 를 통해 이벤트를 외부로 발행**하고, **별도의 Consumer 앱이 후속 처리를 담당하는 구조**를 학습합니다.
+
+<aside>
+📌
+
+**Keywords**
+
+</aside>
+
+- Kafka Producer & Consumer
+- At Most Once / At Least Once / Exactly Once
+- Idempotency & 멱등 처리
+- Dead Letter Queue (DLQ)
+
+<aside>
+🧠
+
+**Learning**
+
+</aside>
+
+## 📮 Kafka Overview
+
+<aside>
+❓
+
+Kafka 는 흔히 **고성능 메시지 큐**라고 생각하지만, 근본적으로는 **분산 로그 저장소(Distributed Log Store)** 입니다.
+
+Kafka 는 메세지가 흘러가는 통로보다는 `로그가 쌓이는 거대한 시스템` 에 좀 더 가깝습니다.
+
+- 일반적인 Message Queue 와는 다르게 **디스크에 Log 를 지속적으로 append** 함
+- Consumer 는 **각자의 Offset 을 기억하고 필요한 시점부터 읽는 것이** 가능함 (재처리 가능)
+</aside>
+
+### Kafka 의 주요 특징
+
+- **고가용성** - Partition + Replica 구조로 브로커 장애 시에도 데이터 유실을 최소화
+- **확장성** - Broker, Partion 의 수평 확장으로 처리량의 선형 증가
+- **범용성** - 단순 메세징 뿐이 아닌 다음의 용도로도 사용
+    - 1️⃣ 로그 수집
+    - 2️⃣ 이벤트 소싱
+    - 3️⃣ 스트리밍 처리의 기반
+
+### Kafka Components
+
+1. **Broker**
+    - 카프카 서버 Unit
+    - Producer 의 메세지를 받아 Offset 지정 후 디스크에 저장
+    - Consumer 의 파티션 Read 에 응답해 디스크의 메세지 전송
+    - `Cluster` 내에서 각 1개씩 존재하는 Role Broker
+        - **Controller**
+
+          다른 브로커를 모니터링하고 장애가 발생한 Broker 에 특정 토픽의 Leader 파티션이 존재한다면, 다른 브로커의 파티션 중 Leader 를 재분배하는 역할을 수행
+
+        - **Coordinator**
+
+          컨슈머 그룹을 모니터링하고 해당 그룹 내의 특정 컨슈머가 장애가 발생해 매칭된 파티션의 메세지를 Consume 할 수 없는 경우, 해당 파티션을 다른 컨슈머에게 매칭해주는 역할 수행 (`Rebalance`)
+
+2. **Cluster**
+    - 고가용성 (HA) 를 위해 여러 서버를 묶어 특정 서버의 장애를 극복할 수 있도록 구성
+    - Broker 가 증가할 수록 메시지 수신, 전달 처리량을 분산시킬 수 있으므로 확장에 유리
+
+      > 동작중인 다른 Broker 에 영향 없이 확장이 가능하므로, 트래픽 양의 증가에 따른 브로커 증설이 손쉽게 가능
+>
+3. **Topic & Partition**
+    - `Topic` 은 메세지를 분류하는 기준이며 N 개의 `Partition` 으로 구성
+        - 1개의 `Leader` 와 0..N 개의 `Follower` 파티션으로 구성해 가용성을 높일 수 있음
+    - `Partition` 은 서로 다른 서버에 분산시킬 수 있기 때문에 수평 확장이 가능
+    - 각 `Topic` 의 메세지 처리순서는 `Partition` 별로 관리됨
+4. **Message**
+    - 카프카에서 취급하는 데이터의 Unit **( ByteArray )**
+5. **Producer & Consumer**
+    - `Producer` - 메세지를 특정 Topic 에 생성
+        - 저장될 파티션을 결정하기 위해 메세지의 Key 해시를 활용하며 Key 가 존재하지 않을 경우, 균형 제어를 위해 Round-Robin 방식으로 메세지를 기록
+        - **Partitioner**
+
+          메세지를 수신할 때, 토픽의 어떤 파티션에 저장될 지 결정하며 Producer 측에서 결정. 메세지에 key 가 없다면 특정  메세지에 key 가 존재한다면 key 의 해시값에 매칭되는 파티션에 데이터를 전송함으로써 항상 같은 파티션에 메세지를 적재해 **순서 보장** 이 가능하도록 처리할 수 있음.
+
+    - `Consumer` - 1개 이상의 Topic 을 구독하며 메세지를 순서대로 읽음
+        - 메세지를 읽을 때마다 파티션 별로 Offset 을 유지해 읽는 메세지의 위치를 추적할 수 있으며 오프셋은 두가지 종류가 존재
+        - `CURRENT-OFFSET`
+
+          컨슈머가 어디까지 처리했는지를 나타내는 offset 이며 메세지를 소비하는 컨슈머가 이를 기록하고 후에 장애가 발생했을 시에 그 뒤부터 이어 처리할 수 있도록 하며 장애 복구 상황을 위해 메세지가 처리된 이후에 반드시 커밋하여야 함
+
+        - 만약 오류가 발생하거나 문제가 발생할 경우, 컨슈머 그룹 차원에서 `--reset-offsets`  옵션을 통해 실패한 시점으로 오프셋을 되돌릴 수 있음
+6. **Consumer Group**
+    - 메세지를 소비할 때, 토픽의 파티션을 매칭하는 그룹 단위이며 N 개의 컨슈머를 포함
+    - 각 파티션은 그룹 내 하나의 컨슈머만 소비할 수 있음
+    - 보통 소비 주체인 Application 단위로 Consumer Group 을 생성, 관리함
+    - 같은 토픽에 대한 소비주체를 늘리고 싶다면, 별도의 컨슈머 그룹을 만들어 토픽을 구독
+
+      ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/0453d381-6abe-4bfa-a4ab-5ef2e4a7ef98/Untitled.png)
+
+
+    > 파티션의 개수가 그룹 내 컨슈머 개수보다 많다면 잉여 파티션의 경우 메세지가 소비될 수 없음을 의미함
+    > 
+    - **( 참고 )** 토픽의 Partition 개수와 Consumer 개수에 따른 소비
+        
+        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/b6f3777a-44b7-4e85-a0b0-3b708ede0291/Untitled.png)
+        
+        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/ba2876f1-df33-45fc-86b5-85eb462e17ba/Untitled.png)
+        
+        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/74cc946f-e4b3-4f1b-9611-374cd526b410/Untitled.png)
+
+7. **Rebalancing**
+    - Consmuer Group 의 **가용성과 확장성**을 확보해주는 개념
+    - 특정 컨슈머로부터 다른 컨슈머로 파티션의 소유권을 이전시키는 행위
+
+      e.g. `Consumer Group` 내에 Consumer 가 추가될 경우, 특정 파티션의 소유권을 이전시키거나 오류가 생긴 Consumer 로부터 소유권을 회수해 다른 Consumer 에 배정함
+
+
+    <aside>
+    🚫 **( 주의 )** 리밸런싱 중에는 컨슈머가 메세지를 읽을 수 없음.
+    
+    </aside>
+    
+    `Rebalancing Case`
+    
+    1. Consumer Group 내에 새로운 Consumer 추가
+    2. Consumer Group 내의 특정 Consumer 장애로 소비 중단
+    3. Topic 내에 새로운 Partition 추가
+8. **Replication**
+    - Cluster 의 가용성을 보장하는 개념
+    - 각 Partition 의 Replica 를 만들어 백업 및 장애 극복
+        - Leader Replica
+
+          각 파티션은 1개의 리더 Replica를 가진다. 모든 Producer, Consumer 요청은 리더를 통해 처리되게 하여 일관성을 보장한다.
+
+        - Follower Replica
+
+          각 파티션의 리더를 제외한 Replica 이며 단순히 리더의 메세지를 복제해 백업한다. 만일, 파티션의 리더가 중단되는 경우 팔로워 중 하나를 새로운 리더로 선출한다.
+
+          > Leader 의 메세지가 동기화되지 않은 Replica 는 Leader 로 선출될 수 없다.
+          >
+            - `In-Sync Replica (ISR)`
+
+              Leader 의 최신 메세지를 계속 요청하는 Follower
+
+            - `Out-Sync Replica (OSR)`
+
+              특정 기준에 의해 Leader 의 메세지를 백업하는 Follower
+
+
+### Messaging Systems
+
+| 구분 | **Redis (Pub/Sub)** | **RabbitMQ (AMQP)** | **Kafka (Distributed Log)** |
+| --- | --- | --- | --- |
+| **기반 모델** | Pub/Sub | 메시지 큐 (AMQP 프로토콜) | Pub/Sub + 분산 로그 |
+| **메시지 저장** | ❌ 저장 안 함
+(채널 자체에 보관 X) | ✅ 일시 저장
+Queue 에 보관(서버/Queue 종료 시 삭제) | ✅ 저장
+디스크(Log)에 영속 저장(보존 기간까지 유지) |
+| **구독 방식** | 채널 기반, subscriber 없으면 메시지 소실 | Exchange → Queue 매핑 후 Consumer 수신 | Topic → Partition, Consumer Group 으로 분배 |
+| **순서 보장** | 없음 | Queue 단위 순서 보장 | Partition 단위 순서 보장(Key 로 동일 Partition 강제 가능) |
+| **확장성** | 제한적 (단일 서버 메모리 한계) | 브로커 클러스터 구성 가능하지만 Scale-out 제한적 | 고수준 확장성 (Broker/Partition 수평 확장) |
+| **재처리 (Replay)** | ❌ 불가
+(실시간 전달 전용) | ❌ 불가 
+(소비하면 Queue 에서 제거) | ✅ 가능 
+(Consumer Offset 조정으로 과거 이벤트 재소비 가능) |
+| **메시지 유실** | Subscriber 없으면 유실 | Queue 보관 중 서버 장애 시 유실 가능 | 설정(`acks=all`, Replica)으로 내구성 보장 |
+| **활용 사례** | 실시간 알림, 단순 신호 전달 | 트랜잭션 메시징, 업무 프로세스 큐잉 | 로그 수집, 이벤트 소싱, 스트리밍 처리, 대규모 이벤트 파이프라인 |
+
+---
+
+## 🚀 Kafka Essentials
+
+> 카프카 활용의 핵심은 **메세지를 잃지 않고, 단 한번만 처리되게 보장할 수 있는가** 입니다.
+**Producer → Broker → Consumer** 전 경로에 걸친 설정과 처리 방식의 조합은 메세지 전달 방식을 결정하는 주요 요소입니다.
+다양한 운영 노하우가 필요하지만, 아래 내용들은 꼭 지켜질 수 있도록 해보세요.
+>
+
+### 📦 Message Delivery Semantics
+
+**1️⃣ Producer → Broker**
+
+- 🎯 **어떻게든 발행 (At Least Once)**
+- `Producer` 는 네트워크 지연, 장애가 있어도 메세지를 최소 한 번은 `Broker` 에 기록되도록 보장해야 합니다.
+
+### 📌 Producer 측 패턴: **Transactional Outbox**
+
+- 도메인 데이터 변경(DB write)과 아웃박스 메시지 기록
+
+  ➡ 두 작업을 **하나의 DB 트랜잭션**으로 묶음
+
+- Outbox 테이블에 쌓인 메시지를 별도의 메시지 릴레이/데몬이 Broker로 전달
+- 실패 시 계속 재시도 → 결과적으로 **At Least Once 발행 보장**
+
+<aside>
+💡  **Producer 예시**
+
+- 주문 생성 시
+    1. 주문 DB insert
+    2. Outbox 테이블에 “OrderCreatedEvent” 기록
+
+       ➡ 하나의 트랜잭션
+
+- 백그라운드 워커가 Kafka로 publish
+- 네트워크 불안해도 워커가 다시 재시도 → **`Broker에는 반드시 1번 이상 기록됨`** (최소 1회)
+</aside>
+
+**2️⃣ Consumer ← Broker**
+
+- 🎯 **어떻게든 한 번만 처리 (At Most Once)**
+- `Consumer` 는 같은 메세지가 여러 번 오더라도, 멱등하게 처리하여 최종 결과는 단 한번만 반영되도록 보장해야 합니다.
+
+### 📌 Consumer 측 패턴: **Transactional Inbox / Idempotent Consumer**
+
+- 메시지를 받을 때 **Inbox 테이블에 메시지 ID를 먼저 기록**
+- 이미 처리된 메시지인지 검사
+- 처리한 뒤 Inbox 상태를 완료로 업데이트
+
+  → 메시지가 중복 오더라도 같은 ID는 무시
+
+  → "어떻게 오든 단 한 번만 처리"
+
+
+<aside>
+💡 **Consumer 예시**
+
+- Kafka 메시지 `orderId=12345` 처리
+- Inbox 테이블에서 메시지 ID(또는 aggregateKey) 확인
+    - 있음 → 이미 처리된 메시지 → skip
+    - 없음 → Insert 후 정상 처리
+- 이메일 발송/포인트 적립/주문 상태 변경 등에 안전
+
+> 멱등성이 필요한 작업(마일리지 적립 같은 것)에 매우 중요
+>
+</aside>
+
+---
+
+### 🛡️ Idempotency (멱등성)
+
+- **왜 필요한가?**
+    - At Least Once 전략에 의해 중복 메시지가 발생할 수 있음
+    - 중복이 오더라도 결과가 변하지 않아야 함
+- **구현 전략**
+    1. `eventId` PK 테이블 → 중복 메시지 무시
+    2. `version` / `updatedAt` 비교 → 최신만 반영
+    3. Upsert → Insert or Update : 중복 메시지에도 동일 결과 유지
+
+---
+
+### 🚨 Operation Tips
+
+- **Retry & Backoff**: 일시 장애는 재시도로 복구, 즉시 무한재시도는 금물
+- **DLQ (Dead Letter Queue)**: 반복 실패 메시지는 DLQ로 격리, 운영자가 후처리
+- **Lag 모니터링**: Consumer가 얼마나 뒤쳐져 있는지 체크 (지연·병목 지표)
+- **Partition 순서 보장**: Partition 단위로만 순서가 보장되므로 `partition.key=aggregateId` 설정 필수
+
+---
+
+## 🤔 오해
+
+1️⃣ **Kafka는 MQ다**
+
+- ❌ MQ는 메시지를 전달하고 삭제하는 방식
+- ✅ Kafka는 데이터를 삭제하지 않고, 설정된 보존 기간 동안 **모든 메시지를 유지**
+
+2️⃣ **Consumer가 메시지를 소유한다**
+
+- ❌ MQ에서는 메시지를 소비하면 큐에서 제거됨
+- ✅ Kafka에서는 메시지는 여전히 남아 있고, **Consumer Group 단위 Offset**만 이동
+
+3️⃣ **Kafka는 순서를 보장하지 않는다**
+
+- ❌ 전체 Topic 차원의 순서는 보장하지 않음
+- ✅ **Partition 단위**로는 엄격하게 순서를 보장
+
+4️⃣ **Kafka는 유실이 없다**
+
+- ❌ 설정에 따라 다름 (acks=0/1이면 유실 가능)
+- ✅ `acks=all` + `min.insync.replicas` 설정 시 **강력한 내구성 보장**
+
+---
+
+<aside>
+📚
+
+**References**
+
+</aside>
+
+| 구분 | 링크 |
+| --- | --- |
+| 🔍 Kafka | [Kafka docs](https://kafka.apache.org/documentation/) |
+| ⚙ Spring Kafka | [Spring for Apache Kafka](https://spring.io/projects/spring-kafka) |
+| 📖 우아콘2023 - 카프카 | [Kafka를 활용한 이벤트 기반 아키텍처 구축](https://www.youtube.com/watch?v=DY3sUeGu74M) |
+| 🌟 라인 - 카프카 활용 | [LINE에서 Kafka를 사용하는 방법](https://engineering.linecorp.com/ko/blog/how-to-use-kafka-in-line-1) |
+
+<aside>
+🌟
+
+**Next Week Preview**
+
+</aside>
+
+> **가장 인기 있는 상품을 보여주고 싶은데, 어떻게 알 수 있을까?**
+>
+>
+>
+> 이번 주차에는 카프카 메세지를 기반으로 안정적으로 이벤트를 발행하고, 처리하기 위한 방안들을 고민해 보았습니다. 이제 우리 **커머스 서비스**에 필요한 기본기는 많이 갖춘 것 같아요.
+>
+> 유저가 정말 좋아할 만한 상품은 뭘까요? 앞서 마련한 기반들을 이용해 우리는 실시간으로 상품 랭킹을 만들어 볼 거예요. 차주에는 **랭킹 파이프라인**을 통해 유저가 정말 좋아할 만한 상품들을 진열해 봅시다.
+>
\ No newline at end of file
diff --git a/docs/week8/quest.md b/docs/week8/quest.md
new file mode 100644
index 000000000..d1470f450
--- /dev/null
+++ b/docs/week8/quest.md
@@ -0,0 +1,127 @@
+# 📝 Round 8 Quests
+
+---
+
+## 💻 Implementation Quest
+
+> 이번에는 카프카 기반의 **이벤트 파이프라인**을 구현합니다.
+각 이벤트를 외부 시스템과 적절하게 주고 받을 수 있는 구조를 직접 체험해봅니다.
+>
+
+<aside>
+🎯
+
+**Must-Have (이번 주에 무조건 가져가야 좋을 것-**무조건 ****하세요**)**
+
+- Kafka
+- Event Pipeline
+- **At Least Once** producer
+- **At Most Once** consumer
+
+**Nice-To-Have (부가적으로 가져가면 좋을 것-**시간이 ****허락하면 ****꼭 ****해보세요**)**
+
+- 상품별 유저 이벤트 집계 테이블 만들기
+- 이외의 이벤트 기반 처리를 고민해보기
+- 메세지 단건 처리 → 배치 처리도 알아보기
+- DLQ 도 고민해보기
+</aside>
+
+### 📋 과제 정보
+
+**Kafka 기반 이벤트 파이프라인을 구현합니다.** (최소 기준)
+
+- `commerce-api` → Kafka 의 방향으로 소통합니다.
+- **Producer** 는 **At Least Once** 보장을 위해 이벤트를 반드시 발행합니다.
+    - **Transactional Outbox Pattern** 을 구현해 보고, 동작을 확인해 봅니다.
+- **Consumer** 는 이벤트를 수취해 아래 기능을 수행합니다.
+    - **집계(Metrics)** : 좋아요 수 / 판매량 / 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert
+
+**토픽 설계** (예시)
+
+- `catalog-events` (상품/재고/좋아요 이벤트, key=productId)
+- `order-events` (주문/결제 이벤트, key=orderId)
+- *각 세부 이벤트 별로 분리하고 싶다면, 분리해도 좋습니다.*
+
+**Producer, Consumer 필수 처리**
+
+- **Producer**
+    - acks=all, idempotence=true 설정
+- **Consumer**
+    - **manual Ack** 처리
+    - `event_handled(event_id PK)` (DB or Redis) 기반의 멱등 처리
+    - `version` 또는 `updated_at` 기준으로 최신 이벤트만 반영
+
+> *왜 이벤트 핸들링 테이블과 로그 테이블을 분리하는 걸까? 에 대해 고민하고 리뷰 포인트에 작성해주세요*
+>
+
+---
+
+## ✅ Checklist
+
+### 🎾 Producer
+
+- [ ]  도메인(애플리케이션) 이벤트 설계
+- [ ]  Producer 앱에서 도메인 이벤트 발행 (catalog-events, order-events, 등)
+- [ ]  **PartitionKey** 기반의 이벤트 순서 보장
+- [ ]  메세지 발행이 실패했을 경우에 대해 고민해보기
+
+### ⚾ Consumer
+
+- [ ]  Consumer 가 Metrics 집계 처리
+- [ ]  `event_handled` 테이블을 통한 멱등 처리 구현
+- [ ]  재고 소진 시 상품 캐시 갱신
+- [ ]  중복 메세지 재전송 테스트 → 최종 결과가 한 번만 반영되는지 확인
+
+---
+
+## ✍️ Technical Writing Quest
+
+> 이번 주에 학습한 내용, 과제 진행을 되돌아보며
+**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다.
+>
+>
+> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.**
+>
+> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요.
+>
+
+### 📚 Technical Writing Guide
+
+### ✅ 작성 기준
+
+| 항목 | 설명 |
+| --- | --- |
+| **형식** | 블로그 |
+| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 |
+| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 |
+| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 |
+| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” |
+
+---
+
+### ✨ 좋은 톤은 이런 느낌이에요
+
+> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자
+>
+
+| 특징 | 예시 |
+| --- | --- |
+| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 |
+| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 |
+| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… |
+
+### ❌ 피해야 할 스타일
+
+| 예시 | 이유 |
+| --- | --- |
+| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 |
+| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 |
+| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 |
+
+### 🎯 Feature Suggestions
+
+- Kafka.. 왜 쓸까? 꼭 필요할까?
+- Kafka는 기본적으로 At Least Once인데, Consumer 멱등 처리가 없으면 무슨 일이 벌어질까?
+- 캐시 무효화와 집계 로직을 한 컨슈머에서 처리하는 것과, 그룹을 나누는 것 중 어떤 차이가 있을까?
+- 이벤트 순서 보장을 위해 key를 aggregateId로 두었는데, 만약 랜덤 키를 썼다면 어떤 문제가 생겼을까?
+- 멱등 처리를 DB 테이블로 할 때와 Redis로 할 때의 차이점은 무엇일까?
\ No newline at end of file
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..56d00670c 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
@@ -1,18 +1,24 @@
 package com.loopers.confg.kafka;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.kafka.clients.admin.NewTopic;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
+import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.kafka.annotation.EnableKafka;
 import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.config.TopicBuilder;
 import org.springframework.kafka.core.*;
 import org.springframework.kafka.listener.ContainerProperties;
 import org.springframework.kafka.support.converter.BatchMessagingMessageConverter;
 import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter;
 
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+
 import java.util.HashMap;
 import java.util.Map;
 
@@ -72,4 +78,71 @@ public ConcurrentKafkaListenerContainerFactory<Object, Object> defaultBatchListe
         factory.setBatchListener(true);
         return factory;
     }
+
+    // ===== 토픽 설정 =====
+
+    /**
+     * 카탈로그 이벤트 토픽 (상품 조회, 좋아요 이벤트)
+     */
+    @Bean
+    public NewTopic catalogEventsTopic() {
+        return TopicBuilder.name("catalog-events")
+                .partitions(3)
+                .replicas(1)
+                .build();
+    }
+
+    /**
+     * 주문 이벤트 토픽 (주문, 결제 이벤트)
+     */
+    @Bean
+    public NewTopic orderEventsTopic() {
+        return TopicBuilder.name("order-events")
+                .partitions(3)
+                .replicas(1)
+                .build();
+    }
+//
+//    /**
+//     * YAML 설정을 기반으로 추가 토픽들을 동적 생성
+//     * (선택사항: 기본 토픽 외에 추가 토픽이 필요한 경우)
+//     */
+//    // @Bean
+//    // @ConditionalOnProperty(name = "kafka.topics", matchIfMissing = false)
+//    // public List<NewTopic> dynamicKafkaTopics(KafkaTopicProperties properties) {
+//    //     List<NewTopic> topics = new ArrayList<>();
+//    //
+//    //     if (properties.getTopics() != null) {
+//    //         properties.getTopics().forEach((key, config) -> {
+//    //             NewTopic topic = TopicBuilder.name(config.getName())
+//    //                     .partitions(config.getPartitions())
+//    //                     .replicas(config.getReplicas())
+//    //                     .build();
+//    //
+//    //             topics.add(topic);
+//    //             log.info("동적 토픽 생성: {} (파티션: {}, 복제: {})",
+//    //                     config.getName(), config.getPartitions(), config.getReplicas());
+//    //         });
+//    //     }
+//    //
+//    //     return topics;
+//    // }
+//
+//    // ===== YAML 기반 토픽 설정 Properties =====
+//
+//    /**
+//     * YAML에서 토픽 설정을 읽어오는 Properties 클래스
+//     */
+//    @Data
+//    @ConfigurationProperties(prefix = "kafka")
+//    public static class KafkaTopicProperties {
+//        private Map<String, TopicConfig> topics;
+//
+//        @Data
+//        public static class TopicConfig {
+//            private String name;
+//            private int partitions = 1;
+//            private int replicas = 1;
+//        }
+//    }
 }
diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml
index d2a5cf33d..55c84f17f 100644
--- a/modules/kafka/src/main/resources/kafka.yml
+++ b/modules/kafka/src/main/resources/kafka.yml
@@ -39,12 +39,59 @@ spring:
     admin:
       properties:
         bootstrap.servers: kafka:9092
+      # 토픽 자동 생성 설정
+      auto-create-topics-enable: true
+      
+# 커스텀 토픽 설정
+kafka:
+  topics:
+    catalog-events:
+      name: catalog-events
+      partitions: 3
+      replicas: 1
+    order-events:
+      name: order-events  
+      partitions: 3
+      replicas: 1
 
 ---
 spring.config.activate.on-profile: dev
 
+kafka:
+  topics:
+    catalog-events:
+      name: catalog-events
+      partitions: 6  # 개발환경에서는 더 많은 파티션
+      replicas: 2
+    order-events:
+      name: order-events  
+      partitions: 6
+      replicas: 2
+
 ---
 spring.config.activate.on-profile: qa
 
+kafka:
+  topics:
+    catalog-events:
+      name: catalog-events
+      partitions: 9  # QA환경에서는 운영과 동일
+      replicas: 2
+    order-events:
+      name: order-events  
+      partitions: 9
+      replicas: 2
+
 ---
 spring.config.activate.on-profile: prd
+
+kafka:
+  topics:
+    catalog-events:
+      name: catalog-events
+      partitions: 12  # 운영환경에서는 최대 성능
+      replicas: 3
+    order-events:
+      name: order-events  
+      partitions: 12
+      replicas: 3

From 5858fcc1a6f0b08b0936c3b678d1755c6cd4d2e8 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 09:49:48 +0900
Subject: [PATCH 12/28] =?UTF-8?q?feat(kafka):=20=EC=A2=8B=EC=95=84?=
 =?UTF-8?q?=EC=9A=94=20=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20=EC=99=84=EB=A3=8C?=
 =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20Outbox=20=EC=A0=80=EC=9E=A5=20?=
 =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 좋아요 변경 이벤트 처리 시 Outbox에 저장하는 로직 추가
- 결제 완료 이벤트 처리 시 Outbox에 저장하는 로직 추가
- 유저 행동 이벤트 처리 시 PRODUCT_VIEW에 대한 Outbox 저장 로직 추가
- Outbox 이벤트 배치 처리 및 재시도 로직 개선
---
 .../application/order/OrderEventHandler.java  |  65 +++++++++++
 .../event/PaymentKafkaEventHandler.java       |  95 ----------------
 .../event/outbox/OutboxRelayScheduler.java    |  48 ++++++--
 .../domain/event/outbox/OutboxRepository.java |   5 +
 .../domain/event/outbox/OutboxService.java    |   4 +
 .../like/event/LikeKafkaEventHandler.java     |  99 -----------------
 .../ProductViewKafkaEventHandler.java         | 104 ------------------
 .../infrastructure/like/LikeEventHandler.java |  57 +++++++++-
 .../tracking/UserBehaviorEventHandler.java    |  56 +++++++++-
 9 files changed, 224 insertions(+), 309 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
index b1e0cb7f5..9bb4b3172 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
@@ -1,13 +1,24 @@
 package com.loopers.application.order;
 
+import java.util.List;
+
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
 
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
+import com.loopers.domain.order.OrderEntity;
+import com.loopers.domain.order.OrderItemEntity;
+import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.event.PaymentCompletedEvent;
 import com.loopers.domain.payment.event.PaymentFailedEvent;
 import com.loopers.domain.payment.event.PaymentTimeoutEvent;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -20,7 +31,12 @@
 @Slf4j
 @RequiredArgsConstructor
 public class OrderEventHandler {
+    private static final String ORDER_EVENTS_TOPIC = "order-events";
+
     private final OrderFacade orderFacade;
+    private final OrderService orderService;
+    private final DomainEventEnvelopeFactory envelopeFactory;
+    private final OutboxRepository outboxRepository;
 
     private void executeSafely(String action, Long orderId, Long userId, Runnable task) {
         if (orderId == null || userId == null) {
@@ -41,8 +57,18 @@ private void executeSafely(String action, Long orderId, Long userId, Runnable ta
     public void handlePaymentCompleted(PaymentCompletedEvent event) {
         Long orderId = event.orderNumber();
         Long userId = event.userId();
+        
+        // 1. 주문 확정 처리
         executeSafely("PAYMENT_COMPLETED", orderId, userId,
                 () -> orderFacade.confirmOrderByPayment(orderId, userId));
+        
+        // 2. Kafka 이벤트용 Outbox 저장
+        try {
+            savePaymentSuccessToOutbox(event);
+        } catch (Exception e) {
+            log.error("결제 완료 이벤트 Outbox 저장 실패 - orderNumber={}, userId={}",
+                    orderId, userId, e);
+        }
     }
 
     @Async
@@ -62,4 +88,43 @@ public void handlePaymentTimeout(PaymentTimeoutEvent event) {
         executeSafely("PAYMENT_TIMEOUT", orderId, userId,
                 () -> orderFacade.cancelOrderByPaymentFailure(orderId, userId));
     }
+
+    /**
+     * 결제 완료 이벤트를 Outbox에 저장
+     */
+    private void savePaymentSuccessToOutbox(PaymentCompletedEvent event) {
+        final Long orderNumber = event.orderNumber();
+        final Long userId = event.userId();
+
+        if (orderNumber == null || userId == null) {
+            log.warn("Outbox 저장 스킵 - 필수값 누락 orderNumber={}, userId={}", orderNumber, userId);
+            return;
+        }
+
+        final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
+        final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
+
+        final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
+                .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
+                .toList();
+
+        final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
+        final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
+
+        // Outbox에 저장 (READY 상태)
+        OutboxEventEntity outboxEvent = OutboxEventEntity.ready(
+                envelope.eventId(),
+                ORDER_EVENTS_TOPIC,
+                String.valueOf(order.getId()), // partition key = orderId(PK)
+                envelope.eventType(),
+                envelope.version(),
+                envelope.occurredAtEpochMillis(),
+                envelope.payloadJson()
+        );
+
+        outboxRepository.save(outboxEvent);
+
+        log.info("결제 완료 이벤트 Outbox 저장 완료 - type={}, orderId={}, itemCount={}",
+                EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
deleted file mode 100644
index 481ef2fc1..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/PaymentKafkaEventHandler.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.loopers.domain.event;
-
-import java.util.List;
-
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.event.TransactionPhase;
-import org.springframework.transaction.event.TransactionalEventListener;
-
-import com.loopers.domain.event.outbox.OutboxEventEntity;
-import com.loopers.domain.event.outbox.OutboxRepository;
-import com.loopers.domain.order.OrderEntity;
-import com.loopers.domain.order.OrderItemEntity;
-import com.loopers.domain.order.OrderService;
-import com.loopers.domain.payment.event.PaymentCompletedEvent;
-import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.DomainEventPublisher;
-import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * 결제 관련 Kafka 이벤트 발행 핸들러
- * - 결제 완료 이벤트를 Kafka로 발행
- * - 다른 도메인 이벤트는 각각의 전용 핸들러에서 처리
- *
- * @author hyunjikoh
- * @since 2025. 12. 17.
- */
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class PaymentKafkaEventHandler {
-    private static final String ORDER_EVENTS_TOPIC = "order-events";
-
-    private final OrderService orderService;
-    private final DomainEventEnvelopeFactory envelopeFactory;
-    private final OutboxRepository outboxRepository;
-    private final DomainEventPublisher domainEventPublisher;
-
-    /**
-     * 결제 완료 이벤트 처리
-     */
-    @Async
-    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
-    public void handlePaymentCompleted(final PaymentCompletedEvent event) {
-        final Long orderNumber = event.orderNumber();
-        final Long userId = event.userId();
-
-        if (orderNumber == null || userId == null) {
-            log.warn("Outbox 적재 스킵 - 필수값 누락 orderNumber={}, userId={}", orderNumber, userId);
-            return;
-        }
-
-
-        final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
-        final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
-
-        final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
-                .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
-                .toList();
-
-        final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
-
-        final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
-        final String partitionKey = String.valueOf(order.getId());
-
-        try {
-            // 1. 즉시 Kafka 발송 시도
-            domainEventPublisher.publish(ORDER_EVENTS_TOPIC, partitionKey, envelope);
-
-            OutboxEventEntity ready = OutboxEventEntity.ready(
-                    envelope.eventId(),
-                    ORDER_EVENTS_TOPIC,
-                    String.valueOf(order.getId()), // partition key = orderId(PK)
-                    envelope.eventType(),
-                    envelope.version(),
-                    envelope.occurredAtEpochMillis(),
-                    envelope.payloadJson()
-            );
-            ready.markSent(); // SENT 상태로 변경
-
-            outboxRepository.save(ready);
-
-            log.info("Outbox 적재 완료 - type={}, orderId={}, itemCount={}",
-                    EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
-        } catch (Exception e) {
-            log.error("Outbox 적재 실패 - type={}, orderNumber={}, userId={}",
-                    EventTypes.PAYMENT_SUCCESS, orderNumber, userId, e);
-        }
-    }
-
-
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
index bce9830c7..18bd90af2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
@@ -9,11 +9,12 @@
 import lombok.extern.slf4j.Slf4j;
 
 /**
- * 실패한 Outbox 이벤트 재시도 스케줄러
+ * Outbox 이벤트 배치 처리 스케줄러
  * <p>
- * Event-Driven + Outbox Fallback 패턴에서:
- * - 즉시 발송은 이벤트 핸들러에서 담당
- * - 실패한 이벤트만 이 스케줄러에서 재시도
+ * 순수 Outbox 패턴:
+ * - 모든 이벤트는 Outbox에 READY 상태로 저장
+ * - 이 스케줄러가 배치 단위로 Kafka 발송 담당
+ * - 실패한 이벤트는 재시도 처리
  *
  * @author hyunjikoh
  * @since 2025. 12. 17.
@@ -27,8 +28,40 @@ public class OutboxRelayScheduler {
     private static final int MAX_RETRY_COUNT = 5;
 
     /**
-     * 실패한 이벤트만 재시도 (FAILED 상태)
-     * 30초마다 실행 - 즉시 발송이 주 방식이므로 재시도는 여유롭게
+     * READY 상태 이벤트 배치 처리 (1초마다)
+     * 순수 Outbox 패턴 - 모든 이벤트를 스케줄러가 처리
+     */
+    @Scheduled(fixedDelay = 1000)
+    public void processReadyEvents() {
+        final List<OutboxEventEntity> readyEvents =
+                outboxService.findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus.READY);
+
+        if (readyEvents.isEmpty()) {
+            return; // 로그 없이 조용히 리턴
+        }
+
+        log.info("READY 이벤트 배치 처리 시작 - 처리할 이벤트 수: {}", readyEvents.size());
+
+        int successCount = 0;
+        int failCount = 0;
+
+        for (OutboxEventEntity event : readyEvents) {
+            boolean isSuccess = outboxService.processEvent(event);
+            if (isSuccess) {
+                successCount++;
+                log.debug("이벤트 발송 성공 - eventId: {}, type: {}", 
+                         event.getEventId(), event.getEventType());
+            } else {
+                failCount++;
+            }
+        }
+
+        log.info("READY 이벤트 배치 처리 완료 - 성공: {}, 실패: {}", successCount, failCount);
+    }
+
+    /**
+     * 실패한 이벤트 재시도 (FAILED 상태)
+     * 30초마다 실행 - 실패한 이벤트만 재시도
      */
     @Scheduled(fixedDelay = 30000)
     public void retryFailedEvents() {
@@ -37,8 +70,7 @@ public void retryFailedEvents() {
                         OutboxStatus.FAILED, MAX_RETRY_COUNT);
 
         if (failedEvents.isEmpty()) {
-            log.debug("재시도할 실패 이벤트 없음");
-            return;
+            return; // 로그 없이 조용히 리턴
         }
 
         log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {}", failedEvents.size());
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
index a47f79447..47e139e33 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -8,6 +8,11 @@
  * @since 2025. 12. 17.
  */
 public interface OutboxRepository {
+    /**
+     * READY 상태 이벤트 조회 (순수 Outbox 패턴용)
+     */
+    List<OutboxEventEntity> findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status);
+    
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus);
 
     /**
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
index 07f1b570a..6cad8e733 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
@@ -61,6 +61,10 @@ public boolean processEvent(OutboxEventEntity event) {
         }
     }
 
+    public List<OutboxEventEntity> findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status) {
+        return outboxRepository.findTop50ByStatusOrderByCreatedAtAsc(status);
+    }
+
     public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(OutboxStatus outboxStatus,
                                                                                              int maxRetryCount) {
         return outboxRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
deleted file mode 100644
index 8644dcfcc..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeKafkaEventHandler.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package com.loopers.domain.like.event;
-
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.event.TransactionPhase;
-import org.springframework.transaction.event.TransactionalEventListener;
-
-import com.loopers.domain.event.EventTypes;
-import com.loopers.domain.event.EventVersions;
-import com.loopers.domain.event.outbox.OutboxEventEntity;
-import com.loopers.domain.event.outbox.OutboxRepository;
-import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.DomainEventPublisher;
-import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * 좋아요 도메인 이벤트를 Kafka로 즉시 발송하는 핸들러
- * <p>
- * Event-Driven + Outbox Fallback 패턴:
- * 1. 트랜잭션 커밋 후 즉시 Kafka 발송 시도
- * 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
- * 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
- *
- * @author hyunjikoh
- * @since 2025. 12. 18.
- */
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class LikeKafkaEventHandler {
-    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
-
-    private final DomainEventEnvelopeFactory envelopeFactory;
-    private final DomainEventPublisher domainEventPublisher;
-    private final OutboxRepository outboxRepository;
-
-    /**
-     * 좋아요 변경 도메인 이벤트를 Kafka로 즉시 발송
-     * 실패 시 Outbox에 저장하여 나중에 재시도
-     */
-    @Async
-    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
-    public void handleLikeChanged(final LikeChangedEvent event) {
-        final Long productId = event.productId();
-        final Long userId = event.userId();
-        final String action = event.action().name(); // LIKE or UNLIKE
-
-        if (productId == null || userId == null || action == null) {
-            log.warn("이벤트 발송 스킵 - 필수값 누락 productId={}, userId={}, action={}",
-                    productId, userId, action);
-            return;
-        }
-
-        final LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, userId, action);
-        final var envelope = envelopeFactory.create(EventTypes.LIKE_ACTION, EventVersions.V1, payload);
-        final String partitionKey = String.valueOf(productId);
-
-        try {
-            // 1. 즉시 Kafka 발송 시도
-            domainEventPublisher.publish(CATALOG_EVENTS_TOPIC, partitionKey, envelope);
-
-            // 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
-            OutboxEventEntity sent = OutboxEventEntity.ready(
-                    envelope.eventId(),
-                    CATALOG_EVENTS_TOPIC,
-                    partitionKey,
-                    envelope.eventType(),
-                    envelope.version(),
-                    envelope.occurredAtEpochMillis(),
-                    envelope.payloadJson()
-            );
-            sent.markSent(); // SENT 상태로 변경
-            outboxRepository.save(sent);
-
-            log.info("이벤트 즉시 발송 성공 - type={}, productId={}, userId={}, action={}",
-                    EventTypes.LIKE_ACTION, productId, userId, action);
-
-        } catch (Exception e) {
-            // 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
-            OutboxEventEntity failed = OutboxEventEntity.ready(
-                    envelope.eventId(),
-                    CATALOG_EVENTS_TOPIC,
-                    partitionKey,
-                    envelope.eventType(),
-                    envelope.version(),
-                    envelope.occurredAtEpochMillis(),
-                    envelope.payloadJson()
-            );
-            failed.markFailed(); // FAILED 상태로 변경
-            outboxRepository.save(failed);
-
-            log.warn("이벤트 즉시 발송 실패, Outbox에 저장 - type={}, productId={}, userId={}, action={}, error={}",
-                    EventTypes.LIKE_ACTION, productId, userId, action, e.getMessage());
-        }
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java
deleted file mode 100644
index b106a0545..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/tracking/ProductViewKafkaEventHandler.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.loopers.domain.tracking;
-
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.event.TransactionPhase;
-import org.springframework.transaction.event.TransactionalEventListener;
-
-import com.loopers.domain.event.EventTypes;
-import com.loopers.domain.event.EventVersions;
-import com.loopers.domain.event.outbox.OutboxEventEntity;
-import com.loopers.domain.event.outbox.OutboxRepository;
-import com.loopers.domain.tracking.event.UserBehaviorEvent;
-import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
-import com.loopers.infrastructure.event.DomainEventPublisher;
-import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * 상품 조회 행동 추적 이벤트를 Kafka로 즉시 발송하는 핸들러
- * <p>
- * Event-Driven + Outbox Fallback 패턴:
- * 1. 트랜잭션 커밋 후 즉시 Kafka 발송 시도
- * 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
- * 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
- *
- * @author hyunjikoh
- * @since 2025. 12. 18.
- */
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class ProductViewKafkaEventHandler {
-    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
-
-    private final DomainEventEnvelopeFactory envelopeFactory;
-    private final DomainEventPublisher domainEventPublisher;
-    private final OutboxRepository outboxRepository;
-
-    /**
-     * 상품 조회 행동 추적 이벤트를 Kafka로 즉시 발송
-     * PRODUCT_VIEW 타입의 UserBehaviorEvent만 처리
-     * 실패 시 Outbox에 저장하여 나중에 재시도
-     */
-    @Async
-    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
-    public void handleProductViewBehavior(final UserBehaviorEvent event) {
-        // PRODUCT_VIEW 타입만 처리
-        if (!"PRODUCT_VIEW".equals(event.eventType())) {
-            return;
-        }
-
-        final Long productId = event.targetId();
-        final Long userId = event.userId();
-
-        if (productId == null || userId == null) {
-            log.warn("이벤트 발송 스킵 - 필수값 누락 productId={}, userId={}", productId, userId);
-            return;
-        }
-
-        final ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, userId);
-        final var envelope = envelopeFactory.create(EventTypes.PRODUCT_VIEW, EventVersions.V1, payload);
-        final String partitionKey = String.valueOf(productId);
-
-        try {
-            // 1. 즉시 Kafka 발송 시도
-            domainEventPublisher.publish(CATALOG_EVENTS_TOPIC, partitionKey, envelope);
-
-            // 2. 성공 시 Outbox에 SENT 상태로 기록 (모니터링용)
-            OutboxEventEntity sent = OutboxEventEntity.ready(
-                    envelope.eventId(),
-                    CATALOG_EVENTS_TOPIC,
-                    partitionKey,
-                    envelope.eventType(),
-                    envelope.version(),
-                    envelope.occurredAtEpochMillis(),
-                    envelope.payloadJson()
-            );
-            sent.markSent(); // SENT 상태로 변경
-            outboxRepository.save(sent);
-
-            log.info("상품 조회 이벤트 즉시 발송 성공 - type={}, productId={}, userId={}",
-                    EventTypes.PRODUCT_VIEW, productId, userId);
-
-        } catch (Exception e) {
-            // 3. 실패 시 Outbox에 FAILED 상태로 기록 (재시도용)
-            OutboxEventEntity failed = OutboxEventEntity.ready(
-                    envelope.eventId(),
-                    CATALOG_EVENTS_TOPIC,
-                    partitionKey,
-                    envelope.eventType(),
-                    envelope.version(),
-                    envelope.occurredAtEpochMillis(),
-                    envelope.payloadJson()
-            );
-            failed.markFailed(); // FAILED 상태로 변경
-            outboxRepository.save(failed);
-
-            log.warn("상품 조회 이벤트 즉시 발송 실패, Outbox에 저장 - type={}, productId={}, userId={}, error={}",
-                    EventTypes.PRODUCT_VIEW, productId, userId, e.getMessage());
-        }
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventHandler.java
index 8b2fde232..104f3dcdf 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventHandler.java
@@ -5,8 +5,14 @@
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
 
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
 import com.loopers.domain.like.event.LikeChangedEvent;
 import com.loopers.domain.product.ProductMVService;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -24,25 +30,29 @@
 @RequiredArgsConstructor
 @Slf4j
 public class LikeEventHandler {
+    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
 
     private final ProductMVService productMVService;
+    private final DomainEventEnvelopeFactory envelopeFactory;
+    private final OutboxRepository outboxRepository;
 
     /**
      * 좋아요 변경 이벤트 처리
      * <p>
      * AFTER_COMMIT + @Async로 좋아요 트랜잭션과 완전 분리
-     * 집계 업데이트 실패가 좋아요 처리에 영향 주지 않음
+     * 1. MV 테이블 집계 업데이트
+     * 2. Kafka 이벤트용 Outbox 저장
      *
      * @param event 좋아요 변경 이벤트
      */
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     @Async
     public void handleLikeChanged(LikeChangedEvent event) {
+        // 1. MV 테이블 집계 업데이트
         try {
             log.debug("좋아요 집계 업데이트 시작 - productId: {}, action: {}, delta: {}",
                     event.productId(), event.action(), event.countDelta());
 
-            // MV 테이블의 좋아요 카운트 업데이트
             productMVService.updateLikeCount(event.productId(), event.countDelta());
 
             log.debug("좋아요 집계 업데이트 완료 - productId: {}, delta: {}",
@@ -53,5 +63,48 @@ public void handleLikeChanged(LikeChangedEvent event) {
             log.error("좋아요 집계 업데이트 실패 - productId: {}, action: {}, delta: {}",
                     event.productId(), event.action(), event.countDelta(), e);
         }
+
+        // 2. Kafka 이벤트용 Outbox 저장
+        try {
+            saveToOutbox(event);
+        } catch (Exception e) {
+            log.error("좋아요 이벤트 Outbox 저장 실패 - productId: {}, action: {}",
+                    event.productId(), event.action(), e);
+        }
+    }
+
+    /**
+     * 좋아요 이벤트를 Outbox에 저장
+     */
+    private void saveToOutbox(LikeChangedEvent event) {
+        final Long productId = event.productId();
+        final Long userId = event.userId();
+        final String action = event.action().name(); // LIKE or UNLIKE
+
+        if (productId == null || userId == null || action == null) {
+            log.warn("Outbox 저장 스킵 - 필수값 누락 productId={}, userId={}, action={}",
+                    productId, userId, action);
+            return;
+        }
+
+        final LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, userId, action);
+        final var envelope = envelopeFactory.create(EventTypes.LIKE_ACTION, EventVersions.V1, payload);
+        final String partitionKey = String.valueOf(productId);
+
+        // Outbox에 저장 (READY 상태)
+        OutboxEventEntity outboxEvent = OutboxEventEntity.ready(
+                envelope.eventId(),
+                CATALOG_EVENTS_TOPIC,
+                partitionKey,
+                envelope.eventType(),
+                envelope.version(),
+                envelope.occurredAtEpochMillis(),
+                envelope.payloadJson()
+        );
+
+        outboxRepository.save(outboxEvent);
+
+        log.info("좋아요 이벤트 Outbox 저장 완료 - type={}, productId={}, userId={}, action={}",
+                EventTypes.LIKE_ACTION, productId, userId, action);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/UserBehaviorEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/UserBehaviorEventHandler.java
index 47397b97e..ddf140067 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/UserBehaviorEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/tracking/UserBehaviorEventHandler.java
@@ -5,7 +5,13 @@
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
 
+import com.loopers.domain.event.EventTypes;
+import com.loopers.domain.event.EventVersions;
+import com.loopers.domain.event.outbox.OutboxEventEntity;
+import com.loopers.domain.event.outbox.OutboxRepository;
 import com.loopers.domain.tracking.event.UserBehaviorEvent;
+import com.loopers.infrastructure.event.DomainEventEnvelopeFactory;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
 import com.loopers.infrastructure.tracking.client.AnalyticsClient;
 
 import lombok.RequiredArgsConstructor;
@@ -24,18 +30,23 @@
 @Slf4j
 @RequiredArgsConstructor
 public class UserBehaviorEventHandler {
+    private static final String CATALOG_EVENTS_TOPIC = "catalog-events";
 
     private final AnalyticsClient analyticsClient;
+    private final DomainEventEnvelopeFactory envelopeFactory;
+    private final OutboxRepository outboxRepository;
 
     /**
      * 유저 행동 이벤트 처리
      * <p>
      * AFTER_COMMIT + @Async로 완전한 트랜잭션 분리
-     * 분석 시스템 전송 실패가 비즈니스 로직에 영향 주지 않음
+     * 1. 분석 시스템으로 데이터 전송
+     * 2. PRODUCT_VIEW인 경우 Kafka 이벤트용 Outbox 저장
      */
     @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
     public void handleUserBehavior(UserBehaviorEvent event) {
+        // 1. 분석 시스템으로 데이터 전송
         try {
             log.debug("유저 행동 분석 데이터 전송 시작 - eventType: {}, userId: {}, targetId: {}",
                     event.eventType(), event.userId(), event.targetId());
@@ -58,5 +69,48 @@ public void handleUserBehavior(UserBehaviorEvent event) {
 
             // TODO: 실패한 이벤트를 재처리 큐에 넣거나 로컬 저장소에 백업
         }
+
+        // 2. PRODUCT_VIEW인 경우 Kafka 이벤트용 Outbox 저장
+        if ("PRODUCT_VIEW".equals(event.eventType())) {
+            try {
+                saveProductViewToOutbox(event);
+            } catch (Exception e) {
+                log.error("상품 조회 이벤트 Outbox 저장 실패 - userId: {}, targetId: {}",
+                        event.userId(), event.targetId(), e);
+            }
+        }
+    }
+
+    /**
+     * 상품 조회 이벤트를 Outbox에 저장
+     */
+    private void saveProductViewToOutbox(UserBehaviorEvent event) {
+        final Long productId = event.targetId();
+        final Long userId = event.userId();
+
+        if (productId == null || userId == null) {
+            log.warn("Outbox 저장 스킵 - 필수값 누락 productId={}, userId={}", productId, userId);
+            return;
+        }
+
+        final ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, userId);
+        final var envelope = envelopeFactory.create(EventTypes.PRODUCT_VIEW, EventVersions.V1, payload);
+        final String partitionKey = String.valueOf(productId);
+
+        // Outbox에 저장 (READY 상태)
+        OutboxEventEntity outboxEvent = OutboxEventEntity.ready(
+                envelope.eventId(),
+                CATALOG_EVENTS_TOPIC,
+                partitionKey,
+                envelope.eventType(),
+                envelope.version(),
+                envelope.occurredAtEpochMillis(),
+                envelope.payloadJson()
+        );
+
+        outboxRepository.save(outboxEvent);
+
+        log.info("상품 조회 이벤트 Outbox 저장 완료 - type={}, productId={}, userId={}",
+                EventTypes.PRODUCT_VIEW, productId, userId);
     }
 }

From 695f586336780994176e8c91b69dcdcdca700399 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 10:21:08 +0900
Subject: [PATCH 13/28] =?UTF-8?q?feat(kafka):=20Outbox=20=EC=9D=B4?=
 =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 상태별 이벤트 조회 시 개수 조절 기능 추가
- READY 및 FAILED 상태 이벤트 처리 시 배치 크기 설정 가능
---
 .../event/outbox/OutboxRelayScheduler.java    | 22 +++++++++++--------
 .../domain/event/outbox/OutboxRepository.java | 15 ++++++++++---
 .../domain/event/outbox/OutboxService.java    | 20 +++++++++++++++--
 .../outbox/OutboxEventJpaRepository.java      | 19 ++++++++++++++++
 .../outbox/OutboxEventRepositoryImpl.java     | 13 +++++++++++
 5 files changed, 75 insertions(+), 14 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
index 18bd90af2..edb5c605b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
@@ -26,6 +26,7 @@ public class OutboxRelayScheduler {
 
     private final OutboxService outboxService;
     private static final int MAX_RETRY_COUNT = 5;
+    private static final int BULK_COUNT = 1000; // 배치 처리 단위
 
     /**
      * READY 상태 이벤트 배치 처리 (1초마다)
@@ -34,13 +35,14 @@ public class OutboxRelayScheduler {
     @Scheduled(fixedDelay = 1000)
     public void processReadyEvents() {
         final List<OutboxEventEntity> readyEvents =
-                outboxService.findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus.READY);
+                outboxService.findTopNByStatusOrderByCreatedAtAsc(OutboxStatus.READY, BULK_COUNT);
 
         if (readyEvents.isEmpty()) {
-            return; // 로그 없이 조용히 리턴
+            return;
         }
 
-        log.info("READY 이벤트 배치 처리 시작 - 처리할 이벤트 수: {}", readyEvents.size());
+        log.info("READY 이벤트 배치 처리 시작 - 처리할 이벤트 수: {} (배치크기: {})", 
+                readyEvents.size(), BULK_COUNT);
 
         int successCount = 0;
         int failCount = 0;
@@ -56,7 +58,8 @@ public void processReadyEvents() {
             }
         }
 
-        log.info("READY 이벤트 배치 처리 완료 - 성공: {}, 실패: {}", successCount, failCount);
+        log.info("READY 이벤트 배치 처리 완료 - 성공: {}, 실패: {} (배치크기: {})", 
+                successCount, failCount, BULK_COUNT);
     }
 
     /**
@@ -66,14 +69,15 @@ public void processReadyEvents() {
     @Scheduled(fixedDelay = 30000)
     public void retryFailedEvents() {
         final List<OutboxEventEntity> failedEvents =
-                outboxService.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
-                        OutboxStatus.FAILED, MAX_RETRY_COUNT);
+                outboxService.findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+                        OutboxStatus.FAILED, MAX_RETRY_COUNT, BULK_COUNT);
 
         if (failedEvents.isEmpty()) {
             return; // 로그 없이 조용히 리턴
         }
 
-        log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {}", failedEvents.size());
+        log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {} (배치크기: {})", 
+                failedEvents.size(), BULK_COUNT);
 
         int retrySuccessCount = 0;
         int retryFailCount = 0;
@@ -97,7 +101,7 @@ public void retryFailedEvents() {
             }
         }
 
-        log.info("실패 이벤트 재시도 완료 - 성공: {}, 실패: {}, 최대재시도초과: {}",
-                retrySuccessCount, retryFailCount, maxRetryReachedCount);
+        log.info("실패 이벤트 재시도 완료 - 성공: {}, 실패: {}, 최대재시도초과: {} (배치크기: {})",
+                retrySuccessCount, retryFailCount, maxRetryReachedCount, BULK_COUNT);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
index 47e139e33..d7bbd55c0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -9,12 +9,15 @@
  */
 public interface OutboxRepository {
     /**
-     * READY 상태 이벤트 조회 (순수 Outbox 패턴용)
+     * READY 상태 이벤트 조회
      */
-    List<OutboxEventEntity> findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status);
-    
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus);
 
+    /**
+     * 지정된 개수만큼 상태별 이벤트 조회 (배치 크기 조절 가능)
+     */
+    List<OutboxEventEntity> findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit);
+
     /**
      * 재시도 가능한 실패 이벤트 조회
      *
@@ -25,5 +28,11 @@ public interface OutboxRepository {
     List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
             OutboxStatus status, int maxRetryCount);
 
+    /**
+     * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능)
+     */
+    List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            OutboxStatus status, int maxRetryCount, int limit);
+
     OutboxEventEntity save(OutboxEventEntity ready);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
index 6cad8e733..8cc58fae6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxService.java
@@ -61,8 +61,15 @@ public boolean processEvent(OutboxEventEntity event) {
         }
     }
 
-    public List<OutboxEventEntity> findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status) {
-        return outboxRepository.findTop50ByStatusOrderByCreatedAtAsc(status);
+    public List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status) {
+        return outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(status);
+    }
+
+    /**
+     * 지정된 개수만큼 READY 상태 이벤트 조회 (배치 크기 조절 가능)
+     */
+    public List<OutboxEventEntity> findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit) {
+        return outboxRepository.findTopNByStatusOrderByCreatedAtAsc(status, limit);
     }
 
     public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(OutboxStatus outboxStatus,
@@ -70,4 +77,13 @@ public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCrea
         return outboxRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
                 outboxStatus, maxRetryCount);
     }
+
+    /**
+     * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능)
+     */
+    public List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            OutboxStatus status, int maxRetryCount, int limit) {
+        return outboxRepository.findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+                status, maxRetryCount, limit);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
index 77489df5c..da35c003c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
@@ -7,10 +7,21 @@
 import com.loopers.domain.event.outbox.OutboxEventEntity;
 import com.loopers.domain.event.outbox.OutboxStatus;
 
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
 public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> {
 
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status);
 
+    /**
+     * 지정된 개수만큼 상태별 이벤트 조회 (배치 크기 조절 가능)
+     */
+    @Query(value = "SELECT * FROM outbox_event WHERE status = :status ORDER BY created_at ASC LIMIT :limit", 
+           nativeQuery = true)
+    List<OutboxEventEntity> findTopNByStatusOrderByCreatedAtAsc(
+            @Param("status") String status, @Param("limit") int limit);
+
     /**
      * 재시도 가능한 실패 이벤트 조회
      *
@@ -20,4 +31,12 @@ public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntit
      */
     List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
             OutboxStatus status, int maxRetryCount);
+
+    /**
+     * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능)
+     */
+    @Query(value = "SELECT * FROM outbox_event WHERE status = :status AND retry_count < :maxRetryCount ORDER BY created_at ASC LIMIT :limit", 
+           nativeQuery = true)
+    List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+            @Param("status") String status, @Param("maxRetryCount") int maxRetryCount, @Param("limit") int limit);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
index ab7f48048..573254838 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
@@ -26,12 +26,25 @@ public List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatu
         return outboxEventJpaRepository.findTop100ByStatusOrderByCreatedAtAsc(outboxStatus);
     }
 
+    @Override
+    public List<OutboxEventEntity> findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit) {
+        return outboxEventJpaRepository.findTopNByStatusOrderByCreatedAtAsc(status.name(), limit);
+    }
+
     @Override
     public List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
             OutboxStatus status, int maxRetryCount) {
         return outboxEventJpaRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(status, maxRetryCount);
     }
 
+    @Override
+    public List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(OutboxStatus status,
+                                                                                            int maxRetryCount,
+                                                                                            int limit) {
+        return outboxEventJpaRepository.findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
+                status.name(), maxRetryCount, limit);
+    }
+
     @Override
     public OutboxEventEntity save(OutboxEventEntity ready) {
         return outboxEventJpaRepository.save(ready);

From 9e2abf1a4ac90de2b3242517c495061b41714ef6 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 10:40:10 +0900
Subject: [PATCH 14/28] =?UTF-8?q?feat(metrics):=20=EB=A9=94=EB=AA=A8?=
 =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20=EB=9D=BD=20=EB=B0=8F=20?=
 =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Redis 분산락 대신 ConcurrentHashMap 기반 메모리 락으로 성능 개선
- 사용하지 않는 락과 처리된 이벤트 캐시를 주기적으로 정리하는 스케줄러 구현
---
 .../domain/metrics/MetricsService.java        | 316 +++++++++---------
 .../lock/RedisDistributedLock.java            | 146 ++++++++
 .../MetricsLockCleanupScheduler.java          |  61 ++++
 3 files changed, 366 insertions(+), 157 deletions(-)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 5e088aa4c..30aa46dfd 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -1,207 +1,209 @@
 package com.loopers.domain.metrics;
 
-import java.time.Duration;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.Optional;
-import java.util.UUID;
-
-import org.springframework.dao.DataIntegrityViolationException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
 import org.springframework.stereotype.Component;
 
-import com.loopers.domain.event.EventEntity;
 import com.loopers.domain.event.EventRepository;
-import com.loopers.infrastructure.cache.ProductCacheService;
-import com.loopers.infrastructure.lock.RedisDistributedLock;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
-import jakarta.transaction.Transactional;
-
 /**
- * Redis 분산락을 이용한 동시성 안전한 메트릭 서비스
+ * ConcurrentHashMap 기반 동시성 안전한 메트릭 서비스
  * <p>
- * 동일한 상품에 대한 동시 업데이트를 분산락으로 제어하여
- * 메트릭 누락 없이 원자적으로 처리합니다.
+ * 상품별 메모리 락을 사용하여 동일한 상품에 대한 동시 업데이트를 제어합니다.
+ * Redis 분산락 대신 메모리 기반 락을 사용하여 성능을 대폭 향상시킵니다.
  *
  * @author hyunjikoh
- * @since 2025. 12. 16.
+ * @since 2025. 12. 19.
  */
 @Component
 @RequiredArgsConstructor
 @Slf4j
 public class MetricsService {
     private final EventRepository eventHandledRepository;
-    private final ProductMetricsRepository productMetricsRepository;
-    private final ProductCacheService productCacheService;
-    private final RedisDistributedLock distributedLock;
-    
-    // 분산락 설정
-    private static final Duration LOCK_EXPIRE_TIME = Duration.ofSeconds(10); // 락 만료 시간
-    private static final Duration MAX_WAIT_TIME = Duration.ofSeconds(5);     // 최대 대기 시간
-
-    @Transactional
+    private final MetricsTransactionService metricsTransactionService;
+
+    // 상품별 메모리 락 관리
+    private final ConcurrentHashMap<Long, ReentrantLock> productLocks = new ConcurrentHashMap<>();
+
+    // 락 획득 설정 (빠른 처리를 위해 짧게 설정)
+    private static final long LOCK_WAIT_TIME_MS = 100; // 100ms 대기
+    private static final int LOCK_CLEANUP_THRESHOLD = 10000; // 락 정리 임계값
+
+    // 메모리 기반 멱등성 체크 (성능 최적화)
+    private final ConcurrentHashMap<String, Boolean> processedEvents = new ConcurrentHashMap<>();
+    private static final int PROCESSED_EVENTS_CLEANUP_THRESHOLD = 50000; // 처리된 이벤트 정리 임계값
+
+    /**
+     * 멱등성 체크 - 메모리 기반으로 성능 최적화
+     * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행하여 성능을 향상시킵니다.
+     */
     public boolean tryMarkHandled(String eventId) {
-        try {
-            eventHandledRepository.save(EventEntity.create(eventId));
+        // 1. 메모리 캐시 먼저 확인 (빠른 경로)
+        if (processedEvents.containsKey(eventId)) {
+            log.debug("이미 처리된 이벤트 (메모리 캐시): {}", eventId);
+            return false;
+        }
+
+        // 2. DB에서 확인 (느린 경로)
+        if (eventHandledRepository.existsById(eventId)) {
+            // DB에 있으면 메모리 캐시에도 추가
+            processedEvents.put(eventId, true);
+            log.debug("이미 처리된 이벤트 (DB 확인): {}", eventId);
+            return false;
+        }
+
+        // 3. 새로운 이벤트 - 트랜잭션 서비스를 통해 안전하게 저장
+        boolean saved = metricsTransactionService.saveEventHandled(eventId);
+        if (saved) {
+            processedEvents.put(eventId, true);
             return true;
-        } catch (DataIntegrityViolationException e) {
-            return false; // 이미 처리됨
+        } else {
+            // 동시성으로 인해 다른 스레드가 먼저 저장한 경우
+            processedEvents.put(eventId, true);
+            log.debug("동시성으로 인한 중복 이벤트: {}", eventId);
+            return false;
         }
     }
 
     /**
-     * 조회수 증가 (분산락 적용)
+     * 조회수 증가 (메모리 락 적용)
      */
     public void incrementView(Long productId, long occurredAtEpochMillis) {
-        String lockKey = "metrics:view:" + productId;
-        String lockValue = generateLockValue();
-        
-        boolean success = distributedLock.executeWithLock(
-            lockKey, 
-            lockValue, 
-            LOCK_EXPIRE_TIME, 
-            MAX_WAIT_TIME,
-            () -> incrementViewWithLock(productId, occurredAtEpochMillis)
-        );
-        
-        if (!success) {
-            log.error("조회수 업데이트 실패 - 분산락 획득 실패: productId={}", productId);
-            throw new RuntimeException("조회수 업데이트 실패: 분산락 획득 실패");
-        }
-    }
-    
-    @Transactional
-    protected void incrementViewWithLock(Long productId, long occurredAtEpochMillis) {
-        final ProductMetricsEntity metrics = getOrCreate(productId);
-        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
-        
-        if (isEventTooOld(metrics, eventTime)) {
-            log.debug("Ignoring old PRODUCT_VIEW event for productId: {}, eventTime: {}, lastEventAt: {}", 
-                     productId, eventTime, metrics.getLastEventAt());
-            return;
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.incrementViewWithTransaction(productId, occurredAtEpochMillis);
+                    log.debug("조회수 업데이트 성공: productId={}", productId);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("조회수 업데이트 스킵 - 락 획득 실패: productId={}", productId);
+                // 락 획득 실패 시 이벤트 스킵 (성능 우선)
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("조회수 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
         }
-        
-        metrics.incrementView(eventTime);
-        productMetricsRepository.save(metrics);
-        
-        log.debug("조회수 업데이트 완료 - productId: {}, newViewCount: {}", productId, metrics.getViewCount());
-        
-        // 캐시 무효화 (조회수 임계값 체크)
-        productCacheService.onViewCountChanged(productId, metrics.getViewCount());
     }
 
+
+
     /**
-     * 좋아요 수 변경 (분산락 적용)
+     * 좋아요 수 변경 (메모리 락 적용)
      */
     public void applyLikeDelta(final Long productId, final int delta, long occurredAtEpochMillis) {
-        String lockKey = "metrics:like:" + productId;
-        String lockValue = generateLockValue();
-        
-        boolean success = distributedLock.executeWithLock(
-            lockKey, 
-            lockValue, 
-            LOCK_EXPIRE_TIME, 
-            MAX_WAIT_TIME,
-            () -> applyLikeDeltaWithLock(productId, delta, occurredAtEpochMillis)
-        );
-        
-        if (!success) {
-            log.error("좋아요 수 업데이트 실패 - 분산락 획득 실패: productId={}, delta={}", productId, delta);
-            throw new RuntimeException("좋아요 수 업데이트 실패: 분산락 획득 실패");
-        }
-    }
-    
-    @Transactional
-    protected void applyLikeDeltaWithLock(final Long productId, final int delta, long occurredAtEpochMillis) {
-        final ProductMetricsEntity metrics = getOrCreate(productId);
-        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
-        
-        if (isEventTooOld(metrics, eventTime)) {
-            log.debug("Ignoring old LIKE_ACTION event for productId: {}, eventTime: {}, lastEventAt: {}", 
-                     productId, eventTime, metrics.getLastEventAt());
-            return;
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.applyLikeDeltaWithTransaction(productId, delta, occurredAtEpochMillis);
+                    log.debug("좋아요 수 업데이트 성공: productId={}, delta={}", productId, delta);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("좋아요 수 업데이트 스킵 - 락 획득 실패: productId={}, delta={}", productId, delta);
+                // 락 획득 실패 시 이벤트 스킵 (성능 우선)
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("좋아요 수 업데이트 중단 - 스레드 인터럽트: productId={}, delta={}", productId, delta);
         }
-        
-        long oldLikeCount = metrics.getLikeCount();
-        metrics.applyLikeDelta(delta, eventTime);
-        productMetricsRepository.save(metrics);
-        
-        log.debug("좋아요 수 업데이트 완료 - productId: {}, delta: {}, oldCount: {}, newCount: {}", 
-                 productId, delta, oldLikeCount, metrics.getLikeCount());
-        
-        // 캐시 무효화 (좋아요 수 변경)
-        productCacheService.onLikeCountChanged(productId);
     }
 
+
+
     /**
-     * 판매량 증가 (분산락 적용)
+     * 판매량 증가 (메모리 락 적용)
      */
     public void addSales(final Long productId, final int quantity, long occurredAtEpochMillis) {
-        String lockKey = "metrics:sales:" + productId;
-        String lockValue = generateLockValue();
-        
-        boolean success = distributedLock.executeWithLock(
-            lockKey, 
-            lockValue, 
-            LOCK_EXPIRE_TIME, 
-            MAX_WAIT_TIME,
-            () -> addSalesWithLock(productId, quantity, occurredAtEpochMillis)
-        );
-        
-        if (!success) {
-            log.error("판매량 업데이트 실패 - 분산락 획득 실패: productId={}, quantity={}", productId, quantity);
-            throw new RuntimeException("판매량 업데이트 실패: 분산락 획득 실패");
-        }
-    }
-    
-    @Transactional
-    protected void addSalesWithLock(final Long productId, final int quantity, long occurredAtEpochMillis) {
-        final ProductMetricsEntity metrics = getOrCreate(productId);
-        final ZonedDateTime eventTime = toZonedDateTime(occurredAtEpochMillis);
-        
-        if (isEventTooOld(metrics, eventTime)) {
-            log.debug("Ignoring old PAYMENT_SUCCESS event for productId: {}, eventTime: {}, lastEventAt: {}", 
-                     productId, eventTime, metrics.getLastEventAt());
-            return;
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.addSalesWithTransaction(productId, quantity, occurredAtEpochMillis);
+                    log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("판매량 업데이트 스킵 - 락 획득 실패: productId={}, quantity={}", productId, quantity);
+                // 락 획득 실패 시 이벤트 스킵 (성능 우선)
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("판매량 업데이트 중단 - 스레드 인터럽트: productId={}, quantity={}", productId, quantity);
         }
-        
-        long oldSalesCount = metrics.getSalesCount();
-        metrics.addSales(quantity, eventTime);
-        productMetricsRepository.save(metrics);
-        
-        log.debug("판매량 업데이트 완료 - productId: {}, quantity: {}, oldCount: {}, newCount: {}", 
-                 productId, quantity, oldSalesCount, metrics.getSalesCount());
-        
-        // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향)
-        productCacheService.onSalesCountChanged(productId);
     }
 
-    private ProductMetricsEntity getOrCreate(final Long productId) {
-        final Optional<ProductMetricsEntity> found = productMetricsRepository.findById(productId);
-        return found.orElseGet(() -> ProductMetricsEntity.create(productId));
+
+
+    /**
+     * 상품별 락 획득 (없으면 생성)
+     */
+    private ReentrantLock getProductLock(Long productId) {
+        return productLocks.computeIfAbsent(productId, k -> new ReentrantLock());
     }
-    
-    private ZonedDateTime toZonedDateTime(long epochMillis) {
-        return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault());
+
+    /**
+     * 락 상태 모니터링 및 정리 (메모리 누수 방지)
+     */
+    public void cleanupUnusedLocks() {
+        if (productLocks.size() > LOCK_CLEANUP_THRESHOLD) {
+            log.info("락 정리 시작 - 현재 락 수: {}", productLocks.size());
+
+            // 사용하지 않는 락 제거 (락이 걸려있지 않은 것들)
+            productLocks.entrySet().removeIf(entry -> {
+                ReentrantLock lock = entry.getValue();
+                return !lock.isLocked() && !lock.hasQueuedThreads();
+            });
+
+            log.info("락 정리 완료 - 정리 후 락 수: {}", productLocks.size());
+        }
     }
-    
-    private boolean isEventTooOld(ProductMetricsEntity metrics, ZonedDateTime eventTime) {
-        // 첫 번째 이벤트인 경우 항상 처리
-        if (metrics.getLastEventAt() == null) {
-            return false;
+
+    /**
+     * 처리된 이벤트 캐시 정리 (메모리 누수 방지)
+     */
+    public void cleanupProcessedEvents() {
+        if (processedEvents.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) {
+            log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEvents.size());
+            
+            // 오래된 이벤트 캐시 절반 정도 제거 (LRU 방식은 아니지만 메모리 절약)
+            int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2;
+            int currentSize = processedEvents.size();
+            int toRemove = currentSize - targetSize;
+            
+            processedEvents.entrySet().stream()
+                    .limit(toRemove)
+                    .map(entry -> entry.getKey())
+                    .forEach(processedEvents::remove);
+            
+            log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEvents.size());
         }
-        
-        // 이벤트 시간이 마지막 처리 시간보다 이전인 경우 무시
-        return eventTime.isBefore(metrics.getLastEventAt());
     }
-    
+
     /**
-     * 분산락용 고유 값 생성
+     * 락 상태 정보 조회 (모니터링용)
      */
-    private String generateLockValue() {
-        return Thread.currentThread().getName() + ":" + UUID.randomUUID().toString();
+    public void logLockStatus() {
+        int totalLocks = productLocks.size();
+        long lockedCount = productLocks.values().stream()
+                .mapToLong(lock -> lock.isLocked() ? 1 : 0)
+                .sum();
+
+        if (totalLocks > 0) {
+            log.debug("메트릭 락 상태 - 총 락: {}, 사용 중: {}", totalLocks, lockedCount);
+        }
     }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
new file mode 100644
index 000000000..8f15cbea8
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
@@ -0,0 +1,146 @@
+package com.loopers.infrastructure.lock;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Redis 기반 분산락 구현
+ * <p>
+ * Lua 스크립트를 사용하여 원자적 락 획득/해제를 보장합니다.
+ * 
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class RedisDistributedLock {
+    
+    private final RedisTemplate<String, String> redisTemplate;
+    
+    private static final String LOCK_PREFIX = "lock:";
+    private static final String UNLOCK_SCRIPT = 
+        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
+        "return redis.call('del', KEYS[1]) " +
+        "else return 0 end";
+    
+    private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>();
+    
+    static {
+        UNLOCK_LUA_SCRIPT.setScriptText(UNLOCK_SCRIPT);
+        UNLOCK_LUA_SCRIPT.setResultType(Long.class);
+    }
+    
+    /**
+     * 락 획득 시도
+     * 
+     * @param lockKey 락 키
+     * @param lockValue 락 값 (보통 스레드 ID나 UUID)
+     * @param expireTime 락 만료 시간
+     * @return 락 획득 성공 여부
+     */
+    public boolean tryLock(String lockKey, String lockValue, Duration expireTime) {
+        String key = LOCK_PREFIX + lockKey;
+        
+        try {
+            Boolean result = redisTemplate.opsForValue()
+                    .setIfAbsent(key, lockValue, expireTime);
+            
+            boolean acquired = Boolean.TRUE.equals(result);
+            
+            if (acquired) {
+                log.debug("분산락 획득 성공 - key: {}, value: {}", key, lockValue);
+            } else {
+                log.debug("분산락 획득 실패 - key: {}, value: {}", key, lockValue);
+            }
+            
+            return acquired;
+            
+        } catch (Exception e) {
+            log.error("분산락 획득 중 오류 발생 - key: {}, value: {}", key, lockValue, e);
+            return false;
+        }
+    }
+    
+    /**
+     * 락 해제
+     * 
+     * @param lockKey 락 키
+     * @param lockValue 락 값 (획득 시 사용한 값과 동일해야 함)
+     * @return 락 해제 성공 여부
+     */
+    public boolean unlock(String lockKey, String lockValue) {
+        String key = LOCK_PREFIX + lockKey;
+        
+        try {
+            List<String> keys = new java.util.ArrayList<>();
+            keys.add(key);
+            Long result = redisTemplate.execute(
+                    UNLOCK_LUA_SCRIPT,
+                    keys,
+                    lockValue
+            );
+            
+            boolean released = Long.valueOf(1).equals(result);
+            
+            if (released) {
+                log.debug("분산락 해제 성공 - key: {}, value: {}", key, lockValue);
+            } else {
+                log.debug("분산락 해제 실패 - key: {}, value: {} (이미 만료되었거나 다른 스레드가 소유)", key, lockValue);
+            }
+            
+            return released;
+            
+        } catch (Exception e) {
+            log.error("분산락 해제 중 오류 발생 - key: {}, value: {}", key, lockValue, e);
+            return false;
+        }
+    }
+    
+    /**
+     * 락을 획득하고 작업을 실행한 후 자동으로 해제
+     * 
+     * @param lockKey 락 키
+     * @param lockValue 락 값
+     * @param expireTime 락 만료 시간
+     * @param maxWaitTime 최대 대기 시간
+     * @param task 실행할 작업
+     * @return 작업 실행 성공 여부
+     */
+    public boolean executeWithLock(String lockKey, String lockValue, Duration expireTime, 
+                                 Duration maxWaitTime, Runnable task) {
+        long startTime = System.currentTimeMillis();
+        long maxWaitMillis = maxWaitTime.toMillis();
+        
+        while (System.currentTimeMillis() - startTime < maxWaitMillis) {
+            if (tryLock(lockKey, lockValue, expireTime)) {
+                try {
+                    task.run();
+                    return true;
+                } finally {
+                    unlock(lockKey, lockValue);
+                }
+            }
+            
+            // 더 짧은 시간 대기 후 재시도 (고성능 처리)
+            try {
+                Thread.sleep(5);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.warn("분산락 대기 중 인터럽트 발생 - key: {}", lockKey);
+                return false;
+            }
+        }
+        
+        log.warn("분산락 획득 타임아웃 - key: {}, maxWaitTime: {}ms", lockKey, maxWaitMillis);
+        return false;
+    }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
new file mode 100644
index 000000000..4fea5785b
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java
@@ -0,0 +1,61 @@
+package com.loopers.infrastructure.scheduler;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import com.loopers.domain.metrics.MetricsService;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 메트릭 서비스의 메모리 락 정리 스케줄러
+ * <p>
+ * 주기적으로 사용하지 않는 락과 처리된 이벤트 캐시를 정리하여 메모리 누수를 방지합니다.
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class MetricsLockCleanupScheduler {
+
+    private final MetricsService metricsService;
+
+    /**
+     * 사용하지 않는 락 정리 (5분마다)
+     */
+    @Scheduled(fixedRate = 5 * 60 * 1000) // 5분
+    public void cleanupUnusedLocks() {
+        try {
+            metricsService.cleanupUnusedLocks();
+        } catch (Exception e) {
+            log.error("락 정리 중 오류 발생", e);
+        }
+    }
+
+    /**
+     * 처리된 이벤트 캐시 정리 (10분마다)
+     */
+    @Scheduled(fixedRate = 10 * 60 * 1000) // 10분
+    public void cleanupProcessedEvents() {
+        try {
+            metricsService.cleanupProcessedEvents();
+        } catch (Exception e) {
+            log.error("처리된 이벤트 캐시 정리 중 오류 발생", e);
+        }
+    }
+
+    /**
+     * 락 상태 모니터링 (1분마다)
+     */
+    @Scheduled(fixedRate = 60 * 1000) // 1분
+    public void monitorLockStatus() {
+        try {
+            metricsService.logLockStatus();
+        } catch (Exception e) {
+            log.error("락 상태 모니터링 중 오류 발생", e);
+        }
+    }
+}
\ No newline at end of file

From c042dff1506fc3e0276ee15234d13e70264c67fb Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 14:49:56 +0900
Subject: [PATCH 15/28] =?UTF-8?q?feat(cache):=20Redis=20=EC=BA=90=EC=8B=9C?=
 =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=ED=82=A4=20?=
 =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B8=B0=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Redis를 이용한 기본 캐시 연산 구현
- 캐시 키 생성기 추가로 일관된 캐시 키 생성 지원
- 상품 및 브랜드 캐시 무효화 기능 추가
---
 .../cache/CacheUpdateStrategy.java            |  25 ---
 .../lock/RedisDistributedLock.java            | 146 --------------
 .../com/loopers/cache/BaseCacheService.java   | 178 ++++++++++++++++++
 .../com/loopers}/cache/CacheKeyGenerator.java |  89 ++++++---
 .../com/loopers}/cache/CacheStrategy.java     |  27 +--
 .../loopers/cache/CacheUpdateStrategy.java    |  24 +++
 6 files changed, 266 insertions(+), 223 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java
 delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
 create mode 100644 modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
 rename {apps/commerce-api/src/main/java/com/loopers/infrastructure => modules/redis/src/main/java/com/loopers}/cache/CacheKeyGenerator.java (64%)
 rename {apps/commerce-api/src/main/java/com/loopers/infrastructure => modules/redis/src/main/java/com/loopers}/cache/CacheStrategy.java (63%)
 create mode 100644 modules/redis/src/main/java/com/loopers/cache/CacheUpdateStrategy.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java
deleted file mode 100644
index e49142a67..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheUpdateStrategy.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.loopers.infrastructure.cache;
-
-/**
- * 캐시 갱신 전략
- */
-public enum CacheUpdateStrategy {
-
-    /**
-     * 배치 갱신 (주기적으로 미리 갱신)
-     * - 적용: Hot 데이터 (상품 목록, 인기 상품 등)
-     */
-    BATCH_REFRESH,
-
-    /**
-     * Cache-Aside (조회 시 캐시 미스 발생 시 DB에서 로드)
-     * - 적용: Warm 데이터 (개별 상품 상세정보 등)
-     */
-    CACHE_ASIDE,
-
-    /**
-     * 캐시 미사용 (항상 DB 직접 조회)
-     * - 적용: Cold 데이터 (조회 빈도 낮은 데이터)
-     */
-    NO_CACHE
-}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
deleted file mode 100644
index 8f15cbea8..000000000
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/lock/RedisDistributedLock.java
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.loopers.infrastructure.lock;
-
-import java.time.Duration;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.script.DefaultRedisScript;
-import org.springframework.stereotype.Component;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Redis 기반 분산락 구현
- * <p>
- * Lua 스크립트를 사용하여 원자적 락 획득/해제를 보장합니다.
- * 
- * @author hyunjikoh
- * @since 2025. 12. 19.
- */
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class RedisDistributedLock {
-    
-    private final RedisTemplate<String, String> redisTemplate;
-    
-    private static final String LOCK_PREFIX = "lock:";
-    private static final String UNLOCK_SCRIPT = 
-        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
-        "return redis.call('del', KEYS[1]) " +
-        "else return 0 end";
-    
-    private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>();
-    
-    static {
-        UNLOCK_LUA_SCRIPT.setScriptText(UNLOCK_SCRIPT);
-        UNLOCK_LUA_SCRIPT.setResultType(Long.class);
-    }
-    
-    /**
-     * 락 획득 시도
-     * 
-     * @param lockKey 락 키
-     * @param lockValue 락 값 (보통 스레드 ID나 UUID)
-     * @param expireTime 락 만료 시간
-     * @return 락 획득 성공 여부
-     */
-    public boolean tryLock(String lockKey, String lockValue, Duration expireTime) {
-        String key = LOCK_PREFIX + lockKey;
-        
-        try {
-            Boolean result = redisTemplate.opsForValue()
-                    .setIfAbsent(key, lockValue, expireTime);
-            
-            boolean acquired = Boolean.TRUE.equals(result);
-            
-            if (acquired) {
-                log.debug("분산락 획득 성공 - key: {}, value: {}", key, lockValue);
-            } else {
-                log.debug("분산락 획득 실패 - key: {}, value: {}", key, lockValue);
-            }
-            
-            return acquired;
-            
-        } catch (Exception e) {
-            log.error("분산락 획득 중 오류 발생 - key: {}, value: {}", key, lockValue, e);
-            return false;
-        }
-    }
-    
-    /**
-     * 락 해제
-     * 
-     * @param lockKey 락 키
-     * @param lockValue 락 값 (획득 시 사용한 값과 동일해야 함)
-     * @return 락 해제 성공 여부
-     */
-    public boolean unlock(String lockKey, String lockValue) {
-        String key = LOCK_PREFIX + lockKey;
-        
-        try {
-            List<String> keys = new java.util.ArrayList<>();
-            keys.add(key);
-            Long result = redisTemplate.execute(
-                    UNLOCK_LUA_SCRIPT,
-                    keys,
-                    lockValue
-            );
-            
-            boolean released = Long.valueOf(1).equals(result);
-            
-            if (released) {
-                log.debug("분산락 해제 성공 - key: {}, value: {}", key, lockValue);
-            } else {
-                log.debug("분산락 해제 실패 - key: {}, value: {} (이미 만료되었거나 다른 스레드가 소유)", key, lockValue);
-            }
-            
-            return released;
-            
-        } catch (Exception e) {
-            log.error("분산락 해제 중 오류 발생 - key: {}, value: {}", key, lockValue, e);
-            return false;
-        }
-    }
-    
-    /**
-     * 락을 획득하고 작업을 실행한 후 자동으로 해제
-     * 
-     * @param lockKey 락 키
-     * @param lockValue 락 값
-     * @param expireTime 락 만료 시간
-     * @param maxWaitTime 최대 대기 시간
-     * @param task 실행할 작업
-     * @return 작업 실행 성공 여부
-     */
-    public boolean executeWithLock(String lockKey, String lockValue, Duration expireTime, 
-                                 Duration maxWaitTime, Runnable task) {
-        long startTime = System.currentTimeMillis();
-        long maxWaitMillis = maxWaitTime.toMillis();
-        
-        while (System.currentTimeMillis() - startTime < maxWaitMillis) {
-            if (tryLock(lockKey, lockValue, expireTime)) {
-                try {
-                    task.run();
-                    return true;
-                } finally {
-                    unlock(lockKey, lockValue);
-                }
-            }
-            
-            // 더 짧은 시간 대기 후 재시도 (고성능 처리)
-            try {
-                Thread.sleep(5);
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-                log.warn("분산락 대기 중 인터럽트 발생 - key: {}", lockKey);
-                return false;
-            }
-        }
-        
-        log.warn("분산락 획득 타임아웃 - key: {}, maxWaitTime: {}ms", lockKey, maxWaitMillis);
-        return false;
-    }
-}
diff --git a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
new file mode 100644
index 000000000..9c452d77b
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
@@ -0,0 +1,178 @@
+package com.loopers.cache;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.stereotype.Service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 기본 캐시 서비스
+ * <p>
+ * Redis 캐시의 공통 연산을 제공합니다.
+ * 캐시 실패 시 로깅만 하고 서비스는 계속 동작합니다.
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class BaseCacheService {
+
+    private final RedisTemplate<String, String> redisTemplate;
+    private final ObjectMapper objectMapper;
+    private final CacheKeyGenerator cacheKeyGenerator;
+
+    // ========== 기본 캐시 연산 ==========
+
+    /**
+     * 캐시 저장
+     */
+    public void set(String key, Object value, long timeout, TimeUnit timeUnit) {
+        try {
+            String jsonValue = objectMapper.writeValueAsString(value);
+            redisTemplate.opsForValue().set(key, jsonValue, timeout, timeUnit);
+            log.debug("캐시 저장 - key: {}", key);
+        } catch (JsonProcessingException e) {
+            log.warn("캐시 저장 실패 (JSON 직렬화) - key: {}", key);
+        } catch (Exception e) {
+            log.warn("캐시 저장 실패 - key: {}, error: {}", key, e.getMessage());
+        }
+    }
+
+    /**
+     * 캐시 조회
+     */
+    public <T> Optional<T> get(String key, Class<T> clazz) {
+        try {
+            String value = redisTemplate.opsForValue().get(key);
+
+            if (value == null) {
+                log.debug("캐시 미스 - key: {}", key);
+                return Optional.empty();
+            }
+
+            T result = objectMapper.readValue(value, clazz);
+            log.debug("캐시 히트 - key: {}", key);
+
+            return Optional.of(result);
+        } catch (JsonProcessingException e) {
+            log.warn("캐시 조회 실패 (JSON 역직렬화) - key: {}", key);
+            return Optional.empty();
+        } catch (Exception e) {
+            log.warn("캐시 조회 실패 - key: {}, error: {}", key, e.getMessage());
+            return Optional.empty();
+        }
+    }
+
+    /**
+     * 캐시 삭제
+     */
+    public void delete(String key) {
+        try {
+            Boolean deleted = redisTemplate.delete(key);
+            if (Boolean.TRUE.equals(deleted)) {
+                log.debug("캐시 삭제 - key: {}", key);
+            }
+        } catch (Exception e) {
+            log.warn("캐시 삭제 실패 - key: {}, error: {}", key, e.getMessage());
+        }
+    }
+
+    /**
+     * 패턴 기반 캐시 삭제
+     */
+    public void deleteByPattern(String pattern) {
+        try {
+            ScanOptions options = ScanOptions.scanOptions()
+                    .match(pattern)
+                    .count(100)
+                    .build();
+
+            Set<String> keys = new HashSet<>();
+            try (Cursor<String> cursor = redisTemplate.scan(options)) {
+                cursor.forEachRemaining(keys::add);
+            }
+
+            if (!keys.isEmpty()) {
+                redisTemplate.delete(keys);
+                log.debug("패턴 캐시 삭제 - pattern: {}, count: {}", pattern, keys.size());
+            }
+        } catch (Exception e) {
+            log.warn("패턴 캐시 삭제 실패 - pattern: {}, error: {}", pattern, e.getMessage());
+        }
+    }
+
+    // ========== 상품 캐시 무효화 ==========
+
+    /**
+     * 특정 상품의 캐시 무효화
+     */
+    public void evictProductCache(Long productId) {
+        try {
+            String productKey = cacheKeyGenerator.generateProductDetailKey(productId);
+            delete(productKey);
+            log.debug("상품 캐시 무효화 - productId: {}", productId);
+        } catch (Exception e) {
+            log.warn("상품 캐시 무효화 실패 - productId: {}", productId, e);
+        }
+    }
+
+    /**
+     * 상품 목록 관련 캐시 무효화
+     */
+    public void evictProductListCaches() {
+        try {
+            // 인기 상품 목록 캐시 삭제
+            delete(cacheKeyGenerator.generatePopularProductsKey());
+
+            // 상품 목록 관련 캐시들 패턴 매칭으로 삭제
+            deleteByPattern(cacheKeyGenerator.generateProductListPattern());
+
+            log.debug("상품 목록 캐시 무효화 완료");
+        } catch (Exception e) {
+            log.warn("상품 목록 캐시 무효화 실패", e);
+        }
+    }
+
+    /**
+     * 판매량 변화 시 캐시 무효화
+     */
+    public void onSalesCountChanged(Long productId) {
+        evictProductCache(productId);
+        evictProductListCaches();
+        log.debug("판매량 변화로 캐시 무효화 - productId: {}", productId);
+    }
+
+    // ========== 브랜드 캐시 무효화 ==========
+
+    /**
+     * 특정 브랜드의 상품 목록 캐시 무효화
+     */
+    public void evictBrandProductListCache(Long brandId) {
+        try {
+            String pattern = cacheKeyGenerator.generateProductListPatternByBrand(brandId);
+            deleteByPattern(pattern);
+            log.debug("브랜드 상품 목록 캐시 무효화 - brandId: {}", brandId);
+        } catch (Exception e) {
+            log.warn("브랜드 상품 목록 캐시 무효화 실패 - brandId: {}", brandId, e);
+        }
+    }
+
+    // ========== Getter ==========
+
+    public CacheKeyGenerator getCacheKeyGenerator() {
+        return cacheKeyGenerator;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
similarity index 64%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
rename to modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
index dd0ceda07..6341ef95a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheKeyGenerator.java
+++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.cache;
+package com.loopers.cache;
 
 import java.util.StringJoiner;
 
@@ -9,10 +9,10 @@
 /**
  * 캐시 키 생성기
  * <p>
- * Hot/Warm/Cold 전략별 캐시 키 생성
- * - product:detail:{productId}
- * - product:ids:{strategy}:{brandId}:{page}:{size}:{sort}
- * - product:page:{brandId}:{productName}:{page}:{size}:{sort}
+ * 일관된 캐시 키 생성을 위한 공통 유틸리티
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
  */
 @Component
 public class CacheKeyGenerator {
@@ -21,10 +21,13 @@ public class CacheKeyGenerator {
     private static final String NULL_VALUE = "null";
 
     // 캐시 키 프리픽스
-    private static final String PRODUCT_PREFIX = "product";
-    private static final String DETAIL_PREFIX = "detail";
-    private static final String IDS_PREFIX = "ids";
-    private static final String PAGE_PREFIX = "page";
+    public static final String PRODUCT_PREFIX = "product";
+    public static final String DETAIL_PREFIX = "detail";
+    public static final String IDS_PREFIX = "ids";
+    public static final String PAGE_PREFIX = "page";
+    public static final String METRICS_PREFIX = "metrics";
+    public static final String POPULAR_PREFIX = "popular";
+    public static final String LIST_PREFIX = "list";
 
     /**
      * 상품 상세 캐시 키: product:detail:{productId}
@@ -39,7 +42,6 @@ public String generateProductDetailKey(Long productId) {
 
     /**
      * 상품 ID 리스트 캐시 키: product:ids:{strategy}:{brandId}:{page}:{size}:{sort}
-     * ID만 캐싱하여 개별 상품 변경 시 전체 캐시 무효화 방지
      */
     public String generateProductIdsKey(CacheStrategy strategy, Long brandId, Pageable pageable) {
         return new StringJoiner(DELIMITER)
@@ -53,7 +55,6 @@ public String generateProductIdsKey(CacheStrategy strategy, Long brandId, Pageab
                 .toString();
     }
 
-
     /**
      * 상품 ID 리스트 패턴: product:ids:{strategy}:*
      */
@@ -66,9 +67,8 @@ public String generateProductIdsPattern(CacheStrategy strategy) {
                 .toString();
     }
 
-
     /**
-     * 특정 브랜드의 모든 목록 패턴 (레거시): product:page:{brandId}:*
+     * 특정 브랜드의 모든 목록 패턴: product:page:{brandId}:*
      */
     public String generateProductListPatternByBrand(Long brandId) {
         return new StringJoiner(DELIMITER)
@@ -79,30 +79,40 @@ public String generateProductListPatternByBrand(Long brandId) {
                 .toString();
     }
 
-
     /**
-     * 상품명을 캐시 키에 안전한 형태로 변환 (공백→언더스코어, 특수문자 제거, 최대 50자)
+     * 인기 상품 캐시 키: product:popular
      */
-    private String sanitizeProductName(String productName) {
-        if (productName == null || productName.trim().isEmpty()) {
-            return NULL_VALUE;
-        }
-
-        // 공백을 언더스코어로 변환하고, 특수문자 제거
-        String sanitized = productName.trim()
-                .replaceAll("\\s+", "_")
-                .replaceAll("[^a-zA-Z0-9가-힣_]", "");
+    public String generatePopularProductsKey() {
+        return new StringJoiner(DELIMITER)
+                .add(PRODUCT_PREFIX)
+                .add(POPULAR_PREFIX)
+                .toString();
+    }
 
-        // 최대 50자로 제한
-        if (sanitized.length() > 50) {
-            sanitized = sanitized.substring(0, 50);
-        }
+    /**
+     * 상품 목록 캐시 패턴: product:list:*
+     */
+    public String generateProductListPattern() {
+        return new StringJoiner(DELIMITER)
+                .add(PRODUCT_PREFIX)
+                .add(LIST_PREFIX)
+                .add("*")
+                .toString();
+    }
 
-        return sanitized.isEmpty() ? NULL_VALUE : sanitized;
+    /**
+     * 상품 메트릭 캐시 키: product:metrics:{productId}
+     */
+    public String generateProductMetricsKey(Long productId) {
+        return new StringJoiner(DELIMITER)
+                .add(PRODUCT_PREFIX)
+                .add(METRICS_PREFIX)
+                .add(String.valueOf(productId))
+                .toString();
     }
 
     /**
-     * Sort 객체를 문자열로 변환 (예: "likeCount_desc,id_asc")
+     * Sort 객체를 문자열로 변환
      */
     private String generateSortString(Sort sort) {
         if (sort.isUnsorted()) {
@@ -117,4 +127,23 @@ private String generateSortString(Sort sort) {
 
         return sortJoiner.toString();
     }
+
+    /**
+     * 상품명을 캐시 키에 안전한 형태로 변환
+     */
+    public String sanitizeProductName(String productName) {
+        if (productName == null || productName.trim().isEmpty()) {
+            return NULL_VALUE;
+        }
+
+        String sanitized = productName.trim()
+                .replaceAll("\\s+", "_")
+                .replaceAll("[^a-zA-Z0-9가-힣_]", "");
+
+        if (sanitized.length() > 50) {
+            sanitized = sanitized.substring(0, 50);
+        }
+
+        return sanitized.isEmpty() ? NULL_VALUE : sanitized;
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java b/modules/redis/src/main/java/com/loopers/cache/CacheStrategy.java
similarity index 63%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java
rename to modules/redis/src/main/java/com/loopers/cache/CacheStrategy.java
index 200450d95..c873eafcb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheStrategy.java
+++ b/modules/redis/src/main/java/com/loopers/cache/CacheStrategy.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.cache;
+package com.loopers.cache;
 
 import java.util.concurrent.TimeUnit;
 
@@ -8,30 +8,19 @@
 /**
  * 캐시 전략: Hot/Warm/Cold 차별화된 TTL과 갱신 방식
  * <p>
- * - Hot: 배치 갱신, TTL 60분 (인기순, 1페이지)
+ * - Hot: 배치 갱신, TTL 30분 (인기순, 1페이지)
  * - Warm: Cache-Aside, TTL 10분 (2~3페이지)
  * - Cold: 캐시 미사용 (4페이지 이상)
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
  */
 @Getter
 @RequiredArgsConstructor
 public enum CacheStrategy {
 
-    /**
-     * Hot: 배치 갱신, TTL 60분
-     * 인기순 정렬, 1페이지 (가장 빈번 조회)
-     */
     HOT(30, TimeUnit.MINUTES, true, CacheUpdateStrategy.BATCH_REFRESH),
-
-    /**
-     * Warm: Cache-Aside, TTL 10분
-     * 2~3페이지 (꾸준한 조회)
-     */
     WARM(10, TimeUnit.MINUTES, false, CacheUpdateStrategy.CACHE_ASIDE),
-
-    /**
-     * Cold: 캐시 미사용
-     * 4페이지 이상 (거의 조회 안 됨)
-     */
     COLD(0, TimeUnit.MINUTES, false, CacheUpdateStrategy.NO_CACHE);
 
     private final long ttl;
@@ -39,16 +28,10 @@ public enum CacheStrategy {
     private final boolean useBatchUpdate;
     private final CacheUpdateStrategy updateStrategy;
 
-    /**
-     * 캐시 사용 여부
-     */
     public boolean shouldCache() {
         return this != COLD;
     }
 
-    /**
-     * 배치 갱신 사용 여부
-     */
     public boolean shouldUseBatchUpdate() {
         return useBatchUpdate;
     }
diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheUpdateStrategy.java b/modules/redis/src/main/java/com/loopers/cache/CacheUpdateStrategy.java
new file mode 100644
index 000000000..c72a18bcf
--- /dev/null
+++ b/modules/redis/src/main/java/com/loopers/cache/CacheUpdateStrategy.java
@@ -0,0 +1,24 @@
+package com.loopers.cache;
+
+/**
+ * 캐시 갱신 전략
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+public enum CacheUpdateStrategy {
+    /**
+     * 배치 갱신: 주기적으로 캐시를 갱신
+     */
+    BATCH_REFRESH,
+
+    /**
+     * Cache-Aside: 캐시 미스 시 DB 조회 후 캐시 저장
+     */
+    CACHE_ASIDE,
+
+    /**
+     * 캐시 미사용
+     */
+    NO_CACHE
+}

From 8cb4128d49006395d1f4d0d8ce7ec9a06148f5ec Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 14:50:07 +0900
Subject: [PATCH 16/28] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?=
 =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EA=B4=80=EB=A6=AC=20=EC=84=9C=EB=B9=84?=
 =?UTF-8?q?=EC=8A=A4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- RedisTemplate을 BaseCacheService로 대체하여 코드 간결화
- 캐시 무효화 메서드를 BaseCacheService에 위임
- Streamer용으로 주석 수정 및 코드 정리
---
 .../domain/product/ProductCacheService.java   |  4 +-
 .../ProductCacheStrategyIntegrationTest.java  |  2 +-
 .../cache/ProductCacheService.java            | 69 +++++--------------
 3 files changed, 19 insertions(+), 56 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
index a388f91ef..2d2913d57 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
@@ -15,9 +15,9 @@
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.loopers.application.product.ProductDetailInfo;
+import com.loopers.cache.CacheKeyGenerator;
+import com.loopers.cache.CacheStrategy;
 import com.loopers.domain.product.dto.ProductSearchFilter;
-import com.loopers.infrastructure.cache.CacheKeyGenerator;
-import com.loopers.infrastructure.cache.CacheStrategy;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
index d95a74823..3ba88431c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
@@ -14,13 +14,13 @@
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
 
+import com.loopers.cache.CacheStrategy;
 import com.loopers.domain.brand.BrandRepository;
 import com.loopers.domain.product.ProductCacheService;
 import com.loopers.domain.product.ProductMVService;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.product.dto.ProductSearchFilter;
 import com.loopers.fixtures.ProductTestFixture;
-import com.loopers.infrastructure.cache.CacheStrategy;
 import com.loopers.utils.DatabaseCleanUp;
 import com.loopers.utils.RedisCleanUp;
 
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
index c776212da..74b499845 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
@@ -1,14 +1,17 @@
 package com.loopers.infrastructure.cache;
 
-import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Component;
 
+import com.loopers.cache.BaseCacheService;
+
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 
 /**
- * 상품 캐시 관리 서비스
- * 메트릭 변화 시 관련 캐시를 무효화
+ * 상품 캐시 관리 서비스 (Streamer용)
+ * <p>
+ * 메트릭 변화 시 관련 캐시를 무효화합니다.
+ * 공통 모듈의 BaseCacheService를 위임하여 사용합니다.
  *
  * @author hyunjikoh
  * @since 2025. 12. 18.
@@ -18,75 +21,35 @@
 @Slf4j
 public class ProductCacheService {
     
-    private final RedisTemplate<String, String> redisTemplate;
-    
-    private static final String PRODUCT_CACHE_PREFIX = "product:";
-    private static final String PRODUCT_LIST_CACHE_PREFIX = "product-list:";
-    private static final String POPULAR_PRODUCTS_KEY = "popular-products";
+    private final BaseCacheService baseCacheService;
     
     /**
      * 특정 상품의 캐시를 무효화
      */
     public void evictProductCache(Long productId) {
-        try {
-            String productKey = PRODUCT_CACHE_PREFIX + productId;
-            Boolean deleted = redisTemplate.delete(productKey);
-            
-            if (deleted) {
-                log.debug("Evicted product cache for productId: {}", productId);
-            }
-        } catch (Exception e) {
-            log.warn("Failed to evict product cache for productId: {}", productId, e);
-        }
+        baseCacheService.evictProductCache(productId);
     }
     
     /**
      * 상품 목록 관련 캐시들을 무효화
-     * 인기 상품, 추천 상품 등의 목록이 변경될 수 있음
      */
     public void evictProductListCaches() {
-        try {
-            // 인기 상품 목록 캐시 삭제
-            redisTemplate.delete(POPULAR_PRODUCTS_KEY);
-            
-            // 상품 목록 관련 캐시들 패턴 매칭으로 삭제
-            var keys = redisTemplate.keys(PRODUCT_LIST_CACHE_PREFIX + "*");
-            if (keys != null && !keys.isEmpty()) {
-                redisTemplate.delete(keys);
-                log.debug("Evicted {} product list cache entries", keys.size());
-            }
-        } catch (Exception e) {
-            log.warn("Failed to evict product list caches", e);
-        }
+        baseCacheService.evictProductListCaches();
     }
     
     /**
      * 판매량 변화 시 호출 - 인기 상품 순위가 변경될 수 있음
      */
     public void onSalesCountChanged(Long productId) {
-        evictProductCache(productId);
-        evictProductListCaches();
-        log.debug("Evicted caches due to sales count change for productId: {}", productId);
-    }
-    
-    /**
-     * 좋아요 수 변화 시 호출 - 상품 상세 정보 갱신 필요
-     */
-    public void onLikeCountChanged(Long productId) {
-        evictProductCache(productId);
-        log.debug("Evicted product cache due to like count change for productId: {}", productId);
+        baseCacheService.onSalesCountChanged(productId);
     }
-    
+
+
+
     /**
-     * 조회수 변화 시 호출 - 일반적으로 캐시 무효화하지 않음 (성능상 이유)
-     * 하지만 특정 임계값을 넘으면 인기 상품 목록 갱신
+     * 브랜드별 상품 목록 캐시 무효화
      */
-    public void onViewCountChanged(Long productId, long newViewCount) {
-        // 조회수가 특정 임계값(예: 1000의 배수)을 넘으면 인기 상품 목록 갱신
-        if (newViewCount > 0 && newViewCount % 1000 == 0) {
-            evictProductListCaches();
-            log.debug("Evicted product list caches due to view milestone for productId: {} (views: {})", 
-                     productId, newViewCount);
-        }
+    public void evictBrandProductListCache(Long brandId) {
+        baseCacheService.evictBrandProductListCache(brandId);
     }
 }

From dcc6855cc3296ef5930e855e90fac684fdc94c80 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 14:50:39 +0900
Subject: [PATCH 17/28] =?UTF-8?q?feat(order):=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?=
 =?UTF-8?q?=EC=83=81=ED=92=88=EB=B3=84=EB=A1=9C=20=EA=B0=9C=EB=B3=84=20?=
 =?UTF-8?q?=EC=A0=80=EC=9E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Outbox에 결제 완료 이벤트를 원자적으로 저장하도록 변경
- 상품별로 개별 이벤트 생성 및 일괄 저장 로직 추가
- 주문 상품이 없을 경우 저장 스킵 처리 및 로그 추가
---
 .../application/order/OrderEventHandler.java  |  92 ++++++++---
 .../application/product/ProductFacade.java    |   2 +-
 .../domain/event/outbox/OutboxRepository.java |   5 +
 .../domain/product/ProductMVService.java      |   2 +-
 .../outbox/OutboxEventRepositoryImpl.java     |   5 +
 .../payloads/PaymentSuccessPayloadV1.java     |  15 +-
 .../product/ProductMVBatchScheduler.java      |   2 +-
 .../domain/metrics/ProductMetricsEntity.java  | 103 ++++++++++--
 .../domain/metrics/ProductMetricsService.java | 151 ++++++++++++++++++
 .../payloads/PaymentSuccessPayloadV1.java     |  16 +-
 10 files changed, 348 insertions(+), 45 deletions(-)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
index 9bb4b3172..0d91087cb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
@@ -1,5 +1,6 @@
 package com.loopers.application.order;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import org.springframework.scheduling.annotation.Async;
@@ -91,6 +92,7 @@ public void handlePaymentTimeout(PaymentTimeoutEvent event) {
 
     /**
      * 결제 완료 이벤트를 Outbox에 저장
+     * 원자성과 멱등성을 보장하기 위해 상품별로 개별 이벤트를 생성하여 일괄 저장
      */
     private void savePaymentSuccessToOutbox(PaymentCompletedEvent event) {
         final Long orderNumber = event.orderNumber();
@@ -104,27 +106,79 @@ private void savePaymentSuccessToOutbox(PaymentCompletedEvent event) {
         final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId);
         final List<OrderItemEntity> orderItems = orderService.getOrderItemsByOrderId(order);
 
-        final List<PaymentSuccessPayloadV1.Item> items = orderItems.stream()
-                .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity()))
-                .toList();
-
-        final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(order.getId(), items);
-        final var envelope = envelopeFactory.create(EventTypes.PAYMENT_SUCCESS, EventVersions.V1, payload);
+        if (orderItems.isEmpty()) {
+            log.warn("Outbox 저장 스킵 - 주문 상품이 없음 orderId={}", order.getId());
+            return;
+        }
 
-        // Outbox에 저장 (READY 상태)
-        OutboxEventEntity outboxEvent = OutboxEventEntity.ready(
-                envelope.eventId(),
-                ORDER_EVENTS_TOPIC,
-                String.valueOf(order.getId()), // partition key = orderId(PK)
-                envelope.eventType(),
-                envelope.version(),
-                envelope.occurredAtEpochMillis(),
-                envelope.payloadJson()
-        );
+        // 상품별로 개별 이벤트 생성 및 일괄 저장 (원자성 보장)
+        try {
+            savePaymentSuccessEventsAtomically(order, orderItems);
+            log.info("결제 완료 이벤트 Outbox 일괄 저장 완료 - orderId={}, itemCount={}", 
+                    order.getId(), orderItems.size());
+        } catch (Exception e) {
+            log.error("결제 완료 이벤트 Outbox 저장 실패 - orderId={}, itemCount={}", 
+                    order.getId(), orderItems.size(), e);
+            throw e; // 상위로 전파하여 전체 트랜잭션 롤백
+        }
+    }
 
-        outboxRepository.save(outboxEvent);
+    /**
+     * 상품별 결제 완료 이벤트를 원자적으로 Outbox에 저장
+     * 모든 상품 이벤트가 성공하거나 모두 실패하도록 보장
+     * 
+     * 새로운 구조: 각 상품별로 주문 컨텍스트 정보를 포함한 개별 이벤트 생성
+     */
+    private void savePaymentSuccessEventsAtomically(OrderEntity order, List<OrderItemEntity> orderItems) {
+        final List<OutboxEventEntity> outboxEvents = new ArrayList<>();
+        final long baseTimestamp = System.currentTimeMillis();
+
+        // 각 상품별로 개별 이벤트 생성
+        for (int i = 0; i < orderItems.size(); i++) {
+            final OrderItemEntity orderItem = orderItems.get(i);
+            
+            // 상품별 개별 페이로드 생성 (주문 컨텍스트 정보 포함)
+            final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
+                    order.getId(),              // 주문 내부 ID (PK)
+                    order.getOrderNumber(),     // 주문 번호 (비즈니스 키)
+                    order.getUserId(),          // 주문한 사용자 ID
+                    orderItem.getProductId(),   // 상품 ID (개별 이벤트)
+                    orderItem.getQuantity(),    // 구매 수량 (개별 이벤트)
+                    orderItem.getUnitPrice(),   // 단가
+                    orderItem.getTotalPrice()   // 해당 상품의 총 금액
+            );
+
+            // 각 상품별로 고유한 이벤트 ID와 타임스탬프 생성 (순서 보장)
+            final var envelope = envelopeFactory.create(
+                    EventTypes.PAYMENT_SUCCESS, 
+                    EventVersions.V1, 
+                    payload
+            );
+
+            // 복합 파티션 키 사용: "orderId-productId" (동일 주문의 동일 상품은 동일 파티션)
+            final String partitionKey = String.format("%d-%d", order.getId(), orderItem.getProductId());
+
+            final OutboxEventEntity outboxEvent = OutboxEventEntity.ready(
+                    envelope.eventId(),
+                    ORDER_EVENTS_TOPIC,
+                    partitionKey, // 주문ID-상품ID 복합 키로 파티션 분산
+                    envelope.eventType(),
+                    envelope.version(),
+                    baseTimestamp + i, // 순서 보장을 위한 타임스탬프 조정
+                    envelope.payloadJson()
+            );
+
+            outboxEvents.add(outboxEvent);
+            
+            log.debug("상품별 결제 완료 이벤트 생성 - orderId={}, orderNumber={}, productId={}, quantity={}, unitPrice={}, totalPrice={}, eventId={}", 
+                    order.getId(), order.getOrderNumber(), orderItem.getProductId(), 
+                    orderItem.getQuantity(), orderItem.getUnitPrice(), orderItem.getTotalPrice(), envelope.eventId());
+        }
 
-        log.info("결제 완료 이벤트 Outbox 저장 완료 - type={}, orderId={}, itemCount={}",
-                EventTypes.PAYMENT_SUCCESS, order.getId(), items.size());
+        // 모든 이벤트를 일괄 저장 (원자성 보장)
+        outboxRepository.saveAll(outboxEvents);
+        
+        log.info("상품별 결제 완료 이벤트 일괄 저장 완료 - orderId={}, orderNumber={}, eventCount={}", 
+                order.getId(), order.getOrderNumber(), outboxEvents.size());
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
index 609ee0d85..50c3ad916 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -7,6 +7,7 @@
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
+import com.loopers.cache.CacheStrategy;
 import com.loopers.domain.brand.BrandEntity;
 import com.loopers.domain.brand.BrandService;
 import com.loopers.domain.like.LikeService;
@@ -14,7 +15,6 @@
 import com.loopers.domain.product.dto.ProductSearchFilter;
 import com.loopers.domain.tracking.UserBehaviorTracker;
 import com.loopers.domain.user.UserService;
-import com.loopers.infrastructure.cache.CacheStrategy;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
index d7bbd55c0..5c7f2547b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -35,4 +35,9 @@ List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc
             OutboxStatus status, int maxRetryCount, int limit);
 
     OutboxEventEntity save(OutboxEventEntity ready);
+    
+    /**
+     * 여러 이벤트를 일괄 저장 (원자성 보장)
+     */
+    List<OutboxEventEntity> saveAll(List<OutboxEventEntity> events);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java
index eb498a116..b669db185 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java
@@ -12,8 +12,8 @@
 import org.springframework.transaction.annotation.Transactional;
 
 import com.loopers.application.product.BatchUpdateResult;
+import com.loopers.cache.CacheStrategy;
 import com.loopers.domain.product.dto.ProductSearchFilter;
-import com.loopers.infrastructure.cache.CacheStrategy;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
index 573254838..6b87384c5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventRepositoryImpl.java
@@ -49,4 +49,9 @@ public List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreat
     public OutboxEventEntity save(OutboxEventEntity ready) {
         return outboxEventJpaRepository.save(ready);
     }
+
+    @Override
+    public List<OutboxEventEntity> saveAll(List<OutboxEventEntity> events) {
+        return outboxEventJpaRepository.saveAll(events);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
index 531d8881c..216c12548 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
@@ -1,16 +1,21 @@
 package com.loopers.infrastructure.event.payloads;
 
-import java.util.List;
+import java.math.BigDecimal;
 
 /**
+ * 결제 완료 이벤트 페이로드 V1
+ * 상품별 개별 이벤트로 발행되며 주문 컨텍스트 정보를 포함합니다.
  *
  * @author hyunjikoh
  * @since 2025. 12. 17.
  */
 public record PaymentSuccessPayloadV1(
-        Long orderNumber,
-        List<Item> items
+        Long orderId,           // 주문 내부 ID (PK)
+        Long orderNumber,       // 주문 번호 (비즈니스 키)
+        Long userId,            // 주문한 사용자 ID
+        Long productId,         // 상품 ID (개별 이벤트)
+        Integer quantity,       // 구매 수량 (개별 이벤트)
+        BigDecimal unitPrice,   // 단가
+        BigDecimal totalPrice   // 해당 상품의 총 금액
 ) {
-    public record Item(Long productId, Integer quantity) {
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
index 0473c2d7b..ee7c0813f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
@@ -11,13 +11,13 @@
 import org.springframework.stereotype.Component;
 
 import com.loopers.application.product.BatchUpdateResult;
+import com.loopers.cache.CacheStrategy;
 import com.loopers.domain.brand.BrandEntity;
 import com.loopers.domain.brand.BrandService;
 import com.loopers.domain.product.ProductCacheService;
 import com.loopers.domain.product.ProductMVService;
 import com.loopers.domain.product.ProductMaterializedViewEntity;
 import com.loopers.domain.product.dto.ProductSearchFilter;
-import com.loopers.infrastructure.cache.CacheStrategy;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
index 03b967e32..cd74558a6 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -1,6 +1,7 @@
 package com.loopers.domain.metrics;
 
 import java.time.ZonedDateTime;
+import java.util.Objects;
 
 import lombok.AllArgsConstructor;
 import lombok.Getter;
@@ -36,11 +37,24 @@ public class ProductMetricsEntity {
     @Column(name = "sales_count", nullable = false)
     private long salesCount = 0L;
 
+    @Column(name = "order_count", nullable = false)
+    private long orderCount = 0L;
+
+    @Column(name = "cart_add_count", nullable = false)
+    private long cartAddCount = 0L;
+
+    @Column(name = "wishlist_add_count", nullable = false)
+    private long wishlistAddCount = 0L;
+
+    @Column(name = "review_count", nullable = false)
+    private long reviewCount = 0L;
+
     @Column(name = "last_event_at")
     private ZonedDateTime lastEventAt;
 
 
     private ProductMetricsEntity(final Long productId) {
+        Objects.requireNonNull(productId);
         this.id = productId;
     }
 
@@ -48,10 +62,6 @@ public static ProductMetricsEntity create(final Long productId) {
         return new ProductMetricsEntity(productId);
     }
 
-    public void incrementView() {
-        this.viewCount += 1;
-        this.lastEventAt = ZonedDateTime.now();
-    }
     
     public void incrementView(ZonedDateTime eventTime) {
         this.viewCount += 1;
@@ -76,20 +86,89 @@ public void applyLikeDelta(final int delta, ZonedDateTime eventTime) {
         this.lastEventAt = eventTime;
     }
 
-    public void addSales(final int quantity) {
-        if (quantity <= 0) {
-            return;
-        }
-        this.salesCount += quantity;
-        this.lastEventAt = ZonedDateTime.now();
-    }
-    
+
     public void addSales(final int quantity, ZonedDateTime eventTime) {
         if (quantity <= 0) {
             return;
         }
         this.salesCount += quantity;
+        this.orderCount += 1; // 주문 건수도 함께 증가
+        this.lastEventAt = eventTime;
+    }
+
+    /**
+     * 장바구니 추가 이벤트 처리
+     */
+    public void incrementCartAdd(ZonedDateTime eventTime) {
+        this.cartAddCount += 1;
         this.lastEventAt = eventTime;
     }
 
+    /**
+     * 위시리스트 추가 이벤트 처리
+     */
+    public void incrementWishlistAdd(ZonedDateTime eventTime) {
+        this.wishlistAddCount += 1;
+        this.lastEventAt = eventTime;
+    }
+
+    /**
+     * 리뷰 작성 이벤트 처리
+     */
+    public void incrementReview(ZonedDateTime eventTime) {
+        this.reviewCount += 1;
+        this.lastEventAt = eventTime;
+    }
+
+    // ========== 비즈니스 분석 메서드 ==========
+
+    /**
+     * 인기도 점수 계산 (가중치 기반)
+     * - 조회수: 1점
+     * - 좋아요: 5점
+     * - 장바구니 추가: 10점
+     * - 위시리스트 추가: 8점
+     * - 주문: 20점
+     * - 리뷰: 15점
+     */
+    public double calculatePopularityScore() {
+        return (viewCount * 1.0) +
+               (likeCount * 5.0) +
+               (cartAddCount * 10.0) +
+               (wishlistAddCount * 8.0) +
+               (orderCount * 20.0) +
+               (reviewCount * 15.0);
+    }
+
+    /**
+     * 전환율 계산 (주문 수 / 조회 수)
+     */
+    public double calculateConversionRate() {
+        if (viewCount == 0) {
+            return 0.0;
+        }
+        return (double) orderCount / viewCount * 100.0; // 백분율
+    }
+
+    /**
+     * 평균 주문 수량 계산
+     */
+    public double calculateAverageOrderQuantity() {
+        if (orderCount == 0) {
+            return 0.0;
+        }
+        return (double) salesCount / orderCount;
+    }
+
+    /**
+     * 참여도 점수 계산 (조회 대비 액션 비율)
+     */
+    public double calculateEngagementRate() {
+        if (viewCount == 0) {
+            return 0.0;
+        }
+        long totalEngagements = likeCount + cartAddCount + wishlistAddCount + orderCount;
+        return (double) totalEngagements / viewCount * 100.0; // 백분율
+    }
+
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
new file mode 100644
index 000000000..9aaa086ce
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
@@ -0,0 +1,151 @@
+package com.loopers.domain.metrics;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.loopers.domain.event.EventRepository;
+import com.loopers.domain.metrics.repository.MetricsRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 메트릭 트랜잭션 처리 서비스
+ * <p>
+ * Spring AOP Self-Invocation 문제를 해결하기 위해 분리된 트랜잭션 서비스입니다.
+ * MetricsService에서 @Transactional 메서드를 호출할 때 발생하는 문제를 방지합니다.
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ProductMetricsService {
+
+    private final MetricsRepository metricsRepository;
+    private final EventRepository eventHandledRepository;
+
+    /**
+     * 조회수 증가 (트랜잭션 적용)
+     */
+    @Transactional
+    public void incrementViewWithTransaction(Long productId, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.incrementView(productId, occurredAtEpochMillis);
+            log.debug("조회수 증가 완료: productId={}", productId);
+        } catch (Exception e) {
+            log.error("조회수 증가 실패: productId={}", productId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 좋아요 수 변경 (트랜잭션 적용)
+     */
+    @Transactional
+    public void applyLikeDeltaWithTransaction(Long productId, int delta, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.applyLikeDelta(productId, delta, occurredAtEpochMillis);
+            log.debug("좋아요 수 변경 완료: productId={}, delta={}", productId, delta);
+        } catch (Exception e) {
+            log.error("좋아요 수 변경 실패: productId={}, delta={}", productId, delta, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 판매량 증가 (트랜잭션 적용)
+     */
+    @Transactional
+    public void addSalesWithTransaction(Long productId, int quantity, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.addSales(productId, quantity, occurredAtEpochMillis);
+            log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity);
+        } catch (Exception e) {
+            log.error("판매량 증가 실패: productId={}, quantity={}", productId, quantity, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 장바구니 추가 (트랜잭션 적용)
+     */
+    @Transactional
+    public void incrementCartAddWithTransaction(Long productId, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.incrementCartAdd(productId, occurredAtEpochMillis);
+            log.debug("장바구니 추가 완료: productId={}", productId);
+        } catch (Exception e) {
+            log.error("장바구니 추가 실패: productId={}", productId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 위시리스트 추가 (트랜잭션 적용)
+     */
+    @Transactional
+    public void incrementWishlistAddWithTransaction(Long productId, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.incrementWishlistAdd(productId, occurredAtEpochMillis);
+            log.debug("위시리스트 추가 완료: productId={}", productId);
+        } catch (Exception e) {
+            log.error("위시리스트 추가 실패: productId={}", productId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 리뷰 작성 (트랜잭션 적용)
+     */
+    @Transactional
+    public void incrementReviewWithTransaction(Long productId, long occurredAtEpochMillis) {
+        try {
+            metricsRepository.incrementReview(productId, occurredAtEpochMillis);
+            log.debug("리뷰 작성 완료: productId={}", productId);
+        } catch (Exception e) {
+            log.error("리뷰 작성 실패: productId={}", productId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 재고 소진 이벤트 처리 (트랜잭션 적용)
+     * 주로 캐시 무효화를 담당합니다.
+     */
+    @Transactional
+    public void handleStockDepletedWithTransaction(Long productId, Long brandId, long occurredAtEpochMillis) {
+        try {
+            // 재고 소진 시 캐시 무효화 처리
+            metricsRepository.handleStockDepleted(productId, brandId, occurredAtEpochMillis);
+            log.debug("재고 소진 처리 완료: productId={}, brandId={}", productId, brandId);
+        } catch (Exception e) {
+            log.error("재고 소진 처리 실패: productId={}, brandId={}", productId, brandId, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 이벤트 처리 완료 마킹 (트랜잭션 적용)
+     * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행합니다.
+     */
+    @Transactional
+    public boolean saveEventHandled(String eventId) {
+        try {
+            // 트랜잭션 내에서 다시 한번 확인 (동시성 안전)
+            if (eventHandledRepository.existsById(eventId)) {
+                log.debug("트랜잭션 내 중복 확인: {}", eventId);
+                return false;
+            }
+            
+            eventHandledRepository.save(com.loopers.domain.event.EventEntity.create(eventId));
+            log.debug("이벤트 처리 완료 저장: {}", eventId);
+            return true;
+        } catch (Exception e) {
+            // 동시성으로 인한 중복 저장 시도 (Unique 제약 조건 위반 등)
+            log.debug("동시성으로 인한 이벤트 저장 실패: {}", eventId, e);
+            return false;
+        }
+    }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
index 0142c0a26..2e4b130f8 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/PaymentSuccessPayloadV1.java
@@ -1,17 +1,21 @@
 package com.loopers.infrastructure.event.payloads;
 
-import java.util.List;
+import java.math.BigDecimal;
 
 /**
- * 결제 성공 이벤트 페이로드 V1
+ * 결제 완료 이벤트 페이로드 V1
+ * 상품별 개별 이벤트로 발행되며 주문 컨텍스트 정보를 포함합니다.
  *
  * @author hyunjikoh
  * @since 2025. 12. 18.
  */
 public record PaymentSuccessPayloadV1(
-        Long orderNumber,
-        List<Item> items
+        Long orderId,           // 주문 내부 ID (PK)
+        Long orderNumber,       // 주문 번호 (비즈니스 키)
+        Long userId,            // 주문한 사용자 ID
+        Long productId,         // 상품 ID (개별 이벤트)
+        Integer quantity,       // 구매 수량 (개별 이벤트)
+        BigDecimal unitPrice,   // 단가
+        BigDecimal totalPrice   // 해당 상품의 총 금액
 ) {
-    public record Item(Long productId, Integer quantity) {
-    }
 }
\ No newline at end of file

From 15155afd082cf0ff28dfa831f1deca7e5a50a30d Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 14:50:42 +0900
Subject: [PATCH 18/28] =?UTF-8?q?feat(order):=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?=
 =?UTF-8?q?=EC=83=81=ED=92=88=EB=B3=84=EB=A1=9C=20=EA=B0=9C=EB=B3=84=20?=
 =?UTF-8?q?=EC=A0=80=EC=9E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Outbox에 결제 완료 이벤트를 원자적으로 저장하도록 변경
- 상품별로 개별 이벤트 생성 및 일괄 저장 로직 추가
- 주문 상품이 없을 경우 저장 스킵 처리 및 로그 추가
---
 .../metrics/MetricsEventHandler.java          |   9 --
 .../domain/metrics/MetricsService.java        |  84 ++++++++++-
 .../metrics/repository/MetricsRepository.java |  32 ++++
 .../infrastructure/config/JsonConfig.java     |  34 -----
 .../event/EventDeserializer.java              |  10 ++
 .../metrics/MetricsRepositoryImpl.java        | 142 ++++++++++++++++++
 .../consumer/MetricsKafkaConsumer.java        |  62 +++++++-
 .../com/loopers/confg/kafka/KafkaConfig.java  |  43 ------
 8 files changed, 321 insertions(+), 95 deletions(-)
 delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
 delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
deleted file mode 100644
index 473dcd411..000000000
--- a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsEventHandler.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.loopers.application.metrics;
-
-/**
- *
- * @author hyunjikoh
- * @since 2025. 12. 16.
- */
-public class MetricsEventHandler {
-}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 30aa46dfd..5e46d19ae 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -1,5 +1,6 @@
 package com.loopers.domain.metrics;
 
+import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReentrantLock;
@@ -25,7 +26,7 @@
 @Slf4j
 public class MetricsService {
     private final EventRepository eventHandledRepository;
-    private final MetricsTransactionService metricsTransactionService;
+    private final ProductMetricsService metricsTransactionService;
 
     // 상품별 메모리 락 관리
     private final ConcurrentHashMap<Long, ReentrantLock> productLocks = new ConcurrentHashMap<>();
@@ -148,6 +149,85 @@ public void addSales(final Long productId, final int quantity, long occurredAtEp
 
 
 
+    /**
+     * 장바구니 추가 이벤트 처리 (메모리 락 적용)
+     */
+    public void incrementCartAdd(Long productId, long occurredAtEpochMillis) {
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.incrementCartAddWithTransaction(productId, occurredAtEpochMillis);
+                    log.debug("장바구니 추가 업데이트 성공: productId={}", productId);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("장바구니 추가 업데이트 스킵 - 락 획득 실패: productId={}", productId);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("장바구니 추가 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
+        }
+    }
+
+    /**
+     * 위시리스트 추가 이벤트 처리 (메모리 락 적용)
+     */
+    public void incrementWishlistAdd(Long productId, long occurredAtEpochMillis) {
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.incrementWishlistAddWithTransaction(productId, occurredAtEpochMillis);
+                    log.debug("위시리스트 추가 업데이트 성공: productId={}", productId);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("위시리스트 추가 업데이트 스킵 - 락 획득 실패: productId={}", productId);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("위시리스트 추가 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
+        }
+    }
+
+    /**
+     * 리뷰 작성 이벤트 처리 (메모리 락 적용)
+     */
+    public void incrementReview(Long productId, long occurredAtEpochMillis) {
+        ReentrantLock lock = getProductLock(productId);
+
+        try {
+            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+                try {
+                    metricsTransactionService.incrementReviewWithTransaction(productId, occurredAtEpochMillis);
+                    log.debug("리뷰 작성 업데이트 성공: productId={}", productId);
+                } finally {
+                    lock.unlock();
+                }
+            } else {
+                log.warn("리뷰 작성 업데이트 스킵 - 락 획득 실패: productId={}", productId);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("리뷰 작성 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
+        }
+    }
+
+    /**
+     * 재고 소진 이벤트 처리 (캐시 무효화 중심)
+     */
+    public void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis) {
+        // 재고 소진은 메트릭 업데이트보다는 캐시 무효화가 주 목적
+        // 락 없이 바로 트랜잭션 서비스 호출
+        metricsTransactionService.handleStockDepletedWithTransaction(productId, brandId, occurredAtEpochMillis);
+        log.info("재고 소진 이벤트 처리 완료: productId={}, brandId={}", productId, brandId);
+    }
+
     /**
      * 상품별 락 획득 (없으면 생성)
      */
@@ -186,7 +266,7 @@ public void cleanupProcessedEvents() {
             
             processedEvents.entrySet().stream()
                     .limit(toRemove)
-                    .map(entry -> entry.getKey())
+                    .map(Map.Entry::getKey)
                     .forEach(processedEvents::remove);
             
             log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEvents.size());
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
new file mode 100644
index 000000000..dc84d2d87
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
@@ -0,0 +1,32 @@
+package com.loopers.domain.metrics.repository;
+
+/**
+ * 메트릭 업데이트를 위한 Repository 인터페이스
+ * <p>
+ * 동시성 안전한 메트릭 업데이트 작업을 담당합니다.
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+public interface MetricsRepository {
+    
+    /**
+     * 조회수 증가
+     */
+    void incrementView(Long productId, long occurredAtEpochMillis);
+    
+    /**
+     * 좋아요 수 변경 (증가/감소)
+     */
+    void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis);
+    
+    /**
+     * 판매량 증가
+     */
+    void addSales(Long productId, int quantity, long occurredAtEpochMillis);
+    
+    /**
+     * 재고 소진 이벤트 처리 (캐시 무효화 중심)
+     */
+    void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis);
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
deleted file mode 100644
index 83194093d..000000000
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/config/JsonConfig.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.loopers.infrastructure.config;
-
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.PropertyNamingStrategies;
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
-
-/**
- * JSON 직렬화/역직렬화 설정
- *
- * @author hyunjikoh
- * @since 2025. 12. 18.
- */
-@Configuration
-public class JsonConfig {
-    
-    @Bean
-    public ObjectMapper objectMapper() {
-        ObjectMapper mapper = new ObjectMapper();
-        
-        // Java 8 시간 API 지원
-        mapper.registerModule(new JavaTimeModule());
-        
-        // 카멜케이스 사용
-        mapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
-        
-        // 알 수 없는 속성 무시 (하위 호환성)
-        mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
-        
-        return mapper;
-    }
-}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
index b6dd3cab9..2c392e5f5 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
@@ -5,6 +5,7 @@
 import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
 import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
@@ -62,4 +63,13 @@ public PaymentSuccessPayloadV1 deserializePaymentSuccess(String payloadJson) {
             return null;
         }
     }
+    
+    public StockDepletedPayloadV1 deserializeStockDepleted(String payloadJson) {
+        try {
+            return objectMapper.readValue(payloadJson, StockDepletedPayloadV1.class);
+        } catch (JsonProcessingException e) {
+            log.error("Failed to deserialize StockDepletedPayloadV1: {}", payloadJson, e);
+            return null;
+        }
+    }
 }
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
new file mode 100644
index 000000000..adb5c9097
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
@@ -0,0 +1,142 @@
+package com.loopers.infrastructure.metrics;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Optional;
+
+import org.springframework.stereotype.Repository;
+
+import com.loopers.domain.metrics.ProductMetricsEntity;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.repository.MetricsRepository;
+import com.loopers.infrastructure.cache.ProductCacheService;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 메트릭 Repository 구현체
+ * <p>
+ * ProductMetricsRepository를 사용하여 실제 메트릭 업데이트를 수행합니다.
+ * 존재하지 않는 상품의 경우 새로 생성하여 처리합니다.
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+@Repository
+@RequiredArgsConstructor
+@Slf4j
+public class MetricsRepositoryImpl implements MetricsRepository {
+
+    private final ProductMetricsRepository productMetricsRepository;
+    private final ProductCacheService productCacheService;
+
+    @Override
+    public void incrementView(Long productId, long occurredAtEpochMillis) {
+        ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
+        
+        Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
+        
+        long newViewCount;
+        if (existingMetrics.isPresent()) {
+            ProductMetricsEntity metrics = existingMetrics.get();
+            metrics.incrementView(eventTime);
+            productMetricsRepository.save(metrics);
+            newViewCount = metrics.getViewCount();
+        } else {
+            // 새로운 상품 메트릭 생성
+            ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId);
+            newMetrics.incrementView(eventTime);
+            productMetricsRepository.save(newMetrics);
+            newViewCount = newMetrics.getViewCount();
+        }
+        
+
+        log.debug("조회수 증가 완료: productId={}, eventTime={}", productId, eventTime);
+    }
+
+    @Override
+    public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) {
+        ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
+        
+        Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
+        
+        if (existingMetrics.isPresent()) {
+            ProductMetricsEntity metrics = existingMetrics.get();
+            metrics.applyLikeDelta(delta, eventTime);
+            productMetricsRepository.save(metrics);
+        } else {
+            // 새로운 상품 메트릭 생성 (좋아요가 음수가 되지 않도록 처리)
+            if (delta > 0) {
+                ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId);
+                newMetrics.applyLikeDelta(delta, eventTime);
+                productMetricsRepository.save(newMetrics);
+            } else {
+                log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta);
+                return; // 캐시 무효화 불필요
+            }
+        }
+        
+        // 좋아요는 캐시 무효화하지 않음 (실시간 반영 불필요)
+        
+        log.debug("좋아요 수 변경 완료: productId={}, delta={}, eventTime={}", productId, delta, eventTime);
+    }
+
+    @Override
+    public void addSales(Long productId, int quantity, long occurredAtEpochMillis) {
+        if (quantity <= 0) {
+            log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity);
+            return;
+        }
+        
+        ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
+        
+        Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
+        
+        if (existingMetrics.isPresent()) {
+            ProductMetricsEntity metrics = existingMetrics.get();
+            metrics.addSales(quantity, eventTime);
+            productMetricsRepository.save(metrics);
+        } else {
+            // 새로운 상품 메트릭 생성
+            ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId);
+            newMetrics.addSales(quantity, eventTime);
+            productMetricsRepository.save(newMetrics);
+        }
+        
+        // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향)
+        productCacheService.onSalesCountChanged(productId);
+        
+        log.debug("판매량 증가 완료: productId={}, quantity={}, eventTime={}", productId, quantity, eventTime);
+    }
+
+    @Override
+    public void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis) {
+        // 재고 소진 이벤트는 주로 캐시 무효화가 목적
+        // 메트릭 자체는 업데이트하지 않고 캐시만 무효화
+        
+        // 상품별 캐시 무효화
+        productCacheService.evictProductCache(productId);
+        
+        // 브랜드별 상품 목록 캐시 무효화 (브랜드 ID가 있는 경우)
+        if (brandId != null) {
+            productCacheService.evictBrandProductListCache(brandId);
+        }
+        
+        // 전체 상품 목록 캐시 무효화 (재고 소진으로 인한 상품 노출 변경)
+        productCacheService.evictProductListCaches();
+        
+        log.info("재고 소진 캐시 무효화 완료: productId={}, brandId={}", productId, brandId);
+    }
+
+    /**
+     * Epoch 밀리초를 ZonedDateTime으로 변환
+     */
+    private ZonedDateTime convertToZonedDateTime(long epochMillis) {
+        return ZonedDateTime.ofInstant(
+            Instant.ofEpochMilli(epochMillis), 
+            ZoneId.systemDefault()
+        );
+    }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
index 40d4ba4c9..33832aeb5 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
@@ -14,6 +14,7 @@
 import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
 import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -87,6 +88,15 @@ private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
             return;
         }
 
+        // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시)
+        if (isOldEvent(envelope.occurredAtEpochMillis())) {
+            log.debug("Ignoring old event: eventId={}, occurredAt={}", 
+                     envelope.eventId(), envelope.occurredAtEpochMillis());
+            // 멱등성 테이블에는 기록하되 비즈니스 로직은 처리하지 않음
+            metricsService.tryMarkHandled(envelope.eventId());
+            return;
+        }
+
         // 멱등성 체크 - 이미 처리된 이벤트는 무시
         final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId());
         if (!isFirstTime) {
@@ -98,6 +108,7 @@ private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
         switch (envelope.eventType()) {
             case "PRODUCT_VIEW" -> handleProductView(envelope);
             case "LIKE_ACTION" -> handleLikeAction(envelope);
+            case "STOCK_DEPLETED" -> handleStockDepleted(envelope);
             default -> log.debug("Unhandled catalog event type: {}", envelope.eventType());
         }
     }
@@ -109,6 +120,14 @@ private void processOrderEvent(ConsumerRecord<Object, Object> record) {
             return;
         }
 
+        // 과거 이벤트 필터링
+        if (isOldEvent(envelope.occurredAtEpochMillis())) {
+            log.debug("Ignoring old event: eventId={}, occurredAt={}", 
+                     envelope.eventId(), envelope.occurredAtEpochMillis());
+            metricsService.tryMarkHandled(envelope.eventId());
+            return;
+        }
+
         // 멱등성 체크
         final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId());
         if (!isFirstTime) {
@@ -149,17 +168,46 @@ private void handleLikeAction(DomainEventEnvelope envelope) {
     
     private void handlePaymentSuccess(DomainEventEnvelope envelope) {
         final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson());
-        if (payload == null || payload.items() == null) {
+        if (payload == null) {
             log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson());
             return;
         }
         
-        for (PaymentSuccessPayloadV1.Item item : payload.items()) {
-            if (item.productId() != null && item.quantity() != null && item.quantity() > 0) {
-                metricsService.addSales(item.productId(), item.quantity(), envelope.occurredAtEpochMillis());
-                log.debug("Processed PAYMENT_SUCCESS for productId: {}, quantity: {}", 
-                         item.productId(), item.quantity());
-            }
+        // 새로운 구조: 상품별 개별 이벤트 처리
+        if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) {
+            metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis());
+            
+            log.debug("Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", 
+                     payload.orderId(), payload.orderNumber(), payload.userId(),
+                     payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice());
+        } else {
+            log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", 
+                    payload.productId(), payload.quantity());
+        }
+    }
+
+    private void handleStockDepleted(DomainEventEnvelope envelope) {
+        final StockDepletedPayloadV1 payload = eventDeserializer.deserializeStockDepleted(envelope.payloadJson());
+        if (payload == null || payload.productId() == null) {
+            log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson());
+            return;
         }
+        
+        // 재고 소진 이벤트는 메트릭 업데이트보다는 캐시 무효화가 주 목적
+        metricsService.handleStockDepleted(payload.productId(), payload.brandId(), envelope.occurredAtEpochMillis());
+        
+        log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", 
+                payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock());
+    }
+
+    /**
+     * 과거 이벤트인지 확인 (1시간 이상 된 이벤트는 과거 이벤트로 간주)
+     */
+    private boolean isOldEvent(long occurredAtEpochMillis) {
+        long currentTime = System.currentTimeMillis();
+        long eventAge = currentTime - occurredAtEpochMillis;
+        long oneHourInMillis = 60 * 60 * 1000; // 1시간
+        
+        return eventAge > oneHourInMillis;
     }
 }
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 56d00670c..eb7bcb37f 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
@@ -102,47 +102,4 @@ public NewTopic orderEventsTopic() {
                 .replicas(1)
                 .build();
     }
-//
-//    /**
-//     * YAML 설정을 기반으로 추가 토픽들을 동적 생성
-//     * (선택사항: 기본 토픽 외에 추가 토픽이 필요한 경우)
-//     */
-//    // @Bean
-//    // @ConditionalOnProperty(name = "kafka.topics", matchIfMissing = false)
-//    // public List<NewTopic> dynamicKafkaTopics(KafkaTopicProperties properties) {
-//    //     List<NewTopic> topics = new ArrayList<>();
-//    //
-//    //     if (properties.getTopics() != null) {
-//    //         properties.getTopics().forEach((key, config) -> {
-//    //             NewTopic topic = TopicBuilder.name(config.getName())
-//    //                     .partitions(config.getPartitions())
-//    //                     .replicas(config.getReplicas())
-//    //                     .build();
-//    //
-//    //             topics.add(topic);
-//    //             log.info("동적 토픽 생성: {} (파티션: {}, 복제: {})",
-//    //                     config.getName(), config.getPartitions(), config.getReplicas());
-//    //         });
-//    //     }
-//    //
-//    //     return topics;
-//    // }
-//
-//    // ===== YAML 기반 토픽 설정 Properties =====
-//
-//    /**
-//     * YAML에서 토픽 설정을 읽어오는 Properties 클래스
-//     */
-//    @Data
-//    @ConfigurationProperties(prefix = "kafka")
-//    public static class KafkaTopicProperties {
-//        private Map<String, TopicConfig> topics;
-//
-//        @Data
-//        public static class TopicConfig {
-//            private String name;
-//            private int partitions = 1;
-//            private int replicas = 1;
-//        }
-//    }
 }

From 17089b872b617efa1a62dd80de2ea24b7694d555 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:07:52 +0900
Subject: [PATCH 19/28] =?UTF-8?q?refactor(cache):=20=EC=BA=90=EC=8B=9C=20?=
 =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C?=
 =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?=
 =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../domain/product/ProductCacheService.java   | 21 -------------------
 .../domain/metrics/MetricsService.java        |  2 +-
 .../com/loopers/cache/BaseCacheService.java   |  2 +-
 3 files changed, 2 insertions(+), 23 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
index 2d2913d57..ee3e71f6f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
@@ -61,27 +61,6 @@ public void cacheProductDetail(Long productId, ProductDetailInfo productDetail)
     }
 
 
-    public void batchCacheProductDetails(java.util.List<ProductDetailInfo> productDetails) {
-        if (productDetails == null || productDetails.isEmpty()) {
-            log.debug("배치 캐시 저장 대상 없음");
-            return;
-        }
-
-        int successCount = 0;
-        int failCount = 0;
-
-        for (ProductDetailInfo productDetail : productDetails) {
-            try {
-                cacheProductDetail(productDetail.id(), productDetail);
-                successCount++;
-            } catch (Exception e) {
-                failCount++;
-                log.warn("배치 캐시 저장 실패 - productId: {}", productDetail.id());
-            }
-        }
-
-        log.info("배치 캐시 완료 - 성공: {}, 실패: {}", successCount, failCount);
-    }
 
 
     public Optional<ProductDetailInfo> getProductDetailFromCache(Long productId) {
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 5e46d19ae..51b1ffb60 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -139,7 +139,7 @@ public void addSales(final Long productId, final int quantity, long occurredAtEp
                 }
             } else {
                 log.warn("판매량 업데이트 스킵 - 락 획득 실패: productId={}, quantity={}", productId, quantity);
-                // 락 획득 실패 시 이벤트 스킵 (성능 우선)
+
             }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
diff --git a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
index 9c452d77b..fa32a4074 100644
--- a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
+++ b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
@@ -82,7 +82,7 @@ public <T> Optional<T> get(String key, Class<T> clazz) {
     public void delete(String key) {
         try {
             Boolean deleted = redisTemplate.delete(key);
-            if (Boolean.TRUE.equals(deleted)) {
+            if (deleted) {
                 log.debug("캐시 삭제 - key: {}", key);
             }
         } catch (Exception e) {

From 1634f5f5eee3aaa33bfeea30ec12ac383cb5c0e9 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:28:06 +0900
Subject: [PATCH 20/28] =?UTF-8?q?feat(metrics):=20=EC=83=81=ED=92=88=20?=
 =?UTF-8?q?=EC=9E=AC=EA=B3=A0=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0=20?=
 =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 상품 상세 캐시의 재고 정보만 업데이트하는 메서드 추가
- 재고 정보가 존재하지 않을 경우 업데이트를 스킵하도록 구현
- 재고 갱신 시 기존 TTL 유지 및 JSON 파싱 오류 처리 로직 추가
- 재고 소진 이벤트 처리 시 remainingStock 정보 전달 방식으로 변경
---
 .../application/order/OrderFacade.java        |   5 +-
 .../domain/metrics/MetricsService.java        |  81 +-----
 .../domain/metrics/ProductMetricsEntity.java  |  93 -------
 .../domain/metrics/ProductMetricsService.java |  54 +---
 .../metrics/repository/MetricsRepository.java |   4 +-
 .../cache/ProductCacheService.java            |   7 +
 .../payloads/StockDepletedPayloadV1.java      |  16 ++
 .../metrics/MetricsRepositoryImpl.java        |  22 +-
 .../consumer/MetricsKafkaConsumer.java        |   4 +-
 ...MetricsEventProcessingIntegrationTest.java | 254 ++++++++++++++++++
 .../consumer/MetricsKafkaConsumerTest.java    | 198 ++++++++++++++
 .../com/loopers/cache/BaseCacheService.java   |  53 +++-
 12 files changed, 552 insertions(+), 239 deletions(-)
 create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
 create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
 create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 40bc019e3..435f1b618 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -21,6 +21,7 @@
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.order.OrderStatus;
 import com.loopers.domain.order.dto.OrderCreationResult;
+import com.loopers.domain.payment.PaymentType;
 import com.loopers.domain.point.PointService;
 import com.loopers.domain.product.ProductEntity;
 import com.loopers.domain.product.ProductService;
@@ -142,7 +143,7 @@ public OrderFacadeDtos.OrderInfo createOrderByPoint(
         OrderFacadeDtos.OrderCreateCommand legacy = OrderFacadeDtos.OrderCreateCommand.builder()
                 .username(command.username())
                 .orderItems(command.orderItems())
-                .paymentType(com.loopers.domain.payment.PaymentType.POINT)
+                .paymentType(PaymentType.POINT)
                 .build();
         return createOrderByPoint(legacy);
     }
@@ -274,7 +275,7 @@ public OrderFacadeDtos.OrderWithPaymentInfo createOrderWithCardPayment(
         OrderFacadeDtos.OrderCreateCommand legacy = OrderFacadeDtos.OrderCreateCommand.builder()
                 .username(command.username())
                 .orderItems(command.orderItems())
-                .paymentType(com.loopers.domain.payment.PaymentType.CARD)
+                .paymentType(PaymentType.CARD)
                 .cardInfo(legacyCard)
                 .build();
         return createOrderWithCardPayment(legacy);
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 51b1ffb60..985106fc2 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -147,85 +147,14 @@ public void addSales(final Long productId, final int quantity, long occurredAtEp
         }
     }
 
-
-
-    /**
-     * 장바구니 추가 이벤트 처리 (메모리 락 적용)
-     */
-    public void incrementCartAdd(Long productId, long occurredAtEpochMillis) {
-        ReentrantLock lock = getProductLock(productId);
-
-        try {
-            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
-                try {
-                    metricsTransactionService.incrementCartAddWithTransaction(productId, occurredAtEpochMillis);
-                    log.debug("장바구니 추가 업데이트 성공: productId={}", productId);
-                } finally {
-                    lock.unlock();
-                }
-            } else {
-                log.warn("장바구니 추가 업데이트 스킵 - 락 획득 실패: productId={}", productId);
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.warn("장바구니 추가 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
-        }
-    }
-
-    /**
-     * 위시리스트 추가 이벤트 처리 (메모리 락 적용)
-     */
-    public void incrementWishlistAdd(Long productId, long occurredAtEpochMillis) {
-        ReentrantLock lock = getProductLock(productId);
-
-        try {
-            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
-                try {
-                    metricsTransactionService.incrementWishlistAddWithTransaction(productId, occurredAtEpochMillis);
-                    log.debug("위시리스트 추가 업데이트 성공: productId={}", productId);
-                } finally {
-                    lock.unlock();
-                }
-            } else {
-                log.warn("위시리스트 추가 업데이트 스킵 - 락 획득 실패: productId={}", productId);
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.warn("위시리스트 추가 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
-        }
-    }
-
-    /**
-     * 리뷰 작성 이벤트 처리 (메모리 락 적용)
-     */
-    public void incrementReview(Long productId, long occurredAtEpochMillis) {
-        ReentrantLock lock = getProductLock(productId);
-
-        try {
-            if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
-                try {
-                    metricsTransactionService.incrementReviewWithTransaction(productId, occurredAtEpochMillis);
-                    log.debug("리뷰 작성 업데이트 성공: productId={}", productId);
-                } finally {
-                    lock.unlock();
-                }
-            } else {
-                log.warn("리뷰 작성 업데이트 스킵 - 락 획득 실패: productId={}", productId);
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.warn("리뷰 작성 업데이트 중단 - 스레드 인터럽트: productId={}", productId);
-        }
-    }
-
     /**
-     * 재고 소진 이벤트 처리 (캐시 무효화 중심)
+     * 재고 소진 이벤트 처리 (캐시 갱신 중심)
      */
-    public void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis) {
-        // 재고 소진은 메트릭 업데이트보다는 캐시 무효화가 주 목적
+    public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) {
+        // 재고 소진은 메트릭 업데이트보다는 캐시 갱신이 주 목적
         // 락 없이 바로 트랜잭션 서비스 호출
-        metricsTransactionService.handleStockDepletedWithTransaction(productId, brandId, occurredAtEpochMillis);
-        log.info("재고 소진 이벤트 처리 완료: productId={}, brandId={}", productId, brandId);
+        metricsTransactionService.handleStockDepletedWithTransaction(productId, brandId, remainingStock, occurredAtEpochMillis);
+        log.info("재고 소진 이벤트 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock);
     }
 
     /**
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
index cd74558a6..ef29205ab 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -40,15 +40,6 @@ public class ProductMetricsEntity {
     @Column(name = "order_count", nullable = false)
     private long orderCount = 0L;
 
-    @Column(name = "cart_add_count", nullable = false)
-    private long cartAddCount = 0L;
-
-    @Column(name = "wishlist_add_count", nullable = false)
-    private long wishlistAddCount = 0L;
-
-    @Column(name = "review_count", nullable = false)
-    private long reviewCount = 0L;
-
     @Column(name = "last_event_at")
     private ZonedDateTime lastEventAt;
 
@@ -67,15 +58,6 @@ public void incrementView(ZonedDateTime eventTime) {
         this.viewCount += 1;
         this.lastEventAt = eventTime;
     }
-
-    public void applyLikeDelta(final int delta) {
-        final long next = this.likeCount + delta;
-
-        //TODO : 검토 필요
-        this.likeCount = Math.max(0, next);
-
-        this.lastEventAt = ZonedDateTime.now();
-    }
     
     public void applyLikeDelta(final int delta, ZonedDateTime eventTime) {
         final long next = this.likeCount + delta;
@@ -96,79 +78,4 @@ public void addSales(final int quantity, ZonedDateTime eventTime) {
         this.lastEventAt = eventTime;
     }
 
-    /**
-     * 장바구니 추가 이벤트 처리
-     */
-    public void incrementCartAdd(ZonedDateTime eventTime) {
-        this.cartAddCount += 1;
-        this.lastEventAt = eventTime;
-    }
-
-    /**
-     * 위시리스트 추가 이벤트 처리
-     */
-    public void incrementWishlistAdd(ZonedDateTime eventTime) {
-        this.wishlistAddCount += 1;
-        this.lastEventAt = eventTime;
-    }
-
-    /**
-     * 리뷰 작성 이벤트 처리
-     */
-    public void incrementReview(ZonedDateTime eventTime) {
-        this.reviewCount += 1;
-        this.lastEventAt = eventTime;
-    }
-
-    // ========== 비즈니스 분석 메서드 ==========
-
-    /**
-     * 인기도 점수 계산 (가중치 기반)
-     * - 조회수: 1점
-     * - 좋아요: 5점
-     * - 장바구니 추가: 10점
-     * - 위시리스트 추가: 8점
-     * - 주문: 20점
-     * - 리뷰: 15점
-     */
-    public double calculatePopularityScore() {
-        return (viewCount * 1.0) +
-               (likeCount * 5.0) +
-               (cartAddCount * 10.0) +
-               (wishlistAddCount * 8.0) +
-               (orderCount * 20.0) +
-               (reviewCount * 15.0);
-    }
-
-    /**
-     * 전환율 계산 (주문 수 / 조회 수)
-     */
-    public double calculateConversionRate() {
-        if (viewCount == 0) {
-            return 0.0;
-        }
-        return (double) orderCount / viewCount * 100.0; // 백분율
-    }
-
-    /**
-     * 평균 주문 수량 계산
-     */
-    public double calculateAverageOrderQuantity() {
-        if (orderCount == 0) {
-            return 0.0;
-        }
-        return (double) salesCount / orderCount;
-    }
-
-    /**
-     * 참여도 점수 계산 (조회 대비 액션 비율)
-     */
-    public double calculateEngagementRate() {
-        if (viewCount == 0) {
-            return 0.0;
-        }
-        long totalEngagements = likeCount + cartAddCount + wishlistAddCount + orderCount;
-        return (double) totalEngagements / viewCount * 100.0; // 백분율
-    }
-
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
index 9aaa086ce..c3755f6d3 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
@@ -66,62 +66,22 @@ public void addSalesWithTransaction(Long productId, int quantity, long occurredA
             log.error("판매량 증가 실패: productId={}, quantity={}", productId, quantity, e);
             throw e;
         }
-    }
 
-    /**
-     * 장바구니 추가 (트랜잭션 적용)
-     */
-    @Transactional
-    public void incrementCartAddWithTransaction(Long productId, long occurredAtEpochMillis) {
-        try {
-            metricsRepository.incrementCartAdd(productId, occurredAtEpochMillis);
-            log.debug("장바구니 추가 완료: productId={}", productId);
-        } catch (Exception e) {
-            log.error("장바구니 추가 실패: productId={}", productId, e);
-            throw e;
-        }
-    }
-
-    /**
-     * 위시리스트 추가 (트랜잭션 적용)
-     */
-    @Transactional
-    public void incrementWishlistAddWithTransaction(Long productId, long occurredAtEpochMillis) {
-        try {
-            metricsRepository.incrementWishlistAdd(productId, occurredAtEpochMillis);
-            log.debug("위시리스트 추가 완료: productId={}", productId);
-        } catch (Exception e) {
-            log.error("위시리스트 추가 실패: productId={}", productId, e);
-            throw e;
-        }
     }
 
-    /**
-     * 리뷰 작성 (트랜잭션 적용)
-     */
-    @Transactional
-    public void incrementReviewWithTransaction(Long productId, long occurredAtEpochMillis) {
-        try {
-            metricsRepository.incrementReview(productId, occurredAtEpochMillis);
-            log.debug("리뷰 작성 완료: productId={}", productId);
-        } catch (Exception e) {
-            log.error("리뷰 작성 실패: productId={}", productId, e);
-            throw e;
-        }
-    }
 
     /**
      * 재고 소진 이벤트 처리 (트랜잭션 적용)
-     * 주로 캐시 무효화를 담당합니다.
+     * 주로 캐시 갱신을 담당합니다.
      */
     @Transactional
-    public void handleStockDepletedWithTransaction(Long productId, Long brandId, long occurredAtEpochMillis) {
+    public void handleStockDepletedWithTransaction(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) {
         try {
-            // 재고 소진 시 캐시 무효화 처리
-            metricsRepository.handleStockDepleted(productId, brandId, occurredAtEpochMillis);
-            log.debug("재고 소진 처리 완료: productId={}, brandId={}", productId, brandId);
+            // 재고 소진 시 캐시 갱신 처리
+            metricsRepository.handleStockDepleted(productId, brandId, remainingStock, occurredAtEpochMillis);
+            log.debug("재고 소진 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock);
         } catch (Exception e) {
-            log.error("재고 소진 처리 실패: productId={}, brandId={}", productId, brandId, e);
+            log.error("재고 소진 처리 실패: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock, e);
             throw e;
         }
     }
@@ -138,7 +98,7 @@ public boolean saveEventHandled(String eventId) {
                 log.debug("트랜잭션 내 중복 확인: {}", eventId);
                 return false;
             }
-            
+
             eventHandledRepository.save(com.loopers.domain.event.EventEntity.create(eventId));
             log.debug("이벤트 처리 완료 저장: {}", eventId);
             return true;
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
index dc84d2d87..58fae6c24 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
@@ -26,7 +26,7 @@ public interface MetricsRepository {
     void addSales(Long productId, int quantity, long occurredAtEpochMillis);
     
     /**
-     * 재고 소진 이벤트 처리 (캐시 무효화 중심)
+     * 재고 소진 이벤트 처리 (캐시 갱신 중심)
      */
-    void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis);
+    void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
index 74b499845..11e82aceb 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
@@ -52,4 +52,11 @@ public void onSalesCountChanged(Long productId) {
     public void evictBrandProductListCache(Long brandId) {
         baseCacheService.evictBrandProductListCache(brandId);
     }
+
+    /**
+     * 상품 상세 캐시의 재고 정보만 업데이트
+     */
+    public void updateProductStock(Long productId, Integer newStock) {
+        baseCacheService.updateProductStock(productId, newStock);
+    }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
new file mode 100644
index 000000000..9340047b8
--- /dev/null
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
@@ -0,0 +1,16 @@
+package com.loopers.infrastructure.event.payloads;
+
+/**
+ * 재고 소진 이벤트 페이로드 V1
+ * 
+ * @author hyunjikoh
+ * @since 2025. 12. 19.
+ */
+public record StockDepletedPayloadV1(
+        Long productId,         // 재고 소진된 상품 ID
+        Long brandId,           // 브랜드 ID (브랜드별 캐시 무효화용)
+        String productName,     // 상품명 (로깅용)
+        Integer remainingStock, // 남은 재고 (0이어야 함)
+        Long warehouseId        // 창고 ID (선택적)
+) {
+}
\ No newline at end of file
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
index adb5c9097..614a79998 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
@@ -112,22 +112,16 @@ public void addSales(Long productId, int quantity, long occurredAtEpochMillis) {
     }
 
     @Override
-    public void handleStockDepleted(Long productId, Long brandId, long occurredAtEpochMillis) {
-        // 재고 소진 이벤트는 주로 캐시 무효화가 목적
-        // 메트릭 자체는 업데이트하지 않고 캐시만 무효화
+    public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) {
+        // 재고 소진 이벤트 처리
+        // 메트릭 자체는 업데이트하지 않고 캐시만 처리
         
-        // 상품별 캐시 무효화
-        productCacheService.evictProductCache(productId);
+        // 상품 상세 캐시의 재고 정보만 갱신 (빠른 응답을 위해)
+        int stockToUpdate = (remainingStock != null) ? remainingStock : 0;
+        productCacheService.updateProductStock(productId, stockToUpdate);
         
-        // 브랜드별 상품 목록 캐시 무효화 (브랜드 ID가 있는 경우)
-        if (brandId != null) {
-            productCacheService.evictBrandProductListCache(brandId);
-        }
-        
-        // 전체 상품 목록 캐시 무효화 (재고 소진으로 인한 상품 노출 변경)
-        productCacheService.evictProductListCaches();
-        
-        log.info("재고 소진 캐시 무효화 완료: productId={}, brandId={}", productId, brandId);
+        log.info("재고 소진 상세 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", 
+                productId, brandId, stockToUpdate);
     }
 
     /**
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
index 33832aeb5..40bbede16 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
@@ -193,8 +193,8 @@ private void handleStockDepleted(DomainEventEnvelope envelope) {
             return;
         }
         
-        // 재고 소진 이벤트는 메트릭 업데이트보다는 캐시 무효화가 주 목적
-        metricsService.handleStockDepleted(payload.productId(), payload.brandId(), envelope.occurredAtEpochMillis());
+        // 재고 소진 이벤트 처리 - remainingStock 정보 전달
+        metricsService.handleStockDepleted(payload.productId(), payload.brandId(), payload.remainingStock(), envelope.occurredAtEpochMillis());
         
         log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", 
                 payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock());
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
new file mode 100644
index 000000000..de7df18e1
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
@@ -0,0 +1,254 @@
+package com.loopers.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+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.core.KafkaTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.event.EventRepository;
+import com.loopers.domain.metrics.ProductMetricsEntity;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.infrastructure.event.DomainEventEnvelope;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+
+/**
+ * 메트릭스 이벤트 처리 통합 테스트
+ * 실제 Kafka를 통한 E2E 테스트
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@SpringBootTest
+class MetricsEventProcessingIntegrationTest {
+
+    @Autowired
+    private KafkaTemplate<Object, Object> kafkaTemplate;
+    
+    @Autowired
+    private ProductMetricsRepository productMetricsRepository;
+    
+    @Autowired
+    private EventRepository eventRepository;
+    
+    @Autowired
+    private ObjectMapper objectMapper;
+    
+    @BeforeEach
+    @Transactional
+    void setUp() {
+        // 테스트 데이터 정리
+        productMetricsRepository.deleteAll();
+        eventRepository.deleteAll();
+    }
+    
+    @Test
+    @DisplayName("PRODUCT_VIEW 이벤트가 정상적으로 처리되어 조회수가 증가해야 한다")
+    void shouldIncrementViewCountOnProductViewEvent() throws Exception {
+        // Given
+        Long productId = 1L;
+        String eventId = "product-view-test-" + System.currentTimeMillis();
+        
+        ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L);
+        String payloadJson = objectMapper.writeValueAsString(payload);
+        
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PRODUCT_VIEW",
+                "v1",
+                System.currentTimeMillis(),
+                payloadJson
+        );
+        
+        // When
+        kafkaTemplate.send("catalog-events", envelope);
+        
+        // Then
+        await().atMost(Duration.ofSeconds(10))
+                .untilAsserted(() -> {
+                    Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
+                    assertThat(metrics).isPresent();
+                    assertThat(metrics.get().getViewCount()).isEqualTo(1L);
+                    assertThat(metrics.get().getLastEventAt()).isNotNull();
+                });
+        
+        // 멱등성 확인 - 이벤트가 처리되었음을 확인
+        assertThat(eventRepository.existsById(eventId)).isTrue();
+    }
+    
+    @Test
+    @DisplayName("동일한 이벤트 ID로 중복 전송해도 한 번만 처리되어야 한다")
+    void shouldProcessDuplicateEventOnlyOnce() throws Exception {
+        // Given
+        Long productId = 2L;
+        String eventId = "duplicate-test-" + System.currentTimeMillis();
+        
+        ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 200L);
+        String payloadJson = objectMapper.writeValueAsString(payload);
+        
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PRODUCT_VIEW",
+                "v1",
+                System.currentTimeMillis(),
+                payloadJson
+        );
+        
+        // When - 같은 이벤트를 두 번 전송
+        kafkaTemplate.send("catalog-events", envelope);
+        kafkaTemplate.send("catalog-events", envelope);
+        
+        // Then - 조회수는 1만 증가해야 함
+        await().atMost(Duration.ofSeconds(10))
+                .untilAsserted(() -> {
+                    Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
+                    assertThat(metrics).isPresent();
+                    assertThat(metrics.get().getViewCount()).isEqualTo(1L);
+                });
+    }
+    
+    @Test
+    @DisplayName("PAYMENT_SUCCESS 이벤트가 정상적으로 처리되어 판매수가 증가해야 한다")
+    void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception {
+        // Given
+        Long productId = 3L;
+        String eventId = "payment-success-test-" + System.currentTimeMillis();
+        
+        // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트)
+        PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
+                12345L,    // orderId
+                67890L,    // orderNumber
+                100L,      // userId
+                productId, // productId
+                2,         // quantity
+                java.math.BigDecimal.valueOf(1000), // unitPrice
+                java.math.BigDecimal.valueOf(2000)  // totalPrice
+        );
+        String payloadJson = objectMapper.writeValueAsString(payload);
+        
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PAYMENT_SUCCESS",
+                "v1",
+                System.currentTimeMillis(),
+                payloadJson
+        );
+        
+        // When
+        kafkaTemplate.send("order-events", envelope);
+        
+        // Then
+        await().atMost(Duration.ofSeconds(10))
+                .untilAsserted(() -> {
+                    Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
+                    
+                    assertThat(metrics).isPresent();
+                    assertThat(metrics.get().getSalesCount()).isEqualTo(2L);
+                    assertThat(metrics.get().getOrderCount()).isEqualTo(1L); // 주문 건수도 확인
+                });
+    }
+    
+    @Test
+    @DisplayName("과거 이벤트는 무시되어야 한다")
+    void shouldIgnoreOldEvents() throws Exception {
+        // Given
+        Long productId = 5L;
+        long currentTime = System.currentTimeMillis();
+        
+        // 먼저 최신 이벤트를 처리
+        String recentEventId = "recent-event-" + currentTime;
+        ProductViewPayloadV1 recentPayload = new ProductViewPayloadV1(productId, 100L);
+        String recentPayloadJson = objectMapper.writeValueAsString(recentPayload);
+        
+        DomainEventEnvelope recentEnvelope = new DomainEventEnvelope(
+                recentEventId,
+                "PRODUCT_VIEW",
+                "v1",
+                currentTime,
+                recentPayloadJson
+        );
+        
+        kafkaTemplate.send("catalog-events", recentEnvelope);
+        
+        // 최신 이벤트가 처리될 때까지 대기
+        await().atMost(Duration.ofSeconds(10))
+                .untilAsserted(() -> {
+                    Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
+                    assertThat(metrics).isPresent();
+                    assertThat(metrics.get().getViewCount()).isEqualTo(1L);
+                });
+        
+        // When - 과거 이벤트 전송 (1시간 전)
+        String oldEventId = "old-event-" + currentTime;
+        ProductViewPayloadV1 oldPayload = new ProductViewPayloadV1(productId, 200L);
+        String oldPayloadJson = objectMapper.writeValueAsString(oldPayload);
+        
+        DomainEventEnvelope oldEnvelope = new DomainEventEnvelope(
+                oldEventId,
+                "PRODUCT_VIEW",
+                "v1",
+                currentTime - 3600000, // 1시간 전
+                oldPayloadJson
+        );
+        
+        kafkaTemplate.send("catalog-events", oldEnvelope);
+        
+        // Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시)
+        Thread.sleep(1000); // 처리 시간 대기
+
+        Optional<ProductMetricsEntity> finalMetrics = productMetricsRepository.findById(productId);
+        assertThat(finalMetrics).isPresent();
+        assertThat(finalMetrics.get().getViewCount()).isEqualTo(1L);
+        
+        // 과거 이벤트도 멱등성 테이블에는 기록되어야 함
+        assertThat(eventRepository.existsById(recentEventId)).isTrue();
+    }
+    
+    @Test
+    @DisplayName("새로운 메트릭 필드들이 정상적으로 초기화되어야 한다")
+    void shouldInitializeNewMetricFields() throws Exception {
+        // Given
+        Long productId = 6L;
+        String eventId = "new-metrics-test-" + System.currentTimeMillis();
+        
+        ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L);
+        String payloadJson = objectMapper.writeValueAsString(payload);
+        
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PRODUCT_VIEW",
+                "v1",
+                System.currentTimeMillis(),
+                payloadJson
+        );
+        
+        // When
+        kafkaTemplate.send("catalog-events", envelope);
+        
+        // Then - 새로운 메트릭 필드들이 0으로 초기화되어야 함
+        await().atMost(Duration.ofSeconds(10))
+                .untilAsserted(() -> {
+                    Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
+                    assertThat(metrics).isPresent();
+                    
+                    ProductMetricsEntity entity = metrics.get();
+                    assertThat(entity.getViewCount()).isEqualTo(1L);
+                    assertThat(entity.getLikeCount()).isEqualTo(0L);
+                    assertThat(entity.getSalesCount()).isEqualTo(0L);
+                    assertThat(entity.getOrderCount()).isEqualTo(0L);
+                    assertThat(entity.getLastEventAt()).isNotNull();
+                });
+    }
+}
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
new file mode 100644
index 000000000..458c66085
--- /dev/null
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
@@ -0,0 +1,198 @@
+package com.loopers.interfaces.consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.kafka.support.Acknowledgment;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.metrics.MetricsService;
+import com.loopers.infrastructure.event.DomainEventEnvelope;
+import com.loopers.infrastructure.event.EventDeserializer;
+import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
+import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
+
+/**
+ * MetricsKafkaConsumer 멱등성 및 신뢰성 테스트
+ *
+ * @author hyunjikoh
+ * @since 2025. 12. 18.
+ */
+@ExtendWith(MockitoExtension.class)
+class MetricsKafkaConsumerTest {
+
+    @Mock
+    private MetricsService metricsService;
+    
+    @Mock
+    private EventDeserializer eventDeserializer;
+    
+    @Mock
+    private Acknowledgment acknowledgment;
+    
+    @InjectMocks
+    private MetricsKafkaConsumer consumer;
+    
+    private ObjectMapper objectMapper;
+    
+    @BeforeEach
+    void setUp() {
+        objectMapper = new ObjectMapper();
+    }
+    
+    @Test
+    @DisplayName("중복된 이벤트 ID는 한 번만 처리되어야 한다")
+    void shouldProcessEventOnlyOnce() {
+        // Given
+        String eventId = "test-event-123";
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PRODUCT_VIEW", 
+                "v1",
+                System.currentTimeMillis(),
+                "{\"productId\":1,\"userId\":100}"
+        );
+        
+        ProductViewPayloadV1 payload = new ProductViewPayloadV1(1L, 100L);
+        
+        ConsumerRecord<Object, Object> record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope);
+        
+        // 첫 번째 호출에서는 true (처음 처리), 두 번째 호출에서는 false (이미 처리됨)
+        when(metricsService.tryMarkHandled(eventId))
+                .thenReturn(true)
+                .thenReturn(false);
+        
+        when(eventDeserializer.deserializeEnvelope(envelope))
+                .thenReturn(envelope);
+        
+        when(eventDeserializer.deserializeProductView(envelope.payloadJson()))
+                .thenReturn(payload);
+        
+        // When - 같은 이벤트를 두 번 처리
+        consumer.onCatalogEvents(List.of(record, record), acknowledgment);
+        
+        // Then - 비즈니스 로직은 한 번만 호출되어야 함
+        verify(metricsService, times(2)).tryMarkHandled(eventId);
+        verify(metricsService, times(1)).incrementView(eq(1L), anyLong());
+        verify(acknowledgment, times(1)).acknowledge();
+    }
+    
+    @Test
+    @DisplayName("잘못된 이벤트 봉투는 무시되어야 한다")
+    void shouldIgnoreInvalidEventEnvelope() {
+        // Given
+        ConsumerRecord<Object, Object> record = new ConsumerRecord<>("catalog-events", 0, 0, null, "invalid-json");
+        
+        when(eventDeserializer.deserializeEnvelope("invalid-json"))
+                .thenReturn(null);
+        
+        // When
+        consumer.onCatalogEvents(List.of(record), acknowledgment);
+        
+        // Then
+        verify(metricsService, never()).tryMarkHandled(anyString());
+        verify(metricsService, never()).incrementView(anyLong(), anyLong());
+        verify(acknowledgment, times(1)).acknowledge(); // 배치는 여전히 ack 되어야 함
+    }
+    
+    @Test
+    @DisplayName("PAYMENT_SUCCESS 이벤트가 상품별로 개별 처리되어야 한다")
+    void shouldProcessPaymentSuccessEvent() {
+        // Given
+        String eventId = "payment-event-456";
+        DomainEventEnvelope envelope = new DomainEventEnvelope(
+                eventId,
+                "PAYMENT_SUCCESS",
+                "v1", 
+                System.currentTimeMillis(),
+                "{\"orderId\":12345,\"orderNumber\":67890,\"userId\":100,\"productId\":1,\"quantity\":2,\"unitPrice\":1000,\"totalPrice\":2000}"
+        );
+        
+        // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트)
+        PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
+                12345L,    // orderId
+                67890L,    // orderNumber
+                100L,      // userId
+                1L,        // productId
+                2,         // quantity
+                java.math.BigDecimal.valueOf(1000), // unitPrice
+                java.math.BigDecimal.valueOf(2000)  // totalPrice
+        );
+        
+        ConsumerRecord<Object, Object> record = new ConsumerRecord<>("order-events", 0, 0, null, envelope);
+        
+        when(metricsService.tryMarkHandled(eventId)).thenReturn(true);
+        when(eventDeserializer.deserializeEnvelope(envelope)).thenReturn(envelope);
+        when(eventDeserializer.deserializePaymentSuccess(envelope.payloadJson())).thenReturn(payload);
+        
+        // When
+        consumer.onOrderEvents(List.of(record), acknowledgment);
+        
+        // Then
+        verify(metricsService, times(1)).tryMarkHandled(eventId);
+        verify(metricsService, times(1)).addSales(eq(1L), eq(2), anyLong());
+        verify(acknowledgment, times(1)).acknowledge();
+    }
+    
+    @Test
+    @DisplayName("개별 메시지 처리 실패가 전체 배치를 실패시키지 않아야 한다")
+    void shouldContinueProcessingWhenIndividualMessageFails() {
+        // Given
+        String validEventId = "valid-event";
+        String invalidEventId = "invalid-event";
+        
+        DomainEventEnvelope validEnvelope = new DomainEventEnvelope(
+                validEventId,
+                "PRODUCT_VIEW",
+                "v1",
+                System.currentTimeMillis(),
+                "{\"productId\":1,\"userId\":100}"
+        );
+        
+        DomainEventEnvelope invalidEnvelope = new DomainEventEnvelope(
+                invalidEventId,
+                "PRODUCT_VIEW", 
+                "v1",
+                System.currentTimeMillis(),
+                "invalid-payload"
+        );
+        
+        ProductViewPayloadV1 validPayload = new ProductViewPayloadV1(1L, 100L);
+        
+        ConsumerRecord<Object, Object> validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope);
+        ConsumerRecord<Object, Object> invalidRecord = new ConsumerRecord<>("catalog-events", 0, 1, null, invalidEnvelope);
+        
+        when(metricsService.tryMarkHandled(validEventId)).thenReturn(true);
+        when(metricsService.tryMarkHandled(invalidEventId)).thenReturn(true);
+        
+        when(eventDeserializer.deserializeEnvelope(validEnvelope)).thenReturn(validEnvelope);
+        when(eventDeserializer.deserializeEnvelope(invalidEnvelope)).thenReturn(invalidEnvelope);
+        
+        when(eventDeserializer.deserializeProductView(validEnvelope.payloadJson())).thenReturn(validPayload);
+        when(eventDeserializer.deserializeProductView(invalidEnvelope.payloadJson())).thenReturn(null); // 파싱 실패
+        
+        // When
+        consumer.onCatalogEvents(List.of(validRecord, invalidRecord), acknowledgment);
+        
+        // Then - 유효한 메시지는 처리되고, 전체 배치는 ack 되어야 함
+        verify(metricsService, times(1)).incrementView(eq(1L), anyLong());
+        verify(acknowledgment, times(1)).acknowledge();
+    }
+}
\ No newline at end of file
diff --git a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
index fa32a4074..9afea602b 100644
--- a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
+++ b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
@@ -1,6 +1,7 @@
 package com.loopers.cache;
 
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
@@ -170,9 +171,55 @@ public void evictBrandProductListCache(Long brandId) {
         }
     }
 
-    // ========== Getter ==========
+    // ========== 상품 재고 캐시 갱신 ==========
 
-    public CacheKeyGenerator getCacheKeyGenerator() {
-        return cacheKeyGenerator;
+    /**
+     * 상품 상세 캐시의 재고 정보만 업데이트
+     * 캐시가 존재하는 경우에만 업데이트하고, 없으면 스킵
+     */
+    public void updateProductStock(Long productId, Integer newStock) {
+        try {
+            String key = cacheKeyGenerator.generateProductDetailKey(productId);
+            
+            // 기존 캐시 조회
+            String cachedValue = redisTemplate.opsForValue().get(key);
+            if (cachedValue == null) {
+                log.debug("상품 상세 캐시 없음 - 재고 갱신 스킵: productId={}", productId);
+                return;
+            }
+            
+            // JSON 파싱하여 재고 정보 업데이트
+            try {
+                // 기존 캐시 데이터를 Map으로 파싱
+                @SuppressWarnings("unchecked")
+                Map<String, Object> productData = objectMapper.readValue(cachedValue, java.util.Map.class);
+                
+                // 재고 정보 업데이트
+                productData.put("stock", newStock);
+                productData.put("isInStock", newStock > 0);
+                
+                // 업데이트된 데이터를 다시 JSON으로 변환하여 저장
+                String updatedValue = objectMapper.writeValueAsString(productData);
+                
+                // 기존 TTL 유지하면서 업데이트
+                Long ttl = redisTemplate.getExpire(key);
+                if (ttl != null && ttl > 0) {
+                    redisTemplate.opsForValue().set(key, updatedValue, ttl, TimeUnit.SECONDS);
+                } else {
+                    // TTL이 없거나 만료된 경우 기본 30분으로 설정
+                    redisTemplate.opsForValue().set(key, updatedValue, 30, TimeUnit.MINUTES);
+                }
+                
+                log.debug("상품 재고 캐시 갱신 완료: productId={}, newStock={}", productId, newStock);
+                
+            } catch (JsonProcessingException e) {
+                log.warn("상품 재고 캐시 갱신 실패 (JSON 처리 오류) - 캐시 삭제: productId={}", productId, e);
+                // JSON 파싱 실패 시 캐시 삭제
+                delete(key);
+            }
+            
+        } catch (Exception e) {
+            log.warn("상품 재고 캐시 갱신 실패: productId={}, error: {}", productId, e.getMessage());
+        }
     }
 }

From 507a975728c6fb3e0db06081ec18e8b6ada8bc56 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:33:18 +0900
Subject: [PATCH 21/28] =?UTF-8?q?refactor(order):=20=EC=A3=BC=EB=AC=B8=20?=
 =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=AA=85=EC=84=B8=EC=97=90=EC=84=9C=20?=
 =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=96=B4?=
 =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- jakarta.validation.Valid 어노테이션을 사용하여
  유효성 검사 일관성 향상
- 불필요한 주석 제거로 코드 가독성 개선
---
 .../application/order/OrderEventHandler.java  |   1 -
 .../application/order/OrderFacade.java        |  13 +-
 .../application/order/OrderFacadeDtos.java    |   1 -
 .../ProductCacheStrategyIntegrationTest.java  |   6 +-
 docs/week8/lecture.md                         | 324 ------------------
 docs/week8/quest.md                           | 127 -------
 docs/week8/todoList                           | 136 --------
 .../com/loopers/cache/BaseCacheService.java   |   4 +-
 .../com/loopers/cache/CacheKeyGenerator.java  |  19 -
 9 files changed, 13 insertions(+), 618 deletions(-)
 delete mode 100644 docs/week8/lecture.md
 delete mode 100644 docs/week8/quest.md
 delete mode 100644 docs/week8/todoList

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
index 0d91087cb..38146e556 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
@@ -126,7 +126,6 @@ private void savePaymentSuccessToOutbox(PaymentCompletedEvent event) {
     /**
      * 상품별 결제 완료 이벤트를 원자적으로 Outbox에 저장
      * 모든 상품 이벤트가 성공하거나 모두 실패하도록 보장
-     * 
      * 새로운 구조: 각 상품별로 주문 컨텍스트 정보를 포함한 개별 이벤트 생성
      */
     private void savePaymentSuccessEventsAtomically(OrderEntity order, List<OrderItemEntity> orderItems) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 435f1b618..4f7a5c0ec 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -11,6 +11,7 @@
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
+import com.loopers.application.order.OrderFacadeDtos.CardOrderCreateCommand;
 import com.loopers.application.payment.PaymentCommand;
 import com.loopers.application.payment.PaymentFacade;
 import com.loopers.application.payment.PaymentInfo;
@@ -31,6 +32,8 @@
 
 import lombok.RequiredArgsConstructor;
 
+import jakarta.validation.Valid;
+
 /**
  * 주문 Facade
  * <p>
@@ -64,7 +67,7 @@ public class OrderFacade {
      * @throws IllegalArgumentException 재고 부족 또는 주문 불가능한 경우
      */
     @Transactional
-    public OrderFacadeDtos.OrderInfo createOrderByPoint(@jakarta.validation.Valid OrderFacadeDtos.OrderCreateCommand command) {
+    public OrderFacadeDtos.OrderInfo createOrderByPoint(@Valid OrderFacadeDtos.OrderCreateCommand command) {
         // 1. 주문자 정보 조회 (락 적용)
         UserEntity user = userService.findByUsernameWithLock(command.username());
 
@@ -139,7 +142,7 @@ public OrderFacadeDtos.OrderInfo createOrderByPoint(@jakarta.validation.Valid Or
      */
     @Transactional
     public OrderFacadeDtos.OrderInfo createOrderByPoint(
-            @jakarta.validation.Valid OrderFacadeDtos.PointOrderCreateCommand command) {
+            @Valid OrderFacadeDtos.PointOrderCreateCommand command) {
         OrderFacadeDtos.OrderCreateCommand legacy = OrderFacadeDtos.OrderCreateCommand.builder()
                 .username(command.username())
                 .orderItems(command.orderItems())
@@ -160,7 +163,7 @@ public OrderFacadeDtos.OrderInfo createOrderByPoint(
      */
     @Transactional
     public OrderFacadeDtos.OrderInfo createOrderForCardPayment(
-            @jakarta.validation.Valid OrderFacadeDtos.OrderCreateCommand command) {
+            @Valid OrderFacadeDtos.OrderCreateCommand command) {
         // 1. 주문자 정보 조회 (락 적용)
         UserEntity user = userService.findByUsernameWithLock(command.username());
 
@@ -239,7 +242,7 @@ public OrderFacadeDtos.OrderInfo createOrderForCardPayment(
      */
     @Transactional
     public OrderFacadeDtos.OrderWithPaymentInfo createOrderWithCardPayment(
-            @jakarta.validation.Valid OrderFacadeDtos.OrderCreateCommand command) {
+            @Valid OrderFacadeDtos.OrderCreateCommand command) {
         // 1. 주문 생성 (재고 차감, 쿠폰 사용, 포인트 차감 안 함)
         OrderFacadeDtos.OrderInfo orderInfo = createOrderForCardPayment(command);
 
@@ -266,7 +269,7 @@ public OrderFacadeDtos.OrderWithPaymentInfo createOrderWithCardPayment(
      */
     @Transactional
     public OrderFacadeDtos.OrderWithPaymentInfo createOrderWithCardPayment(
-            @jakarta.validation.Valid OrderFacadeDtos.CardOrderCreateCommand command) {
+            @Valid CardOrderCreateCommand command) {
         OrderFacadeDtos.OrderCreateCommand.CardPaymentInfo legacyCard = new OrderFacadeDtos.OrderCreateCommand.CardPaymentInfo(
                 command.cardInfo().cardType(),
                 command.cardInfo().cardNo(),
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacadeDtos.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacadeDtos.java
index 84314c377..4eeddd328 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacadeDtos.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacadeDtos.java
@@ -167,7 +167,6 @@ public static OrderSummary from(OrderEntity order, int itemCount) {
      * @since 2025. 11. 14.
      */
     @Builder
-    @Deprecated
     public record OrderCreateCommand(
             @NotBlank
             String username,
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
index 3ba88431c..5520d0191 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheStrategyIntegrationTest.java
@@ -89,7 +89,7 @@ void should_cache_first_page_as_hot_and_second_page_as_warm() {
             // When: 두 번째 페이지 조회
             Pageable page1 = PageRequest.of(1, 20);
             ProductSearchFilter filter1 = new ProductSearchFilter(null, null, page1);
-            Page<ProductInfo> result1 = productFacade.getProducts(filter1);
+            productFacade.getProducts(filter1);
 
             // Then: 캐시에 저장됨 (WARM 전략)
             Optional<List<Long>> cachedIds1 = cacheService.getProductIdsFromCache(
@@ -110,7 +110,7 @@ void should_cache_brand_first_2_pages_as_hot_data() {
             // When: 브랜드별 첫 번째 페이지 조회
             Pageable page0 = PageRequest.of(0, 20);
             ProductSearchFilter filter0 = new ProductSearchFilter(brandId, null, page0);
-            Page<ProductInfo> result0 = productFacade.getProducts(filter0);
+            productFacade.getProducts(filter0);
 
             // Then: 캐시에 저장됨 (Hot 전략)
             Optional<List<Long>> cachedIds0 = cacheService.getProductIdsFromCache(
@@ -121,7 +121,7 @@ void should_cache_brand_first_2_pages_as_hot_data() {
             // When: 브랜드별 두 번째 페이지 조회
             Pageable page1 = PageRequest.of(1, 20);
             ProductSearchFilter filter1 = new ProductSearchFilter(brandId, null, page1);
-            Page<ProductInfo> result1 = productFacade.getProducts(filter1);
+            productFacade.getProducts(filter1);
 
             // Then: 캐시에 저장됨 (Hot 전략)
             Optional<List<Long>> cachedIds1 = cacheService.getProductIdsFromCache(
diff --git a/docs/week8/lecture.md b/docs/week8/lecture.md
deleted file mode 100644
index ce3808a47..000000000
--- a/docs/week8/lecture.md
+++ /dev/null
@@ -1,324 +0,0 @@
-## 🧭 루프팩 BE L2 - Round 8
-
-> 누가 기침을 하였는가 ~
->
->
-> Kafka 기반으로 **서비스 경계 밖으로 이벤트를 발행**하고, 별도 Consumer 앱이 **후속 처리와 운영 책임**을 담당합니다.
->
-> 메시지 전달 보장(At Least Once, Idempotency, DLQ)을 학습하며 **신뢰 가능한 이벤트 파이프라인**을 구현합니다.
->
-
-<aside>
-🎯
-
-**Summary**
-
-</aside>
-
-지난 라운드에서는 **메세지에 대한 이해**와 애플리케이션 **이벤트를 통한 결합**을 느슨하게 만들어보았습니다. 하지만 애플리케이션 안에서만 이벤트가 머물러 있어, **서비스 경계를 넘는 확장성은 부족**했습니다.
-
-이번 라운드에서는 이를 해결하기 위해 **Kafka 를 통해 이벤트를 외부로 발행**하고, **별도의 Consumer 앱이 후속 처리를 담당하는 구조**를 학습합니다.
-
-<aside>
-📌
-
-**Keywords**
-
-</aside>
-
-- Kafka Producer & Consumer
-- At Most Once / At Least Once / Exactly Once
-- Idempotency & 멱등 처리
-- Dead Letter Queue (DLQ)
-
-<aside>
-🧠
-
-**Learning**
-
-</aside>
-
-## 📮 Kafka Overview
-
-<aside>
-❓
-
-Kafka 는 흔히 **고성능 메시지 큐**라고 생각하지만, 근본적으로는 **분산 로그 저장소(Distributed Log Store)** 입니다.
-
-Kafka 는 메세지가 흘러가는 통로보다는 `로그가 쌓이는 거대한 시스템` 에 좀 더 가깝습니다.
-
-- 일반적인 Message Queue 와는 다르게 **디스크에 Log 를 지속적으로 append** 함
-- Consumer 는 **각자의 Offset 을 기억하고 필요한 시점부터 읽는 것이** 가능함 (재처리 가능)
-</aside>
-
-### Kafka 의 주요 특징
-
-- **고가용성** - Partition + Replica 구조로 브로커 장애 시에도 데이터 유실을 최소화
-- **확장성** - Broker, Partion 의 수평 확장으로 처리량의 선형 증가
-- **범용성** - 단순 메세징 뿐이 아닌 다음의 용도로도 사용
-    - 1️⃣ 로그 수집
-    - 2️⃣ 이벤트 소싱
-    - 3️⃣ 스트리밍 처리의 기반
-
-### Kafka Components
-
-1. **Broker**
-    - 카프카 서버 Unit
-    - Producer 의 메세지를 받아 Offset 지정 후 디스크에 저장
-    - Consumer 의 파티션 Read 에 응답해 디스크의 메세지 전송
-    - `Cluster` 내에서 각 1개씩 존재하는 Role Broker
-        - **Controller**
-
-          다른 브로커를 모니터링하고 장애가 발생한 Broker 에 특정 토픽의 Leader 파티션이 존재한다면, 다른 브로커의 파티션 중 Leader 를 재분배하는 역할을 수행
-
-        - **Coordinator**
-
-          컨슈머 그룹을 모니터링하고 해당 그룹 내의 특정 컨슈머가 장애가 발생해 매칭된 파티션의 메세지를 Consume 할 수 없는 경우, 해당 파티션을 다른 컨슈머에게 매칭해주는 역할 수행 (`Rebalance`)
-
-2. **Cluster**
-    - 고가용성 (HA) 를 위해 여러 서버를 묶어 특정 서버의 장애를 극복할 수 있도록 구성
-    - Broker 가 증가할 수록 메시지 수신, 전달 처리량을 분산시킬 수 있으므로 확장에 유리
-
-      > 동작중인 다른 Broker 에 영향 없이 확장이 가능하므로, 트래픽 양의 증가에 따른 브로커 증설이 손쉽게 가능
->
-3. **Topic & Partition**
-    - `Topic` 은 메세지를 분류하는 기준이며 N 개의 `Partition` 으로 구성
-        - 1개의 `Leader` 와 0..N 개의 `Follower` 파티션으로 구성해 가용성을 높일 수 있음
-    - `Partition` 은 서로 다른 서버에 분산시킬 수 있기 때문에 수평 확장이 가능
-    - 각 `Topic` 의 메세지 처리순서는 `Partition` 별로 관리됨
-4. **Message**
-    - 카프카에서 취급하는 데이터의 Unit **( ByteArray )**
-5. **Producer & Consumer**
-    - `Producer` - 메세지를 특정 Topic 에 생성
-        - 저장될 파티션을 결정하기 위해 메세지의 Key 해시를 활용하며 Key 가 존재하지 않을 경우, 균형 제어를 위해 Round-Robin 방식으로 메세지를 기록
-        - **Partitioner**
-
-          메세지를 수신할 때, 토픽의 어떤 파티션에 저장될 지 결정하며 Producer 측에서 결정. 메세지에 key 가 없다면 특정  메세지에 key 가 존재한다면 key 의 해시값에 매칭되는 파티션에 데이터를 전송함으로써 항상 같은 파티션에 메세지를 적재해 **순서 보장** 이 가능하도록 처리할 수 있음.
-
-    - `Consumer` - 1개 이상의 Topic 을 구독하며 메세지를 순서대로 읽음
-        - 메세지를 읽을 때마다 파티션 별로 Offset 을 유지해 읽는 메세지의 위치를 추적할 수 있으며 오프셋은 두가지 종류가 존재
-        - `CURRENT-OFFSET`
-
-          컨슈머가 어디까지 처리했는지를 나타내는 offset 이며 메세지를 소비하는 컨슈머가 이를 기록하고 후에 장애가 발생했을 시에 그 뒤부터 이어 처리할 수 있도록 하며 장애 복구 상황을 위해 메세지가 처리된 이후에 반드시 커밋하여야 함
-
-        - 만약 오류가 발생하거나 문제가 발생할 경우, 컨슈머 그룹 차원에서 `--reset-offsets`  옵션을 통해 실패한 시점으로 오프셋을 되돌릴 수 있음
-6. **Consumer Group**
-    - 메세지를 소비할 때, 토픽의 파티션을 매칭하는 그룹 단위이며 N 개의 컨슈머를 포함
-    - 각 파티션은 그룹 내 하나의 컨슈머만 소비할 수 있음
-    - 보통 소비 주체인 Application 단위로 Consumer Group 을 생성, 관리함
-    - 같은 토픽에 대한 소비주체를 늘리고 싶다면, 별도의 컨슈머 그룹을 만들어 토픽을 구독
-
-      ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/0453d381-6abe-4bfa-a4ab-5ef2e4a7ef98/Untitled.png)
-
-
-    > 파티션의 개수가 그룹 내 컨슈머 개수보다 많다면 잉여 파티션의 경우 메세지가 소비될 수 없음을 의미함
-    > 
-    - **( 참고 )** 토픽의 Partition 개수와 Consumer 개수에 따른 소비
-        
-        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/b6f3777a-44b7-4e85-a0b0-3b708ede0291/Untitled.png)
-        
-        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/ba2876f1-df33-45fc-86b5-85eb462e17ba/Untitled.png)
-        
-        ![Untitled](https://prod-files-secure.s3.us-west-2.amazonaws.com/0e86a30d-2307-454b-9469-59a3c805b1b1/74cc946f-e4b3-4f1b-9611-374cd526b410/Untitled.png)
-
-7. **Rebalancing**
-    - Consmuer Group 의 **가용성과 확장성**을 확보해주는 개념
-    - 특정 컨슈머로부터 다른 컨슈머로 파티션의 소유권을 이전시키는 행위
-
-      e.g. `Consumer Group` 내에 Consumer 가 추가될 경우, 특정 파티션의 소유권을 이전시키거나 오류가 생긴 Consumer 로부터 소유권을 회수해 다른 Consumer 에 배정함
-
-
-    <aside>
-    🚫 **( 주의 )** 리밸런싱 중에는 컨슈머가 메세지를 읽을 수 없음.
-    
-    </aside>
-    
-    `Rebalancing Case`
-    
-    1. Consumer Group 내에 새로운 Consumer 추가
-    2. Consumer Group 내의 특정 Consumer 장애로 소비 중단
-    3. Topic 내에 새로운 Partition 추가
-8. **Replication**
-    - Cluster 의 가용성을 보장하는 개념
-    - 각 Partition 의 Replica 를 만들어 백업 및 장애 극복
-        - Leader Replica
-
-          각 파티션은 1개의 리더 Replica를 가진다. 모든 Producer, Consumer 요청은 리더를 통해 처리되게 하여 일관성을 보장한다.
-
-        - Follower Replica
-
-          각 파티션의 리더를 제외한 Replica 이며 단순히 리더의 메세지를 복제해 백업한다. 만일, 파티션의 리더가 중단되는 경우 팔로워 중 하나를 새로운 리더로 선출한다.
-
-          > Leader 의 메세지가 동기화되지 않은 Replica 는 Leader 로 선출될 수 없다.
-          >
-            - `In-Sync Replica (ISR)`
-
-              Leader 의 최신 메세지를 계속 요청하는 Follower
-
-            - `Out-Sync Replica (OSR)`
-
-              특정 기준에 의해 Leader 의 메세지를 백업하는 Follower
-
-
-### Messaging Systems
-
-| 구분 | **Redis (Pub/Sub)** | **RabbitMQ (AMQP)** | **Kafka (Distributed Log)** |
-| --- | --- | --- | --- |
-| **기반 모델** | Pub/Sub | 메시지 큐 (AMQP 프로토콜) | Pub/Sub + 분산 로그 |
-| **메시지 저장** | ❌ 저장 안 함
-(채널 자체에 보관 X) | ✅ 일시 저장
-Queue 에 보관(서버/Queue 종료 시 삭제) | ✅ 저장
-디스크(Log)에 영속 저장(보존 기간까지 유지) |
-| **구독 방식** | 채널 기반, subscriber 없으면 메시지 소실 | Exchange → Queue 매핑 후 Consumer 수신 | Topic → Partition, Consumer Group 으로 분배 |
-| **순서 보장** | 없음 | Queue 단위 순서 보장 | Partition 단위 순서 보장(Key 로 동일 Partition 강제 가능) |
-| **확장성** | 제한적 (단일 서버 메모리 한계) | 브로커 클러스터 구성 가능하지만 Scale-out 제한적 | 고수준 확장성 (Broker/Partition 수평 확장) |
-| **재처리 (Replay)** | ❌ 불가
-(실시간 전달 전용) | ❌ 불가 
-(소비하면 Queue 에서 제거) | ✅ 가능 
-(Consumer Offset 조정으로 과거 이벤트 재소비 가능) |
-| **메시지 유실** | Subscriber 없으면 유실 | Queue 보관 중 서버 장애 시 유실 가능 | 설정(`acks=all`, Replica)으로 내구성 보장 |
-| **활용 사례** | 실시간 알림, 단순 신호 전달 | 트랜잭션 메시징, 업무 프로세스 큐잉 | 로그 수집, 이벤트 소싱, 스트리밍 처리, 대규모 이벤트 파이프라인 |
-
----
-
-## 🚀 Kafka Essentials
-
-> 카프카 활용의 핵심은 **메세지를 잃지 않고, 단 한번만 처리되게 보장할 수 있는가** 입니다.
-**Producer → Broker → Consumer** 전 경로에 걸친 설정과 처리 방식의 조합은 메세지 전달 방식을 결정하는 주요 요소입니다.
-다양한 운영 노하우가 필요하지만, 아래 내용들은 꼭 지켜질 수 있도록 해보세요.
->
-
-### 📦 Message Delivery Semantics
-
-**1️⃣ Producer → Broker**
-
-- 🎯 **어떻게든 발행 (At Least Once)**
-- `Producer` 는 네트워크 지연, 장애가 있어도 메세지를 최소 한 번은 `Broker` 에 기록되도록 보장해야 합니다.
-
-### 📌 Producer 측 패턴: **Transactional Outbox**
-
-- 도메인 데이터 변경(DB write)과 아웃박스 메시지 기록
-
-  ➡ 두 작업을 **하나의 DB 트랜잭션**으로 묶음
-
-- Outbox 테이블에 쌓인 메시지를 별도의 메시지 릴레이/데몬이 Broker로 전달
-- 실패 시 계속 재시도 → 결과적으로 **At Least Once 발행 보장**
-
-<aside>
-💡  **Producer 예시**
-
-- 주문 생성 시
-    1. 주문 DB insert
-    2. Outbox 테이블에 “OrderCreatedEvent” 기록
-
-       ➡ 하나의 트랜잭션
-
-- 백그라운드 워커가 Kafka로 publish
-- 네트워크 불안해도 워커가 다시 재시도 → **`Broker에는 반드시 1번 이상 기록됨`** (최소 1회)
-</aside>
-
-**2️⃣ Consumer ← Broker**
-
-- 🎯 **어떻게든 한 번만 처리 (At Most Once)**
-- `Consumer` 는 같은 메세지가 여러 번 오더라도, 멱등하게 처리하여 최종 결과는 단 한번만 반영되도록 보장해야 합니다.
-
-### 📌 Consumer 측 패턴: **Transactional Inbox / Idempotent Consumer**
-
-- 메시지를 받을 때 **Inbox 테이블에 메시지 ID를 먼저 기록**
-- 이미 처리된 메시지인지 검사
-- 처리한 뒤 Inbox 상태를 완료로 업데이트
-
-  → 메시지가 중복 오더라도 같은 ID는 무시
-
-  → "어떻게 오든 단 한 번만 처리"
-
-
-<aside>
-💡 **Consumer 예시**
-
-- Kafka 메시지 `orderId=12345` 처리
-- Inbox 테이블에서 메시지 ID(또는 aggregateKey) 확인
-    - 있음 → 이미 처리된 메시지 → skip
-    - 없음 → Insert 후 정상 처리
-- 이메일 발송/포인트 적립/주문 상태 변경 등에 안전
-
-> 멱등성이 필요한 작업(마일리지 적립 같은 것)에 매우 중요
->
-</aside>
-
----
-
-### 🛡️ Idempotency (멱등성)
-
-- **왜 필요한가?**
-    - At Least Once 전략에 의해 중복 메시지가 발생할 수 있음
-    - 중복이 오더라도 결과가 변하지 않아야 함
-- **구현 전략**
-    1. `eventId` PK 테이블 → 중복 메시지 무시
-    2. `version` / `updatedAt` 비교 → 최신만 반영
-    3. Upsert → Insert or Update : 중복 메시지에도 동일 결과 유지
-
----
-
-### 🚨 Operation Tips
-
-- **Retry & Backoff**: 일시 장애는 재시도로 복구, 즉시 무한재시도는 금물
-- **DLQ (Dead Letter Queue)**: 반복 실패 메시지는 DLQ로 격리, 운영자가 후처리
-- **Lag 모니터링**: Consumer가 얼마나 뒤쳐져 있는지 체크 (지연·병목 지표)
-- **Partition 순서 보장**: Partition 단위로만 순서가 보장되므로 `partition.key=aggregateId` 설정 필수
-
----
-
-## 🤔 오해
-
-1️⃣ **Kafka는 MQ다**
-
-- ❌ MQ는 메시지를 전달하고 삭제하는 방식
-- ✅ Kafka는 데이터를 삭제하지 않고, 설정된 보존 기간 동안 **모든 메시지를 유지**
-
-2️⃣ **Consumer가 메시지를 소유한다**
-
-- ❌ MQ에서는 메시지를 소비하면 큐에서 제거됨
-- ✅ Kafka에서는 메시지는 여전히 남아 있고, **Consumer Group 단위 Offset**만 이동
-
-3️⃣ **Kafka는 순서를 보장하지 않는다**
-
-- ❌ 전체 Topic 차원의 순서는 보장하지 않음
-- ✅ **Partition 단위**로는 엄격하게 순서를 보장
-
-4️⃣ **Kafka는 유실이 없다**
-
-- ❌ 설정에 따라 다름 (acks=0/1이면 유실 가능)
-- ✅ `acks=all` + `min.insync.replicas` 설정 시 **강력한 내구성 보장**
-
----
-
-<aside>
-📚
-
-**References**
-
-</aside>
-
-| 구분 | 링크 |
-| --- | --- |
-| 🔍 Kafka | [Kafka docs](https://kafka.apache.org/documentation/) |
-| ⚙ Spring Kafka | [Spring for Apache Kafka](https://spring.io/projects/spring-kafka) |
-| 📖 우아콘2023 - 카프카 | [Kafka를 활용한 이벤트 기반 아키텍처 구축](https://www.youtube.com/watch?v=DY3sUeGu74M) |
-| 🌟 라인 - 카프카 활용 | [LINE에서 Kafka를 사용하는 방법](https://engineering.linecorp.com/ko/blog/how-to-use-kafka-in-line-1) |
-
-<aside>
-🌟
-
-**Next Week Preview**
-
-</aside>
-
-> **가장 인기 있는 상품을 보여주고 싶은데, 어떻게 알 수 있을까?**
->
->
->
-> 이번 주차에는 카프카 메세지를 기반으로 안정적으로 이벤트를 발행하고, 처리하기 위한 방안들을 고민해 보았습니다. 이제 우리 **커머스 서비스**에 필요한 기본기는 많이 갖춘 것 같아요.
->
-> 유저가 정말 좋아할 만한 상품은 뭘까요? 앞서 마련한 기반들을 이용해 우리는 실시간으로 상품 랭킹을 만들어 볼 거예요. 차주에는 **랭킹 파이프라인**을 통해 유저가 정말 좋아할 만한 상품들을 진열해 봅시다.
->
\ No newline at end of file
diff --git a/docs/week8/quest.md b/docs/week8/quest.md
deleted file mode 100644
index d1470f450..000000000
--- a/docs/week8/quest.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# 📝 Round 8 Quests
-
----
-
-## 💻 Implementation Quest
-
-> 이번에는 카프카 기반의 **이벤트 파이프라인**을 구현합니다.
-각 이벤트를 외부 시스템과 적절하게 주고 받을 수 있는 구조를 직접 체험해봅니다.
->
-
-<aside>
-🎯
-
-**Must-Have (이번 주에 무조건 가져가야 좋을 것-**무조건 ****하세요**)**
-
-- Kafka
-- Event Pipeline
-- **At Least Once** producer
-- **At Most Once** consumer
-
-**Nice-To-Have (부가적으로 가져가면 좋을 것-**시간이 ****허락하면 ****꼭 ****해보세요**)**
-
-- 상품별 유저 이벤트 집계 테이블 만들기
-- 이외의 이벤트 기반 처리를 고민해보기
-- 메세지 단건 처리 → 배치 처리도 알아보기
-- DLQ 도 고민해보기
-</aside>
-
-### 📋 과제 정보
-
-**Kafka 기반 이벤트 파이프라인을 구현합니다.** (최소 기준)
-
-- `commerce-api` → Kafka 의 방향으로 소통합니다.
-- **Producer** 는 **At Least Once** 보장을 위해 이벤트를 반드시 발행합니다.
-    - **Transactional Outbox Pattern** 을 구현해 보고, 동작을 확인해 봅니다.
-- **Consumer** 는 이벤트를 수취해 아래 기능을 수행합니다.
-    - **집계(Metrics)** : 좋아요 수 / 판매량 / 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert
-
-**토픽 설계** (예시)
-
-- `catalog-events` (상품/재고/좋아요 이벤트, key=productId)
-- `order-events` (주문/결제 이벤트, key=orderId)
-- *각 세부 이벤트 별로 분리하고 싶다면, 분리해도 좋습니다.*
-
-**Producer, Consumer 필수 처리**
-
-- **Producer**
-    - acks=all, idempotence=true 설정
-- **Consumer**
-    - **manual Ack** 처리
-    - `event_handled(event_id PK)` (DB or Redis) 기반의 멱등 처리
-    - `version` 또는 `updated_at` 기준으로 최신 이벤트만 반영
-
-> *왜 이벤트 핸들링 테이블과 로그 테이블을 분리하는 걸까? 에 대해 고민하고 리뷰 포인트에 작성해주세요*
->
-
----
-
-## ✅ Checklist
-
-### 🎾 Producer
-
-- [ ]  도메인(애플리케이션) 이벤트 설계
-- [ ]  Producer 앱에서 도메인 이벤트 발행 (catalog-events, order-events, 등)
-- [ ]  **PartitionKey** 기반의 이벤트 순서 보장
-- [ ]  메세지 발행이 실패했을 경우에 대해 고민해보기
-
-### ⚾ Consumer
-
-- [ ]  Consumer 가 Metrics 집계 처리
-- [ ]  `event_handled` 테이블을 통한 멱등 처리 구현
-- [ ]  재고 소진 시 상품 캐시 갱신
-- [ ]  중복 메세지 재전송 테스트 → 최종 결과가 한 번만 반영되는지 확인
-
----
-
-## ✍️ Technical Writing Quest
-
-> 이번 주에 학습한 내용, 과제 진행을 되돌아보며
-**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다.
->
->
-> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.**
->
-> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요.
->
-
-### 📚 Technical Writing Guide
-
-### ✅ 작성 기준
-
-| 항목 | 설명 |
-| --- | --- |
-| **형식** | 블로그 |
-| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 |
-| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 |
-| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 |
-| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” |
-
----
-
-### ✨ 좋은 톤은 이런 느낌이에요
-
-> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자
->
-
-| 특징 | 예시 |
-| --- | --- |
-| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 |
-| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 |
-| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… |
-
-### ❌ 피해야 할 스타일
-
-| 예시 | 이유 |
-| --- | --- |
-| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 |
-| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 |
-| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 |
-
-### 🎯 Feature Suggestions
-
-- Kafka.. 왜 쓸까? 꼭 필요할까?
-- Kafka는 기본적으로 At Least Once인데, Consumer 멱등 처리가 없으면 무슨 일이 벌어질까?
-- 캐시 무효화와 집계 로직을 한 컨슈머에서 처리하는 것과, 그룹을 나누는 것 중 어떤 차이가 있을까?
-- 이벤트 순서 보장을 위해 key를 aggregateId로 두었는데, 만약 랜덤 키를 썼다면 어떤 문제가 생겼을까?
-- 멱등 처리를 DB 테이블로 할 때와 Redis로 할 때의 차이점은 무엇일까?
\ No newline at end of file
diff --git a/docs/week8/todoList b/docs/week8/todoList
deleted file mode 100644
index 4e4ebc950..000000000
--- a/docs/week8/todoList
+++ /dev/null
@@ -1,136 +0,0 @@
-# Week8 Kafka Event Pipeline 진행 문서 (현황 + 앞으로 할 일)
-
-## TL;DR
-- **commerce-api(Producer)**는 도메인 이벤트를 발생시키고(결제 완료 등), 트랜잭션 커밋 이후 리스너가 **Kafka로 이벤트를 발행**한다.
-- **commerce-streamer(Consumer)**는 Kafka 메시지를 받아 `event_handled`로 **멱등 처리**하고, `product_metrics`에 **집계(view/like/sales)** 를 반영한다.
-- 다음 단계는 **Consumer가 Producer의 Envelope 포맷을 제대로 파싱**하게 맞추고, 이후 **Outbox 패턴으로 Producer를 At-Least-Once로 승격**하는 것이다.
-
----
-
-## 1) 목표 (Week8 과제 관점)
-### Must-Have
-- Kafka 기반 이벤트 파이프라인 구축
-- Producer: 이벤트를 “반드시(At Least Once)” 발행할 수 있게 설계
-- Consumer: 중복 메시지에도 결과는 “한 번만(At Most Once처럼 보이게)” 반영
-- manual ack + 멱등 처리(`event_handled(event_id PK)`)
-- 집계 테이블에 upsert (`product_metrics`: view/like/sales)
-
-### 우리 팀의 결정 사항
-- **판매량(sales_count)은 결제 성공(PG 콜백 SUCCESS) 시점 기준**으로 반영한다.
-- Streamer는 두 토픽을 모두 처리한다:
-  - `catalog-events`: 상품 이벤트
-  - `order-events`: 주문/결제 이벤트
-
----
-
-## 2) 현재까지 구현한 아키텍처 (관심사 분리 포인트 포함)
-
-### 2.1 Producer (commerce-api) 흐름
-#### 핵심 아이디어
-- 유스케이스(Facade)는 “상태 변경”까지만 책임진다.
-- 외부 전송(Kafka/DataPlatform)은 `@TransactionalEventListener(AFTER_COMMIT)`에서 수행한다.
-- 도메인 엔티티는 “이벤트 발생 사실”만 기록(registerEvent).
-
-#### 결제 성공 시 흐름 (정상 시나리오)
-1) PG 콜백 요청 수신 → `PaymentFacade.handlePaymentCallback()`
-2) 결제 엔티티 상태 변경 → `PaymentService.processPaymentResult()` → `PaymentEntity.processCallbackResult()`
-3) 성공이면 엔티티 내부에서 도메인 이벤트 등록:
-   - `PaymentEntity.completeWithEvent()` → `registerEvent(new PaymentCompletedEvent(...))`
-4) 트랜잭션 커밋
-5) 커밋 이후 이벤트 리스너 실행:
-   - (기존) `DataPlatformEventHandler.handlePaymentCompleted(...)` → 데이터 플랫폼 전송
-   - (추가) `PaymentKafkaPublishEventHandler.handlePaymentCompleted(...)` → Kafka 발행
-
-#### Kafka 발행 로직 위치
-- `PaymentKafkaPublishEventHandler` (AFTER_COMMIT + @Async)
-  - 주문/주문항목을 조회하여 판매량 집계에 필요한 payload 구성
-  - Envelope 생성 (`DomainEventEnvelopeFactory`)
-  - Kafka publish (`DomainEventPublisher`)
-
-> NOTE:
-> AFTER_COMMIT 방식은 “DB 실패했는데 Kafka 발행”을 방지하는데 유리하지만,
-> “DB는 커밋됐는데 Kafka 발행 실패”는 완전히 막지 못한다.
-> 이 문제는 Outbox 패턴으로 해결한다(3단계 목표).
-
----
-
-### 2.2 Consumer (commerce-streamer) 흐름
-#### 핵심 아이디어
-- Kafka 메시지는 중복될 수 있다(At Least Once 전제).
-- 따라서 Consumer는 `event_handled(event_id PK)`로 멱등 처리한다.
-- 성공적으로 DB 반영 후에만 manual ack 한다.
-
-#### Streamer에서 만든 구성요소
-- `event_handled` 테이블(엔티티): 이미 처리된 eventId 저장
-- `product_metrics` 테이블(엔티티): product_id 기준 집계
-  - view_count, like_count, sales_count, last_event_at
-- `MetricsService`:
-  - `tryMarkHandled(eventId)` → 중복이면 skip
-  - `incrementView`, `applyLikeDelta`, `addSales` 등 upsert
-- `MetricsKafkaConsumer`:
-  - `catalog-events`: PRODUCT_VIEW, LIKE_ACTION 처리
-  - `order-events`: PAYMENT_SUCCESS 처리 (items 기반 sales_count 증가)
-  - 처리 후 ack
-
----
-
-## 3) 현재 상태에서 확인해야 하는 체크 포인트 (2단계 마감용)
-
-### Producer 체크
-- [ ] PG 콜백 SUCCESS 발생 시 `PaymentCompletedEvent`가 실제로 발행되는지
-- [ ] `PaymentKafkaPublishEventHandler` 로그에 “Kafka 발행 완료”가 찍히는지
-- [ ] `order-events` 토픽에 메시지가 실제로 적재되는지
-- [ ] partition key가 의도대로 `orderId(PK)`인지 (`String.valueOf(order.getId())`)
-
-### Consumer 체크
-- [ ] Streamer가 `order-events` 메시지를 읽는지
-- [ ] eventId가 event_handled에 먼저 저장되는지(멱등)
-- [ ] 성공적으로 sales_count 반영 후 ack 하는지
-- [ ] 같은 eventId를 재전송해도 sales_count가 2번 증가하지 않는지
-
----
-
-## 4) 앞으로 해야 할 작업 (우선순위 순)
-
-## 4.1 (즉시) Producer ↔ Consumer 메시지 계약(Contract) 맞추기
-현재 streamer는 Map 기반/또는 payload 형태에 의존한다.
-Producer가 보내는 Envelope 구조와 Consumer가 파싱하는 구조를 “하나의 계약”으로 고정해야 한다.
-
-- [ ] Envelope 스펙 확정:
-  - eventId, eventType, version, occurredAtEpochMillis, payloadJson
-- [ ] PAYMENT_SUCCESS(v1) payloadJson 스펙 확정:
-  - orderId
-  - items[{productId, quantity}]
-- [ ] Streamer의 `MetricsKafkaConsumer`가 `payloadJson`을 DTO로 파싱하도록 정리
-  - PaymentSuccessPayloadV1 DTO를 streamer 쪽에도 동일 의미로 유지
-
-## 4.2 (테스트) 최소 E2E 확인
-- [ ] PG 콜백 SUCCESS 1회 → Kafka 발행 → Streamer consume → DB sales_count 증가
-- [ ] 동일 메시지(동일 eventId) 2회 → DB는 1회만 반영(event_handled로 차단)
-
-## 4.3 (수요일 핵심) Outbox 패턴으로 Producer 승격 (At Least Once 달성)
-AFTER_COMMIT에서 Kafka send 실패 시 유실 가능성을 제거하기 위해 Outbox로 승격한다.
-
-- [ ] `outbox_event` 테이블 추가 (event_id PK, topic, key, payload_json, status, retry_count, created_at, published_at)
-- [ ] `PaymentKafkaPublishEventHandler`의 “Kafka send”를 “Outbox insert”로 변경
-- [ ] Outbox Relay(스케줄러/워커) 구현:
-  - READY 조회 → Kafka publish → SENT 업데이트
-  - 실패 시 retry/backoff
-- [ ] 중복 발행 가능성은 consumer 멱등(event_handled)로 처리
-
-## 4.4 (목요일) 최신성/정합성 보강 + 문서/PR 정리
-- [ ] product_metrics.last_event_at 기반으로 “오래된 이벤트 무시” 로직 반영(필요 시)
-- [ ] 실패/재시도 전략(DLQ는 Nice-to-have) 간단 정리
-- [ ] PR Review Points 작성
-  - 왜 sales_count는 결제 성공 기준인가?
-  - 왜 도메인 이벤트 + AFTER_COMMIT + outbox로 구성했는가?
-  - 왜 streamer는 event_handled로 멱등 처리하는가?
-
----
-
-## 5) 운영/리스크 메모 (짧게)
-- AFTER_COMMIT + Async는 “DB 정합성”에는 유리하지만 “발행 보장”은 약함 → Outbox로 해결
-- Consumer는 At Least Once를 전제로 설계해야 함 → event_handled 멱등은 필수
-- 이벤트 타입은 클래스명 기반이 아니라 “계약된 eventType + version”으로 관리하는 것이 안전
-
----
\ No newline at end of file
diff --git a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
index 9afea602b..6b8dd5557 100644
--- a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
+++ b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
@@ -202,8 +202,8 @@ public void updateProductStock(Long productId, Integer newStock) {
                 String updatedValue = objectMapper.writeValueAsString(productData);
                 
                 // 기존 TTL 유지하면서 업데이트
-                Long ttl = redisTemplate.getExpire(key);
-                if (ttl != null && ttl > 0) {
+                long ttl = redisTemplate.getExpire(key);
+                if (ttl > 0) {
                     redisTemplate.opsForValue().set(key, updatedValue, ttl, TimeUnit.SECONDS);
                 } else {
                     // TTL이 없거나 만료된 경우 기본 30분으로 설정
diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
index 6341ef95a..5b5e9423f 100644
--- a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
+++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java
@@ -127,23 +127,4 @@ private String generateSortString(Sort sort) {
 
         return sortJoiner.toString();
     }
-
-    /**
-     * 상품명을 캐시 키에 안전한 형태로 변환
-     */
-    public String sanitizeProductName(String productName) {
-        if (productName == null || productName.trim().isEmpty()) {
-            return NULL_VALUE;
-        }
-
-        String sanitized = productName.trim()
-                .replaceAll("\\s+", "_")
-                .replaceAll("[^a-zA-Z0-9가-힣_]", "");
-
-        if (sanitized.length() > 50) {
-            sanitized = sanitized.substring(0, 50);
-        }
-
-        return sanitized.isEmpty() ? NULL_VALUE : sanitized;
-    }
 }

From 4381938047cd5b97ad3194685cb45a44765e78dc Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:34:55 +0900
Subject: [PATCH 22/28] =?UTF-8?q?style(redis):=20=EC=BD=94=EB=93=9C=20?=
 =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EB=B6=88=ED=95=84?=
 =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EA=B3=B5=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 여러 파일에서 불필요한 공백 및 줄바꿈 제거
- 코드 일관성을 위한 포맷팅 개선
---
 .../application/order/OrderEventHandler.java  | 25 +++---
 .../event/outbox/OutboxRelayScheduler.java    | 10 +--
 .../domain/event/outbox/OutboxRepository.java |  2 +-
 .../domain/product/ProductCacheService.java   |  2 -
 .../outbox/OutboxEventJpaRepository.java      | 13 ++-
 .../loopers/domain/event/EventRepository.java |  4 +-
 .../domain/metrics/MetricsService.java        |  8 +-
 .../domain/metrics/ProductMetricsEntity.java  |  4 +-
 .../metrics/ProductMetricsRepository.java     |  2 +-
 .../domain/metrics/ProductMetricsService.java |  3 +-
 .../metrics/repository/MetricsRepository.java |  8 +-
 .../cache/ProductCacheService.java            |  9 +-
 .../event/EventDeserializer.java              | 18 ++--
 .../event/EventRepositoryImpl.java            |  4 +-
 .../payloads/StockDepletedPayloadV1.java      |  4 +-
 .../metrics/MetricsRepositoryImpl.java        | 34 ++++----
 .../metrics/ProductMetricsRepositoryImpl.java |  2 +-
 .../consumer/MetricsKafkaConsumer.java        | 54 ++++++------
 ...MetricsEventProcessingIntegrationTest.java | 77 ++++++++---------
 .../consumer/MetricsKafkaConsumerTest.java    | 86 +++++++++----------
 apps/pg-simulator/README.md                   |  5 ++
 apps/pg-simulator/build.gradle.kts            |  8 +-
 .../com/loopers/PaymentGatewayApplication.kt  |  2 +-
 .../payment/PaymentApplicationService.kt      |  7 +-
 .../com/loopers/domain/payment/Payment.kt     |  8 +-
 .../domain/payment/TransactionKeyGenerator.kt |  2 +-
 .../interfaces/api/ApiControllerAdvice.kt     |  4 -
 .../UserInfoArgumentResolver.kt               |  2 +-
 .../interfaces/api/payment/PaymentApi.kt      | 10 +--
 .../loopers/config/jpa/DataSourceConfig.java  |  5 +-
 .../loopers/config/jpa/QueryDslConfig.java    |  6 +-
 .../java/com/loopers/domain/BaseEntity.java   | 15 ++--
 .../MySqlTestContainersConfig.java            | 26 +++---
 .../com/loopers/utils/DatabaseCleanUp.java    | 19 ++--
 .../com/loopers/confg/kafka/KafkaConfig.java  | 11 +--
 modules/kafka/src/main/resources/kafka.yml    | 26 +++---
 .../com/loopers/cache/BaseCacheService.java   | 16 ++--
 .../com/loopers/config/redis/RedisConfig.java | 26 +++---
 .../loopers/config/redis/RedisNodeInfo.java   |  3 +-
 .../loopers/config/redis/RedisProperties.java |  7 +-
 .../RedisTestContainersConfig.java            |  3 +-
 .../java/com/loopers/utils/RedisCleanUp.java  |  2 +-
 .../loopers/config/jackson/JacksonConfig.java | 31 +++----
 .../appenders/json-console-appender.xml       |  4 +-
 .../appenders/plain-console-appender.xml      |  4 +-
 .../resources/appenders/slack-appender.xml    |  3 +-
 46 files changed, 301 insertions(+), 323 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
index 38146e556..4326d9848 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java
@@ -58,11 +58,11 @@ private void executeSafely(String action, Long orderId, Long userId, Runnable ta
     public void handlePaymentCompleted(PaymentCompletedEvent event) {
         Long orderId = event.orderNumber();
         Long userId = event.userId();
-        
+
         // 1. 주문 확정 처리
         executeSafely("PAYMENT_COMPLETED", orderId, userId,
                 () -> orderFacade.confirmOrderByPayment(orderId, userId));
-        
+
         // 2. Kafka 이벤트용 Outbox 저장
         try {
             savePaymentSuccessToOutbox(event);
@@ -114,10 +114,10 @@ private void savePaymentSuccessToOutbox(PaymentCompletedEvent event) {
         // 상품별로 개별 이벤트 생성 및 일괄 저장 (원자성 보장)
         try {
             savePaymentSuccessEventsAtomically(order, orderItems);
-            log.info("결제 완료 이벤트 Outbox 일괄 저장 완료 - orderId={}, itemCount={}", 
+            log.info("결제 완료 이벤트 Outbox 일괄 저장 완료 - orderId={}, itemCount={}",
                     order.getId(), orderItems.size());
         } catch (Exception e) {
-            log.error("결제 완료 이벤트 Outbox 저장 실패 - orderId={}, itemCount={}", 
+            log.error("결제 완료 이벤트 Outbox 저장 실패 - orderId={}, itemCount={}",
                     order.getId(), orderItems.size(), e);
             throw e; // 상위로 전파하여 전체 트랜잭션 롤백
         }
@@ -135,7 +135,7 @@ private void savePaymentSuccessEventsAtomically(OrderEntity order, List<OrderIte
         // 각 상품별로 개별 이벤트 생성
         for (int i = 0; i < orderItems.size(); i++) {
             final OrderItemEntity orderItem = orderItems.get(i);
-            
+
             // 상품별 개별 페이로드 생성 (주문 컨텍스트 정보 포함)
             final PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
                     order.getId(),              // 주문 내부 ID (PK)
@@ -149,8 +149,8 @@ private void savePaymentSuccessEventsAtomically(OrderEntity order, List<OrderIte
 
             // 각 상품별로 고유한 이벤트 ID와 타임스탬프 생성 (순서 보장)
             final var envelope = envelopeFactory.create(
-                    EventTypes.PAYMENT_SUCCESS, 
-                    EventVersions.V1, 
+                    EventTypes.PAYMENT_SUCCESS,
+                    EventVersions.V1,
                     payload
             );
 
@@ -168,16 +168,17 @@ private void savePaymentSuccessEventsAtomically(OrderEntity order, List<OrderIte
             );
 
             outboxEvents.add(outboxEvent);
-            
-            log.debug("상품별 결제 완료 이벤트 생성 - orderId={}, orderNumber={}, productId={}, quantity={}, unitPrice={}, totalPrice={}, eventId={}", 
-                    order.getId(), order.getOrderNumber(), orderItem.getProductId(), 
+
+            log.debug(
+                    "상품별 결제 완료 이벤트 생성 - orderId={}, orderNumber={}, productId={}, quantity={}, unitPrice={}, totalPrice={}, eventId={}",
+                    order.getId(), order.getOrderNumber(), orderItem.getProductId(),
                     orderItem.getQuantity(), orderItem.getUnitPrice(), orderItem.getTotalPrice(), envelope.eventId());
         }
 
         // 모든 이벤트를 일괄 저장 (원자성 보장)
         outboxRepository.saveAll(outboxEvents);
-        
-        log.info("상품별 결제 완료 이벤트 일괄 저장 완료 - orderId={}, orderNumber={}, eventCount={}", 
+
+        log.info("상품별 결제 완료 이벤트 일괄 저장 완료 - orderId={}, orderNumber={}, eventCount={}",
                 order.getId(), order.getOrderNumber(), outboxEvents.size());
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
index edb5c605b..cbbf339ff 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRelayScheduler.java
@@ -41,7 +41,7 @@ public void processReadyEvents() {
             return;
         }
 
-        log.info("READY 이벤트 배치 처리 시작 - 처리할 이벤트 수: {} (배치크기: {})", 
+        log.info("READY 이벤트 배치 처리 시작 - 처리할 이벤트 수: {} (배치크기: {})",
                 readyEvents.size(), BULK_COUNT);
 
         int successCount = 0;
@@ -51,14 +51,14 @@ public void processReadyEvents() {
             boolean isSuccess = outboxService.processEvent(event);
             if (isSuccess) {
                 successCount++;
-                log.debug("이벤트 발송 성공 - eventId: {}, type: {}", 
-                         event.getEventId(), event.getEventType());
+                log.debug("이벤트 발송 성공 - eventId: {}, type: {}",
+                        event.getEventId(), event.getEventType());
             } else {
                 failCount++;
             }
         }
 
-        log.info("READY 이벤트 배치 처리 완료 - 성공: {}, 실패: {} (배치크기: {})", 
+        log.info("READY 이벤트 배치 처리 완료 - 성공: {}, 실패: {} (배치크기: {})",
                 successCount, failCount, BULK_COUNT);
     }
 
@@ -76,7 +76,7 @@ public void retryFailedEvents() {
             return; // 로그 없이 조용히 리턴
         }
 
-        log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {} (배치크기: {})", 
+        log.info("실패 이벤트 재시도 시작 - 처리할 이벤트 수: {} (배치크기: {})",
                 failedEvents.size(), BULK_COUNT);
 
         int retrySuccessCount = 0;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
index 5c7f2547b..d82062bc3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/outbox/OutboxRepository.java
@@ -35,7 +35,7 @@ List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc
             OutboxStatus status, int maxRetryCount, int limit);
 
     OutboxEventEntity save(OutboxEventEntity ready);
-    
+
     /**
      * 여러 이벤트를 일괄 저장 (원자성 보장)
      */
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
index ee3e71f6f..e12a4f347 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheService.java
@@ -61,8 +61,6 @@ public void cacheProductDetail(Long productId, ProductDetailInfo productDetail)
     }
 
 
-
-
     public Optional<ProductDetailInfo> getProductDetailFromCache(Long productId) {
         try {
             String key = cacheKeyGenerator.generateProductDetailKey(productId);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
index da35c003c..a30668db6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/outbox/OutboxEventJpaRepository.java
@@ -3,13 +3,12 @@
 import java.util.List;
 
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 import com.loopers.domain.event.outbox.OutboxEventEntity;
 import com.loopers.domain.event.outbox.OutboxStatus;
 
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-
 public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntity, String> {
 
     List<OutboxEventEntity> findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status);
@@ -17,8 +16,8 @@ public interface OutboxEventJpaRepository extends JpaRepository<OutboxEventEntit
     /**
      * 지정된 개수만큼 상태별 이벤트 조회 (배치 크기 조절 가능)
      */
-    @Query(value = "SELECT * FROM outbox_event WHERE status = :status ORDER BY created_at ASC LIMIT :limit", 
-           nativeQuery = true)
+    @Query(value = "SELECT * FROM outbox_event WHERE status = :status ORDER BY created_at ASC LIMIT :limit",
+            nativeQuery = true)
     List<OutboxEventEntity> findTopNByStatusOrderByCreatedAtAsc(
             @Param("status") String status, @Param("limit") int limit);
 
@@ -35,8 +34,8 @@ List<OutboxEventEntity> findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAs
     /**
      * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능)
      */
-    @Query(value = "SELECT * FROM outbox_event WHERE status = :status AND retry_count < :maxRetryCount ORDER BY created_at ASC LIMIT :limit", 
-           nativeQuery = true)
+    @Query(value = "SELECT * FROM outbox_event WHERE status = :status AND retry_count < :maxRetryCount ORDER BY created_at ASC LIMIT :limit",
+            nativeQuery = true)
     List<OutboxEventEntity> findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc(
             @Param("status") String status, @Param("maxRetryCount") int maxRetryCount, @Param("limit") int limit);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
index 7aada55e4..0d8dfb30a 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
@@ -7,8 +7,8 @@
  */
 public interface EventRepository {
     EventEntity save(EventEntity eventEntity);
-    
+
     void deleteAll();
-    
+
     boolean existsById(String eventId);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
index 985106fc2..0171318e5 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java
@@ -96,7 +96,6 @@ public void incrementView(Long productId, long occurredAtEpochMillis) {
     }
 
 
-
     /**
      * 좋아요 수 변경 (메모리 락 적용)
      */
@@ -122,7 +121,6 @@ public void applyLikeDelta(final Long productId, final int delta, long occurredA
     }
 
 
-
     /**
      * 판매량 증가 (메모리 락 적용)
      */
@@ -187,17 +185,17 @@ public void cleanupUnusedLocks() {
     public void cleanupProcessedEvents() {
         if (processedEvents.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) {
             log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEvents.size());
-            
+
             // 오래된 이벤트 캐시 절반 정도 제거 (LRU 방식은 아니지만 메모리 절약)
             int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2;
             int currentSize = processedEvents.size();
             int toRemove = currentSize - targetSize;
-            
+
             processedEvents.entrySet().stream()
                     .limit(toRemove)
                     .map(Map.Entry::getKey)
                     .forEach(processedEvents::remove);
-            
+
             log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEvents.size());
         }
     }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
index ef29205ab..a07933520 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
@@ -53,12 +53,12 @@ public static ProductMetricsEntity create(final Long productId) {
         return new ProductMetricsEntity(productId);
     }
 
-    
+
     public void incrementView(ZonedDateTime eventTime) {
         this.viewCount += 1;
         this.lastEventAt = eventTime;
     }
-    
+
     public void applyLikeDelta(final int delta, ZonedDateTime eventTime) {
         final long next = this.likeCount + delta;
 
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
index 947a50632..ae4bfb12d 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -11,6 +11,6 @@ public interface ProductMetricsRepository {
     ProductMetricsEntity save(ProductMetricsEntity metrics);
 
     Optional<ProductMetricsEntity> findById(Long productId);
-    
+
     void deleteAll();
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
index c3755f6d3..759c3fc43 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
@@ -75,7 +75,8 @@ public void addSalesWithTransaction(Long productId, int quantity, long occurredA
      * 주로 캐시 갱신을 담당합니다.
      */
     @Transactional
-    public void handleStockDepletedWithTransaction(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) {
+    public void handleStockDepletedWithTransaction(Long productId, Long brandId, Integer remainingStock,
+                                                   long occurredAtEpochMillis) {
         try {
             // 재고 소진 시 캐시 갱신 처리
             metricsRepository.handleStockDepleted(productId, brandId, remainingStock, occurredAtEpochMillis);
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
index 58fae6c24..7837bddaf 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java
@@ -9,22 +9,22 @@
  * @since 2025. 12. 19.
  */
 public interface MetricsRepository {
-    
+
     /**
      * 조회수 증가
      */
     void incrementView(Long productId, long occurredAtEpochMillis);
-    
+
     /**
      * 좋아요 수 변경 (증가/감소)
      */
     void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis);
-    
+
     /**
      * 판매량 증가
      */
     void addSales(Long productId, int quantity, long occurredAtEpochMillis);
-    
+
     /**
      * 재고 소진 이벤트 처리 (캐시 갱신 중심)
      */
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
index 11e82aceb..8133f922c 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/cache/ProductCacheService.java
@@ -20,23 +20,23 @@
 @RequiredArgsConstructor
 @Slf4j
 public class ProductCacheService {
-    
+
     private final BaseCacheService baseCacheService;
-    
+
     /**
      * 특정 상품의 캐시를 무효화
      */
     public void evictProductCache(Long productId) {
         baseCacheService.evictProductCache(productId);
     }
-    
+
     /**
      * 상품 목록 관련 캐시들을 무효화
      */
     public void evictProductListCaches() {
         baseCacheService.evictProductListCaches();
     }
-    
+
     /**
      * 판매량 변화 시 호출 - 인기 상품 순위가 변경될 수 있음
      */
@@ -45,7 +45,6 @@ public void onSalesCountChanged(Long productId) {
     }
 
 
-
     /**
      * 브랜드별 상품 목록 캐시 무효화
      */
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
index 2c392e5f5..81ea5cd57 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventDeserializer.java
@@ -1,14 +1,16 @@
 package com.loopers.infrastructure.event;
 
+import org.springframework.stereotype.Component;
+
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1;
 import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1;
 import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1;
 import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1;
+
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
 
 /**
  * 이벤트 역직렬화 유틸리티
@@ -20,9 +22,9 @@
 @RequiredArgsConstructor
 @Slf4j
 public class EventDeserializer {
-    
+
     private final ObjectMapper objectMapper;
-    
+
     public DomainEventEnvelope deserializeEnvelope(Object kafkaValue) {
         try {
             if (kafkaValue instanceof String json) {
@@ -36,7 +38,7 @@ public DomainEventEnvelope deserializeEnvelope(Object kafkaValue) {
             return null;
         }
     }
-    
+
     public ProductViewPayloadV1 deserializeProductView(String payloadJson) {
         try {
             return objectMapper.readValue(payloadJson, ProductViewPayloadV1.class);
@@ -45,7 +47,7 @@ public ProductViewPayloadV1 deserializeProductView(String payloadJson) {
             return null;
         }
     }
-    
+
     public LikeActionPayloadV1 deserializeLikeAction(String payloadJson) {
         try {
             return objectMapper.readValue(payloadJson, LikeActionPayloadV1.class);
@@ -54,7 +56,7 @@ public LikeActionPayloadV1 deserializeLikeAction(String payloadJson) {
             return null;
         }
     }
-    
+
     public PaymentSuccessPayloadV1 deserializePaymentSuccess(String payloadJson) {
         try {
             return objectMapper.readValue(payloadJson, PaymentSuccessPayloadV1.class);
@@ -63,7 +65,7 @@ public PaymentSuccessPayloadV1 deserializePaymentSuccess(String payloadJson) {
             return null;
         }
     }
-    
+
     public StockDepletedPayloadV1 deserializeStockDepleted(String payloadJson) {
         try {
             return objectMapper.readValue(payloadJson, StockDepletedPayloadV1.class);
@@ -72,4 +74,4 @@ public StockDepletedPayloadV1 deserializeStockDepleted(String payloadJson) {
             return null;
         }
     }
-}
\ No newline at end of file
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
index ec6aecfcf..a9d47cf1c 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
@@ -21,12 +21,12 @@ public class EventRepositoryImpl implements EventRepository {
     public EventEntity save(EventEntity eventEntity) {
         return eventJpaRepository.save(eventEntity);
     }
-    
+
     @Override
     public void deleteAll() {
         eventJpaRepository.deleteAll();
     }
-    
+
     @Override
     public boolean existsById(String eventId) {
         return eventJpaRepository.existsById(eventId);
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
index 9340047b8..b9338d10e 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/payloads/StockDepletedPayloadV1.java
@@ -2,7 +2,7 @@
 
 /**
  * 재고 소진 이벤트 페이로드 V1
- * 
+ *
  * @author hyunjikoh
  * @since 2025. 12. 19.
  */
@@ -13,4 +13,4 @@ public record StockDepletedPayloadV1(
         Integer remainingStock, // 남은 재고 (0이어야 함)
         Long warehouseId        // 창고 ID (선택적)
 ) {
-}
\ No newline at end of file
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
index 614a79998..7296bc600 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java
@@ -35,9 +35,9 @@ public class MetricsRepositoryImpl implements MetricsRepository {
     @Override
     public void incrementView(Long productId, long occurredAtEpochMillis) {
         ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
-        
+
         Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
-        
+
         long newViewCount;
         if (existingMetrics.isPresent()) {
             ProductMetricsEntity metrics = existingMetrics.get();
@@ -51,7 +51,7 @@ public void incrementView(Long productId, long occurredAtEpochMillis) {
             productMetricsRepository.save(newMetrics);
             newViewCount = newMetrics.getViewCount();
         }
-        
+
 
         log.debug("조회수 증가 완료: productId={}, eventTime={}", productId, eventTime);
     }
@@ -59,9 +59,9 @@ public void incrementView(Long productId, long occurredAtEpochMillis) {
     @Override
     public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) {
         ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
-        
+
         Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
-        
+
         if (existingMetrics.isPresent()) {
             ProductMetricsEntity metrics = existingMetrics.get();
             metrics.applyLikeDelta(delta, eventTime);
@@ -77,9 +77,9 @@ public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis
                 return; // 캐시 무효화 불필요
             }
         }
-        
+
         // 좋아요는 캐시 무효화하지 않음 (실시간 반영 불필요)
-        
+
         log.debug("좋아요 수 변경 완료: productId={}, delta={}, eventTime={}", productId, delta, eventTime);
     }
 
@@ -89,11 +89,11 @@ public void addSales(Long productId, int quantity, long occurredAtEpochMillis) {
             log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity);
             return;
         }
-        
+
         ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis);
-        
+
         Optional<ProductMetricsEntity> existingMetrics = productMetricsRepository.findById(productId);
-        
+
         if (existingMetrics.isPresent()) {
             ProductMetricsEntity metrics = existingMetrics.get();
             metrics.addSales(quantity, eventTime);
@@ -104,10 +104,10 @@ public void addSales(Long productId, int quantity, long occurredAtEpochMillis) {
             newMetrics.addSales(quantity, eventTime);
             productMetricsRepository.save(newMetrics);
         }
-        
+
         // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향)
         productCacheService.onSalesCountChanged(productId);
-        
+
         log.debug("판매량 증가 완료: productId={}, quantity={}, eventTime={}", productId, quantity, eventTime);
     }
 
@@ -115,12 +115,12 @@ public void addSales(Long productId, int quantity, long occurredAtEpochMillis) {
     public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) {
         // 재고 소진 이벤트 처리
         // 메트릭 자체는 업데이트하지 않고 캐시만 처리
-        
+
         // 상품 상세 캐시의 재고 정보만 갱신 (빠른 응답을 위해)
         int stockToUpdate = (remainingStock != null) ? remainingStock : 0;
         productCacheService.updateProductStock(productId, stockToUpdate);
-        
-        log.info("재고 소진 상세 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", 
+
+        log.info("재고 소진 상세 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}",
                 productId, brandId, stockToUpdate);
     }
 
@@ -129,8 +129,8 @@ public void handleStockDepleted(Long productId, Long brandId, Integer remainingS
      */
     private ZonedDateTime convertToZonedDateTime(long epochMillis) {
         return ZonedDateTime.ofInstant(
-            Instant.ofEpochMilli(epochMillis), 
-            ZoneId.systemDefault()
+                Instant.ofEpochMilli(epochMillis),
+                ZoneId.systemDefault()
         );
     }
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
index 16df85776..4894e575e 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -28,7 +28,7 @@ public ProductMetricsEntity save(ProductMetricsEntity metrics) {
     public Optional<ProductMetricsEntity> findById(Long productId) {
         return productMetricsJpaRepository.findById(productId);
     }
-    
+
     @Override
     public void deleteAll() {
         productMetricsJpaRepository.deleteAll();
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
index 40bbede16..d5d62dbe7 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java
@@ -40,9 +40,9 @@ public class MetricsKafkaConsumer {
     public void onCatalogEvents(
             List<ConsumerRecord<Object, Object>> records,
             Acknowledgment ack) {
-        
+
         log.debug("Processing {} catalog events", records.size());
-        
+
         for (ConsumerRecord<Object, Object> record : records) {
             try {
                 processCatalogEvent(record);
@@ -65,9 +65,9 @@ public void onOrderEvents(
             final List<ConsumerRecord<Object, Object>> records,
             final Acknowledgment ack
     ) {
-        
+
         log.debug("Processing {} order events", records.size());
-        
+
         for (ConsumerRecord<Object, Object> record : records) {
             try {
                 processOrderEvent(record);
@@ -80,7 +80,7 @@ public void onOrderEvents(
         ack.acknowledge();
         log.debug("Acknowledged {} order events", records.size());
     }
-    
+
     private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
         final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value());
         if (envelope == null || envelope.eventId() == null) {
@@ -90,8 +90,8 @@ private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
 
         // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시)
         if (isOldEvent(envelope.occurredAtEpochMillis())) {
-            log.debug("Ignoring old event: eventId={}, occurredAt={}", 
-                     envelope.eventId(), envelope.occurredAtEpochMillis());
+            log.debug("Ignoring old event: eventId={}, occurredAt={}",
+                    envelope.eventId(), envelope.occurredAtEpochMillis());
             // 멱등성 테이블에는 기록하되 비즈니스 로직은 처리하지 않음
             metricsService.tryMarkHandled(envelope.eventId());
             return;
@@ -112,7 +112,7 @@ private void processCatalogEvent(ConsumerRecord<Object, Object> record) {
             default -> log.debug("Unhandled catalog event type: {}", envelope.eventType());
         }
     }
-    
+
     private void processOrderEvent(ConsumerRecord<Object, Object> record) {
         final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value());
         if (envelope == null || envelope.eventId() == null) {
@@ -122,8 +122,8 @@ private void processOrderEvent(ConsumerRecord<Object, Object> record) {
 
         // 과거 이벤트 필터링
         if (isOldEvent(envelope.occurredAtEpochMillis())) {
-            log.debug("Ignoring old event: eventId={}, occurredAt={}", 
-                     envelope.eventId(), envelope.occurredAtEpochMillis());
+            log.debug("Ignoring old event: eventId={}, occurredAt={}",
+                    envelope.eventId(), envelope.occurredAtEpochMillis());
             metricsService.tryMarkHandled(envelope.eventId());
             return;
         }
@@ -142,46 +142,47 @@ private void processOrderEvent(ConsumerRecord<Object, Object> record) {
             log.debug("Unhandled order event type: {}", envelope.eventType());
         }
     }
-    
+
     private void handleProductView(DomainEventEnvelope envelope) {
         final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson());
         if (payload == null || payload.productId() == null) {
             log.warn("Invalid ProductView payload: {}", envelope.payloadJson());
             return;
         }
-        
+
         metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis());
         log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId());
     }
-    
+
     private void handleLikeAction(DomainEventEnvelope envelope) {
         final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson());
         if (payload == null || payload.productId() == null || payload.action() == null) {
             log.warn("Invalid LikeAction payload: {}", envelope.payloadJson());
             return;
         }
-        
+
         final int delta = "LIKE".equals(payload.action()) ? 1 : -1;
         metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis());
         log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action());
     }
-    
+
     private void handlePaymentSuccess(DomainEventEnvelope envelope) {
         final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson());
         if (payload == null) {
             log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson());
             return;
         }
-        
+
         // 새로운 구조: 상품별 개별 이벤트 처리
         if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) {
             metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis());
-            
-            log.debug("Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", 
-                     payload.orderId(), payload.orderNumber(), payload.userId(),
-                     payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice());
+
+            log.debug(
+                    "Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}",
+                    payload.orderId(), payload.orderNumber(), payload.userId(),
+                    payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice());
         } else {
-            log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", 
+            log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}",
                     payload.productId(), payload.quantity());
         }
     }
@@ -192,11 +193,12 @@ private void handleStockDepleted(DomainEventEnvelope envelope) {
             log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson());
             return;
         }
-        
+
         // 재고 소진 이벤트 처리 - remainingStock 정보 전달
-        metricsService.handleStockDepleted(payload.productId(), payload.brandId(), payload.remainingStock(), envelope.occurredAtEpochMillis());
-        
-        log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", 
+        metricsService.handleStockDepleted(payload.productId(), payload.brandId(), payload.remainingStock(),
+                envelope.occurredAtEpochMillis());
+
+        log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}",
                 payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock());
     }
 
@@ -207,7 +209,7 @@ private boolean isOldEvent(long occurredAtEpochMillis) {
         long currentTime = System.currentTimeMillis();
         long eventAge = currentTime - occurredAtEpochMillis;
         long oneHourInMillis = 60 * 60 * 1000; // 1시간
-        
+
         return eventAge > oneHourInMillis;
     }
 }
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
index de7df18e1..a13c5bd6a 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
@@ -2,9 +2,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.awaitility.Awaitility.await;
-
 import java.time.Duration;
-import java.util.List;
 import java.util.Optional;
 
 import org.junit.jupiter.api.BeforeEach;
@@ -13,7 +11,6 @@
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.kafka.core.KafkaTemplate;
-import org.springframework.test.context.ActiveProfiles;
 import org.springframework.transaction.annotation.Transactional;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -36,16 +33,16 @@ class MetricsEventProcessingIntegrationTest {
 
     @Autowired
     private KafkaTemplate<Object, Object> kafkaTemplate;
-    
+
     @Autowired
     private ProductMetricsRepository productMetricsRepository;
-    
+
     @Autowired
     private EventRepository eventRepository;
-    
+
     @Autowired
     private ObjectMapper objectMapper;
-    
+
     @BeforeEach
     @Transactional
     void setUp() {
@@ -53,17 +50,17 @@ void setUp() {
         productMetricsRepository.deleteAll();
         eventRepository.deleteAll();
     }
-    
+
     @Test
     @DisplayName("PRODUCT_VIEW 이벤트가 정상적으로 처리되어 조회수가 증가해야 한다")
     void shouldIncrementViewCountOnProductViewEvent() throws Exception {
         // Given
         Long productId = 1L;
         String eventId = "product-view-test-" + System.currentTimeMillis();
-        
+
         ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L);
         String payloadJson = objectMapper.writeValueAsString(payload);
-        
+
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
                 "PRODUCT_VIEW",
@@ -71,10 +68,10 @@ void shouldIncrementViewCountOnProductViewEvent() throws Exception {
                 System.currentTimeMillis(),
                 payloadJson
         );
-        
+
         // When
         kafkaTemplate.send("catalog-events", envelope);
-        
+
         // Then
         await().atMost(Duration.ofSeconds(10))
                 .untilAsserted(() -> {
@@ -83,21 +80,21 @@ void shouldIncrementViewCountOnProductViewEvent() throws Exception {
                     assertThat(metrics.get().getViewCount()).isEqualTo(1L);
                     assertThat(metrics.get().getLastEventAt()).isNotNull();
                 });
-        
+
         // 멱등성 확인 - 이벤트가 처리되었음을 확인
         assertThat(eventRepository.existsById(eventId)).isTrue();
     }
-    
+
     @Test
     @DisplayName("동일한 이벤트 ID로 중복 전송해도 한 번만 처리되어야 한다")
     void shouldProcessDuplicateEventOnlyOnce() throws Exception {
         // Given
         Long productId = 2L;
         String eventId = "duplicate-test-" + System.currentTimeMillis();
-        
+
         ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 200L);
         String payloadJson = objectMapper.writeValueAsString(payload);
-        
+
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
                 "PRODUCT_VIEW",
@@ -105,11 +102,11 @@ void shouldProcessDuplicateEventOnlyOnce() throws Exception {
                 System.currentTimeMillis(),
                 payloadJson
         );
-        
+
         // When - 같은 이벤트를 두 번 전송
         kafkaTemplate.send("catalog-events", envelope);
         kafkaTemplate.send("catalog-events", envelope);
-        
+
         // Then - 조회수는 1만 증가해야 함
         await().atMost(Duration.ofSeconds(10))
                 .untilAsserted(() -> {
@@ -118,14 +115,14 @@ void shouldProcessDuplicateEventOnlyOnce() throws Exception {
                     assertThat(metrics.get().getViewCount()).isEqualTo(1L);
                 });
     }
-    
+
     @Test
     @DisplayName("PAYMENT_SUCCESS 이벤트가 정상적으로 처리되어 판매수가 증가해야 한다")
     void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception {
         // Given
         Long productId = 3L;
         String eventId = "payment-success-test-" + System.currentTimeMillis();
-        
+
         // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트)
         PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
                 12345L,    // orderId
@@ -137,7 +134,7 @@ void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception {
                 java.math.BigDecimal.valueOf(2000)  // totalPrice
         );
         String payloadJson = objectMapper.writeValueAsString(payload);
-        
+
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
                 "PAYMENT_SUCCESS",
@@ -145,33 +142,33 @@ void shouldIncrementSalesCountOnPaymentSuccessEvent() throws Exception {
                 System.currentTimeMillis(),
                 payloadJson
         );
-        
+
         // When
         kafkaTemplate.send("order-events", envelope);
-        
+
         // Then
         await().atMost(Duration.ofSeconds(10))
                 .untilAsserted(() -> {
                     Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
-                    
+
                     assertThat(metrics).isPresent();
                     assertThat(metrics.get().getSalesCount()).isEqualTo(2L);
                     assertThat(metrics.get().getOrderCount()).isEqualTo(1L); // 주문 건수도 확인
                 });
     }
-    
+
     @Test
     @DisplayName("과거 이벤트는 무시되어야 한다")
     void shouldIgnoreOldEvents() throws Exception {
         // Given
         Long productId = 5L;
         long currentTime = System.currentTimeMillis();
-        
+
         // 먼저 최신 이벤트를 처리
         String recentEventId = "recent-event-" + currentTime;
         ProductViewPayloadV1 recentPayload = new ProductViewPayloadV1(productId, 100L);
         String recentPayloadJson = objectMapper.writeValueAsString(recentPayload);
-        
+
         DomainEventEnvelope recentEnvelope = new DomainEventEnvelope(
                 recentEventId,
                 "PRODUCT_VIEW",
@@ -179,9 +176,9 @@ void shouldIgnoreOldEvents() throws Exception {
                 currentTime,
                 recentPayloadJson
         );
-        
+
         kafkaTemplate.send("catalog-events", recentEnvelope);
-        
+
         // 최신 이벤트가 처리될 때까지 대기
         await().atMost(Duration.ofSeconds(10))
                 .untilAsserted(() -> {
@@ -189,12 +186,12 @@ void shouldIgnoreOldEvents() throws Exception {
                     assertThat(metrics).isPresent();
                     assertThat(metrics.get().getViewCount()).isEqualTo(1L);
                 });
-        
+
         // When - 과거 이벤트 전송 (1시간 전)
         String oldEventId = "old-event-" + currentTime;
         ProductViewPayloadV1 oldPayload = new ProductViewPayloadV1(productId, 200L);
         String oldPayloadJson = objectMapper.writeValueAsString(oldPayload);
-        
+
         DomainEventEnvelope oldEnvelope = new DomainEventEnvelope(
                 oldEventId,
                 "PRODUCT_VIEW",
@@ -202,30 +199,30 @@ void shouldIgnoreOldEvents() throws Exception {
                 currentTime - 3600000, // 1시간 전
                 oldPayloadJson
         );
-        
+
         kafkaTemplate.send("catalog-events", oldEnvelope);
-        
+
         // Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시)
         Thread.sleep(1000); // 처리 시간 대기
 
         Optional<ProductMetricsEntity> finalMetrics = productMetricsRepository.findById(productId);
         assertThat(finalMetrics).isPresent();
         assertThat(finalMetrics.get().getViewCount()).isEqualTo(1L);
-        
+
         // 과거 이벤트도 멱등성 테이블에는 기록되어야 함
         assertThat(eventRepository.existsById(recentEventId)).isTrue();
     }
-    
+
     @Test
     @DisplayName("새로운 메트릭 필드들이 정상적으로 초기화되어야 한다")
     void shouldInitializeNewMetricFields() throws Exception {
         // Given
         Long productId = 6L;
         String eventId = "new-metrics-test-" + System.currentTimeMillis();
-        
+
         ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L);
         String payloadJson = objectMapper.writeValueAsString(payload);
-        
+
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
                 "PRODUCT_VIEW",
@@ -233,16 +230,16 @@ void shouldInitializeNewMetricFields() throws Exception {
                 System.currentTimeMillis(),
                 payloadJson
         );
-        
+
         // When
         kafkaTemplate.send("catalog-events", envelope);
-        
+
         // Then - 새로운 메트릭 필드들이 0으로 초기화되어야 함
         await().atMost(Duration.ofSeconds(10))
                 .untilAsserted(() -> {
                     Optional<ProductMetricsEntity> metrics = productMetricsRepository.findById(productId);
                     assertThat(metrics).isPresent();
-                    
+
                     ProductMetricsEntity entity = metrics.get();
                     assertThat(entity.getViewCount()).isEqualTo(1L);
                     assertThat(entity.getLikeCount()).isEqualTo(0L);
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
index 458c66085..99dfd0b9e 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java
@@ -1,15 +1,7 @@
 package com.loopers.interfaces.consumer;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
 import java.util.List;
 
 import org.apache.kafka.clients.consumer.ConsumerRecord;
@@ -40,23 +32,23 @@ class MetricsKafkaConsumerTest {
 
     @Mock
     private MetricsService metricsService;
-    
+
     @Mock
     private EventDeserializer eventDeserializer;
-    
+
     @Mock
     private Acknowledgment acknowledgment;
-    
+
     @InjectMocks
     private MetricsKafkaConsumer consumer;
-    
+
     private ObjectMapper objectMapper;
-    
+
     @BeforeEach
     void setUp() {
         objectMapper = new ObjectMapper();
     }
-    
+
     @Test
     @DisplayName("중복된 이벤트 ID는 한 번만 처리되어야 한다")
     void shouldProcessEventOnlyOnce() {
@@ -64,54 +56,54 @@ void shouldProcessEventOnlyOnce() {
         String eventId = "test-event-123";
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
-                "PRODUCT_VIEW", 
+                "PRODUCT_VIEW",
                 "v1",
                 System.currentTimeMillis(),
                 "{\"productId\":1,\"userId\":100}"
         );
-        
+
         ProductViewPayloadV1 payload = new ProductViewPayloadV1(1L, 100L);
-        
+
         ConsumerRecord<Object, Object> record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope);
-        
+
         // 첫 번째 호출에서는 true (처음 처리), 두 번째 호출에서는 false (이미 처리됨)
         when(metricsService.tryMarkHandled(eventId))
                 .thenReturn(true)
                 .thenReturn(false);
-        
+
         when(eventDeserializer.deserializeEnvelope(envelope))
                 .thenReturn(envelope);
-        
+
         when(eventDeserializer.deserializeProductView(envelope.payloadJson()))
                 .thenReturn(payload);
-        
+
         // When - 같은 이벤트를 두 번 처리
         consumer.onCatalogEvents(List.of(record, record), acknowledgment);
-        
+
         // Then - 비즈니스 로직은 한 번만 호출되어야 함
         verify(metricsService, times(2)).tryMarkHandled(eventId);
         verify(metricsService, times(1)).incrementView(eq(1L), anyLong());
         verify(acknowledgment, times(1)).acknowledge();
     }
-    
+
     @Test
     @DisplayName("잘못된 이벤트 봉투는 무시되어야 한다")
     void shouldIgnoreInvalidEventEnvelope() {
         // Given
         ConsumerRecord<Object, Object> record = new ConsumerRecord<>("catalog-events", 0, 0, null, "invalid-json");
-        
+
         when(eventDeserializer.deserializeEnvelope("invalid-json"))
                 .thenReturn(null);
-        
+
         // When
         consumer.onCatalogEvents(List.of(record), acknowledgment);
-        
+
         // Then
         verify(metricsService, never()).tryMarkHandled(anyString());
         verify(metricsService, never()).incrementView(anyLong(), anyLong());
         verify(acknowledgment, times(1)).acknowledge(); // 배치는 여전히 ack 되어야 함
     }
-    
+
     @Test
     @DisplayName("PAYMENT_SUCCESS 이벤트가 상품별로 개별 처리되어야 한다")
     void shouldProcessPaymentSuccessEvent() {
@@ -120,11 +112,11 @@ void shouldProcessPaymentSuccessEvent() {
         DomainEventEnvelope envelope = new DomainEventEnvelope(
                 eventId,
                 "PAYMENT_SUCCESS",
-                "v1", 
+                "v1",
                 System.currentTimeMillis(),
                 "{\"orderId\":12345,\"orderNumber\":67890,\"userId\":100,\"productId\":1,\"quantity\":2,\"unitPrice\":1000,\"totalPrice\":2000}"
         );
-        
+
         // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트)
         PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1(
                 12345L,    // orderId
@@ -135,29 +127,29 @@ void shouldProcessPaymentSuccessEvent() {
                 java.math.BigDecimal.valueOf(1000), // unitPrice
                 java.math.BigDecimal.valueOf(2000)  // totalPrice
         );
-        
+
         ConsumerRecord<Object, Object> record = new ConsumerRecord<>("order-events", 0, 0, null, envelope);
-        
+
         when(metricsService.tryMarkHandled(eventId)).thenReturn(true);
         when(eventDeserializer.deserializeEnvelope(envelope)).thenReturn(envelope);
         when(eventDeserializer.deserializePaymentSuccess(envelope.payloadJson())).thenReturn(payload);
-        
+
         // When
         consumer.onOrderEvents(List.of(record), acknowledgment);
-        
+
         // Then
         verify(metricsService, times(1)).tryMarkHandled(eventId);
         verify(metricsService, times(1)).addSales(eq(1L), eq(2), anyLong());
         verify(acknowledgment, times(1)).acknowledge();
     }
-    
+
     @Test
     @DisplayName("개별 메시지 처리 실패가 전체 배치를 실패시키지 않아야 한다")
     void shouldContinueProcessingWhenIndividualMessageFails() {
         // Given
         String validEventId = "valid-event";
         String invalidEventId = "invalid-event";
-        
+
         DomainEventEnvelope validEnvelope = new DomainEventEnvelope(
                 validEventId,
                 "PRODUCT_VIEW",
@@ -165,34 +157,34 @@ void shouldContinueProcessingWhenIndividualMessageFails() {
                 System.currentTimeMillis(),
                 "{\"productId\":1,\"userId\":100}"
         );
-        
+
         DomainEventEnvelope invalidEnvelope = new DomainEventEnvelope(
                 invalidEventId,
-                "PRODUCT_VIEW", 
+                "PRODUCT_VIEW",
                 "v1",
                 System.currentTimeMillis(),
                 "invalid-payload"
         );
-        
+
         ProductViewPayloadV1 validPayload = new ProductViewPayloadV1(1L, 100L);
-        
+
         ConsumerRecord<Object, Object> validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope);
         ConsumerRecord<Object, Object> invalidRecord = new ConsumerRecord<>("catalog-events", 0, 1, null, invalidEnvelope);
-        
+
         when(metricsService.tryMarkHandled(validEventId)).thenReturn(true);
         when(metricsService.tryMarkHandled(invalidEventId)).thenReturn(true);
-        
+
         when(eventDeserializer.deserializeEnvelope(validEnvelope)).thenReturn(validEnvelope);
         when(eventDeserializer.deserializeEnvelope(invalidEnvelope)).thenReturn(invalidEnvelope);
-        
+
         when(eventDeserializer.deserializeProductView(validEnvelope.payloadJson())).thenReturn(validPayload);
         when(eventDeserializer.deserializeProductView(invalidEnvelope.payloadJson())).thenReturn(null); // 파싱 실패
-        
+
         // When
         consumer.onCatalogEvents(List.of(validRecord, invalidRecord), acknowledgment);
-        
+
         // Then - 유효한 메시지는 처리되고, 전체 배치는 ack 되어야 함
         verify(metricsService, times(1)).incrementView(eq(1L), anyLong());
         verify(acknowledgment, times(1)).acknowledge();
     }
-}
\ No newline at end of file
+}
diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md
index 118642638..e4691cd43 100644
--- a/apps/pg-simulator/README.md
+++ b/apps/pg-simulator/README.md
@@ -1,18 +1,23 @@
 ## PG-Simulator (PaymentGateway)
 
 ### Description
+
 Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다.
 `local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다.
+
 - server port : 8082
 - actuator port : 8083
 
 ### Getting Started
+
 부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요.
+
 ```shell
 ./gradlew :apps:pg-simulator:bootRun
 ```
 
 API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다.
+
 - 결제 요청 API
 - 결제 정보 확인 `by transactionKey`
 - 결제 정보 목록 조회 `by orderId`
diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts
index 653d549da..71fb7844a 100644
--- a/apps/pg-simulator/build.gradle.kts
+++ b/apps/pg-simulator/build.gradle.kts
@@ -1,10 +1,10 @@
 plugins {
     val kotlinVersion = "2.0.20"
 
-    id("org.jetbrains.kotlin.jvm") version(kotlinVersion)
-    id("org.jetbrains.kotlin.kapt") version(kotlinVersion)
-    id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion)
-    id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion)
+    id("org.jetbrains.kotlin.jvm") version (kotlinVersion)
+    id("org.jetbrains.kotlin.kapt") version (kotlinVersion)
+    id("org.jetbrains.kotlin.plugin.spring") version (kotlinVersion)
+    id("org.jetbrains.kotlin.plugin.jpa") version (kotlinVersion)
 }
 
 kotlin {
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
index 05595d135..e7017374b 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
@@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
 import org.springframework.boot.context.properties.ConfigurationPropertiesScan
 import org.springframework.boot.runApplication
 import org.springframework.scheduling.annotation.EnableAsync
-import java.util.TimeZone
+import java.util.*
 
 @ConfigurationPropertiesScan
 @EnableAsync
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
index 9a5ebdc5d..7fb361532 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
@@ -1,11 +1,6 @@
 package com.loopers.application.payment
 
-import com.loopers.domain.payment.Payment
-import com.loopers.domain.payment.PaymentEvent
-import com.loopers.domain.payment.PaymentEventPublisher
-import com.loopers.domain.payment.PaymentRelay
-import com.loopers.domain.payment.PaymentRepository
-import com.loopers.domain.payment.TransactionKeyGenerator
+import com.loopers.domain.payment.*
 import com.loopers.domain.user.UserInfo
 import com.loopers.support.error.CoreException
 import com.loopers.support.error.ErrorType
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
index cfc2386c1..6c35924dd 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
@@ -2,13 +2,7 @@ package com.loopers.domain.payment
 
 import com.loopers.support.error.CoreException
 import com.loopers.support.error.ErrorType
-import jakarta.persistence.Column
-import jakarta.persistence.Entity
-import jakarta.persistence.EnumType
-import jakarta.persistence.Enumerated
-import jakarta.persistence.Id
-import jakarta.persistence.Index
-import jakarta.persistence.Table
+import jakarta.persistence.*
 import java.time.LocalDateTime
 
 @Entity
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
index c8703a763..c0f1e2154 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
@@ -3,7 +3,7 @@ package com.loopers.domain.payment
 import org.springframework.stereotype.Component
 import java.time.LocalDateTime
 import java.time.format.DateTimeFormatter
-import java.util.UUID
+import java.util.*
 
 @Component
 class TransactionKeyGenerator {
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
index 434a229e2..1f8669a80 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
@@ -14,10 +14,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
 import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
 import org.springframework.web.server.ServerWebInputException
 import org.springframework.web.servlet.resource.NoResourceFoundException
-import kotlin.collections.joinToString
-import kotlin.jvm.java
-import kotlin.text.isNotEmpty
-import kotlin.text.toRegex
 
 @RestControllerAdvice
 class ApiControllerAdvice {
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
index 9ef6c25da..fe276e1a5 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
@@ -9,7 +9,7 @@ import org.springframework.web.context.request.NativeWebRequest
 import org.springframework.web.method.support.HandlerMethodArgumentResolver
 import org.springframework.web.method.support.ModelAndViewContainer
 
-class UserInfoArgumentResolver: HandlerMethodArgumentResolver {
+class UserInfoArgumentResolver : HandlerMethodArgumentResolver {
     companion object {
         private const val KEY_USER_ID = "X-USER-ID"
     }
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
index 22d5cbe38..fa60fe0fd 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
@@ -1,17 +1,11 @@
 package com.loopers.interfaces.api.payment
 
 import com.loopers.application.payment.PaymentApplicationService
-import com.loopers.interfaces.api.ApiResponse
 import com.loopers.domain.user.UserInfo
+import com.loopers.interfaces.api.ApiResponse
 import com.loopers.support.error.CoreException
 import com.loopers.support.error.ErrorType
-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.RequestBody
-import org.springframework.web.bind.annotation.RequestMapping
-import org.springframework.web.bind.annotation.RequestParam
-import org.springframework.web.bind.annotation.RestController
+import org.springframework.web.bind.annotation.*
 
 @RestController
 @RequestMapping("/api/v1/payments")
diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java
index 723ffa6a4..595af2801 100644
--- a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java
+++ b/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java
@@ -1,13 +1,14 @@
 package com.loopers.config.jpa;
 
-import com.zaxxer.hikari.HikariConfig;
-import com.zaxxer.hikari.HikariDataSource;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
 
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+
 @Configuration
 class DataSourceConfig {
     @Bean
diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java b/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
index 2b95dd37a..753b07311 100644
--- a/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
+++ b/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java
@@ -1,11 +1,13 @@
 package com.loopers.config.jpa;
 
-import com.querydsl.jpa.impl.JPAQueryFactory;
-import jakarta.persistence.EntityManager;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
 
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import jakarta.persistence.EntityManager;
+
 @Configuration
 class QueryDslConfig {
 
diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
index 494335bbd..36f63182e 100644
--- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
+++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java
@@ -1,17 +1,13 @@
 package com.loopers.domain;
 
-import jakarta.persistence.Column;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.MappedSuperclass;
-import jakarta.persistence.PrePersist;
-import jakarta.persistence.PreUpdate;
-import lombok.Getter;
 import java.time.ZonedDateTime;
 
 import org.springframework.data.domain.AbstractAggregateRoot;
 
+import lombok.Getter;
+
+import jakarta.persistence.*;
+
 /**
  * 생성/수정/삭제 정보를 자동으로 관리해준다.
  * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다.
@@ -37,7 +33,8 @@ public abstract class BaseEntity extends AbstractAggregateRoot<BaseEntity> {
      * 엔티티의 유효성을 검증한다.
      * 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다.
      */
-    protected void guard() {}
+    protected void guard() {
+    }
 
     @PrePersist
     private void prePersist() {
diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
index 9c41edacc..2883117b6 100644
--- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
+++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java
@@ -11,22 +11,22 @@ public class MySqlTestContainersConfig {
 
     static {
         mySqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
-            .withDatabaseName("loopers")
-            .withUsername("test")
-            .withPassword("test")
-            .withExposedPorts(3306)
-            .withCommand(
-                "--character-set-server=utf8mb4",
-                "--collation-server=utf8mb4_general_ci",
-                "--skip-character-set-client-handshake"
-            );
+                .withDatabaseName("loopers")
+                .withUsername("test")
+                .withPassword("test")
+                .withExposedPorts(3306)
+                .withCommand(
+                        "--character-set-server=utf8mb4",
+                        "--collation-server=utf8mb4_general_ci",
+                        "--skip-character-set-client-handshake"
+                );
         mySqlContainer.start();
 
         String mySqlJdbcUrl = String.format(
-            "jdbc:mysql://%s:%d/%s",
-            mySqlContainer.getHost(),
-            mySqlContainer.getFirstMappedPort(),
-            mySqlContainer.getDatabaseName()
+                "jdbc:mysql://%s:%d/%s",
+                mySqlContainer.getHost(),
+                mySqlContainer.getFirstMappedPort(),
+                mySqlContainer.getDatabaseName()
         );
 
         System.setProperty("datasource.mysql-jpa.main.jdbc-url", mySqlJdbcUrl);
diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
index 85c43ace9..a2fd9a2f4 100644
--- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
+++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
@@ -1,15 +1,16 @@
 package com.loopers.utils;
 
-import jakarta.persistence.Entity;
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.PersistenceContext;
-import jakarta.persistence.Table;
+import java.util.ArrayList;
+import java.util.List;
+
 import org.springframework.beans.factory.InitializingBean;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.util.ArrayList;
-import java.util.List;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.Table;
 
 @Component
 public class DatabaseCleanUp implements InitializingBean {
@@ -22,9 +23,9 @@ public class DatabaseCleanUp implements InitializingBean {
     @Override
     public void afterPropertiesSet() {
         entityManager.getMetamodel().getEntities().stream()
-            .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null)
-            .map(entity -> entity.getJavaType().getAnnotation(Table.class).name())
-            .forEach(tableNames::add);
+                .filter(entity -> entity.getJavaType().getAnnotation(Entity.class) != null)
+                .map(entity -> entity.getJavaType().getAnnotation(Table.class).name())
+                .forEach(tableNames::add);
     }
 
     @Transactional
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 eb7bcb37f..55950412a 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
@@ -1,10 +1,11 @@
 package com.loopers.confg.kafka;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.HashMap;
+import java.util.Map;
+
 import org.apache.kafka.clients.admin.NewTopic;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
-import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -16,11 +17,7 @@
 import org.springframework.kafka.support.converter.BatchMessagingMessageConverter;
 import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter;
 
-import lombok.Data;
-import lombok.extern.slf4j.Slf4j;
-
-import java.util.HashMap;
-import java.util.Map;
+import com.fasterxml.jackson.databind.ObjectMapper;
 
 @EnableKafka
 @Configuration
diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml
index 55c84f17f..0d3bbe5bc 100644
--- a/modules/kafka/src/main/resources/kafka.yml
+++ b/modules/kafka/src/main/resources/kafka.yml
@@ -3,14 +3,14 @@ spring:
     bootstrap-servers: ${BOOTSTRAP_SERVERS}
     client-id: ${spring.application.name}
     properties:
-        spring.json.add.type.headers: false # json serialize off
-        request.timeout.ms: 20000
-        retry.backoff.ms: 500
-        auto:
-          create.topics.enable: false
-          register.schemas: false
-          offset.reset: latest
-        use.latest.version: true
+      spring.json.add.type.headers: false # json serialize off
+      request.timeout.ms: 20000
+      retry.backoff.ms: 500
+      auto:
+        create.topics.enable: false
+        register.schemas: false
+        offset.reset: latest
+      use.latest.version: true
     producer:
       key-serializer: org.apache.kafka.common.serialization.StringSerializer
       value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
@@ -41,7 +41,7 @@ spring:
         bootstrap.servers: kafka:9092
       # 토픽 자동 생성 설정
       auto-create-topics-enable: true
-      
+
 # 커스텀 토픽 설정
 kafka:
   topics:
@@ -50,7 +50,7 @@ kafka:
       partitions: 3
       replicas: 1
     order-events:
-      name: order-events  
+      name: order-events
       partitions: 3
       replicas: 1
 
@@ -64,7 +64,7 @@ kafka:
       partitions: 6  # 개발환경에서는 더 많은 파티션
       replicas: 2
     order-events:
-      name: order-events  
+      name: order-events
       partitions: 6
       replicas: 2
 
@@ -78,7 +78,7 @@ kafka:
       partitions: 9  # QA환경에서는 운영과 동일
       replicas: 2
     order-events:
-      name: order-events  
+      name: order-events
       partitions: 9
       replicas: 2
 
@@ -92,6 +92,6 @@ kafka:
       partitions: 12  # 운영환경에서는 최대 성능
       replicas: 3
     order-events:
-      name: order-events  
+      name: order-events
       partitions: 12
       replicas: 3
diff --git a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
index 6b8dd5557..21b7ba81a 100644
--- a/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
+++ b/modules/redis/src/main/java/com/loopers/cache/BaseCacheService.java
@@ -180,27 +180,27 @@ public void evictBrandProductListCache(Long brandId) {
     public void updateProductStock(Long productId, Integer newStock) {
         try {
             String key = cacheKeyGenerator.generateProductDetailKey(productId);
-            
+
             // 기존 캐시 조회
             String cachedValue = redisTemplate.opsForValue().get(key);
             if (cachedValue == null) {
                 log.debug("상품 상세 캐시 없음 - 재고 갱신 스킵: productId={}", productId);
                 return;
             }
-            
+
             // JSON 파싱하여 재고 정보 업데이트
             try {
                 // 기존 캐시 데이터를 Map으로 파싱
                 @SuppressWarnings("unchecked")
                 Map<String, Object> productData = objectMapper.readValue(cachedValue, java.util.Map.class);
-                
+
                 // 재고 정보 업데이트
                 productData.put("stock", newStock);
                 productData.put("isInStock", newStock > 0);
-                
+
                 // 업데이트된 데이터를 다시 JSON으로 변환하여 저장
                 String updatedValue = objectMapper.writeValueAsString(productData);
-                
+
                 // 기존 TTL 유지하면서 업데이트
                 long ttl = redisTemplate.getExpire(key);
                 if (ttl > 0) {
@@ -209,15 +209,15 @@ public void updateProductStock(Long productId, Integer newStock) {
                     // TTL이 없거나 만료된 경우 기본 30분으로 설정
                     redisTemplate.opsForValue().set(key, updatedValue, 30, TimeUnit.MINUTES);
                 }
-                
+
                 log.debug("상품 재고 캐시 갱신 완료: productId={}, newStock={}", productId, newStock);
-                
+
             } catch (JsonProcessingException e) {
                 log.warn("상품 재고 캐시 갱신 실패 (JSON 처리 오류) - 캐시 삭제: productId={}", productId, e);
                 // JSON 파싱 실패 시 캐시 삭제
                 delete(key);
             }
-            
+
         } catch (Exception e) {
             log.warn("상품 재고 캐시 갱신 실패: productId={}, error: {}", productId, e.getMessage());
         }
diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java
index 0a2b614ca..1625d2420 100644
--- a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java
+++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java
@@ -2,6 +2,9 @@
 
 
 import io.lettuce.core.ReadFrom;
+import java.util.List;
+import java.util.function.Consumer;
+
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
@@ -13,18 +16,15 @@
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.serializer.StringRedisSerializer;
 
-import java.util.List;
-import java.util.function.Consumer;
-
 @Configuration
 @EnableConfigurationProperties(RedisProperties.class)
-public class RedisConfig{
+public class RedisConfig {
     private static final String CONNECTION_MASTER = "redisConnectionMaster";
     public static final String REDIS_TEMPLATE_MASTER = "redisTemplateMaster";
 
     private final RedisProperties redisProperties;
 
-    public RedisConfig(RedisProperties redisProperties){
+    public RedisConfig(RedisProperties redisProperties) {
         this.redisProperties = redisProperties;
     }
 
@@ -74,22 +74,24 @@ private LettuceConnectionFactory lettuceConnectionFactory(
             RedisNodeInfo master,
             List<RedisNodeInfo> replicas,
             Consumer<LettuceClientConfiguration.LettuceClientConfigurationBuilder> customizer
-    ){
+    ) {
         LettuceClientConfiguration.LettuceClientConfigurationBuilder builder = LettuceClientConfiguration.builder();
-        if(customizer != null) customizer.accept(builder);
+        if (customizer != null)
+            customizer.accept(builder);
         LettuceClientConfiguration clientConfig = builder.build();
-        RedisStaticMasterReplicaConfiguration masterReplicaConfig = new RedisStaticMasterReplicaConfiguration(master.host(), master.port());
+        RedisStaticMasterReplicaConfiguration masterReplicaConfig =
+                new RedisStaticMasterReplicaConfiguration(master.host(), master.port());
         masterReplicaConfig.setDatabase(database);
-        for(RedisNodeInfo r : replicas){
+        for (RedisNodeInfo r : replicas) {
             masterReplicaConfig.addNode(r.host(), r.port());
         }
         return new LettuceConnectionFactory(masterReplicaConfig, clientConfig);
     }
 
-    private <K,V> RedisTemplate<K,V> defaultRedisTemplate(
-            RedisTemplate<K,V> template,
+    private <K, V> RedisTemplate<K, V> defaultRedisTemplate(
+            RedisTemplate<K, V> template,
             LettuceConnectionFactory connectionFactory
-    ){
+    ) {
         StringRedisSerializer s = new StringRedisSerializer();
         template.setKeySerializer(s);
         template.setValueSerializer(s);
diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java
index e03066c16..aeb9ce054 100644
--- a/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java
+++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java
@@ -3,4 +3,5 @@
 public record RedisNodeInfo(
         String host,
         int port
-) { }
+) {
+}
diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java
index a396ca456..ee238819c 100644
--- a/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java
+++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java
@@ -1,12 +1,13 @@
 package com.loopers.config.redis;
 
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
 import java.util.List;
 
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
 @ConfigurationProperties(value = "datasource.redis")
 public record RedisProperties(
         int database,
         RedisNodeInfo master,
         List<RedisNodeInfo> replicas
-) { }
+) {
+}
diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
index 35bf94f06..fa42fa5ae 100644
--- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
+++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
@@ -1,9 +1,10 @@
 package com.loopers.testcontainers;
 
-import com.redis.testcontainers.RedisContainer;
 import org.springframework.context.annotation.Configuration;
 import org.testcontainers.utility.DockerImageName;
 
+import com.redis.testcontainers.RedisContainer;
+
 @Configuration
 public class RedisTestContainersConfig {
     private static final RedisContainer redisContainer = new RedisContainer(DockerImageName.parse("redis:latest"));
diff --git a/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java b/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java
index 5f3cf4d13..a2eb780fb 100644
--- a/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java
+++ b/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java
@@ -12,7 +12,7 @@ public RedisCleanUp(RedisConnectionFactory redisConnectionFactory) {
         this.redisConnectionFactory = redisConnectionFactory;
     }
 
-    public void truncateAll(){
+    public void truncateAll() {
         try (RedisConnection connection = redisConnectionFactory.getConnection()) {
             connection.serverCommands().flushAll();
         }
diff --git a/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
index 4f8bda6f4..f3f11b298 100644
--- a/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
+++ b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
@@ -1,15 +1,16 @@
 package com.loopers.config.jackson;
 
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.SerializationFeature;
 import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.Ordered;
 import org.springframework.core.annotation.Order;
 
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
 @Configuration
 class JacksonConfig {
 
@@ -23,25 +24,25 @@ public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
             // Serialization Features
             builder.serializationInclusion(JsonInclude.Include.NON_NULL);
             builder.featuresToEnable(
-                JsonGenerator.Feature.AUTO_CLOSE_JSON_CONTENT,
-                JsonGenerator.Feature.IGNORE_UNKNOWN,
-                JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN
+                    JsonGenerator.Feature.AUTO_CLOSE_JSON_CONTENT,
+                    JsonGenerator.Feature.IGNORE_UNKNOWN,
+                    JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN
             );
             builder.featuresToDisable(
-                SerializationFeature.FAIL_ON_EMPTY_BEANS
+                    SerializationFeature.FAIL_ON_EMPTY_BEANS
             );
 
             // Deserialization Features
             builder.featuresToEnable(
-                DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT,
-                DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY,
-                DeserializationFeature.READ_ENUMS_USING_TO_STRING,
-                DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES
+                    DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT,
+                    DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY,
+                    DeserializationFeature.READ_ENUMS_USING_TO_STRING,
+                    DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES
             );
             builder.featuresToDisable(
-                DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL,
-                DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES,
-                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
+                    DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL,
+                    DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES,
+                    DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
             );
         };
     }
diff --git a/supports/logging/src/main/resources/appenders/json-console-appender.xml b/supports/logging/src/main/resources/appenders/json-console-appender.xml
index d3b22c38b..0e3baf574 100644
--- a/supports/logging/src/main/resources/appenders/json-console-appender.xml
+++ b/supports/logging/src/main/resources/appenders/json-console-appender.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 <included>
     <property name="CONSOLE_LOG_STRUCTURED_FORMAT" value="logstash"/>
-    <include resource="org/springframework/boot/logging/logback/defaults.xml" />
-    <include resource="org/springframework/boot/logging/logback/structured-console-appender.xml" />
+    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
+    <include resource="org/springframework/boot/logging/logback/structured-console-appender.xml"/>
 </included>
\ No newline at end of file
diff --git a/supports/logging/src/main/resources/appenders/plain-console-appender.xml b/supports/logging/src/main/resources/appenders/plain-console-appender.xml
index 162856618..a72fa102e 100644
--- a/supports/logging/src/main/resources/appenders/plain-console-appender.xml
+++ b/supports/logging/src/main/resources/appenders/plain-console-appender.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 <included>
-    <include resource="org/springframework/boot/logging/logback/defaults.xml" />
-    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
+    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
+    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
 </included>
\ No newline at end of file
diff --git a/supports/logging/src/main/resources/appenders/slack-appender.xml b/supports/logging/src/main/resources/appenders/slack-appender.xml
index a6175a185..bda543223 100644
--- a/supports/logging/src/main/resources/appenders/slack-appender.xml
+++ b/supports/logging/src/main/resources/appenders/slack-appender.xml
@@ -4,7 +4,8 @@
         <layout class="ch.qos.logback.classic.PatternLayout">
             <pattern>
                 *Application:* *`${appName:-}`*
-                *[%-5level]* *`%X{method:-}`* *`%X{requestUri:-}`* *Pod:* `${HOSTNAME:-}` *Trace ID:* `%X{traceId:-}` *Span ID:* `%X{spanId:-}` *Client IP:* `%X{clientIp:-}`%n%msg%n
+                *[%-5level]* *`%X{method:-}`* *`%X{requestUri:-}`* *Pod:* `${HOSTNAME:-}` *Trace ID:* `%X{traceId:-}`
+                *Span ID:* `%X{spanId:-}` *Client IP:* `%X{clientIp:-}`%n%msg%n
             </pattern>
         </layout>
         <webhookUri>${SLACK_WEBHOOK_URI}</webhookUri>

From fc056e2fe1a217ae786053d2b1999c5b24ed7bcc Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:38:17 +0900
Subject: [PATCH 23/28] =?UTF-8?q?feat(monitoring):=20=EB=AA=A8=EB=8B=88?=
 =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EC=9A=94=EC=95=BD=20=EB=8D=B0=EC=9D=B4?=
 =?UTF-8?q?=ED=84=B0=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 stress/summary.json | 330 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 330 insertions(+)
 create mode 100644 stress/summary.json

diff --git a/stress/summary.json b/stress/summary.json
new file mode 100644
index 000000000..9207a5d49
--- /dev/null
+++ b/stress/summary.json
@@ -0,0 +1,330 @@
+{
+  "root_group": {
+    "name": "",
+    "path": "",
+    "id": "d41d8cd98f00b204e9800998ecf8427e",
+    "groups": [],
+    "checks": [
+        {
+          "fails": 0,
+          "name": "product view status 200",
+          "path": "::product view status 200",
+          "id": "d7bcf489942c40269f55b32ca6ce4b46",
+          "passes": 20784
+        },
+        {
+          "name": "product view has data",
+          "path": "::product view has data",
+          "id": "87252c9e9c1b8992cfd921414e0b6022",
+          "passes": 20784,
+          "fails": 0
+        },
+        {
+          "path": "::like action status 200",
+          "id": "703d89a84372f3cd06c20ca212d785cd",
+          "passes": 5793,
+          "fails": 0,
+          "name": "like action status 200"
+        },
+        {
+          "passes": 0,
+          "fails": 5793,
+          "name": "like action success",
+          "path": "::like action success",
+          "id": "b761bcb03efbcded611b024c965a1987"
+        },
+        {
+          "passes": 118,
+          "fails": 22,
+          "name": "order creation status 200",
+          "path": "::order creation status 200",
+          "id": "618cd181f080fa12c8a21ca57866d4cd"
+        },
+        {
+          "passes": 140,
+          "fails": 0,
+          "name": "order has payment info",
+          "path": "::order has payment info",
+          "id": "6dc920b39e30a12a9783e986f5984d4c"
+        },
+        {
+          "id": "ce6654f5cd3e1fa102fd249859b7bd70",
+          "passes": 1,
+          "fails": 117,
+          "name": "payment callback status 200",
+          "path": "::payment callback status 200"
+        },
+        {
+          "path": "::payment callback success",
+          "id": "3e86da78f0eba1a92bab39b624bffb42",
+          "passes": 0,
+          "fails": 118,
+          "name": "payment callback success"
+        }
+      ]
+  },
+  "options": {
+    "summaryTimeUnit": "",
+    "noColor": false,
+    "summaryTrendStats": [
+      "avg",
+      "min",
+      "med",
+      "max",
+      "p(90)",
+      "p(95)"
+    ]
+  },
+  "state": {
+    "isStdOutTTY": true,
+    "isStdErrTTY": true,
+    "testRunDurationMs": 50116.796
+  },
+  "metrics": {
+    "like_action_success": {
+      "type": "rate",
+      "contains": "default",
+      "values": {
+        "rate": 0,
+        "passes": 0,
+        "fails": 5793
+      },
+      "thresholds": {
+        "rate>0.95": {
+          "ok": false
+        }
+      }
+    },
+    "data_received": {
+      "values": {
+        "count": 10863041,
+        "rate": 216754.4988310905
+      },
+      "type": "counter",
+      "contains": "data"
+    },
+    "iterations": {
+      "type": "counter",
+      "contains": "default",
+      "values": {
+        "count": 26847,
+        "rate": 535.6886741123674
+      }
+    },
+    "http_req_duration{expected_response:true}": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "p(95)": 16.176249999999996,
+        "avg": 11.192594583458256,
+        "min": 1.291,
+        "med": 5.9655000000000005,
+        "max": 2618.058,
+        "p(90)": 12.5615
+      }
+    },
+    "idempotency_check": {
+      "type": "rate",
+      "contains": "default",
+      "values": {
+        "fails": 130,
+        "rate": 0,
+        "passes": 0
+      }
+    },
+    "checks": {
+      "type": "rate",
+      "contains": "default",
+      "values": {
+        "passes": 47620,
+        "fails": 6050,
+        "rate": 0.8872740823551333
+      }
+    },
+    "iteration_duration": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "p(90)": 205.666992,
+        "p(95)": 207.953792,
+        "avg": 151.24222687860868,
+        "min": 101.416167,
+        "med": 107.799458,
+        "max": 4129.579875
+      }
+    },
+    "data_sent": {
+      "values": {
+        "count": 3551834,
+        "rate": 70871.13070835573
+      },
+      "type": "counter",
+      "contains": "data"
+    },
+    "vus_max": {
+      "type": "gauge",
+      "contains": "default",
+      "values": {
+        "value": 190,
+        "min": 190,
+        "max": 190
+      }
+    },
+    "http_req_duration": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "med": 5.951,
+        "max": 2618.058,
+        "p(90)": 12.5722,
+        "p(95)": 16.202,
+        "avg": 11.652998714416935,
+        "min": 1.132
+      },
+      "thresholds": {
+        "p(95)<2000": {
+          "ok": true
+        }
+      }
+    },
+    "payment_success": {
+      "values": {
+        "rate": 0,
+        "passes": 0,
+        "fails": 118
+      },
+      "thresholds": {
+        "rate>0.95": {
+          "ok": false
+        }
+      },
+      "type": "rate",
+      "contains": "default"
+    },
+    "http_reqs": {
+      "type": "counter",
+      "contains": "default",
+      "values": {
+        "count": 27225,
+        "rate": 543.2310557123404
+      }
+    },
+    "vus": {
+      "type": "gauge",
+      "contains": "default",
+      "values": {
+        "min": 4,
+        "max": 177,
+        "value": 10
+      }
+    },
+    "http_req_waiting": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "p(90)": 12.532200000000001,
+        "p(95)": 16.159399999999998,
+        "avg": 11.593664903581235,
+        "min": 1.089,
+        "med": 5.892,
+        "max": 2617.997
+      }
+    },
+    "http_req_receiving": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "min": 0.003,
+        "med": 0.038,
+        "max": 4.372,
+        "p(90)": 0.101,
+        "p(95)": 0.125,
+        "avg": 0.04995610651974213
+      }
+    },
+    "events_generated": {
+      "type": "counter",
+      "contains": "default",
+      "values": {
+        "count": 26695,
+        "rate": 532.6557587599973
+      }
+    },
+    "product_view_success": {
+      "type": "rate",
+      "contains": "default",
+      "values": {
+        "rate": 1,
+        "passes": 20784,
+        "fails": 0
+      },
+      "thresholds": {
+        "rate>0.95": {
+          "ok": true
+        }
+      }
+    },
+    "http_req_blocked": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "avg": 0.008632764003672995,
+        "min": 0,
+        "med": 0.002,
+        "max": 2.438,
+        "p(90)": 0.005,
+        "p(95)": 0.01
+      }
+    },
+    "http_req_connecting": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "med": 0,
+        "max": 2.208,
+        "p(90)": 0,
+        "p(95)": 0,
+        "avg": 0.004682644628099177,
+        "min": 0
+      }
+    },
+    "http_req_failed": {
+      "values": {
+        "rate": 0.019430670339761247,
+        "passes": 529,
+        "fails": 26696
+      },
+      "thresholds": {
+        "rate<0.05": {
+          "ok": true
+        }
+      },
+      "type": "rate",
+      "contains": "default"
+    },
+    "http_req_tls_handshaking": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "p(95)": 0,
+        "avg": 0,
+        "min": 0,
+        "med": 0,
+        "max": 0,
+        "p(90)": 0
+      }
+    },
+    "http_req_sending": {
+      "type": "trend",
+      "contains": "time",
+      "values": {
+        "p(90)": 0.016,
+        "p(95)": 0.023,
+        "avg": 0.009377704315884609,
+        "min": 0.001,
+        "med": 0.007,
+        "max": 1.162
+      }
+    }
+  }
+}
\ No newline at end of file

From 849aad723b30b04080e871891564b568d0bc39d0 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Fri, 19 Dec 2025 15:58:49 +0900
Subject: [PATCH 24/28] =?UTF-8?q?feat(kafka):=20Kafka=20=EA=B4=80=EB=A0=A8?=
 =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4?=
 =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../{event-driven-e2e-test.js => kafka_event-driven-e2e-test.js}  | 0
 stress/{summary.json => kafka_test_result.json}                   | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 rename stress/{event-driven-e2e-test.js => kafka_event-driven-e2e-test.js} (100%)
 rename stress/{summary.json => kafka_test_result.json} (100%)

diff --git a/stress/event-driven-e2e-test.js b/stress/kafka_event-driven-e2e-test.js
similarity index 100%
rename from stress/event-driven-e2e-test.js
rename to stress/kafka_event-driven-e2e-test.js
diff --git a/stress/summary.json b/stress/kafka_test_result.json
similarity index 100%
rename from stress/summary.json
rename to stress/kafka_test_result.json

From ede3e03551b86efb265cc2d06f48b2e146b38a47 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Mon, 22 Dec 2025 00:08:53 +0900
Subject: [PATCH 25/28] =?UTF-8?q?refactor(event):=20deleteAll=20=EB=A9=94?=
 =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- 사용되지 않는 deleteAll 메서드를 삭제하여 코드 간결성 향상
---
 .../main/java/com/loopers/domain/event/EventRepository.java  | 1 -
 .../loopers/infrastructure/event/EventRepositoryImpl.java    | 5 -----
 2 files changed, 6 deletions(-)

diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
index 0d8dfb30a..d6236a04a 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventRepository.java
@@ -8,7 +8,6 @@
 public interface EventRepository {
     EventEntity save(EventEntity eventEntity);
 
-    void deleteAll();
 
     boolean existsById(String eventId);
 }
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
index a9d47cf1c..d3785269f 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java
@@ -22,11 +22,6 @@ public EventEntity save(EventEntity eventEntity) {
         return eventJpaRepository.save(eventEntity);
     }
 
-    @Override
-    public void deleteAll() {
-        eventJpaRepository.deleteAll();
-    }
-
     @Override
     public boolean existsById(String eventId) {
         return eventJpaRepository.existsById(eventId);

From 554a9f0c2f4aa230c60ed32c8c77ef9f0583cea6 Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Mon, 22 Dec 2025 00:19:06 +0900
Subject: [PATCH 26/28] =?UTF-8?q?feat(config):=20=EC=95=A0=ED=94=8C?=
 =?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=9D=B4=EB=A6=84?=
 =?UTF-8?q?=EC=9D=84=20commerce-streamer=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Kafka 관련 설정에 맞춰 애플리케이션 이름을 변경하여
  일관성 있는 구성 유지
- Hot 캐시 갱신 주기를 50분에서 20분으로 단축하여
  캐시 효율성 개선
- 테스트 코드에서 불필요한 deleteAll 호출 제거
---
 .../product/ProductMVBatchScheduler.java            |  4 ++--
 .../src/main/resources/application.yml              |  2 +-
 .../MetricsEventProcessingIntegrationTest.java      | 13 ++++++-------
 3 files changed, 9 insertions(+), 10 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
index ee7c0813f..cb9c9b147 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductMVBatchScheduler.java
@@ -85,12 +85,12 @@ public void syncMaterializedView() {
     }
 
     /**
-     * Hot 캐시 갱신 배치 작업 (50분마다)
+     * Hot 캐시 갱신 배치 작업 (20분마다)
      * <p>
      * 배치 갱신으로 캐시 스탬피드 방지
      * ProductMVRepository를 직접 사용하여 likeCount 정렬 보장
      */
-    @Scheduled(fixedRate = 50 * 60 * 1000, initialDelay = 60 * 1000)
+    @Scheduled(fixedRate = 20 * 60 * 1000, initialDelay = 60 * 1000)
     public void refreshHotCache() {
         log.info("Hot 캐시 갱신 시작");
 
diff --git a/apps/commerce-streamer/src/main/resources/application.yml b/apps/commerce-streamer/src/main/resources/application.yml
index fa72b64c8..95629c631 100644
--- a/apps/commerce-streamer/src/main/resources/application.yml
+++ b/apps/commerce-streamer/src/main/resources/application.yml
@@ -19,7 +19,7 @@ spring:
   main:
     web-application-type: servlet
   application:
-    name: commerce-api
+    name: commerce-streamer
   profiles:
     active: local
   config:
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
index a13c5bd6a..da767237b 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java
@@ -48,7 +48,6 @@ class MetricsEventProcessingIntegrationTest {
     void setUp() {
         // 테스트 데이터 정리
         productMetricsRepository.deleteAll();
-        eventRepository.deleteAll();
     }
 
     @Test
@@ -202,12 +201,12 @@ void shouldIgnoreOldEvents() throws Exception {
 
         kafkaTemplate.send("catalog-events", oldEnvelope);
 
-        // Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시)
-        Thread.sleep(1000); // 처리 시간 대기
-
-        Optional<ProductMetricsEntity> finalMetrics = productMetricsRepository.findById(productId);
-        assertThat(finalMetrics).isPresent();
-        assertThat(finalMetrics.get().getViewCount()).isEqualTo(1L);
+// Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시)
+        await().atMost(Duration.ofSeconds(2))
+                .until(() -> {
+                    Optional<ProductMetricsEntity> finalMetrics = productMetricsRepository.findById(productId);
+                    return finalMetrics.isPresent() && finalMetrics.get().getViewCount()==1L;
+                });
 
         // 과거 이벤트도 멱등성 테이블에는 기록되어야 함
         assertThat(eventRepository.existsById(recentEventId)).isTrue();

From a6312e79d2dfacdd52ce2c553771a0633601043d Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Tue, 23 Dec 2025 00:14:52 +0900
Subject: [PATCH 27/28] =?UTF-8?q?feat(kafka):=20Kafka=20=ED=86=A0=ED=94=BD?=
 =?UTF-8?q?=20=ED=8C=8C=ED=8B=B0=EC=85=98=20=EC=88=98=203=EC=97=90?=
 =?UTF-8?q?=EC=84=9C=201=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/main/java/com/loopers/confg/kafka/KafkaConfig.java    | 4 ++--
 1 file changed, 2 insertions(+), 2 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 55950412a..434b2b6de 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
@@ -84,7 +84,7 @@ public ConcurrentKafkaListenerContainerFactory<Object, Object> defaultBatchListe
     @Bean
     public NewTopic catalogEventsTopic() {
         return TopicBuilder.name("catalog-events")
-                .partitions(3)
+                .partitions(1)
                 .replicas(1)
                 .build();
     }
@@ -95,7 +95,7 @@ public NewTopic catalogEventsTopic() {
     @Bean
     public NewTopic orderEventsTopic() {
         return TopicBuilder.name("order-events")
-                .partitions(3)
+                .partitions(1)
                 .replicas(1)
                 .build();
     }

From 71fb2bb74c2a2e5c4e773278ca645508c49ca5df Mon Sep 17 00:00:00 2001
From: hyujikoh <dvum0045@gmail.com>
Date: Tue, 23 Dec 2025 00:15:48 +0900
Subject: [PATCH 28/28] =?UTF-8?q?feat(kafka):=20Kafka=20=ED=86=A0=ED=94=BD?=
 =?UTF-8?q?=20=ED=8C=8C=ED=8B=B0=EC=85=98=20=EC=88=98=EB=A5=BC=203?=
 =?UTF-8?q?=EC=97=90=EC=84=9C=201=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/confg/kafka/KafkaConfig.java  | 26 -------------------
 modules/kafka/src/main/resources/kafka.yml    |  4 +--
 2 files changed, 2 insertions(+), 28 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 434b2b6de..5f0f81e3f 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
@@ -3,7 +3,6 @@
 import java.util.HashMap;
 import java.util.Map;
 
-import org.apache.kafka.clients.admin.NewTopic;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -11,7 +10,6 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.kafka.annotation.EnableKafka;
 import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
-import org.springframework.kafka.config.TopicBuilder;
 import org.springframework.kafka.core.*;
 import org.springframework.kafka.listener.ContainerProperties;
 import org.springframework.kafka.support.converter.BatchMessagingMessageConverter;
@@ -75,28 +73,4 @@ public ConcurrentKafkaListenerContainerFactory<Object, Object> defaultBatchListe
         factory.setBatchListener(true);
         return factory;
     }
-
-    // ===== 토픽 설정 =====
-
-    /**
-     * 카탈로그 이벤트 토픽 (상품 조회, 좋아요 이벤트)
-     */
-    @Bean
-    public NewTopic catalogEventsTopic() {
-        return TopicBuilder.name("catalog-events")
-                .partitions(1)
-                .replicas(1)
-                .build();
-    }
-
-    /**
-     * 주문 이벤트 토픽 (주문, 결제 이벤트)
-     */
-    @Bean
-    public NewTopic orderEventsTopic() {
-        return TopicBuilder.name("order-events")
-                .partitions(1)
-                .replicas(1)
-                .build();
-    }
 }
diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml
index 0d3bbe5bc..03a6bf4ce 100644
--- a/modules/kafka/src/main/resources/kafka.yml
+++ b/modules/kafka/src/main/resources/kafka.yml
@@ -47,11 +47,11 @@ kafka:
   topics:
     catalog-events:
       name: catalog-events
-      partitions: 3
+      partitions: 1
       replicas: 1
     order-events:
       name: order-events
-      partitions: 3
+      partitions: 1
       replicas: 1
 
 ---
