FamilyRelation / FamilyInvitation을 만들며 “도메인 규칙을 어디에 둘 것인가”를 정리한 기록이다.
ONECO는 도메인 주도 개발(DDD)을 지향한다. Family 도메인을 구현하면서 가장 먼저 부딪힌 질문은 단순했다.
- “부모–자녀 관계는 단순 조인 테이블일까, 아니면 ‘연결’ 자체가 도메인일까?”
- “JPA @ManyToOne으로 멤버를 물고 갈까, 아니면 ID 값 참조로 갈까?”
- “연결 해제는 삭제인가, 상태 변화인가?”
- “초대 코드는 어떻게 만들고, 만료는 어떻게 처리할까?”
이번 글은 위 질문들에 대해 팀(우리)이 내린 결론과, 그 결론이 코드에 어떻게 반영됐는지 정리한 설계 일지다.
1) FamilyRelation은 “관계”가 아니라 “연결”이다
Family 도메인은 단순히 member_id 두 개를 이어붙이는 문제가 아니었다.
- 연결 상태(connected/disconnected)
- 연결 해제 권한(부모/자녀만 가능)
- 연결의 생명주기(생성 → 해제)
- 추후 확장(연결 이력, 재연결, 해제 사유 등)
즉 FamilyRelation은 단순 조인 테이블이 아니라 상태와 규칙을 가진 도메인 엔티티로 보는 게 자연스러웠다.
그래서 FamilyRelation은 다음 속성을 가진다.
- parentId, childId (값 참조)
- status (CONNECTED, DISCONNECTED)
- 유니크 제약: (parent_id, child_id) 중복 연결 방지
@Table(
name = "family_relation",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_family_relation_parent_child",
columnNames = {"parent_id", "child_id"}
)
}
)
2) ManyToOne vs 값 참조(ID) — 우리는 값 참조를 선택했다
가장 큰 갈림길은 이거였다.
옵션 A) @ManyToOne으로 Member 엔티티 참조
- 장점: 객체 그래프 접근 편함, join/fetch가 직관적
- 단점: 애그리게이트 경계가 흐려지고, LAZY/N+1/직렬화 문제로 운영 난이도가 올라갈 수 있음
옵션 B) MemberId 값 참조로만 저장
- 장점: 애그리게이트 경계가 명확해지고, 조회 비용이 예측 가능해짐
- 단점: 조회가 필요하면 별도 쿼리(Join DTO/QueryRepository)로 해결해야 함
ONECO는 DDD를 지향하며 “연결”이 독립적인 도메인으로 성장할 가능성이 컸다.
그래서 FamilyRelation이 Member 객체를 직접 들고 있기보다 MemberId 값 참조로 경계를 유지하는 쪽이 더 일관된 선택이었다.
현재 최종 코드에서도 parentId, childId는 값 객체로 들고 간다.
@Column(name = "parent_id", nullable = false)
private MemberId parentId;
@Column(name = "child_id", nullable = false)
private MemberId childId;
(프로젝트 전반에서 MemberId는 값 객체로 쓰고 있고, 저장 방식은 컨버터/매핑 정책에 따라 결정된다.)
3) “삭제” 대신 “상태 전이”로 소프트 딜리트 설계
연결 해제는 테이블에서 row를 지우는 삭제라기보다 도메인 이벤트에 가깝다.
그래서 우리는 soft delete(논리 삭제) 를 “status 변경”으로 표현했다.
- 생성은 항상 CONNECTED로만
- 해제는 DISCONNECTED로 상태 전이
이 규칙을 강제하기 위해 생성자는 private + 정적 팩토리를 사용한다.
private FamilyRelation(MemberId parentId, MemberId childId) {
requireNonNull(parentId, "부모 ID가 null 입니다.");
requireNonNull(childId, "자녀 ID가 null 입니다.");
if (parentId.equals(childId)) {
throw BaseException.from(FamilyErrorCode.FAMILY_RELATION_INVALID_SAME_MEMBER);
}
this.parentId = parentId;
this.childId = childId;
this.status = RelationStatus.CONNECTED;
}
public static FamilyRelation connect(MemberId parentId, MemberId childId) {
return new FamilyRelation(parentId, childId);
}
4) disconnect는 “static이 아니라 인스턴스 행위”다
개발중 고민했던 중요한 내용:
“disconnect는 도메인 계층인데, 서비스에서 호출하려면 static이 필요하지 않아?”
결론은 필요 없다.
- static은 “객체 없이 호출”할 때 의미가 있고
- disconnect는 “이미 존재하는 관계 엔티티의 상태를 변경”하는 행위다
- 따라서 relation.disconnect(actor) 처럼 인스턴스 메서드가 맞다
서비스 계층은 보통 이렇게 한다:
- repository로 FamilyRelation 조회
- 엔티티 행위 호출(disconnect)
- 트랜잭션 내 dirty checking으로 저장
이때 핵심은 “규칙은 엔티티 안에” 두는 것이다.
5) 도메인 엔티티가 스스로를 보호하도록 만들기
disconnect에는 최소 3개의 도메인 규칙이 붙는다.
- actor는 null이면 안 된다
- actor는 parent/child 중 하나여야 한다
- 이미 DISCONNECTED면 안 된다
최종 코드에서 이 규칙은 엔티티 내부에서 검증한다.
즉 “서비스가 검증하고 엔티티는 setter만 한다”가 아니라, 엔티티가 스스로를 보호한다.
public void disconnect(MemberId actor) {
requireNonNull(actor, "가족연결 해제 요청자(actor)가 null 입니다.");
if (!this.parentId.equals(actor) && !this.childId.equals(actor)) {
throw BaseException.from(FamilyErrorCode.FAMILY_RELATION_DISCONNECT_FORBIDDEN);
}
if (this.status == RelationStatus.DISCONNECTED) {
throw BaseException.from(FamilyErrorCode.FAMILY_RELATION_ALREADY_DISCONNECTED);
}
this.status = RelationStatus.DISCONNECTED;
}
여기서 requireNonNull을 엔티티 내부에 둔 이유도 명확하다.
- 도메인 엔티티는 유효하지 않은 상태로 바뀌지 않도록 스스로 방어해야 한다
- (실무에서 서비스 계층 검증이 누락되는 사고를 줄일 수 있다)
6) FamilyInvitation: MySQL Entity에서 Redis 기반으로 전환한 이유
초대권은 처음에는 다음과 같은 테이블/엔티티 모델이 자연스러워 보였다.
- inviter(초대자)
- accepter(수락자, 초기 null)
- inviteCode(or hash)
- status(ACTIVE/USED/EXPIRED)
- expiresAt(24시간)
- acceptedAt(초기 null)
이 방식의 장점은 명확했다.
- 이력/감사(누가 언제 생성했고, 누가 수락했고, 왜 실패했는지)가 DB에 남는다
- “만료/수락” 상태를 도메인 상태 전이로 모델링하기 쉽다
- 운영/CS 대응이 편하다
하지만 구현을 진행하며 “초대권”의 본질을 다시 보게 됐다.
24시간 짜리 토큰에 가깝다.
초대권은 영속적으로 관리해야 하는 비즈니스 객체라기보다,
- “일정 시간(24h) 뒤 자동으로 사라져야 하고”
- “수락되면 즉시 무효화되어야 하며”
- “재발급이 아니라 새 초대권을 만든다”
- “링크 파라미터로 전달되는 단기 자격 증명”에 가깝다
즉, 만료/정리(purge) 가 기능 요구사항의 핵심이었다.
MySQL에 저장하면 다음 부담이 생긴다.
- 만료된 초대권을 어떻게 처리할지(상태 업데이트? 배치? 삭제 정책?)
- 테이블이 계속 쌓이며 관리 비용이 증가
- 만료 처리를 “시간 기반 스케줄링”으로 운영해야 함
이 지점에서 Redis가 자연스럽게 후보로 올라왔다.
또한 우리 팀은 인증/인가 도메인에서 Redis를 이미 도입한 상태였기에, Redis를 도입하는게 큰 부담은 아니었다.
Redis를 쓰면 초대권의 핵심 규칙인 “24시간 만료”가 저장소 특성으로 해결된다.
- 초대권 생성 시: SET key value EX 86400 (TTL 24h)
- 만료 시점: Redis가 자동 삭제
- 재발급 정책: “새 초대권 생성”과 구조적으로 잘 맞는다(이전 키는 만료되어 사라짐)
추가로 얻는 장점:
- DB 테이블이 불필요하게 커지지 않는다
- 만료 배치 작업이 단순해지거나 아예 필요 없어질 수 있다
- 조회 성능이 빠르고, 초대권은 조회 패턴이 단순(키 기반)하다
물론 트레이드오프도 있다.
- DB처럼 풍부한 감사 로그/이력 조회는 약해진다
- Redis 장애/유실에 대한 운영 고려(필요하면 재생성/재시도 정책)
- 보안 측면에서 무차별 대입 방지(레이트 리밋)는 여전히 필요
ONECO는 “초대권 이력 화면” 같은 요구가 현재 단계에서 강하지 않았고, 초대권은 본질적으로 단기 데이터였기에 Redis 전환이 더 적합하다고 판단했다.
7) (정리 포인트) ID 값 객체 도입 실험의 마무리
현재 코드에는 FamilyRelationId 값 객체와 컨버터가 있다.
@Embeddable
public class FamilyRelationId implements Serializable {
@Column(name = "family_relation_id", nullable = false)
private Long value;
...
}
@Converter(autoApply = true)
public class FamilyRelationIdConverter
implements AttributeConverter<FamilyRelationId, Long> { ... }
그리고 repository는 JpaRepository<FamilyRelation, FamilyRelationId>로 선언되어 있다.
다만 엔티티 PK는 현재 Long id로 되어 있으므로, 여기에는 정리가 필요하다.
- 선택지 A: repository의 ID 타입을 Long으로 맞춘다(가장 현실적/단순)
- 선택지 B: 엔티티 PK를 FamilyRelationId로 올리고(@EmbeddedId 등) 진짜 VO PK로 간다(변경 폭 큼)
이번 단계에서는 도메인 모델의 핵심(연결 규칙/값 참조/초대 만료)을 먼저 안정화했고, ID VO는 다음 리팩토링 후보로 남겨두는 쪽이 합리적이다.
연관 블로그 게시글
[ONECO] Family 도메인에서 FamilyRelation 매핑 고민기
ManyToOne vs 값 참조(ID) — 결국 우리는 “값 참조”를 선택했다 ONECO는 도메인 주도 개발(DDD)을 지향하며 진행 중인 프로젝트다. Family 도메인을 구현하면서, “부모–자녀 계정 연결”이라는 요구
goodjunseon-tech-blog.tistory.com
[ONECO] "값참조"를 더 도메인 답게 만들기
Long vs MemberId(VO) — 결국 우리는 MemberId(VO)를 선택했다. 이전 글에서 ONECO는 FamilyRelation이 Member를 @ManyToOne으로 들고 가지 않고, parentId / childId 같은 값 참조(ID) 를 선택했다고 정리했다. [ONECO] Family
goodjunseon-tech-blog.tistory.com
'Tech Note' 카테고리의 다른 글
| [ONECO] AI를 활용한 PR 문서 작성 자동화 파이프라인 구축기 (0) | 2026.01.10 |
|---|---|
| [ONECO] "값참조"를 더 도메인 답게 만들기 (0) | 2025.12.17 |
| [ONECO] Family 도메인에서 FamilyRelation 매핑 고민기 (0) | 2025.12.16 |
