Skip to content
Open
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
6 changes: 6 additions & 0 deletions reservation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ dependencies {
// === Kafka 의존성 ===
implementation 'org.springframework.kafka:spring-kafka'
testImplementation 'org.springframework.kafka:spring-kafka-test'

// === Spring Validation 의존성 ===
implementation 'org.springframework.boot:spring-boot-starter-validation'

// === ArchUnit 아키텍처 테스트 의존성 ===
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package net.catsnap.CatsnapReservation.program.application;

import net.catsnap.CatsnapReservation.program.domain.Program;
import net.catsnap.CatsnapReservation.program.application.dto.request.ProgramCreateRequest;
import net.catsnap.CatsnapReservation.program.application.dto.response.ProgramResponse;
import net.catsnap.CatsnapReservation.program.infrastructure.repository.ProgramRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* Program 애그리거트의 Application Service
*
* <p>도메인 계층의 객체들을 오케스트레이션하여 비즈니스 유스케이스를 구현합니다.
*/
@Service
public class ProgramService {

private final ProgramRepository programRepository;

public ProgramService(ProgramRepository programRepository) {
this.programRepository = programRepository;
}

/**
* 프로그램 생성 유스케이스
* <p>
* 원시 타입을 도메인 엔티티에 전달하고, 도메인 엔티티가 VO 생성 및 검증을 담당합니다.
*
* @param photographerId 작가 ID
* @param request 프로그램 생성 요청 정보
* @return 생성된 프로그램 응답
*/
@Transactional
public ProgramResponse createProgram(Long photographerId, ProgramCreateRequest request) {
Program program = Program.create(
photographerId,
request.title(),
request.description(),
request.price(),
request.durationMinutes()
);

Program savedProgram = programRepository.save(program);

return new ProgramResponse(savedProgram.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.catsnap.CatsnapReservation.program.application.dto.request;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

/**
* 프로그램 생성 요청 DTO
*/
public record ProgramCreateRequest(
@NotBlank(message = "프로그램 제목은 필수입니다")
String title,

String description,

@NotNull(message = "가격은 필수입니다")
@Min(value = 0, message = "가격은 0원 이상이어야 합니다")
Long price,

@NotNull(message = "소요 시간은 필수입니다")
@Min(value = 1, message = "소요 시간은 1분 이상이어야 합니다")
Integer durationMinutes
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.catsnap.CatsnapReservation.program.application.dto.response;

/**
* 프로그램 생성 응답 DTO
*/
public record ProgramResponse(
Long id
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package net.catsnap.CatsnapReservation.program.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import net.catsnap.CatsnapReservation.program.domain.vo.Description;
import net.catsnap.CatsnapReservation.program.domain.vo.Duration;
import net.catsnap.CatsnapReservation.program.domain.vo.Price;
import net.catsnap.CatsnapReservation.program.domain.vo.Title;
import net.catsnap.CatsnapReservation.program.infrastructure.converter.DescriptionConverter;
import net.catsnap.CatsnapReservation.program.infrastructure.converter.DurationConverter;
import net.catsnap.CatsnapReservation.program.infrastructure.converter.PriceConverter;
import net.catsnap.CatsnapReservation.program.infrastructure.converter.TitleConverter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

/**
* 작가 프로그램 엔티티 (Aggregate Root)
* <p>
* 작가가 제공하는 촬영 프로그램을 표현하는 도메인 엔티티입니다.
* 프로그램 제목, 설명, 가격, 소요 시간 정보를 관리합니다.
*/
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Program {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long photographerId;

@Column(nullable = false, length = 100)
@Convert(converter = TitleConverter.class)
private Title title;

@Column(length = 500)
@Convert(converter = DescriptionConverter.class)
private Description description;

@Column(nullable = false)
@Convert(converter = PriceConverter.class)
private Price price;

@Column(nullable = false)
@Convert(converter = DurationConverter.class)
private Duration duration;

private LocalDateTime deletedAt;

@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column(nullable = false)
Comment on lines +65 to +69
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createdAt/updatedAt@CreatedDate/@LastModifiedDate를 사용하면서 컬럼을 nullable = false로 지정했는데, reservation 모듈에서는 @EnableJpaAuditing 설정을 찾을 수 없습니다. 이 상태면 엔티티 저장 시 auditing 값이 채워지지 않아 insert 시점에 NOT NULL 제약 위반으로 실패할 수 있습니다. reservation 애플리케이션에 JPA Auditing 활성화(@EnableJpaAuditing) 설정을 추가하거나, 해당 컬럼을 nullable로 두고 직접 값을 세팅하는 방식으로 수정이 필요합니다.

Suggested change
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column

Copilot uses AI. Check for mistakes.
private LocalDateTime updatedAt;

private Program(
Long photographerId,
Title title,
Description description,
Price price,
Duration duration
) {
validatePhotographerId(photographerId);
this.photographerId = photographerId;
this.title = title;
this.description = description;
this.price = price;
this.duration = duration;
}

/**
* 새로운 프로그램 생성
* <p>
* 원시 타입을 받아 내부에서 VO를 생성하고 검증합니다.
* 도메인 엔티티가 자신의 불변식(invariant)을 보장합니다.
*
* @param photographerId 작가 ID
* @param titleValue 프로그램 제목
* @param descriptionValue 프로그램 설명 (nullable)
* @param priceValue 가격
* @param durationMinutes 소요 시간 (분)
* @return 생성된 Program
*/
public static Program create(
Long photographerId,
String titleValue,
String descriptionValue,
Long priceValue,
Integer durationMinutes
) {
Title title = new Title(titleValue);
Description description = new Description(descriptionValue);
Price price = new Price(priceValue);
Duration duration = new Duration(durationMinutes);

return new Program(photographerId, title, description, price, duration);
}

/**
* 프로그램 정보 수정
* <p>
* 원시 타입을 받아 내부에서 VO를 생성하고 검증합니다.
*
* @param titleValue 프로그램 제목
* @param descriptionValue 프로그램 설명 (nullable)
* @param priceValue 가격
* @param durationMinutes 소요 시간 (분)
*/
public void update(
String titleValue,
String descriptionValue,
Long priceValue,
Integer durationMinutes
) {
this.title = new Title(titleValue);
this.description = new Description(descriptionValue);
this.price = new Price(priceValue);
this.duration = new Duration(durationMinutes);
}

/**
* 프로그램 소프트 삭제
*/
public void delete() {
this.deletedAt = LocalDateTime.now();
}
Comment on lines +140 to +142
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

delete() 메서드의 테스트 용이성 개선을 권장합니다.

LocalDateTime.now()를 직접 호출하면 단위 테스트에서 시간을 제어하기 어렵습니다. Clock을 주입하거나 삭제 시간을 파라미터로 받는 방식을 고려해 보세요.

♻️ Clock 사용 예시
// 엔티티에 Clock 필드 추가 또는 메서드 파라미터로 전달
public void delete(Clock clock) {
    this.deletedAt = LocalDateTime.now(clock);
}

// 기본 메서드 오버로드 (기존 호출 유지)
public void delete() {
    delete(Clock.systemDefaultZone());
}
🤖 Prompt for AI Agents
In
`@reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/Program.java`
around lines 140 - 142, The delete() method sets deletedAt using
LocalDateTime.now(), which makes tests time-dependent; modify Program.delete to
accept a Clock parameter (e.g., delete(Clock clock)) and set deletedAt =
LocalDateTime.now(clock), and add a no-arg overload delete() that delegates to
delete(Clock.systemDefaultZone()) so existing callers remain unchanged; update
references to Program.delete in tests to pass a fixed Clock to allow
deterministic unit tests.


/**
* 삭제 여부 확인
*/
public boolean isDeleted() {
return deletedAt != null;
}

/**
* 작가 소유권 확인
*/
public boolean isOwnedBy(Long photographerId) {
return this.photographerId.equals(photographerId);
}

private void validatePhotographerId(Long photographerId) {
if (photographerId == null) {
throw new IllegalArgumentException("작가 ID는 필수입니다.");
}
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Program program = (Program) o;
return Objects.equals(id, program.id);
}

@Override
public int hashCode() {
return Objects.hash(id);
}
Comment on lines +164 to +179
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

JPA 엔티티의 equals()/hashCode() 구현 시 주의사항

현재 구현은 id만으로 동등성을 판단합니다. 이 방식은 일반적이지만, 영속화되지 않은 엔티티(id == null)들이 HashSet이나 HashMap에 추가된 후 영속화되면 hashCode가 변경되어 컬렉션에서 해당 엔티티를 찾을 수 없게 될 수 있습니다.

현재 사용 패턴에서 이 문제가 발생하지 않는다면 괜찮지만, 향후 영속화 전 엔티티를 컬렉션에 담는 경우가 생긴다면 주의가 필요합니다.

♻️ 대안: id가 null일 때 인스턴스 비교 사용
 `@Override`
 public boolean equals(Object o) {
     if (this == o) {
         return true;
     }
     if (o == null || getClass() != o.getClass()) {
         return false;
     }
     Program program = (Program) o;
-    return Objects.equals(id, program.id);
+    return id != null && Objects.equals(id, program.id);
 }

 `@Override`
 public int hashCode() {
-    return Objects.hash(id);
+    return getClass().hashCode();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Program program = (Program) o;
return Objects.equals(id, program.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
`@Override`
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Program program = (Program) o;
return id != null && Objects.equals(id, program.id);
}
`@Override`
public int hashCode() {
return getClass().hashCode();
}
🤖 Prompt for AI Agents
In
`@reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/Program.java`
around lines 164 - 179, The equals/hashCode use only the id field which breaks
collections when id is null; update Program.equals(Object) to treat transient
entities specially by returning this == o when either id is null (i.e., if id ==
null then fall back to instance equality) and otherwise compare ids, and update
Program.hashCode() to return System.identityHashCode(this) (or another stable
identity-based value) when id is null and Objects.hash(id) when id is non-null
so the hashCode remains stable before/after persistence.


@Override
public String toString() {
return String.format("Program{id=%d, photographerId=%d, title=%s}",
id, photographerId, title);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package net.catsnap.CatsnapReservation.program.domain.vo;

import java.util.Objects;
import lombok.Getter;
import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode;
import net.catsnap.CatsnapReservation.shared.domain.error.DomainException;

/**
* 프로그램 설명 값 객체 (Value Object)
*
* <p>프로그램의 간단 설명을 표현하는 불변 객체입니다.
* null을 허용하며, 값이 있는 경우 최대 500자까지 허용합니다.
*/
@Getter
public class Description {

private static final int MAX_LENGTH = 500;

private final String value;

public Description(String value) {
validate(value);
this.value = value;
}

private void validate(String value) {
if (value != null && value.length() > MAX_LENGTH) {
String message = String.format("프로그램 설명은 %d자 이하여야 합니다. 현재: %d자",
MAX_LENGTH, value.length());
throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, message);
}
}

public boolean isEmpty() {
return value == null || value.isBlank();
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Description that = (Description) o;
return Objects.equals(value, that.value);
}

@Override
public int hashCode() {
return Objects.hash(value);
}

@Override
public String toString() {
return value == null ? "" : value;
}
}
Loading
Loading