본문 바로가기
golang

[GORM] Implements Customized Data Type (feat.Scanner & Valuer)

by 참된오징어 2021. 12. 3.

GO, GORM을 이용하면서 내가 새로 정의한 커스텀 타입을 데이터베이스에 CRUD 하는 과정에서 이슈가 있었다.

데이터베이스에 내가 새로 정의한 커스텀 타입을 CRUD 하고 싶은 경우, Database는 지원하지 않는 타입이라고 에러를 뱉어버린다.

 

간단한 예제 코드로 설명을 해보면 다음과 같이 Customer라는 테이블에 값을 넣으려고 한다.

create table customer
(
    id         int        null,
    signUpDate date       null -- 가입한 날짜
);

 

모델링은 다음과 같다.

type CustomDate time.Time

type Customer struct {
	Id         int64      `json:"id" gorm:"column:id"`
	SignUpDate CustomDate `json:"signUpDate" gorm:"column:signUpDate"`
}

 

위와 같이 가입한 날짜를 이용해 비즈니스 로직을 처리할 일이 있어서 다음과 같이 한번 더 struct로 Wrapping 하여 모델링을 했는데 SignUpDate와 같이 커스텀 타입을 만들어서 하고 싶은 경우 과연 잘 들어갈까?

 

 

 

package model

import ...

type CustomDate struct {
	time.Time
}

type Customer struct {
	Id         int64      `json:"id" gorm:"column:id"`
	SignUpDate CustomDate `json:"signUpDate" gorm:"column:signUpDate"`
}

func Save() {
	customer := Customer{Id: 2, SignUpDate: CustomDate{time.Now()}}
	result := database.Db.Create(&customer)
	fmt.Println(customer)
}

func (Customer) TableName() string {
	return "customer"
}

[error] invalid field found for struct model.Customer's field SignUpDate: define a valid foreign key for relations or implement the Valuer/Scanner interface.

 

데이터베이스는 해당 타입을 알지 못해 에러가 발생한다.

에러에서 감사하게도 Valuer / Scanner interface를 구현하라고 안내해준다.

 

Golang은 데이터베이스 드라이버가 데이터를 커스텀 된 데이터 타입으로 저장 및 검색을 허용하는 메커니즘을 제공해주는 것을 알게 되었고 해당 메커니즘은 database/sql 패키지Scanner & Valuer 인터페이스를 구현하면 된다.

 

GORM 공식 문서에서도 커스텀 데이터 타입은 Scanner & Valuer 인터페이스를 구현하라고 안내를 하고 있다.

Scanner & Valuer 인터페이스가 무엇이고 GORM에서 어떻게 동작하고 있는 걸까?

 

 

 

# Valuer 인터페이스


Valuer interface docs : https://pkg.go.dev/database/sql/driver#Valuer

 

driver package - database/sql/driver - pkg.go.dev

type ColumnConverter interface { ColumnConverter(idx int) ValueConverter } ColumnConverter may be optionally implemented by Stmt if the statement is aware of its own columns' types and can convert from any type to a driver Value. Deprecated: Drivers should

pkg.go.dev

Valuer 인터페이스는 모든 타입을 데이터베이스 드라이버가 DB에 저장하고 작업할 수 있는 타입인 [driver.Value] 타입으로 변환해주는 Value() 메서드를 재정의 하도록 설계가 되어있다.

좀 더 쉽게 말하면 Valuer 인터페이스의 Value() 메서드를 재정의함으로써

어떤 유형이든(커스텀 타입 등) 데이터베이스가 이해할 수 있도록 드라이버가 DB에서 작업할수 있도록 변환하는 방법을 허용하는 것이다.

 

즉, Value() 메서드를 재정의한다면 insert는 성공할 것이다.

func (c CustomDate) Value() (driver.Value, error) {
	return c.Time.Format("2006-01-02"), nil
}

예상에 맞게 Value() 메서드를 정의함으로써 데이터베이스가 이해할 수 있도록 재정의해주었더니 성공하였다.

 

