From a8d0698c1cad99b11d90bc1f2c6bcd7194444e6d Mon Sep 17 00:00:00 2001 From: chokyungho Date: Fri, 23 Jan 2026 15:53:07 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FEAT]=20kafka=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20event-schema-shared=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reservation/Dockerfile | 24 ++++++++++++++++++++---- reservation/build.gradle | 6 ++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/reservation/Dockerfile b/reservation/Dockerfile index 4832775a..57aefbbe 100644 --- a/reservation/Dockerfile +++ b/reservation/Dockerfile @@ -2,15 +2,31 @@ FROM eclipse-temurin:17-jdk AS builder WORKDIR /app +# Gradle wrapper 및 설정 파일 복사 COPY gradlew . COPY gradle gradle -COPY authorization-shared authorization-shared -COPY reservation reservation +COPY build.gradle . -RUN echo "include 'reservation'" > settings.gradle && \ - echo "include 'authorization-shared'" >> settings.gradle +# reservation 빌드를 위한 settings.gradle 생성 (필요 모듈만 포함) +RUN printf "include 'reservation'\ninclude 'authorization-shared'\ninclude 'event-schema-shared'\n" > settings.gradle +# 의존성 다운로드를 위한 빌드 파일 복사 +COPY authorization-shared/build.gradle authorization-shared/build.gradle +COPY event-schema-shared/build.gradle event-schema-shared/build.gradle +COPY reservation/build.gradle reservation/build.gradle + +# Gradle wrapper 실행 권한 부여 RUN chmod +x gradlew + +# 의존성 다운로드 +RUN ./gradlew :reservation:dependencies --no-daemon || true + +# 전체 소스 코드 복사 +COPY authorization-shared authorization-shared +COPY event-schema-shared event-schema-shared +COPY reservation reservation + +# 빌드 실행 RUN ./gradlew :reservation:clean :reservation:build -x test --no-daemon diff --git a/reservation/build.gradle b/reservation/build.gradle index 4c843bd8..fcaf97df 100644 --- a/reservation/build.gradle +++ b/reservation/build.gradle @@ -40,6 +40,8 @@ jacocoTestReport { } dependencies { + // === Event Schema Shared 모듈 === + implementation project(':event-schema-shared') implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' @@ -56,6 +58,10 @@ dependencies { // === 롬복 의존성 === compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + // === Kafka 의존성 === + implementation 'org.springframework.kafka:spring-kafka' + testImplementation 'org.springframework.kafka:spring-kafka-test' } tasks.named('test') { From 084cde0db9298865a62ab84b6fb5fabd7194d87e Mon Sep 17 00:00:00 2001 From: chokyungho Date: Fri, 23 Jan 2026 16:02:59 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FEAT]=20kafka=20=EC=84=A4=EC=A0=95=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 --- .../event/infrastructure/EventConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java new file mode 100644 index 00000000..47e160d3 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java @@ -0,0 +1,15 @@ +package net.catsnap.CatsnapReservation.event.infrastructure; + +import net.catsnap.shared.application.EventDeserializer; +import net.catsnap.shared.infrastructure.AvroEventDeserializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EventConfig { + + @Bean + public EventDeserializer eventDeserializer() { + return new AvroEventDeserializer(); + } +} From 7c74e27faf7bb73d7fed3b8cdc49bfc7523348c7 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 15:29:04 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[CHORE]=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/infrastructure/EventConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java index 47e160d3..88766fd2 100644 --- a/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/event/infrastructure/EventConfig.java @@ -5,6 +5,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 이벤트 처리 관련 빈 설정 + * + *

Kafka Consumer가 수신한 EventEnvelope의 payload를 도메인 이벤트로 변환할 때 사용됩니다.

