Go에서 Reflect와 Struct Tag를 사용하여 간단한 ORM 만들어보기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Go에서 간단한 ORM을 만들어 내부 작동 방식 알아보기
현대 애플리케이션 개발 세계에서는 종종 데이터베이스와 상호 작용하게 됩니다. 원시 SQL 쿼리를 작성하면 궁극적인 제어권을 얻을 수 있지만, 복잡한 데이터 구조에서는 번거롭고 오류가 발생하기 쉽습니다. 객체-관계형 매퍼(ORM)가 등장하는 곳입니다. ORM은 객체 지향 프로그래밍 언어와 관계형 데이터베이스 간의 격차를 해소하여 개발자가 데이터베이스 레코드를 프로그래밍 언어의 네이티브 객체처럼 다룰 수 있도록 합니다. 이 추상화 계층은 생산성과 가독성을 크게 향상시킵니다. 하지만 ORM의 마법은 종종 그들의 근본적인 메커니즘을 가립니다.
이 글에서는 Go에서 조잡한 ORM을 구축하여 ORM의 특성을 파헤치는 것을 목표로 합니다. Go의 reflect 패키지와 struct tag를 사용하여 간단한 ORM을 어떻게 구축할 수 있는지 이해함으로써, 그들의 유용성과 그 뒤에 있는 엔지니어링 원리에 대한 더 깊은 감사를 얻게 될 것입니다. 이 여정은 ORM이 해결하는 핵심 과제와 reflection이 솔루션을 제공하는 독창적인 방법을 밝혀줄 것입니다.
데이터베이스 추상화의 구성 요소
구현에 뛰어들기 전에 ORM과 우리의 작은 프로젝트에 중심이 되는 몇 가지 주요 개념을 정의해 보겠습니다.
- ORM (객체-관계형 매퍼): 프로그래밍 언어의 객체 모델에 데이터베이스 스키마를 매핑하는 프로그래밍 도구입니다. 이를 통해 개발자는 SQL의 복잡성을 추상화하여 프로그래밍 언어의 패러다임을 사용하여 데이터베이스의 데이터를 조작할 수 있습니다.
 - Reflection: 컴퓨터 과학에서 reflection은 컴퓨터 프로그램이 런타임에 자체 구조와 동작을 검사, 내부 검사 및 수정하는 기능입니다. Go의 
reflect패키지는 이를 위한 강력한 도구를 제공하여 동적으로 struct 필드, 해당 유형 및 해당 값을 검사할 수 있도록 합니다. - Struct Tags: Go에서 struct 필드에 첨부할 수 있는 메타데이터 문자열입니다. 이러한 태그는 Go 컴파일러에 의해 무시되지만 런타임에 reflection을 사용하여 액세스할 수 있습니다. 특정 필드를 데이터베이스 열 이름에 매핑하거나 유효성 검사 규칙을 정의하는 방법에 대한 지침을 라이브러리 또는 ORM에 제공하는 데 완벽합니다.
 - 데이터베이스 드라이버: 애플리케이션이 특정 데이터베이스 시스템(예: MySQL 드라이버, PostgreSQL 드라이버)과 상호 작용할 수 있도록 하는 소프트웨어 구성 요소입니다. 우리의 ORM은 다양한 드라이버와의 일반적인 상호 작용을 제공하는 Go의 
