본문 바로가기

Golang/gorm

[gorm] gorm.Model 사용 및 삭제 시 주의할 점

gorm.Model 구조체에 대하여 기본적인 사용 방법Unique 속성을 가진 테이블에 대하여 gorm의 Delete(Soft delete) 함수 실행 시 어떤 문제가 발생할 수 있고 해결할 수 있는지에 대해서 살펴보겠습니다 :)


 

gorm.Model 사용하기

gorm에서는 CreatedAt, UpdatedAt 등의 Auditing을 하기 위해 아래와 같은 gorm.Model을 제공합니다.

type Model struct {
    ID        uint `gorm:"primarykey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt DeletedAt `gorm:"index"`
}

 

위의 gorm.Model을 Embedded 구조체로 아래와 같이 이용할 수 있습니다.

 

type UserWithModel struct {
	gorm.Model
	Name string `json:"name" gorm:"unique"`
}

 

AutoMigrate를 이용하면 아래와 같이 (id, created_at, updated_at, deleted_at) 컬럼과
idx_users_deleted_at 인덱스를 가진 테이블을 생성하게 됩니다.

// gorm.DB의 AutoMigrate()
if err := db.AutoMigrate(&UserWithModel{}); err != nil {
    t.Fail()
}
-- 위의 코드로 생성 된 테이블
create table user_with_models
(
    id         bigint unsigned auto_increment primary key,
    created_at datetime(3)  null,
    updated_at datetime(3)  null,
    deleted_at datetime(3)  null,
    name       varchar(191) null,
    constraint name unique (name)
);
create index idx_user_with_models_deleted_at on user_with_models (deleted_at);

 

간단하게 Find/Save/Update/First/Delete 함수를 호출하면 아래와 같이 쿼리를 수행하게 됩니다.

  • Find -> Select
  • Save (pk: nil or default value) -> Insert
  • Save (pk is not nil or not default value) -> Update
  • First -> Select
  • Delete -> Update
Note: "gorm.DeletedAt" 필드가 존재하면 Soft Delete 방식인 deleted_at 컬럼에 삭제 시간을 업데이트합니다.
(참고: https://gorm.io/docs/delete.html#Soft-Delete)

 

func TestUserWithModel(t *testing.T) {
	db := NewDB(t)

	//----------------------------------------------
	// Find function
	// > Query: SELECT * FROM `user_with_models` WHERE `user_with_models`.`deleted_at` IS NULL
	//----------------------------------------------
	var findUsers []*UserWithModel
	err := db.Find(&findUsers).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Save function
	// > Query: INSERT INTO `user_with_models` (`created_at`,`updated_at`,`deleted_at`,`name`) VALUES ('2021-08-22 01:37:10.026','2021-08-22 01:37:10.026',NULL,'user1')
	//----------------------------------------------
	user1 := UserWithModel{Name: "user1"}
	err = db.Save(&user1).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Update function
	// > Query: UPDATE `user_with_models` SET `created_at`='2021-08-22 02:00:05.063',`updated_at`='2021-08-22 02:00:05.068',`deleted_at`=NULL,`name`='updated-user1' WHERE `id` = 1
	//----------------------------------------------
	user1.Name = "updated-user1"
	err = db.Save(&user1).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// First function
	// > Query: SELECT * FROM `user_with_models` WHERE `user_with_models`.`id` = 1 AND `user_with_models`.`deleted_at` IS NULL ORDER BY `user_with_models`.`id` LIMIT 1
	//----------------------------------------------
	var findUser UserWithModel
	err = db.First(&findUser, user1.ID).Error

	//----------------------------------------------
	// Delete function
	// > Query: SELECT * FROM `user_with_models` WHERE `user_with_models`.`id` = 1 AND `user_with_models`.`deleted_at` IS NULL ORDER BY `user_with_models`.`id` LIMIT 1
	//----------------------------------------------
	err = db.Delete(&findUser).Error
	assert.NoError(t, err)
}

 

 

Soft delete 사용 시 문제점

유저를 생성하고 삭제 한 뒤 같은 이름으로 다시 저장해보겠습니다.

 

func TestEmbeddedUser_DeleteAndSaveAgain(t *testing.T) {
    db := NewDB(t)
	
    // User 저장
	user1 := UserWithModel{Name: "user1"}
	err = db.Save(&user1).Error
	assert.NoError(t, err)
    
    // User 삭제
    err = db.Delete(&findUser).Error
	assert.NoError(t, err)
    
    // User 저장(위의 name)
    user2 := UserWithModel{Name: user1.Name}
	err = db.Save(&user2).Error
	assert.Error(t, err)
	merr, ok := err.(*mysql2.MySQLError)
	assert.True(t, ok)
	assert.EqualValues(t, 1062, merr.Number)
	assert.Contains(t, merr.Message, "Duplicate") 
}

 

같은 Name 필드를 가진 user2를 저장하면 MySQL 기준 1062 에러코드를 가진 Duplicate Entry 에러가 발생하게 됩니다.

이유는Soft Delete 방식을 이용하기 때문에 nameuser1인 레코드에 deleted_at만 추가하고 레코드는 그대로 남아있기 때문입니다.


해결 방안

Note: 해당 해결 방안은 테이블의 Unique 속성을 유지하고 Soft Delete를 유지하는 경우에 대한 해결 방안입니다.
(만약 위의 제약사항이 없다면
(1) name 컬럼 unique 속성 제거 + 애플리케이션에서 중복 검사
(2) db.Unscoped().Delete(&User)를 이용하여 레코드 삭제
와 같은 방법도 이용할 수 있습니다.)

만약 (name, deleted_at) 2가지 컬럼을 unique 속성으로 이용한다면 쉽게 해결 될 것 같지만, MySQL에서 모든 null은 unique 하므로
같은 이름의 유저를 계속해서 생성할 수 있습니다.

해결책1) uint 타입의 soft_delete.DeletedAt 이용하기

첫번째는 "gorm.io/plugin/soft_delete" 패키지에 있는 soft_delete.DeletedAt 필드를 사용하는 방식입니다.

 

import (
  "gorm.io/plugin/soft_delete"
)

// 구조체
type UserWithSoftDelete struct {
	ID        uint                  `json:"id" gorm:"primarykey"`
	CreatedAt time.Time             `json:"createdAt"`
	UpdatedAt time.Time             `json:"updatedAt"`
	Name      string                `gorm:"size:256;uniqueIndex:idx_name_deleted_at_unix"`
	DeletedAt soft_delete.DeletedAt `gorm:"uniqueIndex:idx_name_deleted_at_unix"`
}

// DDL
create table user_with_soft_deletes
(
    id         bigint unsigned auto_increment primary key,
    created_at datetime(3)     null,
    updated_at datetime(3)     null,
    name       varchar(256)    null,
    deleted_at bigint unsigned null,
    constraint idx_name_deleted_at_unix unique (name, deleted_at)
);

 

soft_delete.DeletedAt 필드를 사용하면 Find, First, Delete 함수에 대하여 아래와 같은 쿼리를 이용합니다.

 

func TestUserWithSoftDelete(t *testing.T) {
	db := NewDB(t)

	//----------------------------------------------
	// Find function
	// > Query: SELECT * FROM `user_with_soft_deletes` WHERE `user_with_soft_deletes`.`deleted_at` = 0
	//----------------------------------------------
	var findUsers []*UserWithSoftDelete
	err := db.Find(&findUsers).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Save function
	// > Query: INSERT INTO `user_with_soft_deletes` (`created_at`,`updated_at`,`name`,`deleted_at`) VALUES ('2021-08-22 01:52:01.415','2021-08-22 01:52:01.415','user1','0')
	//----------------------------------------------
	user1 := UserWithSoftDelete{Name: "user1"}
	err = db.Save(&user1).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// First function
	// > Query: SELECT * FROM `user_with_soft_deletes` WHERE `user_with_soft_deletes`.`id` = 1 AND `user_with_soft_deletes`.`deleted_at` = 0 ORDER BY `user_with_soft_deletes`.`id` LIMIT 1
	//----------------------------------------------
	var findUser UserWithSoftDelete
	err = db.First(&findUser, user1.ID).Error

	//----------------------------------------------
	// Delete function
	// > Query: UPDATE `user_with_soft_deletes` SET `deleted_at`=1629564721 WHERE `user_with_soft_deletes`.`id` = 1 AND `user_with_soft_deletes`.`deleted_at` = 0
	//----------------------------------------------
	err = db.Delete(&findUser).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Save function
	// > Query: INSERT INTO `user_with_soft_deletes` (`created_at`,`updated_at`,`name`,`deleted_at`) VALUES ('2021-08-22 01:52:01.432','2021-08-22 01:52:01.432','user1','0')
	//----------------------------------------------
	user2 := UserWithSoftDelete{Name: user1.Name}
	err = db.Save(&user2).Error
	assert.NoError(t, err)
}

해결책2) tinyint(1) 타입 이용하기

두번째는 bool 포인터를 이용하여 삭제 시 active 컬럼에 NULL 값을 넣어주는 것입니다.

 

Note: gorm에서는 Delete Flag를 제공합니다(https://gorm.io/docs/delete.html#Delete-Flag).
해당 태그를 사용하면 실제로 데이터가 0(기본),1(삭제 시)을 이용하게 되는데, 이전에 삭제 된 레코드가 있을 경우 삭제 시
중복된 요청으로 처리되기 때문에 해당 방법은 제외하였습니다.

 

아래와 같이 Active 필드에 대하여 bool 포인터를 기본값 1로 정의합니다. 여기서 삭제 시 active 컬럼에 NULL 값을 이용하기 때문에

(name, active) 필드에 대하여 unique 속성을 부여합니다.

 

// 구조체
type UserWithPointer struct {
	ID        uint      `json:"id" gorm:"primarykey"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
	Name      string    `gorm:"size:256;uniqueIndex:idx_name_active"`
	Active    *bool     `gorm:"uniqueIndex:idx_name_active;default:1"`
}

