본문 바로가기
개발의 기록/Server

Java/Kotlin Spring 개발자의 Go 전환기 #6: GORM으로 데이터베이스 접근하기

by prographer J 2025. 12. 10.
728x90

"JPA처럼 편한 ORM이 Go에도 있나요?"

 

📌 이 글의 핵심

  • Spring JPA 개발자를 위한 GORM 완벽 가이드
  • JPA와 GORM의 유사점과 차이점 비교
  • Clean Architecture와 GORM 통합 패턴
  • GORM을 사용한 실전 Repository 구현

🎯 이런 분들께 추천합니다

  • Spring JPA/Hibernate에 익숙한 백엔드 개발자
  • Go에서 ORM을 사용하고 싶은 개발자
  • JPA의 편리함을 Go에서도 누리고 싶은 팀

⏱️ 읽는 시간: 약 12분


들어가며: Go에도 ORM이 있습니다!

Spring Boot에서 Go로 전환하면서 가장 궁금했던 점:

"JPA처럼 편한 ORM이 Go에도 있나요?"

답은 네, GORM이 있습니다!

많은 Go 개발자들이 Raw SQL을 권장하지만, 대부분의 실무 프로젝트에서는 ORM이 더 나은 선택입니다.


GORM이란?

GORM(Go Object-Relational Mapping)은 Go에서 가장 인기 있는 ORM 라이브러리입니다.

GitHub Stars: 37k+ (Spring Data JPA: 3k+)

주요 특징

  • JPA와 유사한 API - Java 개발자에게 익숙함
  • 풍부한 기능 - Association, Hook, Transaction 등
  • 자동 마이그레이션 - 테이블 자동 생성/수정
  • 다양한 DB 지원 - PostgreSQL, MySQL, SQLite 등

Spring JPA vs GORM 비교

Spring JPA (Java)

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(unique = true, nullable = false)
    private String email;

    private String name;
    private String provider;

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
    Optional<User> findByEmail(String email);
}

GORM (Go)

type User struct {
    ID        string    `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
    Email     string    `gorm:"uniqueIndex;not null"`
    Name      string
    Provider  string
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

type UserRepository struct {
    db *gorm.DB
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var user User
    if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
        return nil, err
    }
    return &user, nil
}

 

핵심 차이:

  • JPA: 인터페이스만 정의하면 자동 구현
  • GORM: 메서드를 직접 작성하지만 쿼리 빌더 제공

GORM 시작하기

1. 설치

go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

2. 데이터베이스 연결

// internal/infrastructure/postgres/database.go
package postgres

import (
    "fmt"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

type Database struct {
    DB *gorm.DB
}

func NewDatabase(dsn string) (*Database, error) {
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to connect database: %w", err)
    }

    return &Database{DB: db}, nil
}

func (d *Database) Close() error {
    sqlDB, err := d.DB.DB()
    if err != nil {
        return err
    }
    return sqlDB.Close()
}

3. 모델 정의

// internal/infrastructure/postgres/models/user.go
package models

import "time"

type User struct {
    ID        string    `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
    Email     string    `gorm:"uniqueIndex;not null"`
    Name      string    `gorm:"size:255"`
    Provider  string    `gorm:"size:50;not null"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

// TableName 메서드로 테이블명 커스터마이징
func (User) TableName() string {
    return "users"
}

 


 

GORM 태그 완벽 가이드

JPA Annotation → GORM Tag 매핑

JPA Annotation GORM Tag 설명
@Id primaryKey 기본 키
@GeneratedValue autoIncrement 또는 default:gen_random_uuid() 키자동 생성
@Column(unique = true) uniqueIndex 유니크 제약
@Column(nullable = false) not null NOT NULL 제약
@Column(length = 255) size:255 길이 제한
@CreatedDate autoCreateTime 생성 시각 자동 설정
@LastModifiedDate autoUpdateTime 수정 시각 자동 업데이트
@ManyToOne foreignKey 외래 키
@OneToMany constraint:OnDelete:CASCADE 연관 관계

실전 예제

type Project struct {
    ID          string    `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
    Name        string    `gorm:"not null;size:255"`
    Description string    `gorm:"type:text"`
    OwnerID     string    `gorm:"not null;type:uuid"`
    Owner       User      `gorm:"foreignKey:OwnerID"` // JPA의 @ManyToOne
    Status      string    `gorm:"default:'active';size:50"`
    CreatedAt   time.Time `gorm:"autoCreateTime"`
    UpdatedAt   time.Time `gorm:"autoUpdateTime"`
}

 


 

Clean Architecture와 GORM 통합

문제: GORM 모델을 Domain에 둘 수 없다

JPA 방식 (잘못된 예):

// ❌ 나쁜 예: Domain에 GORM 태그
// internal/domain/user/user.go
package user

import "gorm.io/gorm"

type User struct {
    gorm.Model  // ← Domain이 Infrastructure(GORM)에 의존!
    Email string `gorm:"uniqueIndex"`
}

해결: Domain과 Model 분리

1. Domain 엔티티 (순수 Go 구조체)