database/sql패키지에 의존합니다. 
우리 간단한 ORM의 핵심 원리는 reflection을 사용하여 Go struct를 검사하고, 해당 필드(이름, 유형 등)에 대한 정보를 추출한 다음, struct tag를 사용하여 이러한 필드를 해당 데이터베이스 테이블 열에 매핑하는 것입니다. 이 매핑을 통해 INSERT 및 SELECT와 같은 일반적인 작업을 위한 동적 SQL 쿼리를 구성할 수 있습니다.
기본적인 ORM 작업: 저장 및 검색
우리의 최소 ORM은 두 가지 기초적인 작업에 초점을 맞출 것입니다: 새 레코드를 데이터베이스에 저장하고 기존 레코드를 로드하는 것입니다.
데이터베이스 테이블을 나타내는 간단한 User struct를 정의하는 것으로 시작하겠습니다. struct tag를 사용하여 데이터베이스 열 이름을 지정할 것입니다.
package main import ( "database/sql" "fmt" "reflect" "strings" _ "github.com/go-sql-driver/mysql" // 원하는 데이터베이스 드라이버로 바꾸세요 ) // User는 데이터베이스에서 사용자를 나타냅니다 type User struct { ID int `db:"id"` Name string `db:"name"` Email string `db:"email"` IsActive bool `db:"is_active"` CreatedAt string `db:"created_at"` // 단순화를 위해 datetime에 string 사용 } // 우리의 간단한 ORM 함수는 이 전역 DB 연결과 상호 작용합니다 var db *sql.DB func init() { var err error // 실제 데이터베이스 연결 문자열로 바꾸세요 // MySQL 예시: "user:password@tcp(127.0.0.1:3306)/database_name" db, err = sql.Open("mysql", "root:password@tcp(127.0.0.1:3306)/my_database") if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } fmt.Println("Successfully connected to the database!") // 테이블이 없으면 데모를 위해 간단한 테이블을 만듭니다 _, err = db.Exec(`CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, is_active BOOLEAN DEFAULT TRUE, created_at VARCHAR(255) )`) if err != nil { panic(err) } fmt.Println("Table 'users' ensured.") } // Insert는 struct를 받아 해당 테이블에 삽입합니다 // 테이블 이름은 struct 이름의 소문자 복수형이라고 가정합니다. // struct tag `db`를 사용하여 필드를 열 이름에 매핑합니다. func Insert(obj interface{}) (sql.Result, error) { val := reflect.ValueOf(obj) typ := reflect.TypeOf(obj) if typ.Kind() != reflect.Struct { return nil, fmt.Errorf("Insert expects a struct, got %s", typ.Kind()) } tableName := strings.ToLower(typ.Name()) + "s" // 간단한 복수형 var columns []string var placeholders []string var values []interface{} for i := 0; i < val.NumField(); i++ { field := typ.Field(i) fieldVal := val.Field(i) dbTag := field.Tag.Get("db") if dbTag == "" || strings.EqualFold(dbTag, "id") { // 자동 증가 ID 또는 빈 태그 건너뛰기 continue } columns = append(columns, dbTag) placeholders = append(placeholders, "?") // MySQL 플레이스홀더 values = append(values, fieldVal.Interface()) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) fmt.Printf("Executing query: %s with values: %v\n", query, values) return db.Exec(query, values...) } // FindByID는 데이터베이스에서 레코드를 검색하고 제공된 struct 포인터를 채웁니다. // 테이블 이름은 struct 이름의 소문자 복수형이라고 가정합니다. // struct tag `db`를 사용하여 필드를 열 이름에 매핑합니다. func FindByID(id int, objPtr interface{}) error { val := reflect.ValueOf(objPtr) if val.Kind() != reflect.Ptr || val.IsNil() { return fmt.Errorf("FindByID expects a non-nil struct pointer") } elem := val.Elem() if elem.Kind() != reflect.Struct { return fmt.Errorf("FindByID expects a pointer to a struct, got %s", elem.Kind()) } typ := elem.Type() tableName := strings.ToLower(typ.Name()) + "s" var columns []string var dest []interface{} // Scan을 위한 필드 포인터 for i := 0; i < elem.NumField(); i++ { field := typ.Field(i) fieldVal := elem.Field(i) dbTag := field.Tag.Get("db") if dbTag == "" { // db 태그가 없는 필드는 건너뜁니다 continue } columns = append(columns, dbTag) dest = append(dest, fieldVal.Addr().Interface()) // 필드의 주소를 가져옵니다 } query := fmt.Sprintf("SELECT %s FROM %s WHERE id = ?", strings.Join(columns, ", "), tableName) fmt.Printf("Executing query: %s with ID: %d\n", query, id) row := db.QueryRow(query, id) return row.Scan(dest...) } func main() { defer db.Close() // 1. 새 사용자 삽입 newUser := User{ Name: "Alice Smith", Email: "alice@example.com", IsActive: true, CreatedAt: "2023-10-27 10:00:00", } result, err := Insert(newUser) if err != nil { fmt.Printf("Error inserting user: %v\n", err) } else { id, _ := result.LastInsertId() fmt.Printf("User inserted with ID: %d\n", id) } // 2. ID로 사용자 찾기 var fetchedUser User err = FindByID(1, &fetchedUser) // ID 1이 있다고 가정 if err != nil { fmt.Printf("Error finding user: %v\n", err) } else { fmt.Printf("Fetched user: %+v\n", fetchedUser) } // 3. 다른 사용자 삽입하여 다른 데이터 시연 anotherUser := User{ Name: "Bob Johnson", Email: "bob@example.com", IsActive: false, CreatedAt: "2023-10-27 11:30:00", } result, err = Insert(anotherUser) if err != nil { fmt.Printf("Error inserting another user: %v\n", err) } else { id, _ := result.LastInsertId() fmt.Printf("Another user inserted with ID: %d\n", id) } var fetchedAnotherUser User err = FindByID(2, &fetchedAnotherUser) if err != nil { fmt.Printf("Error finding another user: %v\n", err) } else { fmt.Printf("Fetched another user: %+v\n", fetchedAnotherUser) } }
코드 설명:
- 
main및init함수:init함수는 MySQL 데이터베이스에 대한 전역*sql.DB연결을 설정합니다(자신의 연결 문자열로 바꿔야 하며github.com/go-sql-driver/mysql드라이버가 가져와졌는지 또는 다른 데이터베이스 드라이버를 사용하는지 확인해야 합니다).- 또한 예제를 위해 
users테이블이 있는지 확인합니다. main은 시연을 조율하여Insert와FindByID를 호출합니다.
 - 
Userstruct:- 각 필드에는 
ID int db:"id"와 같은dbstruct tag가 있습니다. 이 태그는 ORM에 해당 필드가 어떤 데이터베이스 열에 매핑되는지 정확하게 알려줍니다.ID필드는 일반적으로 자동 증가되므로 특별하게 처리할 것입니다. 
 - 각 필드에는 
 - 
Insert함수:obj(interface{})라는 struct를 인수로 받습니다.reflect.ValueOf(obj)및reflect.TypeOf(obj)를 사용하여 reflection 값과 유형을 가져옵니다.- 그런 다음 struct의 필드를 반복합니다.
field := typ.Field(i)는reflect.StructField메타데이터(이름, 유형, 태그 등)를 제공합니다.fieldVal := val.Field(i)는 실제 필드 내용을 위한reflect.Value를 제공합니다.dbTag := field.Tag.Get("db")는dbstruct tag의 값을 추출합니다.id가 자동 증가된다고 가정하고 빈 태그가 있거나 "id" 태그인 필드는 삽입에서 건너뜁니다.columns,placeholders,values슬라이스는 struct의 필드와 해당 값을 기반으로 동적으로 빌드됩니다.
 - 마지막으로 
fmt.Sprintf는INSERTSQL 쿼리를 구성한 다음db.Exec를 사용하여 실행합니다. 
 - 
FindByID함수:id와 검색된 데이터를 저장할 struct에 대한 포인터(objPtr)를 인수로 받습니다.reflect.ValueOf(objPtr).Elem()이 여기서 중요합니다.ptr을 struct를 수정할 수 있으므로 포인터로 사용해야 합니다.Elem()은 포인터를 역참조하여 기본 structreflect.Value를 가져옵니다.Insert와 유사하게 struct의 필드를 반복합니다.dest := append(dest, fieldVal.Addr().Interface())은 스캔에 중요합니다.Addr()는 필드의 주소를 나타내는reflect.Value를 반환하고Interface()는 이를row.Scan이 필드를 직접 채우기 위해 예상하는interface{}로 변환합니다.SELECT쿼리가 구성되고db.QueryRow().Scan(dest...)가 이를 실행하고 struct 필드를 포인터를 통해 직접 채웁니다.
 
Reflection 및 Struct Tag를 사용하는 이유?
reflect 없이 Insert 또는 FindByID 함수를 작성하려고 한다고 상상해 보세요. 각 struct 유형에 대해 별도의 함수(InsertUser, FindUserByID, InsertProduct, FindProductByID 등)를 작성하고 SQL 쿼리에서 각 struct 필드를 해당 데이터베이스 열에 수동으로 매핑해야 합니다. 이는 엄청난 코드 중복과 유지 관리 악몽으로 이어질 것입니다.
Reflection은 런타임에 구조를 동적으로 검사하고 필요한 정보를 추출하여 모든 struct에서 작동할 수 있는 일반 함수를 작성할 수 있도록 합니다. Struct tag는 struct 자체의 비즈니스 논동적을 흐리지 않고 reflection 프로세스를 안내하는 메타데이터를 선언적이고 깔끔한 방식으로 추가 방법을 제공합니다. ORM을 위한 구성 역할을 합니다.
결론
이 "매우 간단한" ORM을 구축함으로써, 우리는 더 정교한 ORM 프레임워크를 구동하는 핵심 원리를 다루었습니다. Go의 reflect 패키지가 유형 및 값의 동적 내부 검사를 가능하게 하는 방법과 struct tag가 Go 객체를 데이터베이스 스키마에 매핑하는 데 필수적인 메타데이터를 제공하는 방법을 보았습니다. 이 조합은 보일러플레이트 코드를 크게 줄이고 유지 관리성을 향상시키는 일반적인 데이터베이스 상호 작용 함수를 가능하게 합니다. 우리의 ORM에는 트랜잭션 관리, 복잡한 쿼리 빌딩 또는 관계 처리와 같은 기능이 부족하지만, 객체와 관계형 세계를 런타임 메타데이터 해석을 통해 연결한다는 핵심 원리를 분명히 보여줍니다. ORM은 본질적으로 애플리케이션 객체와 데이터베이스 레코드 간의 변환을 지능적으로 reflection 및 convention(종종 struct tag에 의해 안내됨)을 사용하여 데이터베이스 상호 작용을 추상화하는 강력한 도구입니다.

