Spring boot + Kotlin + JPA 온보딩 본문

Spring boot + Kotlin + JPA 온보딩

JinHwan Kim 2022. 5. 2. 17:16

1. Kotlin 핵심 문법

1.1 data class

Java의 DTO/VO를 한 줄로 만든다. equals, hashCode, toString, copy가 자동 생성된다.

// Java로 치면 @Data @AllArgsConstructor 붙인 클래스
data class Target(
    val id: String,
    val name: String,
    val url: String
)

val t = Target("1", "API", "http://...")
val t2 = t.copy(name = "변경됨")  // name만 바꾼 새 객체

 

주의: data class의 equals/hashCode는 주 생성자의 프로퍼티만 비교한다. body에 선언한 프로퍼티는 무시된다.

data class User(val id: String) {
    var nickname: String = ""  // equals/hashCode에 포함 안 됨!
}

val a = User("1").apply { nickname = "A" }
val b = User("1").apply { nickname = "B" }
a == b  // true — nickname이 달라도 id만 비교

 

1.2 val vs var — DDD에서의 불변성

val name = "고정"   // final — 재할당 불가
var count = 0       // 재할당 가능

 

원칙: val을 기본으로 쓰고, 꼭 필요할 때만 var.

DDD에서 Value Object는 반드시 불변(val만 사용)이어야 한다. 상태 변경이 필요하면 copy()로 새 객체를 만든다:

// 나쁜 예 — mutable Value Object
data class CheckResult(
    val targetId: String,
    var status: String = "PENDING",    // var = 외부에서 변경 가능 → 위험!
    var latency: Long? = null
)

// 좋은 예 — immutable Value Object
data class CheckResult(
    val targetId: String,
    val status: HealthStatus = HealthStatus.PENDING,
    val latency: Long? = null
) {
    fun markUp(latency: Long, ...): CheckResult =
        copy(status = HealthStatus.UP, latency = latency, ...)  // 새 객체 반환
}

 

val이라고 불변은 아니다. "재할당 불가"일 뿐, 내부 상태는 변할 수 있다:

val list = mutableListOf(1, 2, 3)
list.add(4)          // OK — list 자체를 바꾼 게 아니라 내용을 바꿈
// list = mutableListOf()  // 컴파일 에러 — 재할당 불가

 

진짜 불변 컬렉션을 원하면:

val list: List<Int> = listOf(1, 2, 3)   // 읽기 전용 인터페이스
// list.add(4)  // 컴파일 에러

 

1.3 null safety

Kotlin은 기본적으로 null을 허용하지 않는다. null이 가능한 타입은 ?를 붙인다.

val name: String = "hello"      // null 불가
val name: String? = null        // null 가능

// safe call — null이면 전체가 null
val len = name?.length              // Int?

// safe call 체이닝
val city = user?.address?.city      // 하나라도 null이면 전체 null

// elvis operator — null이면 기본값
val len = name?.length ?: 0         // Int

// elvis + return/throw
fun process(name: String?) {
    val n = name ?: return                    // null이면 함수 종료
    val n = name ?: throw IllegalArgumentException("name is null")
}

// !! — null이 아님을 확신할 때 (NPE 가능하므로 가급적 피할 것)
val len = name!!.length

 

1.4 sealed class와 sealed interface

enum의 확장판. 각 하위 타입이 서로 다른 필드를 가질 수 있다.
DDD에서 다형성 Value Object를 표현할 때 핵심적으로 사용된다.

sealed class AuthConfig {
    abstract val type: String
}

data class OAuth2Auth(
    override val type: String = "OAUTH2",
    val tokenUrl: String,
    val clientId: String,
    val clientSecret: String
) : AuthConfig()

data class LoginAuth(
    override val type: String = "LOGIN",
    val loginUrl: String,
    val username: String,
    val password: String
) : AuthConfig()

 

when과 함께 쓰면 모든 케이스를 강제할 수 있다:

when (auth) {
    is OAuth2Auth -> handleOAuth(auth)   // auth가 자동으로 OAuth2Auth 타입으로 캐스팅됨 (smart cast)
    is LoginAuth  -> handleLogin(auth)
    // sealed class라서 else가 필요 없음 — 새 타입 추가하면 컴파일 에러
}

 

