From bd810f23fa3ca8e051fa946d3befb0388dc25c54 Mon Sep 17 00:00:00 2001 From: hyujikoh 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 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 { +} 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 { +} 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 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 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 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 { +public interface EventJpaRepository extends JpaRepository { } 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 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> messages, - Acknowledgment acknowledgment - ){ + List> 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> records, + Acknowledgment ack) { + for (ConsumerRecord record : records) { + final Map 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 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> records, + final Acknowledgment ack + ) { + for (ConsumerRecord record : records) { + final Map 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 payload = asMap(event.get("payload")); + final List> items = listOfMap(payload.get("items")); + for (Map 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 asMap(final Object value) { + if (value instanceof Map m) { + return (Map) m; + } + return Map.of(); + } + + private List> listOfMap(final Object value) { + if (value instanceof List list) { + return list.stream() + .filter(it -> it instanceof Map) + .map(it -> (Map) 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 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 orderItems = orderService.getOrderItemsByOrderId(order); + + final List 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 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 items +) { + public record Item(Long productId, Integer quantity) { + } +} From 0bf6f96a1f478232bbc34e24cb78c4f46b896f49 Mon Sep 17 00:00:00 2001 From: hyujikoh 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 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 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 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 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 { + + List 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 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 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 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 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 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 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 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 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> records, Acknowledgment ack) { + + log.debug("Processing {} catalog events", records.size()); + for (ConsumerRecord record : records) { - final Map 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 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> records, final Acknowledgment ack ) { + + log.debug("Processing {} order events", records.size()); + for (ConsumerRecord record : records) { - final Map 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 payload = asMap(event.get("payload")); - final List> items = listOfMap(payload.get("items")); - for (Map 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 record) { + final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); + if (envelope == null || envelope.eventId() == null) { + log.warn("Invalid event envelope: {}", record.value()); + return; + } - private Map asMap(final Object value) { - if (value instanceof Map m) { - return (Map) m; + // 멱등성 체크 - 이미 처리된 이벤트는 무시 + final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); + if (!isFirstTime) { + log.debug("Event already processed: {}", envelope.eventId()); + return; } - return Map.of(); - } - private List> listOfMap(final Object value) { - if (value instanceof List list) { - return list.stream() - .filter(it -> it instanceof Map) - .map(it -> (Map) 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 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 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 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 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 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 orderItems = orderService.getOrderItemsByOrderId(order); - final List items = orderItems.stream() - .map(oi -> new PaymentSuccessPayloadV1.Item(oi.getProductId(), oi.getQuantity())) - .toList(); + final OrderEntity order = orderService.getOrderByOrderNumberAndUserId(orderNumber, userId); + final List orderItems = orderService.getOrderItemsByOrderId(order); + + final List 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 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 이벤트 재시도 스케줄러 + *

+ * 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 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 findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus); + /** + * 재시도 가능한 실패 이벤트 조회 + * + * @param status 상태 (FAILED) + * @param maxRetryCount 최대 재시도 횟수 + * @return 재시도 가능한 이벤트 목록 + */ + List 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 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로 즉시 발송하는 핸들러 + *

+ * 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로 즉시 발송하는 핸들러 + *

+ * 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 { List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + + /** + * 재시도 가능한 실패 이벤트 조회 + * + * @param status 상태 (FAILED) + * @param maxRetryCount 최대 재시도 횟수 + * @return 재시도 가능한 이벤트 목록 (최대 50개) + */ + List 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 findTop100ByStatusOrderByCreatedAtAsc(OutboxStatu return outboxEventJpaRepository.findTop100ByStatusOrderByCreatedAtAsc(outboxStatus); } + @Override + public List 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 분산락을 이용한 동시성 안전한 메트릭 서비스 + *

+ * 동일한 상품에 대한 동시 업데이트를 분산락으로 제어하여 + * 메트릭 누락 없이 원자적으로 처리합니다. * * @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)을 학습하며 **신뢰 가능한 이벤트 파이프라인**을 구현합니다. +> + +

+ +지난 라운드에서는 **메세지에 대한 이해**와 애플리케이션 **이벤트를 통한 결합**을 느슨하게 만들어보았습니다. 하지만 애플리케이션 안에서만 이벤트가 머물러 있어, **서비스 경계를 넘는 확장성은 부족**했습니다. + +이번 라운드에서는 이를 해결하기 위해 **Kafka 를 통해 이벤트를 외부로 발행**하고, **별도의 Consumer 앱이 후속 처리를 담당하는 구조**를 학습합니다. + + + +- Kafka Producer & Consumer +- At Most Once / At Least Once / Exactly Once +- Idempotency & 멱등 처리 +- Dead Letter Queue (DLQ) + + + +## 📮 Kafka Overview + + + +### 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 에 배정함 + + + + + `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 발행 보장** + + + +**2️⃣ Consumer ← Broker** + +- 🎯 **어떻게든 한 번만 처리 (At Most Once)** +- `Consumer` 는 같은 메세지가 여러 번 오더라도, 멱등하게 처리하여 최종 결과는 단 한번만 반영되도록 보장해야 합니다. + +### 📌 Consumer 측 패턴: **Transactional Inbox / Idempotent Consumer** + +- 메시지를 받을 때 **Inbox 테이블에 메시지 ID를 먼저 기록** +- 이미 처리된 메시지인지 검사 +- 처리한 뒤 Inbox 상태를 완료로 업데이트 + + → 메시지가 중복 오더라도 같은 ID는 무시 + + → "어떻게 오든 단 한 번만 처리" + + + + +--- + +### 🛡️ 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` 설정 시 **강력한 내구성 보장** + +--- + + + +| 구분 | 링크 | +| --- | --- | +| 🔍 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) | + + + +> **가장 인기 있는 상품을 보여주고 싶은데, 어떻게 알 수 있을까?** +> +> +> +> 이번 주차에는 카프카 메세지를 기반으로 안정적으로 이벤트를 발행하고, 처리하기 위한 방안들을 고민해 보았습니다. 이제 우리 **커머스 서비스**에 필요한 기본기는 많이 갖춘 것 같아요. +> +> 유저가 정말 좋아할 만한 상품은 뭘까요? 앞서 마련한 기반들을 이용해 우리는 실시간으로 상품 랭킹을 만들어 볼 거예요. 차주에는 **랭킹 파이프라인**을 통해 유저가 정말 좋아할 만한 상품들을 진열해 봅시다. +> \ 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 + +> 이번에는 카프카 기반의 **이벤트 파이프라인**을 구현합니다. +각 이벤트를 외부 시스템과 적절하게 주고 받을 수 있는 구조를 직접 체험해봅니다. +> + + + +### 📋 과제 정보 + +**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 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 dynamicKafkaTopics(KafkaTopicProperties properties) { +// // List 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 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 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 orderItems = orderService.getOrderItemsByOrderId(order); + + final List 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 orderItems = orderService.getOrderItemsByOrderId(order); - - final List 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 이벤트 배치 처리 스케줄러 *

- * 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 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 findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status); + List 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 findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status) { + return outboxRepository.findTop50ByStatusOrderByCreatedAtAsc(status); + } + public List 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로 즉시 발송하는 핸들러 - *

- * 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로 즉시 발송하는 핸들러 - *

- * 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; /** * 좋아요 변경 이벤트 처리 *

* 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; /** * 유저 행동 이벤트 처리 *

* 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 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 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 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 findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status); - List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus outboxStatus); + /** + * 지정된 개수만큼 상태별 이벤트 조회 (배치 크기 조절 가능) + */ + List findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit); + /** * 재시도 가능한 실패 이벤트 조회 * @@ -25,5 +28,11 @@ public interface OutboxRepository { List findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc( OutboxStatus status, int maxRetryCount); + /** + * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능) + */ + List 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 findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status) { - return outboxRepository.findTop50ByStatusOrderByCreatedAtAsc(status); + public List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status) { + return outboxRepository.findTop100ByStatusOrderByCreatedAtAsc(status); + } + + /** + * 지정된 개수만큼 READY 상태 이벤트 조회 (배치 크기 조절 가능) + */ + public List findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit) { + return outboxRepository.findTopNByStatusOrderByCreatedAtAsc(status, limit); } public List findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(OutboxStatus outboxStatus, @@ -70,4 +77,13 @@ public List findTop50ByStatusAndRetryCountLessThanOrderByCrea return outboxRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc( outboxStatus, maxRetryCount); } + + /** + * 지정된 개수만큼 재시도 가능한 실패 이벤트 조회 (배치 크기 조절 가능) + */ + public List 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 { List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + /** + * 지정된 개수만큼 상태별 이벤트 조회 (배치 크기 조절 가능) + */ + @Query(value = "SELECT * FROM outbox_event WHERE status = :status ORDER BY created_at ASC LIMIT :limit", + nativeQuery = true) + List findTopNByStatusOrderByCreatedAtAsc( + @Param("status") String status, @Param("limit") int limit); + /** * 재시도 가능한 실패 이벤트 조회 * @@ -20,4 +31,12 @@ public interface OutboxEventJpaRepository extends JpaRepository 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 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 findTop100ByStatusOrderByCreatedAtAsc(OutboxStatu return outboxEventJpaRepository.findTop100ByStatusOrderByCreatedAtAsc(outboxStatus); } + @Override + public List findTopNByStatusOrderByCreatedAtAsc(OutboxStatus status, int limit) { + return outboxEventJpaRepository.findTopNByStatusOrderByCreatedAtAsc(status.name(), limit); + } + @Override public List findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc( OutboxStatus status, int maxRetryCount) { return outboxEventJpaRepository.findTop50ByStatusAndRetryCountLessThanOrderByCreatedAtAsc(status, maxRetryCount); } + @Override + public List 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 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 기반 동시성 안전한 메트릭 서비스 *

- * 동일한 상품에 대한 동시 업데이트를 분산락으로 제어하여 - * 메트릭 누락 없이 원자적으로 처리합니다. + * 상품별 메모리 락을 사용하여 동일한 상품에 대한 동시 업데이트를 제어합니다. + * 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 productLocks = new ConcurrentHashMap<>(); + + // 락 획득 설정 (빠른 처리를 위해 짧게 설정) + private static final long LOCK_WAIT_TIME_MS = 100; // 100ms 대기 + private static final int LOCK_CLEANUP_THRESHOLD = 10000; // 락 정리 임계값 + + // 메모리 기반 멱등성 체크 (성능 최적화) + private final ConcurrentHashMap 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 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 기반 분산락 구현 + *

+ * Lua 스크립트를 사용하여 원자적 락 획득/해제를 보장합니다. + * + * @author hyunjikoh + * @since 2025. 12. 19. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisDistributedLock { + + private final RedisTemplate 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 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 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; + +/** + * 메트릭 서비스의 메모리 락 정리 스케줄러 + *

+ * 주기적으로 사용하지 않는 락과 처리된 이벤트 캐시를 정리하여 메모리 누수를 방지합니다. + * + * @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 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 기반 분산락 구현 - *

- * Lua 스크립트를 사용하여 원자적 락 획득/해제를 보장합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class RedisDistributedLock { - - private final RedisTemplate 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 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 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; + +/** + * 기본 캐시 서비스 + *

+ * Redis 캐시의 공통 연산을 제공합니다. + * 캐시 실패 시 로깅만 하고 서비스는 계속 동작합니다. + * + * @author hyunjikoh + * @since 2025. 12. 19. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BaseCacheService { + + private final RedisTemplate 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 Optional get(String key, Class 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 keys = new HashSet<>(); + try (Cursor 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 @@ /** * 캐시 키 생성기 *

- * 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과 갱신 방식 *

- * - 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 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용) + *

+ * 메트릭 변화 시 관련 캐시를 무효화합니다. + * 공통 모듈의 BaseCacheService를 위임하여 사용합니다. * * @author hyunjikoh * @since 2025. 12. 18. @@ -18,75 +21,35 @@ @Slf4j public class ProductCacheService { - private final RedisTemplate 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 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 orderItems = orderService.getOrderItemsByOrderId(order); - final List 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 orderItems) { + final List 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 findTopNByStatusAndRetryCountLessThanOrderByCreatedAtAsc OutboxStatus status, int maxRetryCount, int limit); OutboxEventEntity save(OutboxEventEntity ready); + + /** + * 여러 이벤트를 일괄 저장 (원자성 보장) + */ + List saveAll(List 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 findTopNByStatusAndRetryCountLessThanOrderByCreat public OutboxEventEntity save(OutboxEventEntity ready) { return outboxEventJpaRepository.save(ready); } + + @Override + public List saveAll(List 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 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; + +/** + * 메트릭 트랜잭션 처리 서비스 + *

+ * 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 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 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 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 인터페이스 + *

+ * 동시성 안전한 메트릭 업데이트 작업을 담당합니다. + * + * @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 구현체 + *

+ * 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 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 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 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 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 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 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 dynamicKafkaTopics(KafkaTopicProperties properties) { -// // List 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 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 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 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 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 Optional get(String key, Class 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 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 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 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 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 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 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 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 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 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 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 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 validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope); + ConsumerRecord 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 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 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 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 *

@@ -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 result1 = productFacade.getProducts(filter1); + productFacade.getProducts(filter1); // Then: 캐시에 저장됨 (WARM 전략) Optional> 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 result0 = productFacade.getProducts(filter0); + productFacade.getProducts(filter0); // Then: 캐시에 저장됨 (Hot 전략) Optional> 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 result1 = productFacade.getProducts(filter1); + productFacade.getProducts(filter1); // Then: 캐시에 저장됨 (Hot 전략) Optional> 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)을 학습하며 **신뢰 가능한 이벤트 파이프라인**을 구현합니다. -> - -

- -지난 라운드에서는 **메세지에 대한 이해**와 애플리케이션 **이벤트를 통한 결합**을 느슨하게 만들어보았습니다. 하지만 애플리케이션 안에서만 이벤트가 머물러 있어, **서비스 경계를 넘는 확장성은 부족**했습니다. - -이번 라운드에서는 이를 해결하기 위해 **Kafka 를 통해 이벤트를 외부로 발행**하고, **별도의 Consumer 앱이 후속 처리를 담당하는 구조**를 학습합니다. - - - -- Kafka Producer & Consumer -- At Most Once / At Least Once / Exactly Once -- Idempotency & 멱등 처리 -- Dead Letter Queue (DLQ) - - - -## 📮 Kafka Overview - - - -### 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 에 배정함 - - - - - `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 발행 보장** - - - -**2️⃣ Consumer ← Broker** - -- 🎯 **어떻게든 한 번만 처리 (At Most Once)** -- `Consumer` 는 같은 메세지가 여러 번 오더라도, 멱등하게 처리하여 최종 결과는 단 한번만 반영되도록 보장해야 합니다. - -### 📌 Consumer 측 패턴: **Transactional Inbox / Idempotent Consumer** - -- 메시지를 받을 때 **Inbox 테이블에 메시지 ID를 먼저 기록** -- 이미 처리된 메시지인지 검사 -- 처리한 뒤 Inbox 상태를 완료로 업데이트 - - → 메시지가 중복 오더라도 같은 ID는 무시 - - → "어떻게 오든 단 한 번만 처리" - - - - ---- - -### 🛡️ 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` 설정 시 **강력한 내구성 보장** - ---- - - - -| 구분 | 링크 | -| --- | --- | -| 🔍 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) | - - - -> **가장 인기 있는 상품을 보여주고 싶은데, 어떻게 알 수 있을까?** -> -> -> -> 이번 주차에는 카프카 메세지를 기반으로 안정적으로 이벤트를 발행하고, 처리하기 위한 방안들을 고민해 보았습니다. 이제 우리 **커머스 서비스**에 필요한 기본기는 많이 갖춘 것 같아요. -> -> 유저가 정말 좋아할 만한 상품은 뭘까요? 앞서 마련한 기반들을 이용해 우리는 실시간으로 상품 랭킹을 만들어 볼 거예요. 차주에는 **랭킹 파이프라인**을 통해 유저가 정말 좋아할 만한 상품들을 진열해 봅시다. -> \ 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 - -> 이번에는 카프카 기반의 **이벤트 파이프라인**을 구현합니다. -각 이벤트를 외부 시스템과 적절하게 주고 받을 수 있는 구조를 직접 체험해봅니다. -> - - - -### 📋 과제 정보 - -**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 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 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 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 { List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); @@ -17,8 +16,8 @@ public interface OutboxEventJpaRepository extends JpaRepository findTopNByStatusOrderByCreatedAtAsc( @Param("status") String status, @Param("limit") int limit); @@ -35,8 +34,8 @@ List 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 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 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 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 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 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 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> records, Acknowledgment ack) { - + log.debug("Processing {} catalog events", records.size()); - + for (ConsumerRecord record : records) { try { processCatalogEvent(record); @@ -65,9 +65,9 @@ public void onOrderEvents( final List> records, final Acknowledgment ack ) { - + log.debug("Processing {} order events", records.size()); - + for (ConsumerRecord 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 record) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); if (envelope == null || envelope.eventId() == null) { @@ -90,8 +90,8 @@ private void processCatalogEvent(ConsumerRecord 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 record) { default -> log.debug("Unhandled catalog event type: {}", envelope.eventType()); } } - + private void processOrderEvent(ConsumerRecord record) { final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); if (envelope == null || envelope.eventId() == null) { @@ -122,8 +122,8 @@ private void processOrderEvent(ConsumerRecord 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 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 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 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 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 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 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 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 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 validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope); ConsumerRecord 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 { * 엔티티의 유효성을 검증한다. * 이 메소드는 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 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 replicas, Consumer 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 RedisTemplate defaultRedisTemplate( - RedisTemplate template, + private RedisTemplate defaultRedisTemplate( + RedisTemplate 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 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 @@ - - + + \ 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 @@ - - + + \ 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 @@ *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 ${SLACK_WEBHOOK_URI} From fc056e2fe1a217ae786053d2b1999c5b24ed7bcc Mon Sep 17 00:00:00 2001 From: hyujikoh 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 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 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 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분마다) *

* 배치 갱신으로 캐시 스탬피드 방지 * 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 finalMetrics = productMetricsRepository.findById(productId); - assertThat(finalMetrics).isPresent(); - assertThat(finalMetrics.get().getViewCount()).isEqualTo(1L); +// Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시) + await().atMost(Duration.ofSeconds(2)) + .until(() -> { + Optional 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 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 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 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 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 ---