Go 애플리케이션은 일반적으로 동시 작업을 처리하기 위해 고루틴을 사용합니다.
go-mssqldb 드라이버와 database/sql 패키지는 동시 사용을 위해 설계되었지만, 연결 유출, 데이터 충돌, 풀 부족을 피하려면 특정 패턴을 따라야 합니다. 이 글에서는 고루틴 안전, 직원 풀, 그리고 원활한 셧다운 패턴에 대해 다룹니다.
고루틴 안전
SQL입니다. DB는 동시 사용에도 안전합니다
인스턴스는 *sql.DB 여러 고루틴에서 동시에 사용하는 것이 안전합니다. 내부 연결 풀을 관리하고 동기화를 처리합니다:
// CORRECT: Share a single *sql.DB across all goroutines.
var db *sql.DB
func main() {
var err error
db, err = sql.Open("sqlserver", connString)
if err != nil {
log.Fatal(err)
}
defer db.Close()
http.HandleFunc("/employees", listEmployees) // Each request runs in its own goroutine.
log.Fatal(http.ListenAndServe(":8080", nil))
}
Warning
요청별이나 고루틴별로 새 sql.Open 파일을 만들지 마세요. 각 sql.Open 호출은 별도의 연결 풀을 생성합니다. 요청당 풀을 생성하면 자원이 낭비되고 서버 측 연결 한계가 빠르게 소진될 수 있습니다.
sql.Rows, sql.Tx 및 sql.Conn은 동시에 사용하기에 안전하지 않습니다
이 타입들은 단일 연결을 나타내며 한 번에 하나의 고루틴에서 사용해야 합니다:
// WRONG: Sharing rows across goroutines causes data races.
rows, _ := db.QueryContext(ctx, "SELECT BusinessEntityID, FirstName + ' ' + LastName FROM Sales.vSalesPerson")
go func() { rows.Next() }() // DATA RACE
go func() { rows.Next() }() // DATA RACE
// CORRECT: Process rows in the goroutine that created them.
rows, _ := db.QueryContext(ctx, "SELECT BusinessEntityID, FirstName + ' ' + LastName FROM Sales.vSalesPerson")
defer rows.Close()
for rows.Next() {
// Process in this goroutine only.
}
일꾼 풀 패턴
여러 항목을 동시에 처리해야 할 때(예: 수천 개의 레코드를 업데이트할 때) 고정 크기의 워커 풀을 사용하세요. 이 접근법은 동시성을 제한하여 풀 고갈과 서버 과부하를 방지합니다:
import "sync"
func updateEmployeeLocations(ctx context.Context, db *sql.DB, updates []EmployeeUpdate) error {
const maxWorkers = 10
sem := make(chan struct{}, maxWorkers)
var mu sync.Mutex
var firstErr error
var wg sync.WaitGroup
for _, u := range updates {
select {
case <-ctx.Done():
return ctx.Err()
case sem <- struct{}{}: // Acquire a worker slot.
}
wg.Add(1)
go func(u EmployeeUpdate) {
defer wg.Done()
defer func() { <-sem }() // Release the worker slot.
_, err := db.ExecContext(ctx,
"UPDATE HumanResources.Department SET GroupName = @grp WHERE DepartmentID = @id",
sql.Named("grp", u.GroupName),
sql.Named("id", u.Id))
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
}(u)
}
wg.Wait()
return firstErr
}
워커 풀에 errgroup을 사용하세요
이 패키지는 golang.org/x/sync/errgroup 내장된 오류 전파와 문맥 삭제 기능을 통해 워커 풀을 단순화합니다:
import "golang.org/x/sync/errgroup"
func updateDepartmentGroups(ctx context.Context, db *sql.DB, updates []DepartmentUpdate) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // Maximum concurrent goroutines.
for _, u := range updates {
u := u
g.Go(func() error {
_, err := db.ExecContext(ctx,
"UPDATE HumanResources.Department SET GroupName = @grp WHERE DepartmentID = @id",
sql.Named("grp", u.GroupName),
sql.Named("id", u.Id))
return err
})
}
return g.Wait()
}
Tip
에러그룹 한도를 보다 낮은 MaxOpenConns값으로 설정하세요. 워커 수가 풀 크기와 같으면 워커가 모든 연결을 점유하여 헬스 체크나 기타 쿼리를 수행할 여지를 남기지 않습니다.
병렬 쿼리
전체 지연 시간을 줄이기 위해 독립적인 쿼리를 동시에 실행하세요:
func getDashboardData(ctx context.Context, db *sql.DB) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var orderCount int
var customerCount int
var revenue float64
g.Go(func() error {
return db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM Sales.SalesOrderHeader WHERE OrderDate >= DATEADD(day, -7, GETUTCDATE())").
Scan(&orderCount)
})
g.Go(func() error {
return db.QueryRowContext(ctx,
"SELECT COUNT(DISTINCT CustomerID) FROM Sales.SalesOrderHeader WHERE OrderDate >= DATEADD(day, -7, GETUTCDATE())").
Scan(&customerCount)
})
g.Go(func() error {
return db.QueryRowContext(ctx,
"SELECT ISNULL(SUM(TotalDue), 0) FROM Sales.SalesOrderHeader WHERE OrderDate >= DATEADD(day, -7, GETUTCDATE())").
Scan(&revenue)
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{
OrderCount: orderCount,
CustomerCount: customerCount,
WeeklyRevenue: revenue,
}, nil
}
통제된 동시성을 가진 배치 처리
대규모 배치 작업(데이터 가져오기, 레코드 동기화)을 위해 배치와 동시성을 결합하여 처리량을 극대화합니다:
func importRecords(ctx context.Context, db *sql.DB, records []Record) error {
const batchSize = 100
const maxWorkers = 5
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxWorkers)
for i := 0; i < len(records); i += batchSize {
end := i + batchSize
if end > len(records) {
end = len(records)
}
batch := records[i:end]
g.Go(func() error {
return insertBatch(ctx, db, batch)
})
}
return g.Wait()
}
func insertBatch(ctx context.Context, db *sql.DB, batch []Record) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(mssql.CopyIn("Production.ScrapReason", mssql.BulkOptions{}, "Name"))
if err != nil {
return err
}
for _, r := range batch {
if _, err := stmt.Exec(r.Name); err != nil {
return err
}
}
if _, err := stmt.Exec(); err != nil {
return err
}
if err := stmt.Close(); err != nil {
return err
}
return tx.Commit()
}
정상적으로 종료
애플리케이션이 종료 신호를 받으면 풀을 닫기 전에 활성 데이터베이스 작업을 제거하세요.
db.Close()를 갑자기 호출하면 진행 중인 쿼리가 취소되고 서버에 고아 세션이 남을 수 있습니다.
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
db, err := sql.Open("sqlserver", connString)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Addr: ":8080"}
// Run the server in a goroutine.
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
}
}()
// Wait for interrupt signal.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
// Give in-flight HTTP requests up to 30 seconds to complete.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown error: %v", err)
}
// Close the database pool after HTTP handlers have drained.
// This waits for any remaining connections to be returned.
if err := db.Close(); err != nil {
log.Printf("Database close error: %v", err)
}
log.Println("Shutdown complete.")
}
Important
HTTP 서버(또는 다른 요청 라우터)가 데이터 소모를 마친 후 데이터베이스 풀을 종료하세요. 먼저 풀을 닫으면 비행 중 핸들러가 연결 오류가 발생합니다.
동시 작업 부하에 대한 연결 풀 크기
연결 풀의 크기를 고루틴 수가 아니라 애플리케이션의 예상 동시성에 따라 결정하세요:
| 애플리케이션 유형 | 권장 MaxOpenConns |
이유 |
|---|---|---|
| HTTP API, 낮은 동시성 | 10-25 | 일반적인 동시 요청 수와 일치합니다. |
| HTTP API, 높은 동시성 | 25-50 | 병렬 핸들러를 위한 더 많은 연결. |
| 배경 작업자(배치) | 노동자 풀당 5-10명 | 각 근로자는 자신만의 연결고리가 필요합니다. |
| 혼합 (API + 백그라운드 작업) | API + 워커 필요량의 합 | 각 서브시스템마다 충분한 헤드룸이 있는지 확인하세요. |
db.SetMaxOpenConns(25) // Total connections across all goroutines.
db.SetMaxIdleConns(10) // Keep warm connections ready for bursts.
db.SetConnMaxLifetime(5 * time.Minute) // Rotate connections for load balancer compatibility.
Tip
항상 MaxOpenConns을(를) 설정하세요. 기본값(0)은 무제한입니다. 부하가 걸린 무제한 풀은 수백 개의 연결을 열어 서버를 과부하시킬 수 있으며, 특히 Azure SQL에서는 연결 제한이 티어에 따라 달라집니다.
흔한 동시성 실수를 피하세요
고루틴 간에 *sql.Rows를 공유하지 마세요
*sql.Rows 값을 여러 고루틴에 전달하면 데이터 경쟁이 발생합니다:
// WRONG: rows is consumed by two goroutines.
rows, _ := db.QueryContext(ctx, "SELECT BusinessEntityID, FirstName + ' ' + LastName FROM Sales.vSalesPerson")
go processRows(rows)
go processRows(rows) // Race condition.
줄 고리로 닫는 것도 잊지 마세요
루프 내부에서 누수가 발생 *sql.Rows 하면 연결 풀이 고갈됩니다:
// WRONG: Rows leak when the loop starts a new iteration.
for _, location := range locations {
rows, _ := db.QueryContext(ctx,
"SELECT FirstName + ' ' + LastName FROM Sales.vSalesPerson WHERE CountryRegionName = @p1",
sql.Named("p1", location))
for rows.Next() {
// Process...
}
// rows.Close() never called if an error occurs.
}
// CORRECT: Use a helper function with defer.
for _, location := range locations {
if err := processLocation(ctx, db, location); err != nil {
return err
}
}
func processLocation(ctx context.Context, db *sql.DB, location string) error {
rows, err := db.QueryContext(ctx,
"SELECT FirstName + ' ' + LastName FROM Sales.vSalesPerson WHERE CountryRegionName = @p1",
sql.Named("p1", location))
if err != nil {
return err
}
defer rows.Close() // Guaranteed cleanup.
for rows.Next() {
// Process...
}
return rows.Err()
}
연결 고정성이 필요한 경우가 아니라면 db.Conn을 사용하지 마세요.
db.Conn(ctx) 특정 연결을 고정합니다. 불필요하게 사용하면 효과적인 풀 크기가 줄어듭니다:
// WRONG: Unnecessary pinning.
conn, _ := db.Conn(ctx)
defer conn.Close()
conn.QueryContext(ctx, "SELECT 1") // Use db.QueryContext instead.
// CORRECT: Use db.Conn only for temp tables or session-scoped state.
conn, _ := db.Conn(ctx)
defer conn.Close()
conn.ExecContext(ctx, "CREATE TABLE #Temp (Id INT)")
conn.ExecContext(ctx, "INSERT INTO #Temp VALUES (1)")
conn.QueryContext(ctx, "SELECT * FROM #Temp")
동시성 체크리스트
| Area | 권장 사항 |
|---|---|
| 풀 공유 | 전체 애플리케이션을 하나의 인스턴스로 만드 *sql.DB 세요. |
| 고루틴 안전 |
*sql.Rows, *sql.Tx 또는 *sql.Conn를 고루틴 간에 공유하지 마세요. |
| 워커 풀 | 동시성을 제어하려면 errgroup.SetLimit 또는 세마포어 채널을 사용하세요. |
| 수영장 크기 | 서버 연결 한도보다 낮게 설정 MaxOpenConns 하세요. |
| 정상적인 종료 | 데이터베이스 풀을 닫기 전에 HTTP 핸들러를 모두 제거하세요. |
| 리소스 정리 | 항상 해당 객체를 생성한 고루틴에서 defer rows.Close() 및 defer tx.Rollback()하세요. |
| 병렬 쿼리 | 집계 페이지의 지연을 줄이기 위해 독립적인 쿼리를 동시에 실행하세요. |