// DDL
create table user_with_pointers
(
    id         bigint unsigned auto_increment primary key,
    created_at datetime(3)          null,
    updated_at datetime(3)          null,
    name       varchar(256)         null,
    active     tinyint(1) default 1 null,
    constraint idx_name_active unique (name, active)
);

 

그러면 아래와 같이 사용자 등록/삭제/등록(같은 이름)을 수행하면 테스트가 성공하는 것을 확인할 수 있습니다.

func TestUserWithPointer(t *testing.T) {
	db := NewDB(t)

	//----------------------------------------------
	// Save function
	// > Query: INSERT INTO `user_with_pointers` (`created_at`,`updated_at`,`name`,`active`) VALUES ('2021-08-22 02:10:08.069','2021-08-22 02:10:08.069','user1',true)
	//----------------------------------------------
	user1 := UserWithPointer{Name: "user1"}
	err = db.Save(&user1).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Delete function
	// > Query: UPDATE `user_with_pointers` SET `created_at`='2021-08-22 02:16:55.904',`updated_at`='2021-08-22 02:16:55.912',`name`='user1',`active`=NULL WHERE `id` = 1
	//----------------------------------------------
	findUser.Active = nil
	err = db.Save(&findUser).Error
	assert.NoError(t, err)

	//----------------------------------------------
	// Save function
	// > Query: INSERT INTO `user_with_pointers` (`created_at`,`updated_at`,`name`,`active`) VALUES ('2021-08-22 02:25:33.825','2021-08-22 02:25:33.825','user1',true)
	//----------------------------------------------
	user2 := UserWithSoftDelete{Name: user1.Name}
	err = db.Save(&user2).Error
	assert.NoError(t, err)
}

 

 

