[Backend] Domain-Driven Design(DDD)와 Hexagonal Architecture

2025. 5. 3. 23:16·Backend

최근 구직을 하면서 많은 채용 공고를 보았는데 자주 눈에 들어오는 단어가 있었다.

DDD를 잘 이해하고 사용할 수 있는가를 보는 회사들이 꽤나 있었다.

들어보기는 했던 개념이였는데 지금까지 전통적인 아키텍처 (Controller -> Service -> Repository)를 주로 사용했던터라 익숙하지는 않지만 진행하던 사이드 프로젝트에 DDD와 헥사고날 아키텍처를 도입해보기로 하고 전부 리팩토링 하는 과정을 거쳤다.

 

DDD와 헥사고날 아키텍처에 대해서 알아본 개념들과 사이드 프로젝트에 어떻게 적용을 했는가에 대해 작성해보려 한다.

 


 

1. Domain-Driven Design (DDD)란?

  • 복잡한 도메인을 이해하고, 그 지식을 기반으로 핵심 도메인을 중심으로 소프트웨어를 설계하는 방법론 (GPT 왈)

1.1 Aggregate

  • DDD의 가장 핵심적인 개념 중 하나
  • Entity와 Value Object를 묶은 도메인 일관성의 단위
  • 하나의 Aggregate Root를 중심으로 구성되며, 외부에서는 반드시 Root를 통해서만 내부에 접근
  • ex) Order 도메인
    • Order : Aggregate Root
    • OrderItem : Aggregate에 속한 Entity이지만 외부에서 직접 접근 금지
    • 외부는 항상 Order를 통해서만 내부 상태 변경 가능
Aggregate: Order
 ├─ Aggregate Root: Order
 ├─ Entity: OrderItem
 └─ Value Object: ShippingAddress
@Entity
class Order(
  @Id
  val id: Long,

  @OneToMany(cascade = [CascadeType.ALL])
  private val orderItems: MutableList<OrderItem> = mutableListOf(),
	
  @Embedded
  val shippingAddress: Address
) {
  fun addItem(
    item: OrderItem
  ) {
    this.orderItems.add(item) // 반드시 Root를 통해서만 내부 변경
  }
  fun changeShippingAddress(
    newAddress: Address
  ) {
    // 유효성 검사 후 변경
  }
}

// ❌ 잘못된 설계 ❌
val item = OrderItem(...);
item.changeQuantity(5) // 외부 서비스가 OrderItem을 직접 생성하거나 조작

 

1.2 도메인 서비스 (Domain Service)

  • 도메인 로직 중 특정 Entity나 Value Object에 소속되기 애매한 중요한 비즈니스 규칙을 표현하기 위해 사용
  • 일반적으로 불변이며 상태를 갖지 않는 클래스
  • Entity 내부에 넣기에는 어색하지만 중요한 도메인 연산
  • Application Service와 구분 (비즈니스 의미 존재)
  • ex) 두 회원 간의 송금 가능 여부 판단
    • Member 엔티티에 넣기에는 어색하지만 송금이 가능한지 판단하는 도메인 규칙은 반드시 필요
class TransferValidationService {
  fun catTransfer(
    sender: Member,
    receiver: Member,
    amount: Money
  ): Boolean {
    return !sender.isBlocked && !receiver.isBlocked && sender.balance >= amount
  }
}

 

1.3 도메인 이벤트 (Domain Event)

  • 도메인 모델 내부에서 발생한 '의미 있는 상태 변화'를 외부에 알리는 객체
  • '무엇이 발생했다'는 사실을 표현하며, 비동기 / 이벤트 기반 아키텍처와 사용하면 좋음
  • ex) 주문 완료 후 이벤트 발생
data class OrderCompletedEvent(
  val orderId: Long,
  val completedAt: LocalDateTime
)

class Order(
  @Id
  val id: Long,
  ...
) {
  fun completeOrder(): OrderCompletedEvent {
    this.status = OrderStatus.COMPLETED
    return OrderCompletedEvent(id, LocalDateTime.now())
  }
}

 


2. Hexagonal Architecture (헥사고날 아키텍처)란?

  • Ports and Adapter Architecture라고도 부른다고 함
  • 도메인 로직을 중심으로 외부와의 의존성을 분리하는 설계 방식

2.1 핵심 개념 요약

  • 도메인 로직은 중심 (Hexagon 내부)
    • Domain Model, Aggregate, Domain Service, Domain Event 등 핵심 로직
  • 포트 (Ports)
    • 도메인과 외부를 연결하는 인터페이스
    • 입력 포트 (Inbound Port) : 애플리케이션 계층의 구현 (ex. UseCase)
    • 출력 포트 (Outbound Port) : 외부 의존성과 연결 (ex. Repository, 외부 API)
  • 어댑터 (Adapter)
    • 포트를 구현하는 외부 기술 계층
    • 입력 어댑터 (Inbound Adapter) : Controller (HTTP), Consumer (Kafka, RabbitMQ 등)
    • 출력 어댑터 (Outbound Adapter) : JPA 구현체, 외부 API 호출 코드 등

2.2 전통적인 아키텍처(Layered Architecture)와의 차이점

  • 전통적인 계층형 아키텍처 (Layered Architecture)
    • 위에서 아래로 의존성 흐름 (UI -> 비즈니스 -> 인프라)
    • 계층 간 의존성 명확
    • 단점
      • 도메인 코드가 인프라에 의존할 가능성
      • 인프라 변경 시 도메인 코드 영향
      • 도메인 테스트가 어렵고, 애플리케이션이 DB나 Web에 묶이는 경향
[ Presentation Layer ] ← Controller, View
        ↓
[ Application Layer ] ← Service
        ↓
[ Domain Layer ]       ← Entity, Business Logic
        ↓
[ Infrastructure Layer ] ← DB, API, 외부 시스템
  • 헥사고날 아키텍처 (Hexagonal Architecture)
    • 도메인이 아키텍처의 중심 -> 도메인은 외부에 의존하지 않음
    • 포트(인터페이스)로 외부 기술과 연결 -> 기술 교체 쉬움
    • 모든 입출력은 어댑터(구현체)로 격리
    • 테스트, 유지보수에 강함

 

3. 그래서 나는 어떻게 적용했는가?

  • 진행하고 있던 사이드 프로젝트의 Auth 인증 도메인을 어떻게 리팩토링 했는지 소개해보려고 한다. (수줍수줍)
  • 기술 스택은 아래와 같다.
    • Spring Boot 2.5.7, Kotlin 1.9.22
    • Spring Data JPA와 Redis
  • 폴더 구조는 아래와 같이 잡았다. (GPT에게 물어본 결과 전체적인 구조는 괜찮으나 선택적으로 개선할 지점도 있다고 했다.)
    • User 도메인은 따로 작업했으나 이해를 돕고자 User의 Repository와 JPA Entity를 'auth' 폴더 안에 넣어서 표현했다.
    • adapter
      • in : Controller는 web 폴더 안에 두었다. 만일 메세지 큐를 사용한다면 Consumer는 'adapter/in/message'경로에 생성할 것 같다.
      • out : JPA의 Entity와 Repository 실제 구현체가 위치한다. (+ Redis)
    • application
      • command : Controller에서 Application Service를 호출할 때 Command를 인자로 넘기게 했습니다...만..! GPT는 CQRS 패턴처럼 보이니 dto로 수정하는게 좋을 것 같다고 의견을 내주었습니다.
      • service : UseCase의 구현체인 Application Service 파일들이 위치한다.
      • usecase : 비즈니스 흐름을 정의하는 인터페이스가 위치한다.
    • domain
      • model : 도메인 모델
      • port.out : 외부로 나가는 port가 위치한다. JWT 또한 외부 라이브러리를 사용하기 때문에 port로 분리하고 DB 관련 인터페이스를 정의했다.
    • infra
      • jwt와 Redis 폴더를 구분했다.