// internal/domain/user/user.go
package user

import "time"

// Domain 엔티티: 인프라에 독립적
type User struct {
    ID        string
    Email     string
    Name      string
    Provider  string
    CreatedAt time.Time
    UpdatedAt time.Time
}

 

2. GORM 모델 (Infrastructure)

// internal/infrastructure/postgres/models/user.go
package models

import (
    "time"
    domainUser "myapp/internal/domain/user"
)

// GORM 모델: DB 구조 정의
type User struct {
    ID        string    `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
    Email     string    `gorm:"uniqueIndex;not null"`
    Name      string    `gorm:"size:255"`
    Provider  string    `gorm:"size:50;not null"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

// Domain 엔티티로 변환
func (m *User) ToDomain() *domainUser.User {
    return &domainUser.User{
        ID:        m.ID,
        Email:     m.Email,
        Name:      m.Name,
        Provider:  m.Provider,
        CreatedAt: m.CreatedAt,
        UpdatedAt: m.UpdatedAt,
    }
}

// Domain 엔티티에서 변환
func FromDomain(u *domainUser.User) *User {
    return &User{
        ID:        u.ID,
        Email:     u.Email,
        Name:      u.Name,
        Provider:  u.Provider,
        CreatedAt: u.CreatedAt,
        UpdatedAt: u.UpdatedAt,
    }
}

 

3. Repository 구현

// internal/infrastructure/postgres/user_repository.go
package postgres

import (
    "context"
    "errors"
    "gorm.io/gorm"

    domainUser "myapp/internal/domain/user"
    "myapp/internal/infrastructure/postgres/models"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *domainUser.User) error {
    model := models.FromDomain(user)

    if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
        return err
    }

    // ID, CreatedAt 등 자동 생성된 값 반영
    *user = *model.ToDomain()
    return nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domainUser.User, error) {
    var model models.User

    err := r.db.WithContext(ctx).
        Where("email = ?", email).
        First(&model).Error

    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, domainUser.ErrNotFound
        }
        return nil, err
    }

    return model.ToDomain(), nil
}

func (r *UserRepository) Update(ctx context.Context, user *domainUser.User) error {
    model := models.FromDomain(user)

    return r.db.WithContext(ctx).
        Model(&models.User{}).
        Where("id = ?", user.ID).
        Updates(model).Error
}

func (r *UserRepository) Delete(ctx context.Context, id string) error {
    return r.db.WithContext(ctx).
        Delete(&models.User{}, "id = ?", id).Error
}

GORM 주요 기능

1. 쿼리 빌더 (JPA Criteria API와 유사)

// JPA 방식
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
cq.where(
    cb.and(
        cb.equal(user.get("provider"), "google"),
        cb.greaterThan(user.get("createdAt"), date)
    )
);

// GORM 방식
db.Where("provider = ? AND created_at > ?", "google", date).Find(&users)

// 또는 체이닝
db.Where("provider = ?", "google").
   Where("created_at > ?", date).
   Find(&users)

2. Association (연관 관계)

type Project struct {
    ID      string `gorm:"primaryKey"`
    Name    string
    OwnerID string
    Owner   User `gorm:"foreignKey:OwnerID"` // JPA @ManyToOne
    Members []ProjectMember `gorm:"foreignKey:ProjectID"` // JPA @OneToMany
}

// Eager Loading (JPA FetchType.EAGER)
var project Project
db.Preload("Owner").First(&project, projectID)

// Lazy Loading은 명시적으로
var project Project
db.First(&project, projectID)
// 나중에 필요할 때
db.Model(&project).Association("Owner").Find(&project.Owner)

3. Hook (JPA @PrePersist, @PostLoad)

// JPA 방식
@Entity
public class User {
    @PrePersist
    void prePersist() { }

    @PostLoad
    void postLoad() { }
}

// GORM 방식
func (u *User) BeforeCreate(tx *gorm.DB) error {
    // JPA @PrePersist
    u.ID = uuid.New().String()
    return nil
}

func (u *User) AfterFind(tx *gorm.DB) error {
    // JPA @PostLoad
    // 추가 처리...
    return nil
}

4. Transaction (JPA @Transactional)

// JPA 방식
@Transactional
public void createUserAndProject(User user, Project project) {
    userRepository.save(user);
    projectRepository.save(project);
}

// GORM 방식
func (s *Service) CreateUserAndProject(ctx context.Context, user *User, project *Project) error {
    return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        if err := tx.Create(user).Error; err != nil {
            return err
        }

        if err := tx.Create(project).Error; err != nil {
            return err
        }

        return nil
    })
}

 

자동 마이그레이션

Spring JPA 방식

# application.yml
spring:
  jpa:
    hibernate:
      ddl-auto: update  # create, create-drop, validate, update

GORM 방식

func (d *Database) AutoMigrate() error {
    return d.DB.AutoMigrate(
        &models.User{},
        &models.Project{},
        &models.Role{},
        &models.Permission{},
    )
}