GORM 내부에서는 Struct를 DB Schema에 매핑하는 과정을 다음과 같이 호출하면서

Valuer 인터페이스를 재정의한 컬럼인지 확인하고 리플렉션을 통해 해당 컬럼과 필드를 매핑해준다.

 

Create() method stacktrace

 

ParseField() valuer reflection

 

 

 

 

# Scanner 인터페이스


Scanner interface docs : https://pkg.go.dev/database/sql#Scanner

 

sql package - database/sql - pkg.go.dev

Package sql provides a generic interface around SQL (or SQL-like) databases. The sql package must be used in conjunction with a database driver. See https://golang.org/s/sqldrivers for a list of drivers. Drivers that do not support context cancellation wil

pkg.go.dev

Scanner 인터페이스는 데이터베이스에서 Struct로 Select 시 데이터(기본 타입, 커스텀 타입등)를 가져와 매핑하기 위해

필요한 인터페이스다.

Scan() 메서드는 일반적인 Go에서 지원하는 타입 및 sql 패키지에서 제공하는 유형으로 변환이 가능하다.

또한 Scan() 메서드는 포인터를 통해 값을 할당하기 때문에 Scan() 메서드는 포인터 리시버로 선언을 해야한다.

*string
*[]byte
*int, *int8, *int16, *int32, *int64
*uint, *uint8, *uint16, *uint32, *uint64
*bool
*float32, *float64
*interface{}
*RawBytes
*Rows (cursor value)
any type implementing Scanner (see Scanner docs)

 

또한 메서드 시그니쳐는 다음과 같은데

Scan(src interface{}) error

src 데이터, 즉 조회한 값을 구조체로 저장, 변환할 수 없다면 오류가 반환되도록 재정의 해야한다.

또한 []byte 같은 레퍼런스 타입은 다음 Scan() 호출전까지 유효하다.

즉, 드라이버가 메모리를 소유하고 있기 때문에 값의 유지가 필요하면 값을 복사해야한다. (포인터 리시버인 이유중 하나지 않을까 싶다)

 

만약 Scan() 메서드를 재정의 하지 않고 Select를 할 경우 다음과 같이 에러 문구를 볼 수 있다.
sql: Scan error on column index 1, name "signUpDate": 
unsupported Scan, storing driver.Value type time.Time into type *model.CustomDate

 

 

아래와 같이 Scan() 메서드를 재정의하면 커스텀 타입 구조체에 값을 담을 수 있다.

func (c *CustomDate) Scan(value interface{}) error {
	c.Time = value.(time.Time)
	return nil
}

현재 사용하고 있는 v2 버전인 gorm.io/gorm 에서는 어디서 Scan() 메서드를 호출하는지는 찾지 못했다..

대신 find(interface{}).Execute() 메서드에서 리플렉션을 통해 구조체와 테이블, 그리고 필드들과 컬럼들을 매핑해놓은 정보를 바탕으로

쿼리를 통해 데이터를 가져와 구조체에 값을 넣어주는 것을 확인할 수 있다.

 

Query 수행 전

 

Query 수행 후

p.fns 에는 gorm 에서 수행해야할 function들 즉, preLoading, Query 등이 담겨있다.

 

현재 버전인 gorm v2에서는 확인을 못했지만

이전 버전 v1인 jinzhu/gorm 에서는 아래 사진과 같이 Scanner를 재정의 했는지 확인하는 로직을 살펴볼 수 있다.

https://github.com/jinzhu/gorm/blob/ac78f05986ab456936afd148e629533d8d819289/model_struct.go#L218-L230

 


공부하면서 정리한 내용이다보니 최대한 공식 문서 및 디버깅을 토대로 포스팅하였습니다!

잘못된 정보가 있을 수도 있습니다 :) 조심히 알려주시면 적극 반영하겠습니다!!

'golang' 카테고리의 다른 글

[go] Context 내부 속으로 - (1)  (0) 2022.04.25

댓글