# ORM 모델을 도메인 엔티티로 쓰면 안 되는 순간
작은 서비스에서는 구조체 하나를 DB 테이블 매핑과 도메인 엔티티로 겸용하는 편이 빠릅니다. 문제는 ORM에는 자기만의 규칙이 있고, 그 규칙이 도메인 규칙과 충돌하기 시작할 때 드러납니다. 가장 흔한 사고는 "값을 0으로 바꿨는데 반영되지 않는다"입니다.
이 글에서는 ORM 관례가 도메인 의미를 침범하는 구체적인 지점, 분리하지 않아도 되는 경우, 그리고 분리할 때 실제로 지불하는 비용을 정리합니다.
# 1. 제로 값이 사라지는 문제
GORM으로 구조체를 넘겨 갱신하면 규칙이 이렇습니다.
will only update non-zero fields by default
ReplicaCount를 3에서 0으로 바꾸려고 구조체에 0을 담아 넘기면, GORM은 그 필드를 "설정되지 않은 것" 으로 보고 SQL에서 제외합니다. 에러도 경고도 없습니다. 반환된 행 수가 1이라 성공한 것처럼 보이고, 다음 조회에서 여전히 3이 나옵니다.
Go의 제로 값과 "값 없음"을 ORM이 구분하지 못해서 생기는 일입니다. 0, "", false가 전부 여기 걸립니다. 불리언 플래그를 끄는 기능이 동작하지 않는 버그가 이 형태로 자주 나옵니다.
문서가 제시하는 회피 방법은 두 가지입니다.
// 1) 맵으로 넘긴다 - 키가 있으면 값이 0이어도 갱신
db.Model(&ws).Updates(map[string]interface{}{
"replica_count": 0,
"auto_start": false,
})
// 2) Select 로 대상 컬럼을 명시한다
db.Model(&ws).Select("replica_count", "auto_start").Updates(Workspace{
ReplicaCount: 0,
AutoStart: false,
})
여기서 봐야 할 것은 회피 방법 자체가 아닙니다. 도메인 로직이 ORM의 제로 값 규칙을 알고 있어야 한다는 사실입니다. 서비스 계층에서 "0으로 설정"이라는 의사를 표현하려면 맵을 만들거나 컬럼 이름을 나열해야 하고, 그 순간 도메인 코드에 DB 컬럼명이 등장합니다.
# 2. ORM이 가진 다른 관례들
제로 값만 문제가 아닙니다.
조건 없는 갱신 차단. GORM은 WHERE 없는 전체 갱신을 막고 ErrMissingWhereClause를 반환합니다. 안전장치로는 훌륭하지만, "모든 행의 상태를 초기화"가 정당한 도메인 연산일 때는 세션 옵션이나 원시 SQL로 우회해야 합니다.
Save와 Updates의 차이. Save는 upsert에 가깝게 동작해 기본 키가 있으면 모든 필드를 씁니다. 문서는 조합에 대해 분명히 경고합니다.
Don't use
SavewithModel, it's an Undefined Behavior.
훅을 건너뛰는 메서드. UpdateColumn, UpdateColumns는 Update와 비슷하게 동작하지만 훅과 시간 추적을 의도적으로 건너뜁니다. 검증 로직을 훅에 넣어 뒀다면, 어떤 메서드를 쓰느냐에 따라 검증이 실행되기도 하고 안 되기도 합니다.
정리하면 이렇습니다.
| ORM 관례 | 도메인에 새어 나오는 형태 |
|---|---|
| 제로 값 제외 | "0으로 설정" 의도를 맵/Select로 표현 |
| 조건 없는 갱신 차단 | 전체 갱신 연산에 우회 코드 필요 |
Save의 전체 필드 갱신 | 부분 갱신과 전체 갱신을 메서드로 구분 |
| 훅 실행 여부가 메서드마다 다름 | 검증이 호출 방식에 의존 |
| 소프트 삭제 | 조회마다 삭제 여부 조건이 암묵적으로 붙음 |
마지막 줄이 특히 위험합니다. 소프트 삭제를 쓰면 기본 조회에 삭제 조건이 자동으로 붙는데, 이 사실을 모르는 사람이 유니크 제약을 걸면 "지운 것 같은데 같은 이름으로 다시 못 만드는" 상황이 생깁니다.
# 3. 언제 분리하고, 언제 두는가
분리하지 않아도 되는 경우가 분명히 있습니다. 필드가 그대로 DB 컬럼이고, 검증 규칙이 거의 없고, 서비스가 CRUD 위주라면 구조체 하나로 충분합니다. 이때 계층을 나누면 변환 코드만 늘어납니다.
분리가 필요해지는 신호는 다음과 같습니다.
- 도메인 규칙 때문에 ORM 태그가 아닌 이유로 필드 형태를 바꾸고 싶을 때 (예: 상태를 문자열이 아니라 열거 타입으로)
- 같은 개념이 DB에는 여러 테이블로 흩어져 있을 때
- 저장 여부와 무관한 계산된 값이 엔티티에 필요할 때
- 위 2절의 회피 코드가 서비스 계층에 반복해서 등장할 때
- 테스트에서 DB 없이 도메인 규칙을 검증하고 싶을 때
네 번째가 가장 실무적인 판단 기준입니다. ORM 관례를 우회하는 코드가 도메인 로직에 두 번 이상 나타나면 경계가 필요하다는 신호입니다.
# 4. 분리했을 때의 모양
ORM 모델은 어댑터 안에만 두고, 서비스는 도메인 엔티티만 다룹니다.
// internal/core/domain/workspace.go - DB 를 모른다
type Workspace struct {
ID ID
Name string
Replicas int
State State // 열거 타입
}
func (w *Workspace) Stop() error {
if w.State == StateStopped {
return ErrAlreadyStopped
}
w.State = StateStopped
w.Replicas = 0 // 도메인에서는 그냥 0 이다
return nil
}
// internal/adapters/out/postgres/workspace.go - 여기서만 ORM 을 안다
type workspaceRow struct {
ID string `gorm:"primaryKey"`
Name string
Replicas int
State string
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (r *repo) Save(ctx context.Context, w *domain.Workspace) error {
row := toRow(w)
// 제로 값 문제는 어댑터가 흡수한다. 도메인은 이 사실을 모른다.
return r.db.WithContext(ctx).Model(&workspaceRow{ID: row.ID}).
Select("name", "replicas", "state").
Updates(row).Error
}
경계가 생기면 도메인 규칙을 DB 없이 테스트할 수 있습니다.
func TestStop_이미_중지면_에러(t *testing.T) {
w := &domain.Workspace{State: domain.StateStopped}
if err := w.Stop(); !errors.Is(err, domain.ErrAlreadyStopped) {
t.Fatal(err)
}
}
이 구조는 포트와 어댑터 배치와 그대로 이어집니다. 전체 그림은 Go 백엔드에 헥사고날 아키텍처 적용하기 (opens new window)에 정리했습니다.
# 5. 분리하면서 지불하는 비용
솔직하게 적어 둘 필요가 있습니다.
변환 코드가 늘어납니다. 필드가 스무 개면 매핑 함수 두 개에 스무 줄씩 생깁니다. 필드를 추가할 때마다 세 곳을 고쳐야 합니다. 이 비용은 실재하고, 자동 생성으로 줄일 수는 있어도 없앨 수는 없습니다.
조회 최적화가 어려워집니다. ORM 모델을 그대로 쓰면 조인해서 한 번에 가져온 결과를 바로 응답으로 내보낼 수 있습니다. 도메인 엔티티를 거치면 그 지름길이 막힙니다. 목록 조회처럼 성능이 중요한 경로에서는 읽기 전용 쿼리를 도메인 밖으로 빼는 예외를 두는 편이 현실적입니다. 쓰기 경로만 엄격하게 지키고, 읽기는 전용 쿼리 모델을 따로 두는 방식입니다.
부분 갱신 표현이 번거로워집니다. 도메인 엔티티는 전체 상태를 나타내는데 DB에는 일부만 쓰고 싶을 때, 어댑터가 "무엇이 바뀌었는가"를 알아야 합니다. 변경 필드를 명시하는 갱신 명령 타입을 따로 두면 해결되지만, 타입이 하나 더 늘어납니다.
# 6. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 0/false/""로 바꿔도 반영 안 됨 | 구조체 갱신 시 제로 값 제외 | 맵 또는 Select 사용 |
| 의도치 않게 모든 필드가 덮어써짐 | Save가 전체 필드를 씀 | 부분 갱신은 Updates |
| 검증 훅이 실행되지 않음 | UpdateColumn 계열은 훅 생략 | 메서드 선택 재검토 |
| 삭제했는데 같은 이름 재생성 불가 | 소프트 삭제 + 유니크 제약 | 부분 인덱스 또는 하드 삭제 |
| 전체 갱신이 거부됨 | ErrMissingWhereClause | 조건 추가 또는 명시적 허용 |
| 도메인 테스트에 DB가 필요함 | 엔티티가 ORM에 묶임 | 어댑터로 ORM 격리 |
# 7. 마무리
- ORM에는 자기만의 규칙이 있습니다. 제로 값 제외가 대표적이고, 에러 없이 조용히 동작하기 때문에 발견이 늦습니다.
- 회피 코드가 서비스 계층에 반복해서 나타나면 경계를 만들 때입니다.
- 분리하면 도메인 규칙을 DB 없이 테스트할 수 있지만, 매핑 코드와 조회 최적화라는 대가를 치릅니다.
- 쓰기 경로는 엄격하게, 읽기 경로는 전용 쿼리로 우회하는 절충이 실무에서 자주 맞습니다.