티스토리 뷰
작성일: 2025년 12월 22일
카테고리: Backend, Go, Troubleshooting
키워드: Go, GORM, PostgreSQL, Troubleshooting, TimeZone
요약
GORM을 사용하면서 실제 프로덕션 환경에서 마주친 Edge Cases와 해결 방법을 정리한다. 타임존(KST/UTC) 불일치 문제, Zero Value 업데이트 함정, AutoMigrate와 init.sql의 권한 충돌, 그리고 Upsert, Hook 전파, 연결 끊김 등 실무에서 시간을 허비하기 쉬운 함정들을 다룬다.
1. 타임존(TimeZone) 불일치 문제
KST(Asia/Seoul) 환경에서 운영할 때 가장 흔하게 겪는 문제다.
문제 상황
// 한국 시간 2025-12-22 15:00:00에 레코드 생성
user := User{Name: "test"}
db.Create(&user)
// DB 조회 결과: created_at = 2025-12-22 06:00:00 (9시간 차이!)
또는 반대로:
// DB에 저장된 시간: 2025-12-22 15:00:00 (UTC)
// Go에서 조회 후 출력: 2025-12-23 00:00:00 (KST로 변환됨)
원인 분석
타임존 불일치는 여러 지점에서 발생한다:
[Go 애플리케이션] → [GORM] → [PostgreSQL Driver] → [PostgreSQL Server]
time.Local DSN TimeZone 세션 timezone 서버 timezone| 구간 | 설정 위치 | 기본값 |
|---|---|---|
| Go 런타임 | time.Local, TZ 환경변수 |
시스템 로케일 |
| DSN | TimeZone=Asia/Seoul |
서버 기본값 |
| PostgreSQL 세션 | SET timezone |
postgresql.conf |
| PostgreSQL 서버 | timezone in postgresql.conf |
OS 설정 |
해결 방법
권장: 백엔드는 UTC, 프론트엔드에서 로컬 타임존 변환
이 방식이 표준적이고, 글로벌 서비스나 멀티 타임존 환경에서 유리하다.
// Go 백엔드: DSN에 UTC 명시
dsn := "host=localhost user=app dbname=myapp TimeZone=UTC"
// 시간 저장/처리는 항상 UTC
now := time.Now().UTC()
user.CreatedAt = now
// 프론트엔드 (TypeScript): 로컬 타임존으로 변환
const utcDate = new Date(response.createdAt); // ISO 8601 문자열
const localDate = utcDate.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
// 또는 date-fns-tz 사용
import { formatInTimeZone } from 'date-fns-tz';
const kstDate = formatInTimeZone(utcDate, 'Asia/Seoul', 'yyyy-MM-dd HH:mm:ss');
API 응답 형식:
{
"createdAt": "2025-12-22T06:00:00Z" // ISO 8601 UTC
}
대안: 모든 구간 KST 통일 (한국 전용 서비스)
글로벌 서비스가 아니고, 한국 사용자만 대상이라면 KST로 통일하는 것도 가능하다.
// DSN
dsn := "host=localhost user=app dbname=myapp TimeZone=Asia/Seoul"
// Go 런타임 (main 또는 init)
func init() {
loc, err := time.LoadLocation("Asia/Seoul")
if err != nil {
panic(err)
}
time.Local = loc
}
Docker 환경에서 TZ 설정:
# docker-compose.yml
services:
api:
environment:
TZ: UTC # 백엔드는 UTC 권장
postgres:
environment:
TZ: UTC
TIMESTAMPTZ vs TIMESTAMP
PostgreSQL에서 타임존 처리:
| 타입 | 저장 방식 | 권장 |
|---|---|---|
TIMESTAMP |
타임존 정보 없이 저장 | 비권장 |
TIMESTAMPTZ |
UTC로 변환 후 저장, 조회 시 세션 타임존으로 변환 | 권장 |
GORM에서 time.Time 필드는 기본적으로 TIMESTAMPTZ로 매핑된다.
디버깅 방법
-- PostgreSQL 세션 타임존 확인
SHOW timezone;
-- 서버 타임존 확인
SELECT current_setting('TIMEZONE');
-- 현재 시간 비교
SELECT NOW(), NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'Asia/Seoul';
// Go 타임존 확인
fmt.Println("Local:", time.Local)
fmt.Println("Now:", time.Now())
fmt.Println("Now UTC:", time.Now().UTC())
2. Zero Value 업데이트 문제
문제 상황
구조체를 사용하여 업데이트할 때, Go의 Zero Value가 무시되는 현상이 발생한다.
type User struct {
ID uuid.UUID
Name string
IsActive bool
}
// IsActive를 false로 변경하고 싶음
user.IsActive = false
db.Model(&user).Updates(user)
기대 결과: is_active가 false로 업데이트
실제 결과: is_active가 변경되지 않음
원인 분석
GORM은 구조체 업데이트 시 Zero Value(false, 0, "")를 "변경 의도 없음"으로 간주하고 무시한다. 이는 의도하지 않은 필드 덮어쓰기를 방지하기 위한 설계다.
// GORM 내부 동작 (의사 코드)
for field, value := range struct {
if value == zeroValue {
continue // 무시
}
updateFields[field] = value
}
해결 방법
방법 1: Map 사용 (권장)
db.Model(&user).Updates(map[string]interface{}{
"is_active": false,
})
방법 2: Select로 필드 명시
db.Model(&user).Select("IsActive").Updates(user)
방법 3: 포인터 타입 사용
type User struct {
ID uuid.UUID
Name string
IsActive *bool `gorm:"default:true"`
}
falseVal := false
user.IsActive = &falseVal
db.Model(&user).Updates(user)
권장 패턴
업데이트 함수에서 Map을 일관되게 사용:
func (r *userRepository) UpdateStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
return r.db.WithContext(ctx).
Model(&entity.User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"is_active": isActive,
}).Error
}
2. AutoMigrate와 init.sql 권한 충돌
문제 상황
Docker Compose로 PostgreSQL을 구성할 때, init.sql로 테이블을 생성하고 애플리케이션에서 AutoMigrate를 실행하면 다음 에러가 발생한다.
ERROR: permission denied for table users
또는 테이블이 존재함에도 재생성을 시도:
ERROR: relation "users" already exists (SQLSTATE 42P07)
원인 분석
시나리오 1: 권한 문제
# docker-compose.yml
services:
postgres:
environment:
POSTGRES_USER: postgres # 슈퍼유저
POSTGRES_DB: myapp
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
-- init.sql (postgres 슈퍼유저로 실행됨)
CREATE TABLE users (
id UUID PRIMARY KEY,
name VARCHAR(100)
);
// 애플리케이션 (app_user로 접속)
dsn := "user=app_user password=secret dbname=myapp"
db.AutoMigrate(&User{}) // app_user는 postgres가 만든 테이블 수정 권한 없음
테이블 소유자가 postgres이고, 애플리케이션이 app_user로 접속하면 테이블 스키마를 수정할 권한이 없다.
시나리오 2: 스키마 불일치
-- init.sql
CREATE TABLE myschema.users (...);
// 애플리케이션은 public 스키마를 봄
dsn := "dbname=myapp" // search_path 미지정
db.Migrator().HasTable(&User{}) // public.users 검사 → false
db.AutoMigrate(&User{}) // public.users 생성 시도 → 실패 (이미 myschema에 존재)
해결 방법
방법 1: 스키마 관리 단일화 (권장)
init.sql은 데이터베이스와 사용자 생성만 담당하고, 테이블 생성은 애플리케이션에 위임:
-- init.sql
CREATE USER app_user WITH PASSWORD 'secret';
CREATE DATABASE myapp OWNER app_user;
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;
// 애플리케이션에서 테이블 생성
db.AutoMigrate(&User{}, &Post{})
방법 2: init.sql에서 권한 부여
-- init.sql
CREATE TABLE users (...);
GRANT ALL ON TABLE users TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
방법 3: 동일 사용자로 통일
services:
postgres:
environment:
POSTGRES_USER: app_user # 동일 사용자
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
진단 명령어
-- 테이블 소유자 확인
SELECT tablename, tableowner FROM pg_tables WHERE schemaname = 'public';
-- 현재 사용자 확인
SELECT current_user, session_user;
-- 권한 확인
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'users';
3. AutoMigrate "relation already exists" (SQLSTATE 42P07)
문제 상황
ERROR: relation "regions" already exists (SQLSTATE 42P07)
테이블이 이미 존재함에도 GORM이 CREATE TABLE을 시도한다.
원인 분석
GORM의 AutoMigrate는 내부적으로 Migrator().HasTable()로 테이블 존재 여부를 확인한다. 이 검사가 False Negative를 반환하면 CREATE를 시도한다.
주요 원인:
- search_path 불일치: 테이블이 다른 스키마에 존재
- 권한 문제: information_schema 조회 권한 없음
- 대소문자 이슈: PostgreSQL은 기본 소문자, 수동 생성 시 대문자 포함 가능
해결 방법
방법 1: DSN에 search_path 명시
dsn := "host=localhost user=app dbname=myapp search_path=public"
방법 2: TableName으로 스키마 명시
func (Region) TableName() string {
return "public.regions"
}
방법 3: 조건부 마이그레이션
if !db.Migrator().HasTable(&Region{}) {
if err := db.AutoMigrate(&Region{}); err != nil {
log.Printf("Migration skipped or failed: %v", err)
}
}
4. PostgreSQL DSN 옵션 이해
자주 사용하는 DSN 옵션
dsn := "host=localhost port=5432 user=app password=secret dbname=myapp sslmode=disable TimeZone=UTC search_path=public connect_timeout=10"
| 옵션 | 설명 | 기본값 |
|---|---|---|
host |
서버 주소 | localhost |
port |
포트 | 5432 |
user |
사용자 | (필수) |
password |
비밀번호 | (없음) |
dbname |
데이터베이스 | (필수) |
sslmode |
SSL 모드 | prefer |
TimeZone |
세션 타임존 | 서버 기본값 |
search_path |
스키마 검색 경로 | "$user", public |
connect_timeout |
연결 타임아웃(초) | 0 (무한) |
application_name |
클라이언트 식별자 | (없음) |
SSL 모드 옵션
| 값 | 설명 |
|---|---|
disable |
SSL 비활성화 |
require |
SSL 필수 (인증서 검증 안함) |
verify-ca |
CA 인증서 검증 |
verify-full |
CA + 호스트명 검증 |
환경별 권장 설정
개발 환경:
dsn := "host=localhost user=dev password=dev dbname=myapp_dev sslmode=disable"
프로덕션 환경:
dsn := "host=db.example.com user=app password=*** dbname=myapp sslmode=require connect_timeout=10 application_name=api-server"
5. Preload 순서와 N+1 문제
문제 상황
var users []User
db.Find(&users)
for _, user := range users {
fmt.Println(user.Organization.Name) // N+1 쿼리 발생!
}
해결 방법
Preload 사용:
db.Preload("Organization").Find(&users)
중첩 Preload:
db.Preload("Organizations", func(db *gorm.DB) *gorm.DB {
return db.Order("is_primary DESC")
}).Preload("Organizations.Organization").Find(&users)
조건부 Preload:
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Where("is_published = ?", true).Limit(5)
}).Find(&users)
6. Soft Delete 주의사항
문제 상황
Soft Delete된 레코드가 조회되지 않아 "레코드 없음" 오류가 발생한다.
// 삭제
db.Delete(&user)
// 이후 조회
db.First(&user, id) // ErrRecordNotFound (삭제된 레코드)
해결 방법
삭제된 레코드 포함 조회:
db.Unscoped().First(&user, id)
영구 삭제:
db.Unscoped().Delete(&user)
모든 레코드 조회 (삭제 포함):
db.Unscoped().Find(&users)
7. 복합 조건 쿼리 시 괄호 문제
문제 상황
// 의도: (A OR B) AND C
db.Where("status = ? OR status = ?", "active", "pending").
Where("org_id = ?", orgID).Find(&items)
// 실제 SQL: status = 'active' OR status = 'pending' AND org_id = '...'
// PostgreSQL에서 AND가 OR보다 우선순위 높음 → 의도와 다른 결과
해결 방법
OR 그룹화:
db.Where(
db.Where("status = ?", "active").Or("status = ?", "pending"),
).Where("org_id = ?", orgID).Find(&items)
Raw 조건 사용:
db.Where("(status = ? OR status = ?) AND org_id = ?", "active", "pending", orgID).Find(&items)
IN 절 사용 (권장):
db.Where("status IN ?", []string{"active", "pending"}).
Where("org_id = ?", orgID).Find(&items)
8. 트랜잭션 내 에러 핸들링
문제 상황
트랜잭션 내에서 에러가 발생했는데 롤백되지 않음:
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&user)
if someCondition {
log.Println("Error occurred")
// return 없음 → 커밋됨!
}
return nil
})
해결 방법
항상 error 반환:
db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err // 롤백
}
if someCondition {
return errors.New("validation failed") // 롤백
}
return nil // 커밋
})
9. Upsert (ON CONFLICT) 사용 시 주의사항
문제 상황
// 중복 시 업데이트하려고 Clauses 사용
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "updated_at"}),
}).Create(&user)
// 결과: updated_at이 업데이트되지 않음
원인 분석
AssignmentColumns는 충돌 시 해당 컬럼을 새 값으로 업데이트하지만, updated_at은 GORM의 자동 업데이트 대상이 아닌 경우 갱신되지 않는다.
해결 방법
// 방법 1: UpdateAll 사용
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
UpdateAll: true, // 모든 컬럼 업데이트
}).Create(&user)
// 방법 2: 명시적으로 시간 포함
now := time.Now()
user.UpdatedAt = now
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"name": user.Name,
"updated_at": now,
}),
}).Create(&user)
10. Hook이 Preload에서 동작하지 않음
문제 상황
func (u *User) AfterFind(tx *gorm.DB) error {
u.FullName = u.FirstName + " " + u.LastName
return nil
}
// Main 쿼리에서는 동작
db.First(&user, id) // AfterFind 호출됨
// Preload에서는 동작 안함
db.Preload("Author").Find(&posts) // Author의 AfterFind 호출 안됨
원인 분석
GORM의 Preload는 별도 쿼리로 실행되며, Hook 호출이 기본적으로 비활성화되어 있다.
해결 방법
// Preload 내에서 Hook을 수동 호출하거나, 조회 후 처리
var posts []Post
db.Preload("Author").Find(&posts)
for i := range posts {
posts[i].Author.AfterFind(db) // 수동 호출
}
// 또는 Preload 대신 Joins 사용 (단, 구조가 달라짐)
db.Joins("Author").Find(&posts)
11. 연결 끊김과 재연결
문제 상황
장시간 운영 후 갑자기 쿼리 실패:
pq: unexpected EOF
pq: connection reset by peer
driver: bad connection
원인 분석
- PostgreSQL의
idle_in_transaction_session_timeout - 네트워크 타임아웃 (NAT, 로드밸런서)
- PostgreSQL 재시작
해결 방법
sqlDB, _ := db.DB()
// 연결 최대 수명 (PostgreSQL idle timeout보다 짧게)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
// 유휴 연결 최대 시간
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
// 연결 풀 설정
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
Kubernetes 환경:
# PostgreSQL StatefulSet
env:
- name: POSTGRES_ARGS
value: "-c idle_in_transaction_session_timeout=60000" # 60초
12. FirstOrCreate 동시성 문제
문제 상황
// 두 요청이 동시에 실행
db.FirstOrCreate(&user, User{Email: "test@example.com"})
// 결과: 둘 다 "없음"으로 판단 → 중복 생성 시도 → 하나는 실패
원인 분석
FirstOrCreate는 SELECT → INSERT 두 단계로 동작하며, 이 사이에 Race Condition이 발생한다.
해결 방법
방법 1: Upsert 사용
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoNothing: true,
}).Create(&user)
// 이후 조회
db.Where("email = ?", user.Email).First(&user)
방법 2: DB 레벨 Unique 제약 + 에러 핸들링
err := db.Create(&user).Error
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
db.Where("email = ?", user.Email).First(&user)
return nil
}
return err
}
13. Find vs First vs Take 차이
문제 상황
var user User
db.Find(&user, id) // user가 비어있어도 에러 없음
db.First(&user, id) // 없으면 ErrRecordNotFound
db.Take(&user, id) // 없으면 ErrRecordNotFound
차이점
| 메서드 | 결과 없을 때 | 정렬 | 용도 |
|---|---|---|---|
Find |
에러 없음, 빈 값 | 없음 | 다중 조회, 없어도 OK |
First |
ErrRecordNotFound |
PK ASC | 단일 조회, 존재 필수 |
Take |
ErrRecordNotFound |
없음 | 아무거나 하나 |
올바른 사용
// 존재 여부가 중요한 경우
var user User
err := db.First(&user, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, repository.ErrNotFound
}
// 없어도 괜찮은 경우
var users []User
db.Where("org_id = ?", orgID).Find(&users) // 빈 슬라이스 반환
14. Raw SQL과 Scan 타입 불일치
문제 상황
type Result struct {
Count int64
Total float64
}
var result Result
db.Raw("SELECT COUNT(*) as count, SUM(amount) as total FROM orders").Scan(&result)
// result.Total = 0 (기대: 실제 합계)
원인 분석
PostgreSQL의 SUM()은 numeric 타입을 반환하는데, Go의 float64와 매핑이 안 될 수 있다.
해결 방법
// 방법 1: 타입 캐스팅
db.Raw("SELECT COUNT(*)::bigint as count, SUM(amount)::float as total FROM orders").Scan(&result)
// 방법 2: sql.NullFloat64 사용
type Result struct {
Count int64
Total sql.NullFloat64
}
15. 대량 Insert 시 메모리 문제
문제 상황
// 100만 건 Insert
var users []User // 메모리에 100만 개 로드
db.Create(&users) // OOM 또는 타임아웃
해결 방법
// CreateInBatches 사용
db.CreateInBatches(users, 1000) // 1000개씩 나눠서 Insert
// 또는 스트리밍 처리
batchSize := 1000
for i := 0; i < len(users); i += batchSize {
end := i + batchSize
if end > len(users) {
end = len(users)
}
if err := db.Create(users[i:end]).Error; err != nil {
return err
}
}
체크리스트
새 프로젝트에서 GORM 설정 시 확인 사항:
- DSN에
TimeZone설정 (UTC 또는 Asia/Seoul 통일) - DSN에
search_path설정 - 연결 풀 설정 (MaxOpenConns, MaxIdleConns, ConnMaxLifetime)
- Zero Value 업데이트는 Map 사용
- 관계 조회는 Preload 사용
- 스키마 관리 책임 단일화 (App 또는 DB)
- 프로덕션에서는 AutoMigrate 대신 마이그레이션 도구 사용
- Context 전파 (
WithContext) - 에러 타입 분류 (
ErrRecordNotFound체크) - FirstOrCreate 대신 Upsert 패턴 고려
- Docker 환경에서 TZ 환경변수 설정
참고 자료
공식 문서
관련 글
'실제 경험과 인사이트를 AI와 함께 정리한 글' 카테고리의 다른 글
| HTTP Cookie Deep Dive: 웹 상태 관리의 핵심 (0) | 2025.12.22 |
|---|---|
| Next.js + Tailwind CSS v4 + shadcn/ui 테마 시스템 구축 가이드 (0) | 2025.12.22 |
| GORM 기반 엔터프라이즈 Go API Server 아키텍처 (0) | 2025.12.22 |
| GORM 소개: Go 개발자를 위한 ORM 완벽 가이드 (0) | 2025.12.22 |
| Claude Code로 세련된 UI 만들기 (0) | 2025.12.18 |
- Total
- Today
- Yesterday
- frontend
- Tailwind CSS
- 개발 도구
- AI
- troubleshooting
- Ontology
- knowledge graph
- claude code
- api gateway
- LLM
- LangChain
- workflow
- PYTHON
- Rag
- SHACL
- Developer Tools
- ai 개발 도구
- security
- Claude
- authorization
- architecture
- AI agent
- Tax Analysis
- AI Development
- authentication
- backend
- Kubernetes
- Next.js
- Go
- react
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |