[GORM] GORM과 함께 데이터베이스 연결하기

riveroverflow·2024년 9월 2일

ORM이란?

ORM, Object-Relational Mapping은 객체 지향 프로그래밍 언어에서 관계형 데이터베이스와 매핑하는 것을 의미한다.
ORM을 이용하면 데이터베이스와의 사용이 더 쉬워지고, SQL injection과 같은 공격에도 안전하다.
객체 지향적인 프로그래밍으로 데이터베이스에 접근하여, 코드가 더 간결해질 수 있다.

그러나, 쿼리 복잡도가 높아질 수 있고 복잡한 경우 성능저하가 올 수도 있다.
객체와 테이블 간에 불일치 시 문제가 발생할 수도 있다.

GORM 기본 사용법

GORM은 GO에서 가장 유명한 ORM 라이브러리이다.

지원하는 기능은 아래 외에도 훨씬 많다.

  • Associations
  • Hooks
  • Preload, Joins
  • Transactions, Nested Transactions, Save Point, Rollback to Saved Point
  • Auto migrations
  • Logger

설치

프로젝트의 루트 디렉터리에서 다음을 실행한다:

$ go get -u gorm.io/gorm
$ go get -u gorm.io/driver/mysql ##DB마다 다른 드라이버를 선택하시오.

데이터베이스에 연결(MySQL 기준)

config/db.go

package config

import (
	"fmt"
	"proj-name/internal/models"
	"log"
	"os"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

// DB object
var DB *gorm.DB

// ConnectToDB - establish db connection..
func ConnectToDB() {
	dsn := fmt.Sprintf(
		"%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local",
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASS"),
		os.Getenv("DB_ADDR"),
		os.Getenv("DB_TABLE"),
	)

	var err error
	DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatal("Failed to connect to database: ", err)
	}

	DB.AutoMigrate(&models.User{}, &models.Notice{}, &models.Treasure{}, &models.LostItem{}, &models.Image{})
	fmt.Println("DB connection established")
}

우선, gorm패키지의 DB객체 DB를 만든다.
그리고, Open(mysql.Open(dsn), &gorm.Config{})를 통해서 dsn을 입력한다.
dsn에는 미리 연결 정보에 대한 문자열을 저장하는데, 보안을 위해 환경 변수에서 민감한 값을 가져온다.
만약 Open이 에러를 반환하지 않는다면, 연결에 성공한 것이다.

DB.AutoMigrate()는 구조체 인자로 주어진 모델들에 맞춰서 데이터베이스 스키마를 동기화시킨다는 의미이다.

구조체 모델 정의

GORM에서는 Go의 구조체를 데이터베이스 테이블에 쉽게 매핑할 수 있도록 단순화한다.

모델은 기본적인 구조체를 정의하는데 쓰인다.
이러한 구조체는 기본적인 Go의 타입들 및 별칭, 사용자 지정 타입을 따를 수 있다.
(단, database/sql패키지에서 Scanner와 Valuer 인터페이스가 구현되어야 함)

User 모델을 설계한다고 해보자:

type User struct {
  ID           uint           // Primary Key
  Name         string         // 문자열 필드
  Email        *string        // null을 허용하는 문자열 포인터
  Age          uint8          // 부호없는 8비트정수
  Birthday     *time.Time     // time.Time에 대한 포인터, null가능.
  MemberNumber sql.NullString // nullable string을 다루는 sql.NullString
  ActivatedAt  sql.NullTime   // nullable time fields을 다루는 sql.NullTime
  CreatedAt    time.Time      // GORM에 의해 자동으로 생성될 수 있는 컬럼.
  UpdatedAt    time.Time      // GORM에 의해 자동으로 업데이트되는 타임스탬프 컬럼.
}
  • 기본적인 데이터형은 직접적으로 쓰인다.
  • 타입에 대한 포인터는 nullable한 필드를 나타낸다.
  • database/sql패키지의 sql.NullStringsql.NullTime은 더 많은 제어가 가능한 nullable필드이다.
  • CreatedAtUpdatedAt은 GORM이 생성 또는 수정 시 자동으로 찍어주는 타임스탬프이다.

모델 생성 규칙

모델 생성 규칙은 다음과 같다:
1. Primary Key: GORM은 각 모델에서 기본적으로 ID필드를 primary key로 인식한다.
2. Table 이름: 기본적으로, GORM은 구조체 이름을 snake_case로 변환한다.
예를 들어, User 구조체는 데이터베이스에서는 users테이블에 매핑된다.
3. Column 이름: GRORM은 자동으로 구조체의 필드를 snake_case로 변환하여 데이터베이스의 컬럼으로 매핑한다.
4. Timestamp 필드: GORM은 CreatedAtUpdatedAt필드가 있으면, 자동으로 생성과 업데이트 타임스탬프 기록을 해준다.

필드 태그

태그는 모델 선언에서 선택적이다.
태그의 표기방법은 관대하지만, camelCase가 권장된다.
만약 여러 태그가 붙는다면, ;로 구분해주면 된다.

태그 이름설명
columncolumn이름
typecolumn데이터 유형. 호환가능한 일반 유형(bool, int, uint, float, string, time, bytes)을 사용하는 것을 선호한다. 이는 모든 데이터베이스에서 작동하며, not null, size, autoIncrement와 같은 태그들과 함께 사용될 수도 있다.
serializerdb에 직렬화와 역직렬화를 어떻게 할지 정할 수 있다.
size데이터의 크기/길이를 정한다. size:256
primaryKey해당 컬럼을 PK로 설정
unique중복되는 값이 없게 설정
default기본값을 설정
precision컬럼의 정밀도를 설정
scale컬럼의 크기를 지정
not nullnull불가능하게 지정
autoIncrement컬럼을 자동 증가하도록 설정
autoIncrementIncrement지정된 값만큼 증가하도록 설정
embedded구조체 안에 다른 구조체를 포함시킴
embeddedPreix이름 앞에 접두사를 추가
autoCreateTime생성 시의 시간을 기록함. unix타임을 초단위로 담은 int이고, nano/milli도 가능.
autoUpdateTime생성 및 수정 시의. ㅣ간을 기록함. unix타임을 초단위로 담은 int이고, nano/milli단위도 가능.
index데이터베이스 인덱스 생성
uniqueIndex해당 필드에 대해 유니크 인덱스 생성
check필드에 제약조건 설정
<-쓰기권한 지정. <-:create생성만 가능한 필드
->읽기권한 지정. ->false시 읽기 권한 없음.
-이 필드 무시. 읽기 쓰기 권한없음. -:migration시 마이그레이션 권한 없음. -:all시 모든 권한 없음.
comemnt마이그레이션 시 열에 주석 남김

간단한 CRUD

Create

user := User{Name: "riveroverflow", Age: 23, Birthday: time.Now()}

res := db.Create(&user) // 생성할 데이터의 포인터를 넘겨서 생성 요청

user.ID // 입력된 데이터의 PK를 반환
res.Error // 에러 반환
res.RowsAffected // 적용된 레코드의 개수 반환

Read(Select)

단일객체 조회하기:

var user User
// 맨 위 레코드 가져오기
db.First(&user) // SELECT * FROM users ORDER BY id LIMIT 1;

// PK가 10인 레코드 조회
db.First(&user, 10) // SELECT * FROM users WHERE id = 10;

// uuid와 같이 PK가 문자열인 경우
db.First(&user, "id = ?", "1b74413f-f3b4-345d-da26-e8c062e3472a")
// SELECT * FROM users WHERE id = "1b74413f-f3b4-345d-da26-e8c062e3472a";

다중객체 조회하기:

var users []User

db.Find(&users) // SELECT * FROM users;

res.RowsAffected // 레코드의 개수
res.Error // 에러를 반환(에러 없으면 nil)

not조건 붙이기

db.Not("name = ?", "riveroverflow").First(&user)
// SELECT * FROM users WHERE NOT name = "riveroverflow" ORDER BY id LIMIT 1;