여기서 주의해야할 점은 크게 2가지 있는것 같습니다.

 

1. Find, First등의 쿼리에서 active 컬럼이 NULL인 조건이 필요합니다.

예를들어 아래와 같이 Find 함수를 호출하면 삭제된 레코드(active == null)까지 모두 조회합니다. 그래서 Where 조건을 포함해야합니다.

 

//----------------------------------------------
// Find function
// > Query: SELECT * FROM `user_with_pointers`
//----------------------------------------------
var findUsers []*UserWithPointer
err := db.Find(&findUsers).Error
assert.NoError(t, err)

//----------------------------------------------
// Find function
// > Query: SELECT * FROM `user_with_pointers` WHERE active IS NOT NULL
//----------------------------------------------
err = db.Where("active IS NOT NULL").Find(&findUsers).Error
assert.NoError(t, err)

 

2. user.Active 값이 포인터이기 때문에 nil을 포함할 수 있습니다. 그래서 IsActive() bool 같은 함수 정의가 필요해보입니다.


gorm에서 제공하는 기본 gorm.Model은 Delete, Find 등의 사용이 편리하지만

unique 속성 soft delete가 필요한 모델에 대해서는 위와 같이 별도로 작업이 필요해보입니다.

 

2번째 해결 방법인 bool pointer에 대해서 Find 등의 함수에서 자동으로 Where 구문이 추가될 수 있는지, 방법을 아시는분이 있으면 댓글 부탁드립니다! 감사합니다 :)