go-mssqldb가 포함된 쿼리 및 문장

드라이버는 go-mssqldb 쿼리 실행과 문장 실행에 표준 database/sql 인터페이스를 사용합니다. 이 글에서는 운전자와 관련된 데이터 접근 방식의 일반적인 패턴을 다룹니다.

SELECT 쿼리 실행

행을 반환하는 쿼리를 실행하려면 QueryContext을(를) 사용합니다:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName FROM Sales.vSalesPerson WHERE CountryRegionName = @p1",
    sql.Named("p1", "Australia"))
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id int
    var name, location string
    if err := rows.Scan(&id, &name, &location); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d: %s (%s)\n", id, name, location)
}
if err = rows.Err(); err != nil {
    log.Fatal(err)
}

Important

항상 rows.Close()를 호출하고(보통 defer와 함께), 루프 후에 rows.Err()를 확인하세요. 줄을 닫지 않으면 수영장 연결이 새어나올 수 있습니다. rows.Close() 또한 드라이버가 남은 토큰을 소모하는 동안 서버 측 오류를 반환할 수 있으니, 결과 세트가 완전히 소모되지 않았을 때는 무시하지 마세요.

중간에 읽기를 중단하는 경우, 행을 명시적으로 닫고 닫기 오류를 처리하세요:

rows, err := db.QueryContext(ctx,
    "SELECT TOP (100) ProductID, Name FROM Production.Product ORDER BY ProductID")
if err != nil {
    log.Fatal(err)
}

for rows.Next() {
    var id int
    var name string
    if err := rows.Scan(&id, &name); err != nil {
        _ = rows.Close()
        log.Fatal(err)
    }

    fmt.Printf("%d %s\n", id, name)
    break // Stop early for demonstration.
}

if err := rows.Close(); err != nil {
    log.Fatal(err)
}
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

이 글의 예시들은 AdventureWorks2025 샘플 데이터베이스를 기준으로 합니다. 읽기 중심 예제는 Sales.vSalesPerson, Production.Product, Sales.SalesOrderHeader와 같은 기본 제공 객체를 쿼리합니다. 쓰기 중심 예제는 HumanResources.DepartmentProduction.ProductInventory를 대상으로 합니다.

단일 행 조회

정확히 한 행이 예상되는 경우 QueryRowContext를 사용하세요:

var id int
var name string
err := db.QueryRowContext(ctx,
    "SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE BusinessEntityID = @p1",
    sql.Named("p1", 280)).Scan(&id, &name)
if err == sql.ErrNoRows {
    fmt.Println("No employee found.")
} else if err != nil {
    log.Fatal(err)
} else {
    fmt.Printf("Employee %d: %s\n", id, name)
}

문장 실행

ExecContext, INSERT, UPDATE 및 DDL 문에는 DELETE 사용:

result, err := db.ExecContext(ctx,
    "INSERT INTO HumanResources.Department (Name, GroupName) VALUES (@p1, @p2)",
    sql.Named("p1", "Data Science"),
    sql.Named("p2", "Research and Development"))
if err != nil {
    log.Fatal(err)
}

rowsAffected, _ := result.RowsAffected()
fmt.Printf("Rows affected: %d\n", rowsAffected)

Important

드라이버는 go-mssqldb를 지원하지 않습니다. LastInsertId() 호출하면 오류가 발생합니다. 삽입된 IDENTITY 값을 가져오려면 OUTPUT 절 또는 별도의 SELECT SCOPE_IDENTITY() 쿼리를 사용하세요.

SELECT SCOPE_IDENTITY()를 사용하는 경우, ID 범위가 동일한 연결에서 유지되도록 INSERT와 동일한 배치 또는 트랜잭션에서 실행하세요.

보고되는 RowsAffected() 카운트는 해당 행을 생성한 카운트에 따라 달라집니다:

  • 저장 프로시저: 모든 문이 를 RowsAffected() 사용하는 SET NOCOUNT ON프로시저 내에서 실행될 때, 행 수가 전송되지 않으므로 0을 반환합니다. 개수를 얻으려면 SELECT를 제거하거나, OUTPUT 매개변수 또는 SET NOCOUNT ON 문을 통해 개수를 반환합니다.
  • AFTER 트리거:RowsAffected()AFTER를 사용하지 않는 SET NOCOUNT ON트리거의 영향을 받는 행을 포함합니다. 트리거가 두 개의 감사 행을 기록하는 테이블의 단일 행 UPDATE 은 1이 아니라 3을 반환합니다. 트리거 본문에 SET NOCOUNT ON를 추가하면 트리거의 행이 제외되고 1이 반환됩니다. 외부 진술서의 집계는 여전히 보고되고 있습니다. 행 수를 바로잡기 위해 트리거에서 SET NOCOUNT ON를 제거하지 마세요. 이를 제거하면 수치가 부풀려집니다.
  • INSTEAD OF 트리거: 카운트에는 트리거의 문장과 원래 문장이 포함되며, 원문은 실행되지 않습니다. 트리거가 두 개의 감사 행을 작성하고 업데이트를 수행하는 단일 행 UPDATE 은 4를 반환합니다. 트리거가 업데이트를 수행하지 않아도 호출은 3을 반환하고 행은 변하지 않으므로, 0이 아닌 카운트는 데이터가 변경되었다는 것을 확인하지 못합니다. 결과를 검증할 필요가 있을 때 영향을 받은 테이블을 쿼리하세요. 트리거 본문에 SET NOCOUNT ON를 추가하면 두 경우 모두 1이 반환됩니다.
  • 다중 문장 배치:ExecContext(ctx, "UPDATE ...; UPDATE ...") 마지막 문장의 개수가 아닌 모든 계산된 문장의 합을 반환합니다. 각 명세서당 개수를 얻기 위해 별도의 ExecContext 호출을 사용하세요.

매개 변수가 있는 쿼리

SQL 인젝션을 피하려면 항상 매개변수화된 쿼리를 사용하세요. 드라이버는 위치 매개변수와 이름 매개변수 모두를 지원합니다.

Important

go-mssqldb 드라이버는 위치 매개변수에 @p1, @p2 등을 사용하고 이름 매개변수에는 sql.Named()를 사용합니다. 일부 다른 드라이버에서 사용하는 ? 플레이스홀더 구문(MySQL의 go-sql-driver 등)은 sqlserver 드라이버 이름에서는 작동하지 않습니다. 다른 데이터베이스에서 마이그레이션하는 경우, 모든 ? 또는 $1-스타일 자리 표시자를 @p1-스타일 또는 명명된 매개변수로 바꾸세요.

위치 매개 변수

@p1, @p2 자리 표시자를 사용하고 값을 순서대로 전달하세요:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName, CountryRegionName FROM Sales.vSalesPerson WHERE FirstName = @p1 AND CountryRegionName = @p2",
    "Jared", "Australia")

명명된 매개 변수

sql.Named()을(를) 사용하여 값들을 이름이 지정된 자리 표시자에 바인딩합니다:

rows, err := db.QueryContext(ctx,
    "SELECT BusinessEntityID, FirstName, CountryRegionName FROM Sales.vSalesPerson WHERE FirstName = @name AND CountryRegionName = @location",
    sql.Named("name", "Jared"),
    sql.Named("location", "Australia"))

다중 결과 집합

단일 배치나 저장 프로시저가 반환하는 여러 결과 세트를 반복하는 데 사용됩니다 rows.NextResultSet() .

Important

rows.Next()를 호출하기 전에 각 결과 집합에 대해 rows.NextResultSet()를 완전히 소진해야 합니다. NextResultSet() 전에 Next()를 호출하면 false를 반환하고 나머지 행은 아무 알림 없이 건너뜁니다.

이 루프 패턴을 사용하여 모든 결과 세트를 신뢰성 있게 처리하세요:

rows, err := db.QueryContext(ctx,
    `SELECT TOP (3) ProductID, Name
     FROM Production.Product
     ORDER BY ProductID;

    SELECT TOP (3) SalesOrderID, CONVERT(NVARCHAR(10), OrderDate, 23) AS OrderDate
     FROM Sales.SalesOrderHeader
     ORDER BY SalesOrderID DESC;`)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

setIndex := 0
for {
    switch setIndex {
    case 0:
        for rows.Next() {
            var productID int
            var productName string
            if err := rows.Scan(&productID, &productName); err != nil {
                log.Fatal(err)
            }
            fmt.Printf("Product %d: %s\n", productID, productName)
        }
    case 1:
        for rows.Next() {
            var salesOrderID int
            var orderDate string
            if err := rows.Scan(&salesOrderID, &orderDate); err != nil {
                log.Fatal(err)
            }
            fmt.Printf("Order %d: %s\n", salesOrderID, orderDate)
        }
    }

    if err := rows.Err(); err != nil {
        log.Fatal(err)
    }
    if !rows.NextResultSet() {
        break
    }
    setIndex++
}