sealed class vs sealed interface:

// sealed class — 상태(프로퍼티)를 공유할 때
sealed class Error(val code: Int)

// sealed interface — 다중 구현이 필요할 때 (Kotlin은 단일 상속만 가능하므로)
sealed interface Loggable
sealed interface Retryable
class NetworkError : Loggable, Retryable  // 둘 다 구현 가능

 

1.5 확장 함수

 

기존 클래스에 메서드를 추가하는 것처럼 쓸 수 있다.

fun String.toSlug(): String = this.lowercase().replace(" ", "-")

"Hello World".toSlug()  // "hello-world"

 

실전에서 자주 쓰는 패턴:

// nullable 타입에 확장 함수
fun String?.orDefault(): String = this ?: "(없음)"

null.orDefault()  // "(없음)"

// 제네릭 확장 함수
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null

 

1.6 scope functions 총정리

 

 

// let — null 체크 + 변환
val token = target.auth?.let { authPort.getAccessToken(it) }

// apply — 객체 설정 (빌더 대용)
val config = MonitoringProperties().apply {
    // this가 MonitoringProperties
}

// also — 디버깅, 로깅
val targets = repository.findAll()
    .also { log.debug("found ${it.size} targets") }

 

선택 기준:

- 반환값이 필요하면 let/run,

- 원본 객체가 필요하면 apply/also.

 

1.7 enum class — Value Object로서의 enum

DDD에서 상태값처럼 제한된 선택지를 표현할 때 enum을 사용한다.
문자열 비교 대신 타입 안전한 enum을 쓰면 잘못된 값이 들어올 수 없다.

// 나쁜 예 — 문자열로 상태 관리
data class CheckResult(val status: String = "PENDING")  // "UPP" 같은 오타 가능

// 좋은 예 — enum Value Object
enum class HealthStatus {
    PENDING, UP, DOWN;

    fun isDown(): Boolean = this == DOWN
    fun isUp(): Boolean = this == UP
}

data class CheckResult(val status: HealthStatus = HealthStatus.PENDING)

 

enum에 행위를 넣어서 풍부한 도메인 모델을 만들 수 있다:

enum class HealthStatus {
    PENDING, UP, DOWN;

    fun isDown(): Boolean = this == DOWN  // 상태 판단 로직이 enum 안에!
    fun isUp(): Boolean = this == UP
}

// 사용
val result = CheckResult(status = HealthStatus.DOWN)
if (result.status.isDown()) { ... }  // 문자열 비교 대신 메서드 호출

 

1.8 컬렉션 연산

val names = targets
    .filter { it.enabled }                    // 조건 필터
    .map { it.name }                          // 변환
    .distinct()                               // 중복 제거
    .sorted()                                 // 정렬

// associate — List → Map
val resultMap = results.associateBy { it.targetId }  // Map<String, CheckResult>

// any / all / none
val hasDown = results.any { it.isDown() }     // 도메인 메서드 사용
val allUp = results.all { it.status.isUp() }

 

1.9 when 표현식

Java의 switch를 대체하는데, 훨씬 강력하다. DDD에서 도메인 모델의 상태 전이를 표현할 때 핵심적이다.

// 타입 매칭 (smart cast) — sealed class와 함께
when (auth) {
    is OAuth2Auth -> auth.tokenUrl   // 자동 캐스팅
    is LoginAuth -> auth.loginUrl
}

// 조건 매칭 — 도메인 로직에서 활용
when {
    response.hasError() ->
        base.markDown(response.latency, response.statusCode, response.error!!)
    response.statusCode == expectedStatus ->
        base.markUp(response.latency, response.statusCode!!, previousResult)
    else ->
        base.markDown(response.latency, response.statusCode, "Expected $expectedStatus, got ${response.statusCode}")
}

 

1.10 companion object — 팩토리 메서드

DDD에서 팩토리 패턴을 구현할 때 companion object를 활용한다:

