Gin 기초 #6 데이터베이스 연동 (GORM)

5 분 소요

지난 글에서 미들웨어를 다뤘습니다. 여기까지 오면 요청을 받고, 검증하고, 응답하고, 공통 처리를 거는 기본기가 갖춰졌습니다. 이번 글은 그 위에 데이터베이스를 붙입니다. GORM으로 실제 데이터를 저장하는 CRUD API를 만듭니다.

  • #1 시작과 첫 서버
  • #2 라우팅과 핸들러
  • #3 요청 바인딩과 검증
  • #4 응답 처리 — JSON, 상태 코드, 에러
  • #5 미들웨어
  • #6 데이터베이스 연동 (GORM) ← 이번 글
  • #7 프로젝트 구조와 미니 REST API

GORM이란? #

GORM은 Go에서 가장 널리 쓰이는 ORM입니다. SQL을 직접 쓰는 대신, Go 구조체와 메서드로 데이터베이스를 다루게 해 줍니다. SQL을 직접 다루는 방식은 고 실전 #4 DB 연동에서 다뤘으니, 두 접근을 비교해 보면 차이가 분명합니다.

이번 글은 설치가 간단한 SQLite를 예로 듭니다. PostgreSQL이나 MySQL도 드라이버만 바꾸면 같은 코드가 거의 그대로 동작합니다.

설치
go get gorm.io/gorm
go get gorm.io/driver/sqlite

데이터베이스 연결 #

gorm.Open으로 연결을 엽니다. 돌려받은 *gorm.DB가 이후 모든 쿼리의 출발점입니다.

연결
import (
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func setupDB() *gorm.DB {
	db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
	if err != nil {
		log.Fatal("DB 연결 실패:", err)
	}
	return db
}

연결 실패는 서버가 정상 동작할 수 없는 상황이므로 log.Fatal로 즉시 종료합니다.

모델 정의 #

GORM은 Go 구조체를 테이블에 대응시킵니다. 구조체 이름의 복수형이 테이블 이름이 됩니다(Userusers).

모델
type User struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Name      string    `json:"name"`
	Email     string    `json:"email" gorm:"uniqueIndex"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}
  • gorm:"primaryKey" — 기본 키 지정. ID라는 이름이면 GORM이 자동으로 기본 키로 인식하지만 명시해도 좋음
  • gorm:"uniqueIndex" — 유니크 인덱스, 이메일 중복을 DB 수준에서 차단
  • CreatedAt / UpdatedAt — GORM이 생성, 수정 시각을 자동으로 채워 주는 약속된 필드 이름

json 태그와 gorm 태그를 함께 단 점에 주목하세요. 같은 구조체가 API 응답 직렬화(#4)와 DB 매핑을 겸합니다.

마이그레이션 — AutoMigrate #

AutoMigrate는 모델에 맞춰 테이블을 만들거나 컬럼을 추가합니다. 개발 단계에서 스키마를 빠르게 맞출 때 편합니다.

마이그레이션
db.AutoMigrate(&User{})

운영 환경에서는 보통 별도의 마이그레이션 도구로 스키마를 관리하지만, 입문 단계에서는 AutoMigrate로 충분합니다.

CRUD 기본 동작 #

GORM의 핵심 메서드들을 먼저 정리합니다.

CRUD 메서드
db.Create(&user)              // 생성
db.First(&user, id)           // 기본 키로 한 건 조회
db.Find(&users)               // 여러 건 조회
db.Save(&user)                // 수정 (전체 저장)
db.Model(&user).Updates(...)  // 수정 (일부 필드)
db.Delete(&user, id)          // 삭제

조회 결과가 없을 때 Firstgorm.ErrRecordNotFound 에러를 돌려줍니다. 이 에러를 잡아 404로 응답하는 패턴을 아래에서 씁니다.

Gin 핸들러와 결합하기 #

이제 이 메서드들을 Gin 핸들러에 붙입니다. 핸들러가 *gorm.DB에 접근해야 하므로, 클로저로 db를 가둬 핸들러를 만드는 방식을 씁니다.

생성 — POST /users
func createUser(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		var req struct {
			Name  string `json:"name" binding:"required"`
			Email string `json:"email" binding:"required,email"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		user := User{Name: req.Name, Email: req.Email}
		if err := db.Create(&user).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "저장 실패"})
			return
		}

		c.JSON(http.StatusCreated, user)
	}
}