or조건 붙이기

db.Where("role = ?", "admin").Or("role = ?", "super_admin").Find(&users)
// SELECT * FROM users WHERE role = 'admin' OR role = 'super_admin';

특정 필드만 고르기

db.Select("name", "age").Find(&users)
// SELECT name, age FROM users;

정렬

db.Order("age desc, name").Find(&users)
// SELECT * FROM users ORDER BY age desc, name;

limit, offset

db.Limit(10).Offset(5).Find(&users)
// SELECT * FROM users OFFSET 5 LIMIT 10;

groupby

db.Model(&User{}).Select("name, sum(age) as total").Where("name LIKE ?", "group%").Group("name").First(&result)
// SELECT name, sum(age) as total FROM `users` WHERE name LIKE "group%" GROUP BY `name` LIMIT 1

Update

db.First(&user)

user.Name = "riveroverflow2"
user.Age = 200
db.Save(&user) // 레코드의 주소를 전달하여 업데이트 요청

Delete

db.Delete(&User{}, 10)
// DELETE FROM users WHERE id = 10;

관계가 있는 DB

관계형 데이터베이스에서는 테이블 간에 관계를 지정할 수 있다.

  • 1대1 관계
  • 1대다 관계
  • 다대다 관계

여기서는, 1대1 관계인 Has one을 봐보자.
분실물에 대한 구조체 LostItem과, 이미지에 대한 구조체 Image가 있다.

분실물 LostItem은 하나의 이미지를 업로드한다고 하자.
-> LostItem has one Image.

모델 구조체 정의를 아래와 같이 해주면 된다.

package models

// Image Model
type Image struct {
	ImageID  uint   `gorm:"type:integer;primaryKey;autoIncrement"`
	ImageKey string `gorm:"type:varchar(255);not null"`
	ImageURL string `gorm:"type:varchar(2048);not null"`
}
package models

// LostItem Model
type LostItem struct {
	ItemID   uint   `gorm:"type:integer;primaryKey;autoIncrement"`
	ItemName string `gorm:"type:varchar(255);not null"`
	FoundIn  string `gorm:"type:varchar(255);not null"`
	ImageID  uint   `gorm:"type:integer"`
	Image    Image  `gorm:"foreginKey:ImageId"`
}

가지는 모델이 그 모델의 PK에 대한 foreign key를 가지면 된다.
그리고 구조체의 필드에 소유한 모델도 명시하면 된다.

Join으로 연관된 데이터 가져오기

Join연산을 이용하여 연관된 Image까지 하나의 쿼리로 가져올 수 있다.

var lostItem *[]models.LostItem
res := config.DB.Joins("Image").Find(&lostItem)
if res.Error != nil {
	return nil, res.Error
}

실제 프로젝트에 적용

공지사항을 CRUD하는 예시 코드이다.

routers/initRouters.go

package routers

import (
	"proj-name/internal/middlewares"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
)

// SetupRouter - Initalize All of Routers
func SetupRouter() *gin.Engine {
	router := gin.Default()
	router.Use(middlewares.JSONParsingMiddleware)

	auth := router.Group("/auth")
	{
		auth.POST("/login", middlewares.ValidateBodyMiddleware("user_id", "user_password"), loginHandler)
	}

	notice := router.Group("/notices")
	{
		notice.GET("/", getNoticesHandler)
		notice.PUT("/:noticeId", middlewares.RequireAuth, middlewares.ValidateBodyMiddleware("title", "content"), modifyNoticeHandler)
		notice.POST("/", middlewares.RequireAuth, middlewares.ValidateBodyMiddleware("title", "content"), createNoticeHandler)
		notice.DELETE("/:noticeId", middlewares.RequireAuth, deleteNoticeHandler)
	}
	return router
}

routers/noticeHander.go

package routers

import (
	"proj-name/internal/services"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
)

func getNoticesHandler(c *gin.Context) {
	notices, err := services.GetNotices()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": err,
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"notices": notices,
	})
}