data class CheckResult(
    val targetId: String,
    val status: HealthStatus = HealthStatus.PENDING
) {
    companion object {
        // 팩토리 메서드 — 의미 있는 이름으로 객체 생성
        fun pending(targetId: String): CheckResult = CheckResult(targetId = targetId)
    }
}

// 사용
val result = CheckResult.pending(target.id)  // 의도가 명확

 

2. Spring + Kotlin 주의점

2.1 클래스가 기본으로 final이다

Java는 클래스가 기본 open이지만, Kotlin은 기본 final이다.
Spring의 프록시(AOP, 트랜잭션 등)는 클래스를 상속해서 동작하므로 문제가 된다.

해결: kotlin("plugin.spring") 플러그인이 자동으로 @Component, @Service, @Configuration 등이 붙은 클래스를 open으로 만든다.

plugins {
    kotlin("plugin.spring") version "1.9.22"  // 이게 해결해줌
}

 

 

2.2 @ConfigurationProperties와 data class

Spring Boot 3.x에서 @ConfigurationProperties를 data class에 쓸 때, 생성자 바인딩이 자동 적용된다.

함정: val 프로퍼티에 기본값이 없으면 yml에 값이 없을 때 에러가 난다. 항상 기본값을 넣어라.

@ConfigurationProperties(prefix = "monitoring")
data class MonitoringProperties(
    val dataDir: String = "./data",           // kebab-case → camelCase 자동 변환
    val checkInterval: Long = 60,
    val slack: SlackProperties = SlackProperties()
)

 

 

2.3 Jackson + Kotlin

Kotlin data class를 JSON으로 직렬화/역직렬화하려면 jackson-module-kotlin이 필요하다.

이 모듈이 없으면 기본 생성자가 없다는 에러가 발생한다.

implementation("cohttp://m.fasterxml.jackson.module:jackson-module-kotlin")

val mapper = ObjectMapper().apply {
    registerModule(kotlinModule())          // Kotlin 지원
    registerModule(JavaTimeModule())        // LocalDateTime 등
    disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}

 

 

2.4 lateinit vs lazy

사용 기준:

- @PostConstruct에서 초기화 → lateinit var

- 처음 접근할 때 한 번만 계산 → by lazy

- 생성자에서 바로 넣을 수 있으면 → 그냥 val (가장 좋음)

// lateinit — 나중에 초기화할 var. @PostConstruct에서 초기화할 때 사용
lateinit var file: File

// lazy — 처음 접근할 때 초기화하는 val
val expensiveResult: String by lazy { heavyComputation() }

 

 

2.5 코루틴과 WebFlux

이 프로젝트는 WebClient로 HTTP 요청을 보내는데, .block()으로 동기 방식으로 쓰고 있다:

지금은 .block()으로 충분하다. 동시 체크 대상이 수백 개가 되면 코루틴 전환을 고려하면 된다.

val response = webClient.get()
    .uri(url)
    .retrieve()
    .bodyToMono(String::class.java)
    .block()  // 블로킹 — 응답 올 때까지 스레드가 대기

 

3. DDD 핵심 개념

3.1 DDD란?

 

소프트웨어의 복잡성을 도메인(비즈니스) 로직 중심으로 다루는 설계 방법론.
코드가 비즈니스 전문가의 언어(유비쿼터스 언어)를 반영하고, 핵심 비즈니스 규칙이 도메인 모델 안에 들어가야 한다.

 

3.2 전략적 설계 vs 전술적 설계

이 프로젝트는 전술적 설계에 집중한다.

 

 

3.3 Entity vs Value Object

DDD의 가장 기본적인 구분:

 

 

// Entity — id로 식별, 상태 변경 가능
data class Target(
    val id: String = UUID.randomUUID().toString(),  // 식별자
    val name: String,
    val url: String,
    ...
)

// Value Object — 값으로 식별, 불변
enum class HealthStatus { PENDING, UP, DOWN }

data class CheckResult(
    val targetId: String,
    val status: HealthStatus = HealthStatus.PENDING,  // 모든 필드가 val
    ...
) {
    fun markUp(...): CheckResult = copy(...)  // 변경 = 새 객체 생성
}

 