Transactions

특정 격리 수준을 사용하여 트랜잭션을 시작하려면 BeginTx를 사용합니다. 격리 수준, 저장 지점, 교착 상태 처리, 재시도 패턴 등 포괄적인 거래 지침은 트랜잭션을 참조하세요.

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
})
if err != nil {
    log.Fatal(err)
}
defer tx.Rollback()

// Subtract from source location.
_, err = tx.ExecContext(ctx,
    "UPDATE Production.ProductInventory SET Quantity = Quantity - @p1 WHERE ProductID = @p2 AND LocationID = 1",
    sql.Named("p1", 5),
    sql.Named("p2", 1))
if err != nil {
    log.Fatal(err)
}

// Add to destination location.
_, err = tx.ExecContext(ctx,
    "UPDATE Production.ProductInventory SET Quantity = Quantity + @p1 WHERE ProductID = @p2 AND LocationID = 6",
    sql.Named("p1", 5),
    sql.Named("p2", 1))
if err != nil {
    log.Fatal(err)
}

if err = tx.Commit(); err != nil {
    log.Fatal(err)
}

삽입된 정체성 값을 얻기

드라이버는 go-mssqldb를 지원하지 않습니다. LastInsertId() 동일한 문에서 IDENTITY 값을 검색하려면 OUTPUT 절을 사용하세요:

var newID int64
err := db.QueryRowContext(ctx,
    "INSERT INTO HumanResources.Department (Name, GroupName) OUTPUT INSERTED.DepartmentID VALUES (@name, @grp)",
    sql.Named("name", "Data Science"),
    sql.Named("grp", "Research and Development")).Scan(&newID)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Inserted department with ID: %d\n", newID)

여러 행의 경우:

rows, err := db.QueryContext(ctx, `
    INSERT INTO HumanResources.Department (Name, GroupName)
    OUTPUT INSERTED.DepartmentID, INSERTED.Name
    VALUES (@n1, @g1), (@n2, @g2)`,
    sql.Named("n1", "Data Science"), sql.Named("g1", "Research and Development"),
    sql.Named("n2", "Cloud Ops"), sql.Named("g2", "Information Technology"))
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id int64
    var name string
    if err := rows.Scan(&id, &name); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Inserted: %d - %s\n", id, name)
}

페이지 나누기

서버 측 페이지 매김에는 OFFSETFETCH NEXT을 사용합니다. ORDER BY 다음 조항이 필요합니다:

오프셋 기반 페이지네이션

오프셋과 페이지 크기를 매개변수로 전달합니다:

func getEmployeesPage(ctx context.Context, db *sql.DB, page, pageSize int) ([]Employee, error) {
    offset := (page - 1) * pageSize
    rows, err := db.QueryContext(ctx, `
        SELECT BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location
        FROM Sales.vSalesPerson
        ORDER BY BusinessEntityID
        OFFSET @offset ROWS
        FETCH NEXT @pageSize ROWS ONLY`,
        sql.Named("offset", offset),
        sql.Named("pageSize", pageSize))
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var employees []Employee
    for rows.Next() {
        var e Employee
        if err := rows.Scan(&e.Id, &e.Name, &e.Location); err != nil {
            return nil, err
        }
        employees = append(employees, e)
    }
    return employees, rows.Err()
}

큰 테이블을 위한 키셋 페이지네이션

서버가 행을 건너뛰어야 하므로 큰 테이블에서는 오프셋 페이지네이션이 느려집니다. 키셋 페이지네이션은 마지막으로 본 키를 사용하여 효율적으로 다음 페이지를 가져오는 방식입니다:

func getNextPage(ctx context.Context, db *sql.DB, lastID int, pageSize int) ([]Employee, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT TOP(@pageSize) BusinessEntityID, FirstName + ' ' + LastName AS Name, CountryRegionName AS Location
        FROM Sales.vSalesPerson
        WHERE BusinessEntityID > @lastID
        ORDER BY BusinessEntityID`,
        sql.Named("pageSize", pageSize),
        sql.Named("lastID", lastID))
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var employees []Employee
    for rows.Next() {
        var e Employee
        if err := rows.Scan(&e.Id, &e.Name, &e.Location); err != nil {
            return nil, err
        }
        employees = append(employees, e)
    }
    return employees, rows.Err()
}

Tip

키셋 페이지네이션은 깊은 페이지(1000+페이지)보다 OFFSET/FETCH 훨씬 빠르며, 이는 스캐닝 및 스킵 대신 인덱스 시크를 사용하기 때문입니다.

여러 문 일괄 처리

네트워크 왕복을 줄이기 위해 한 번의 호출에서 여러 SQL 문장을 전송하세요.

rows, err := db.QueryContext(ctx, `
    SELECT COUNT(*) FROM HumanResources.Employee;
    SELECT COUNT(*) FROM Sales.SalesOrderHeader;
    SELECT COUNT(*) FROM Production.Product;`)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

var empCount, orderCount, productCount int

if rows.Next() {
    if err := rows.Scan(&empCount); err != nil {
        log.Fatal(err)
    }
}

if rows.NextResultSet() && rows.Next() {
    if err := rows.Scan(&orderCount); err != nil {
        log.Fatal(err)
    }
}

if rows.NextResultSet() && rows.Next() {
    if err := rows.Scan(&productCount); err != nil {
        log.Fatal(err)
    }
}

if err := rows.Err(); err != nil {
    log.Fatal(err)
}
fmt.Printf("Employees: %d, Orders: %d, Products: %d\n",
    empCount, orderCount, productCount)

대규모 결과 집합을 효율적으로 처리합니다

수백만 행을 반환하는 쿼리의 경우, 처리 결과는 스트리밍 방식으로 처리됩니다. 모든 행을 메모리에 쌓지 마세요.

func processLargeTable(ctx context.Context, db *sql.DB) error {
    rows, err := db.QueryContext(ctx, "SELECT TransactionID, CONVERT(NVARCHAR(30), TransactionDate, 126) FROM Production.TransactionHistory")
    if err != nil {
        return err
    }
    defer rows.Close()

    var processed int
    for rows.Next() {
        var id int
        var data string
        if err := rows.Scan(&id, &data); err != nil {
            return err
        }

        // Process each row without accumulating.
        if err := handleRow(id, data); err != nil {
            return err
        }

        processed++
        if processed%10000 == 0 {
            log.Printf("Processed %d rows", processed)
        }
    }
    return rows.Err()
}

주의

열린 *sql.Rows은(는) rows.Close()가 호출될 때까지 연결 풀의 연결 하나를 점유한 상태로 유지합니다. 매우 오래 걸리는 결과 집합 처리의 경우, 연결을 몇 분씩 유지하지 않도록 키셋 페이지네이션을 사용해 작업을 범위로 나누는 것을 고려하세요.

업서트에 MERGE 사용

SQL Server는 삽입 또는 업데이트(upsert) 연산에 이 MERGE 문장을 사용합니다.

_, err := db.ExecContext(ctx, `
    MERGE HumanResources.Department AS target
    USING (SELECT @id AS DepartmentID, @name AS Name, @grp AS GroupName) AS source
    ON target.DepartmentID = source.DepartmentID
    WHEN MATCHED THEN
        UPDATE SET Name = source.Name, GroupName = source.GroupName
    WHEN NOT MATCHED THEN
        INSERT (Name, GroupName)
        VALUES (source.Name, source.GroupName);`,
    sql.Named("id", dept.Id),
    sql.Named("name", dept.Name),
    sql.Named("grp", dept.GroupName))

준비된 문

재사용 가능한 준비된 진술서를 작성하는 데 사용 PrepareContext 하세요. 준비된 문장은 동일한 쿼리가 서로 다른 매개변수로 여러 번 실행될 때 성능을 향상시킬 수 있습니다.

stmt, err := db.PrepareContext(ctx,
    "SELECT TOP (1) FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE CountryRegionName = @p1")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, location := range []string{"Australia", "India", "Germany"} {
    var name string
    err := stmt.QueryRowContext(ctx, location).Scan(&name)
    if err != nil {
        log.Println(location, err)
        continue
    }
    fmt.Printf("%s: %s\n", location, name)
}

맥락 소멸

모든 database/sql 메서드는 context.Context를 인수로 받습니다. 타임아웃이나 취소 시 사용하세요.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM Production.TransactionHistory")

컨텍스트 마감일이 만료되면 드라이버는 서버에서 쿼리를 취소하고 호출자에게 오류를 반환합니다.