Go 웹 앱에서의 데이터베이스 연결 관리: 의존성 주입 vs. 싱글톤 심층 분석
Ethan Miller
Product Engineer · Leapcell

소개
Go 웹 개발의 세계에서 데이터베이스 연결 관리는 근본적인 관심사입니다. Go의 sql.DB 인스턴스는 장기간 유지되고 스레드에 안전하도록 설계되었으며, 각 요청마다 열고 닫는 대신 애플리케이션 전체에서 재사용되어야 합니다. 그렇다면 이 sql.DB 인스턴스를 핸들러 및 서비스와 같은 웹 애플리케이션의 다양한 부분에 올바르게 인스턴스화하고 제공하는 방법은 무엇일까요? 이 간단해 보이는 작업은 종종 두 가지 일반적인 접근 방식, 즉 싱글톤 패턴과 의존성 주입 간의 논쟁을 불러일으킵니다. 각 접근 방식의 미묘한 차이와 테스트 가능성, 유연성 및 유지보수성에 미치는 영향을 이해하는 것은 견고하고 확장 가능한 Go 애플리케이션을 구축하는 데 중요합니다. 이 글에서는 두 가지 접근 방식을 모두 살펴보고, sql.DB를 사용한 핵심 원칙 및 실제 구현을 검토하며, 프로젝트에 "올바른" 방법을 결정하는 데 도움을 드릴 것입니다.
핵심 개념
비교 분석에 들어가기 전에 논의의 중심이 될 핵심 개념에 대한 공통된 이해를 확립해 보겠습니다.
Go의 sql.DB: 이것은 데이터베이스 연결 풀을 나타내는 Go의 표준 라이브러리 타입입니다. 연결의 생명주기, 즉 열기, 닫기 및 재사용을 관리합니다. 본질적으로 스레드에 안전하며 한 번 생성되어 애플리케이션 전체에서 공유되도록 설계되었습니다. sql.DB를 잘못 관리하면 연결 누수, 성능 병목 현상 또는 애플리케이션 충돌이 발생할 수 있습니다.
싱글톤 패턴: 클래스의 인스턴스화를 하나의 "단일" 인스턴스로 제한하는 디자인 패턴입니다. 그 의도는 클래스가 단 하나의 인스턴스만 가지도록 보장하고 전역 액세스 지점을 제공하는 것입니다. Go에서는 일반적으로 패키지 수준 변수가 한 번 초기화되는 데, 종종 init 함수 내에서 또는 sync.Once를 사용하여 수행됩니다.
의존성 주입 (DI): 의존성을 해결하기 위해 제어의 역전을 구현하는 소프트웨어 디자인 패턴입니다. 컴포넌트가 의존성을 생성하는 대신, 외부 소스에서 컴포넌트에 의존성이 제공됩니다(주입됨). 이는 컴포넌트를 더 독립적이고 테스트하기 쉬우며 변경에 더 유연하게 만들어 약한 결합을 촉진합니다. 일반적인 DI 기술에는 생성자 주입, 세터 주입 및 인터페이스 주입이 포함됩니다.
sql.DB를 위한 싱글톤 패턴
싱글톤 패턴은 sql.DB와 같은 공유 리소스를 관리하기 위한 직관적인 첫 번째 접근 방식인 경우가 많습니다. sql.DB는 이상적으로 애플리케이션 전체에 단일 인스턴스만 있어야 하므로 싱글톤이 완벽하게 맞는 것처럼 보입니다.
원칙
아이디어는 sql.DB의 단일하고 전역적으로 액세스 가능한 인스턴스를 갖는 것입니다. 이 인스턴스는 일반적으로 애플리케이션 시작 시 한 번 초기화된 다음, 이를 필요로 하는 코드의 어떤 부분에서도 직접 액세스됩니다.
구현 예제
package database import ( "database/sql" "log" "sync" _ "github.com/go-sql-driver/mysql" // 데이터베이스 드라이버로 바꾸세요 ) var ( db *sql.DB once sync.Once ) // InitDB는 데이터베이스 연결 풀을 초기화합니다. // 초기화가 한 번만 발생하도록 sync.Once를 사용합니다. func InitDB(dsn string) { once.Do(func() { var err error db, err = sql.Open("mysql", dsn) // 또는 "postgres", "sqlite3" 등 if err != nil { log.Fatalf("Failed to open database: %v", err) } // 선택 사항: 연결 풀 매개변수 설정 db.SetMaxOpenConns(20) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(0) // 연결은 영구적으로 재사용됩니다. if err = db.Ping(); err != nil { log.Fatalf("Failed to connect to database: %v", err) } log.Println("Database connection pool initialized") }) } // GetDB는 초기화된 데이터베이스 인스턴스를 반환합니다. // InitDB가 먼저 호출되지 않으면 패닉이 발생합니다. func GetDB() *sql.DB { if db == nil { log.Fatal("Database not initialized. Call InitDB() first.") } return db } // CloseDB는 데이터베이스 연결 풀을 닫습니다. func CloseDB() { if db != nil { if err := db.Close(); err != nil { log.Printf("Error closing database: %v", err) } log.Println("Database connection pool closed") } }
그리고 main.go에서:
package main import ( "fmt" "log" "net/http" "os" "yourproject/database" // 데이터베이스 패키지가 여기 있다고 가정 ) func main() { // 데이터베이스 초기화 dsn := os.Getenv("DATABASE_DSN") if dsn == "" { log.Fatal("DATABASE_DSN environment variable not set") } database.InitDB(dsn) defer database.CloseDB() http.HandleFunc("/users", listUsersHandler) log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Server failed: %v", err) } } // listUsersHandler는 싱글톤을 통해 직접 데이터베이스에 액세스합니다. func listUsersHandler(w http.ResponseWriter, r *http.Request) { db := database.GetDB() // 직접 액세스 rows, err := db.Query("SELECT id, name FROM users") if err != nil { http.Error(w, "Failed to query users", http.StatusInternalServerError) log.Printf("Error querying users: %v", err) return } defer rows.Close() // ... 행 처리 및 응답 전송 fmt.Fprintln(w, "Users listed successfully (via singleton)") }
애플리케이션 시나리오 및 단점
싱글톤 패턴은 구현하기 쉽고 애플리케이션을 실행하는 빠른 방법을 제공합니다. 종종 단순성이 우선시되는 소규모 애플리케이션이나 프로토타입에서 볼 수 있습니다.
그러나 상당한 단점이 있습니다:
- 전역 상태: 애플리케이션의 어떤 부분이든 공유
db인스턴스를 수정할 수 있으므로 코드를 추론하기 어렵게 만드는 전역 상태를 도입합니다. - 테스트 용이성:
database.GetDB()에 의존하는 함수나 핸들러를 단위 테스트하는 것이 어렵습니다. 테스트 데이터베이스를 위해sql.DB인스턴스를 모킹하거나 대체하기가 쉽지 않아 다른 테스트에 영향을 미치거나 복잡한 설정/해제가 필요할 수 있습니다. 이는 일반적으로 실제 단위 테스트 대신 통합 테스트로 이어집니다. - 유연성: 더 복잡한 싱글톤 변형에 의존하지 않고도 동일한 애플리케이션 인스턴스 내에서 다른 데이터베이스 구성(예: 읽기 복제본
sql.DB와 쓰기 마스터sql.DB)을 사용하기 어렵게 만듭니다. - 숨겨진 의존성:
sql.DB에 대한 의존성이 함수 서명에 명시적으로 표시되지 않아 코드를 이해하고 리팩터링하기 어렵습니다.
sql.DB에 대한 의존성 주입
의존성 주입(DI)은 특히 애플리케이션이 복잡해짐에 따라 싱글톤 패턴에 대한 더 강력하고 유연한 대안을 제공합니다.
원칙
컴포넌트가 의존성을 조회하거나 생성하는 대신, 해당 의존성이 컴포넌트에 "주입"됩니다. sql.DB의 경우, 필요로 하는 함수, 메서드 또는 구조체 필드에 sql.DB 인스턴스를 인수로 전달하는 것을 의미합니다.
구현 예제
listUsersHandler를 DI를 사용하도록 리팩터링해 보겠습니다.
먼저, sql.DB 작업이 사용할 인터페이스를 정의합니다. 이 패턴은 더 약한 결합을 촉진하고 모킹을 용이하게 하기 위해 Go DI에서 자주 사용됩니다.
// database/db_interface.go package database import "database/sql" // Queryer는 기본 데이터베이스 쿼리 작업을 추상화하는 인터페이스입니다. // 현재 핸들러에 필요한 메서드만 포함합니다. type Queryer interface { Query(query string, args ...interface{}) (*sql.Rows, error) // Exec, QueryRow 등 다른 메서드 추가 } // 실제 *sql.DB는 암묵적으로 Queryer를 구현합니다. // 이를 위해 `type DB struct { *sql.DB }`를 명시적으로 작성할 필요는 없습니다.
이제 Queryer 인터페이스를 수락하도록 핸들러를 다시 정의합니다. 이 패턴은 종종 종속성을 보유하는 "리포지토리" 또는 "서비스" 구조체를 만들어 달성됩니다.
// main.go (계속) package main import ( "fmt" "log" "net/http" "os" "yourproject/database" ) // UserService는 사용자 관련 작업을 처리하는 서비스입니다. // database.Queryer에 의존합니다. type UserService struct { db database.Queryer } // NewUserService는 제공된 데이터베이스 연결로 새 UserService를 생성합니다. func NewUserService(db database.Queryer) *UserService { return &UserService{db: db} } // ListUsersHandler는 UserService의 HTTP 핸들러 메서드입니다. func (s *UserService) ListUsersHandler(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query("SELECT id, name FROM users") if err != nil { http.Error(w, "Failed to query users", http.StatusInternalServerError) log.Printf("Error querying users: %v", err) return } defer rows.Close() // ... 행 처리 및 응답 전송 fmt.Fprintln(w, "Users listed successfully (via dependency injection)") } func main() { dsn := os.Getenv("DATABASE_DSN") if dsn == "" { log.Fatal("DATABASE_DSN environment variable not set") } // 1. 실제 sql.DB 인스턴스를 여기서 한 번 생성합니다. // 이 부분은 싱글톤 초기화와 유사하지만 전역적이지는 않습니다. db, err := sql.Open("mysql", dsn) // 드라이버로 바꾸세요 if err != nil { log.Fatalf("Failed to open database: %v", err) } defer db.Close() // main이 종료될 때 연결이 닫히도록 보장 db.SetMaxOpenConns(20) db.SetMaxIdleConns(10) if err = db.Ping(); err != nil { log.Fatalf("Failed to connect to database: %v", err) } log.Println("Database connection pool initialized") // 2. db 인스턴스를 UserService에 주입합니다. userService := NewUserService(db) // 의존성 주입이 여기서 발생합니다. // 3. 핸들러를 등록합니다. 참고: 메서드를 직접 전달하고 있습니다. http.HandleFunc("/users", userService.ListUsersHandler) log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Server failed: %v", err) } }
애플리케이션 시나리오 및 장점
의존성 주입은 유지보수성, 테스트 용이성 및 유연성이 가장 중요한 시나리오에서 빛을 발합니다.
장점:
-
테스트 용이성:
interface{}를 주입함으로써 단위 테스트에서 데이터베이스를 쉽게 모킹할 수 있습니다. 실제 데이터베이스에 연결하지 않고도 예측 가능한 데이터나 오류를 반환하는database.Queryer의 모킹 구현을 만들 수 있습니다.// _test.go 파일에서 type MockQueryer struct{} func (m *MockQueryer) Query(query string, args ...interface{}) (*sql.Rows, error) { // 테스트를 위한 더미 행 반환 // 이 부분은 *sql.Rows를 실제로 모킹하기 위해 좀 더 설정이 필요하지만, // 원칙은 명확합니다: 종속성을 제어합니다. return &sql.Rows{}, nil // 간결성을 위해 단순화 } func TestUserService_ListUsersHandler(t *testing.T) { mockDB := &MockQueryer{} userService := NewUserService(mockDB) req, _ := http.NewRequest("GET", "/users", nil) rr := httptest.NewRecorder() userService.ListUsersHandler(rr, req) if status := rr.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } // 응답 본문에 대한 추가 검증 } -
명시적 종속성: 종속성은 구조체 필드 또는 함수 매개변수에 명시적으로 선언되어 코드를 이해하고 추론하기 쉽게 만듭니다.
-
유연성:
UserService로직을 변경하지 않고도Queryer의 다른 구현(예: 실제sql.DB, 읽기 전용 복제본sql.DB, 테스트용 인메모리 데이터베이스, 또는 다른 데이터베이스 드라이버)을 쉽게 교체할 수 있습니다. -
약한 결합: 컴포넌트는 느슨하게 결합되어 있어, 한 컴포넌트의 변경(예:
sql.DB설정 방식)은 인터페이스 계약이 유지되는 한 다른 컴포넌트에 직접적인 영향을 미치지 않습니다. -
동시성 안전성:
sql.DB가 종속성으로 주입될 때, 인스턴스 자체가 올바르게 관리되는 한(이는sql.DB가 내부적으로 수행함) 스레드 안전성 특성이 유지됩니다. 주입 패턴은 새로운 동시성 문제를 도입하는 것이 아니라 공유 리소스를 안전하게 관리하는 데 도움이 됩니다.
어떤 접근 방식이 "정확한"가?
두 패턴 모두 sql.DB 인스턴스를 관리할 수 있지만, 비중요한 Go 웹 애플리케이션의 경우 의존성 주입이 일반적으로 선호되는 접근 방식입니다.
- 작은 유틸리티 또는 빠른 스크립트의 경우: 잘 구현된 싱글톤(
sync.Once사용)은 단순성 때문에 허용될 수 있습니다. - 견고하고, 테스트 가능하며, 유지보수 가능한 웹 애플리케이션의 경우: 인터페이스와 결합된 의존성 주입은 우수한 유연성과 테스트 용이성을 제공합니다. 이는 명시적 종속성 및 작고 집중된 인터페이스를 위한 Go 철학과 더 잘 일치합니다.
DI 설정을 위한 초기 비용은 약간 더 높을 수 있지만, 장기적인 유지보수성, 리팩터링 용이성, 그리고 코드에 대한 확신(효과적인 단위 테스트를 통해)의 이점은 이 비용을 훨씬 초과합니다. 이는 애플리케이션을 변경에 더 잘 적응시키고 새로운 팀 구성원이 이해하고 기여하기 쉽게 만듭니다.
결론
Go 웹 애플리케이션에서 sql.DB를 관리하려면 코드의 다른 부분에 해당 단일, 장기간 사용 인스턴스를 사용할 수 있도록 하는 전략을 선택해야 합니다. 싱글톤 패턴은 단순성을 제공하지만 전역 상태를 도입하여 테스트 용이성과 유연성을 저해합니다. 종속성을 명시적으로 제공하는 의존성 주입은 더 모듈화되고, 테스트 가능하며, 유지보수 가능한 코드로 이어집니다. 중요한 Go 웹 애플리케이션의 경우, 인터페이스를 사용한 의존성 주입을 채택하는 것이 데이터베이스 연결을 관리하는 "정확한" 그리고 가장 유익한 접근 방식입니다. 이는 이해하고 발전시키기 쉬운 코드베이스를 육성합니다.

