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

entgo 2편. Optional과 Nillable을 헷갈리지 않는 필드 설계

entgo 2편. Optional과 Nillable을 헷갈리지 않는 필드 설계

1편에서는 entgo를 ORM보다 스키마 중심 그래프 모델링 도구로 보는 관점을 정리했다. 이번에는 그 스키마 안에서도 가장 먼저 부딪히는 필드 설계를 본다. entgo를 처음 쓸 때는 Optional()Nillable()부터 헷갈리기 쉽다. 둘 다 비어 있을 수 있는 값처럼 보이기 때문이다.

하지만 둘은 목적이 다르다. 이 차이를 초반에 정확히 잡아두지 않으면 create API, JSON 응답, 이후 migration에서 계속 어색해진다.

entgo에서 필드 설계가 먼저인 이유

entgo에서 필드는 단순한 컬럼 선언이 아니다. 필드 하나를 어떻게 정의하느냐에 따라 생성되는 struct 타입, create/update builder, nullable 여부, 기본값 처리 방식이 같이 결정된다.

예를 들어 아래 두 필드는 겉으로 보면 비슷해 보인다.

go
field.String("nickname").Optional()
field.String("display_name").Optional().Nillable()

둘 다 필수 입력은 아니다. 하지만 생성되는 Go struct 필드는 다르다. Optional()만 있으면 보통 string으로 생성되고, Nillable()까지 붙이면 *string으로 생성된다. entgo에서는 이 차이가 꽤 중요하다.

Optional은 생성 시 필수가 아니라는 뜻이다

ent 공식 문서 기준으로 필드는 기본적으로 required다. 값을 넣지 않고 create를 호출하면 저장할 수 없다. Optional()은 이 필드를 엔티티 생성 시 필수로 요구하지 않겠다는 뜻이다.

go
func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("name"),
		field.String("nickname").Optional(),
	}
}

이 경우 name은 반드시 넣어야 하고, nickname은 생략할 수 있다. 데이터베이스 쪽에서는 nullable 컬럼으로 매핑된다.

중요한 건 여기서 끝이라는 점이다. Optional()은 입력 필수 여부를 바꾼다. 값이 없을 때 Go 코드에서 nil로 구분하게 만들어주지는 않는다.

Nillable은 zero value와 NULL을 구분하기 위한 옵션이다

실무에서 더 헷갈리는 건 여기다. Optional 필드는 비어 있을 수 있으니 struct에서도 비어 있는 상태가 자연스럽게 표현될 것 같지만, 실제 생성 타입은 기본 Go 타입일 수 있다.

예를 들어 optional string은 기본적으로 string이다. 이 경우 조회 결과가 비어 있으면 빈 문자열 ""과 데이터베이스의 NULL을 코드에서 구분하기 어렵다.

이때 Nillable()을 붙인다.

go
func (User) Fields() []ent.Field {
	return []ent.Field{
		field.String("nickname").Optional(),
		field.String("display_name").Optional().Nillable(),
	}
}

생성된 struct는 대략 이런 차이가 난다.

go
type User struct {
	Nickname    string
	DisplayName *string
}

Nickname은 값이 없을 때 빈 문자열로 보일 수 있고, DisplayNamenil인지 실제 문자열 포인터인지로 구분된다. 즉 Nillable()의 핵심은 optional 입력이 아니라, NULL과 zero value를 구분할 수 있게 타입을 바꾸는 것이다.

언제 Optional만 쓰고, 언제 Nillable까지 붙일까

판단 기준은 단순하다.

Optional만으로 충분한 경우

값이 없어도 코드에서 zero value로 취급해도 문제가 없는 필드다.

  • 정렬이나 필터 기준으로 쓰지 않는 보조 문자열
  • 조회 시 빈 값과 NULL을 구분할 필요가 없는 필드
  • 내부적으로 “없음”을 빈 문자열이나 0으로 봐도 되는 값

Nillable이 필요한 경우

값이 비어 있다는 사실 자체가 의미가 있는 필드다.

  • NULL과 빈 문자열을 구분해야 하는 표시 이름
  • 부분 조회 후 JSON 응답에서 zero value 노출을 피하고 싶은 시간 필드
  • 외부 API에 내려줄 때 값이 없음을 명확히 표현해야 하는 필드

이 차이를 무시하면 나중에 ""가 진짜 빈 값인지, 원래 NULL이었는지 애매해진다.

time.Time에서는 더 자주 문제를 만든다

문자열보다 더 자주 헷갈리는 건 time.Time이다. Go의 zero value는 0001-01-01T00:00:00Z다. 이 값은 실제 데이터가 없는 상태와 쉽게 섞인다.

