본문으로 건너뛰기
· backend · 3분 읽기

entgo 4편. Predicate와 Traversal로 조회 흐름을 설계하는 법

3편에서는 edge로 엔티티 사이의 관계를 어떻게 표현하는지 봤다. 관계를 정의하고 나면 다음 질문은 자연스럽게 조회로 넘어간다. 어떤 조건으로 엔티티를 찾고, 어떤 관계를 따라가며, 어느 지점에서 필터를 걸 것인가.

entgo에서는 이 흐름을 주로 Predicate와 Traversal로 읽는다. Predicate는 Where에 들어가는 조건이고, Traversal은 edge를 따라 다음 엔티티로 이동하는 조회 흐름이다. SQL로 보면 WHEREJOIN에 가까운 개념이지만, entgo 코드에서는 모델 이름과 edge 이름을 기준으로 드러난다.

Predicate는 조건을 이름 있는 코드로 남긴다

가장 단순한 조회는 필드 조건이다.

go
users, err := client.User.
    Query().
    Where(
        user.EmailEQ("a8m@example.com"),
        user.ActiveEQ(true),
    ).
    All(ctx)

여기서 user.EmailEQ, user.ActiveEQ 같은 함수가 predicate다. 문자열로 "email = ?"를 직접 쓰지 않고, 스키마에서 생성된 함수를 조합한다. 그래서 필드 이름이 바뀌면 컴파일 단계에서 깨진다.

이 장점은 조건이 조금 복잡해질 때 더 분명해진다.

go
users, err := client.User.
    Query().
    Where(
        user.Or(
            user.RoleEQ(user.RoleAdmin),
            user.RoleEQ(user.RoleOwner),
        ),
        user.DeletedAtIsNil(),
    ).
    All(ctx)

조건을 코드로 조합하되, 필드와 enum은 스키마에서 나온 타입을 따른다. 조회 조건이 문자열 조각으로 흩어지지 않는다는 점이 entgo다운 부분이다.

Predicate는 재사용 단위가 될 수 있다

실무에서는 같은 조건이 여러 곳에 반복된다. 예를 들어 활성 사용자만 보는 조건, 삭제되지 않은 리소스만 보는 조건, 조직 안의 데이터만 보는 조건이 그렇다.

이런 조건은 함수로 빼면 읽기 좋아진다.

go
func ActiveUsers() predicate.User {
    return user.And(
        user.ActiveEQ(true),
        user.DeletedAtIsNil(),
    )
}

func InOrganization(orgID int) predicate.Project {
    return project.OrganizationIDEQ(orgID)
}

서비스 코드에서는 조건의 세부 구현보다 의도가 먼저 보인다.

go
projects, err := client.Project.
    Query().
    Where(
        InOrganization(orgID),
        project.StatusEQ(project.StatusActive),
    ).
    All(ctx)

다만 모든 조건을 공통 함수로 빼는 것이 답은 아니다. 한 화면에서만 쓰는 조건까지 추상화하면 오히려 찾기 어려워진다. 여러 유스케이스에서 반복되고, 이름을 붙였을 때 도메인 의미가 분명해지는 조건만 빼는 편이 낫다.

Traversal은 관계를 따라가는 조회다

edge를 정의하면 entgo는 관계를 따라 이동하는 query API를 만든다. 예를 들어 조직의 프로젝트를 찾고, 그 프로젝트의 태스크를 조회할 수 있다.

go
tasks, err := client.Organization.
    Query().
    Where(organization.IDEQ(orgID)).
    QueryProjects().
    Where(project.StatusEQ(project.StatusActive)).
    QueryTasks().
    Where(task.DoneEQ(false)).
    All(ctx)

이 코드는 SQL join을 직접 적지 않는다. 대신 Organization -> Projects -> Tasks라는 모델 이동 경로를 그대로 드러낸다. 1편에서 entgo를 그래프 모델링 도구로 봐야 한다고 한 이유가 여기서 다시 나온다.

Traversal의 장점은 조회 경로가 코드에 남는다는 점이다. tasks를 어디서 가져왔는지, 어떤 관계를 통과했는지 체인만 봐도 알 수 있다.

HasEdgeWith로 반대쪽 조건을 걸 수 있다

항상 edge를 따라 이동해야 하는 것은 아니다. 현재 엔티티를 조회하되, 연결된 엔티티 조건으로 필터링하고 싶을 때가 있다.

예를 들어 활성 조직에 속한 프로젝트만 조회한다고 하자.

go
projects, err := client.Project.
    Query().
    Where(
        project.HasOrganizationWith(
            organization.ActiveEQ(true),
        ),
    ).
    All(ctx)

HasOrganizationWith는 프로젝트를 결과로 유지하면서 organization 조건을 적용한다. 반대로 조직에서 프로젝트로 이동해야 한다면 QueryOrganization()이나 QueryProjects()를 쓸 수 있다.

기준은 결과 타입이다.

  • 결과가 연결된 엔티티여야 하면 Query<Edge>()로 이동한다.
  • 결과는 현재 엔티티인데 관계 조건만 필요하면 Has<Edge>With()를 쓴다.

이 차이를 잡아두면 query 체인이 덜 흔들린다.

Eager Loading은 traversal과 목적이 다르다

Traversal은 조회 대상을 바꾸는 흐름이다. 반면 eager loading은 조회 대상은 유지하면서 연결 데이터를 함께 가져오는 방식이다.

go
users, err := client.User.
    Query().
    Where(user.ActiveEQ(true)).
    WithPets(func(q *ent.PetQuery) {
        q.Where(pet.DeletedAtIsNil())
    }).
    All(ctx)

이 코드는 결과가 []*ent.User다. 다만 각 user의 Edges.Pets가 채워진다. 화면에서 사용자 목록과 각 사용자의 반려동물을 같이 보여줘야 한다면 이쪽이 자연스럽다.

반대로 특정 사용자의 반려동물 목록 자체가 필요하다면 traversal이 더 맞다.

go
pets, err := client.User.
    Query().
    Where(user.IDEQ(userID)).
    QueryPets().
    All(ctx)

결과 타입을 먼저 정하면 둘 중 무엇을 쓸지 결정하기 쉽다.

조회 코드를 서비스 정책과 섞지 않는다

Predicate와 Traversal이 강력하다고 해서 모든 정책을 query 체인에 밀어 넣으면 안 된다. 조회 조건에는 데이터 접근 규칙과 유스케이스 조건이 섞이기 쉽다.

예를 들어 deleted_at IS NULL, organization_id = ? 같은 조건은 거의 모든 조회에 필요한 기본 데이터 경계에 가깝다. 반면 “이번 프로모션 대상 사용자” 같은 조건은 특정 유스케이스 정책이다.

둘을 같은 함수에 넣으면 재사용할수록 의미가 흐려진다.

go
func VisibleProjects(orgID int) []predicate.Project {
    return []predicate.Project{
        project.OrganizationIDEQ(orgID),
        project.DeletedAtIsNil(),
    }
}

이 정도는 데이터 접근 경계로 볼 수 있다. 하지만 결제 플랜, 이벤트 기간, 사용자 실험군 같은 조건까지 들어가기 시작하면 서비스 계층에서 조합하는 편이 낫다.

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

첫째, SQL join 관성으로 entgo query를 읽는 경우다. 실제 SQL을 이해하는 것은 중요하지만, entgo 코드에서는 edge 이름과 결과 타입을 먼저 보는 편이 낫다.

둘째, Query<Edge>()Has<Edge>With()를 섞어 쓰는 기준이 없는 경우다. 결과 타입이 바뀌어야 하면 traversal, 현재 타입을 유지해야 하면 relation predicate로 보면 된다.

셋째, eager loading을 무조건 성능 최적화로만 보는 경우다. With<Edge>()는 N+1을 줄이는 데 도움을 주지만, 필요 없는 edge를 항상 같이 가져오면 오히려 조회가 무거워진다. 화면이나 API 응답에서 실제로 필요한 관계만 붙여야 한다.

넷째, 공통 predicate를 너무 일찍 많이 만드는 경우다. 이름 붙일 도메인 의미가 있을 때만 재사용 단위로 빼고, 단순한 일회성 조건은 query 안에 두는 편이 더 읽기 쉽다.

다음 편으로 넘어가기 전에

4편의 핵심은 조회 코드를 결과 타입과 관계 경로 기준으로 읽는 것이다. Predicate는 조건을 타입 안전하게 남기고, Traversal은 edge를 따라 조회 대상을 바꾼다. Eager loading은 결과 타입을 유지하면서 연결 데이터를 함께 가져온다.

이제 모델의 모양, 필드, 관계, 조회 흐름까지 봤다. 다음 단계는 데이터 규칙이다. 같은 이메일이 두 번 들어오면 안 되는지, 한 조직 안에서 프로젝트 키가 유일해야 하는지, 어떤 조회 조건에 인덱스를 남겨야 하는지 살펴볼 차례다.

참고 자료

  • entgo Predicates: generated predicate와 custom predicate 조합 방식을 설명하는 공식 문서.
  • entgo Traversal: edge를 따라 query를 이어가는 graph traversal 흐름을 설명하는 공식 문서.
  • entgo Eager Loading: With<Edge>()로 관계 데이터를 함께 가져오는 방식을 설명하는 공식 문서.
공유하기 X LinkedIn

관련 글