본문으로 건너뛰기
· 백엔드 · 3분 읽기

entgo 3편. Edges로 1:1, 1:N, N:M 관계를 설계하는 법

entgo 3편. Edges로 1:1, 1:N, N:M 관계를 설계하는 법

2편에서는 Optional과 Nillable을 중심으로 필드 설계를 봤다. 필드가 엔티티 하나의 형태를 정하는 작업이었다면, 이번에는 엔티티끼리 어떻게 연결할지를 본다. entgo에서는 이 연결을 edge로 표현한다.

관계형 데이터베이스를 써본 사람이라면 foreign key부터 떠올리기 쉽다. entgo도 결국 같은 관계를 다루지만, 출발점은 조금 다르다. 먼저 edge를 정의하고, 그 정의에서 조회 API와 관계 제약이 같이 생성된다. 그래서 entgo의 관계 설계는 테이블 연결을 나중에 덧붙이는 작업보다, 스키마 모델링의 일부에 더 가깝다.

entgo에서 관계를 읽는 핵심 문법

관계 설계에서 먼저 익혀둘 건 세 가지다.

  • edge.To: 관계를 소유하는 쪽
  • edge.From: 이미 정의된 관계를 반대쪽에서 참조하는 쪽
  • Unique(): cardinality를 바꾸는 옵션

예를 들어 사용자가 여러 대의 차를 가질 수 있다고 해보자.

go
func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("cars", Car.Type),
	}
}

func (Car) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("owner", User.Type).
			Ref("cars").
			Unique(),
	}
}

여기서 carsUser가 소유하는 관계고, ownerCar가 그 관계를 거꾸로 읽기 위한 back-reference다. entgo 문서가 ownerinverse를 따로 설명하는 이유도 여기 있다. 데이터베이스에 관계를 두 번 만드는 게 아니라, 하나의 관계를 양쪽에서 읽을 수 있게 모델링하는 것이다.

edge.To와 edge.From을 어떻게 나눠 생각할까

처음에는 ToFrom이 헷갈리기 쉽다. 이때는 “어느 쪽이 관계를 먼저 정의하느냐”로 보면 편하다.

  • edge.To("pets", Pet.Type): User가 pets 관계를 정의한다.
  • edge.From("owner", User.Type).Ref("pets"): Pet은 User의 pets 관계를 owner라는 이름으로 참조한다.

edge.From은 독립적인 새 관계를 만드는 문법이라기보다, 기존 관계에 반대 방향 이름을 붙이는 쪽에 가깝다. 그래서 Ref()가 필요하다. 참조 대상이 되는 edge 이름을 명시해야 하기 때문이다.

이 구조를 이해하면 generated API도 자연스럽다.

  • user.QueryPets()
  • pet.QueryOwner()

관계를 먼저 정의했기 때문에, 이후 조회 메서드도 그 관계 이름을 따라간다.

Unique가 cardinality를 결정한다

entgo에서 1:1, 1:N, N:M은 문장으로 선언하는 게 아니라 edge 조합과 Unique()로 결정된다.

1:1 관계

사용자와 카드처럼 양쪽이 하나씩만 연결되는 경우다.

go
func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("card", Card.Type).
			Unique(),
	}
}

func (Card) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("owner", User.Type).
			Ref("card").
			Unique(),
	}
}

양쪽에 Unique()가 붙으면 User는 카드 하나, Card는 소유자 하나를 가진다. 이 경우 조회 API도 All()보다 Only()가 더 자연스럽다.

1:N 관계

사용자와 게시글, 사용자와 반려동물처럼 한쪽은 여러 개, 반대쪽은 하나인 경우다.

go
func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("pets", Pet.Type),
	}
}

func (Pet) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("owner", User.Type).
			Ref("pets").
			Unique(),
	}
}

여기서는 Pet 쪽만 Unique()가 붙는다. 즉 각 pet은 owner 하나만 가지지만, user는 여러 pet을 가질 수 있다.

N:M 관계

사용자와 그룹처럼 양쪽 모두 여러 개가 연결될 수 있는 경우다.

go
func (Group) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("users", User.Type),
	}
}

func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("groups", Group.Type).
			Ref("users"),
	}
}

여기에는 Unique()가 없다. 그래서 한 사용자는 여러 그룹에 속할 수 있고, 한 그룹도 여러 사용자를 가질 수 있다.

정리하면 단순하다.

  • 한쪽만 하나여야 하면 그쪽에 Unique()가 붙는다.
  • 양쪽 다 하나면 둘 다 Unique()다.
  • 양쪽 다 여러 개면 Unique() 없이 간다.

back-reference를 왜 꼭 같이 생각해야 할까

관계를 선언할 때 초반에는 edge.To만 먼저 쓰고 끝내고 싶어진다. 하지만 실무에서는 거의 항상 반대 방향 조회가 필요하다.

예를 들어 Pet을 조회한 뒤 소유자를 알아야 할 수 있고, Group을 조회한 뒤 사용자 목록을 가져와야 할 수 있다. 이때 back-reference가 없으면 스키마 모델링과 실제 조회 흐름이 어긋난다.

그래서 entgo에서는 관계를 설계할 때 “반대 방향에서 이 관계를 읽어야 하는가”를 같이 생각하는 편이 좋다. 대부분의 경우 답은 그렇다.

back-reference를 붙여두면 generated API도 더 일관되게 나온다.

  • group.QueryUsers()
  • user.QueryGroups()
  • pet.QueryOwner()
  • owner.QueryPets()

관계 모델링이 조회 API 설계까지 같이 결정한다는 뜻이다.

self-reference도 같은 원리로 읽으면 된다

같은 타입끼리 연결하는 self-reference도 예외는 아니다. 예를 들어 트리 구조라면 부모와 자식 관계를 같은 엔티티 안에서 정의할 수 있다.

go
func (Node) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("children", Node.Type).
			From("parent").
			Unique(),
	}
}

읽는 방법은 같다.

  • children은 한 노드가 여러 자식을 가진다는 뜻이다.
  • parent는 각 노드가 부모 하나를 가진다는 back-reference다.
  • Unique()가 parent 쪽 cardinality를 제한한다.

즉 self-reference라고 해서 새로운 개념이 필요한 건 아니다. 같은 패턴이 같은 타입에 적용될 뿐이다.

foreign key를 필드로 드러내고 싶을 때는 Edge Field를 쓴다

관계만 선언해도 대부분의 작업은 가능하다. 그래도 실무에서는 foreign key 값을 직접 보고 싶을 때가 있다. 디버깅, 단순 필터링, 외부 시스템 연동 때문에 owner_id 같은 값을 필드로 노출해야 하는 경우다.

이럴 때 edge field를 쓴다.

go
func (Pet) Fields() []ent.Field {
	return []ent.Field{
		field.Int("owner_id").Optional(),
	}
}

func (Pet) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("owner", User.Type).
			Ref("pets").
			Unique().
			Field("owner_id"),
	}
}

이렇게 하면 관계는 relation대로 유지하면서 foreign key를 명시적으로 다룰 수 있다. 모든 관계에 edge field가 필요한 건 아니지만, id 기반 조건이나 응답 구조가 중요한 곳에서는 꽤 유용하다.

Required와 Immutable은 관계 제약까지 스키마에 올리는 옵션이다

필드에서 Immutable()을 봤듯이, edge에도 제약을 스키마에 올릴 수 있다.

Required()

관계가 반드시 있어야 하는 경우다. 예를 들어 모든 결제는 주문 하나에 반드시 속해야 한다면 이런 제약을 edge 레벨에서 표현할 수 있다.

go
edge.From("order", Order.Type).
	Ref("payments").
	Unique().
	Required()

이 옵션을 두면 관계가 없는 생성이 허용되지 않는다. 서비스 레이어에서 따로 검증하는 대신, 스키마 수준에서 의도를 먼저 고정하는 방식이다.

Immutable()

생성 후 관계 변경을 막고 싶을 때 쓴다.

go
edge.From("creator", User.Type).
	Ref("posts").
	Unique().
	Immutable()

이 경우 생성 이후에는 creator를 바꾸는 setter가 나오지 않는다. 작성자, 최초 소유자처럼 변경되면 안 되는 관계에 맞다.

관계에도 이런 제약이 들어간다는 점이 entgo다운 부분이다. 단순히 join 관계만 정의하는 게 아니라, 관계의 수명주기까지 스키마에서 표현하게 만든다.

처음 edge를 설계할 때 자주 하는 실수

입문 단계에서는 비슷한 실수가 반복된다.

첫째, edge.Toedge.From을 서로 대칭 문법으로 오해하는 경우다. 실제로는 소유 관계와 참조 관계의 차이가 있다.

둘째, Unique()를 어디에 붙여야 하는지 헷갈려서 cardinality를 반대로 만드는 경우다. 이때는 “이 엔티티가 몇 개를 가질 수 있는가”를 문장으로 먼저 적고 스키마로 옮기는 편이 낫다.

셋째, back-reference를 빼고 시작했다가 나중에 조회 API가 불편해지는 경우다. 처음부터 양방향 탐색이 필요한지 같이 보는 편이 낫다.

넷째, 모든 foreign key를 필드로 드러내려는 경우다. edge field는 필요할 때만 쓰면 된다. 기본은 관계 중심으로 두고, 명시적 id 접근이 필요할 때만 꺼내는 편이 단순하다.

다음 편으로 넘어가기 전에

3편에서 핵심은 edge 문법 자체보다 관계를 어떤 기준으로 읽을지다. edge.To는 관계를 정의하는 쪽이고, edge.From은 그 관계를 반대 방향에서 읽는 쪽이다. 그리고 1:1, 1:N, N:M은 Unique()가 사실상 결정한다.

이 기준이 잡히면 다음 단계는 자연스럽다. 관계를 만들었으면, 이제 그 관계를 따라 어떻게 조회하고 조건을 조합할지 봐야 한다. 다음 편은 CRUD보다 Predicate와 Traversal 감각에 더 가깝다.

참고 자료

  • entgo Getting Started: User, Car, Group 예제로 relation과 traversal 흐름을 보여주는 공식 입문 문서.
  • entgo Edges: edge.To, edge.From, 관계 유형, edge field, required, immutable을 설명하는 공식 문서.
공유하기 X LinkedIn

관련 글