상황 설명
회사에서 MySQL로 DB를 바꾸면서 코드를 전반적으로 수정할 수 있는 기회가 있었다.
평소에 사소하지만 눈여겨 보고 있었던 리팩토링 희망사항(?)이 있었는데,
바로 config에 입력된 수많은 db 테이블들을 go 코드에서 각각 객체로 만들기 위해 또 변수명으로 해당 이름을 재사용(!)한다는 점이다.
말보다는 코드로 보는게 설명이 편할 것 같아 냅다 예시 코드를 가져왔다. (그렇다 원래는 MongoDB를 사용했다.)
// config.toml
[SomeDB]
collectionName1 = "collectionName1"
collectionName2 = "collectionName2"
collectionName3 = "collectionName3"
// config.go
type SomeDB struct {
...
CollectionName1 string
CollectionName2 string
CollectionName3 string
...
}
type SomeDBRepo struct {
db *mongo.Database
collectionName1 *mongo.Collection
collectionName2 *mongo.Collection
collectionName3 *mongo.Collection
// 이런식으로 한 10개 넘게 있다
...
}
func NewSomeDBRepo(cfg *config.Config, opt *options.DatabaseOptions) (*SomeDBRepo, error) {
r := &SomeDB{
...
}
someDBConfig := cfg.Database.SomeDB
if someDBClient, err := db_util.ConnectMongoDB(someDBConfig.DataSource, "", ""); err != nil {
return nil, err
} else {
r.db = someDBClient.Database(cfg.Database.SomeDB.DB, opt)
r.collectionName1 = r.db.Collection(cfg.Database.SomeDB.CollectionName1)
r.collectionName2 = r.db.Collection(cfg.Database.SomeDB.CollectionName2)
r.collectionName3 = r.db.Collection(cfg.Database.SomeDB.CollectionName3)
...
}
자 문제가 보이지 않는가?! 똑같은 collectionName들이 계속! 반복된다!
그리고 새로운 컬렉션이 추가될 때마다 코드는 계속 늘어난다!
이미 기존에 만들어진 서버에 기능을 추가할 때는 편의상 큰 문제점을 느끼지 않고 넘어갈 수 있는 부분이었다.
그렇지만 이제 코드를 통째로 수정하려고 하다 보니 사소한 거슬림이 큰 거슬림이 되어 도저히! 게으른 개발자로서 넘어갈 수 없는 코드가 되었다.
수정 계획 세우기
그럼 코드를 어떻게 수정하면 좋을까?
1.
일단 config.toml을 config.go에 선언된 타입으로 디코딩을 해주는데, toml에 선언된 그대로 key-value 짝이 되어있어야 하므로 config.go 는 기존 그대로의 형식을 사용해야한다.
// config.toml
[SomeDB]
collectionName1 = "collectionName1"
collectionName2 = "collectionName2"
collectionName3 = "collectionName3"
// config.go
type SomeDB struct {
...
CollectionName1 string
CollectionName2 string
CollectionName3 string
...
}
2.
여기 repo 부분에서 반복되는 부분이 싫어서 없애려고 했지만 불가능하다는걸 깨달았다. (바보였다! 너무 게으른 생각이었다!)
type SomeDBRepo struct {
db *mongo.Database
collectionName1 db.Collection
collectionName2 db.Collection
collectionName3 db.Collection
...
}
어쨌든 위의 repo 구조체 필드로 프로세스들이 접근해서 사용해야 하는데, 이 이름들은 각각 분리가 되어있어야 개발자들이 어떤 테이블에 접근이 가능한지 알 수 있다ㅋㅋㅋ... 고로 이 부분도 기존과 그대로다.
3.
수정해야하는 부분이 좁혀졌다. 바로 db.Collection 객체 초기화 부분이다.
각각 따로 선언문을 작성할 필요 없이 반복문 하나로 초기화를 완료시키려고 한다.
SomeDB 객체(struct)가 가지고 있는 collection(MySQL은 table이라고 부르지만 아무튼) field들을,
필드 이름을 기준으로 DB repo 객체에 field로 똑같이 넣어줄 것이다.
정리하자면 조건 및 과제는 다음과 같다.
- 변수 이름은 config.go의 SomeDB 객체의 field 이름과 동일해야하고,
- public 접근이 가능하도록 대문자로 시작해야함
- 해당 변수에 db.Collection() 객체로 초기화
구현하기
과제를 분할-정복 해보았다.
한번에 쭉~ 구현하면 좋겠지만 나도 처음 개발하는 거라 일단 생각나는 기능(task)을 조각조각 구현한 다음에 이어 붙이는 방식을 선택했다.
내가 해결했던 task들의 순서를 그대로 복기하면서 정리해보겠다!
1. 어떤 struct 의 필드 이름을 어떻게 가져올 수 있을까?
제일 중요한 task다.
go의 struct에서 field는 거기에 해당하는 value를 가져오는 지시자로서만 사용되지 field 그 자체에 목적을 두지 않는다.
value를 부르기 위한 이름 그 이상도 이하도 아니기 때문이다.
그렇지만 나는 그 field에 관심이 있었고, 그 이름을 알고 싶었다! (너의 이름은?)
서치를 하다가 reflect 패키지를 이용하면 field 이름을 가져올 수 있다는 사실을 알게 되었다!
reflect 패키지에 대해서 간략하게 설명하면 다음과 같다.
- 런타임에 타입, 값, 구조 등을 검사하고 조작할 수 있는 기능을 제공
- 동적 타입 검사, 인터페이스 값 조사, 구조체 필드 접근 등에 사용
reflect 에서는 어떤 값을 크게 두가지 분류로 나누어 생각한다. 바로 Type 과 Value 다.
Type은 실제로 우리가 알고 있는 Go에서의 타입이다. Go의 Interface에는 어떠한 타입이든 담을 수 있으므로, 실제로 인터페이스에 어떤 타입이 들어갔는지를 확인하기 위해 주로 사용한다. 즉, 변수의 타입 정보를 나타낸다.
reflect.TypeOf() 함수로 얻을 수 있으며, 타입의 이름, 종류, 메서드, 필드 등의 정보를 제공한다.
Value는 변수의 실제 값을 나타내며, reflect.ValueOf() 함수로 얻을 수 있다.
값 읽기, 수정, 메서드 호출 등의 작업을 수행할 수 있다.
이 reflect.Type를 통해 구조체 필드에 접근할 수 있는데, reflect가 리턴해주는 구조체 필드 데이터 구조(StructField)를 통해 field name을 가져올 수 있다.
func (reflect.Type) Field(i int) reflect.StructField
// Field returns a struct type's i'th field.
// It panics if the type's Kind is not Struct.
// It panics if i is not in the range [0, NumField()).
type StructField struct {
// Name is the field name.
Name string
// PkgPath is the package path that qualifies a lower case (unexported)
// field name. It is empty for upper case (exported) field names.
// See https://golang.org/ref/spec#Uniqueness_of_identifiers
PkgPath string
Type Type // field type
Tag StructTag // field tag string
Offset uintptr // offset within struct, in bytes
Index []int // index sequence for Type.FieldByIndex
Anonymous bool // is an embedded field
}
다만 이제 reflect.StructField 구조체를 받기 위해서는 해당 Field가 몇번째 필드인지 정보가 필요하다.
이를 위해 reflect.Value 메소드인 NumField()를 사용한다.
// NumField returns the number of fields in the struct v.
// It panics if v's Kind is not Struct.
func (v Value) NumField() int {
v.mustBe(Struct)
tt := (*structType)(unsafe.Pointer(v.typ))
return len(tt.fields)
}
config 데이터를 config 구조체로 받기 때문에 위의 모든 함수를 별탈 없이 쓸 수 있다.
그렇게 완성된 첫번째 코드 조각은 다음과 같다.
value := reflect.ValueOf(p.cfg)
for i := 0; i < value.NumField(); i++ {
fieldName := value.Type().Field(i).Name
}
2. 필드 이름으로 이것저것 완성!
이제 이름을 가져올 수 있으니 할 일이 세가지가 있다.
1) repo 구조체 필드에 넣을 public 변수명을 만들고,
2) 변수에 해당되는 value 값(실제 table의 이름)을 config.toml에서 가져온다.
3) 마지막으로 2번의 값을 사용해서 db.collection() 객체를 만들어, 1번의 변수명에 할당시켜주면 끝!
1)
우선 public 변수명을 만들어보자. 변수명을 만드는 법은 쉽다.
UpperFieldName := strings.ToUpper(string(fieldName[0])) + fieldName[1:]
이렇게 public 변수명이 만들어졌다. (사실 이미 대문자이지만 혹시나 소문자로 들어갈 경우를 대비했다.)
2)
config.toml에서 위의 필드명에 해당되는 value 역시 쉽게 가져올 수 있다. config 객체에 접근을 하면 되니까~
collection := session.Collection(p.cfg.Collections[UpperFieldName])
value를 가져오면서 바로 db.collection 객체를 만들도록 코드를 작성했다.
config 구조체에 collection 관련 정보만 있는게 아니기 때문에, collection 정보만 가져오기 위해 조건문이 붙여졌다.
if strings.HasPrefix(fieldName, "Collection") {
...
}
3)
이제 만들어진 db.Collection을 동일한 이름을 가진 필드에 할당을 해야한다.
필드는 이름으로 가져올 수 있지만, 이미 for문을 돌면서 우리는 필드를 순서대로 접근하고 있는 상태다.
그냥 순서에 맞게 필드에 접근하고 있으니, 지금 접근하고 있는 필드에 바로 set을 해주면 된다.
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
...
field.Set(reflect.ValueOf(collection))
}
필드에는 value 타입만 set 할 수 있으므로 reflect.ValueOf 메소드를 사용해 db.Collection의 value를 가져와서 set을 해주었다.
func (v Value) Set(x Value)
// Set assigns x to the value v. It panics if Value.CanSet returns false. As in Go, x's value must be assignable to v's type and must not be derived from an unexported field.
최종 코드
최종 코드는 그래서 아래와 같다.
// config의 collection 이름을 바탕으로 db.Collection 객체 채우기
v := reflect.ValueOf(p).Elem()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := v.Type().Field(i).Name
if strings.HasPrefix(fieldName, "Collection") {
UpperFieldName := strings.ToUpper(string(fieldName[0])) + fieldName[1:]
collection := session.Collection(p.cfg.Collections[UpperFieldName])
exists, err := collection.Exists()
if err != nil {
if !exists {
return nil, fmt.Errorf("collection does not exists: %s", collection.Name())
}
return nil, err
} else {
field.Set(reflect.ValueOf(collection))
}
}
}
추가적으로 collection이 실제로 존재하는지 확인하는 코드도 추가했다.
해당 경우 에러를 반환되지 않고, 그냥 빈 db.Collection을 주기 때문인데, 그렇게된다면 존재하지 않은 collection을 set 하게되는 위험이 있기 때문이다.
후기
만들고 나니까 묵힌 체증이 내려가는 느낌이었다ㅋㅋㅋㅋ
그동안 뭉치같이 화면을 차지하는 collection 초기화 코드를 정말 고치고 싶었는데, 깔끔 효율적으로 변해서 너무 뿌듯했다.
그리고 reflect 패키지 숙련도가 확연히 늘었다.
예전에도 util 함수 개발하면서 써본 적이 있었는데 그때는 진짜 처음 접하는거라 살짝 아리까리한 상태에서 사용했었다.
지금은 공식 문서도, 레퍼런스 자료들도 두번 보는거라 그런지 더 이해가 잘되고, 패키지를 제대로 알고 사용하는 느낌이 들면서, 개발 실력이 약간 상승된 것 같아 기분이 좋았다.
내가 개발하는 프로젝트에서 무사히 돌아가는걸 확인한 후에, 다른 팀원들도 쓸 수 있도록 함수화 시킨 다음에 공통 패키지에 PR을 제안하려고 했는데!!!! 아쉽게도 나보다 먼저 해당 기능을 먼저 만들어서 제의를 한 팀원이 있었다. (코드가 거의 유사했다 허허)
개발 하는 중간에 해당 커밋을 봤어서 의욕이 살짝 꺾였었으나, 그래도 시작했으니 끝내자라는 생각으로 다른 팀원의 코드를 보지 않고 내 스스로 온전히 코드를 완성했다.
아무튼 내 코드이건 아니건, 해당 PR은 긍정적으로 받아들여졌다. 팀 내에서 아주 요긴히 쓰고 있는 중이다.
개인적으로 생각한 한계점(?)은 필드 이름이 "Collection"으로 무조건 시작이 되어야 한다는 규칙이 있다는 것이다.
우리 팀에서는 컨벤션으로 정해진 부분이지만 다른 컨벤션을 가진 팀에서 사용하기에는 범용성 측면에서 한계가 있다고 생각한다.
'Language > Go' 카테고리의 다른 글
[Go] Decimal decoder : Decimal을 MongoDB에 바로 String으로 저장 (3) | 2024.06.10 |
---|---|
[Go] encoding/json 패키지 - json.Decoder, Unmarshal & io.ReadAll (1) | 2023.10.31 |