GORM 메서드는 *gorm.DB를 돌려주고, 그 안의 .Error 필드로 결과를 확인합니다. 생성에 성공하면 user.ID가 채워지며, #4에서 본 대로 201 Created로 응답합니다.

조회 핸들러입니다.

조회 — GET /users/:id
func getUser(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		id := c.Param("id")

		var user User
		if err := db.First(&user, id).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				c.JSON(http.StatusNotFound, gin.H{"error": "사용자를 찾을 수 없습니다"})
				return
			}
			c.JSON(http.StatusInternalServerError, gin.H{"error": "조회 실패"})
			return
		}

		c.JSON(http.StatusOK, user)
	}
}

errors.Is로 “레코드 없음"과 그 외 DB 오류를 구분해, 각각 404와 500으로 응답합니다. 목록 조회는 더 단순합니다.

목록 — GET /users
func listUsers(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		var users []User
		if err := db.Find(&users).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "조회 실패"})
			return
		}
		c.JSON(http.StatusOK, users)
	}
}

수정과 삭제도 같은 틀입니다.

수정·삭제
func updateUser(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		var user User
		if err := db.First(&user, c.Param("id")).Error; err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "사용자를 찾을 수 없습니다"})
			return
		}

		var req struct {
			Name string `json:"name"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		db.Model(&user).Update("name", req.Name)
		c.JSON(http.StatusOK, user)
	}
}

func deleteUser(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		if err := db.Delete(&User{}, c.Param("id")).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "삭제 실패"})
			return
		}
		c.Status(http.StatusNoContent)
	}
}

라우트에 연결하기 #

클로저로 만든 핸들러를 라우트에 등록합니다. db를 각 핸들러에 넘기는 형태입니다.

main.go
func main() {
	db := setupDB()
	db.AutoMigrate(&User{})

	r := gin.Default()

	users := r.Group("/users")
	{
		users.POST("", createUser(db))
		users.GET("", listUsers(db))
		users.GET("/:id", getUser(db))
		users.PUT("/:id", updateUser(db))
		users.DELETE("/:id", deleteUser(db))
	}

	r.Run()
}

이제 사용자를 생성하고 조회하는 완전한 CRUD API가 동작합니다.

동작 확인
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"커티스","email":"a@b.com"}'

curl http://localhost:8080/users

관계 매핑은 별도로 #

사용자가 여러 게시글을 갖는 1:N 같은 관계 매핑은 GORM의 핵심 주제이지만, 양이 많아 여기서는 다루지 않습니다. 관계 모델링과 연관 데이터 조회는 고 언어 GORM 1:N 관계 글에서 따로 정리했으니 이어서 보면 됩니다.

마무리 #

이번 글에서 정리한 내용입니다.

  • GORM은 Go 구조체로 DB를 다루는 ORM, 드라이버만 바꾸면 다른 DB로 이식 가능
  • gorm.Open으로 연결하고 *gorm.DB로 쿼리, AutoMigrate로 개발 단계 스키마 관리
  • 모델은 gorm 태그로 기본 키, 인덱스를 지정, CreatedAt/UpdatedAt은 자동 관리
  • CRUD는 Create/First/Find/Updates/Delete, 결과는 .Error로 확인
  • 핸들러는 클로저로 db를 가둬 만들고, errors.Is로 “없음"을 404로 분기
  • 관계 매핑은 별도 GORM 글에서 이어서

다음 글(#7 프로젝트 구조와 미니 REST API)에서는 지금까지 한 파일에 모은 코드를 레이어별로 나누고, 환경설정을 분리해 시리즈를 마무리하겠습니다.

X