go-mssqldb와 동시 프로그래밍

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()하세요.
병렬 쿼리 집계 페이지의 지연을 줄이기 위해 독립적인 쿼리를 동시에 실행하세요.