3.4 풍부한 도메인 모델 (Rich Domain Model)

핵심 원칙:

비즈니스 로직은 도메인 모델 안에 있어야 한다.

빈약한 도메인 모델(Anemic Domain Model)은 getter/setter만 있는 데이터 클래스다.
Service에 모든 로직이 들어가면 결국 절차적 프로그래밍이 된다.

// 나쁜 예 — 빈약한 도메인 모델 (Anemic)
data class CheckResult(
    val targetId: String,
    var status: String = "PENDING"  // 그냥 데이터 그릇
)

// Service에 모든 로직이 몰림
class HealthCheckService {
    fun executeCheck(target: Target) {
        val result = CheckResult(targetId = target.id)
        if (response.error != null) {
            result.status = "DOWN"                    // Service가 직접 상태 변경
            result.lastFailedAt = LocalDateTime.now()  // 도메인 규칙이 Service에!
        }
    }
}
// 좋은 예 — 풍부한 도메인 모델 (Rich)
data class CheckResult(
    val targetId: String,
    val status: HealthStatus = HealthStatus.PENDING
) {
    fun markDown(latency: Long, statusCode: Int?, reason: String): CheckResult =
        copy(
            status = HealthStatus.DOWN,
            latency = latency,
            lastFailedAt = LocalDateTime.now(),  // 도메인 규칙이 모델 안에!
            lastFailReason = reason
        )
}

data class Target(...) {
    fun evaluate(response: HttpCheckResponse, previousResult: CheckResult?): CheckResult {
        // Target이 자신의 expectedStatus를 알고 있으므로, 판단 로직이 여기에
        return when {
            response.hasError() -> base.markDown(...)
            response.statusCode == expectedStatus -> base.markUp(...)
            else -> base.markDown(...)
        }
    }

    fun shouldNotifyDown(prev: CheckResult?, new: CheckResult): Boolean =
        new.isDown() && prev?.isDown() != true  // 알림 규칙도 도메인에
}

// Service는 오케스트레이션만 담당
class HealthCheckService {
    fun executeCheck(target: Target): CheckResult {
        val response = httpCheckPort.execute(target.url, target.method, token)
        val result = target.evaluate(response, previousResult)  // 도메인에 위임
        if (target.shouldNotifyDown(previousResult, result)) {  // 도메인에 위임
            notificationPort.sendDownAlert(target, result)
        }
        return result
    }
}

 

3.5 Aggregate (집합체)

관련된 Entity와 Value Object를 하나의 일관성 단위로 묶는 것.

- Aggregate Root: Target — 외부에서는 반드시 Target을 통해서만 CheckResult에 접근

- Target.evaluate()가 CheckResult를 생성하는 것이 이 패턴의 핵심

- 외부에서 CheckResult를 직접 만들지 않고, Target에게 "평가해줘"라고 요청

Target (Aggregate Root)
├── AuthConfig (Value Object)
├── CheckResult (Value Object)
└── HealthStatus (Value Object)

 

3.6 Domain Service

Entity나 Value Object에 속하지 않는 도메인 로직이 있을 때 사용한다.
이 프로젝트에서는 Application Service(HealthCheckServiceImpl)가 이 역할을 겸한다.

 

구분 기준:

- Entity/VO에 넣을 수 있으면 → 거기에 넣는다 (우선)

- 여러 Aggregate에 걸치는 로직이면 → Domain Service

- 외부 시스템 조율이면 → Application Service

 

3.7 유비쿼터스 언어 (Ubiquitous Language)

코드에 사용하는 용어가 비즈니스 용어와 일치해야 한다:

비즈니스 용어코드

모니터링 대상 Target
헬스체크 결과 CheckResult
상태 (정상/장애) HealthStatus.UP / DOWN
장애 알림 sendDownAlert()
복구 알림 sendRecoveryAlert()
헬스체크 평가 Target.evaluate()

 

4. 헥사고날 아키텍처 깊이 파기

4.1 한 줄 요약

비즈니스 로직(domain)을 중심에 두고, 외부(DB, HTTP, Slack 등)와의 연결은 인터페이스(port)로 분리한다.

 

