[ONECO] "값참조"를 더 도메인 답게 만들기

2025. 12. 17. 11:39·Tech Note

 

Long vs MemberId(VO)  —  결국 우리는 MemberId(VO)를 선택했다.

 

이전 글에서 ONECO는 FamilyRelation이 Member를 @ManyToOne으로 들고 가지 않고, parentId / childId 같은 값 참조(ID) 를 선택했다고 정리했다.

 

 

[ONECO] Family 도메인에서 FamilyRelation 매핑 고민기

ManyToOne vs 값 참조(ID) — 결국 우리는 “값 참조”를 선택했다 ONECO는 도메인 주도 개발(DDD)을 지향하며 진행 중인 프로젝트다. Family 도메인을 구현하면서, “부모–자녀 계정 연결”이라는 요구

goodjunseon-tech-blog.tistory.com

 

그 다음 단계에서 팀이 마주친 고민은 이거였다.

 

“그럼 그 ID는 그냥 Long이면 충분할까?

아니면 MemberId 같은 VO(Value Object) 로 감싸야 할까?”

 

ONECO는 결론적으로 Long 대신 MemberId VO를 도입했다.

이번 글은 그 이유를 “도메인 규칙, 안전성, 코드 품질, 확장성” 관점에서 정리한 기록이다.


배경: “값 참조(ID)”는 끝이 아니라 시작이다

값 참조를 선택하면, 엔티티는 보통 이렇게 생긴다.

@Column(name = "parent_id", nullable = false)
private Long parentId;

@Column(name = "child_id", nullable = false)
private Long childId;

이 구조만으로도 @ManyToOne의 함정(LAZY/N+1/프록시/직렬화 등)에서 많이 자유로워진다.

 

그런데 시간이 지나면 이런 문제가 슬슬 올라온다.

  • Long은 너무 “범용 타입”이라 도메인 의미가 사라진다
  • Long은 “서로 다른 ID”를 컴파일 타임에 구분해주지 못한다
  • null, 0, 음수 같은 값이 자연스럽게 섞이며 규칙이 흐려진다
  • 결국 서비스 계층에서 검증/변환 로직이 퍼지기 시작한다

즉, 값 참조를 선택했는데도 ID가 Long이면 도메인 관점에서 반쯤만 성공한 상태가 되기 쉽다.


우리가 MemberId VO를 도입한 핵심 이유

1) “이 값이 무엇인지”를 타입이 말하게 하고 싶었다

Long은 의미가 없다.

Long id = 1L;         // 이게 memberId인지 relationId인지 알 수 없다
Long parentId = 1L;   // 변수명에 의미를 맡긴다

반면 VO는 “타입 자체가 의미”가 된다.

MemberId parentId = MemberId.of(1L);
MemberId childId  = MemberId.of(2L);

이제 코드를 읽는 사람도, IDE도, 리뷰어도 “이 값은 Member의 식별자” 라는 사실을 즉시 이해한다.

도메인 모델에서 “의미 있는 값”은 Primitive(Long/String)에 묻히면 손해다.

2) 서로 다른 ID를 실수로 섞는 걸 컴파일 단계에서 막고 싶었다

Long을 쓰면 이런 실수가 가능하다.

Long relationId = 10L;
Long memberId = 10L;

familyRelation.changeStatus(memberId); // 타입이 같으니 실수해도 컴파일 OK

ID가 모두 Long이면, 실수는 런타임까지 숨어든다.

특히 프로젝트가 커질수록 이런 버그는 “재현 어렵고, 리뷰로도 놓치기 쉬운” 형태로 나타난다.

 

VO로 바꾸면 이런 실수는 애초에 컴파일이 안 된다.

RelationId relationId = RelationId.of(10L);
MemberId memberId = MemberId.of(10L);

// familyRelation.changeStatus(memberId); // 컴파일 에러: 타입이 다름

“ID 섞임” 같은 버그를 타입 시스템으로 제거하는 것

이게 VO 도입의 가장 강력한 실익 중 하나였다.


3) ID의 유효성 규칙을 “한 곳”에 모으고 싶었다

Long을 그대로 쓰면, 유효성 검증이 퍼진다.

  • 어느 곳은 id != null 체크
  • 어느 곳은 id > 0 체크
  • 어느 곳은 그냥 믿고 진행

결국 “도메인 규칙이 흩어져서 일관성”이 깨진다.

 

VO를 쓰면 규칙을 생성 시점에 강제할 수 있다.

@Embeddable
public class MemberId {

  @Column(name = "member_id", nullable = false)
  private Long value;

  protected MemberId() {}

  private MemberId(Long value) {
    if (value == null || value <= 0) {
      throw new IllegalArgumentException("memberId는 양수여야 합니다.");
    }
    this.value = value;
  }

  public static MemberId of(Long value) {
    return new MemberId(value);
  }

  public Long value() {
    return value;
  }
}

이제 도메인 전반에서 “유효하지 않은 MemberId는 존재할 수 없다” 가 된다.

규칙을 "사용 시점"이 아니라 "생성 시점"에 강제하면, 시스템 전체가 단단해진다.

