ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • GORM 기반 엔터프라이즈 Go API Server 아키텍처
    실제 경험과 인사이트를 AI와 함께 정리한 글 2025. 12. 22. 13:19

    작성일: 2025년 12월 22일
    카테고리: Backend, Go, Architecture
    키워드: Go, GORM, Clean Architecture, Repository Pattern, PostgreSQL

    요약

    엔터프라이즈급 Go API Server를 구축할 때 GORM을 어떻게 활용해야 하는지 실제 프로젝트 사례를 통해 설명한다. Clean Architecture 기반의 계층 분리, Repository 패턴 구현, Custom Type을 활용한 JSONB 처리, 그리고 멀티테넌시와 감사 로그 설계까지 프로덕션 환경에서 검증된 패턴을 다룬다.

    프로젝트 구조

    Clean Architecture를 적용한 Go API Server의 표준 구조다.

    api/
    ├── cmd/server/              # 애플리케이션 진입점
    │   └── main.go
    ├── internal/
    │   ├── domain/              # 도메인 계층
    │   │   ├── entity/          # GORM 엔티티 정의
    │   │   ├── repository/      # Repository 인터페이스
    │   │   └── service/         # 도메인 서비스
    │   ├── infrastructure/      # 인프라 계층
    │   │   ├── persistence/     # Repository 구현, DB 연결
    │   │   └── client/          # 외부 서비스 클라이언트
    │   ├── interface/           # 프레젠테이션 계층
    │   │   └── api/v1/          # HTTP 핸들러
    │   └── usecase/             # 비즈니스 로직 오케스트레이션
    ├── pkg/
    │   ├── config/              # 설정 관리
    │   └── logger/              # 로깅
    └── go.mod

    계층별 책임

    계층 위치 책임
    Domain internal/domain 엔티티 정의, Repository 인터페이스, 비즈니스 규칙
    Infrastructure internal/infrastructure Repository 구현, DB 연결, 외부 시스템 통합
    Interface internal/interface HTTP 핸들러, 요청/응답 변환
    UseCase internal/usecase 비즈니스 로직 조합, 트랜잭션 경계

    의존성 방향

    Interface → UseCase → Domain ← Infrastructure

    Domain 계층은 다른 계층에 의존하지 않으며, Infrastructure가 Domain의 인터페이스를 구현한다.

    베이스 모델 설계

    UUID 기반 Primary Key

    분산 시스템에서는 Auto Increment 대신 UUID를 사용한다.

    // internal/domain/entity/base.go
    package entity
    
    import (
        "time"
    
        "github.com/google/uuid"
        "gorm.io/gorm"
    )
    
    // gen_random_uuid()는 PostgreSQL 13+ 기본 제공, 이전 버전은 pgcrypto 확장 필요
    type BaseModel struct {
        ID        uuid.UUID      `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
        CreatedAt time.Time      `gorm:"autoCreateTime"`
        UpdatedAt time.Time      `gorm:"autoUpdateTime"`
        DeletedAt gorm.DeletedAt `gorm:"index"`
    }

    UUID 선택 이유:

    • 분산 환경에서 ID 충돌 없이 생성 가능
    • 데이터베이스 간 마이그레이션 용이
    • URL에 노출되어도 순서 추론 불가 (보안)

    타임스탬프 자동화

    GORM의 autoCreateTime, autoUpdateTime 태그를 사용하면 별도 코드 없이 시간이 자동 기록된다.

    Repository 패턴 구현

    인터페이스 정의

    Repository 인터페이스는 Domain 계층에 정의한다.

    // internal/domain/repository/user_repository.go
    package repository
    
    import (
        "context"
    
        "github.com/google/uuid"
        "myapp/internal/domain/entity"
    )
    
    var ErrNotFound = errors.New("record not found")
    
    type UserRepository interface {
        Create(ctx context.Context, user *entity.User) error
        FindByID(ctx context.Context, id uuid.UUID) (*entity.User, error)
        FindByEmail(ctx context.Context, email string) (*entity.User, error)
        FindAll(ctx context.Context, search string, offset, limit int) ([]*entity.User, int64, error)
        Update(ctx context.Context, user *entity.User) error
        Delete(ctx context.Context, id uuid.UUID) error
        Count(ctx context.Context) (int64, error)
    }

    설계 원칙:

    • 모든 메서드는 context.Context를 첫 번째 인자로 받음
    • 표준 에러 정의 (ErrNotFound)
    • 페이지네이션은 offset, limit 파라미터와 total 반환

    Repository 구현

    Infrastructure 계층에서 인터페이스를 구현한다.

    // internal/infrastructure/persistence/user_repository.go
    package persistence
    
    import (
        "context"
        "errors"
    
        "github.com/google/uuid"
        "gorm.io/gorm"
        "myapp/internal/domain/entity"
        "myapp/internal/domain/repository"
    )
    
    type userRepository struct {
        db *gorm.DB
    }
    
    func NewUserRepository(db *Database) repository.UserRepository {
        return &userRepository{db: db.DB}
    }
    
    func (r *userRepository) Create(ctx context.Context, user *entity.User) error {
        return r.db.WithContext(ctx).Create(user).Error
    }
    
    func (r *userRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.User, error) {
        var user entity.User
        err := r.db.WithContext(ctx).
            Preload("Organizations", func(db *gorm.DB) *gorm.DB {
                return db.Order("is_primary DESC, created_at ASC")
            }).
            Preload("Organizations.Organization").
            Where("id = ?", id).
            First(&user).Error
    
        if err != nil {
            if errors.Is(err, gorm.ErrRecordNotFound) {
                return nil, repository.ErrNotFound
            }
            return nil, err
        }
        return &user, nil
    }
    
    func (r *userRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) {
        var user entity.User
        err := r.db.WithContext(ctx).
            Where("email = ?", email).
            First(&user).Error
    
        if err != nil {
            if errors.Is(err, gorm.ErrRecordNotFound) {
                return nil, repository.ErrNotFound
            }
            return nil, err
        }
        return &user, nil
    }
    
    func (r *userRepository) FindAll(ctx context.Context, search string, offset, limit int) ([]*entity.User, int64, error) {
        var users []*entity.User
        var total int64
    
        query := r.db.WithContext(ctx).Model(&entity.User{})
    
        // PostgreSQL ILIKE: 대소문자 무관 검색
        if search != "" {
            pattern := "%" + search + "%"
            query = query.Where(
                "name ILIKE ? OR email ILIKE ?",
                pattern, pattern,
            )
        }
    
        if err := query.Count(&total).Error; err != nil {
            return nil, 0, err
        }
    
        if err := r.db.WithContext(ctx).
            Preload("Organizations", func(db *gorm.DB) *gorm.DB {
                return db.Order("is_primary DESC")
            }).
            Order("created_at DESC").
            Offset(offset).
            Limit(limit).
            Find(&users).Error; err != nil {
            return nil, 0, err
        }
    
        return users, total, nil
    }
    
    func (r *userRepository) Update(ctx context.Context, user *entity.User) error {
        return r.db.WithContext(ctx).Save(user).Error
    }
    
    func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error {
        return r.db.WithContext(ctx).Delete(&entity.User{}, id).Error
    }
    
    func (r *userRepository) Count(ctx context.Context) (int64, error) {
        var count int64
        err := r.db.WithContext(ctx).Model(&entity.User{}).Count(&count).Error
        return count, err
    }

    핵심 패턴

    1. Context 전파

    모든 쿼리에 WithContext(ctx)를 사용하여 요청 스코프의 취소와 타임아웃을 지원한다.

    db.WithContext(ctx).First(&user, id)

    2. 에러 매핑

    GORM의 에러를 도메인 에러로 변환한다.

    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, repository.ErrNotFound
    }

    3. Preload로 N+1 해결

    관계 데이터는 항상 Preload로 로드한다.

    db.Preload("Organizations").
       Preload("Organizations.Organization").
       First(&user, id)

    Custom Type 구현

    PostgreSQL의 JSONB 컬럼을 Go 타입으로 매핑하려면 driver.Valuersql.Scanner 인터페이스를 구현해야 한다.

    StringList 타입

    // internal/domain/entity/types.go
    package entity
    
    import (
        "database/sql/driver"
        "encoding/json"
    )
    
    type StringList []string
    
    // DB에 저장할 때 호출
    func (s StringList) Value() (driver.Value, error) {
        if s == nil {
            return "[]", nil
        }
        return json.Marshal(s)
    }
    
    // DB에서 읽을 때 호출
    func (s *StringList) Scan(value interface{}) error {
        if value == nil {
            *s = StringList{}
            return nil
        }
    
        var bytes []byte
        switch v := value.(type) {
        case []byte:
            bytes = v
        case string:
            bytes = []byte(v)
        default:
            return errors.New("unsupported type for StringList")
        }
    
        return json.Unmarshal(bytes, s)
    }

    JSONMap 타입

    type JSONMap map[string]interface{}
    
    func (j JSONMap) Value() (driver.Value, error) {
        if j == nil {
            return "{}", nil
        }
        return json.Marshal(j)
    }
    
    func (j *JSONMap) Scan(value interface{}) error {
        if value == nil {
            *j = JSONMap{}
            return nil
        }
    
        var bytes []byte
        switch v := value.(type) {
        case []byte:
            bytes = v
        case string:
            bytes = []byte(v)
        default:
            return errors.New("unsupported type for JSONMap")
        }
    
        return json.Unmarshal(bytes, j)
    }

    사용 예시

    type User struct {
        BaseModel
        Email       string     `gorm:"type:varchar(255);uniqueIndex;not null"`
        LegalsAgreed StringList `gorm:"type:jsonb;default:'[]'"`
        Preferences  JSONMap    `gorm:"type:jsonb;default:'{}'"`
    }

    트랜잭션 패턴

    상태 전이가 포함된 트랜잭션

    // internal/infrastructure/persistence/snapshot_repository.go
    
    func (r *snapshotRepository) MarkDeployed(ctx context.Context, id uuid.UUID) error {
        // 1. 현재 스냅샷 조회
        var snapshot entity.Snapshot
        if err := r.db.WithContext(ctx).Where("id = ?", id).First(&snapshot).Error; err != nil {
            return err
        }
    
        // 2. 트랜잭션 내에서 상태 전이
        return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
            // 이전 Deployed 스냅샷을 Superseded로 변경
            if err := tx.Model(&entity.Snapshot{}).
                Where("environment_id = ? AND status = ? AND id != ?",
                    snapshot.EnvironmentID, entity.SnapshotStatusDeployed, id).
                Update("status", entity.SnapshotStatusSuperseded).Error; err != nil {
                return err
            }
    
            // 현재 스냅샷을 Deployed로 변경
            if err := tx.Model(&entity.Snapshot{}).
                Where("id = ?", id).
                Updates(map[string]interface{}{
                    "status":      entity.SnapshotStatusDeployed,
                    "deployed_at": time.Now(),
                }).Error; err != nil {
                return err
            }
    
            return nil // 커밋
        })
    }

    부분 업데이트

    Zero Value 문제를 피하기 위해 Map을 사용한다.

    func (r *snapshotRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status entity.SnapshotStatus) error {
        updates := map[string]interface{}{
            "status": status,
        }
        if status == entity.SnapshotStatusDeployed {
            updates["deployed_at"] = time.Now()
        }
        return r.db.WithContext(ctx).
            Model(&entity.Snapshot{}).
            Where("id = ?", id).
            Updates(updates).Error
    }

    멀티테넌시 구현

    Organization 기반 데이터 격리

    type Organization struct {
        BaseModel
        Name string `gorm:"type:varchar(100);not null"`
        Slug string `gorm:"type:varchar(50);uniqueIndex;not null"`
    }
    
    type Gateway struct {
        BaseModel
        AppID  string     `gorm:"type:varchar(20);uniqueIndex;not null"`
        OrgID  *uuid.UUID `gorm:"type:uuid;index"` // nullable: 시스템 리소스
        // ...
    }

    테넌트 필터링

    func (r *gatewayRepository) FindByOrganization(ctx context.Context, orgID uuid.UUID, offset, limit int) ([]*entity.Gateway, int64, error) {
        var gateways []*entity.Gateway
        var total int64
    
        query := r.db.WithContext(ctx).
            Model(&entity.Gateway{}).
            Where("org_id = ?", orgID)
    
        if err := query.Count(&total).Error; err != nil {
            return nil, 0, err
        }
    
        if err := r.db.WithContext(ctx).
            Where("org_id = ?", orgID).
            Order("created_at DESC").
            Offset(offset).
            Limit(limit).
            Find(&gateways).Error; err != nil {
            return nil, 0, err
        }
    
        return gateways, total, nil
    }

    감사 로그 설계

    AuditLog 엔티티

    type AuditLog struct {
        BaseModel
        ActorID      uuid.UUID `gorm:"type:uuid;index;not null"`
        ActorType    string    `gorm:"type:varchar(50);not null"` // user, system, api_key
        Action       string    `gorm:"type:varchar(50);not null"` // create, update, delete
        ResourceType string    `gorm:"type:varchar(50);index;not null"`
        ResourceID   uuid.UUID `gorm:"type:uuid;index;not null"`
        Changes      JSONMap   `gorm:"type:jsonb"`
        IPAddress    string    `gorm:"type:varchar(45)"`
        UserAgent    string    `gorm:"type:varchar(255)"`
    }

    감사 로그 기록

    func (r *auditLogRepository) Log(ctx context.Context, log *entity.AuditLog) error {
        return r.db.WithContext(ctx).Create(log).Error
    }
    
    // UseCase에서 사용
    func (uc *GatewayUseCase) Update(ctx context.Context, id uuid.UUID, input UpdateGatewayInput) error {
        gateway, err := uc.gatewayRepo.FindByID(ctx, id)
        if err != nil {
            return err
        }
    
        oldValues := map[string]interface{}{
            "display_name": gateway.DisplayName,
            "is_active":    gateway.IsActive,
        }
    
        gateway.DisplayName = input.DisplayName
        gateway.IsActive = input.IsActive
    
        if err := uc.gatewayRepo.Update(ctx, gateway); err != nil {
            return err
        }
    
        // 감사 로그 기록
        return uc.auditRepo.Log(ctx, &entity.AuditLog{
            ActorID:      input.ActorID,
            ActorType:    "user",
            Action:       "update",
            ResourceType: "gateway",
            ResourceID:   gateway.ID,
            Changes: entity.JSONMap{
                "old": oldValues,
                "new": map[string]interface{}{
                    "display_name": input.DisplayName,
                    "is_active":    input.IsActive,
                },
            },
        })
    }

    데이터베이스 연결 래퍼

    // internal/infrastructure/persistence/database.go
    package persistence
    
    import (
        "time"
    
        "gorm.io/driver/postgres"
        "gorm.io/gorm"
        "gorm.io/gorm/logger"
        "myapp/pkg/config"
    )
    
    type Database struct {
        *gorm.DB
    }
    
    func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) {
        dsn := cfg.DSN()
    
        gormConfig := &gorm.Config{
            Logger: logger.Default.LogMode(logger.Info),
        }
    
        db, err := gorm.Open(postgres.Open(dsn), gormConfig)
        if err != nil {
            return nil, err
        }
    
        sqlDB, err := db.DB()
        if err != nil {
            return nil, err
        }
    
        sqlDB.SetMaxIdleConns(10)
        sqlDB.SetMaxOpenConns(100)
        sqlDB.SetConnMaxLifetime(time.Hour)
    
        return &Database{DB: db}, nil
    }
    
    func (d *Database) AutoMigrate(models ...interface{}) error {
        return d.DB.AutoMigrate(models...)
    }
    
    func (d *Database) Close() error {
        sqlDB, err := d.DB.DB()
        if err != nil {
            return err
        }
        return sqlDB.Close()
    }

    의존성 주입

    // cmd/server/main.go
    func main() {
        cfg := config.Load()
    
        // 1. Database 연결
        db, err := persistence.NewDatabase(&cfg.Database)
        if err != nil {
            log.Fatal("Failed to connect database:", err)
        }
        defer db.Close()
    
        // 2. AutoMigrate
        if err := db.AutoMigrate(
            &entity.User{},
            &entity.Organization{},
            &entity.Gateway{},
            &entity.AuditLog{},
        ); err != nil {
            log.Fatal("Failed to migrate:", err)
        }
    
        // 3. Repository 초기화
        userRepo := persistence.NewUserRepository(db)
        orgRepo := persistence.NewOrganizationRepository(db)
        gatewayRepo := persistence.NewGatewayRepository(db)
        auditRepo := persistence.NewAuditLogRepository(db)
    
        // 4. UseCase 초기화
        gatewayUseCase := usecase.NewGatewayUseCase(gatewayRepo, auditRepo)
    
        // 5. Handler 초기화
        deps := &api.RouterDeps{
            UserRepo:       userRepo,
            OrgRepo:        orgRepo,
            GatewayUseCase: gatewayUseCase,
        }
    
        router := api.NewRouter(deps)
        router.Run(":8080")
    }

    마이그레이션 전략

    개발 환경

    AutoMigrate 사용:

    if cfg.Environment == "development" {
        db.AutoMigrate(&entity.User{}, &entity.Gateway{})
    }

    프로덕션 환경

    전용 마이그레이션 도구 사용 권장:

    # golang-migrate 사용 예시
    migrate -path ./migrations -database "postgres://..." up

    마이그레이션 파일 구조:

    migrations/
    ├── 000001_create_users.up.sql
    ├── 000001_create_users.down.sql
    ├── 000002_create_gateways.up.sql
    └── 000002_create_gateways.down.sql

    참고 자료

    공식 문서

    관련 글

Designed by Tistory.