Go에서 우아한 도메인 주도 설계 객체

발행: (2026년 1월 19일 오후 02:41 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

예시 시나리오

“반복 구매에 보상을 제공함으로써 고객이 우리 커피숍에 다시 오도록 어떻게 장려할 수 있을까요?”

핵심 문제

  1. 고객이 커피를 구매합니다.
  2. 우리는 각 구매마다 포인트를 부여하고 싶습니다.
  3. 포인트는 30일 후에 만료됩니다.
  4. 리더보드를 표시하고 보상을 제공할 수 있습니다 (예: 무료 커피).

도메인 전문가와 여러 차례 협업한 결과, 우리는 다음을 식별했습니다:

  • EntityCustomer (고객 정보와 그들의 커피 주문을 포함)
  • Value ObjectCoffeeOrder (단일 주문에 대한 정보를 포함)

1️⃣ Define the Value Object (CoffeeOrder)

Value objects는 불변(immutable) 객체로, 도메인의 서술적 측면을 나타내며 고유한 식별자가 아니라 속성(값) 만으로 정의됩니다.

type CoffeeOrder struct {
    // 주문된 커피의 크기.
    Size string
    // OrderBy는 주문을 한 사용자의 ID입니다.
    OrderBy int
    // OrderTime은 주문이 발생한 시점입니다.
    OrderTime time.Time
}

IDCoffeeOrderID가 없습니다 – 값 객체는 오직 필드들에 의해 식별됩니다.

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{}), 유효하지 않은 제로값 객체가 생성됩니다.

불변성을 강제하는 단계

  1. 필드를 내보내지 않음 – 외부에서의 변형을 방지합니다.
  2. 생성자를 제공 – 입력을 검증하고 완전히 초기화된 객체를 반환합니다.
  3. 인터페이스를 통해 동작을 노출 – 구체적인 구현을 숨깁니다.
  4. 값 리시버 사용 – 기본 데이터의 우발적인 수정을 방지합니다.

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에서 서비스는 도메인 계층 위에서 동작하는 비즈니스 작업을 조정합니다. 예를 들어, 다음과 같은 서비스를 만들 수 있습니다:

  1. 지난 30일 동안 주문된 모든 CoffeeOrder를 조회합니다.
  2. 해당 주문과 연관된 Customer를 가져옵니다.
  3. 고객을 포인트 기준으로 정렬하여 리더보드를 생성합니다.

린터를 사용한 실천 강제화

많은 Go 린터가 존재하지만, DDD 패턴에 특화된 것은 없습니다. 그 격차를 메우기 위해 저는 godddlint 라는 린터를 만들었으며, 다음과 같은 규칙을 검사합니다:

  • 값 객체에 대한 불변성.
  • 엔티티에 대해 원시 타입보다 커스텀 타입 사용.
  • 엔티티와 값 객체에 대한 포인터와 비포인터 리시버의 올바른 사용.
  • 메서드 반환문 안에 errors.New 사용 금지 (도메인 전용 오류를 권장).

이 린터는 아직 활발히 개발 중이지만, 보다 표현력 있고 견고한 도메인 모델을 작성하는 데 도움이 될 수 있습니다.

godddlint – 유용하게 사용할 수 있는 DDD‑전용 린터.

요약

  • 불변성.
  • 컴파일 타임 안전성을 위한 도메인 전용 타입 생성.
  • 사용자 정의 도메인 오류 정의.

여러분은 어떠신가요?

Go에서 DDD에 효과적인 패턴은 무엇인가요?
댓글로 경험을 공유해주세요!

Back to Blog

관련 글

더 보기 »