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

entgo 5편. Unique, Index, Constraint로 데이터 규칙을 코드에 남기는 법

필드와 엣지를 정하면 모델의 모양은 어느 정도 잡힌다. 하지만 모델의 모양만으로는 데이터가 안전해지지 않는다. 같은 이메일을 가진 사용자가 두 번 만들어질 수 있는지, 한 조직 안에서 프로젝트 키가 중복될 수 있는지, 게시글 목록을 어떤 조건으로 자주 조회하는지까지 스키마에 남겨야 한다.

entgo에서는 이 역할을 주로 Unique(), Indexes(), 엣지 기반 인덱스가 맡는다. 이 글은 entgo 스키마에 데이터 규칙을 남길 때 어떤 기준으로 필드 제약, 복합 인덱스, 관계 단위 제약을 나누면 좋은지 정리한다.

단일 필드의 중복 금지는 필드에 둔다

가장 단순한 규칙은 한 필드가 전체 테이블에서 유일해야 하는 경우다. 이메일, 외부 시스템 ID, 공개 slug처럼 하나의 값만으로 중복 여부가 결정되는 값이 여기에 해당한다.

go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

type User struct {
    ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("email").
            NotEmpty().
            Unique(),
        field.String("name").
            NotEmpty(),
    }
}

이 규칙은 애플리케이션 코드의 if exists 검사보다 DB 제약으로 남기는 편이 낫다. 생성 요청이 동시에 들어오면 애플리케이션에서 먼저 조회하는 방식은 쉽게 경합 조건을 만든다. 반면 unique 제약은 마지막 방어선을 DB에 둔다.

다만 Unique()를 붙인다고 사용자에게 보여줄 에러 메시지까지 해결되는 것은 아니다. 중복 삽입은 DB 또는 ent mutation 단계에서 에러로 드러난다. 서비스 계층에서는 이 에러를 도메인에 맞는 응답으로 바꾸는 처리가 필요하다.

go
user, err := client.User.
    Create().
    SetEmail(email).
    SetName(name).
    Save(ctx)
if err != nil {
    if ent.IsConstraintError(err) {
        return nil, ErrEmailAlreadyExists
    }
    return nil, err
}

return user, nil

중복 금지는 스키마에 두고, 에러 해석은 유스케이스에 둔다. 이 경계를 지키면 DB 규칙과 API 응답 정책이 서로 섞이지 않는다.

여러 필드가 함께 유일해야 하면 복합 인덱스를 쓴다

실무에서 더 자주 만나는 규칙은 하나의 필드가 아니라 여러 필드의 조합이 유일해야 하는 경우다. 예를 들어 프로젝트 키는 전체 서비스에서 유일하지 않아도 된다. 대신 한 조직 안에서는 유일해야 한다.

이 경우 field.String("key").Unique()는 규칙을 과하게 만든다. 조직 A의 api, 조직 B의 api가 모두 가능해야 하기 때문이다. 이럴 때는 Indexes()에 복합 unique 인덱스를 둔다.

go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
)

type Project struct {
    ent.Schema
}

func (Project) Fields() []ent.Field {
    return []ent.Field{
        field.Int("organization_id"),
        field.String("key").
            NotEmpty(),
        field.String("name").
            NotEmpty(),
    }
}

func (Project) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("organization_id", "key").
            Unique(),
    }
}

이 코드는 다음 규칙을 표현한다.

  • key 하나만으로는 유일하지 않다.
  • organization_idkey의 조합은 유일하다.
  • 따라서 같은 조직 안에서는 같은 프로젝트 키를 만들 수 없다.

복합 unique 인덱스는 단순한 성능 최적화가 아니다. 데이터의 소유 범위를 코드에 남기는 장치다. organization_id가 빠진 단일 unique 제약을 쓰면 멀티테넌트 모델이 깨진다. 반대로 unique 제약을 두지 않으면 같은 조직 안에 같은 키가 생길 수 있다.

관계 기준의 유일성은 엣지와 함께 표현한다

외래 키 필드를 직접 들고 있으면 복합 인덱스를 필드만으로 만들 수 있다. 하지만 entgo에서는 관계를 엣지로 모델링하는 경우가 많다. 이때도 관계 기준의 유일성을 스키마에 남길 수 있다.

예를 들어 한 도시 안에서는 거리 이름이 유일해야 하지만, 다른 도시에는 같은 거리 이름이 있을 수 있다고 하자. 이 규칙은 Street.name 단독 unique가 아니다. city 관계와 name의 조합이 unique다.

go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
)

type Street struct {
    ent.Schema
}

func (Street) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").
            NotEmpty(),
    }
}

func (Street) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("city", City.Type).
            Ref("streets").
            Unique(),
    }
}

func (Street) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("name").
            Edges("city").
            Unique(),
    }
}

여기서 edge.From(...).Unique()는 하나의 Street가 하나의 City에 속한다는 관계의 카디널리티를 표현한다. index.Fields("name").Edges("city").Unique()는 같은 도시 안에서 같은 거리 이름을 막는다.