+ * + */ @Configuration public class EventConfig { From 7a3846ca41ee436c6dfe7cc6e895f7b13874af5f Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 16:29:53 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[FEAT]=20=EC=8A=A4=EC=BC=80=EC=A4=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=8C=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PhotographerScheduleRepository.java | 27 +++++++ .../PhotographerScheduleRepositoryTest.java | 75 +++++++++++++++++++ .../src/test/resources/application.yml | 3 + 3 files changed, 105 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepository.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepositoryTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepository.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepository.java new file mode 100644 index 00000000..504d449f --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepository.java @@ -0,0 +1,27 @@ +package net.catsnap.CatsnapReservation.schedule.infrastructure.repository; + +import java.util.Optional; +import net.catsnap.CatsnapReservation.schedule.domain.PhotographerSchedule; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 사진작가 스케줄 Repository + */ +public interface PhotographerScheduleRepository extends JpaRepository { + + /** + * 사진작가 ID로 스케줄을 조회합니다. + * + * @param photographerId 사진작가 ID + * @return 스케줄 (존재하지 않으면 empty) + */ + Optional findByPhotographerId(Long photographerId); + + /** + * 사진작가 ID로 스케줄 존재 여부를 확인합니다. + * + * @param photographerId 사진작가 ID + * @return 존재하면 true, 없으면 false + */ + boolean existsByPhotographerId(Long photographerId); +} \ No newline at end of file diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepositoryTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepositoryTest.java new file mode 100644 index 00000000..43601a6b --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/repository/PhotographerScheduleRepositoryTest.java @@ -0,0 +1,75 @@ +package net.catsnap.CatsnapReservation.schedule.infrastructure.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import net.catsnap.CatsnapReservation.schedule.domain.PhotographerSchedule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DisplayName("PhotographerScheduleRepository 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@DataJpaTest +class PhotographerScheduleRepositoryTest { + + @Autowired + private PhotographerScheduleRepository photographerScheduleRepository; + + @Test + void photographerId로_스케줄을_조회할_수_있다() { + // given + Long photographerId = 1L; + PhotographerSchedule schedule = PhotographerSchedule.initSchedule(photographerId); + photographerScheduleRepository.save(schedule); + + // when + Optional found = photographerScheduleRepository.findByPhotographerId(photographerId); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getPhotographerId()).isEqualTo(photographerId); + } + + @Test + void 존재하지_않는_photographerId로_조회하면_빈_Optional을_반환한다() { + // given + Long nonExistentPhotographerId = 999L; + + // when + Optional found = photographerScheduleRepository.findByPhotographerId(nonExistentPhotographerId); + + // then + assertThat(found).isEmpty(); + } + + @Test + void photographerId로_스케줄_존재_여부를_확인할_수_있다() { + // given + Long photographerId = 1L; + PhotographerSchedule schedule = PhotographerSchedule.initSchedule(photographerId); + photographerScheduleRepository.save(schedule); + + // when + boolean exists = photographerScheduleRepository.existsByPhotographerId(photographerId); + + // then + assertThat(exists).isTrue(); + } + + @Test + void 존재하지_않는_photographerId로_존재_여부를_확인하면_false를_반환한다() { + // given + Long nonExistentPhotographerId = 999L; + + // when + boolean exists = photographerScheduleRepository.existsByPhotographerId(nonExistentPhotographerId); + + // then + assertThat(exists).isFalse(); + } +} diff --git a/reservation/src/test/resources/application.yml b/reservation/src/test/resources/application.yml index d11f286a..e169b7d3 100644 --- a/reservation/src/test/resources/application.yml +++ b/reservation/src/test/resources/application.yml @@ -1,4 +1,7 @@ spring: + test: + database: + replace: none datasource: url: jdbc:h2:mem:testdb;MODE=PostgreSQL;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON; driver-class-name: org.h2.Driver From 80389c839c547d92d17daf1cd5ea0314d9b47fc3 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 16:35:35 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=EC=9E=91=EA=B0=80=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EC=83=9D=EC=84=B1=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PhotographerScheduleService.java | 45 ++++++++ ...grapherScheduleServiceIntegrationTest.java | 101 ++++++++++++++++++ .../fixture/PhotographerScheduleFixture.java | 49 +++++++++ 3 files changed, 195 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleService.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleServiceIntegrationTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/fixture/PhotographerScheduleFixture.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleService.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleService.java new file mode 100644 index 00000000..793471e6 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleService.java @@ -0,0 +1,45 @@ +package net.catsnap.CatsnapReservation.schedule.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.catsnap.CatsnapReservation.schedule.domain.PhotographerSchedule; +import net.catsnap.CatsnapReservation.schedule.infrastructure.repository.PhotographerScheduleRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사진작가 스케줄 관리 서비스 + * + *

사진작가의 예약 가능 스케줄을 생성하고 관리합니다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PhotographerScheduleService { + + private final PhotographerScheduleRepository photographerScheduleRepository; + + /** + * 사진작가의 기본 스케줄을 생성합니다. + * + *

이미 스케줄이 존재하면 아무 작업도 수행하지 않습니다 (멱등성 보장). + * 동시 요청으로 인한 중복 생성 시도 시 DataIntegrityViolationException을 catch하여 정상 처리합니다.

+ * + * @param photographerId 사진작가 ID + */ + @Transactional + public void createDefaultSchedule(Long photographerId) { + if (photographerScheduleRepository.existsByPhotographerId(photographerId)) { + return; + } + + try { + PhotographerSchedule schedule = PhotographerSchedule.initSchedule(photographerId); + photographerScheduleRepository.saveAndFlush(schedule); + log.info("Schedule created: photographerId={}", photographerId); + } catch (DataIntegrityViolationException e) { + log.info("Schedule already created by concurrent request: photographerId={}", photographerId); + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleServiceIntegrationTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleServiceIntegrationTest.java new file mode 100644 index 00000000..82be2f2b --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/application/PhotographerScheduleServiceIntegrationTest.java @@ -0,0 +1,101 @@ +package net.catsnap.CatsnapReservation.schedule.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import net.catsnap.CatsnapReservation.schedule.domain.PhotographerSchedule; +import net.catsnap.CatsnapReservation.schedule.fixture.PhotographerScheduleFixture; +import net.catsnap.CatsnapReservation.schedule.infrastructure.repository.PhotographerScheduleRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@DisplayName("PhotographerScheduleService 통합 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@SpringBootTest +@Transactional +class PhotographerScheduleServiceIntegrationTest { + + @Autowired + private PhotographerScheduleService photographerScheduleService; + + @Autowired + private PhotographerScheduleRepository photographerScheduleRepository; + + @BeforeEach + void setUp() { + photographerScheduleRepository.deleteAll(); + } + + @Test + void 새로운_photographerId로_기본_스케줄을_생성한다() { + // given + Long photographerId = 1L; + + // when + photographerScheduleService.createDefaultSchedule(photographerId); + + // then + Optional found = photographerScheduleRepository.findByPhotographerId(photographerId); + assertThat(found).isPresent(); + assertThat(found.get().getPhotographerId()).isEqualTo(photographerId); + assertThat(found.get().getWeekdayRules()).hasSize(7); + } + + @Test + void 이미_스케줄이_존재하면_중복_생성하지_않는다() { + // given + Long photographerId = 1L; + PhotographerSchedule existingSchedule = PhotographerScheduleFixture.createWithPhotographerId(photographerId); + photographerScheduleRepository.save(existingSchedule); + Long originalId = existingSchedule.getId(); + + // when + photographerScheduleService.createDefaultSchedule(photographerId); + + // then + Optional found = photographerScheduleRepository.findByPhotographerId(photographerId); + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(originalId); + assertThat(photographerScheduleRepository.count()).isEqualTo(1); + } + + @Test + void 동일한_photographerId로_여러번_호출해도_스케줄은_하나만_존재한다() { + // given + Long photographerId = 1L; + + // when + photographerScheduleService.createDefaultSchedule(photographerId); + photographerScheduleService.createDefaultSchedule(photographerId); + photographerScheduleService.createDefaultSchedule(photographerId); + + // then + assertThat(photographerScheduleRepository.count()).isEqualTo(1); + } + + @Test + void 서로_다른_photographerId로_호출하면_각각_스케줄이_생성된다() { + // given + Long photographerId1 = 1L; + Long photographerId2 = 2L; + Long photographerId3 = 3L; + + // when + photographerScheduleService.createDefaultSchedule(photographerId1); + photographerScheduleService.createDefaultSchedule(photographerId2); + photographerScheduleService.createDefaultSchedule(photographerId3); + + // then + assertThat(photographerScheduleRepository.count()).isEqualTo(3); + assertThat(photographerScheduleRepository.findByPhotographerId(photographerId1)).isPresent(); + assertThat(photographerScheduleRepository.findByPhotographerId(photographerId2)).isPresent(); + assertThat(photographerScheduleRepository.findByPhotographerId(photographerId3)).isPresent(); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/fixture/PhotographerScheduleFixture.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/fixture/PhotographerScheduleFixture.java new file mode 100644 index 00000000..f7ab2961 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/fixture/PhotographerScheduleFixture.java @@ -0,0 +1,49 @@ +package net.catsnap.CatsnapReservation.schedule.fixture; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.List; +import net.catsnap.CatsnapReservation.schedule.domain.PhotographerSchedule; +import net.catsnap.CatsnapReservation.schedule.domain.vo.AvailableStartTimes; + +/** + * PhotographerSchedule 테스트용 Fixture + */ +public class PhotographerScheduleFixture { + + public static final Long DEFAULT_PHOTOGRAPHER_ID = 1L; + + /** + * 기본 스케줄 생성 (모든 요일 휴무) + */ + public static PhotographerSchedule createDefault() { + return PhotographerSchedule.initSchedule(DEFAULT_PHOTOGRAPHER_ID); + } + + /** + * 지정된 photographerId로 기본 스케줄 생성 + */ + public static PhotographerSchedule createWithPhotographerId(Long photographerId) { + return PhotographerSchedule.initSchedule(photographerId); + } + + /** + * 평일 근무 스케줄 생성 (월~금 9:00, 10:00, 11:00) + */ + public static PhotographerSchedule createWeekdaySchedule(Long photographerId) { + PhotographerSchedule schedule = PhotographerSchedule.initSchedule(photographerId); + AvailableStartTimes workingTimes = AvailableStartTimes.of(List.of( + LocalTime.of(9, 0), + LocalTime.of(10, 0), + LocalTime.of(11, 0) + )); + + schedule.updateWeekdayRule(DayOfWeek.MONDAY, workingTimes); + schedule.updateWeekdayRule(DayOfWeek.TUESDAY, workingTimes); + schedule.updateWeekdayRule(DayOfWeek.WEDNESDAY, workingTimes); + schedule.updateWeekdayRule(DayOfWeek.THURSDAY, workingTimes); + schedule.updateWeekdayRule(DayOfWeek.FRIDAY, workingTimes); + + return schedule; + } +} From 9d00c2e3d8bece7ee6e818d9a82c09e6a4c27884 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 16:49:54 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[FEAT]=20kafka=20=EC=9E=91=EA=B0=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PhotographerCreatedEventConsumer.java | 87 +++++++++++++++ .../PhotographerCreatedEventConsumerTest.java | 104 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java new file mode 100644 index 00000000..61485176 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java @@ -0,0 +1,87 @@ +package net.catsnap.CatsnapReservation.schedule.infrastructure.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.catsnap.CatsnapReservation.schedule.application.PhotographerScheduleService; +import net.catsnap.event.photographer.v1.PhotographerCreated; +import net.catsnap.event.shared.EventEnvelope; +import net.catsnap.shared.application.EventDeserializer; +import org.springframework.kafka.annotation.DltHandler; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.annotation.RetryableTopic; +import org.springframework.kafka.retrytopic.DltStrategy; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.retry.annotation.Backoff; +import org.springframework.stereotype.Component; + +/** + * PhotographerCreated 이벤트 Kafka Consumer + * + *

member-service에서 사진작가가 생성되면 해당 이벤트를 수신하여 + * 기본 스케줄을 생성합니다.

+ * + *

재시도 정책

+ *
    + *
  • 최대 3회 시도 (원본 1회 + 재시도 2회)
  • + *
  • 재시도 간격: 5초 → 10초 (exponential backoff)
  • + *
  • 최종 실패 시 DLT(Dead Letter Topic)로 이동
  • + *
+ * + *

생성되는 토픽

+ *
    + *
  • PhotographerCreated (원본)
  • + *
  • PhotographerCreated-retry-0 (5초 후 재시도)
  • + *
  • PhotographerCreated-retry-1 (10초 후 재시도)
  • + *
  • PhotographerCreated-dlt (최종 실패)
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PhotographerCreatedEventConsumer { + + private final PhotographerScheduleService photographerScheduleService; + private final EventDeserializer eventDeserializer; + + /** + * PhotographerCreated 이벤트를 처리합니다. + * + * @param envelope Kafka로부터 수신한 이벤트 envelope + */ + @RetryableTopic( + attempts = "3", + backoff = @Backoff(delay = 5000, multiplier = 2), + dltStrategy = DltStrategy.FAIL_ON_ERROR, + autoCreateTopics = "true" + ) + @KafkaListener(topics = "PhotographerCreated", groupId = "schedule-create") + public void consume(EventEnvelope envelope) { + log.info("Received PhotographerCreated event: eventId={}, aggregateId={}", + envelope.getEventId(), envelope.getAggregateId()); + + PhotographerCreated event = eventDeserializer.deserialize( + envelope.getPayload().array(), + PhotographerCreated.class + ); + + photographerScheduleService.createDefaultSchedule(event.getPhotographerId()); + } + + /** + * DLT(Dead Letter Topic)로 이동된 메시지를 처리합니다. + * + *

재시도 횟수를 모두 소진한 메시지가 이 핸들러로 전달됩니다. + * 현재는 로깅만 수행하며, 추후 알림 연동이 필요합니다.

+ * + * @param envelope 처리 실패한 이벤트 envelope + * @param errorMessage 실패 원인 메시지 + */ + @DltHandler + public void handleDlt(EventEnvelope envelope, + @Header(KafkaHeaders.EXCEPTION_MESSAGE) String errorMessage) { + log.error("DLT 이동 - eventId: {}, aggregateId: {}, error: {}", + envelope.getEventId(), envelope.getAggregateId(), errorMessage); + // TODO: Slack/Discord 알림 연동 + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java new file mode 100644 index 00000000..13fc05fd --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java @@ -0,0 +1,104 @@ +package net.catsnap.CatsnapReservation.schedule.infrastructure.event; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.Map; +import net.catsnap.CatsnapReservation.schedule.application.PhotographerScheduleService; +import net.catsnap.event.photographer.v1.PhotographerCreated; +import net.catsnap.event.shared.EventEnvelope; +import net.catsnap.shared.application.EventDeserializer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +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; + +@DisplayName("PhotographerCreatedEventConsumer 단위 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class PhotographerCreatedEventConsumerTest { + + @InjectMocks + private PhotographerCreatedEventConsumer consumer; + + @Mock + private PhotographerScheduleService photographerScheduleService; + + @Mock + private EventDeserializer eventDeserializer; + + @Test + void 이벤트를_수신하면_스케줄_생성_서비스를_호출한다() { + // given + Long photographerId = 1L; + EventEnvelope envelope = createEventEnvelope("event-1", "1"); + PhotographerCreated event = new PhotographerCreated(photographerId); + + given(eventDeserializer.deserialize(any(byte[].class), eq(PhotographerCreated.class))) + .willReturn(event); + + // when + consumer.consume(envelope); + + // then + then(photographerScheduleService).should().createDefaultSchedule(photographerId); + } + + @Test + void 이벤트_페이로드를_역직렬화한다() { + // given + Long photographerId = 1L; + byte[] payloadBytes = "test-payload".getBytes(); + EventEnvelope envelope = createEventEnvelope("event-1", "1", payloadBytes); + PhotographerCreated event = new PhotographerCreated(photographerId); + + given(eventDeserializer.deserialize(eq(payloadBytes), eq(PhotographerCreated.class))) + .willReturn(event); + + // when + consumer.consume(envelope); + + // then + then(eventDeserializer).should().deserialize(payloadBytes, PhotographerCreated.class); + } + + @Test + void DLT_핸들러가_예외_없이_실행된다() { + // given + EventEnvelope envelope = createEventEnvelope("event-1", "1"); + String errorMessage = "Test error message"; + + // when & then (예외 없이 실행되면 성공) + consumer.handleDlt(envelope, errorMessage); + + // 서비스는 호출되지 않음 + then(photographerScheduleService).should(never()).createDefaultSchedule(any()); + } + + private EventEnvelope createEventEnvelope(String eventId, String aggregateId) { + return createEventEnvelope(eventId, aggregateId, "payload".getBytes()); + } + + private EventEnvelope createEventEnvelope(String eventId, String aggregateId, byte[] payload) { + return EventEnvelope.newBuilder() + .setEventId(eventId) + .setEventType("PhotographerCreated") + .setAggregateId(aggregateId) + .setAggregateType("Photographer") + .setVersion(1) + .setTimestamp(Instant.now()) + .setPayload(ByteBuffer.wrap(payload)) + .setMetadata(Map.of()) + .build(); + } +} From 5c4ce68312ee518e868ea83f4590d7e25716f716 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 20:00:36 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[REFACT]=20PhotographerCreatedEventConsumer?= =?UTF-8?q?=EC=97=90=EC=84=9C=20ByteBuffer=20payload=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/PhotographerCreatedEventConsumer.java | 7 ++++++- .../event/PhotographerCreatedEventConsumerTest.java | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java index 61485176..6f48ef0c 100644 --- a/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumer.java @@ -14,6 +14,7 @@ import org.springframework.messaging.handler.annotation.Header; import org.springframework.retry.annotation.Backoff; import org.springframework.stereotype.Component; +import java.nio.ByteBuffer; /** * PhotographerCreated 이벤트 Kafka Consumer @@ -60,8 +61,12 @@ public void consume(EventEnvelope envelope) { log.info("Received PhotographerCreated event: eventId={}, aggregateId={}", envelope.getEventId(), envelope.getAggregateId()); + ByteBuffer buffer = envelope.getPayload(); + byte[] payloadBytes = new byte[buffer.remaining()]; + buffer.duplicate().get(payloadBytes); + PhotographerCreated event = eventDeserializer.deserialize( - envelope.getPayload().array(), + payloadBytes, PhotographerCreated.class ); diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java index 13fc05fd..50055b87 100644 --- a/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/schedule/infrastructure/event/PhotographerCreatedEventConsumerTest.java @@ -1,6 +1,7 @@ package net.catsnap.CatsnapReservation.schedule.infrastructure.event; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -8,6 +9,7 @@ import java.nio.ByteBuffer; import java.time.Instant; +import java.util.Arrays; import java.util.Map; import net.catsnap.CatsnapReservation.schedule.application.PhotographerScheduleService; import net.catsnap.event.photographer.v1.PhotographerCreated; @@ -62,14 +64,15 @@ class PhotographerCreatedEventConsumerTest { EventEnvelope envelope = createEventEnvelope("event-1", "1", payloadBytes); PhotographerCreated event = new PhotographerCreated(photographerId); - given(eventDeserializer.deserialize(eq(payloadBytes), eq(PhotographerCreated.class))) + given(eventDeserializer.deserialize(any(byte[].class), eq(PhotographerCreated.class))) .willReturn(event); // when consumer.consume(envelope); // then - then(eventDeserializer).should().deserialize(payloadBytes, PhotographerCreated.class); + then(eventDeserializer).should() + .deserialize(argThat(bytes -> Arrays.equals(bytes, payloadBytes)), eq(PhotographerCreated.class)); } @Test