ManyToOne vs 값 참조(ID) — 결국 우리는 “값 참조”를 선택했다
ONECO는 도메인 주도 개발(DDD)을 지향하며 진행 중인 프로젝트다.
Family 도메인을 구현하면서, “부모–자녀 계정 연결”이라는 요구사항을 표현하기 위해 FamilyRelation 엔티티를 만들었고,
여기서 아주 흔하지만 꽤 중요한 설계 갈림길을 만났다.
FamilyRelation이 Member를 어떻게 참조해야 할까?
JPA 연관관계(@ManyToOne)로 객체 그래프를 만들 것인가 아니면 parentId, childId 같은 식별자 값만 들고 갈 것인가
이번 글은 그 고민 과정과 각 방식의 장단점, 그리고 ONECO에서 최종적으로 “값 참조”를 도입한 이유를 정리한 기록이다.
배경: FamilyRelation은 “연결” 자체가 도메인이다
FamilyRelation은 단순한 조인 테이블이 아니라, 다음과 같은 도메인 정보를 품을 가능성이 높다.
- 연결 상태(PENDING / APPROVED / REJECTED / DISCONNECTED 등)
- 유효 기간(validFrom/validTo)
- 누가 초대했는지, 연결 이력, 정책(재연결 제한) 등
즉, “관계”는 의미 있는 엔티티이며, 단순히 Member의 연관 컬렉션으로 종속되기보다 독립적으로 다뤄질 여지가 큰 도메인이다.
선택지 1: @ManyToOne으로 객체 참조하기
처음 떠올리기 쉬운 방식은 다음처럼 Member를 엔티티 연관관계로 직접 들고 있는 형태다.
@Entity
@Table(name = "family_relation")
public class FamilyRelation {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id", nullable = false)
private Member parent;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "child_id", nullable = false)
private Member child;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RelationStatus status;
}
장점
- 개발 속도가 빠르다: relation.getParent().getName() 처럼 바로 접근 가능
- 조회 시 JPQL/fetch join으로 한 번에 필요한 데이터를 가져오기 편하다
- DB 레벨에서 FK 무결성을 강제하기 자연스럽다(DDL로 FK 추가)
단점
하지만 DDD + JPA 실무에서는 이 방식이 “편한 만큼 자주 비싸지는” 포인트가 있다.
- 애그리게이트 경계가 흐려진다그러면 FamilyRelation에서 상태 전이 규칙을 명확히 세우기보다, Member를 로딩해 무언가를 하게 되는 구조가 되기 쉽다.
- FamilyRelation은 “관계”인데, 모델이 Member 객체를 품고 있으면 도메인 사고가 쉽게 “Member 중심 객체 그래프”로 흘러간다.
- 지연 로딩(LAZY)로 인한 예측 불가능성
- 의도치 않게 프록시 초기화가 발생하거나
- N+1 문제가 생기고
- 트랜잭션 범위를 벗어난 접근으로 예외가 터지고
- DTO 변환/직렬화 과정에서 연쇄 로딩이 발생하기도 한다
- 삭제/탈퇴 정책에 제약이 생긴다(soft delete를 강제하게 되거나, 관계 기록 보존 정책과 충돌하기도 함)
- Member 탈퇴를 hard delete로 가져가거나, 기록을 남겨야 하는 요구가 붙으면 FK와 연관관계가 복잡해진다.
- 도메인 모델이 “JPA 편의성”에 끌려갈 위험
- DDD에서는 도메인 모델이 중심이어야 하는데, 연관관계 중심 설계를 하면 “ORM이 편한 구조”가 “도메인이 원하는 구조”를 이길 때가 있다.
선택지 2: 값 참조(ID)로만 들고 가기
두 번째 선택지는 Member 객체를 직접 들지 않고, 식별자 값만 보관하는 방식이다.
@Entity
@Table(name = "family_relation")
public class FamilyRelation {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parent_id", nullable = false)
private Long parentId;
@Column(name = "child_id", nullable = false)
private Long childId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private RelationStatus status;
}
(ONECO는 DDD를 지향하므로 여기서 한 단계 더 나아가 MemberId 같은 VO로 감싸는 선택도 가능하다.)
장점
- 애그리게이트 경계를 지키기 쉽다“다른 애그리게이트는 ID로 참조한다”는 DDD 원칙에 잘 맞는다.
- FamilyRelation은 “연결 상태/규칙”을 책임지고, Member는 Member대로 책임을 가진다.
- 조회 비용이 예측 가능해진다즉, 성능/쿼리/트랜잭션 경계가 더 명확해진다.
- 필요할 때만 Member를 조회한다.
- JPA 특유의 함정(LAZY, N+1, 직렬화, 프록시)을 줄인다
- 엔티티 그래프가 커지는 것을 방지하고, “관계” 자체를 독립적으로 다루기 쉬워진다.
- 회원 탈퇴/보존 정책이 유연해진다
- 관계 기록을 남기고 싶을 때도, Member의 삭제 정책과 덜 얽힌다.
단점
- Member의 정보가 필요하면 결국 추가 조회가 필요하다
- “DB 무결성”을 ORM이 알아서 잡아주진 않는다
- → 대신, DB FK 제약을 직접 걸면 된다 (중요)
흔한 오해: “값 참조면 FK를 못 건다” 전혀 아니다.
parent_id, child_id 컬럼에 FK를 걸어 무결성은 DB가 지키게 하면 된다.
ONECO의 결론: 우리는 “값 참조(ID)”를 선택했다
우리 프로젝트에서 FamilyRelation은 단순 조회 편의보다, 다음 가치가 더 중요했다.
1) 도메인 모델을 “관계 중심”으로 세우고 싶었다
FamilyRelation에는 상태 전이, 유효 기간, 이력 등 정책이 붙을 가능성이 크다.
이때 객체 그래프로 Member를 품는 순간, 도메인 로직이 Member에 기대거나 서비스 계층에서 객체를 조립하는 방향으로 흐르기 쉽다.
→ 우리는 FamilyRelation이 자기 규칙을 가진 독립 엔티티로 서길 원했다.
2) 애그리게이트 경계를 명확히 유지하고 싶었다
Member는 Member 애그리게이트다.
FamilyRelation이 Member 객체를 직접 들고 있으면, 설계/코드 리뷰 단계부터 “어디까지 한 트랜잭션에서 묶어야 하지?” 같은 애매함이 생겼다.
→ ID 참조로 경계를 분리하니, “관계는 관계만 다룬다”가 코드로 강제된다.
3) 조회 비용과 장애 포인트를 예측 가능하게 만들고 싶었다
LAZY 로딩은 편하지만, 프로젝트가 커질수록 “어디서 Member가 로딩되는지”가 디버깅 포인트가 된다.
특히 API 응답 DTO 변환 과정에서의 연쇄 로딩/N+1은 팀 생산성을 크게 떨어뜨린다.
→ 값 참조로 가면 “Member가 필요할 때만 조회한다”는 규칙이 자연스럽게 생긴다.
구현 방향: 값 참조 + 조회는 전용 쿼리로
값 참조를 선택하면, 화면/API에서 Member 정보가 필요할 때는 보통 다음 중 하나로 해결한다.
- 조회 전용 쿼리(Join)로 DTO를 바로 조회
- FamilyRelation 조회 후 MemberRepository로 필요한 멤버를 배치 조회
예: 목록 화면이 parent/child 이름까지 필요하다면, 애초에 조회 전용 쿼리를 분리한다.
public interface FamilyRelationQueryRepository {
List<FamilyRelationView> findRelationsWithMemberInfo(Long memberId);
}
즉, “도메인 모델은 도메인 규칙에 집중”하고, “표현/조회는 조회 모델에서 해결”하는 쪽으로 구조가 정리된다.
덤: unique=true는 정말 조심해야 한다
처음 연관관계로 매핑할 때 흔히 하는 실수가:
@JoinColumn(name = "parent_id", unique = true)
@JoinColumn(name = "child_id", unique = true)
이건 각각을 단독 유니크로 만들어버려서,
- parent는 하나의 relation만 가능
- child도 하나의 relation만 가능
요구사항이 “부모 1명 – 자녀 여러명”이라면 parent 쪽 unique는 바로 장애가 된다.
대부분은 UNIQUE(parent_id, child_id) 같은 복합 유니크가 의도에 더 가깝다.
마무리
ManyToOne은 빠르고 편하다.
하지만 DDD 관점에서 “관계가 독립적인 도메인”이 되는 순간, 그 편리함이 모델의 방향을 바꿔버릴 수 있다.
ONECO에서는 FamilyRelation의 성격(상태/정책/확장 가능성), 애그리게이트 경계, 조회 비용의 예측 가능성을 우선했고,
그 결과 값 참조(ID)를 선택했다.
'Tech Note' 카테고리의 다른 글
| [ONECO] AI를 활용한 PR 문서 작성 자동화 파이프라인 구축기 (0) | 2026.01.10 |
|---|---|
| [ONECO] Family 도메인 설계 일지 (0) | 2025.12.19 |
| [ONECO] "값참조"를 더 도메인 답게 만들기 (0) | 2025.12.17 |