4) DDD에서 말하는 “다른 애그리게이트는 ID로 참조한다”를 더 정확히 지키고 싶었다

 

이전 글에서 우리가 선택한 방향은:

  • FamilyRelation은 “관계”의 규칙을 가진 독립 엔티티
  • Member는 별도 애그리게이트
  • 둘은 객체 그래프가 아니라 식별자로 연결

여기서 “식별자”가 단순 Long이면, 의도는 맞지만 표현력이 약하다.

MemberId VO는 “애그리게이트 간 연결은 식별자로만 한다”는 원칙을 코드 차원에서 더 또렷하게 만든다.

 

즉,

  • 값 참조(ID) 라는 전략을 선택했고
  • VO(MemberId) 는 그 전략을 “도메인 언어로 완성”하는 단계였다

5) 장기적으로 확장 가능성을 열어두고 싶었다

 

지금은 MemberId = Long 기반일 수 있다.

하지만 시간이 지나면 이런 요구가 생길 수 있다.

  • Snowflake / UUIDv7 같은 다른 키 전략으로 전환
  • 외부 연동으로 문자열 기반 식별자 도입
  • 샤딩/멀티테넌시로 tenantId + memberId 같은 복합 식별자 필요

Long이 도메인 전역에 퍼져 있으면, 전환 비용이 매우 크다.

반면 MemberId VO는 “외부 노출면”을 줄여준다.

  • 내부 구현이 바뀌어도,
  • 도메인 로직의 대부분은 MemberId 타입을 그대로 쓰면 된다.
VO는 단순 “예쁜 래퍼”가 아니라, 미래 변화의 충격을 흡수하는 경계다.

적용 예시: FamilyRelation에 VO를 도입하면 무엇이 좋아지나

변경 전 (Long)

private Long parentId;
private Long childId;

변경 후 (MemberId VO)

@AttributeOverride(name = "value", column = @Column(name = "parent_id", nullable = false))
private MemberId parentId;

@AttributeOverride(name = "value", column = @Column(name = "child_id", nullable = false))
private MemberId childId;

이제 FamilyRelation의 생성/검증/상태전이 로직에서 “ID 의미”가 훨씬 또렷해지고,

도메인 코드가 Long의 허술함(null/0/음수/섞임)으로부터 안전해진다.


트레이드오프도 있다

VO는 항상 “좋기만 한 선택”은 아니다. 우리가 체감한 비용도 있다.

  • 매핑 코드가 늘어난다 (@Embeddable, @AttributeOverride)
  • 조회 전용 쿼리/DTO 프로젝션에서 값을 꺼내는 과정이 생긴다 (memberId.value())
  • 팀원들이 VO 패턴에 익숙해지는 시간이 필요하다

하지만 ONECO에서는 이 비용보다,

  • 도메인 의미를 타입으로 고정하는 이득
  • 실수를 컴파일 단계에서 제거하는 이득
  • 규칙을 한 곳에 모으는 이득

이 훨씬 크다고 판단했다.


결론: 값 참조를 “도메인 언어”로 완성하는 마지막 한 걸음

정리하면 ONECO에서의 흐름은 이렇게 이어졌다.

  1. 관계가 도메인이기 때문에 @ManyToOne 대신 값 참조(ID) 를 선택했다
  2. 값 참조의 ID가 Long이면 의미/안전성이 약해서
  3. MemberId VO를 도입해 도메인 의미 + 규칙 + 타입 안정성을 얻었다

즉, MemberId는 단순한 래퍼가 아니라

  • 도메인의 언어를 코드에 새기는 장치이자
  • 실수 가능성을 줄이는 안전장치이며
  • 변화의 충격을 흡수하는 경계다.

ONECO에서 “값 참조”는 성능/경계의 선택이었고,

“MemberId VO”는 그 선택을 DDD답게 마무리하는 선택이었다.

'Tech Note' 카테고리의 다른 글

[ONECO] AI를 활용한 PR 문서 작성 자동화 파이프라인 구축기  (0) 2026.01.10
[ONECO] Family 도메인 설계 일지  (0) 2025.12.19
[ONECO] Family 도메인에서 FamilyRelation 매핑 고민기  (0) 2025.12.16
'Tech Note' 카테고리의 다른 글
  • [ONECO] AI를 활용한 PR 문서 작성 자동화 파이프라인 구축기
  • [ONECO] Family 도메인 설계 일지
  • [ONECO] Family 도메인에서 FamilyRelation 매핑 고민기
goodjunseon
goodjunseon
  • goodjunseon
    The Dev/Arch Archives
    goodjunseon
  • 전체
    오늘
    어제
    • 분류 전체보기 (13)
      • Retrospective (1)
      • Tech Note (7)
        • Architecture (2)
        • Trouble Shooting (1)
      • Tech Insight (0)
      • Algorithm (5)
      • About me (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    spring
    기술블로그
    코딩테스트
    Tech Note
    백준
    Algorithm
    ONECO
    도메인주도개발
    알고리즘
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
goodjunseon
[ONECO] "값참조"를 더 도메인 답게 만들기
상단으로

티스토리툴바