4.2 레이어드 아키텍처와의 비교

레이어드(일반적인 Spring 프로젝트):

- Service가 Repository 구현체에 의존

- 테스트할 때 DB가 있어야 함

Controller → Service → Repository
                         ↓
                    DataSource (MySQL, JPA 등)

 

헥사고날:

- Service가 인터페이스에만 의존

- Adapter를 교체해도 Service는 수정 불필요

Controller → [UseCase 인터페이스] ← ServiceImpl → [Port 인터페이스] ← Adapter (구현체)

 

4.3 핵심 개념

Port (포트)

- 인터페이스다.

- "무엇을 할 수 있는가 / 무엇이 필요한가"를 정의한다.

 

"Driving"과 "Driven"이 헷갈리면:

- Driving = "나(외부)가 시스템을 운전한다" → Controller, Scheduler

- Driven = "시스템이 나(외부)를 부린다" → DB, Slack, HTTP 클라이언트

 

Adapter (어댑터)

- Port의 구현체다.

- 실제 기술 코드가 들어간다.

 

4.4 Port를 나누는 기준

- 분리 기준: 변경 이유가 다르면 분리한다. 

- 저장소를 바꾸는 것과 알림을 바꾸는 것은 별개.

// 나쁜 예 — 모든 걸 하나에 (Interface Segregation 위반)
interface DataPort {
    fun findAllTargets(): List<Target>
    fun saveTarget(target: Target): Target
    fun sendSlack(message: String)
}

// 좋은 예 — 역할별로 분리
interface TargetPort { ... }
interface CheckResultPort { ... }
interface NotificationPort { ... }

 

4.5 Domain 순수성

- 이상적으로 domain 패키지는 프레임워크 의존성이 0이어야 한다.

허용비허용

Jackson 어노테이션 (@JsonTypeInfo) Spring 어노테이션 (@Component, @Service)
Java 표준 라이브러리 (LocalDateTime) JPA 어노테이션 (@Entity, @Column)
  WebClient, RestTemplate 등 HTTP 클라이언트

 

4.6 Application Service의 역할

- Application Service (UseCase 구현체)는 오케스트레이터다.
- 직접 비즈니스 로직을 수행하는 게 아니라, 도메인 모델에 위임하고 Port들을 조합해서 흐름만 만든다.

@Service
class HealthCheckServiceImpl(...) : HealthCheckUseCase {
    // 하는 일: 1) 대상 조회 → 2) 토큰 획득 → 3) HTTP 요청 → 4) 도메인에 평가 위임 → 5) 알림 → 6) 저장
    private fun executeCheck(target: Target): CheckResult {
        val token = resolveToken(target)                        // 2
        val response = httpCheckPort.execute(...)                // 3
        val result = target.evaluate(response, previousResult)  // 4 — 도메인에 위임!
        // 알림 판단도 도메인에 위임
        if (target.shouldNotifyDown(previousResult, result)) {  // 5
            notificationPort.sendDownAlert(target, result)
        }
        checkResultPort.save(result)                            // 6
        return result
    }
}

 

Application Service가 하면 안 되는 것:

- JSON 파일을 직접 읽기/쓰기 (→ TargetPort가 할 일)

- HTTP 요청을 직접 보내기 (→ HttpCheckPort가 할 일)

- 비즈니스 판단 로직 (→ 도메인 모델이 할 일)

 

5. DDD가 적용된 코드 분석

5.1 Value Object: HealthStatus

// domain/model/HealthStatus.kt
enum class HealthStatus {
    PENDING, UP, DOWN;

    fun isDown(): Boolean = this == DOWN
    fun isUp(): Boolean = this == UP
}

 

DDD 포인트:

- 문자열 "UP", "DOWN" 대신 enum으로 타입 안전성 확보

- 행위(isDown(), isUp())가 Value Object 안에 — 풍부한 모델

- 잘못된 값("UPP")이 들어올 수 없음

 

5.2 Value Object: CheckResult (불변 + 팩토리 + 자기 변환)

