entgo 7편. Mixin으로 반복 필드를 줄이되 도메인을 흐리지 않기
- entgo 시작하기: ORM보다 스키마 중심 그래프 모델링으로 보기
- entgo 2편. Optional과 Nillable을 헷갈리지 않는 필드 설계
- entgo 3편. Edges로 1:1, 1:N, N:M 관계를 설계하는 법
- entgo 4편. Predicate와 Traversal로 조회 흐름을 설계하는 법
- entgo 5편. Unique, Index, Constraint로 데이터 규칙을 코드에 남기는 법
- entgo 6편. Hooks와 Interceptors로 모델 변경 흐름 제어하기
- entgo 7편. Mixin으로 반복 필드를 줄이되 도메인을 흐리지 않기 현재 · 7/7
스키마가 몇 개 없을 때는 반복 필드가 크게 거슬리지 않는다. created_at, updated_at 정도는 각 스키마에 직접 적어도 부담이 작다. 하지만 엔티티가 늘어나면 같은 필드 정의가 여러 파일에 흩어진다. soft delete, tenant id, audit field까지 붙기 시작하면 반복은 더 빨리 커진다.
entgo의 Mixin은 이런 반복을 줄이는 데 적합하다. 공식 문서에서도 Mixin을 여러 스키마에 주입할 수 있는 재사용 가능한 ent.Schema 조각으로 설명한다. 필드뿐 아니라 엣지, 인덱스, 훅, 정책, 애노테이션도 Mixin으로 구성할 수 있다.
다만 Mixin을 단순한 중복 제거 장치로만 보면 스키마가 오히려 흐려진다. 모든 엔티티에 공통 필드를 무심코 붙이면 각 엔티티가 어떤 생명주기와 규칙을 갖는지 코드만 보고 판단하기 어려워진다. 이 글에서는 반복 필드를 Mixin으로 옮기되, 도메인 의미를 잃지 않는 기준을 정리한다.
가장 안전한 시작점은 시간 필드다
대부분의 서비스 엔티티는 생성 시각과 수정 시각을 가진다. entgo에서는 직접 Mixin을 만들 수도 있고, 내장 Mixin을 사용할 수도 있다.
직접 작성하면 다음처럼 구성할 수 있다.
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/mixin"
)
type TimeMixin struct {
mixin.Schema
}
func (TimeMixin) Fields() []ent.Field {
return []ent.Field{
field.Time("created_at").
Immutable().
Default(time.Now),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now),
}
}스키마에서는 Mixin() 메서드로 주입한다.
package schema
import "entgo.io/ent"
type User struct {
ent.Schema
}
func (User) Mixin() []ent.Mixin {
return []ent.Mixin{
TimeMixin{},
}
}이 정도의 Mixin은 부담이 작다. 생성 시각과 수정 시각은 대개 도메인 규칙이라기보다 레코드의 생명주기 메타데이터에 가깝다. 필드 정의가 여러 스키마에 반복되는 것보다 한 곳에서 관리하는 편이 낫다.
다만 이름은 팀의 컨벤션을 먼저 정해야 한다. entgo의 내장 mixin.Time은 create_time, update_time 필드를 제공한다. 이미 프로젝트에서 created_at, updated_at을 쓰고 있다면 직접 Mixin을 만드는 편이 자연스럽다. 반대로 새 프로젝트라면 내장 Mixin을 받아들이는 선택도 가능하다.
Mixin은 도메인 분류를 대신하지 않는다
문제는 여기서 한 걸음 더 나갔을 때 생긴다. 예를 들어 대부분의 테이블에 soft delete가 필요하다고 해서 BaseMixin 같은 이름으로 시간 필드, 삭제 필드, tenant id, 작성자 id를 한 번에 넣기 쉽다.
func (Post) Mixin() []ent.Mixin {
return []ent.Mixin{
BaseMixin{},
}
}처음에는 편하다. 하지만 시간이 지나면 BaseMixin이 무엇을 의미하는지 알기 어려워진다. 생성 시각을 갖는다는 뜻인지, tenant에 종속된다는 뜻인지, soft delete 대상이라는 뜻인지 코드에서 드러나지 않는다.
Mixin은 기술적 재사용 단위이면서 동시에 모델의 성격을 보여주는 이름이 된다. 그래서 다음처럼 역할을 나누는 편이 읽기 쉽다.
func (Post) Mixin() []ent.Mixin {
return []ent.Mixin{
TimeMixin{},
SoftDeleteMixin{},
TenantMixin{},
}
}줄 수는 조금 늘어난다. 대신 스키마를 열었을 때 Post가 시간 필드를 갖고, soft delete 대상이며, tenant 경계 안에 있다는 사실이 바로 보인다. 이 정보는 중복 제거보다 중요하다.
soft delete는 필드보다 정책에 가깝다
soft delete는 단순히 deleted_at 필드를 추가하는 문제로 끝나지 않는다. 삭제할 때 실제 delete를 update로 바꿔야 하고, 조회할 때 삭제된 레코드를 기본적으로 제외해야 한다. 복구나 관리자 조회처럼 삭제된 레코드를 포함해야 하는 경로도 필요하다.
entgo 공식 문서의 interceptor 예제도 soft delete를 Mixin으로 다룬다. 필드, 조회 필터, 삭제 훅이 함께 묶인다. 이 방향은 실무에서도 타당하다. soft delete는 필드가 아니라 엔티티의 생명주기 정책이기 때문이다.
간단히 줄이면 다음 형태가 된다.
package schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/mixin"
)
type SoftDeleteMixin struct {
mixin.Schema
}
func (SoftDeleteMixin) Fields() []ent.Field {
return []ent.Field{
field.Time("deleted_at").
Optional().
Nillable(),
}
}필드만 보면 쉬워 보인다. 하지만 여기까지만 구현하면 조회 쪽에서 실수가 반복된다.
posts, err := client.Post.Query().
Where(post.DeletedAtIsNil()).
All(ctx)모든 조회에 이 조건을 직접 붙이는 방식은 오래 버티기 어렵다. 누락된 조회 하나가 삭제된 데이터를 사용자에게 노출할 수 있다. soft delete를 Mixin으로 둔다면 조회 기본값과 삭제 동작도 같은 정책 안에서 다뤄야 한다.
다만 이 글에서 다루는 핵심은 구현 세부사항보다 기준이다. soft delete Mixin은 모든 엔티티에 자동으로 붙일 대상이 아니다. 삭제 이력이 필요한 엔티티와 실제 삭제가 맞는 엔티티를 구분해야 한다.
예를 들어 게시글, 댓글, 주문처럼 사용자 행동의 이력을 보존해야 하는 모델은 soft delete가 자연스럽다. 반대로 임시 토큰, 일회성 인증 코드, 처리 완료 후 의미가 사라지는 작업 큐 레코드는 실제 삭제가 더 단순할 수 있다.
Mixin은 이 판단을 숨기면 안 된다.
tenant id는 더 조심해야 한다
멀티테넌시를 지원하는 서비스라면 많은 엔티티가 tenant_id를 가진다. 이 필드도 Mixin 후보처럼 보인다.
type TenantMixin struct {
mixin.Schema
}
func (TenantMixin) Fields() []ent.Field {
return []ent.Field{
field.UUID("tenant_id", uuid.UUID{}),
}
}하지만 tenant id는 시간 필드보다 도메인 의미가 훨씬 강하다. 어떤 엔티티가 tenant에 속한다는 것은 접근 제어, 조회 범위, 유니크 제약, 데이터 이동 정책에 영향을 준다.
따라서 TenantMixin을 만들 때는 필드 하나만 넣고 끝내지 않는 편이 좋다. 최소한 인덱스나 유니크 제약이 어디에 필요한지 함께 검토해야 한다.
func (TenantMixin) Indexes() []ent.Index {
return []ent.Index{
index.Fields("tenant_id"),
}
}그리고 각 스키마의 고유 제약도 tenant 기준인지 전역 기준인지 분명히 해야 한다.
func (Project) Indexes() []ent.Index {
return []ent.Index{
index.Fields("tenant_id", "slug").
Unique(),
}
}slug가 전역으로 유일해야 하는지, tenant 안에서만 유일하면 되는지는 비즈니스 규칙이다. TenantMixin으로 tenant_id를 숨긴 뒤 각 스키마의 인덱스 판단까지 흐려지면 문제가 된다.
개인적으로는 tenant id를 Mixin으로 빼더라도, tenant 기준 유니크 제약은 각 스키마에 남기는 편을 선호한다. 유니크 제약은 해당 엔티티의 식별 규칙을 드러내는 부분이기 때문이다.
Mixin 이름은 기술보다 의미를 담아야 한다
CommonMixin, BaseMixin, DefaultMixin 같은 이름은 처음에는 편하지만 시간이 지날수록 비용이 커진다. 이름만 보고 어떤 필드와 정책이 들어오는지 알 수 없다. 더 나쁜 점은 새 공통 필드를 어디에 넣을지 고민할 때 BaseMixin이 기본 선택지가 된다는 것이다.
Mixin은 작게 나누고 이름을 구체적으로 붙이는 편이 낫다.
func (Invoice) Mixin() []ent.Mixin {
return []ent.Mixin{
TimeMixin{},
TenantMixin{},
AuditMixin{},
}
}이렇게 하면 스키마가 조금 장황해진다. 하지만 장황함이 항상 나쁜 것은 아니다. 도메인 규칙이 드러나는 장황함은 유지보수에 도움이 된다. 반대로 BaseMixin{} 한 줄로 모든 것을 감추면 짧지만 불친절한 코드가 된다.
기준은 단순하다. Mixin을 읽지 않아도 스키마의 성격을 어느 정도 짐작할 수 있어야 한다.
모든 반복이 Mixin 대상은 아니다
반복 필드가 보인다고 바로 Mixin으로 옮길 필요는 없다. 두세 스키마에 같은 필드가 있다고 해서 항상 같은 의미를 갖는 것은 아니다.
예를 들어 status 필드는 여러 엔티티에 등장할 수 있다. 주문의 상태, 결제의 상태, 게시글의 상태는 이름만 같을 뿐 의미가 다르다. 이런 필드를 공통 Mixin으로 묶으면 도메인을 흐릴 가능성이 높다.
// 피하는 편이 낫다.
type StatusMixin struct {
mixin.Schema
}반대로 같은 이름의 필드가 같은 규칙과 같은 생명주기를 갖는다면 Mixin 후보가 된다. created_at, updated_at은 보통 여기에 해당한다. soft delete도 정책이 일관되게 적용되는 모델 그룹이라면 Mixin으로 묶을 수 있다.
질문은 “중복인가?”가 아니라 “같은 의미인가?”에 가깝다.
스키마를 읽는 사람을 기준으로 판단한다
entgo 스키마는 데이터베이스 정의이면서 도메인 모델의 입구다. 생성된 코드를 통해 쿼리와 변경 API가 만들어지기 때문에 스키마의 표현이 서비스 코드 전체에 영향을 준다.
Mixin을 잘 쓰면 반복 필드를 줄이고 정책을 한곳에 모을 수 있다. 반대로 과하게 쓰면 스키마 파일만 봐서는 엔티티의 실제 필드와 규칙을 파악하기 어려워진다.
실무에서는 다음 기준을 둔다.
- 시간 필드처럼 의미가 안정적인 메타데이터는 Mixin으로 옮긴다.
- soft delete처럼 동작이 필요한 규칙은 필드, 조회, 변경 정책을 함께 본다.
- tenant id처럼 접근 제어와 식별 규칙에 영향을 주는 필드는 스키마별 제약을 숨기지 않는다.
BaseMixin처럼 의미가 넓은 이름은 피한다.- 같은 필드명이 아니라 같은 도메인 의미를 기준으로 묶는다.
Mixin의 목적은 스키마를 짧게 만드는 데 있지 않다. 반복되는 규칙을 한곳에 두되, 각 엔티티가 어떤 성격을 갖는지 계속 읽히게 만드는 데 있다.
스키마가 커질수록 이 차이가 중요해진다. 줄인 코드보다 남겨 둔 의미가 더 오래 간다.
참고 자료
- ent 공식 문서, Mixin: https://entgo.io/docs/schema-mixin/
- ent 공식 문서, Interceptors - Soft Delete 예제: https://entgo.io/docs/interceptors/