헥사고날 아키텍처를 적용하면서, 클래스들을 어떤 레이어에 배치해야 할지 고민해본 적 있으신가요? 이 글에서는 헥사고날 아키텍처의 핵심 원칙을 이해하고, 다양한 클래스 유형을 올바른 위치에 배치하는 실용적인 방법을 제시합니다. 깔끔하고 유지보수하기 쉬운 애플리케이션 구조를 만드는 데 도움이 될 것입니다.

헥사고날 아키텍처란 무엇인가?

헥사고날 아키텍처(Hexagonal Architecture)는 Alistair Cockburn이 Ports and Adapters 아키텍처라는 이름으로 처음 소개했습니다. 이 아키텍처의 핵심 목표는 애플리케이션의 핵심 비즈니스 로직(도메인)을 외부 기술(데이터베이스, UI, 외부 API 등)로부터 철저히 분리하여, 비즈니스 규칙의 변경이 외부 기술 스택의 변경으로 인해 영향을 받지 않도록 하는 것입니다.

우리의 애플리케이션을 육각형(Hexagon)으로 비유하여, 육각형 내부에는 순수한 비즈니스 로직만 존재하고, 육각형 외부에는 데이터베이스, 웹 인터페이스, 배치 작업 등 다양한 외부 시스템들이 위치합니다. 내부와 외부의 경계는 포트(Ports)라는 인터페이스로 정의되며, 이 포트를 어댑터(Adapters)가 구현하여 외부 시스템과 소통합니다.

이러한 분리는 다음과 같은 이점을 제공합니다.

  • 유연성: 데이터베이스나 UI 프레임워크가 변경되어도 핵심 도메인 로직은 그대로 유지될 수 있습니다.
  • 테스트 용이성: 외부 의존성 없이 순수한 비즈니스 로직만 단위 테스트할 수 있어 테스트 작성이 쉽고 빠릅니다.
  • 확장성: 새로운 외부 시스템(예: 다른 종류의 데이터베이스, 새로운 통신 프로토콜)을 추가하기 용이합니다.

가장 중요한 원칙은 의존성 역전 원칙(Dependency Inversion Principle)의 적용입니다. 즉, 외부 레이어가 내부 레이어에 의존하도록 하는 것입니다. 이를 통해 핵심 도메인이 외부 인프라에 대한 지식을 갖지 않게 됩니다.

헥사고날 아키텍처의 핵심 구성 요소 및 클래스 배치

이제 헥사고날 아키텍처의 주요 구성 요소를 살펴보고, 각 구성 요소에 해당하는 클래스들을 어디에 배치해야 할지 구체적으로 알아보겠습니다.

내부(Inner) 레이어: 도메인 (Domain)

가장 중심에 위치하며, 애플리케이션의 핵심 비즈니스 규칙데이터를 포함합니다. 이 계층은 어떤 외부 기술에도 의존하지 않는 순수한 비즈니스 로직으로 구성되어야 합니다.