// domain/model/CheckResult.kt
data class CheckResult(
    val targetId: String,
    val status: HealthStatus = HealthStatus.PENDING,  // 모든 필드 val
    val latency: Long? = null,
    ...
) {
    fun isDown(): Boolean = status.isDown()

    fun markUp(latency: Long, statusCode: Int, previousResult: CheckResult?): CheckResult =
        copy(status = HealthStatus.UP, ...)  // 새 객체 반환

    fun markDown(latency: Long, statusCode: Int?, reason: String): CheckResult =
        copy(status = HealthStatus.DOWN, ...)

    companion object {
        fun pending(targetId: String): CheckResult = CheckResult(targetId = targetId)
    }
}

 

DDD 포인트:

var 없음 — 완전 불변. 상태 변경은 copy()로 새 객체 생성

- markUp(), markDown() — 상태 전이 로직이 모델 안에

- pending() 팩토리 — 의미 있는 이름으로 생성

- isDown() — 외부에서 문자열 비교 대신 도메인 메서드 사용

 

5.3 Entity + Aggregate Root: Target

// domain/model/Target.kt
data class Target(
    val id: String = UUID.randomUUID().toString(),
    val name: String,
    val url: String,
    val method: String = "GET",
    val expectedStatus: Int = 200,
    val auth: AuthConfig? = null,
    ...
) {
    fun requiresAuth(): Boolean = auth != null

    fun evaluate(response: HttpCheckResponse, previousResult: CheckResult?): CheckResult {
        val base = CheckResult.pending(id)
        return when {
            response.hasError() -> base.markDown(...)
            response.statusCode == expectedStatus -> base.markUp(...)
            else -> base.markDown(...)
        }
    }

    fun shouldNotifyDown(previousResult: CheckResult?, newResult: CheckResult): Boolean =
        newResult.isDown() && previousResult?.isDown() != true

    fun shouldNotifyRecovery(previousResult: CheckResult?, newResult: CheckResult): Boolean =
        newResult.status.isUp() && previousResult?.isDown() == true
}

 

DDD 포인트:

- evaluate() — Target이 자신의 expectedStatus를 알고 있으므로, 응답 평가 로직이 Target에 위치

- shouldNotifyDown(), shouldNotifyRecovery() — 알림 판단도 도메인 규칙이므로 모델에

- requiresAuth() — 인증 필요 여부 판단을 캡슐화

- Aggregate Root로서 CheckResult 생성을 evaluate() 안에서 담당

 

5.4 Application Service: 오케스트레이션만 담당

// 리팩토링 전 — Service에 비즈니스 로직이 몰려있음
if (response.error != null) {
    result.status = "DOWN"                     // 직접 상태 변경
    result.lastFailedAt = LocalDateTime.now()   // 직접 시간 설정
    if (!wasDown) notificationPort.sendDownAlert(...)  // 알림 판단도 직접
}

// 리팩토링 후 — 도메인에 위임
val result = target.evaluate(response, previousResult)         // 평가 → Target
if (target.shouldNotifyDown(previousResult, result)) {          // 알림 판단 → Target
    notificationPort.sendDownAlert(target, result)              // 알림 실행 → Port
}

 

5.5 패키지별 Adapter 역할 분리

adapter/out/
├── persistence/    # 데이터 저장 (JsonTargetAdapter, JsonCheckResultAdapter)
├── http/           # HTTP 통신 (WebClientHttpCheckAdapter)
├── auth/           # 인증 (HttpAuthAdapter)
└── slack/          # 알림 (SlackNotificationAdapter)

DDD 포인트:

- Adapter가 기술적 관심사별로 명확히 분리.
- WebClientHttpCheckAdapter는 persistence가 아니라 http 통신이므로 http/ 패키지에 위치.

 

6. 패키지 구조와 의존성 규칙

