diff --git a/build.gradle b/build.gradle index 1d92d52..6a5952b 100644 --- a/build.gradle +++ b/build.gradle @@ -29,11 +29,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/chaeso/zip/server/sample/application/SampleService.java b/src/main/java/chaeso/zip/server/sample/application/SampleService.java index 08817b3..4fa12d3 100644 --- a/src/main/java/chaeso/zip/server/sample/application/SampleService.java +++ b/src/main/java/chaeso/zip/server/sample/application/SampleService.java @@ -1,46 +1,32 @@ package chaeso.zip.server.sample.application; +import chaeso.zip.server.sample.application.dto.CreateSampleCommand; import chaeso.zip.server.sample.application.dto.SampleResponse; -import chaeso.zip.server.sample.domain.Sample; -import chaeso.zip.server.sample.domain.SampleNotFoundException; -import chaeso.zip.server.sample.domain.SampleRepository; import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** - * 샘플 애플리케이션 서비스. 트랜잭션 경계와 유스케이스 흐름을 담당하며 도메인 객체에 작업을 위임한다. + * 샘플 애플리케이션 서비스. 유스케이스 진입점(인터페이스)으로, 구현은 {@link SampleServiceImpl} 가 담당한다. * *

컨벤션: *

*/ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SampleService { +public interface SampleService { - private final SampleRepository sampleRepository; + /** + * 커맨드를 받아 새로운 샘플을 생성한다. + */ + SampleResponse create(CreateSampleCommand command); - @Transactional - public SampleResponse create(String name) { - Sample sample = sampleRepository.save(Sample.create(name)); - return SampleResponse.from(sample); - } + /** + * 식별자로 샘플을 단건 조회한다. + */ + SampleResponse getById(Long id); - public SampleResponse getById(Long id) { - Sample sample = sampleRepository.findById(id) - .orElseThrow(() -> new SampleNotFoundException(id)); - return SampleResponse.from(sample); - } - - public List getAll() { - return sampleRepository.findAll().stream() - .map(SampleResponse::from) - .toList(); - } + /** + * 전체 샘플 목록을 조회한다. + */ + List getAll(); } diff --git a/src/main/java/chaeso/zip/server/sample/application/SampleServiceImpl.java b/src/main/java/chaeso/zip/server/sample/application/SampleServiceImpl.java new file mode 100644 index 0000000..adacf45 --- /dev/null +++ b/src/main/java/chaeso/zip/server/sample/application/SampleServiceImpl.java @@ -0,0 +1,49 @@ +package chaeso.zip.server.sample.application; + +import chaeso.zip.server.sample.application.dto.CreateSampleCommand; +import chaeso.zip.server.sample.application.dto.SampleResponse; +import chaeso.zip.server.sample.domain.Sample; +import chaeso.zip.server.sample.domain.SampleNotFoundException; +import chaeso.zip.server.sample.domain.SampleRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * {@link SampleService} 구현체. 트랜잭션 경계와 유스케이스 흐름을 담당하며 도메인 객체에 작업을 위임한다. + * + *

컨벤션: + *

+ */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SampleServiceImpl implements SampleService { + + private final SampleRepository sampleRepository; + + @Override + @Transactional + public SampleResponse create(CreateSampleCommand command) { + Sample sample = sampleRepository.save(Sample.create(command.name())); + return SampleResponse.from(sample); + } + + @Override + public SampleResponse getById(Long id) { + Sample sample = sampleRepository.findById(id) + .orElseThrow(() -> new SampleNotFoundException(id)); + return SampleResponse.from(sample); + } + + @Override + public List getAll() { + return sampleRepository.findAll().stream() + .map(SampleResponse::from) + .toList(); + } +} diff --git a/src/main/java/chaeso/zip/server/sample/application/dto/CreateSampleCommand.java b/src/main/java/chaeso/zip/server/sample/application/dto/CreateSampleCommand.java new file mode 100644 index 0000000..bba39aa --- /dev/null +++ b/src/main/java/chaeso/zip/server/sample/application/dto/CreateSampleCommand.java @@ -0,0 +1,15 @@ +package chaeso.zip.server.sample.application.dto; + +/** + * 샘플 생성 유스케이스의 입력 커맨드. 애플리케이션 계층의 입력 경계로, 표현 계층의 요청 DTO 와 분리한다. + * + *

컨벤션: + *

    + *
  • 애플리케이션 서비스의 입력은 원시 타입 나열 대신 Command 객체로 받는다
  • + *
  • HTTP/검증 관심사(웹 어노테이션)는 표현 계층 요청 DTO 가, 유스케이스 입력 형태는 Command 가 담당
  • + *
  • 표현 계층 요청 DTO 의 {@code toCommand()} 로 변환해 전달한다
  • + *
+ */ +public record CreateSampleCommand(String name) { + +} diff --git a/src/main/java/chaeso/zip/server/sample/presentation/SampleController.java b/src/main/java/chaeso/zip/server/sample/presentation/SampleController.java index aa64bef..586c2a7 100644 --- a/src/main/java/chaeso/zip/server/sample/presentation/SampleController.java +++ b/src/main/java/chaeso/zip/server/sample/presentation/SampleController.java @@ -43,7 +43,7 @@ public class SampleController { @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse create(@Valid @RequestBody CreateSampleRequest request) { - return ApiResponse.success(sampleService.create(request.name())); + return ApiResponse.success(sampleService.create(request.toCommand())); } @Operation(summary = "샘플 단건 조회", description = "식별자로 샘플을 조회한다.") diff --git a/src/main/java/chaeso/zip/server/sample/presentation/dto/CreateSampleRequest.java b/src/main/java/chaeso/zip/server/sample/presentation/dto/CreateSampleRequest.java index 722e5ab..bf7fffa 100644 --- a/src/main/java/chaeso/zip/server/sample/presentation/dto/CreateSampleRequest.java +++ b/src/main/java/chaeso/zip/server/sample/presentation/dto/CreateSampleRequest.java @@ -1,11 +1,12 @@ package chaeso.zip.server.sample.presentation.dto; +import chaeso.zip.server.sample.application.dto.CreateSampleCommand; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; /** - * 샘플 생성 요청 DTO. 요청 검증은 표현 계층 DTO 에서 수행한다. + * 샘플 생성 요청 DTO. 요청 검증은 표현 계층 DTO 에서 수행하고, {@link #toCommand()} 로 애플리케이션 커맨드로 변환한다. */ @Schema(description = "샘플 생성 요청") public record CreateSampleRequest( @@ -14,4 +15,7 @@ public record CreateSampleRequest( @Size(max = 100, message = "이름은 100자를 초과할 수 없습니다.") String name) { + public CreateSampleCommand toCommand() { + return new CreateSampleCommand(name); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e89950b..41402d9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -8,9 +8,12 @@ spring: password: ${DB_PASSWORD:postgres} hikari: maximum-pool-size: ${DB_POOL_SIZE:10} + flyway: + enabled: ${FLYWAY_ENABLED:true} + baseline-on-migrate: true jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} + ddl-auto: ${JPA_DDL_AUTO:validate} properties: hibernate: format_sql: true diff --git a/src/main/resources/db/migration/V1__create_sample_table.sql b/src/main/resources/db/migration/V1__create_sample_table.sql new file mode 100644 index 0000000..04fa65a --- /dev/null +++ b/src/main/resources/db/migration/V1__create_sample_table.sql @@ -0,0 +1,9 @@ +-- 컨벤션 예시: 스키마는 Flyway 마이그레이션으로만 관리하고, JPA ddl-auto 는 validate 로 검증만 한다. +-- 파일명 규칙: V<버전>__<설명>.sql (예: V2__add_sample_status.sql) + +create table sample ( + id bigint generated by default as identity primary key, + name varchar(100) not null, + created_at timestamp not null, + updated_at timestamp not null +); diff --git a/src/test/java/chaeso/zip/server/architecture/LayerDependencyTest.java b/src/test/java/chaeso/zip/server/architecture/LayerDependencyTest.java new file mode 100644 index 0000000..7799b74 --- /dev/null +++ b/src/test/java/chaeso/zip/server/architecture/LayerDependencyTest.java @@ -0,0 +1,57 @@ +package chaeso.zip.server.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.Architectures; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +/** + * 계층/패키지 의존 방향을 코드로 강제하는 아키텍처 테스트. + * + *

계층 규칙: presentation → application → domain (역방향 의존 금지) + */ +@AnalyzeClasses( + packages = "chaeso.zip.server", + importOptions = ImportOption.DoNotIncludeTests.class) +class LayerDependencyTest { + + @ArchTest + static final ArchRule 계층_의존_방향을_지킨다 = Architectures.layeredArchitecture() + .consideringOnlyDependenciesInLayers() + .layer("Presentation").definedBy("..presentation..") + .layer("Application").definedBy("..application..") + .layer("Domain").definedBy("..domain..") + .whereLayer("Presentation").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Presentation"); + + @ArchTest + static final ArchRule 도메인은_애플리케이션_표현_계층에_의존하지_않는다 = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAnyPackage("..application..", "..presentation..") + .as("도메인 계층은 상위 계층(application/presentation)에 의존해서는 안 된다"); + + @ArchTest + static final ArchRule 도메인은_웹_계층에_의존하지_않는다 = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAnyPackage("org.springframework.web..") + .as("도메인 계층은 Spring Web 에 의존해서는 안 된다"); + + @ArchTest + static final ArchRule 컨트롤러는_표현_계층에만_위치한다 = classes() + .that().areAnnotatedWith(RestController.class) + .should().resideInAPackage("..presentation..") + .as("@RestController 는 presentation 패키지에만 위치해야 한다"); + + @ArchTest + static final ArchRule 서비스는_애플리케이션_계층에만_위치한다 = classes() + .that().areAnnotatedWith(Service.class) + .should().resideInAPackage("..application..") + .as("@Service 는 application 패키지에만 위치해야 한다"); +} diff --git a/src/test/java/chaeso/zip/server/sample/application/SampleServiceTest.java b/src/test/java/chaeso/zip/server/sample/application/SampleServiceTest.java index 1887e84..471271d 100644 --- a/src/test/java/chaeso/zip/server/sample/application/SampleServiceTest.java +++ b/src/test/java/chaeso/zip/server/sample/application/SampleServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import chaeso.zip.server.sample.application.dto.CreateSampleCommand; import chaeso.zip.server.sample.application.dto.SampleResponse; import chaeso.zip.server.sample.domain.Sample; import chaeso.zip.server.sample.domain.SampleNotFoundException; @@ -27,14 +28,14 @@ class SampleServiceTest { private SampleRepository sampleRepository; @InjectMocks - private SampleService sampleService; + private SampleServiceImpl sampleService; @Test @DisplayName("샘플을 생성하면 저장 후 응답을 반환한다") void create() { given(sampleRepository.save(any(Sample.class))).willAnswer(invocation -> invocation.getArgument(0)); - SampleResponse response = sampleService.create("채소"); + SampleResponse response = sampleService.create(new CreateSampleCommand("채소")); assertThat(response.name()).isEqualTo("채소"); } diff --git a/src/test/java/chaeso/zip/server/sample/presentation/SampleControllerTest.java b/src/test/java/chaeso/zip/server/sample/presentation/SampleControllerTest.java index 659a652..365ee11 100644 --- a/src/test/java/chaeso/zip/server/sample/presentation/SampleControllerTest.java +++ b/src/test/java/chaeso/zip/server/sample/presentation/SampleControllerTest.java @@ -1,12 +1,13 @@ package chaeso.zip.server.sample.presentation; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import chaeso.zip.server.sample.application.SampleService; +import chaeso.zip.server.sample.application.dto.CreateSampleCommand; import chaeso.zip.server.sample.application.dto.SampleResponse; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDateTime; @@ -37,7 +38,7 @@ class SampleControllerTest { @Test @DisplayName("샘플 생성 요청이 성공하면 201 과 공통 응답 포맷을 반환한다") void create_success() throws Exception { - given(sampleService.create(anyString())) + given(sampleService.create(any(CreateSampleCommand.class))) .willReturn(new SampleResponse(1L, "채소", LocalDateTime.now(), LocalDateTime.now())); mockMvc.perform(post("/api/v1/samples") diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 71bc9b9..56d3f5f 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,4 +1,6 @@ spring: + flyway: + enabled: false datasource: url: jdbc:h2:mem:chaeso-zip;MODE=PostgreSQL;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver