entgo 6편. Hooks와 Interceptors로 모델 변경 흐름 제어하기
- 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로 모델 변경 흐름 제어하기 현재 · 6/6
앞선 글들에서는 entgo의 스키마, 엣지, 쿼리, Predicate, Traversal을 중심으로 읽기 모델을 다뤘다. 모델링은 읽기에서만 무너지지 않는다. 오히려 생성, 수정, 삭제 흐름에서 더 자주 무너진다.
예를 들어 주문 상태는 paid에서 draft로 돌아가면 안 된다. 모든 생성 요청에는 created_by가 채워져야 한다. 삭제는 실제 삭제가 아니라 deleted_at을 채우는 방식이어야 한다. 이런 규칙을 서비스 메서드마다 직접 넣기 시작하면 금방 흩어진다.
entgo에는 이 지점을 다루는 확장 지점이 있다. 쓰기 흐름에는 Hooks가 있고, 읽기 흐름에는 Interceptors와 Traversers가 있다. 이름만 보면 둘 다 비슷한 미들웨어처럼 보이지만 역할은 다르다. Hooks는 mutation 주변에서 실행된다. Interceptors는 query 실행 주변에서 실행된다. Traversers는 graph traversal의 각 단계에 끼어든다.
이 차이를 정확히 잡아야 한다. Hooks로 읽기 기본 필터를 만들려고 하거나, Interceptor로 쓰기 검증을 하려고 하면 모델이 더 애매해진다.
Hooks는 mutation의 경계다
entgo 문서에서 Hook은 ent.Mutator를 받아 다시 ent.Mutator를 반환하는 함수로 정의된다. HTTP 미들웨어와 비슷한 구조다. mutation 실행 전후에 로직을 넣을 수 있다.
entgo의 mutation 종류는 크게 다섯 가지다.
CreateUpdateOneUpdateDeleteOneDelete
이 구분은 실무에서 꽤 중요하다. 단건 수정과 다건 수정은 같은 검증 규칙을 적용하기 어렵다. 단건 수정은 현재 상태를 조회해서 상태 전이를 검증할 수 있지만, 다건 수정은 대상이 여러 개라 같은 방식으로 처리하기 어렵다.
예를 들어 주문 상태 전이를 검증한다고 하자. UpdateOne에서는 주문 하나의 현재 상태를 확인한 뒤 새 상태와 비교할 수 있다.
package schema
import (
"context"
"fmt"
gen "myapp/ent"
"myapp/ent/hook"
"myapp/ent/order"
"entgo.io/ent"
)
func (Order) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(validateOrderStatusTransition(), ent.OpUpdateOne),
}
}
func validateOrderStatusTransition() ent.Hook {
return func(next ent.Mutator) ent.Mutator {
return hook.OrderFunc(func(ctx context.Context, m *gen.OrderMutation) (ent.Value, error) {
nextStatus, ok := m.Status()
if !ok {
return next.Mutate(ctx, m)
}
id, ok := m.ID()
if !ok {
return nil, fmt.Errorf("missing order id")
}
current, err := m.Client().Order.Get(ctx, id)
if err != nil {
return nil, err
}
if current.Status == order.StatusPaid && nextStatus == order.StatusDraft {
return nil, fmt.Errorf("paid order cannot move back to draft")
}
return next.Mutate(ctx, m)
})
}
}이 코드는 서비스 레이어에서 직접 검증할 수도 있다. 하지만 같은 mutation이 여러 유스케이스에서 발생한다면 Hook이 더 낫다. 규칙이 모델에 붙어 있기 때문이다. 주문 상태 전이는 특정 API의 규칙이 아니라 주문 모델의 규칙이다.
반대로 특정 화면이나 특정 유스케이스에서만 필요한 검증이라면 Hook에 넣지 않는 편이 낫다. Hook은 생각보다 넓게 적용된다. 모델의 불변식에 가까운 규칙만 넣는 것이 안전하다.
Schema Hook과 Runtime Hook을 나눈다
entgo Hook은 크게 두 위치에 둘 수 있다.
Schema Hook은 스키마 타입에 정의한다. 특정 엔티티의 도메인 규칙을 한곳에 모으는 데 적합하다.
func (Order) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(validateOrderStatusTransition(), ent.OpUpdateOne),
}
}Runtime Hook은 클라이언트에 등록한다. 로깅, 메트릭, 트레이싱처럼 특정 엔티티보다 애플리케이션 실행 환경에 가까운 관심사에 적합하다.
client.Use(func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
value, err := next.Mutate(ctx, m)
if err != nil {
return nil, err
}
logger.Info("mutation completed",
"type", m.Type(),
"op", m.Op(),
)
return value, nil
})
})이 기준을 지키면 코드 위치를 고르기 쉽다.
- 상태 전이 검증: Schema Hook
- 자동 필드 채우기: Schema Hook 또는 Mixin Hook
- 감사 로그 기록: Runtime Hook 또는 별도 Outbox Hook
- mutation 처리 시간 측정: Runtime Hook
- 삭제 금지 정책: Schema Hook 또는 Privacy Rule
주의할 점도 있다. Schema Hook을 사용하면 ent/runtime import가 필요할 수 있다. entgo 문서도 schema hook 등록을 위해 런타임 패키지를 import해야 한다고 설명한다.
import _ "myapp/ent/runtime"이 import를 빼먹으면 Hook을 작성해도 실제로 등록되지 않아 예상과 다른 동작을 할 수 있다.
자동 필드는 Hook보다 Mixin과 같이 둔다
여러 엔티티에 반복되는 규칙은 Hook만 따로 두기보다 Mixin과 함께 두는 편이 낫다. 예를 들어 created_by, updated_by를 여러 테이블에 공통으로 넣는다고 하자.
package schema
import (
"context"
"fmt"
"myapp/authctx"
"entgo.io/ent"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/mixin"
)
type AuditMixin struct {
mixin.Schema
}
func (AuditMixin) Fields() []ent.Field {
return []ent.Field{
field.Int("created_by").Immutable(),
field.Int("updated_by").Optional(),
}
}
func (AuditMixin) Hooks() []ent.Hook {
return []ent.Hook{
setAuditUser(),
}
}
func setAuditUser() ent.Hook {
type auditMutation interface {
SetCreatedBy(int)
SetUpdatedBy(int)
}
return func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
userID, ok := authctx.UserID(ctx)
if !ok {
return nil, fmt.Errorf("missing user id")
}
am, ok := m.(auditMutation)
if !ok {
return next.Mutate(ctx, m)
}
switch {
case m.Op().Is(ent.OpCreate):
am.SetCreatedBy(userID)
am.SetUpdatedBy(userID)
case m.Op().Is(ent.OpUpdateOne | ent.OpUpdate):
am.SetUpdatedBy(userID)
}
return next.Mutate(ctx, m)
})
}
}이 예시는 두 가지를 보여준다.
첫째, 공통 필드와 공통 Hook은 같은 Mixin에 두는 편이 읽기 쉽다. 필드만 보고 Hook을 찾아다니지 않아도 된다.
둘째, context에 의존하는 값은 경계가 필요하다. authctx.UserID(ctx) 같은 함수가 없다면 Hook 내부에서 HTTP 요청이나 세션 구현을 직접 알게 된다. 그렇게 되면 ent schema가 웹 프레임워크에 묶인다. Hook은 도메인 규칙을 모으는 데 유용하지만, 인프라 세부사항을 끌고 들어오기 쉬운 지점이기도 하다.
삭제는 Hooks와 Interceptors가 같이 필요하다
Soft delete는 Hooks와 Interceptors의 차이를 이해하기 좋은 예다.
삭제 요청이 들어오면 실제 DELETE를 실행하지 않고 deleted_at을 채운다. 이 부분은 쓰기 흐름이므로 Hook이 맡는다.
func softDeleteHook() ent.Hook {
type softDeleteMutation interface {
SetOp(ent.Op)
SetDeletedAt(time.Time)
}
return hook.On(
func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
sm, ok := m.(softDeleteMutation)
if !ok {
return nil, fmt.Errorf("unexpected mutation type %T", m)
}
sm.SetOp(ent.OpUpdate)
sm.SetDeletedAt(time.Now())
return next.Mutate(ctx, m)
})
},
ent.OpDeleteOne|ent.OpDelete,
)
}하지만 이것만으로는 부족하다. 삭제된 데이터를 기본 조회에서 빼야 한다. 이 부분은 읽기 흐름이다. Hook이 아니라 Interceptor 또는 Traverser가 맡아야 한다.
entgo 공식 문서의 soft delete 예시도 Mixin 안에 필드, Interceptor, Hook을 함께 둔다. Hook은 삭제 mutation을 update mutation으로 바꾸고, Interceptor는 기본 조회에서 삭제된 row를 제외한다.
func (SoftDeleteMixin) Interceptors() []ent.Interceptor {
return []ent.Interceptor{
intercept.TraverseFunc(func(ctx context.Context, q intercept.Query) error {
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
return nil
}
q.WhereP(sql.FieldIsNull("deleted_at"))
return nil
}),
}
}이 구조가 중요한 이유는 책임이 나뉘기 때문이다.
- 삭제를 어떻게 기록할지는 Hook의 책임이다.
- 삭제된 데이터를 기본 조회에서 숨길지는 Interceptor 또는 Traverser의 책임이다.
- 특정 관리자 기능에서 삭제된 데이터까지 볼지는 context나 별도 query API의 책임이다.
한곳에 몰아넣으면 처음에는 단순해 보이지만, 예외가 생길 때마다 코드가 불안정해진다.
Interceptor와 Traverser는 읽기 흐름의 위치가 다르다
Interceptors는 query 실행 주변에서 동작한다. 실행 직전에 query를 조정하거나 실행 후 결과를 다룰 수 있다. 로깅, 캐싱, 기본 limit 같은 작업에 맞다.
client.Intercept(
intercept.Func(func(ctx context.Context, q intercept.Query) error {
if ent.QueryFromContext(ctx).Limit == nil {
q.Limit(1000)
}
return nil
}),
)Traverser는 graph traversal의 각 단계에 적용된다. 예를 들어 User -> Group -> Post로 이어지는 traversal에서 중간 단계마다 기본 필터를 넣어야 한다면 Traverser가 더 자연스럽다. entgo 문서도 기본 필터는 Traverse 함수가, 로깅이나 캐싱은 Intercept 함수가 더 잘 맞는다고 설명한다.
이 차이는 멀티 테넌시나 soft delete에서 자주 드러난다. 최종 query에만 필터를 넣으면 중간 traversal에서 이미 원하지 않는 관계가 섞일 수 있다. 반면 Traverser는 각 traversal 단계에 조건을 넣을 수 있다.
Hook에 넣지 말아야 할 것
Hook은 편리하지만 모든 쓰기 로직의 답은 아니다. 다음 로직은 Hook에 넣기 전에 한 번 더 생각하는 편이 낫다.
첫째, 외부 시스템 호출이다. 결제 승인, 메시지 발송, 검색 색인 반영 같은 작업을 Hook에서 바로 실행하면 transaction 경계와 재시도 전략이 흐려진다. mutation은 실패했는데 외부 호출은 성공하거나, 반대로 외부 호출 때문에 DB 변경이 실패할 수 있다. 이런 작업은 outbox 패턴이나 애플리케이션 서비스 계층에서 명시적으로 다루는 편이 낫다.
둘째, 유스케이스별 분기다. 특정 API에서만 필요한 검증을 Hook에 넣으면 다른 경로의 mutation까지 영향을 받는다. Hook은 넓게 적용되는 지점이다. 넓게 적용돼도 괜찮은 규칙만 넣어야 한다.
셋째, 조회가 많이 필요한 복잡한 검증이다. Hook 내부에서 여러 테이블을 조회하기 시작하면 mutation 하나의 비용을 예측하기 어려워진다. 상태 전이처럼 모델의 핵심 규칙이라면 감수할 수 있다. 하지만 화면 편의를 위한 검증이라면 서비스 계층이 더 낫다.
기준은 단순하다
entgo에서 모델 변경 흐름을 제어할 때 기준은 다음처럼 잡을 수 있다.
- 생성, 수정, 삭제의 공통 규칙은 Hook을 먼저 검토한다.
- 특정 엔티티의 불변식은 Schema Hook에 둔다.
- 로깅, 메트릭, 트레이싱은 Runtime Hook에 둔다.
- 여러 엔티티에 반복되는 필드와 규칙은 Mixin으로 묶는다.
- 기본 조회 필터는 Interceptor 또는 Traverser에 둔다.
- traversal 중간 단계까지 필터가 필요하면 Traverser를 쓴다.
- 외부 시스템 호출은 Hook에 직접 넣지 않는다.
Hooks와 Interceptors는 entgo 코드를 더 추상적으로 만들기 위한 장치가 아니다. CRUD 호출 주변에 흩어지는 규칙을 적절한 경계로 옮기는 장치다. 쓰기 흐름의 규칙과 읽기 흐름의 규칙을 분리하면 서비스 코드는 얇아지고, 모델의 제약은 더 잘 보인다.
다음 편에서는 이 흐름을 Privacy Rule과 연결해볼 수 있다. Hook이 mutation 자체를 감싸는 지점이라면, Privacy는 누가 어떤 작업을 할 수 있는지 결정하는 지점이다. 둘을 섞지 않아야 권한 정책과 모델 규칙이 서로를 침범하지 않는다.
참고 자료
- ent 공식 문서, Hooks: https://entgo.io/docs/hooks/
- ent 공식 문서, Interceptors: https://entgo.io/docs/interceptors/