도메인 엔티티 (Domain Entities)

  • 정의: 고유한 식별자를 가지며 생명 주기가 있는 비즈니스 객체입니다. 비즈니스 핵심 규칙과 데이터를 함께 캡슐화합니다.
  • 위치: domain 패키지 내부, 예를 들어 com.example.hexagonal.domain
  • 설명: 엔티티는 단순한 데이터 컨테이너가 아닙니다. 자신의 상태를 변경하는 행위(Behavior)를 포함해야 하며, 이러한 행위는 비즈니스 규칙을 준수해야 합니다. 예를 들어, Order 엔티티는 cancel() 또는 addItem()과 같은 메서드를 가질 수 있습니다.
  • Kotlin 예시:
    // com.example.hexagonal.domain
    package com.example.hexagonal.domain
    
    import java.time.LocalDateTime
    import java.util.UUID
    
    data class UserId(val value: UUID) // 값 객체로 사용될 수 있음
    
    class User(
        val id: UserId,
        var name: String,
        val email: String, // 불변 속성
        var createdAt: LocalDateTime,
        var updatedAt: LocalDateTime? = null
    ) {
        init {
            require(name.isNotBlank()) { "User name cannot be blank" }
            require(email.matches(Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6}$"))) { "Invalid email format" }
        }
    
        fun changeName(newName: String) {
            if (newName.isBlank()) {
                throw IllegalArgumentException("New name cannot be blank")
            }
            this.name = newName
            this.updatedAt = LocalDateTime.now()
        }
    
        fun updateInfo(newName: String) {
            changeName(newName)
            // 다른 비즈니스 로직 추가 가능
        }
    
        companion object {
            fun create(name: String, email: String): User {
                val now = LocalDateTime.now()
                return User(
                    id = UserId(UUID.randomUUID()),
                    name = name,
                    email = email,
                    createdAt = now,
                    updatedAt = now
                )
            }
        }
    }
    

값 객체 (Value Objects)

  • 정의: 불변(immutable)하며, 고유한 식별자 없이 자신의 속성 값으로 자신을 식별하는 객체입니다.
  • 위치: domain 패키지 내부, 엔티티와 함께 com.example.hexagonal.domain
  • 설명: UserId, Email, Address, Money 등과 같이 특정 개념을 나타내며, 도메인에서 중요한 의미를 가집니다. 엔티티의 속성으로 사용되거나 독립적인 의미를 가질 수 있습니다. 값 객체는 비교 시 모든 속성 값이 같으면 동일하다고 판단합니다.
  • Kotlin 예시: User 엔티티 내부의 UserId와 위 User 클래스의 email 속성이 예시가 됩니다.
    // com.example.hexagonal.domain
    package com.example.hexagonal.domain
    
    import java.util.UUID
    
    // UserId는 값 객체의 좋은 예시입니다.
    data class UserId(val value: UUID) {
        init {
            require(value != UUID.fromString("00000000-0000-0000-0000-000000000000")) { "Invalid UserId" }
        }
        override fun toString(): String = value.toString()
    }
    
    data class Email(val value: String) {
        init {
            require(value.matches(Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6}$"))) { "Invalid email format" }
        }
    }
    
    data class Money(val amount: Int, val currency: String) {
        init {
            require(amount >= 0) { "Amount cannot be negative" }
            require(currency.isNotBlank()) { "Currency cannot be blank" }
        }
    
        fun add(other: Money): Money {
            require(this.currency == other.currency) { "Currencies must be the same" }
            return Money(this.amount + other.amount, this.currency)
        }
    }
    

도메인 서비스 (Domain Services)

  • 정의: 특정 엔티티에만 속하기 어려운 여러 엔티티에 걸쳐 있는 비즈니스 로직이나, 엔티티의 책임을 벗어나는 도메인 로직을 캡슐화합니다.
  • 위치: domain/service 패키지, 예를 들어 com.example.hexagonal.domain.service
  • 설명: 도메인 계층 내에서 비즈니스 로직을 조율하지만, 인프라 관련 작업(예: 데이터베이스 접근)은 수행하지 않습니다. 도메인 서비스는 다른 도메인 객체(엔티티, 값 객체)를 사용하고, 도메인 이벤트를 발행할 수 있습니다. 예를 들어, OrderCreationServiceCustomer 엔티티와 Product 엔티티를 사용하여 Order 엔티티를 생성하는 복잡한 로직을 처리할 수 있습니다.
  • Kotlin 예시:
    // com.example.hexagonal.domain.service
    package com.example.hexagonal.domain.service
    
    import com.example.hexagonal.domain.User
    
    class UserValidationService {
        fun isValidUser(user: User): Boolean {
            // 복잡한 도메인 유효성 검사 로직 (예: 특정 비즈니스 규칙 만족 여부)
            return user.name.length > 2 && user.email.isNotBlank()
        }
    
        fun isUniqueEmail(email: String): Boolean {
            // 이메일 중복 검사는 도메인 로직이지만, 실제 확인은 인프라(리포지토리)를 통해 이루어져야 합니다.
            // 여기서는 도메인 서비스가 '어떤 것이 유효한지'에 대한 비즈니스 규칙을 정의하고,
            // 실제 데이터 접근은 Application Service를 통해 Port/Adapter로 위임됩니다.
            // 이 메소드는 인프라에 의존하지 않는 추상적인 형태로 존재해야 합니다.
            return true // placeholder, 실제 구현은 Application Service에서 Output Port를 통해.
        }
    }
    

외부(Outer) 레이어: 애플리케이션 및 인프라 (Application & Infrastructure)

도메인 계층을 감싸는 외부 레이어입니다. 이 계층은 도메인 계층에 정의된 포트(Ports)를 통해 내부와 소통하며, 실제 외부 기술(UI, DB, 메시징 등)을 다룹니다.

포트 (Ports)

  • 정의: 내부(도메인/애플리케이션)와 외부(어댑터)를 연결하는 계약(인터페이스)입니다. 입력 포트(Input Port)출력 포트(Output Port)로 나뉩니다.
  • 위치:
    • Input Port: application/port/in 패키지, 예를 들어 com.example.hexagonal.application.port.in
    • Output Port: application/port/out 패키지, 예를 들어 com.example.hexagonal.application.port.out (때로는 도메인 계층에 domain/port로 두기도 하지만, 도메인이 포트에 의존하지 않도록 application 계층에 두는 것이 일반적입니다.)
  • 설명:
    • Input Port (Driving Port): 애플리케이션의 사용 사례(Use Case)를 정의하는 인터페이스입니다. 외부(웹 컨트롤러, 메시지 리스너)가 애플리케이션의 기능을 호출하기 위한 진입점 역할을 합니다. 예를 들어, RegisterUserUseCase 또는 GetUserQuery.
    • Output Port (Driven Port): 애플리케이션 또는 도메인 계층이 외부 인프라(데이터베이스, 외부 서비스)와 상호작용하기 위한 인터페이스입니다. 예를 들어, UserRepository 또는 PaymentGatewayPort.
  • Kotlin 예시:
    // com.example.hexagonal.application.port.in
    package com.example.hexagonal.application.port.`in`
    
    import com.example.hexagonal.domain.User
    import com.example.hexagonal.domain.UserId
    
    interface RegisterUserUseCase {
        fun registerUser(command: RegisterUserCommand): User
    }
    
    data class RegisterUserCommand(val name: String, val email: String)
    
    interface GetUserQuery {
        fun getUserById(userId: UserId): User?
    }
    
    // com.example.hexagonal.application.port.out
    package com.example.hexagonal.application.port.out
    
    import com.example.hexagonal.domain.User
    import com.example.hexagonal.domain.UserId
    
    interface UserRepository {
        fun save(user: User): User
        fun findById(id: UserId): User?
        fun findByEmail(email: String): User?
    }
    

어댑터 (Adapters)

  • 정의: 외부 기술을 포트에 맞게 변환하고 구현하는 클래스입니다.
  • 위치: adapter/{기술_유형} 패키지.
    • Input Adapter: adapter/in/web, adapter/in/batch 등 (예: com.example.hexagonal.adapter.in.web)
    • Output Adapter: adapter/out/persistence, adapter/out/external 등 (예: com.example.hexagonal.adapter.out.persistence)
  • 설명:
    • Input Adapter (Driving Adapter): Input Port를 호출하는 역할을 합니다. 사용자 인터페이스(웹 컨트롤러), REST API 컨트롤러, 메시지 리스너 등이 해당됩니다. 외부의 요청을 애플리케이션 계층이 이해할 수 있는 형식으로 변환하여 Input Port를 통해 Application Service로 전달합니다.
    • Output Adapter (Driven Adapter): Output Port 인터페이스를 구현하는 클래스입니다. 데이터베이스 접근(JPA 리포지토리), 외부 API 클라이언트, 메시지 발행 등이 해당됩니다. 애플리케이션 계층이 Output Port를 통해 요청하는 인프라 작업을 실제 기술 스택(JDBC, HTTP 클라이언트 등)을 사용하여 처리합니다.
  • Kotlin 예시:
    // com.example.hexagonal.adapter.in.web
    package com.example.hexagonal.adapter.`in`.web
    
    import com.example.hexagonal.application.port.`in`.GetUserQuery
    import com.example.hexagonal.application.port.`in`.RegisterUserCommand
    import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
    import com.example.hexagonal.domain.User
    import com.example.hexagonal.domain.UserId
    import org.springframework.http.HttpStatus
    import org.springframework.http.ResponseEntity
    import org.springframework.web.bind.annotation.*
    import java.util.UUID
    
    @RestController
    @RequestMapping("/users")
    class UserController(
        private val registerUserUseCase: RegisterUserUseCase,
        private val getUserQuery: GetUserQuery
    ) {
    
        @PostMapping
        fun registerUser(@RequestBody request: RegisterUserRequest): ResponseEntity<UserResponse> {
            val command = RegisterUserCommand(request.name, request.email)
            val user = registerUserUseCase.registerUser(command)
            return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.fromDomain(user))
        }
    
        @GetMapping("/{userId}")
        fun getUser(@PathVariable userId: UUID): ResponseEntity<UserResponse> {
            val user = getUserQuery.getUserById(UserId(userId))
            return user?.let { ResponseEntity.ok(UserResponse.fromDomain(it)) }
                ?: ResponseEntity.notFound().build()
        }
    }
    
    data class RegisterUserRequest(val name: String, val email: String)
    data class UserResponse(val id: UUID, val name: String, val email: String) {
        companion object {
            fun fromDomain(user: User): UserResponse {
                return UserResponse(user.id.value, user.name, user.email)
            }
        }
    }
    
    // com.example.hexagonal.adapter.out.persistence
    package com.example.hexagonal.adapter.out.persistence
    
    import com.example.hexagonal.application.port.out.UserRepository
    import com.example.hexagonal.domain.User
    import com.example.hexagonal.domain.UserId
    import org.springframework.stereotype.Repository
    import java.time.LocalDateTime
    import java.util.concurrent.ConcurrentHashMap
    
    @Repository
    class InMemoryUserRepositoryAdapter : UserRepository {
        private val store: ConcurrentHashMap<UserId, User> = ConcurrentHashMap()
    
        override fun save(user: User): User {
            store[user.id] = user
            return user
        }
    
        override fun findById(id: UserId): User? {
            return store[id]
        }
    
        override fun findByEmail(email: String): User? {
            return store.values.firstOrNull { it.email == email }
        }
    }
    

애플리케이션 서비스 (Application Services / Use Cases)

  • 정의: 특정 사용 사례(Use Case) 또는 애플리케이션 기능을 구현하는 클래스입니다. Input Port를 구현하고, 도메인 객체도메인 서비스를 사용하여 비즈니스 시나리오를 실행하며, Output Port를 통해 외부 인프라에 접근합니다.
  • 위치: application/service 패키지, 예를 들어 com.example.hexagonal.application.service
  • 설명: 비즈니스 흐름을 정의하고, 트랜잭션 관리, 보안, 로깅 등 애플리케이션 수준의 횡단 관심사를 처리할 수 있습니다. 이 계층은 도메인 로직을 직접 포함하기보다는 도메인 계층의 객체와 서비스를 조율하는 역할을 합니다.
  • Kotlin 예시:
    // com.example.hexagonal.application.service
    package com.example.hexagonal.application.service
    
    import com.example.hexagonal.application.port.`in`.GetUserQuery
    import com.example.hexagonal.application.port.`in`.RegisterUserCommand
    import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
    import com.example.hexagonal.application.port.out.UserRepository
    import com.example.hexagonal.domain.User
    import com.example.hexagonal.domain.UserId
    import com.example.hexagonal.domain.service.UserValidationService
    import org.springframework.stereotype.Service
    import org.springframework.transaction.annotation.Transactional
    
    @Service
    @Transactional // 애플리케이션 서비스에서 트랜잭션 관리
    class UserRegistrationService(
        private val userRepository: UserRepository,
        private val userValidationService: UserValidationService
    ) : RegisterUserUseCase {
        override fun registerUser(command: RegisterUserCommand): User {
            // 이메일 중복 검사는 Application Service에서 Output Port를 통해 인프라에 위임.
            if (userRepository.findByEmail(command.email) != null) {
                throw IllegalArgumentException("Email already exists")
            }
    
            val newUser = User.create(command.name, command.email)
    
            // 도메인 서비스의 비즈니스 규칙 활용
            if (!userValidationService.isValidUser(newUser)) {
                throw IllegalArgumentException("Invalid user data according to domain rules")
            }
    
            return userRepository.save(newUser)
        }
    }
    
    @Service
    @Transactional(readOnly = true) // 읽기 전용 트랜잭션
    class UserQueryService(
        private val userRepository: UserRepository
    ) : GetUserQuery {
        override fun getUserById(userId: UserId): User? {
            return userRepository.findById(userId)
        }
    }
    

설정 (Configuration)

  • 정의: 애플리케이션의 시작, 의존성 주입(Dependency Injection) 컨테이너 설정 등을 담당하는 클래스입니다.
  • 위치: 일반적으로 configuration 패키지 또는 애플리케이션의 루트에 가깝게 위치합니다. 예를 들어 com.example.hexagonal.configuration.
  • 설명: Spring Framework의 @Configuration 클래스와 같이, 애플리케이션의 구성 요소들을 Bean으로 등록하고, 의존성을 연결해주는 역할을 합니다. 이 계층에서 포트와 어댑터의 실제 구현체가 연결됩니다.
  • Kotlin 예시:
    // com.example.hexagonal.configuration
    package com.example.hexagonal.configuration
    
    import com.example.hexagonal.application.port.`in`.GetUserQuery
    import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
    import com.example.hexagonal.application.port.out.UserRepository
    import com.example.hexagonal.application.service.UserQueryService
    import com.example.hexagonal.application.service.UserRegistrationService
    import com.example.hexagonal.domain.service.UserValidationService
    import org.springframework.context.annotation.Bean
    import org.springframework.context.annotation.Configuration
    
    @Configuration
    class HexagonalArchitectureConfig {
    
        // 도메인 서비스는 인프라에 의존하지 않으므로 직접 Bean으로 등록 가능
        @Bean
        fun userValidationService(): UserValidationService {
            return UserValidationService()
        }
    
        // 애플리케이션 서비스는 Output Port에 의존하며, Output Port는 Adapter에 의해 구현됨.
        // 여기서는 InMemoryUserRepositoryAdapter를 UserRepository의 구현체로 주입.
        @Bean
        fun registerUserUseCase(userRepository: UserRepository, userValidationService: UserValidationService): RegisterUserUseCase {
            return UserRegistrationService(userRepository, userValidationService)
        }
    
        @Bean
        fun getUserQuery(userRepository: UserRepository): GetUserQuery {
            return UserQueryService(userRepository)
        }
    }
    

클래스 배치 실전 가이드 및 흔한 고민 해결

헥사고날 아키텍처를 처음 접하는 개발자들이 자주 하는 고민과 그 해결책을 짚어보겠습니다.

  • Controller는 어디에 두나요?

    Controller는 웹 요청을 받아서 Input Port를 호출하는 역할을 하므로, Input Adapter에 해당합니다. 따라서 adapter/in/web 패키지(예: com.example.hexagonal.adapter.in.web)에 배치하는 것이 가장 적절합니다. 컨트롤러는 도메인 계층을 직접 참조해서는 안 되며, Application Service (즉, Input Port의 구현체)를 주입받아 사용해야 합니다.

  • Repository 인터페이스는 어디에 두나요?

    Repository 인터페이스는 도메인 또는 애플리케이션 계층이 데이터 접근에 필요한 계약을 정의하는 Output Port입니다. 이 인터페이스는 핵심 비즈니스 로직(도메인)이 인프라 세부 사항에 오염되지 않도록 보호해야 합니다. 따라서 application/port/out 패키지(예: com.example.hexagonal.application.port.out)에 두는 것이 일반적입니다. 이렇게 하면 도메인 계층이 인프라를 전혀 알지 못하게 되므로 의존성 역전이 완벽하게 이루어집니다.

  • Repository 구현체는 어디에 두나요?

    Repository 인터페이스의 실제 구현체는 데이터베이스 기술(예: JPA, MongoDB)에 의존하는 Output Adapter입니다. 따라서 adapter/out/persistence 패키지(예: com.example.hexagonal.adapter.out.persistence)에 배치해야 합니다. 이 구현체는 Output Port 인터페이스를 구현하며, 실제 데이터베이스와의 상호작용 로직을 포함합니다.

  • Service 클래스가 너무 많아요! Domain ServiceApplication Service를 어떻게 구분하나요?

    Service 클래스의 역할이 모호해지는 것이 헥사고날 아키텍처 도입 초기 가장 흔한 문제입니다.

    • Domain Service: 특정 엔티티에 속하기 어렵거나 여러 엔티티에 걸쳐 있는 핵심 비즈니스 로직을 캡슐화합니다. 예를 들어, 계좌 이체 로직은 Account 엔티티 두 개에 걸쳐 있으므로 도메인 서비스에 적합합니다. 도메인 서비스는 인프라에 의존해서는 안 됩니다.
    • Application Service (또는 Use Case): 특정 사용 사례를 구현하며, 도메인 객체도메인 서비스조율하여 비즈니스 시나리오를 완성합니다. 트랜잭션, 보안, 로깅 등 애플리케이션 수준의 횡단 관심사를 처리하고, Output Port를 통해 인프라와 소통합니다.

    이 둘을 명확히 구분함으로써 각 서비스의 책임을 분리하고, 도메인 계층의 순수성을 유지할 수 있습니다.

  • DTO (Data Transfer Object)는 어디에 두나요?

    DTO는 계층 간 데이터 전송을 목적으로 하는 객체입니다. 헥사고날 아키텍처에서는 DTO가 도메인 계층에 침투하는 것을 강력히 지양해야 합니다.

    • Input Adapter (예: UserController)에서 요청을 받을 때 사용하는 Request DTOadapter/in/{기술_유형} 패키지 내부에 (예: com.example.hexagonal.adapter.in.web) 정의합니다.
    • Output Adapter (예: JpaUserRepository)에서 데이터를 반환할 때 사용하는 Response DTO 또한 해당 어댑터 패키지 또는 application/port/in 또는 out과 관련된 하위 패키지에 정의될 수 있습니다. 중요한 것은 도메인 객체를 외부로 직접 노출하지 않고 DTO를 통해 변환하여 전달하는 것입니다.

Kotlin 코드 예시: 전체 구조

다음은 위에서 설명한 클래스 배치 원칙을 따르는 간단한 사용자 등록 및 조회 애플리케이션의 전체 Kotlin 코드 구조 예시입니다.

├── src/main/kotlin
│   └── com
│       └── example
│           └── hexagonal
│               ├── HexagonalApplication.kt
│               ├── adapter
│               │   ├── in
│               │   │   └── web
│               │   │       └── UserController.kt
│               │   └── out
│               │       └── persistence
│               │           └── InMemoryUserRepositoryAdapter.kt
│               ├── application
│               │   ├── port
│               │   │   ├── in
│               │   │   │   └── UserUseCase.kt
│               │   │   └── out
│               │   │       └── UserRepository.kt
│               │   └── service
│               │       └── UserApplicationService.kt
│               ├── configuration
│               │   └── HexagonalArchitectureConfig.kt
│               └── domain
│                   ├── User.kt
│                   └── service
│                       └── UserValidationService.kt
// src/main/kotlin/com/example/hexagonal/HexagonalApplication.kt
package com.example.hexagonal

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class HexagonalApplication

fun main(args: Array<String>) {
    runApplication<HexagonalApplication>(*args)
}
// src/main/kotlin/com/example/hexagonal/domain/User.kt
package com.example.hexagonal.domain

import java.time.LocalDateTime
import java.util.UUID

data class UserId(val value: UUID) {
    init {
        require(value != UUID.fromString("00000000-0000-0000-0000-000000000000")) { "Invalid UserId" }
    }
    override fun toString(): String = value.toString()
}

class User(
    val id: UserId,
    var name: String,
    val email: String,
    var createdAt: LocalDateTime,
    var updatedAt: LocalDateTime? = null
) {
    init {
        require(name.isNotBlank()) { "User name cannot be blank" }
        require(email.matches(Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,6}$"))) { "Invalid email format" }
    }

    fun changeName(newName: String) {
        if (newName.isBlank()) {
            throw IllegalArgumentException("New name cannot be blank")
        }
        this.name = newName
        this.updatedAt = LocalDateTime.now()
    }

    companion object {
        fun create(name: String, email: String): User {
            val now = LocalDateTime.now()
            return User(
                id = UserId(UUID.randomUUID()),
                name = name,
                email = email,
                createdAt = now,
                updatedAt = now
            )
        }
    }
}
// src/main/kotlin/com/example/hexagonal/domain/service/UserValidationService.kt
package com.example.hexagonal.domain.service

import com.example.hexagonal.domain.User

class UserValidationService {
    fun isValidUser(user: User): Boolean {
        return user.name.length > 2 && user.email.isNotBlank()
    }
}
// src/main/kotlin/com/example/hexagonal/application/port/in/UserUseCase.kt
package com.example.hexagonal.application.port.`in`

import com.example.hexagonal.domain.User
import com.example.hexagonal.domain.UserId

interface RegisterUserUseCase {
    fun registerUser(command: RegisterUserCommand): User
}

data class RegisterUserCommand(val name: String, val email: String)

interface GetUserQuery {
    fun getUserById(userId: UserId): User?
}
// src/main/kotlin/com/example/hexagonal/application/port/out/UserRepository.kt
package com.example.hexagonal.application.port.out

import com.example.hexagonal.domain.User
import com.example.hexagonal.domain.UserId

interface UserRepository {
    fun save(user: User): User
    fun findById(id: UserId): User?
    fun findByEmail(email: String): User?
}
// src/main/kotlin/com/example/hexagonal/application/service/UserApplicationService.kt
package com.example.hexagonal.application.service

import com.example.hexagonal.application.port.`in`.GetUserQuery
import com.example.hexagonal.application.port.`in`.RegisterUserCommand
import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
import com.example.hexagonal.application.port.out.UserRepository
import com.example.hexagonal.domain.User
import com.example.hexagonal.domain.UserId
import com.example.hexagonal.domain.service.UserValidationService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserRegistrationService(
    private val userRepository: UserRepository,
    private val userValidationService: UserValidationService
) : RegisterUserUseCase {
    override fun registerUser(command: RegisterUserCommand): User {
        if (userRepository.findByEmail(command.email) != null) {
            throw IllegalArgumentException("Email already exists")
        }

        val newUser = User.create(command.name, command.email)

        if (!userValidationService.isValidUser(newUser)) {
            throw IllegalArgumentException("Invalid user data according to domain rules")
        }

        return userRepository.save(newUser)
    }
}

@Service
@Transactional(readOnly = true)
class UserQueryService(
    private val userRepository: UserRepository
) : GetUserQuery {
    override fun getUserById(userId: UserId): User? {
        return userRepository.findById(userId)
    }
}
// src/main/kotlin/com/example/hexagonal/adapter/in/web/UserController.kt
package com.example.hexagonal.adapter.`in`.web

import com.example.hexagonal.application.port.`in`.GetUserQuery
import com.example.hexagonal.application.port.`in`.RegisterUserCommand
import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
import com.example.hexagonal.domain.User
import com.example.hexagonal.domain.UserId
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.UUID

@RestController
@RequestMapping("/users")
class UserController(
    private val registerUserUseCase: RegisterUserUseCase,
    private val getUserQuery: GetUserQuery
) {

    @PostMapping
    fun registerUser(@RequestBody request: RegisterUserRequest): ResponseEntity<UserResponse> {
        val command = RegisterUserCommand(request.name, request.email)
        val user = registerUserUseCase.registerUser(command)
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.fromDomain(user))
    }

    @GetMapping("/{userId}")
    fun getUser(@PathVariable userId: UUID): ResponseEntity<UserResponse> {
        val user = getUserQuery.getUserById(UserId(userId))
        return user?.let { ResponseEntity.ok(UserResponse.fromDomain(it)) }
            ?: ResponseEntity.notFound().build()
    }
}

data class RegisterUserRequest(val name: String, val email: String)
data class UserResponse(val id: UUID, val name: String, val email: String) {
    companion object {
        fun fromDomain(user: User): UserResponse {
            return UserResponse(user.id.value, user.name, user.email)
        }
    }
}
// src/main/kotlin/com/example/hexagonal/adapter/out/persistence/InMemoryUserRepositoryAdapter.kt
package com.example.hexagonal.adapter.out.persistence

import com.example.hexagonal.application.port.out.UserRepository
import com.example.hexagonal.domain.User
import com.example.hexagonal.domain.UserId
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.util.concurrent.ConcurrentHashMap

@Repository
class InMemoryUserRepositoryAdapter : UserRepository {
    private val store: ConcurrentHashMap<UserId, User> = ConcurrentHashMap()

    override fun save(user: User): User {
        store[user.id] = user
        return user
    }

    override fun findById(id: UserId): User? {
        return store[id]
    }

    override fun findByEmail(email: String): User? {
        return store.values.firstOrNull { it.email == email }
    }
}
// src/main/kotlin/com/example/hexagonal/configuration/HexagonalArchitectureConfig.kt
package com.example.hexagonal.configuration

import com.example.hexagonal.adapter.out.persistence.InMemoryUserRepositoryAdapter
import com.example.hexagonal.application.port.`in`.GetUserQuery
import com.example.hexagonal.application.port.`in`.RegisterUserUseCase
import com.example.hexagonal.application.port.out.UserRepository
import com.example.hexagonal.application.service.UserQueryService
import com.example.hexagonal.application.service.UserRegistrationService
import com.example.hexagonal.domain.service.UserValidationService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class HexagonalArchitectureConfig {

    @Bean
    fun userValidationService(): UserValidationService {
        return UserValidationService()
    }

    @Bean
    fun userRepository(): UserRepository {
        return InMemoryUserRepositoryAdapter()
    }

    @Bean
    fun registerUserUseCase(userRepository: UserRepository, userValidationService: UserValidationService): RegisterUserUseCase {
        return UserRegistrationService(userRepository, userValidationService)
    }

    @Bean
    fun getUserQuery(userRepository: UserRepository): GetUserQuery {
        return UserQueryService(userRepository)
    }
}

결론 및 도움말

헥사고날 아키텍처는 애플리케이션의 복잡성을 관리하고 핵심 비즈니스 로직의 독립성을 보장하는 강력한 설계 패러다임입니다. 이 아키텍처의 핵심은 도메인을 중심으로 포트어댑터를 통해 외부 세계와 소통하는 것입니다. 클래스를 어디에 두어야 할지 고민될 때는, 항상 다음 질문을 스스로에게 던져보세요:

  1. 이 클래스가 순수한 비즈니스 규칙과 관련되어 있는가? 그렇다면 domain 계층으로.
  2. 이 클래스가 특정 사용 사례를 조율하는가? 그렇다면 application/service 계층으로 (Input Port 구현).
  3. 이 클래스가 외부 기술(DB, UI, API)에 의존하는가? 그렇다면 adapter 계층으로 (Output Port 구현 또는 Input Port 호출).
  4. 이 클래스가 내부와 외부를 연결하는 계약인가? 그렇다면 port 계층으로.

이 가이드라인을 통해 더욱 견고하고, 유연하며, 테스트하기 쉬운 애플리케이션을 설계하시길 바랍니다. 초기에는 약간의 학습 곡선이 있을 수 있지만, 장기적으로는 유지보수 비용을 크게 줄여줄 것입니다.

참고 자료