공식 문서도 Nillable required field 예제를 따로 설명한다. 이유는 부분 select나 JSON marshaling에서 zero value가 그대로 노출될 수 있기 때문이다.

go
field.Time("created_at").
	Default(time.Now)

field.Time("published_at").
	Optional().
	Nillable()

published_at처럼 실제로 비어 있을 수 있는 시간 값은 *time.Time으로 받는 편이 안전하다. 반면 항상 생성 시 채워지는 created_attime.Time으로 두는 쪽이 더 단순하다.

Default와 Optional은 같이 보게 된다

필드 설계를 하다 보면 Optional() 다음으로 많이 만나는 게 Default()다. 둘은 같이 쓰이지만 역할은 다르다.

go
field.String("role").Default("member")
field.Time("created_at").Default(time.Now)

Default()는 값을 생략했을 때 entgo가 기본값을 채우는 방식이다. 즉 필드가 required여도 기본값이 있으면 create builder에서 직접 넣지 않아도 된다.

실무에서는 다음처럼 정리하면 편하다.

  • 반드시 값은 있어야 하지만 호출자가 매번 넣을 필요는 없으면 Default()
  • 값 자체가 없어도 되면 Optional()
  • 값이 없음을 nil로 구분해야 하면 Optional().Nillable()

이 셋을 섞어 쓰기 시작하면 스키마 의도가 훨씬 분명해진다.

Immutable은 생성 이후 변경 금지라는 뜻이다

입문 단계에서 같이 익혀둘 만한 옵션이 Immutable()이다.

go
field.Time("created_at").
	Default(time.Now).
	Immutable()

이 설정을 넣으면 update builder에는 해당 필드 setter가 생성되지 않는다. created_at, 외부 연동용 발급 ID, 생성 시점에만 정해지는 값에 주로 쓴다.

이 옵션은 validation보다 더 앞단에서 API 형태를 바꾼다. 즉 잘못된 업데이트를 런타임 전에 줄이는 효과가 있다. entgo다운 설계 제약이 이런 지점에서 드러난다.

Unique, Enum 같은 옵션도 필드 설계의 일부다

Optional과 Nillable이 가장 헷갈리지만, 필드 설계는 여기서 끝나지 않는다.

go
field.String("email").Unique()

field.Enum("status").
	Values("draft", "published", "archived").
	Default("draft")

Unique()는 단순 제약 조건이 아니라 이후 upsert, 조회 조건, 사용자 입력 검증과도 이어진다. Enum()은 상태 문자열을 코드와 데이터베이스 양쪽에서 제한하는 역할을 한다. 이런 옵션은 초반에 조금 번거로워 보여도, 상태값이 흩어지는 걸 막는 데 도움이 된다.

필드 설계에서 중요한 건 “저장만 되면 된다”가 아니다. 이후 코드를 어떻게 읽고, 어떻게 수정하고, 어떤 잘못을 미리 막을지를 같이 결정하는 일이다.

처음 스키마를 짤 때 자주 하는 실수

입문 단계에서 흔한 실수는 세 가지 정도다.

첫째, optional이면 전부 nillable이어야 한다고 생각하는 경우다. 이렇게 가면 포인터가 불필요하게 많아진다. 코드가 오히려 지저분해진다.

둘째, 반대로 optional만 주고 NULL과 zero value를 구분해야 하는 필드까지 기본 타입으로 두는 경우다. 이때는 조회 응답과 후처리 코드가 애매해진다.

셋째, 기본값으로 해결할 문제와 optional로 풀 문제를 섞는 경우다. 예를 들어 상태값처럼 기본값이 명확한 필드는 optional보다 default가 더 잘 맞는다.

이런 판단은 작은 차이처럼 보이지만, 필드 수가 많아질수록 누적된다.

다음 편으로 넘어가기 전에

2편에서 핵심은 하나다. entgo에서 필드 설계는 입력 검증 몇 개 붙이는 작업이 아니라, 생성되는 타입과 이후 사용 방식을 같이 설계하는 일이라는 점이다. 특히 Optional()은 생성 시 필수 여부를 바꾸고, Nillable()은 Go 타입에서 nil을 구분하게 만든다는 차이를 먼저 기억해두는 게 좋다.

이 기준이 잡혀 있으면 다음 단계인 edge 설계도 훨씬 자연스럽다. 필드에서 값의 의미를 정리했다면, 다음은 엔티티 사이의 관계 의미를 정리할 차례다.

참고 자료

  • entgo Fields: Optional, Nillable, Default, Immutable, Enum 등 필드 옵션 전반을 설명하는 공식 문서.
  • entgo CRUD API: 생성된 create/update/query builder가 필드 정의와 어떻게 연결되는지 확인할 수 있다.
공유하기 X LinkedIn

관련 글