요약: 카카오페이의 여신업무 내재화 프로젝트를 DDD를 적용하여 구축한 내용을 공유하고자 합니다. DDD의 개념을 간략하게 다루고 실무에 어떻게 적용하였는지 비즈니스 도메인을 설계한 과정들을 다루고, Spring Multi Module 환경에서 도메인 Entity 관리에 대해 코드레벨로 설명하고 있습니다.
💡 리뷰어 한줄평
geuru.geon DDD를 팀 내에 도입한 배경과 과정을 코드를 통해 더 쉽게 이해할 수 있는 글입니다. 혹시 독자분들이 정책이 견고한 도메인을 다루고 계시다면, DDD 도입도 좋아 보입니다! 필립의 경험을 통해 확인해 보시죠. 😊
yooni.que 복잡한 도메인에서의 DDD 적용 과정을 예시와 함께 보다 구체적으로 이해하는 데 도움이 되는 문서입니다. 독자분들도 프로젝트팀의 입장에서 같이 고민해 보시며 DDD를 실제 프로젝트에 적용하는 데 한 걸음 더 나아갈 수 있기를 바랍니다.
시작하며
안녕하세요. 카카오페이 후불결제TF에서 Backend 개발을 하고 있는 필립입니다.
카카오페이 후불결제(BNPL)의 여신코어시스템을 DDD(Domain Driven Design, 도메인 주도 설계)로 구축한 경험을 공유하고자 합니다.
여신비즈니스가 시스템으로 구현되는 과정에서 DDD를 프로젝트 팀에서 어떻게 활용하였는지, 코드레벨에서는 어떤 구조로 설계하고 구현하였는지에 대해 공유하고자 합니다.
신규로 시스템 구축을 준비하시거나, 운영한 지 오래된 시스템을 다시 새로운 구조로 구축하고자 하시는 분들께 도움이 되었으면 좋겠습니다.
DDD란?
DDD는 Eric Evans가 2003년 출간한 “Domain Driven Design”이라는 책에서 처음 소개된 개념으로, 복잡한 도메인 모델을 설계하고 구현하기 위한 방법론입니다. DDD는 도메인 전문가와 개발자가 협력하여 도메인 모델을 정의하고, 이를 기반으로 소프트웨어를 설계하는 것을 목표로 합니다. DDD는 다음과 같은 주요 개념을 포함합니다.
- 도메인(Domain): 소프트웨어가 해결하고자 하는 문제 영역입니다. 예를 들어, 여신 시스템의 경우 대출, 심사, 승인 등의 도메인이 있습니다.
- 유비쿼터스 언어(Ubiquitous Language): 도메인 전문가와 개발자가 공통으로 사용하는 언어로, 도메인 모델을 설명하는 데 사용됩니다. 이를 통해 팀원 간의 의사소통을 원활하게 하고, 도메인에 대한 이해를 높일 수 있습니다.
- 바운디드 컨텍스트(Bounded Context): 도메인을 명확하게 구분 짓는 경계입니다. 각 바운디드 컨텍스트는 독립적으로 개발되고 배포될 수 있으며, 서로 다른 바운디드 컨텍스트 간의 상호작용은 명확한 인터페이스를 통해 이루어집니다.
- 애그리거트(Aggregate): 도메인 모델의 일관성을 유지하기 위해 관련된 객체들을 묶어 관리하는 단위입니다. 애그리거트는 하나의 루트 엔티티(aggregate root)를 가지며, 외부에서는 루트 엔티티를 통해서만 접근할 수 있습니다.
- 엔티티(Entity): 고유한 식별자를 가지며, 상태와 행동을 갖는 객체입니다. 엔티티는 도메인 모델의 핵심 구성 요소입니다.
- 밸류오브젝트(Value Object): 고유한 식별자를 가지지 않으며, 불변성을 가지는 객체입니다. 값 객체는 도메인 모델의 속성을 표현하는 데 사용됩니다.
왜 프로젝트팀은 DDD를 선택하였을까?
카카오페이는 2021년 정부의 금융규제 샌드박스제도에 소액후불결제 후불교통 서비스를 인가받았습니다. 서비스를 위한 여신시스템 구축 당시 외부업체의 솔루션으로 여신코어를 구축하여 오픈하고 운영하였으나, 장기적으로 좀 더 효율적이고 유지보수가 용이하여 카카오페이 내에서 소통이 용이한 시스템 구축을 하기 위해 24년에 이를 내재화하기로 결정하였습니다.
은행의 여신시스템은 대출의 회원/심사/승인/납부/연체/사후관리와 같은 전반적인 대출과 관련된 비즈니스를 다루고 있어 관련 도메인들을 내재화해야만 했고, 프로젝트팀은 비즈니스 영위를 위해 Backend 시스템을 설계하는 전반적인 과정을 진행하게 되었습니다.
프로젝트팀이 Backend 개발 방법으로 DDD를 선택한 가장 큰 이유는, 정책이 명확한 도메인을 견고하게 설계/구현하는 데 있습니다. 대출의 전반적인 프로세스를 다루는 여신시스템은 비즈니스 복잡성이 높기 때문에 개발자는 도메인에 대해 깊이 이해하고 있어야만 합니다. 여신전문가, 기획자, 개발자가 도메인에 대한 지식의 눈높이를 맞추고 비즈니스가 시스템 구축으로 이루어질 수 있도록 해야만 했습니다. 그래서 해당 프로젝트의 개발 방법으로 DDD를 선택하여, 도메인의 복잡성을 체계적으로 관리하고 유지보수가 용이한 설계를 할 수 있을 것이라 생각했습니다. 프로젝트를 시작하며 모두가 같이 도메인 모델, 바운디드 컨텍스트(Bounded Context), 에그리거트(Aggregate)를 정하고 도메인 설계에 활용한 유비쿼터스 언어(Ubiquitos Language)로 소통했습니다. 그 과정에서 팀원 모두가 비즈니스와 시스템 설계를 이해하고 같이 고민하려고 하였습니다. 도메인의 기능을 기반으로 Application Level을 구현하여 정책에 관련된 소스코드가 일원화되고, 각 Bounded Context가 유일한 기능을 하여 기능의 중복과 분산을 최대한 방지할 수 있었습니다.
현재 여신서비스를 운영하면서도 코드 Level의 분석 시, DDD를 이용했던 설계 내용은 쉽게 이해할 수 있고, 분석 시간을 짧게 줄이는데 도움이 되고 있습니다.
Design
프로젝트팀은 비즈니스를 코딩하기 전에 몇 가지 설계 원칙을 정하고, 아키텍처를 구축하기로 하였습니다.
Step1 - Bounded Context & Aggregate Root
시스템 구축을 위해 필요한 도메인은 어떤 것들이 있는지 먼저 분류해 보고, Aggregate Root를 정의해 보았습니다. 각 도메인에서 수행할 비즈니스를 나열하고 그것들을 효율적으로 관리하고 수행하기 위한 도메인을 설계합니다.
예를 들어, 사용한 금액에 대한 납부가 이루어진다면 그 납부에는 납부수단이 필요하고, 납부취소가 이루어질 수 있습니다. 이때 납부가 중심이 되기 때문에 Aggregate Root로 납부를 선정합니다. 이와 같이 비즈니스에 중심이 되는 Domain Model을 Aggregate Root로 보고, 그와 연관된 비즈니스들을 묶어서 Bounded Context를 만들 수 있습니다. 이렇게 설계한 내용은 코드로 옮기기 위한 UML을 그리는데 필요한 설계서가 됩니다.
저희가 프로젝트에서 정한 Rule 몇 가지는 아래와 같습니다.
- Bounded Context
- 각 Context는 도메인 모델이 적용되는 명시적인 경계가 됩니다.
- Context는 하위 도메인으로 분할하여 관리 가능한 수준으로 만듭니다.
- Bounded Context는 Gradle module(Sub project)의 단위가 됩니다.
- Aggregate Root
- Aggregate는 데이터의 일관성을 유지하기 위한 트랜젝션의 경계가 됩니다.
- Aggregate Root를 통해서만 Aggregate 내부의 Entity, Value Object에 접근하고 수정할 수 있습니다.
[여신코어(GAIA) Domain 설계서]
Step2 - Domain Model & Function
도메인 모델을 구체적으로 설계합니다. 도메인 모델에서 필수적으로 관리되어야 하는 프로퍼티들을 정의하고, 그 도메인 모델의 기능에 대해서 생각해야 합니다. 각 도메인의 기능은 타도메인에서는 수행할 수 없고 해당 도메인을 통해서만 실행되고 관리될 수 있게 분리되어 설계되어야만 합니다. 도메인의 기능을 타도메인에서 사용할 수 있다면 도메인 간의 의존성이 생기고 Bounded Context가 지켜지지 않아 분석되지 않은 사이드이펙트가 발생될 수 있습니다. 예시로 한도의 잔액을 변경하는 기능은 한도 도메인을 통해서만 사용하도록 해야 합니다. 타 도메인에서 동일한 기능을 구현하게 된다면 유효성 검증, 변경에 따른 잔액 변경이력 생성 등과 같은 기능이 누락되거나 중복될 수 있습니다.
- 도메인 모델 설계 원칙
- 도메인 모델은 나눌 수 있는 최대한 작은 단위로 나누어 설계합니다.
- 도메인에 종속된 데이터를 유일하게 관리하고 제어합니다.
- 도메인 모델은 도메인에 대한 비즈니스 규칙을 포함합니다.
아래 예시와 같이 납부 도메인 영역의 객체들은 Aggregate Root인 *Recovery Domain 객체를 통해 컨트롤되고 하위 도메인객체들이 생성되게 됩니다. 이는 이후에 Application Level에서 비즈니스 로직을 구현할 때, 도메인 객체를 이용한 기능을 조합하여 사용하는데 분석시간을 줄여줄 수 있습니다. Aggregate Root는 도메인 모델의 일관성을 유지하기 위한 트랜젝션의 경계가 되고, 하위 도메인 객체를 컨트롤하기 때문에 도메인의 비즈니스의 코드 구현을 파악하기 용이하게 해 줍니다.
[납부 도메인 모델 정의]
*Recovery(납부): 계좌의 한도를 복원한다는 의미로 납부 도메인은 Recovery라는 용어를 사용하였습니다.
Step3 - Domain을 이용한 Application Design
프로젝트팀은 application-flow를 PlantUML을 이용하여 코드로 비즈니스 흐름을 가시화하고, 함께 모여 리뷰하면서 각 개발자가 맡은 도메인의 기능에 대해 논의하면서 flow를 설계하였습니다.
승인이라는 기능을 기획에서 정의한 정책에 맞게 수행하기 위해, 각 도메인이 제공하는 필요한 기능들 사이의 호출 흐름을 PlantUML로 가시화하고 함께 모여 리뷰하며 설계를 하였습니다. 구현과정에서 고려하지 못했던 기능을 추가하는 케이스도 있었지만, 이러한 설계과정은 코드를 작성하는데 고민하는 시간을 단축시켜 주고 불필요한 커뮤니케이션 비용을 줄이는데 큰 효과를 볼 수 있었습니다. 비즈니스 로직을 구현하기 위한 기능들은 각 도메인에 구현되어 있었고 해당 기능들은 단위테스트로 검증되어 있었기 때문에 기능을 구현하는데 소요되는 시간이 줄어들 수 있었습니다.
[프로젝트에서 사용한 승인 Transaction Application flow]
Implementation
Package & Class Design
Application은 Business Logic을 구현하기 위해 도메인 모듈을 사용합니다.
(아래 그림처럼) 도메인 모듈은 Application이 직접 JPA Entity를 접근하는 것을 막습니다. 대신 Application은 DomainEntity를 통해 명령(command)을 전달합니다.
이후 DomainRepository가 DomainEntity를 JPAEntity로 변환하여 DB에 반영합니다. 이는 도메인 설계가 DB Table 구조에 종속되지 않을 수 있으며, 도메인의 기능의 확장을 유연하게 할 수 있습니다.
각 도메인 객체에 대한 기능검증은 단위테스트로 검증하고 안정적인 기능을 보장하기 위해 노력해야 합니다. 프로젝트팀은 Application Level을 구현하면서 도메인을 구현하는 것이 아닌, 각자가 맡은 도메인의 기능을 먼저 안정적으로 구현하는데 집중하였습니다.
[Application Layer와 Domain Layer의 Entity 제어 구조]
가입이라는 기능을 가진 Application의 예시를 들어보겠습니다.
여신시스템에 가입이 이루어지기 위해서는 회원, 계좌, 한도 등을 신규로 생성해야만 합니다.
이를 구현하기 위해 Application은 아래와 같이 3개의 도메인 모듈이 필요함으로 각 도메인 package 의존성을 설정하여 사용합니다.
각 도메인은 Command로 받을 수 있는 기능을 제공하고 Application은 제공된 기능을 사용하는 방식입니다. 해당 도메인의 유효성 검사, Business Logic 또한 도메인 내에서 제어됩니다.
[Application의 도메인 모듈 활용과 JPA Entity 캡슐화]
Codes
예시 코드로 살펴보겠습니다.
-
package 구조
ㄴ gaia-domain ㄴ gaia-user-domain ㄴ gaia-account-domain ㄴ gaia-credit-limit-domain ㄴ gaia-screening-domain ㄴ gaia-core-app ㄴ build.gradle.kts // 1. 의존성 설정 설명
-
의존성 설정
gaia-core-app은 필요한 domain을 의존성 설정하여 사용합니다. 각 도메인 모듈은 서로 연관관계를 맺지 않습니다.// gaia-core-app build.gradle.kts implementation(project(":gaia-domain:gaia-user-domain")) implementation(project(":gaia-domain:gaia-screening-domain")) implementation(project(":gaia-domain:gaia-credit-limit-domain")) implementation(project(":gaia-domain:gaia-account-domain"))
-
가입 Application에서 계좌 생성
도메인 모듈에 DomainEntity와 DomainRepository를 Import하여 사용하고 아래 코드와 같이 사용합니다.import com.kakaopay.gaia.account.domain.entity.Account import com.kakaopay.gaia.account.domain.repository.AccountDomainRepository @Service class RegisterService( private val userDomainRepository: UserDomainRepository, // 회원 private val accountDomainRepository: AccountDomainRepository, // 계좌 private val creditLimitDomainRepository: CreditLimitDomainRepository, // 한도 ) { // 가입 Application @Transactional fun register(payAccountId: Long, request: RegisterRequestV1): RegisterResponseV1 { ... // 계좌 생성 val account = createAccount( userExternalId = user.externalId, productExternalId = product.externalId, creditLimitTotalAmount = creditLimitTotalAmount, recoveryDay = recoveryDay, issuedAt = registeredAt ) // 회원 생성 ... // 한도 생성 ... } private fun createAccount( userExternalId: String, productExternalId: String, creditLimitTotalAmount: BigDecimal, recoveryDay: Int, issuedAt: LocalDateTime, ): Account = Account.CreateCommand( // 가입 Command를 이용한 계좌 생성 userExternalId = userExternalId, productExternalId = productExternalId, creditLimitTotalAmount = creditLimitTotalAmount, recoveryDay = recoveryDay, issuedAt = issuedAt, ) .let(Account::create) .let(accountDomainRepository::save) .getOrThrow() }
-
계좌 DomainEntity 생성 코드
계좌 도메인은 최초 생성 명령을 받았을 때, 내부 Property들의 Default 값들을 미리 정의하고 있어 정책을 도메인에 구현해 놓을 수 있습니다. 도메인을 사용하는 Application에서는 생성에 필요한 CreateCommand의 parameter만 입력하여 계좌에서 설정한 Rule에 따라 계좌 도메인을 만들게 됩니다. 이외 다른 Command들이 실행될 때의 정책도 모두 도메인 객체에 의해 제어되도록 구현합니다.class Account internal constructor( ... ) { companion object { /** 계좌 생성 */ fun create(command: CreateCommand): Account = Account( externalId = generateExternalId(PREFIX_ACCOUNT), userExternalId = command.userExternalId, productExternalId = command.productExternalId, issuedAt = command.issuedAt, statusCode = StatusCode.NORMAL, lockYn = YesNoType.N, creditLimit = AccountCreditLimit( totalAmount = command.creditLimitTotalAmount, usedAmount = BigDecimal.ZERO, availableAmount = command.creditLimitTotalAmount, ), recoveryDay = command.recoveryDay, lastInterestPaymentDate = null, lastTransactedDate = command.issuedAt.toLocalDate(), eventOfDefault = EventOfDefault.createNone(), withdrewAt = null, writeOffs = WriteOffs.init(), assetClassification = AssetClassification.NORMAL ) } }
-
도메인 모듈 내 DomainRepository를 이용해 DomainEntity
<-->
JpaEntity 변환 후 저장 & 조회class AccountDomainRepositoryImpl( private val accountJpaRepository: AccountDomainJpaRepository ): AccountDomainRepository { @Transactional override fun save(domainEntity: Account): Result<Account> { return runCatching { run { accountJpaRepository.findByIdOrNull(domainEntity.externalId) ?.apply(domainEntity) ?: AccountEntity.from(domainEntity) }.let(accountJpaRepository::save).toDomainEntity(withWriteOff = true) } } @Transactional(readOnly = true) override fun find(externalId: String, withWriteOff: Boolean): Result<Account> { return runCatching { accountJpaRepository.findByIdOrNull(externalId) ?.toDomainEntity(withWriteOff) ?: throwAccountNotFoundException(detailMessage = "externalId: $externalId") } } }
-
Domain Entity Code Conventions
-
생성자 외부 노출을 방어하기 위해 Kotlin class의 생성자에 private 접근 제어자를 추가하여, factory method만을 이용하여 class를 생성할 수 있도록 합니다.
-
변경 가능한 properties는 var로 선언하고
internal set
을 이용하여 모듈(aggregate) 내부에서만 변경가능하도록 합니다. -
public Command class를 이용하여 public member method를 사용할 수 있도록 합니다.
-
ex)
import java.time.LocalDate import java.time.LocalDateTime class Account internal constructor( val externalId: String, val userExternalId: String, val productExternalId: String, statusCode: StatusCode, lockYn: YesNoType = YesNoType.N, lastInterestPaymentDate: LocalDate?, lastTransactedDate: LocalDate, withdrewAt: LocalDateTime?, recoveryDay: Int, ) { var statusCode: StatusCode = statusCode internal set var lockYn: YesNoType = lockYn internal set var lastInterestPaymentDate: LocalDate? = lastInterestPaymentDate internal set var lastTransactedDate: LocalDate = lastTransactedDate internal set ... data class ConfirmOverdueCommand( val confirmedDate: LocalDate, val scheduledEodDate: LocalDate, ) data class ReleaseOverdueCommand(val releasedDate: LocalDate) ... }
-
-
JPA Entity & Repository Code Conventions
- JPA Entity와 Repository는 kotlin internal class로 선언하여 DomainEntity를 이용해서만 접근할 수 있도록 하고, 도메인에 대한 데이터 처리를 Domain Class로 모두 집중합니다.
- DomainEntity → JpaEntity, JPAEntity → DomainEntity로 변환하는 로직에 대한 책임을 집니다.
- ex)
internal interface AccountJpaRepository : JpaRepository<AccountEntity, String> @Entity @Table(name = "account") internal data class AccountEntity( @Id override var externalId: String, var userExternalId: String, var productExternalId: String, var issuedAt: LocalDateTime, var statusCode: String, var lockYn: YesNoType, var creditLimitTotalAmount: BigDecimal, var creditLimitUsedAmount: BigDecimal, ..... @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, mappedBy = "accountEntity") var writeOffEntities: MutableList<WriteOffEntity> = mutableListOf(), ) : BaseEntity() { fun toDomainEntity(withWriteOff: Boolean = false): Account = Account( externalId = this.externalId, .. lockYn = lockYn, creditLimit = AccountCreditLimit( totalAmount = this.creditLimitTotalAmount, usedAmount = this.creditLimitUsedAmount, availableAmount = this.creditLimitAvailableAmount, ), ... writeOffs = if (withWriteOff) { WriteOffs.from( list = writeOffEntities.map { it.toDomainEntity() }, writeOffYn = writeOffYn ) } else { WriteOffs.from(writeOffYn) } ) fun apply(domainEntity: Account): AccountEntity { return apply { this.statusCode = domainEntity.statusCode.name this.lockYn = domainEntity.lockYn this.creditLimitTotalAmount = domainEntity.creditLimit.totalAmount ... this.recoveryDay = domainEntity.recoveryDay this.assetClassificationCode = domainEntity.assetClassification?.name domainEntity.writeOffs.forEach { item -> this.writeOffEntities.addIfNotExistOrModify( key = item.externalId, value = WriteOffEntity.of(accountEntity = this, domainEntity = item), ) { it.apply(item) } } } } companion object { fun from(domainModel: Account): AccountEntity = with(domainModel) { AccountEntity( externalId = this.externalId, userExternalId = this.userExternalId, productExternalId = this.productExternalId, ... withdrewAt = this.withdrewAt, assetClassificationCode = this.assetClassification?.name, ).apply { writeOffEntities = domainModel.writeOffs.list.map { WriteOffEntity.of(accountEntity = this, domainEntity = it) }.toMutableList() } } } }
공통 기능은?
도메인모듈을 사용하는 Application은 Admin, 내/외부 API, 배치잡 등이 될 수 있습니다. 프로젝트에 적용한 설계 방식은 도메인모듈에는 도메인 기능만 담고, 도메인을 조합하는 Application 기능은 넣지 않기 때문에 각각 Application들에서 기능을 구현해야 합니다. 그러나 여신의 중요 업무인 “납부”라는 기능은 API를 통한 유저의 즉시납부, Batch를 통한 자동납부가 발생할 수 있습니다. DomainEntity를 사용하는 간단한 기능은 각각 구현해도 이슈가 없지만, 납부와 같이 여러 도메인 모듈에 영향을 주는 기능은 각각 구현했을 경우 유지보수가 힘들어집니다.
프로젝트에서는 Biz-component라는 package를 만들어 공통으로 사용해야 하는 주요 기능들에 대해서는 이를 활용하는 것을 규칙으로 하여, 주요 기능의 중복 구현을 방어하고 집중할 수 있었습니다. Biz-component는 납부라는 기능이 동작했을 때 필수적으로 동작해야 하는 기능을 묶어 개발하였고, API, 배치 등에서 납부를 수행했을 때 이 Component를 사용하도록 규칙을 정했습니다. API, 배치가 기능을 사용하는데 서로 다른 비즈니스가 필요할 수 있다면 그런 것은 Biz-component에 담지 않고, 두 모듈 모두 공통적으로 사용할 수 있는 핵심 비즈니스 로직만 담기 위해 노력하였습니다.
[App, BizComponent, Domain의 관계]
아쉬웠던 JPA Entity의 중복 관리
Spring Batch를 사용하여 개발할 때, Domain모듈에서 정의된 비즈니스 로직을 사용한 배치도 존재하지만, 데이터 처리가 필요한 배치의 경우에는 JpaRepository/Entity에 접근하여 작업을 하는 것이 대량 작업에서 유리할 수 있고 코드도 간단하게 작성할 수 있습니다.
통계, 데이터 파기 등과 같은 배치잡은 도메인 모듈을 사용하지 않고, JPA Entity를 직접 사용하여 작업하는 것이 더 효율적이었습니다. 도메인 모듈 내의 JPA Entity는 모듈 내에서만 사용할 수 있도록 internal
로 선언되어 있었습니다. 이러한 이유로 배치를 위한 또 다른 JPA Entity를 추가로 만들어야 했습니다.
이는 동일한 DB Table을 사용하는 다른 JPA Entity를 중복으로 만들게 되었고 도메인 변경에서 개발자가 잊지 않고 챙겨하는 포인트가 늘어나게 되었습니다.
JPA Entity를 public
으로 선언하고 프로젝트 내부 규칙으로 사용하지 않도록 할 수도 있었겠지만, 컴파일 에러가 발생하지 않는다면 히스토리를 모르는 개발자는 규칙을 이해하지 못하고 기존 설계 규칙이 위반되는 가능성이 있었을 것입니다.
현재 방법이 최선은 아니었겠지만 보다 나은 방법에 대해 고민해보려고 합니다.
// 중복으로 관리되는 Batch 전용 Entity
@Entity
@Table(name = "credit_limit")
class BatchCreditLimitEntity(
@Id
val creditLimitId: Long,
var externalId: String,
...
var availableAmount: BigDecimal,
val updatedAt: LocalDateTime,
)
마치며
여신 도메인의 기존 시스템을 안정적으로 내재화하는 프로젝트를 진행하며 도메인 주도 설계(DDD)를 도입했습니다. 이는 단순히 기능을 이전하는 것을 넘어, 변화에 유연하고 지속 가능한 시스템을 구축하기 위한 전략적인 선택이었습니다.
프로젝트 초기에는 팀원 전체가 DDD의 개념과 원칙에 익숙해지고 공통의 이해(Shared Understanding)를 구축하는데 시간과 노력이 필요했습니다. 하지만 이 과정은 견고한 도메인 모델(Domain Model)을 정립하기 위한 필수적인 투자였습니다. 점차 팀원들의 DDD 이해도가 높아지면서 설계 및 구현 과정에서 시너지가 발생했고, 개발 생산성은 눈에 띄게 향상되는 것을 볼 수 있었습니다.
특히, DDD의 핵심 가치인 ‘공통 언어(Ubiquitous Language)‘의 정립은 프로젝트 성공의 중요한 키가 되었습니다. 개발자와 기획자, 여신 전문가 모두가 기술 용어가 아닌, 모두가 이해하는 도메인 중심의 언어로 소통하면서 요구사항의 모호함이 줄어들고, 핵심 비즈니스 로직에 대한 깊이 있는 논의가 가능해졌습니다. 이는 요즘에 적용했었던 MVP(Minimum Viable Product)를 빠르게 개발하며 서비스 Layer 기능 중심으로 확장해 나가던 방식과는 다른 접근이었습니다. 이미 핵심 기능이 정의된 이번 프로젝트에서는 DDD를 통해 설계의 완성도를 높이고, 복잡한 도메인 지식을 코드에 명확하게 반영하는 것이 무엇보다 중요했고, 이는 결과적으로 시스템의 안정성과 유지보수성을 향상할 수 있었습니다.
팀원들과 치열하게 토론하며 도메인 모델을 구체화하고, 함께 정한 설계 원칙을 준수하며 협업했던 경험은 기술적인 성장뿐만 아니라, 복잡한 문제를 해결하는 효과적인 소통 방식을 배울 수 있는 소중한 기회였습니다. DDD를 통해 얻은 견고한 아키텍처와 명확한 도메인 모델은 향후 시스템 확장과 변화에 유연하게 대응할 수 있는 기반이 될 수 있을 것입니다. 이번 경험은 더 나은 소프트웨어를 만들기 위한 고민과 협업의 가치를 다시 한번 깨닫게 해주었으며, 앞으로 구축 프로젝트를 할 때에도 여러 고민을 해결하고 구현하는데 많은 도움이 될 것 같습니다.
참고자료
- Domain-Driven Design Tackling Complexity in the Heart of Software(evans, Eric)