Go에서 우아한 도메인 주도 설계 객체
Source: Dev.to
예시 시나리오
“반복 구매에 보상을 제공함으로써 고객이 우리 커피숍에 다시 오도록 어떻게 장려할 수 있을까요?”
핵심 문제
- 고객이 커피를 구매합니다.
- 우리는 각 구매마다 포인트를 부여하고 싶습니다.
- 포인트는 30일 후에 만료됩니다.
- 리더보드를 표시하고 보상을 제공할 수 있습니다 (예: 무료 커피).
도메인 전문가와 여러 차례 협업한 결과, 우리는 다음을 식별했습니다:
- Entity –
Customer(고객 정보와 그들의 커피 주문을 포함) - Value Object –
CoffeeOrder(단일 주문에 대한 정보를 포함)
1️⃣ Define the Value Object (CoffeeOrder)
Value objects는 불변(immutable) 객체로, 도메인의 서술적 측면을 나타내며 고유한 식별자가 아니라 속성(값) 만으로 정의됩니다.
type CoffeeOrder struct {
// 주문된 커피의 크기.
Size string
// OrderBy는 주문을 한 사용자의 ID입니다.
OrderBy int
// OrderTime은 주문이 발생한 시점입니다.
OrderTime time.Time
}
ID나CoffeeOrderID가 없습니다 – 값 객체는 오직 필드들에 의해 식별됩니다.
2️⃣ Define the Entity (Customer)
엔터티는 고유한 정체성으로 정의된 객체이며, 라이프사이클과 상태 변화를 통한 연속성을 가집니다.
type Customer struct {
// ID of the customer.
ID int
// Name of the customer.
Name string
// CoffeeOrders holds the orders of this customer.
CoffeeOrders []CoffeeOrder
}
3️⃣ 비즈니스 로직 예시
// PointsAt returns the points of a customer at a given moment in time.
func (c *Customer) PointsAt(ref time.Time) int {
points := 0
for _, co := range c.CoffeeOrders {
points += co.Points(ref)
}
return points
}
이 코드가 동작하긴 하지만, 특히 불변성(immutability) 을 중심으로 개선할 수 있는 부분이 여러 가지 있습니다.
🔒 CoffeeOrder의 불변성 달성
현재 구현이 불변성이 없는 이유
- 필드가 내보내져 있어 호출자가 직접 변경할 수 있습니다 (
co.OrderBy = 5). - 구조체를 검증 없이 인스턴스화할 수 있어 (
co := CoffeeOrder{}), 유효하지 않은 제로값 객체가 생성됩니다.
불변성을 강제하는 단계
- 필드를 내보내지 않음 – 외부에서의 변형을 방지합니다.
- 생성자를 제공 – 입력을 검증하고 완전히 초기화된 객체를 반환합니다.
- 인터페이스를 통해 동작을 노출 – 구체적인 구현을 숨깁니다.
- 값 리시버 사용 – 기본 데이터의 우발적인 수정을 방지합니다.
3.1 도메인‑특화 타입 정의
Using custom types over primitives adds semantic meaning and lets us attach validation logic.
type CustomerID int
type CoffeeSize string
// IsValid reports whether the coffee size is one of the allowed values.
func (cs CoffeeSize) IsValid() bool {
switch cs {
case "small", "medium", "large":
return true
default:
return false
}
}
3.2 인터페이스와 비공개 구현 정의
type CoffeeOrder interface {
Size() CoffeeSize
OrderBy() CustomerID
OrderTime() time.Time
Points(ref time.Time) CoffeePoints
// x is an unexported method that guarantees the implementation
// can only be defined in this package.
x()
}
type coffeeOrder struct {
size CoffeeSize
orderBy CustomerID
orderTime time.Time
}
// Ensure coffeeOrder implements CoffeeOrder.
var _ CoffeeOrder = (*coffeeOrder)(nil)
// Unexported method to satisfy the interface.
func (c *coffeeOrder) x() {}
3.3 검증이 포함된 생성자
func NewCoffeeOrder(
size CoffeeSize,
orderBy CustomerID,
orderTime time.Time,
) (CoffeeOrder, error) {
if !size.IsValid() {
return nil, errors.New("invalid coffee size")
}
// Additional business‑rule checks can be added here.
return &coffeeOrder{
size: size,
orderBy: orderBy,
orderTime: orderTime,
}, nil
}
3.4 인터페이스 메서드 구현
func (c *coffeeOrder) Size() CoffeeSize { return c.size }
func (c *coffeeOrder) OrderBy() CustomerID { return c.orderBy }
func (c *coffeeOrder) OrderTime() time.Time { return c.orderTime }
// Points calculates the points earned for this order at the given reference time.
func (c *coffeeOrder) Points(ref time.Time) CoffeePoints {
// Example logic: 1 point per coffee, expires after 30 days.
if ref.Sub(c.orderTime) > 30*24*time.Hour {
return 0
}
return 1
}
이제 CoffeeOrder는:
- 불변 – 생성 후 필드를 변경할 수 없습니다.
- 검증됨 – 생성자가 비즈니스 규칙을 강제합니다.
- 캡슐화 – 호출자는 내보낸 인터페이스를 통해서만 상호작용합니다.
4️⃣ 새로운 값 객체를 사용하도록 엔티티 업데이트
type Customer struct {
ID CustomerID
Name string
// Store the interface to keep the entity decoupled from the concrete type.
CoffeeOrders []CoffeeOrder
}
// PointsAt returns the total points of a customer at a particular moment.
func (c *Customer) PointsAt(ref time.Time) CoffeePoints {
var total CoffeePoints
for _, order := range c.CoffeeOrders {
total += order.Points(ref)
}
return total
}
💡 핵심 정리
| ✅ | 권장 사항 |
|---|---|
커스텀 타입 정의 (예: CustomerID, CoffeeSize)를 통해 원시 값에 도메인 의미를 부여합니다. | |
| 구현을 인터페이스 뒤에 숨기기로 외부 코드가 잘못된 객체를 생성하지 못하도록 합니다. | |
| 값 객체의 필드는 절대 내보내지 않으며, 불변 조건을 강제하는 생성자를 사용합니다. | |
| 불변 객체는 값 리시버를 선호하고, 변형이 필요할 때만 포인터 리시버를 사용합니다. | |
| 방어적 복사는 값 객체가 가변 입력(슬라이스, 맵 등)을 받을 때 필요합니다. |
이러한 패턴을 따르면 Go에서 DDD 개념을 모델링하면서 도메인 레이어를 타입‑안전, 표현력 풍부, 유지보수 가능하게 만들 수 있습니다. 즐거운 코딩 되세요!
도메인 검증을 위한 사용자 정의 오류
사용자가 제공한 값—예를 들어 커피 크기—을 검증할 때는 일반적인 errors.New 대신 도메인 특화 오류를 반환해야 합니다. 오류는 호출자가 무엇이 잘못되었는지 이해할 수 있도록 충분한 컨텍스트를 포함해야 합니다.
// Declare that the custom error implements the error interface.
var _ error = new(WrongCoffeeSizeError)
type WrongCoffeeSizeError struct {
Size string
}
// Error implements the error interface.
func (e WrongCoffeeSizeError) Error() string {
return fmt.Sprintf("invalid coffee size: %s", e.Size)
}
그런 다음 사용자 정의 오류를 다음과 같이 사용할 수 있습니다:
if !size.IsValid() {
return CoffeeOrder{}, WrongCoffeeSizeError{Size: string(size)}
}
DDD에서의 서비스
Domain‑Driven Design에서 서비스는 도메인 계층 위에서 동작하는 비즈니스 작업을 조정합니다. 예를 들어, 다음과 같은 서비스를 만들 수 있습니다:
- 지난 30일 동안 주문된 모든
CoffeeOrder를 조회합니다. - 해당 주문과 연관된
Customer를 가져옵니다. - 고객을 포인트 기준으로 정렬하여 리더보드를 생성합니다.
린터를 사용한 실천 강제화
많은 Go 린터가 존재하지만, DDD 패턴에 특화된 것은 없습니다. 그 격차를 메우기 위해 저는 godddlint 라는 린터를 만들었으며, 다음과 같은 규칙을 검사합니다:
- 값 객체에 대한 불변성.
- 엔티티에 대해 원시 타입보다 커스텀 타입 사용.
- 엔티티와 값 객체에 대한 포인터와 비포인터 리시버의 올바른 사용.
- 메서드 반환문 안에
errors.New사용 금지 (도메인 전용 오류를 권장).
이 린터는 아직 활발히 개발 중이지만, 보다 표현력 있고 견고한 도메인 모델을 작성하는 데 도움이 될 수 있습니다.
godddlint – 유용하게 사용할 수 있는 DDD‑전용 린터.
요약
- 불변성.
- 컴파일 타임 안전성을 위한 도메인 전용 타입 생성.
- 사용자 정의 도메인 오류 정의.
여러분은 어떠신가요?
Go에서 DDD에 효과적인 패턴은 무엇인가요?
댓글로 경험을 공유해주세요!