나비어리 시스템 개발기 #3: 백엔드 - 도메인 모델 설계
들어가며
기술스택 에 대해 다뤘으니, 이번에는 나비어리 시스템에서 내가 도메인 설계를 어떻게 했는지에 대해 이야기해보려고 한다.
초기 프로젝트를 설계할 때 가장 고민했떤 부분은 항상 설계인 것 같다. 설계를 잘못하게 되면 다 만들어놓고 다시 만들게 되는 경우가 종종 생기고, 그때마다 항상 후회하기 때문이다.
앵무새 브리딩 시스템은 일단 래퍼런스 삼을만한 툴이나 오픈소스가 매우 한정적이다. 외국에 있는 툴들도 있긴해서 보긴 했으나 뭔가… 많이 아쉬운 부분이 많았다. 기능적으로도 그렇고 UI/UX적으로도..
그리고 여러가지 기능들을 생각해봤을 때 브리딩 로직 자체가 단순히 데이터를 기록하는 것이 아니라, 생물학적 제약과 윤리적 고려사항이 함께 작용하는 복잡한 현실세계의 문제를 시스템으로 구현하는 것이었다.
예를 들어
- 근친교배 방지를 위한 계산
- 알 -> 유조 -> 성조로 이어지는 라이프사이클 관리
- 또 각 스텝에 맞는 기록들과 검증
- 쌍 형성 시 다양한 검증 규칙
이런 복잡한 로직들을 잘 구현하기 위해 평소 좋아하던 DDD(Domain Driven Design)의 설계방식을 적용해서 설계해보고자 했다.
1. Bounded Context 설계
나비어리 시스템은 여러개의 컨텍스트로 나뉘며, 그 중 일부만 소개하면 다음과 같다.
1.1 Breeding Context
번식과 관련된 핵심 도메인. 쌍 관리, 산롼, 부화 등을 담당한다.
Aggregate Root
- Pair
- 번식 쌍 관리, 근친계수 계산 및 결과를 저장하여 관리한다.
- Clutch
- 산란 주기 관리, 알 정보를 추적한다.
- Parrot
- 앵무새 개체 정보 관리와 라이프사이클을 관리한다.
export class Clutch extends Aggregate<Clutch> {
id: string;
pairId: string;
sequence: number;
totalEggs: number;
fertilizedEggs: number;
hatchedChicks: number;
layEgg() {
this.totalEggs += 1;
}
markEggFertilized() {
this.fertilizedEggs += 1;
}
recordHatch() {
this.hatchedChicks += 1;
}
...
}
//
export class Egg extends Aggregate<Egg> {
clutchId: string
...
}
언뜻보면 Egg가 Clutch에 속해있기 때문에 Clutch가 Aggregate Root이고 Egg를 하위 Aggregate로 두면 되지 않을까? 라는 생각을 할 수 있다.
하지만 Egg는 Clutch와는 다른 라이프사이클을 가지고 있고 알 검란, 부화 등 개별로 관리해야하는 경우가 빈번하다.
또한, 하위 Aggregate의 경우 Aggregate Root를 통해 수정해야되는데 Clutch를 통해 Egg를 수정하는 것이 부자연스럽다고 생각했다.
그래서 Egg를 따로 Aggregate Root로 두어 생성 시, 부화 시 도메인 이벤트를 발행하여 clutch를 업데이트 하는 방식으로 설계했다.
1.2 Health Context (건광, 기록 관리)
개체들의 건강과 기록을 관리하기 위한 Context
Aggregate Root
- HealthRecord
- EggCandlingRecord
- 검란 기록
- ChickWeightREcord
- 유조 몸무게 측정 정보
- ChickFeedingRecord
- 유조 핸드 포뮬라 기록
- ParrotFeedingRecord
- 성조 먹이 급여 기록
- EggCandlingRecord
1.3 Facility Context (시설 관리)
시설 관리와 관련된 컨텍스트로, 새장, 축사, 장비 등을 관리한다.
Aggregate Root
- Cage
- 새장 정보 관리
- Equipment
- 기타 장비 정보 관리
1.4 Schedule Context (일정 관리)
- Schedule
- 일정의 정의 및 관리
- Task
- 실제 수행할 작업
2. 도메인 구현
2.1 Parrot
앵무새는 이 나비어리 시스템에서 가장 핵심적인 모델이다. 앵무새는 크게 두 가지 경로로 시스템에 등록된다.
- 외부 유입 - 해외, 또는 국내의 다른 브리더에게 구매한 종조
- 자체 브리딩 - 나비어리에서 직접 번식시킨 개체들의
이 두 경로는 초기 상태가 다르고 데이터 역시 다르다.
export class Parrot extends Aggregate<Parrot> {
id: string; // 발목링 넘버링
type: 'Parrot' | 'Chick';
status: 'Feeding' | 'breerder' | ...
fatherId?: string;
motherId?: string;
inbreedingCoefficient: number; // 근친계수
// 외부 종조 입식정보
importedAt?: Date;
importedFrom?: string;
importPrice?: Money;
importFileIds?: string[]
hatchedAt?: Date;
eggId?: string;
}
외부에서 들여온 종조의 경우 입식 관련된 정보들이 있어야한다. 이를테면 서류, 가격, 구매처 등 출처를 알기위한 정보들이 필요하다. 그리고 위닝이 끝난 성조들을 데려오기 때문에 초기 상태 역시 브리딩 개체와는 다르게 type:‘Parrot’, status: ‘Breeder’으로 만들어야한다.
그리고 발목링 넘버링 역시 다르기 때문에 id역시 브리딩 개체와는 다른 규칙으로 작성된다. 이러한 문제들을 해결하기위해 처음에는 CTI(class table inheritance) 를 고려했으나 초기 모델 상 CTI를 도입하는것도 부담이고 그렇게까지 나눌 정도인가? 라는 생각이 들어 일단 단일테이블 - 단일클래스 구성으로 진행했다.
다만 두 개체의 초기상태나 이런것들이 다르기 때문에 생성 메서드를 분리했다
static fromImported({
importedAt,
importedFrom,
importPrice,
importFileIds,
importMemo,
...
}: {
importedAt: Date;
importedFrom: string;
importFileIds: string[];
importPrice: Value<Money>;
importMemo: string;
...
}) {
if (!importedAt) {
throw badRequest('importedAt is required when register imported parrot.', {
errorMessage: '수입개체는 수입된 일시를 입력해야 합니다.',
});
}
// ... 검증들
const id = Parrot.generateId({
isImported: true,
...
});
return new Parrot({
id,
type: 'Parrot',
saleStatus: 'nfs',
status: 'breeder',
importedAt,
importedFrom,
importPrice: new Money(importPrice),
importFileIds,
importMemo,
...
});
}
static fromHatched({
eggId,
fatherId,
motherId,
inbreedingCoefficient,
hatchedAt,
...
}: {
eggId: string;
fatherId: string;
motherId: string;
inbreedingCoefficient: number;
hatchedAt: Date;
...
}) {
const id = Parrot.generateId({
isImported: false,
...
});
return new Parrot({
id,
type: 'Chick',
saleStatus: 'available',
eggId,
fatherId,
motherId,
inbreedingCoefficient,
...
});
}
수입 개체는 성조이기 때문에 바로 종조(‘Parrot’, ‘breeder’) 상태로 시작하고 브리딩을 위해 입식한 개체이기 때문에 판매 불가(‘nfs’)로 시작한다.
그에 비해 자체 부화한 개체는 알에서 부화하면 등록하기 때문에 핸드 포뮬러를 급이중인 유조(‘Chick’, ‘feeding’) 상태로 시작한다. 또한, 부모로부터 근친계수를 계산하여 저장한다.
2.2 Pair
페어를 생성할 때는 반드시 근친계수를 계산하고 검증해야 한다. 근친계수가 일정 수준 이상이면 근친에 의한 여러가지 문제가 생길 수 있기 때문에 항상 관리해주어야한다.
근친계수 계산의 경우 도메인 모델에서는 제어하기 힘들지만 비즈니스 로직이 돌아가는 곳이기 때문에 도메인 서비스(InbreedingService)로 만들어서 사용했다.
static async of(
{
maleId,
femaleId,
...
}: {
maleId: string;
femaleId: string;
...
},
inbreedingService: InbreedingService,
) {
const id = Pair.generateId({ pairedAt, sequenceNumber });
const { offspringCoefficient, cumulativeCoefficient } = await inbreedingService.calculateInbreedingCoefficient(
maleId,
femaleId,
);
if (offspringCoefficient >= 10_00) {
throw badRequest(`Offspring coefficient(${(offspringCoefficient / 100).toFixed(2)}%) is too high.`, {
errorMessage: `자손 근친계수(${(offspringCoefficient / 100).toFixed(
2,
)}%)가 너무 높습니다. 자손 근친계수는 10% 이하여야 합니다.`,
});
}
if (cumulativeCoefficient >= 20_00) {
throw badRequest(`Cumulative coefficient(${(cumulativeCoefficient / 100).toFixed(2)}%) is too high.`, {
errorMessage: `누적 근친계수(${(cumulativeCoefficient / 100).toFixed(
2,
)}%)가 너무 높습니다. 5대 누적근친계수는 20% 이하여야 합니다.`,
});
}
return new Pair({
id,
maleId,
femaleId,
cageId,
offspringCoefficient,
note,
pairedAt,
});
}
Inbreeding Service의 경우 Writght의 공식을 사용했다.
3. 설계 원칙, 트레이드 오프
DDD는 설계 이론이다. 실제로 어떻게 코드를 짜고 어떤방식으로 구현해야되는지에 대한 설명은 없다. 그래서 나는 어느정도는 DDD의 설계방법을 따르고 어느정도는 실용성을 위해 타협했다.
일단 거의 모든 비즈니스 로직을 도메인 모델에 구현하고 도메인 모델에 구현하기 힘든 것들은 도메인 서비스에 구현했다. 이를 통해 도메인 모델은 Rich Domain Model이 되어 해당 도메인의 비즈니스 로직을 보고자 한다면 모델을 보면 된다.
3.1 UbiQuitous Language
메서드 명을 최대한 실무에서 사용하는 도메인 용어로 표현하고자 했다.
// 올바른 예시
parrot.wean(); // 습식인 핸드 포뮬러를 떼는 것이지만 실제로 wean이라는 표현을 사용한다.
egg.hatch();
cage.occupy();
// 잘못된 예시
parrot.status = "weaned";
parrot.eatSolidFood();
이런식으로 도메인 용어를 사용하여 도메인 전문가와 소통할 때 컨텍스트 전환을 하지 않아도 되도록 했다.
3.2 도메인 이벤트
알이 부화하면 자동으로 유조가 생성되어야 한다. 물론 수동으로 등록할수도 있고 Egg 부화 버튼 클릭 시 Parrot을 직접 생성하게도 할 수 있다.
하지만 첫번째 경우에는 사람이 너무 번거롭고 아날로그식이다. 현재 많은 브리더들이 이런식으로 하고 있다고 생각한다. 두번째의 경우 둘이 너무 강결합이 되어 좋지 않다.
알이 부화하면 사람이 부화 버튼을 클릭한다. 이때 EggHatchedEvent를 발행한다.
export class Egg extends Aggragte<Egg> {
hatch({ hatchedAt }: { hatchedAt: Date }){
...
this.hatchedAt = hatchedAt;
this.publishEvent( // publishEvent는 Aggregate에 내장된 메서드이다.
new EggHatchedEvent(
this.id,
this.hatchedAt
...
)
)
}
}
그리고 해당 이벤트를 구독하고 있는 이벤트 핸들러에서 parrot을 처리하면 된다.
export class ParrotService extends DddService {
...
@EventHandler(EggHatchedEvent,{
eventId: EggHatchedEvent.eventId,
description: '알이 부화하면 유조를 등록한다.'
})
@Transactional()
async onEggHatchedEvent(evnet: EggHatchedEvent){
const { eggId, fatherId, motherId, inbreedingCoefficient, hatchedAt } = event;
const parrot = Parrot.fromHateched({
eggId,
fatherId,
motherId,
inbreedingCoefficient,
hatchedAt,
...
})
await this.parrotRepository.save([parrot]);
}
}
이런식으로 하면 장점과 단점이 있다.
- 장점
- 결합이 느슨해진다.
- Egg모델과 Parrot 모델이 서로 알지 못하더라도 이벤트를 통해 통신할 수 있다.
- 확장성이 좋다.
- 만약 나중에 유조를 등록하는 로직이 추가된다면 이벤트를 구독하는 핸들러만 추가하면 된다.
- 책임 분리
- 알은 부화 사실만 기록하면 되고
- ParrotService는 유조 등록 로직을 담당한다.
- 단점
- 트랜잭션이 분리된다.
- EggService에서 발행된 이벤트를 ParrotService에서 처리하기 때문에 트랜잭션이 분리된다.
- 물론 EDA를 택하면 당연한 논리이다. 이런 경우 보상 트랜잭션이라던가 그런 정책을 취할 수 있다.
- flow를 한눈에 알기 쉽지않다.
- 코드가 명시적으로 이어지는게 아닌 느슨하게 결합되어 있기 때문에 한눈에 알기 쉽지 않다.
- 멱등성을 신경써야한다.
- 이벤트 핸들러는 여러번 실행해도 같은 결과가 나오도록 멱등성을 지켜야한다. 두번 실행됐을수도, 실행되지 않을 수도 있는 환경이기 때문에 항상 재실행과 같은 상황을 고려해야한다.
물론 현재 이것말고도 단점들이 있다. 보상트랜잭션의 경우 현재는 고려하지 않았다. 일단 건수가 그렇게 많지 않기도 하고 이벤트 자체는 디비에 전부 기록중이라 나중에 재실행이 가능하다.
또, 도메인 이벤트가 현재는 카프카와 같은 시스템이 아닌 RxJs를 사용하여 메모리상에서 돌아가기 때문에 서버가 중간에 꺼지거나 하면 이벤트 핸들러가 동작하지 않을수도 있다.
이런 부분을 대처하기 위해 데이터베이스에 먼저 저장하고 저장이 되었다면 언제든지 재실행이 가능하도록 구현했다.
3.3 트레이드 오프
DDD는 사실 초기 프로젝트에서 사용하기에 비효율적일 수 있다. 일단, 도메인 전문가와 소프트웨어 개발자가 한명이 아닌 다른 사람이라면 컨텍스트를 맞추는 작업부터 해야한다.
나의 경우 내 자신이 도메인 전문가이자 소프트웨어 개발자였기 때문에 이런 부분에서는 조금 유리했다.
또한, 초기 개발에 있어 폴더 구조나 아키텍쳐 상에서 여러가지 구조적인 설계를 해야하기 때문에 시간이 오래걸릴 수 있다. 하지만 이미 개인 프로젝트나 전 회사에서 많이 써왔던 방식들에서 조금만 변형하면 되는 부분들이 많았기 때문에 크게 느려졌다고 생각이 들지 않는다.
물론 나 역시 DB와 ORM을 바꾼다던가, 모델이 대규모로 통폐합 된다던가 (초기에는 Parrot과 Chick을 분리했다가 통합했다.) 하는 일들이 있었다. 이런 과정에서 수정사항도 굉장히 많아져서 초기 설계가 완벽하지 않았다는 생각이 들기도 한다.
DDD에서는 도메인 모델의 순수성을 지키는 것을 선호하지만 나는 실용성을 위해 타협했다.
@Entity()
class Parrot extneds Aggregate<Parrot> {
@PrimaryKey()
id: string;
}
mikroORM에서는 이런식으로 클래스 위에 데코레이터를 사용하여 엔티티를 정의한다. 이런 방식은 도메인 레이어에서 도메인과 상관없는 ORM을 참조하게되며 순수성이 깨지기도 한다.
물론 이런식으로 해도 엔티티 클래스와 도메인 클래스를 아예 나눠도 좋다. 다만, 그럴 경우 엔티티/클래스 매퍼가 필요하며 코드량도 늘어나고 반복작업이 늘어난다.
이러한 것들을 고려해서 나는 그냥 도메인 클래스 == 엔티티 클래스로 사용하기로 했다.
마무리
기존에 세상에 없던 브리딩 시스템, 복잡한 현실세계의 문제를 해결하기 위해 나는 DDD를 선택했다. 정리해보자면 아래의 가치를 핵심으로 지켜가며 사용했던 것 같다.
1. 비즈니스 로직을 코드로 명확히 표현
근친계수 계산, 라이프사이클 관리 같은 현실의 복잡한 로직을 도메인 모델과 도메인 서비스에 구현했다. 덕분에 비즈니스 규칙이 코드에 명확히 드러나고, 나중에 봐도 이해하기 쉽도록 현실세계의 언어를 사용한다.
2. DDD의 여러가지 방법을 선택적으로 사용
팩토리 메서드, 도메인 이벤트 같은 패턴은 실제로 문제를 해결하는 데 도움이 되었다. 하지만 순수한 DDD를 따르기보다는 실용성을 우선했다. ORM 데코레이터를 직접 사용하는 것과 같은 타협을 했다.
3. DDD는 개발방법, 패턴이 아닌 설계
초기에는 Parrot과 Chick을 분리했다가 통합하기도 하고 (이건 완벽히 도메인 모델을 코드적으로 풀어내기 위해 편한 방법을 찾다가 생긴 문제였다.), PostgreSQL에서 MySQL로 바꾸기도 했다.
설계가 완벽하지 않더라도 중요한 비즈니스 로직이 모델에 잘 담겨있으면 비즈니스 로직이 담아져 있는 곳들은 거의 수정을 하지 않았다. (도메인 모델 역시 ORM관련 코드 외에는 전혀 손대지 않았다.)
DDD는 은탄환이 아니다. 하지만 복잡한 도메인을 다룰 때 생각을 정리하고 코드로 표현하는 데 좋은 가이드가 되었다. 특히 혼자 개발하면서 도메인 전문가이자 개발자였기 때문에, 내가 아는 브리딩 지식을 코드로 옮기는 과정이 자연스러웠다. 이건 평소에 DDD를 좋아하고 많이 설계해본 경험이 있어서 가능했을 수도 있다.
결국 중요한 건 도메인을 이해하고, 그걸 코드로 잘 표현하는 것이다. 이는 내가 개발자가 된 초기부터 가장 중요하게 여기는 가치였다. 최근들어 AI시대에 들어와 더욱 중요해진 것 같다고 느낀다. 여러분들도 여러분들이 하는 개발에서 자신이 하는 개발이 무엇을 해결하기 위한 것인지 근본을 뚫어보는 눈을 가지길 바란다 :)