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
이유:
- ✅ 빠른 개발 속도 (JPA와 유사한 생산성)
- ✅ 팀 생산성 (Spring 경험자 빠른 적응)
- ✅ Clean Architecture 통합 가능 (Domain/Model 분리)
- ✅ Go Best Practice (대부분의 Go 프로젝트가 선택)
마치며: Spring JPA 개발자에게
GORM은 Go의 JPA입니다.
처음엔 메서드를 직접 작성해야 해서 불편할 수 있습니다.
하지만 JPA 개발 경험이 있다면, 1-2일이면 GORM에 익숙해질 것입니다.
그리고 깨닫게 될 것입니다:
"Go에서도 JPA처럼 편하게 개발할 수 있구나!"
다음 편 예고:
"Go의 패키지 구조로 Clean Architecture 구현하기"
반응형
댓글