Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions reservation/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
6 changes: 6 additions & 0 deletions reservation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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;

/**
* 이벤트 처리 관련 빈 설정
*
* <p>Kafka Consumer가 수신한 EventEnvelope의 payload를 도메인 이벤트로 변환할 때 사용됩니다.</p>
*
*/
@Configuration
public class EventConfig {

@Bean
public EventDeserializer eventDeserializer() {
return new AvroEventDeserializer();
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 사진작가 스케줄 관리 서비스
*
* <p>사진작가의 예약 가능 스케줄을 생성하고 관리합니다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PhotographerScheduleService {

private final PhotographerScheduleRepository photographerScheduleRepository;

/**
* 사진작가의 기본 스케줄을 생성합니다.
*
* <p>이미 스케줄이 존재하면 아무 작업도 수행하지 않습니다 (멱등성 보장).
* 동시 요청으로 인한 중복 생성 시도 시 DataIntegrityViolationException을 catch하여 정상 처리합니다.</p>
*
* @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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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;
import java.nio.ByteBuffer;

/**
* PhotographerCreated 이벤트 Kafka Consumer
*
* <p>member-service에서 사진작가가 생성되면 해당 이벤트를 수신하여
* 기본 스케줄을 생성합니다.</p>
*
* <h3>재시도 정책</h3>
* <ul>
* <li>최대 3회 시도 (원본 1회 + 재시도 2회)</li>
* <li>재시도 간격: 5초 → 10초 (exponential backoff)</li>
* <li>최종 실패 시 DLT(Dead Letter Topic)로 이동</li>
* </ul>
*
* <h3>생성되는 토픽</h3>
* <ul>
* <li>PhotographerCreated (원본)</li>
* <li>PhotographerCreated-retry-0 (5초 후 재시도)</li>
* <li>PhotographerCreated-retry-1 (10초 후 재시도)</li>
* <li>PhotographerCreated-dlt (최종 실패)</li>
* </ul>
*/
@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());

ByteBuffer buffer = envelope.getPayload();
byte[] payloadBytes = new byte[buffer.remaining()];
buffer.duplicate().get(payloadBytes);

PhotographerCreated event = eventDeserializer.deserialize(
payloadBytes,
PhotographerCreated.class
);

photographerScheduleService.createDefaultSchedule(event.getPhotographerId());
}

/**
* DLT(Dead Letter Topic)로 이동된 메시지를 처리합니다.
*
* <p>재시도 횟수를 모두 소진한 메시지가 이 핸들러로 전달됩니다.
* 현재는 로깅만 수행하며, 추후 알림 연동이 필요합니다.</p>
*
* @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 알림 연동
}
}
Original file line number Diff line number Diff line change
@@ -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<PhotographerSchedule, Long> {

/**
* 사진작가 ID로 스케줄을 조회합니다.
*
* @param photographerId 사진작가 ID
* @return 스케줄 (존재하지 않으면 empty)
*/
Optional<PhotographerSchedule> findByPhotographerId(Long photographerId);

/**
* 사진작가 ID로 스케줄 존재 여부를 확인합니다.
*
* @param photographerId 사진작가 ID
* @return 존재하면 true, 없으면 false
*/
boolean existsByPhotographerId(Long photographerId);
}
Original file line number Diff line number Diff line change
@@ -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<PhotographerSchedule> 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<PhotographerSchedule> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading