도메인 설계 리뷰: 비정규화, 의존성, 그리고 표현의 책임


1. 들어가며

이 글은 도메인 설계에 대한 나의 고민과 선택, 그리고 그 배경을 기록한 것이다.

회사 내 코드 리뷰에서는 비정규화에 대한 부정적 피드백이 많았지만, 실상은 도메인 표현과 책임에 대한 관점 차이가 더 본질적인 문제였다. 전통적인 정규화 중심의 사고방식과, 도메인의 자율성과 명확한 역할 표현을 중시하는 내 접근 사이의 간극이 컸다.

2. 기존 구조의 문제와 자율성을 향한 리펙토링

2.1. 기존 구조의 문제

초기에는 CouponCouponGroup을 참조하여 유효성 판단을 수행했다.

public class CouponGroup {
    private String id;
    private List<Coupon> coupons = new ArrayList<>();
    private String name;
    private int couponCount;
    private Instant startAt;
    private Instant endAt;
}

public class Coupon {
    private String no;
    private CouponGroup couponGroup;
    private CouponStatus status;
}

이 구조는 다음과 같은 문제를 드러났다.

  • 쿠폰 단독으로 유효성 판단 불가능
  • 도메인 책임이 그룹에 분산됨

2.2. 쿠폰의 자율성 확보가 중요한 이유

CouponCouponGroup이 하나의 애그리게이트로 구성돼 있어 내부 결합 자체는 문제가 되지 않는다. 다만, 쿠폰이 자체적으로 판단 가능한 구조가 되면 애그리게이트의 자율성과 테스트 용이성 면에서 유리하다.

2.3. 비정규화는 책임 중심 설계 전략이다.

나는 도메인의 자율성과 독립적 판단 능력 확보를 위해 비정규화를 선택했다.

쿠폰 생성시, 그룹의 유효성 정보를 쿠폰에 복사하여 쿠폰이 완결된 정보 단위가 되도록 했다.

public class Coupon {
    // ...
    private Instant startAt;
    private Instant endAt;

    public Coupon(CouponGroup group) {
        // ...
        this.startAt = group.getStartAt();
        this.endAt = group.getEndAt();
    }
}

이는 단순한 정규화 위반이 아닌, 설계적 판단에 따른 책임 집중이었다. 도메인 자율성과 독립성 확보를 위한 의도적인 비정규화였으며, 이는 단순한 데이터 구조의 변경이 아닌 역할 중심 설계의 구현 이었다.

2.4. 정규화 논쟁: 데이터 중복 vs 도메인 표현

물론 리뷰 과정에서 다음과 같은 부정적인 피드백도 있었다.

“정규화를 지키지 않았고, 데이터가 중복됐다. 설계를 잘못한 것이다.”

이런 지적이 왜 나왔는지는 이해된다. 정규화는 오래된 개발자들에게 익숙한 안정적 원칙이기도 하니까.

하지만 내가 마주한 건 단순한 데이터 중복 문제가 아니었다. 도메인 책임이 애매하게 분산돼 있었고, 이를 해결하기 위해서 의도적으로 비정규화를 선택한 거였다.

물론 정규화는 중요한 원칙이다. 그렇다고 그게 목적 그 자체가 목적이 되어선 안 된다. 도메인 설계의 중심은 ‘표현’과 ‘책임’에 있어야 한다. 의도적이고 책임 중심의 비정규화는 도메인 설계에 있어 정당한 전략이 될 수 있다.

3. 설계 원칙의 해석 차이와 도메인 중심 사고

3.1. “타입을 넘기면 의존”의 단편적 사고

일부 리뷰어는 다음과 같은 구조를 두고, Company를 넘기는 것이 쿠폰과 회사 간 의존이 생긴다고 보았다.

// "Coupon이 Company 객체를 인자로 받으면 Company에 의존하게 되는 것이다."
public void register(Company company) {
    this.status = CouponStatus.REGISTERED;
    this.registeredBy = company.getId();
    // ...
}
  • Coupon은 Company에 대해 아무런 행위를 하지 않음
  • 단지 Company의 ID를 전달받아 저장할 뿐.
  • 이는 구조적 의존이라기보다 일시적/약한 의존 (transient dependency)에 가깝다.

또한 단순한 식별자 전달처럼 보일 수 있지만, 이는 ‘등록’이라는 도메인 행위의 주체를 명시하는 설계적 맥락을 담고 있다.

3.1.1. VO(Value Object)로의 리팩토링 여지

“의존된다”는 단편적인 시각에서 벗어나,

현재 구조를 CompanyId와 같은 값 객체(Value Object) 형태로 리팩토링할 수 있다는 관점이 더 생산적인 피드백 방향이라고 생각한다.

이런 리팩토링은 단순히 타입을 감추는 것이 아니라, 의미 있는 도메인 개념을 명시적으로 표현하려는 설계적 선택이다. 리뷰 과정에서 VO 개념이나 점진적 리팩토링 전략에 대한 이해 없이 “타입을 받으니 의존이다”라는 식의 평면적인 비판은, 설계 논의의 깊이를 떨어뜨릴 수 있다.

차라리 “지금은 Company를 직접 넘기지만, 이후에는 CompanyId 같은 VO로 분리하는 것이 좋겠다” 는 피드백이 훨씬 건설적이고, 도메인 설계를 발전시키는 데 기여할 수 있다고 생각한다.

3.1.2. 타입 제거 = 결합도 감소?

많은 사람들이 타입을 단순히 제거하면 결합도가 줄어든다고 생각할 수 있다.

예를 들어, Company 객체를 Coupon에 넘기는 대신 String companyId와 같은 기본 타입을 넘기면 결합도가 낮아진다고 생각할 수 있다. 하지만 실제로 단순히 타입을 제거하는 것만으로 결합도를 줄이는 건 아니다.

결합도를 줄이기 위해서는 도메인 의도를 명확하게 표현하는 타입 설계가 중요하다. 단순히 기본 타입으로 변환하는 것보다는, 의도적으로 도메인의 의미를 반영한 타입을 사용하는 게 훨씬 더 유효하다.

왜 명확하게 표현된 타입이 결합도를 낮추는 걸까?

public class Coupon {

    public void register(CompanyId companyId) {
        this.status = CouponStatus.REGISTERED;
        this.registeredBy = companyId.getValue();
    }
}
  1. 의도 명확화: CompanyIdCouponCompany라는 구체적인 엔티티에 의존하는 것이 아니라, 단지 CompanyId라는 개념에 의존하도록 만든다. 이는 Coupon 클래스가 Company 객체의 변경에 영향을 받지 않도록 하여 결합도를 낮춘다.
  2. 구체적인 구현에 대한 의존 최소화: CouponCompany 객체를 다룰 때 Company의 구현 변경이 Coupon에 영향을 미칠 수 있다. 하지만 CompanyId를 사용하면 CouponCompany의 구체적인 구현에 의존하지 않게 되어, 변경에 유연해진다.
  3. 유연한 변경 가능성: CompanyId와 같은 값 객체를 사용하면, Company의 변경이 Coupon에 미치는 영향을 최소화한다. Coupon은 이제 Company의 속성 변경과 무관하게 CompanyId만 처리하면 된다.

이처럼 도메인 모델의 의도를 정확히 표현하는 타입 설계는 중요하다. 도메인 의도를 표현한 타입 설계야말로 결합도를 줄이는 진정한 의미의 설계 개선이다.

  • String companyId vs CompanyId companyId: 후자가 도메인 의미 전달에 더 명확하다.
  • 단순히 기본 타입으로 바꾸는 게 결합도를 줄이는 게 아니라, 설계 의도를 감추는 결과로 이어질 수 있다.

3.2. “서비스 계층에서도 기본 타입만 사용한다” 논리의 위험

서비스 계층은 단순히 레포지토리에서 데이터를 꺼내서 전달하는 곳이 아니다. 그 본질은 도메인 객체들의 협력을 조율하고, 책임을 연결하는 오케스트레이션 계층이다.

예를 들어, 회사가 쿠폰을 등록하는 시나리오를 보자

// 오케스트레이션 계층에서 기본 타입만 쓰는 집착의 함정
public class CouponService {
    private final CouponRepository couponRepository;

    public void register(String couponNo, Company company) {
        if (!company.canRegisterCoupon()) {
            throw new IllegalStateException("회사 정책상 쿠폰 등록이 제한되어 있음");
        }

        Coupon coupon = couponRepository.get(couponNo);
        coupon.register(company.getId());
    }
}

그런데 어떤 리뷰어는 이렇게 말했다.

“서비스 계층도 기본 타입만 써야 한다. Company 대신 companyId(String)만 넘겨라. 그래야 결합도가 낮아진다.”

결합도를 낮추기 위해 Company가 아니라 그냥 String companyId만 넘기라는 식이다. 정말 그럴까? 진짜 결합도를 낮추는 선택일까?

사실 이건 결합도 문제가 아니다. 오히려 이런 식의 설계는 다음과 같은 문제를 만든다.

  • 도메인 간 협력의 의미가 코드에서 사라진다. 그냥 ID만 전달하니 이게 어떤 도메인과 협력하는 건지 드러나지 않는다.
  • Company는 단순한 데이터 덩어리가 아니다. canRegisterCoupon() 같은 책임을 가진 객체인데, ID만 넘기면 이런 역할 수행이 불가능해진다.
  • 결국 CouponService 안에서 다시 CompanyRepository를 호출하게 만들고, 책임을 이중으로 나누는 구조가 된다.
  • 이는 도메인 서비스가 타 도메인의 인프라와 의존하게 만들고, 패키지 경계를 깨는 구조적 설계 위반이 된다.

3.2.1. 의도를 숨기고 협력 구조를 흐리는 잘못된 단순화다.

요약하자면 서비스 계층에서 기본 타입만 쓰는 건 결합도를 낮추는 게 아니라, 도메인 의도를 감추고 협력 구조를 흐리는 잘못된 단순화다. 이는 설계의 복잡성을 단순화하려다 오히려 설계 책임을 회피하는 방향으로 흐를 수 있다.

서비스는 도메인 협력의 중심이다. 의미 있는 도메인 객체를 통해 협력 관계를 명확히 드러내야 한다. 그게 진짜 결합도를 낮추는 설계다.

3.3. 도메인 관점에서 본 registeredBy 필드

쿠폰 도메인에서 registeredBy 필드를 두고 내부 논쟁이 있었다.

public class Coupon {
    private String registeredBy;

    public void register(CompanyId companyId) {
        this.registeredBy = companyId.getValue();
        // ...
    }
}

리뷰 과정에서 일부 팀원은 registeredBy사용자 ID를 의미한다고 받아들였지만, 실제로는 회사 ID가 들어가 혼란을 겪었다. 그 배경엔 다음과 같은 조직 내부의 규칙이 있다.

  • ~By 네이밍은 사용자 ID로 의미한다는 관례
  • 네이밍을 기술적 일관성이나 컨벤션 관점에서만 판단하려는 사고 방식

3.3.1. 도메인 내부의 역할과 의미

하지만 DDD 관점에서 중요한 것은 도메인 내부에서 역할과 의미다.

  • 쿠폰을 등록한 주체는 사용자일 수도, 회사일 수도 있다.
  • registeredBy는 도메인 내에서 “등록 행위를 수행한 주체”를 표현하는 역할 기반 필드다.
  • 중요한 건 일관된 네이밍 규칙보다, 도메인 의미에 충실한 표현이 우선시돼야 한다.

3.3.2. 네이밍 일관성보다 도메인 맥락

이런 오해는 단순히 네이밍 일관성의 문제가 아니라, 도메인 맥락을 무시하고, 데이터를 직관적 식별자로만 보려는 사고방식에서 비롯된 문제다.

현재 시스템에서 ~By 필드가 대부분 사용자 ID를 담는다고 해서 무조건 ~By 필드가 사용자 ID를 의미해야 된다는 규칙이 되는 것은 아니다. 실제로는 기존 도메인에서 줄곧 사용자 ID를 담아왔기 때문에 그렇게 여겨졌을 뿐, 그 패턴 자체가 규칙이 되어야 할 이유는 없다.

  • registeredBy는 문법적으로도, 의미적으로도 “누가 등록했는가”를 잘 표현하고 있다.
  • 문제가 되는 건 필드명 자체가 아니라, 이를 일방적으로 사용자 ID로 해석하는 기술 중심 사고방식이다.
  • 도메인 맥락에서는 “등록 주체”가 사용자일 수도 있고, 회사일 수도 있다.
  • 특히 마이크로서비스 환경에서는 각 도메인이 표현의 자율성을 가지며, 그 독립성이 설계 품질에 직접적인 영향을 준다

결국 registeredBy는 단순한 ID가 아니라, 도메인 행위의 주체를 추상적으로 표현한 필드로 해석되어야 한다.

3.4. 의미 없는 최적화보다 표현이 우선이다

“왜 쿠폰 상태를 enum으로 표현했냐? 보통 ‘I’, ‘R’, ‘U’ 같은 문자열로 처리하는 게 일반적이지 않냐?”

리뷰 중 이런 피드백이 있었다.

public class Coupon {
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private CouponStatus status;
}

나는 이걸 듣고 정말 당황스러웠다.

물론 로그성 테이블이나, 대량 트래픽을 고려한 아카이브 테이블이라면 그런 최적화는 이해할 수 있다. 데이터 크기를 줄이고 인덱스 성능을 높이기 위한 설계적 판단이라면 의미 있다.

하지만 이건 도메인 중심의 애그리게이트 모델이다.

애초에 도메인 의미를 표현해야 하는 객체에 대해, 고작 문자열 코드로 상태를 관리하자는 건 표현력을 스스로 포기하겠다는 말이다.

public enum CouponStatus {
    ISSUED, REGISTERED, USED
}
  • ISSUED, REGISTERED, USED는 단지 상태 코드가 아니라, 행위의 결과와 상태 전이의 의미를 담고 있다.
  • enum은 타입 안정성과 IDE 레벨의 자동 완성, 리팩토링 친화성 등 여러 면에서 도메인 표현을 풍부하게 만든다.
  • 그걸 단지 문자열 “R” 하나로 뭉개는 건, 도메인 표현의 책임을 방기하는 셈이다.

오히려 문자열 코드 방식은 다음과 같은 문제를 만든다.

  • 상태별 의미가 불명확해져서 코드 해석이 어렵다.
  • 실수로 존재하지 않는 코드값을 넣어도 컴파일 타임에 잡을 수 없다.
  • enum처럼 전이 가능 상태에 대한 제약도 코드 수준에서 표현할 수 없다.

그런데도 “문자열로 하는 게 일반적이다”라는 말은 설계 판단 없이 과거 방식만 반복하는 식견 없는 피드백일 뿐이다.

4. 마무리하며

이번 리뷰는 단순한 구조 변경이나 코드 포맷팅 이야기로 보이진 않았다.

나는 도메인 설계의 책임, 표현력, 자율성 같은 더 근본적인 이야기를 하고 싶었다.

  • 이 도메인은 자기 책임을 다하고 있는가?
  • 이 인자가 어떤 의미인지 코드만 보고 알 수 있는가?
  • 이 설계는 서비스 간 경계를 잘 유지하고 있는가?

이런 질문은 결국 “우리가 왜 이렇게 만들었는가”에 대한 답을 찾는 과정이고, 도메인 중심의 사고를 하겠다면 반드시 넘어야 하는 지점이라고 생각한다.

하지만 리뷰를 받고 나서 한참을 생각에 잠겼다.

“그건 아닌 것 같아요. 더는 고칠 마음이 없습니다.” 그 말 이후로 대화는 멈췄다. 내 코드가 틀렸다는 지적이 아쉬운 게 아니었다. 그걸 “틀렸다”고 말하는 관점이 실망스러웠다.

상태를 문자열로 표현하는 게 당연하고, Enum은 무겁다며 꺼리는 태도. 표현 방식에 대한 이런 시각은 결국, 설계보다는 구현의 편의에 기울어 있는 판단처럼 느껴졌다. 게다가 비정규화는 무조건 나쁘다는 전제를 깔고 시작하는 피드백. “요즘 누가 그렇게 해요?”라는 식의 말은, 변화가 많은 도메인에서의 유연함이나 예외 처리 같은 현실적인 고민을 담기엔 너무 단편적이었다. 마치 설계에는 하나의 정답만 존재하고, 그 정답은 과거의 익숙한 방식에 있다는 듯한 느낌이었다.

‘도메인 중심’이라는 말을 자주 듣지만, 정작 그 안에는 도메인에 대한 책임이나 표현에 대한 고민은 드러나지 않았다. 구조보다는 언어에, 코드보다는 습관에 머무르는 설계 문화가 안타까웠다.

실제로 도메인을 구현하고, 변화와 복잡함 속에서 책임을 정리해본 경험이 없다면, 단편적인 기준으로 판단하기 쉬운 것도 사실이다. 하지만 지금처럼 MSA 전환이라는 복잡한 시점에서는, 그런 태도 하나하나가 설계 품질에 영향을 줄 수밖에 없다.

물론 도메인이라고 해서 무조건 코드로 표현해야 하는 것도 아니다. 예컨대 쿠폰처럼 조건이 자주 바뀌고 예외가 많은, 로직보다 데이터 중심의 접근이 더 유연한 도메인도 분명 존재한다. 이런 도메인은 어느 정도 copy-and-paste 전략이 더 현실적일 수 있고, 완벽한 추상화보다는 변화에 강한 구조만 갖추는 게 더 현실적일 수도 있다.

그래서 나는 이번 글이 “데이터 중심 사고 = 나쁘다”는 비판으로 읽히지 않기를 바란다. 내가 지적하고 싶은 건, 어떤 도메인이든 “깊은 고민 없이 단편적으로 판단하는 태도”에 대한 문제의식이다.

코드 리뷰는 결국 서로 다른 생각을 교환하고, 그 차이를 이해하고 존중하는 과정이어야 한다. 다름을 무시하고, 과거 방식만을 고수하며, “요즘은 안 그래요”라는 말로 덮어버리는 태도는 특히 MSA 전환처럼 설계적 판단이 중요한 시기엔 더 위험하다.

설계는 언제나 정답이 아닌 의도와 책임에 대한 선택의 결과다. 이번 경험이 팀 전체가 도메인과 설계에 대해 한층 더 깊은 고민을 하게 되는 계기가 되길 바란다.