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에서의 흐름은 이렇게 이어졌다.
- 관계가 도메인이기 때문에 @ManyToOne 대신 값 참조(ID) 를 선택했다
- 값 참조의 ID가 Long이면 의미/안전성이 약해서
- 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 |