둘은 서로 다른 규칙이다. 하나는 관계의 모양이고, 다른 하나는 관계 안에서의 중복 금지다. 둘 중 하나만 있으면 모델이 애매해진다.

조회 패턴도 스키마에 남긴다

인덱스는 unique 제약만을 위한 기능이 아니다. 자주 조회하는 조건을 스키마에 드러내는 역할도 한다.

go
func (Project) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("organization_id", "status"),
        index.Fields("organization_id", "created_at"),
        index.Fields("organization_id", "key").
            Unique(),
    }
}

이 예시는 세 가지 의도를 담고 있다.

  • 조직별 상태 조회가 자주 일어난다.
  • 조직별 최신 프로젝트 목록을 자주 본다.
  • 조직 안에서 프로젝트 키는 중복될 수 없다.

인덱스는 많을수록 좋은 설정이 아니다. 쓰기 비용과 저장 공간을 늘린다. 그래서 모든 검색 조건을 인덱스로 만들기보다, 실제 제품 코드에서 반복되는 조회 조건을 기준으로 둔다.

스키마만 보고도 “이 엔티티는 조직 단위로 조회되는구나”, “이 키는 조직 안에서만 유일하구나”를 읽을 수 있으면 좋은 모델에 가깝다.

DB 제약과 애플리케이션 검증을 나눈다

entgo 필드에는 NotEmpty(), MinLen(), MaxLen(), Validate() 같은 검증을 둘 수 있다. 이런 검증은 생성 또는 수정 전에 실행되는 애플리케이션 레벨 규칙이다.

go
func (Project) Fields() []ent.Field {
    return []ent.Field{
        field.String("key").
            NotEmpty().
            MaxLen(40).
            Validate(func(s string) error {
                if strings.Contains(s, " ") {
                    return errors.New("project key must not contain spaces")
                }
                return nil
            }),
    }
}

이 검증은 입력을 빠르게 거절하고, 도메인에 가까운 에러를 만들기 좋다. 하지만 DB 제약과 같은 것은 아니다. entgo를 거치지 않는 SQL, 별도 배치, 마이그레이션 스크립트가 데이터를 쓰면 이 검증은 실행되지 않는다.

반대로 unique 인덱스와 외래 키 제약은 DB가 지킨다. 어떤 코드 경로로 쓰든 DB에 도달하는 순간 적용된다. 그래서 기준을 나눠야 한다.

  • 항상 깨지면 안 되는 중복 금지: DB unique 제약
  • 관계의 소유와 카디널리티: edge와 FK 제약
  • 입력 형식과 사용자 친화적 검증: ent field validator
  • DB 방언별 특수 인덱스나 check 성격의 규칙: 마이그레이션 설계에서 별도 검토

애플리케이션 검증은 사용자 경험을 좋게 만든다. DB 제약은 데이터 무결성을 지킨다. 둘은 경쟁 관계가 아니라 서로 다른 층의 방어선이다.

스키마에 남길 규칙을 고르는 기준

모든 비즈니스 규칙을 entgo 스키마에 넣을 필요는 없다. 스키마는 데이터 구조와 무결성에 가까운 규칙을 담을 때 가장 읽기 좋다.

예를 들어 “무료 플랜은 프로젝트를 3개까지만 만들 수 있다”는 규칙은 스키마보다 유스케이스에 가깝다. 플랜 정책은 바뀔 수 있고, 시간이나 결제 상태 같은 외부 조건을 함께 본다.

반면 “한 조직 안에서 프로젝트 키는 중복될 수 없다”는 규칙은 스키마에 가깝다. 제품 정책이 조금 바뀌어도 같은 조직 안에서 같은 키를 허용할 가능성은 낮다. 데이터 자체의 정합성이기 때문이다.

나는 보통 다음 질문으로 나눈다.

  • 이 규칙이 깨지면 데이터 자체가 모순되는가?
  • 동시에 두 요청이 들어와도 반드시 지켜야 하는가?
  • entgo가 아닌 경로로 데이터가 들어와도 지켜야 하는가?
  • 이 규칙을 모르면 조회 코드나 API 코드가 계속 방어적으로 변하는가?

대부분 “그렇다”라면 스키마나 DB 제약으로 올린다. “상황에 따라 다르다”라면 서비스 계층이나 정책 객체에 둔다.

마무리

entgo 스키마는 테이블 모양만 적는 곳이 아니다. 어떤 값이 유일한지, 어떤 조합이 중복되면 안 되는지, 어떤 관계 안에서만 유효한 규칙인지 남기는 곳이다.

단일 필드 규칙은 필드의 Unique()에 둔다. 여러 필드가 함께 만드는 규칙은 index.Fields(...).Unique()로 둔다. 관계 범위가 포함되면 엣지 기반 인덱스를 검토한다. 입력 형식 검증은 field validator로 두되, DB 제약을 대체한다고 보지 않는다.

이 기준을 지키면 모델을 읽는 사람이 API 코드까지 따라가지 않아도 데이터의 기본 규칙을 이해할 수 있다. 스키마가 데이터 모델의 문서 역할을 하게 된다.

참고 자료

공유하기 X LinkedIn

관련 글