func modifyNoticeHandler(c *gin.Context) {
	noticeID, err := strconv.Atoi(c.Param("noticeId"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "cannot convert string to integer",
		})
		return
	}

	body, exists := c.Get("body")
	if !exists {
		c.JSON(http.StatusBadRequest, gin.H{"error": "error loading body:"})
		return
	}
	bodyMap := body.(map[string]interface{})
	title := bodyMap["title"].(string)
	content := bodyMap["content"].(string)

	err = services.ModifyNotice(noticeID, title, content)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": err.Error(),
		})
		return
	}

	c.JSON(http.StatusNoContent, gin.H{})
}

func createNoticeHandler(c *gin.Context) {
	body, exists := c.Get("body")
	if !exists {
		c.JSON(http.StatusBadRequest, gin.H{"error": "error loading body:"})
		return
	}
	bodyMap := body.(map[string]interface{})
	title := bodyMap["title"].(string)
	content := bodyMap["content"].(string)

	err := services.CreateNotice(title, content)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": err.Error(),
		})
		return
	}

	c.JSON(http.StatusCreated, gin.H{})
}

func deleteNoticeHandler(c *gin.Context) {
	noticeID, err := strconv.Atoi(c.Param("noticeId"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "cannot convert string to integer",
		})
		return
	}

	err = services.DeleteNotice(noticeID)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": err,
		})
		return
	}

	c.JSON(http.StatusNoContent, gin.H{})
}

services/noticeService.go

package services

import (
	"proj-name/internal/models"
	"proj-name/internal/repositories"
)

// GetNotices - get All Notices
func GetNotices() (*[]models.Notice, error) {
	notices, err := repositories.FindAllNotices()
	if err != nil {
		return nil, err
	}
	return notices, nil
}

// ModifyNotice - Edit notice data
func ModifyNotice(noticeID int, title, content string) error {
	err := repositories.UpdateNoticeByID(noticeID, title, content)
	if err != nil {
		return err
	}

	return nil
}

// CreateNotice - Create Notice with body data
func CreateNotice(title, content string) error {
	err := repositories.CreateNewNotice(title, content)
	if err != nil {
		return err
	}
	return nil
}

// DeleteNotice - Delete notice by its ID
func DeleteNotice(noticeID int) error {
	err := repositories.DeleteNoticeByID(noticeID)
	if err != nil {
		return err
	}
	return nil
}

`repositories/noticeRepository.go``

package repositories

import (
	"proj-name/config"
	"proj-name/internal/models"
)

// FindAllNotices - get All Notice data
func FindAllNotices() (*[]models.Notice, error) {
	var notices []models.Notice

	res := config.DB.Find(&notices)
	if res.Error != nil {
		return nil, res.Error
	}

	return &notices, nil
}

// UpdateNoticeByID - Update Notice By its id
func UpdateNoticeByID(noticeID int, title, content string) error {
	var notice models.Notice

	res := config.DB.First(&notice, noticeID)
	if res.Error != nil {
		return res.Error
	}

	notice.Title = title
	notice.Content = content

	res = config.DB.Save(notice)
	if res.Error != nil {
		return res.Error
	}

	return nil
}

// CreateNewNotice - Create New Notice data to DB
func CreateNewNotice(title, content string) error {
	notice := models.Notice{Title: title, Content: content}
	res := config.DB.Create(&notice)
	if res.Error != nil {
		return res.Error
	}
	return nil
}

// DeleteNoticeByID - Delete Notice data by its ID
func DeleteNoticeByID(noticeID int) error {
	var notice models.Notice
	res := config.DB.Delete(&notice, noticeID)
	if res.Error != nil {
		return res.Error
	}
	return nil
}

// CountAllOfNotices - Amounts all of Notices
func CountAllOfNotices() (int64, error) {
	var count int64
	res := config.DB.Model(&models.Notice{}).Count(&count)
	if res.Error != nil {
		return 0, res.Error
	}
	return count, nil
}

참조

https://gorm.io/ko_KR/docs/index.html

0개의 댓글