Skip to content

Latest commit

 

History

History
411 lines (301 loc) · 8.43 KB

File metadata and controls

411 lines (301 loc) · 8.43 KB

Clean Architecture 공통 설계 원칙 (Kotlin Version)


1. 목적

  • 비즈니스 로직과 기술 세부사항 완전 분리
  • 프레임워크 교체 가능 구조 확보
  • 테스트 용이성 극대화
  • 도메인 중심 설계 유지
  • 장기 유지보수 가능한 코드베이스 구축

2. 레이어 구조

2.1 의존성 방향 (절대 규칙)

Presentation
      ↓
Application
      ↓
    Domain
      ↑
Infrastructure
  • 의존성은 항상 안쪽(Domain) 방향으로만 향한다.
  • Domain은 어떤 외부 프레임워크도 모른다.
  • Infrastructure는 가장 바깥에서 기술을 담당한다.

3. 레이어별 책임

3.1 Domain Layer

책임

  • 핵심 비즈니스 규칙
  • Aggregate Root
  • Entity
  • Value Object
  • Domain Policy
  • Domain Event
  • 비즈니스 예외 정의

금지 사항

  • Spring 의존성
  • JPA 어노테이션
  • HTTP 관련 코드
  • DB 접근 코드
  • 메시징 코드
  • 외부 API 호출

예시

package com.example.domain.sample.model

class Aggregate private constructor(
    private var status: Status
) {

    companion object {
        fun create(): Aggregate {
            return Aggregate(Status.PENDING)
        }
    }

    fun changeStatus(newStatus: Status) {
        if (!status.canTransitionTo(newStatus)) {
            throw InvalidStateException("Invalid state transition")
        }
        this.status = newStatus
    }

    fun getStatus(): Status = status
}

enum class Status {
    PENDING,
    CONFIRMED;

    fun canTransitionTo(newStatus: Status): Boolean {
        return this == PENDING && newStatus == CONFIRMED
    }
}

class InvalidStateException(
    message: String
) : RuntimeException(message)

3.2 Application Layer

책임

  • 유스케이스 오케스트레이션
  • 트랜잭션 경계 설정
  • Port 인터페이스 호출
  • DTO ↔ Domain 변환
  • 도메인 이벤트 발행 트리거

허용

  • @Service
  • @Transactional

금지

  • 기술 구현 세부사항 직접 포함
  • DB 엔티티 직접 사용
  • 비즈니스 규칙 구현 (Domain에 위임)

예시

package com.example.application.sample.service

import com.example.application.sample.port.RepositoryPort
import com.example.application.sample.usecase.CreateUseCase
import com.example.domain.sample.model.Aggregate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class CreateUseCaseService(
    private val repositoryPort: RepositoryPort
) : CreateUseCase {

    override fun execute(command: CreateUseCase.Command): CreateUseCase.Result {

        val aggregate = Aggregate.create()

        val saved = repositoryPort.save(aggregate)

        return CreateUseCase.Result.from(saved)
    }
}

3.3 Infrastructure Layer

책임

  • DB 연동
  • 외부 API 연동
  • 메시징 시스템 연동
  • 캐시
  • 파일 시스템
  • 프레임워크 설정

역할

  • Port 인터페이스 구현
  • 기술 예외 → 도메인/애플리케이션 예외로 변환

예시

package com.example.infrastructure.persistence.sample

import com.example.application.sample.port.RepositoryPort
import com.example.domain.sample.model.Aggregate
import org.springframework.stereotype.Component

@Component
class JpaRepositoryAdapter(
    private val repository: SpringDataRepository
) : RepositoryPort {

    override fun save(aggregate: Aggregate): Aggregate {

        val entity = Mapper.toEntity(aggregate)

        val saved = repository.save(entity)

        return Mapper.toDomain(saved)
    }
}

3.4 Presentation Layer

책임

  • HTTP 요청/응답 처리
  • 입력 검증
  • 인증/인가
  • API 문서화

금지

  • 비즈니스 로직 작성
  • DB 접근

예시

package com.example.presentation.sample

import com.example.application.sample.usecase.CreateUseCase
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/resources")
class ResourceController(
    private val createUseCase: CreateUseCase
) {

    @PostMapping
    fun create(
        @RequestBody request: RequestDto
    ): ResponseEntity<ResponseDto> {

        val command = RequestMapper.toCommand(request)

        val result = createUseCase.execute(command)

        return ResponseEntity.ok(
            ResponseMapper.toResponse(result)
        )
    }

    data class RequestDto(
        val id: Long
    )

    data class ResponseDto(
        val id: Long,
        val status: String
    )
}

4. Port & Adapter 패턴

4.1 Inbound Port (UseCase 인터페이스)

위치: Application

package com.example.application.sample.usecase

interface CreateUseCase {

    fun execute(command: Command): Result

    data class Command(
        val id: Long
    )

    data class Result(
        val id: Long,
        val status: String
    ) {
        companion object {
            fun from(aggregate: com.example.domain.sample.model.Aggregate): Result {
                return Result(
                    id = 0L,
                    status = aggregate.getStatus().name
                )
            }
        }
    }
}

4.2 Outbound Port (외부 의존 추상화)

위치: Domain 또는 Application (팀 컨벤션에 따라 통일)

package com.example.application.sample.port

import com.example.domain.sample.model.Aggregate

interface RepositoryPort {
    fun save(aggregate: Aggregate): Aggregate
    fun findById(id: Long): Aggregate?
}

4.3 Adapter (Infrastructure 구현체)

Infrastructure는 Port를 구현한다.

Application은 Port 인터페이스에만 의존한다.


5. 패키지 구조 권장안 (Feature-First)

이 프로젝트는 Domain 레이어를 물리적으로 분리한 Nested Submodule 구조를 사용한다.

{service}/                          ← 컨테이너 (build.gradle.kts 최소화)
├── domain/                         ← :{service}:domain Gradle 서브모듈 (순수 Kotlin)
│   └── src/main/kotlin/com/kgd/{service}/
│       └── domain/
│           ├── model/
│           ├── policy/
│           ├── event/
│           └── exception/
└── app/                            ← :{service}:app Gradle 서브모듈 (Spring Boot)
    └── src/main/kotlin/com/kgd/{service}/
        ├── application/
        │   └── {entity}/
        │       ├── usecase/
        │       ├── service/
        │       ├── port/
        │       └── dto/
        ├── infrastructure/
        │   ├── persistence/
        │   ├── client/
        │   ├── messaging/
        │   └── config/
        └── presentation/
            └── {entity}/
                ├── controller/
                └── dto/

domain 모듈 제약: Spring/JPA 의존성이 없으므로 어노테이션 사용 시 컴파일 에러로 차단된다.


6. 데이터 흐름 규칙

입력 흐름

Request DTO
→ Command
→ UseCase
→ Domain
→ Port
→ Infrastructure

출력 흐름

Infrastructure
→ Domain
→ Result
→ Response DTO


7. 예외 처리 규칙

7.1 Domain Exception

  • 비즈니스 규칙 위반
  • RuntimeException 상속
  • 프레임워크 독립

7.2 Infrastructure Exception

  • 기술적 오류
  • 반드시 Domain/Application 예외로 변환 후 전파

7.3 Presentation

  • GlobalExceptionHandler에서 최종 변환
  • HTTP 상태 코드 매핑

8. 테스트 전략

Domain

  • 순수 단위 테스트
  • Mock 사용 금지

Application

  • Port는 Mock 사용
  • 유스케이스 중심 테스트

Infrastructure

  • 통합 테스트
  • 실제 DB/외부 시스템 테스트

Presentation

  • API 테스트
  • 요청/응답 검증

9. 아키텍처 품질 체크리스트

  • Application → Infrastructure 직접 참조 0건
  • Domain → Spring/JPA 의존 0건
  • 순환 참조 0건
  • Domain 테스트 커버리지 90% 이상
  • 주요 유스케이스 통합 테스트 100%

10. 핵심 원칙 요약

  1. Domain은 프레임워크를 모른다.
  2. 의존성은 항상 안쪽으로 향한다.
  3. Infrastructure는 Port를 구현한다.
  4. Application은 흐름을 조정하고 비즈니스 규칙은 Domain에 위임한다.
  5. Controller는 UseCase만 호출한다.

이 문서는 모든 도메인에서 공통적으로 적용 가능한 Kotlin 기반 Clean Architecture 기준 문서이다.