// main.go
func main() {
    db := postgres.NewDatabase(dsn)

    // 개발 환경에서만
    if config.Env == "development" {
        db.AutoMigrate()
    }
}

 

주의: 프로덕션에서는 AutoMigrate 대신 마이그레이션 도구 사용 권장 (golang-migrate 등)


 

실전 예제: 로그인 기능 구현

1. Domain 정의

// internal/domain/user/user.go
package user

import (
    "context"
    "time"
)

type User struct {
    ID        string
    Email     string
    Name      string
    Provider  string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type Repository interface {
    Create(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
    Update(ctx context.Context, user *User) error
}

2. GORM Repository 구현

// internal/infrastructure/postgres/user_repository.go
package postgres

import (
    "context"
    "errors"
    "gorm.io/gorm"

    domainUser "myapp/internal/domain/user"
    "myapp/internal/infrastructure/postgres/models"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domainUser.Repository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *domainUser.User) error {
    model := models.FromDomain(user)

    if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
        return err
    }

    *user = *model.ToDomain()
    return nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domainUser.User, error) {
    var model models.User

    err := r.db.WithContext(ctx).
        Where("email = ?", email).
        First(&model).Error

    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, domainUser.ErrNotFound
        }
        return nil, err
    }

    return model.ToDomain(), nil
}

3. UseCase 사용

// internal/usecase/auth/login.go
package auth

import (
    "context"
    "myapp/internal/domain/user"
)

type LoginUseCase struct {
    userRepo user.Repository // Domain 인터페이스에 의존
}

func NewLoginUseCase(userRepo user.Repository) *LoginUseCase {
    return &LoginUseCase{userRepo: userRepo}
}

func (uc *LoginUseCase) Execute(ctx context.Context, email string) (*user.User, error) {
    // GORM을 직접 사용하지 않음 - Repository 인터페이스 사용
    existingUser, err := uc.userRepo.FindByEmail(ctx, email)
    if err != nil {
        // 새 사용자 생성
        newUser := &user.User{
            Email:    email,
            Provider: "google",
        }

        if err := uc.userRepo.Create(ctx, newUser); err != nil {
            return nil, err
        }

        return newUser, nil
    }

    return existingUser, nil
}

 


GORM vs Raw SQL: 언제 무엇을 사용할까?

GORM을 사용해야 하는 경우 (90% 이상)

빠른 개발이 중요한 프로젝트

// Raw SQL: 20줄
query := `INSERT INTO users (id, email, name) VALUES ($1, $2, $3) RETURNING created_at`
// ...

// GORM: 2줄
db.Create(&user)

 

CRUD 중심 비즈니스 로직

  • 사용자 관리, 게시판, 주문 시스템 등
  • 복잡한 쿼리 최적화가 핵심이 아닌 경우

팀 생산성 우선

  • 신입 개발자도 빠르게 적응
  • JPA 경험자라면 1-2일이면 익숙해짐

Raw SQL을 고려해야 하는 경우 (10% 미만)

⚠️ 복잡한 집계 쿼리

// GORM으로 표현하기 어려운 복잡한 쿼리
query := `
    SELECT u.*, COUNT(p.id) as project_count
    FROM users u
    LEFT JOIN projects p ON u.id = p.owner_id
    WHERE u.created_at > $1
    GROUP BY u.id
    HAVING COUNT(p.id) > 5
    ORDER BY project_count DESC
`

 

⚠️ 극한의 성능 최적화

  • 초당 10만+ 요청
  • N+1 문제 해결이 어려운 경우

 

정리

JPA → GORM 전환 체크리스트

기능 Spring JPA GORM
엔티티 정의 @Entity gorm 태그
Repository 인터페이스 자동 구현 메서드 직접 작성
연관 관계 @OneToMany foreignKey 태그
Eager Loading FetchType.EAGER Preload()
Transaction @Transactional db.Transaction()
Hook @PrePersist BeforeCreate()

핵심 차이

JPA:

  • 인터페이스만 정의 → 자동 구현
  • Annotation 중심
  • Spring 생태계와 강하게 결합

GORM:

  • 메서드 직접 작성 → 명시적
  • 구조체 태그 중심
  • Go 표준 라이브러리와 호환

우리의 선택: GORM

이유:

  1. ✅ 빠른 개발 속도 (JPA와 유사한 생산성)
  2. ✅ 팀 생산성 (Spring 경험자 빠른 적응)
  3. ✅ Clean Architecture 통합 가능 (Domain/Model 분리)
  4. ✅ Go Best Practice (대부분의 Go 프로젝트가 선택)

마치며: Spring JPA 개발자에게

GORM은 Go의 JPA입니다.

처음엔 메서드를 직접 작성해야 해서 불편할 수 있습니다.

하지만 JPA 개발 경험이 있다면, 1-2일이면 GORM에 익숙해질 것입니다.

그리고 깨닫게 될 것입니다:

"Go에서도 JPA처럼 편하게 개발할 수 있구나!"

다음 편 예고:

"Go의 패키지 구조로 Clean Architecture 구현하기"

반응형

댓글