auth
├── adapter
│   ├── in
│   │   └── web
│   │       ├── dto
│   │       │   └── SignInRequest.kt
│   │       ├── mapper
│   │       │   └── UserTokenResponseMapper.kt
│   │       └── rest
│   │           └── AuthRest.kt
│   └── out
│       └── persistence
│           ├── entity
│           │   └── UserTokenEntity.kt
│           │   └── UserEntity.kt
│           └── repository
│               └── UserTokenRedisRepository.kt
│               └── UserRepositoryImpl.kt
│               └── UserJpaRepository.kt
├── application
│   ├── command
│   │   └── SignInCommand.kt
│   ├── service
│   │   └── AuthService.kt
│   └── usecase
│       └── AuthUseCase.kt
├── domain
│   ├── model
│   │   └── UserToken.kt
│   └── port
│       └── out
│           ├── AuthCachePort.kt
│           └── TokenGeneratePort.kt
│           └── UserRepository.kt
└── infra
    ├── jwt
    │   └── JwtTokenUtils.kt
    └── redis
        └── AuthCacheAdapter.kt

 

  • 구현한 API는 총 3가지로 아래와 같다.
    • 이미 존재하는 회원인지 체크
    • 로그인
    • 회원가입
  • 계층형 아키텍처만 사용하던 내가 DDD + 헥사고날을 적용하며 가졌던 의문과 같이 코드 예시를 적어보겠다.

 

  • Entity와 Repository를 어디에 두어야할까?
    • 작업할 때 Entity와 Repository를 먼저 생성하는 편이라 가장 먼저 들었던 의문이었다.
    • 이왕 Entity와 Repository를 생성하는김에 도메인 객체도 생성하자고 생각했다.
  • domain/model/User.kt
class User(
  val id: Long? = -1L,
  val provider: Provider,
  val encryptedSub: String,
  val hashedSub: String,
  val email: String,
  val nickname: String,
  val birthDate: LocalDate
)
enum class Provider {
  GOOGLE;
  companion object {
    fun getProvider(
      value: String
    ): Provider = try {
      Provider.valueOf(value.uppercase())
    } catch (e: Exception) {
      throw NotFoundProviderException()
    }
  }
}
  • adapter/out/persistence/entity/UserEntity.kt
@Entity
@Table(name = "user")
class UserEntity(
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  val id: Long? = -1,
  @Enumerated(EnumType.STRING)
  val provider: Provider,
  @Column(name = "encrypted_sub")
  val encryptedSub: String,
  @Column(name = "hashed_sub")
  val hashedSub: String,
  val email: String,
  val nickname: String,
  @Column(name = "birth_date")
  val birthDate: LocalDate
): AbstractBaseAuditEntity() {
  // 정적 팩토리 메서드
  companion object {
    fun fromModel(
      user: User
    ): UserEntity {
      return UserEntity(
        provider = user.provider,
        encryptedSub = user.encryptedSub,
        hashedSub = user.hashedSub,
        email = user.email,
        nickname = user.nickname,
        birthDate = user.birthDate
      )
    }
  }
  fun toModel() = User(
    id = this.id,
    provider = this.provider,
    encryptedSub = this.encryptedSub,
    hashedSub = this.hashedSub,
    email = this.email,
    nickname = this.nickname,
    birthDate = this.birthDate
  )
}
  • adapter/out/persistence/entity/UserTokenEntity.kt
    • Redis에 들어갈 Entity도 여기에 생성했습니다.
@RedisHash(value = "user-token")
data class UserTokenEntity(
  @Id
  val userId: Long,
  val refreshToken: String,
  @TimeToLive
  val expirationTime: Long? = null
)

 

  • domain/port/out/userRepository.kt
interface UserRepository {
  fun existsByHashedSubAndProvider(
    hashedSub: String,
    provider: Provider
  ): Boolean
  
  fun save(
    user: User
  ): User

  fun findByHashedSubAndProvider(
    hashedSub: String,
    provider: Provider
  ): User?

  fun findByHashedSub(
    hashedSub: String
  ): User?
}

 

  • adapter/out/persistence/repository/UserRepositoryImpl.kt