com.goqual.monitoring/
├── domain/                  # 핵심 — 외부 의존성 없음, 비즈니스 로직의 집
│   ├── model/               #   Target(Entity), CheckResult(VO), HealthStatus(VO), AuthConfig(VO)
│   └── port/
│       ├── in/              #   HealthCheckUseCase, SlackUseCase
│       └── out/             #   TargetPort, CheckResultPort, AuthPort, NotificationPort, HttpCheckPort
│
├── application/             # 유스케이스 구현 — domain만 의존, 오케스트레이션
│   └── service/             #   HealthCheckServiceImpl, SlackServiceImpl
│
├── adapter/                 # 외부 기술 연결 — domain의 port를 구현
│   ├── in/web/              #   HealthCheckController (driving adapter)
│   └── out/
│       ├── persistence/     #   JsonTargetAdapter, JsonCheckResultAdapter
│       ├── http/            #   WebClientHttpCheckAdapter
│       ├── auth/            #   HttpAuthAdapter
│       └── slack/           #   SlackNotificationAdapter
│
└── infrastructure/          # Spring 설정, 스케줄러
    ├── config/              #   AppConfig, MonitoringProperties
    └── scheduler/           #   HealthCheckScheduler

 

의존성 규칙

domain         ← 아무것도 의존하지 않음 (Jackson 어노테이션만 예외)
application    → domain
adapter.in     → domain (port.in 인터페이스 호출)
adapter.out    → domain (port.out 인터페이스 구현)
infrastructure → 전체 가능

 

절대 하면 안 되는 것:

금지이유

domain  adapter 핵심이 구현 기술에 의존하면 교체 불가능
domain  infrastructure Spring 설정에 의존하면 프레임워크 종속
application  adapter Service가 JSON인지 DB인지 알면 안 됨
adapter.in  adapter.out Controller가 Repository를 직접 호출하면 Service 우회

 

7. 자주 하는 실수와 안티패턴

7.1 빈약한 도메인 모델 (Anemic Domain Model)

// 안티패턴 — 모델이 그냥 데이터 그릇
data class CheckResult(
    val targetId: String,
    var status: String = "PENDING"
)

// 모든 로직이 Service에
class Service {
    fun check() {
        result.status = "DOWN"  // 도메인 규칙이 Service에 흩어져 있음
    }
}

// 올바른 패턴 — 모델에 행위 부여
data class CheckResult(...) {
    fun markDown(...): CheckResult = copy(status = HealthStatus.DOWN, ...)
}

 

7.2 var를 쓰는 도메인 모델

// 안티패턴 — 외부에서 아무 때나 상태 변경 가능
data class CheckResult(var status: String, var latency: Long?)

fun somewhere() {
    result.status = "INVALID_VALUE"  // 제약 없음!
}

// 올바른 패턴 — copy를 통한 통제된 변경
data class CheckResult(val status: HealthStatus, val latency: Long?) {
    fun markDown(...): CheckResult = copy(...)  // 유효한 전이만 허용
}

 

7.3 Port를 안 만들고 바로 구현

// 안티패턴 — Service가 JSON 파일을 직접 읽음
@Service
class HealthCheckService(private val objectMapper: ObjectMapper) {
    fun findAll(): List<Target> {
        val file = File("data/targets.json")
        return objectMapper.readValue(file.readText())  // 기술 종속!
    }
}

// 올바른 패턴 — Port를 통해 추상화
@Service
class HealthCheckService(private val targetPort: TargetPort) {
    fun findAll(): List<Target> = targetPort.findAll()
}

 

7.4 Domain Model에 프레임워크 어노테이션

// 안티패턴 — JPA 어노테이션이 domain model에
@Entity
data class Target(@Id val id: String, @Column val name: String)

// 올바른 패턴 — domain model은 순수, JPA 엔티티는 adapter에
// domain/model/Target.kt
data class Target(val id: String, val name: String)

// adapter/out/persistence/TargetEntity.kt
@Entity
data class TargetEntity(@Id val id: String, val name: String) {
    fun toDomain() = Target(id, name)
    companion object {
        fun fromDomain(t: Target) = TargetEntity(t.id, t.name)
    }
}

 

7.5 문자열로 상태 관리

// 안티패턴
if (result.status == "DONW") { ... }  // 오타를 컴파일러가 못 잡음

// 올바른 패턴
if (result.status.isDown()) { ... }  // 타입 안전, 자동완성 지원

 

 

 

Comments