도메인이란 무엇인가?
도메인(Domain)은 소프트웨어가 해결하려는 특정 분야의 문제 영역을 의미한다.
게임 개발에서는 전투, 인벤토리, 퀘스트, 성장 시스템 등이 도메인에 해당한다. 온라인 쇼핑몰에서는 상품, 주문, 결제, 배송 등이 주요 도메인이 된다.
소프트웨어를 개발할 때 중요한 것은 단순히 기능을 구현하는 것이 아니라, 해당 분야의 개념과 규칙을 정확하게 모델링하는 것이다.
예를 들어 게임의 인벤토리 시스템을 구현한다고 가정하자. 단순히 아이템을 배열에 저장하는 것만으로는 충분하지 않다. 아이템의 최대 보유 수량, 중첩 가능 여부, 장착 조건, 사용 규칙 등 게임이 지켜야 할 규칙도 함께 처리해야 한다.
이러한 문제 영역의 개념과 규칙을 코드에 반영하는 것이 도메인 모델링의 출발점이다.
도메인 주도 설계(DDD)란 무엇인가?
DDD(Domain-Driven Design)는 도메인을 중심으로 소프트웨어를 설계하고 개발하는 접근 방식이다. 한국어로는 도메인 주도 설계라고 한다.
DDD는 에릭 에반스(Eric Evans)가 2003년에 출간한 《Domain-Driven Design: Tackling Complexity in the Heart of Software》를 통해 널리 알려졌다.
DDD의 핵심은 기술적인 구조보다 소프트웨어가 해결해야 하는 문제와 도메인의 규칙을 중심에 두는 것이다.
예를 들어 결제 시스템을 구현한다면 단순히 결제 금액을 저장하는 것보다 다음과 같은 규칙을 먼저 정의하는 것이 중요하다.
- 결제 금액은 0보다 커야 한다.
- 이미 취소된 결제는 다시 취소할 수 없다.
- 결제가 완료되기 전에는 주문을 결제 완료 상태로 변경할 수 없다.
- 결제 취소와 환불 상태는 일관되게 관리해야 한다.
이러한 규칙은 단순한 데이터 저장의 문제가 아니다. 소프트웨어가 반드시 지켜야 하는 도메인의 규칙이다.
DDD는 이러한 규칙이 여러 서비스나 데이터베이스 계층에 무질서하게 흩어지지 않도록 도메인 모델을 중심으로 설계하는 데 도움을 준다.
도메인 모델이란 무엇인가?
도메인 모델(Domain Model)은 특정 문제 영역의 개념과 규칙을 소프트웨어로 표현한 모델이다.
단순한 데이터 구조와 달리, 도메인 모델은 데이터뿐 아니라 해당 데이터가 지켜야 하는 규칙과 수행할 수 있는 동작도 포함할 수 있다.
예를 들어 게임 캐릭터의 체력 시스템을 살펴보자.
public class Character
{
public int Health;
public int MaxHealth;
}
이 구조는 체력 데이터를 저장하지만, 체력이 음수가 되면 안 된다는 규칙이나 피해를 처리하는 방법을 자체적으로 보장하지 않는다.
도메인 규칙을 모델에 포함하면 다음과 같이 설계할 수 있다.
public class Health
{
public int Current { get; private set; }
public int Max { get; }
public Health(int max)
{
if (max <= 0)
throw new ArgumentOutOfRangeException(nameof(max));
Max = max;
Current = max;
}
public void TakeDamage(int damage)
{
if (damage < 0)
throw new ArgumentOutOfRangeException(nameof(damage));
Current = Math.Max(0, Current - damage);
}
public void Heal(int amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Current = Math.Min(Max, Current + amount);
}
}
이 모델은 체력의 현재 값과 최대 값뿐 아니라 피해와 회복에 관한 규칙도 관리한다.
외부 코드가 Current 값을 임의로 변경할 수 없으므로, 체력 변경은 정의된 메서드를 통해 이루어진다.
이처럼 도메인 모델은 단순한 데이터 저장소가 아니라 도메인의 규칙을 표현하고 보호하는 역할을 수행할 수 있다.
DDD의 핵심 개념
유비쿼터스 언어(Ubiquitous Language)
유비쿼터스 언어는 개발자와 도메인 전문가가 동일한 개념을 동일한 용어로 이해하고 사용하는 것을 의미한다.
예를 들어 게임 기획에서 ‘장착’, ‘보관’, ‘사용’을 서로 다른 개념으로 정의했다면 코드에서도 이를 명확하게 구분해야 한다.
public void Equip(Item item)
{
// 장착 규칙 처리
}
public void Store(Item item)
{
// 보관 규칙 처리
}
public void Use(Item item)
{
// 사용 규칙 처리
}
메서드의 실제 책임은 도메인 규칙에 따라 정의해야 하지만, 중요한 것은 이름만으로도 각 기능의 의미를 이해할 수 있어야 한다는 점이다.
공통된 언어를 사용하면 기획서와 코드 사이의 의미 차이를 줄이고, 잘못된 해석으로 인한 구현 오류를 예방할 수 있다.
엔티티(Entity)
엔티티는 식별자를 통해 개별 객체를 구분하는 도메인 객체다.
예를 들어 캐릭터의 이름이나 체력이 변경되더라도 동일한 캐릭터라면 같은 엔티티로 취급할 수 있다.
public class Character
{
public Guid Id { get; }
public string Name { get; private set; }
public Character(Guid id, string name)
{
Id = id;
Name = name;
}
public void Rename(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("이름이 필요합니다.");
Name = name;
}
}
이 예제에서 Id는 캐릭터의 식별자다. 이름은 변경될 수 있지만 식별자는 동일하게 유지된다.
엔티티의 핵심 기준은 속성이 얼마나 많은지가 아니라, 시간이 지나면서 상태가 바뀌더라도 동일한 대상으로 인식해야 하는지에 있다.
값 객체(Value Object)
값 객체는 식별자보다 값 자체의 의미를 중심으로 취급하는 객체다.
예를 들어 돈의 금액과 통화, 좌표, 기간 등이 값 객체로 표현될 수 있다.
public readonly record struct Money(
decimal Amount,
string Currency
);
이 구조에서는 금액뿐 아니라 통화도 함께 고려할 수 있다. 서로 다른 통화의 금액을 무조건 더하는 것과 같은 잘못된 연산을 방지하려면 추가적인 검증과 연산 규칙을 정의해야 한다.
값 객체는 일반적으로 생성 후 변경하지 않는 불변 객체로 설계하는 경우가 많다. 불변성을 유지하면 값의 의미와 유효성을 관리하기 쉬워진다.
엔티티와 값 객체의 차이는 객체의 크기가 아니라 식별과 동등성을 판단하는 기준에 있다.
애그리거트(Aggregate)
애그리거트는 서로 연관된 도메인 객체를 하나의 일관성 경계로 묶은 것이다.
애그리거트 내부의 규칙은 정해진 진입점을 통해 보호하며, 외부에서는 일반적으로 애그리거트 루트(Aggregate Root)를 통해 내부 상태를 변경한다.
애그리거트는 단순히 여러 객체를 하나의 클래스에 넣는 개념이 아니다. 어떤 객체들이 하나의 규칙을 공유하며, 그 규칙을 어디에서 보장할 것인지 결정하는 설계 개념이다.
애그리거트가 필요한 이유
게임의 인벤토리 시스템을 예로 들어 보자.
인벤토리는 다음과 같은 규칙을 가질 수 있다.
- 최대 20개의 슬롯을 가진다.
- 포션은 슬롯당 최대 99개까지 중첩할 수 있다.
- 아이템 추가는 인벤토리의 규칙을 따라야 한다.
- 외부 코드가 내부 아이템 목록을 임의로 변경해서는 안 된다.
이러한 규칙을 여러 클래스에 분산시키면 어떤 코드에서는 슬롯 제한을 확인하고, 다른 코드에서는 확인하지 않는 문제가 발생할 수 있다.
애그리거트를 적용하면 인벤토리의 규칙을 인벤토리 루트에서 통제할 수 있다.
일반적인 구현 방식
먼저 아이템을 다음과 같이 구현한다고 가정하자.
public class Item
{
public string Id;
public int Count;
}
public class Inventory
{
public List<Item> Items = new();
}
이 구조에서는 외부 코드가 직접 아이템을 추가할 수 있다.
inventory.Items.Add(new Item
{
Id = "Potion",
Count = 999
});
인벤토리가 20개 슬롯으로 제한되어 있더라도 이 코드는 해당 규칙을 우회할 수 있다. 아이템 수량도 제한되지 않는다.
문제의 원인은 데이터 구조 자체가 아니라, 도메인 규칙을 통제하는 명확한 경계가 없다는 점이다.
애그리거트 루트를 통한 구현
먼저 아이템 엔티티를 구현한다.
public sealed class Item
{
public string Id { get; }
public int Count { get; private set; }
public Item(string id, int count)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Invalid item ID");
if (count <= 0 || count > 99)
throw new ArgumentOutOfRangeException(nameof(count));
Id = id;
Count = count;
}
internal int Add(int amount)
{
int added = Math.Min(amount, 99 - Count);
Count += added;
return added;
}
}
아이템은 자신의 수량 제한을 관리한다. Count의 변경 권한을 제한해 외부에서 임의로 수량을 설정하지 못하도록 한다.
다음으로 인벤토리를 구현한다.
public sealed class Inventory
{
private readonly List<Item> _items = new();
public IReadOnlyList<Item> Items => _items;
public int MaxSlots { get; } = 20;
public bool TryAdd(string itemId, int count)
{
if (count <= 0)
return false;
foreach (var item in _items)
{
if (item.Id != itemId || item.Count >= 99)
continue;
int added = item.Add(count);
count -= added;
if (count <= 0)
return true;
}
while (count > 0)
{
if (_items.Count >= MaxSlots)
return false;
int amount = Math.Min(count, 99);
_items.Add(new Item(itemId, amount));
count -= amount;
}
return true;
}
}
이 코드는 애그리거트의 기본 구조를 설명하기 위한 예시다. 실제 프로젝트에서는 슬롯이 부족할 때 일부 아이템만 추가되는 상황을 어떻게 처리할지 정책을 정의해야 한다. 전체 추가가 성공하거나 전부 실패해야 한다면 변경 전에 필요한 슬롯을 검증하거나 롤백을 지원해야 한다.
이제 외부 코드는 다음과 같이 인벤토리를 통해 아이템을 추가한다.
inventory.TryAdd("Potion", 10);
외부에서 아이템 목록을 직접 수정하는 대신 인벤토리에 작업을 요청한다. 인벤토리는 슬롯 제한을 확인하고, 아이템은 자신의 수량 규칙을 지킨다.
애그리거트, 애그리거트 루트, 엔티티의 차이
위 예제의 구조를 구분하면 다음과 같다.
- Inventory: 애그리거트 루트. 외부에서 인벤토리 애그리거트에 접근하는 대표 객체다.
- Item: 인벤토리 내부에 포함된 엔티티다.
- Inventory와 내부 Item들의 집합: 애그리거트다.
따라서 애그리거트는 클래스 하나를 의미하지 않는다. 객체들의 집합과 그 집합이 지켜야 하는 일관성 경계를 의미한다.
또한 IReadOnlyList<Item>만 반환한다고 해서 경계가 완벽하게 보호되는 것은 아니다. 컬렉션의 수정은 막더라도 내부에 있는 Item 객체의 변경 메서드가 외부에 공개되어 있다면 상태가 변경될 수 있다. 실제 구현에서는 접근 제한자, 불변 객체, 도메인 인터페이스 등을 활용해 경계를 강화해야 한다.
애그리거트 설계 시 주의사항
관련된 객체를 모두 하나의 애그리거트로 묶을 필요는 없다.
예를 들어 캐릭터와 인벤토리는 서로 관련이 있지만 반드시 하나의 애그리거트일 필요는 없다.
- 인벤토리는 아이템 보유와 중첩 규칙을 관리한다.
- 캐릭터는 장착 조건과 능력치 변경 규칙을 관리한다.
이처럼 각 애그리거트가 자신에게 필요한 규칙을 책임지도록 분리할 수 있다.
애그리거트를 지나치게 크게 만들면 변경 범위가 커지고, 동시성 제어와 유지보수 비용도 증가할 수 있다. 반대로 지나치게 작게 분리하면 하나의 규칙을 보장하기 위해 여러 객체를 동시에 관리해야 할 수 있다.
핵심은 객체 간의 관계가 아니라 어떤 규칙을 하나의 일관성 경계 안에서 보장해야 하는가를 기준으로 설계하는 것이다.
리포지토리(Repository)
리포지토리는 도메인 객체를 저장하고 조회하는 작업을 추상화하는 패턴이다.
도메인 로직이 데이터베이스나 파일 시스템의 구체적인 구현에 직접 의존하지 않도록 분리할 수 있다.
public interface ICharacterRepository
{
Character? FindById(Guid id);
void Save(Character character);
}
게임 로직이나 애플리케이션 서비스는 이 인터페이스를 통해 캐릭터를 조회하고 저장할 수 있다.
실제 구현은 데이터베이스, 파일, 서버 API 등 다양한 저장 방식으로 제공할 수 있다.
다만 모든 프로젝트에 별도의 리포지토리 계층이 필요한 것은 아니다. 저장 구조가 단순한 프로젝트에서는 불필요한 추상화가 오히려 복잡성을 증가시킬 수 있다.
DDD와 계층형 아키텍처
DDD를 적용할 때는 도메인 로직과 기술적인 구현을 분리하는 것이 중요하다.
대표적으로 다음과 같은 계층을 구성할 수 있다.
도메인 계층
핵심 비즈니스 규칙과 도메인 모델을 관리한다. 엔티티, 값 객체, 애그리거트 등이 여기에 포함될 수 있다.
애플리케이션 계층
사용자의 요청이나 시스템의 작업을 처리하는 흐름을 조정한다. 필요한 도메인 객체를 조회하고, 도메인 동작을 호출하며, 작업의 순서를 관리한다.
인프라스트럭처 계층
데이터베이스 접근, 파일 입출력, 외부 API 호출 등 구체적인 기술 구현을 담당한다.
표현 계층
사용자 인터페이스, HTTP API, 게임 클라이언트의 입력 처리 등 외부와 시스템이 상호작용하는 부분을 담당한다.
이러한 계층은 도메인 로직이 특정 프레임워크나 저장 기술에 지나치게 의존하지 않도록 돕는다.
단, DDD가 반드시 네 개의 프로젝트나 폴더를 만들어야 한다는 뜻은 아니다. 중요한 것은 계층의 개수보다 책임과 의존 관계를 명확하게 정의하는 것이다.
DDD와 객체지향 프로그래밍의 차이
객체지향 프로그래밍(OOP)은 데이터와 동작을 객체로 구성하고, 객체 간의 관계와 책임을 설계하는 프로그래밍 패러다임이다.
DDD는 객체지향 프로그래밍을 활용할 수 있지만, 객체지향 프로그래밍 자체와 동일한 개념은 아니다.
| 구분 | 객체지향 프로그래밍 | 도메인 주도 설계 |
|---|---|---|
| 핵심 관심사 | 객체의 책임과 협력 | 도메인 개념과 규칙의 정확한 모델링 |
| 설계 기준 | 객체 구조와 추상화 | 문제 영역과 도메인 규칙 |
| 주요 개념 | 캡슐화, 상속, 다형성 | 도메인 모델, 유비쿼터스 언어, 애그리거트 |
| 적용 범위 | 프로그래밍 패러다임 | 복잡한 도메인을 다루기 위한 설계 접근법 |
객체지향적으로 잘 작성된 코드라고 해서 반드시 DDD를 적용한 것은 아니다.
DDD의 핵심은 객체를 많이 만드는 것이 아니라, 도메인의 개념과 규칙이 코드 구조에 올바르게 반영되도록 하는 것이다.
게임 개발에서 DDD를 활용하는 방법
게임 프로젝트에서도 도메인 중심 설계는 유용하다. 특히 규칙이 복잡하고 여러 시스템이 서로 영향을 주는 프로젝트에서 효과를 얻기 쉽다.
예를 들어 RPG 게임에서는 다음과 같이 도메인을 구분할 수 있다.
- 전투 도메인: 피해 계산, 상태 이상, 방어 판정
- 인벤토리 도메인: 아이템 보유, 중첩, 장착, 사용
- 성장 도메인: 경험치, 레벨업, 능력치 성장
- 퀘스트 도메인: 진행 조건, 완료 판정, 보상
- 경제 도메인: 재화 지급, 소비, 거래 규칙
이러한 구분은 반드시 별도의 실행 모듈이나 서버로 분리해야 한다는 의미는 아니다. 우선 개념과 책임을 논리적으로 구분하는 것만으로도 시스템 간 결합도를 낮추는 데 도움이 된다.
Unity 프로젝트에서는 핵심 도메인 로직을 MonoBehaviour와 분리하고, 순수 C# 객체로 구현하는 방식도 고려할 수 있다.
이렇게 하면 Unity 씬이나 게임 오브젝트에 의존하지 않고도 도메인 규칙을 테스트할 수 있다. 반면 애니메이션, 물리 연산, 렌더링처럼 Unity 엔진 기능과 직접 관련된 코드는 엔진 계층에 두는 편이 자연스럽다.
또한 모든 게임 시스템에 DDD를 적용할 필요는 없다. 단순한 캐주얼 게임이나 규칙이 적은 기능에서는 일반적인 객체지향 설계만으로 충분할 수 있다.
DDD를 적용할 때 주의할 점
DDD는 코드의 구조를 복잡하게 만드는 기법이 아니다.
프로젝트의 문제 영역이 단순한데도 모든 개념을 엔티티, 값 객체, 애그리거트, 리포지토리로 분리하면 오히려 불필요한 코드와 추상화가 증가할 수 있다.
DDD를 적용하기 전에 다음 사항을 먼저 판단하는 것이 좋다.
- 도메인 규칙이 얼마나 복잡한가?
- 여러 기능에서 동일한 규칙을 사용하고 있는가?
- 규칙의 변경이 여러 시스템에 영향을 주는가?
- 데이터의 일관성을 보장해야 하는가?
- 도메인 로직을 외부 기술과 분리할 필요가 있는가?
이러한 질문에 대한 답이 명확할수록 DDD를 적용할 위치와 범위를 합리적으로 결정할 수 있다.
특히 DDD는 단순한 클래스 설계 방법이 아니다. 도메인 전문가와 개발자가 문제 영역을 함께 이해하고, 공통된 언어와 모델을 지속적으로 개선하는 과정까지 포함한다.