[ONECO] Family 도메인 설계 일지

2025. 12. 19. 15:27·Tech Note

 

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) 처럼 인스턴스 메서드가 맞다

서비스 계층은 보통 이렇게 한다:

  1. repository로 FamilyRelation 조회
  2. 엔티티 행위 호출(disconnect)
  3. 트랜잭션 내 dirty checking으로 저장

이때 핵심은 “규칙은 엔티티 안에” 두는 것이다.

 

5) 도메인 엔티티가 스스로를 보호하도록 만들기

disconnect에는 최소 3개의 도메인 규칙이 붙는다.

  1. actor는 null이면 안 된다
  2. actor는 parent/child 중 하나여야 한다
  3. 이미 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
'Tech Note' 카테고리의 다른 글
  • [ONECO] AI를 활용한 PR 문서 작성 자동화 파이프라인 구축기
  • [ONECO] "값참조"를 더 도메인 답게 만들기
  • [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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
goodjunseon
[ONECO] Family 도메인 설계 일지
상단으로

티스토리툴바