@Repository
class UserRepositoryImpl(
  private val jpaRepository: UserJpaRepository
): UserRepository {
  override fun existsByHashedSubAndProvider(
    hashedSub: String,
    provider: Provider
  ): Boolean {
    return jpaRepository.existsByHashedSubAndProvider(hashedSub, provider)
  }

  override fun save(
    user: User
  ): User {
    return jpaRepository.save(
      UserEntity.fromModel(user)
    ).toModel()
  }

  override fun findByHashedSubAndProvider(
    hashedSub: String,
    provider: Provider
  ): User? {
    return jpaRepository.findByHashedSubAndProvider(
      hashedSub,
      provider
    )?.toModel()
  }

  override fun findByHashedSub(
    hashedSub: String
  ): User? {
    return jpaRepository.findByHashedSub(hashedSub)?.toModel()
  }
}

 

  • adapter/in/web/rest/AuthRest.kt
    • Request DTO로 받아온 값을 UseCase로 전달하기 전에 전부 Command로 변환했다.
    • toCommand를 통해 변환 할 때는 주체에서 작업을 하는게 좋을 것 같아 DTO 클래스 안에 Command로 변환하는 코드 작성
@RestController
@RequestMapping("/api/v1/auth")
class AuthRest(
  private val authUseCase: AuthUseCase
) {
  @GetMapping("/exist")
  @ApiOperation("존재하는 회원인지 체크")
  fun checkExist(
    params: CheckExistRequest
  ): ResponseEntity<CheckExistResponse> {
    val command = params.toCommand()
    val isExist = authUseCase.checkExistUser(command)
    val response = CheckExistResponse(isExist)
    
    return ResponseEntity
        .ok()
        .body(response)
  }

  @PostMapping("/sign-up")
  @ApiOperation("회원가입")
  fun signUp(
    @RequestBody request: SignUpRequest
  ): ResponseEntity<UserTokenResponse> {
    val signUpCommand = request.toCommand()
    val userToken = authUseCase.signUp(signUpCommand)
    val response = userToken.toUserTokenResponse()

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(response)
  }

  @PostMapping("/sign-in")
  @ApiOperation("로그인")
  fun signIn(
    @RequestBody request: SignInRequest
  ): ResponseEntity<UserTokenResponse> {
    val command = request.toCommand()
    val userToken = authUseCase.signIn(command)
    val response = userToken.toUserTokenResponse()

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(response)
  }
}

 

  • adapter/in/web/dto/SignUpRequest.kt
data class SignUpRequest(
  val sub: String,
  val provider: Provider,
  val email: String,
  val nickname: String,
  @JsonFormat(pattern = "yyyy-MM-dd")
  val birthDate: LocalDate
)

// Kotlin의 확장함수 이용
fun SignUpRequest.toCommand() = SignUpCommand(
  this.sub,
  this.provider,
  this.email,
  this.nickname,
  this.birthDate
)

 

  • application/usecase/AuthUseCase.kt
interface AuthUseCase {
  fun checkExistUser(
    command: CheckExistUserCommand
  ): Boolean

  fun signUp(
    command: SignUpCommand
  ): UserToken

  fun signIn(
    command: SignInCommand
  ): UserToken
}

 

  • application/service/AuthService.kt (AuthUseCase 구현체)
    • Repository에 접근할 때는 User 도메인 모델을 사용하도록 했다.
    • 'checkExistUser'은 단순 검증이기 때문에 도메인 객체를 넘기지 않고 검증에 필요한 값만 넘기도록 했다.
    • GPT 왈 'save()' 또는 'findById()'를 이용한 조립이 필요한 경우에는 도메인 객체를 넘기고 단순 검증인 경우에는 굳이 도메인 객체를 넘기지 않아도 괜찮다고 했다.
@Service
class AuthService(
  private val passwordEncoder: PasswordEncoder,
  private val userRepository: UserRepository,
  private val tokenGeneratePort: TokenGeneratePort,
  private val authCachePort: AuthCachePort
): AuthUseCase {
  override fun checkExistUser(
    command: CheckExistUserCommand
  ): Boolean {
    val hashedSub = tokenGeneratePort.hashWithSHA256(command.sub)
    val isExist = userRepository.existsByHashedSubAndProvider(
      hashedSub = hashedSub,
      provider = command.provider
    )
    return isExist
  }

  @Transactional
  override fun signUp(
    command: SignUpCommand
  ): UserToken {
    val hashedSub = tokenGeneratePort.hashWithSHA256(command.sub)

    val checkExistUserCommand = CheckExistUserCommand(command.sub, command.provider)
    when (checkExistUser(checkExistUserCommand)) {
      true -> throw ExistUserException()
      false -> {
        var user = command.toModel(
          encryptedSub = passwordEncoder.encode(command.sub),
          hashedSub = hashedSub
        )
        user = userRepository.save(user)
        val userToken = tokenGeneratePort.generateToken(user)
        authCachePort.saveRefreshToken(user, userToken)
        return userToken
      }
    }
  }

  @Transactional
  override fun signIn(
    command: SignInCommand
  ): UserToken {
    val hashedSub = tokenGeneratePort.hashWithSHA256(command.sub)
    userRepository.findByHashedSubAndProvider(
      hashedSub,
      command.provider
    )?.let { user ->
      val userToken = tokenGeneratePort.generateToken(user)
      authCachePort.saveRefreshToken(user, userToken)
      return userToken
    } ?: throw NotFoundUserException()
  }
}

 


4. 사이드 프로젝트에 간략하게 적용해본 후기

사이드 프로젝트 진행 상황이 빠르게 흘러가지 못하고 있어 Auth에만 적용을 해보았다.

장점이라고 느껴진 부분은 Service 로직 재활용에 있어서 간편하다는 생각이 들었다. 계층형 아키텍처를 사용했을 때는 이런 부분들이 늘 고민으로 와닿았는데 사실 이건 CQRS 패턴에 관련된거 아닌가 싶기도 하다..

단점이라고 느껴진 부분은 너무 복잡하다는 생각이 들었다. 뭐 하나를 추가한다고 했을 때 추가되는 코드량이 어마무시 하겠다는 생각이 들었다. 실무에 도입을 한다면 현재 서비스가 해당 아키텍처를 도입해도 될만한 사이즈인지 잘 생각할 필요가 있다는 생각이 들었다.

 

개인적으로 사용해보면서 기술의 교체가 이루어질 때 해당 아키텍처의 장점이 크게 빛이 날거라고 생각이 들기는했다.

테스트 코드는 아직 작성하지 못했으나 테스트 코드를 작성하면서 느끼게 될 장점들이 더 있을까? 싶기도 했다.

 

작성한 코드의 전문은 아래 깃허브 주소에 있다.

 

GitHub - yujin-Kim-98/twinme-api: 나와의 대화

나와의 대화. Contribute to yujin-Kim-98/twinme-api development by creating an account on GitHub.

github.com

 

끄읏-

'Backend' 카테고리의 다른 글

[Backend] 동시성 이슈와 해결 방법 알아보기  (0) 2025.03.31
'Backend' 카테고리의 다른 글
  • [Backend] 동시성 이슈와 해결 방법 알아보기
나는 유찌
나는 유찌
쩌리쨩
  • 나는 유찌
    유찌 개발 일기
    나는 유찌
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 사이드 프로젝트
        • 게시판
        • 블로그(Spring boot + React.js ..
      • 데이터베이스
        • SQLD
      • 이슈 해결
      • Front
        • Javascript
        • Vue.js
        • HTML+CSS
      • Backend
        • Spring
        • ORM
        • JAVA
      • 공부
        • HTTP
        • OOP
        • 이것저것
        • 코딩테스트 | 알고리즘
      • Computer Science
        • Computer architecture
        • 데이터베이스
        • 운영체제
      • 일상
        • 독서
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    access token
    히가시노 게이고
    AccessDecisionVoter
    Access Token Refresh Token
    권한 scope 처리
    AntPathMatcher
    JWT이란?
    spring
    Kotlin AccessDecisionManager
    LeetCode
    mssql
    독서
    jwt 로그인 구현
    Spring Boot
    Spring boot에서 JWT 구현
    phantom read
    role scope
    redis 분산락
    refresh token
    DIRTY READ
    Spring Security AccessDecisionManager
    Kotlin AntPathMatcher
    Access token 재발급
    한국소설
    jwt
    추리소설
    pessimisticlock
    spring 격리수준
    웹 개발
    mysql 격리수준
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
나는 유찌
[Backend] Domain-Driven Design(DDD)와 Hexagonal Architecture
상단으로

티스토리툴바