이 글은 SQL Server와 함께 드라이버를 go-mssqldb 사용하는 Go 애플리케이션의 성능 최적화에 관한 지침을 제공합니다.
가장 영향력 있는 변화부터 시작하세요
대부분의 애플리케이션은 첫날부터 드라이버별 튜닝이 필요하지 않습니다. 패킷 크기를 변경하거나, 준비된 성명서를 여기저기 추가하거나, 대량 옵션을 조정하기 전에 다음 단계부터 시작하세요:
- 서버 한도와 맞춘 제한된 연결 풀 크기를 설정하세요.
- 차단된 SQL 호출이 연결을 고정하고 앱이 부하 시 멈춘 것처럼 보이지 않도록 컨텍스트 타임아웃을 추가하세요.
- 느린 쿼리, 누락된 인덱스, 불필요한 SQL Server 왕복 문제를 수정하세요.
- 변경 전후로 작업 부하를 벤치마킹하세요.
많은 서비스에서 연결 풀 설정과 쿼리 형태가 패킷 크기나 문장 준비보다 더 중요합니다.
연결 풀 조정
database/sql 연결 풀은 성능에 가장 큰 영향을 미치는 요소입니다. 부족하게 설정된 풀은 고루틴이 연결 대기를 차단하게 만들고, 과도하게 프로비저닝된 풀은 서버 자원을 낭비합니다.
db.SetMaxOpenConns(25) // Match your workload concurrency
db.SetMaxIdleConns(10) // Keep warm connections ready
db.SetConnMaxLifetime(5 * time.Minute) // Recycle connections periodically
db.SetConnMaxIdleTime(1 * time.Minute) // Close stale idle connections
풀 경합을 감지하려면 db.Stats().WaitCount 및 db.Stats().WaitDuration을(를) 모니터링하세요. 자세한 내용은 연결 풀링을 참조하세요.
패킷 크기 증가
기본 TDS 패킷 크기는 4,096바이트입니다. 대용량 결과 집합이나 대용량 데이터를 전송하는 작업의 경우, 패킷 크기를 늘리면 네트워크 왕복 횟수가 줄어듭니다.
sqlserver://<user>:<password>@<server>?database=AdventureWorks2025&packet+size=16384
유효 범위: 512에서 32,767. 8,192 또는 16,384의 값은 고처리량 시나리오에서 흔히 사용됩니다.
벤치마크 데이터가 네트워크 전송이 작업 부하를 지배한다는 것을 보여주지 않는 한 기본 패킷 크기를 유지하세요. 작은 쿼리와 행을 가진 OLTP 스타일 트래픽의 경우, 큰 패킷은 의미 있는 이점 없이 복잡성을 증가시키는 경우가 많습니다.
준비된 문장 사용
준비된 문은 서버에서 반복적인 쿼리 파싱과 계획 컴파일 작업을 피할 수 있습니다. 같은 쿼리를 여러 번 다른 매개변수로 실행할 때 다음을 사용 db.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 _, loc := range locations {
var name string
stmt.QueryRowContext(ctx, loc).Scan(&name)
}
Tip
서버 측 준비된 문 핸들의 소모를 방지하려면 defer stmt.Close()를 사용하여 준비된 문을 닫으세요.
모든 진술서를 기본적으로 준비하지 마세요. 같은 구문이 핫 패스에서 반복해서 실행될 때 가장 큰 효과를 발휘합니다. 일회성 쿼리의 경우 QueryContext 또는 ExecContext가 보통 더 간단하고 충분히 빠릅니다.
대량 인서트는 대량 복사본을 사용하세요
개별 INSERT 문은 대량의 데이터를 로드할 때 속도가 느립니다. 대량 복사는 쿼리 프로세서를 우회하여 데이터를 직접 서버로 스트리밍합니다:
stmt, err := txn.Prepare(mssql.CopyIn("MyTable", mssql.BulkOptions{
Tablock: true,
RowsPerBatch: 5000,
}, "Col1", "Col2"))
자세한 내용은 대량 운영(Bulk operations)을 참조하세요.
적절할 때 varchar를 사용하세요
기본적으로 코드는 string 매개 변수를 nvarchar(유니코드)로 전송합니다. 열에서 varchar를 사용하는 경우 서버가 암시적 변환을 수행하거나 인덱스를 사용하지 않을 수 있습니다.
varchar 매개변수를 보내려면 mssql.VarChar를 사용하세요.
db.QueryContext(ctx, "SELECT * FROM Production.Product WHERE ProductNumber = @p1",
mssql.VarChar("FR-R92B-58"))
컨텍스트 타임아웃 사용
차단된 SQL 호출이 연결을 고정하거나 호출자를 지연시키는 것을 방지하기 위해 개별 쿼리에 대한 컨텍스트 마감일을 설정하세요.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM LargeTable")
자원을 즉시 닫으세요
열기 *sql.Rows, *sql.Tx, 그리고 *sql.Conn 객체들이 풀에서 연결을 핀으로 고정합니다. 항상 가능한 빨리 닫으세요.
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
// process
}
return rows.Err()
왕복 횟수 감소
-
쿼리를 일괄 처리하여 가능하면 하나의 호출로 만드세요:
"SELECT ...; SELECT ...;". -
OUTPUT절을 사용하세요. 별도의SELECT SCOPE_IDENTITY()호출 대신 - 개별 인서트를 반복하는 대신 테이블 값 매개변수를 사용하여 단일 호출에서 여러 행을 전송합니다.
성능 체크리스트
| Area | 권장 사항 |
|---|---|
| 풀 | 서버 한도와 워크로드 동시성을 기준으로 설정 MaxOpenConns 하세요. |
| 풀 |
MaxIdleConns을(를) MaxOpenConns의 절반 이상으로 설정합니다. |
| Network | 대용량 전송이나 대용량 로드를 벤치마크한 후에만 변경 packet size 하세요. |
| Queries | 자주 반복되는 진술에만 준비된 진술문을 사용하세요. |
| Queries | 암시적 변환을 방지하려면 varchar 열에 mssql.VarChar을 사용하세요. |
| 대형 하중 | 배치 삽입에는 벌크 카피(mssql.CopyIn)를 사용하세요. |
| Resources |
Rows, Tx, Conn 객체를 즉시 닫으세요. |
| 타임아웃 | 모든 쿼리와 문장에 맥락 마감일을 설정하세요. |
| 읽기 위주 | 읽기 복제본에는 ApplicationIntent=ReadOnly를 사용하세요. |
| Benchmarks | 최적화 전후로 측정하는 데 사용 testing.B 하세요. |
| Monitoring | 쿼리 저장소를 활성화하고 SSMS 보고서나 Query Performance Insight를 사용하세요. |
| Monitoring | Prometheus나 OpenTelemetry로 내보내세요 db.Stats() . |
벤치마크 데이터베이스 운영
Go를 testing.B 사용하여 데이터베이스 작업 성능을 측정하고 최적화 변경 사항을 검증합니다:
func BenchmarkInsertSingle(b *testing.B) {
db := setupDB(b)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := db.ExecContext(ctx,
"INSERT INTO BenchTable (Name) VALUES (@p1)",
sql.Named("p1", fmt.Sprintf("bench-%d", i)))
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkInsertBulk(b *testing.B) {
db := setupDB(b)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
txn, err := db.BeginTx(ctx, nil)
if err != nil {
b.Fatal(err)
}
stmt, err := txn.Prepare(mssql.CopyIn("BenchTable",
mssql.BulkOptions{}, "Name"))
if err != nil {
b.Fatal(err)
}
for j := 0; j < 1000; j++ {
if _, err := stmt.Exec(fmt.Sprintf("bench-%d-%d", i, j)); err != nil {
b.Fatal(err)
}
}
if _, err := stmt.Exec(); err != nil {
b.Fatal(err)
}
stmt.Close()
if err := txn.Commit(); err != nil {
b.Fatal(err)
}
}
}
벤치마크를 실행하세요:
go test -bench=BenchmarkInsert -benchmem -count=5
Tip
통계적으로 의미 있는 결과를 얻으려면 -count=5 이상을 사용하세요.
Benchstat을 사용해 변경 전후의 벤치마크 결과를 비교하세요.
읽기 전용 라우팅 사용
SQL Server 환경에 읽기 가능한 보조 쿼리가 있는 가용성 그룹이 있다면, 연결 문자열에 다음 설정을 ApplicationIntent=ReadOnly 하여 읽기 전용 쿼리를 보조 복제본으로 라우팅하세요:
sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly
읽기와 쓰기 작업용 별도의 *sql.DB 인스턴스를 생성하세요:
writeDB, err := sql.Open("sqlserver",
"sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025")
if err != nil {
log.Fatal(err)
}
readDB, err := sql.Open("sqlserver",
"sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly")
if err != nil {
log.Fatal(err)
}
// Use readDB for reports, dashboards, and analytics.
// Use writeDB for inserts, updates, and deletes.
쿼리 계획 분석
쿼리를 실행하지 않고 실행 계획을 가져오는 데 사용됩니다 SET SHOWPLAN_XML . 이 방법은 테이블 스캔, 누락된 인덱스, 그리고 비용이 많이 드는 연산을 식별하는 데 도움을 줍니다:
func getQueryPlan(ctx context.Context, db *sql.DB, query string) (string, error) {
// Use a dedicated connection so SHOWPLAN mode doesn't affect other queries.
conn, err := db.Conn(ctx)
if err != nil {
return "", err
}
defer conn.Close()
// Enable SHOWPLAN_XML mode.
_, err = conn.ExecContext(ctx, "SET SHOWPLAN_XML ON")
if err != nil {
return "", err
}
var planXML string
err = conn.QueryRowContext(ctx, query).Scan(&planXML)
if err != nil {
return "", err
}
// Disable SHOWPLAN_XML mode.
_, _ = conn.ExecContext(ctx, "SET SHOWPLAN_XML OFF")
return planXML, nil
}
Warning
SET SHOWPLAN_XML ON 전체 연결에 영향을 미칩니다. 항상 db.Conn(ctx)를 사용하여 SHOWPLAN 모드를 별도의 전용 연결로 격리하세요.
느린 쿼리를 찾기 위해 쿼리 저장소를 사용하세요
Go 벤치마크와 클라이언트 측 타이밍은 애플리케이션 관점에서 쿼리가 걸리는 시간을 알려주지만, 이 수치는 네트워크 지연 시간, 서버 실행 시간, 클라이언트 처리를 합친 값입니다. 쿼리 저장소는 서버의 실행 계획과 런타임 통계를 캡처하여 SQL Server가 각 쿼리를 어떻게 실행하는지, 얼마나 자주 실행되는지, 그리고 시간이 지남에 따라 성능이 어떻게 변했는지 정확히 확인할 수 있습니다.
쿼리 저장소는 특히 매개변수 스니핑, 계획 회귀, 그리고 서버 자원을 가장 많이 사용하는 쿼리를 식별하는 데 유용합니다. 이미 활성화되어 있지 않다면 데이터베이스에서 활성화하세요:
ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;
활성화되면 여러 방법으로 성과 데이터를 검토할 수 있습니다:
- SQL Server Management Studio (SSMS): 개체 탐색기에서 데이터베이스를 펼친 후 쿼리 저장소 폴더를 열고, Top Resource Consuming Queries, Regressed Queries, Overall Resource Consumption과 같은 내장 보고서를 사용하세요.
- Azure 포털: Azure SQL Database의 경우, Query Performance Insight 블레이드를 열어 도구를 설치하지 않고도 자원을 가장 많이 사용하는 쿼리를 확인할 수 있습니다.
-
Transact-SQL (T-SQL): 프로그래밍 방식으로 액세스해야 하는 경우 Go 애플리케이션에서 직접
sys.query_store_runtime_stats및sys.query_store_plan카탈로그 뷰를 쿼리하세요.
Tip
쿼리 저장소는 서버 재시작 후에도 데이터를 유지하므로, 며칠 또는 몇 주간의 성능 추세를 분석할 수 있습니다. 회귀 쿼리 보고서를 활용해 스키마나 코드 변경 후 느려진 쿼리를 빠르게 찾아보세요.
성과 대시보드를 사용하세요
성능 대시보드는 SQL Server 상태를 실시간으로 파악하는 내장된 SSMS 보고서입니다. SSMS 개체 탐색기에서 서버 인스턴스를 우클릭한 후 Reports>Standard Reports>Performance Dashboard를 선택하세요.
대시보드에 다음이 표시됩니다:
- 현재 대기 상황과 병목 현상.
- 최근 비용이 많이 드는 쿼리
- CPU, I/O, 메모리 사용 추세.
- 활성 사용자 요청과 차단된 세션.
성능 대시보드는 개발 및 부하 테스트 시 진단 쿼리를 작성하지 않고도 문제를 빠르게 발견할 수 있어 유용합니다.
Go에서 서버 측 지표를 모니터링하세요
SQL Server 성능 데이터를 Go 애플리케이션에서 Prometheus나 OpenTelemetry 같은 모니터링 시스템에 노출해야 한다면, 동적 관리 뷰(DMV)를 직접 쿼리하세요:
가장 비용이 많이 드는 쿼리
평균 경과 시간 순으로 순위가 매겨진 상위 10개의 쿼리를 확인하세요:
rows, err := db.QueryContext(ctx, `
SELECT TOP 10
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
qs.execution_count,
qs.total_logical_reads / qs.execution_count AS avg_reads,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
ORDER BY avg_elapsed_us DESC`)
현재 진행 중인 요청
서버에서 현재 실행 중인 모든 요청을 나열하세요. 본인 세션을 제외하고:
rows, err := db.QueryContext(ctx, `
SELECT
r.session_id,
r.status,
r.wait_type,
r.cpu_time,
r.logical_reads,
t.text AS query_text
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.session_id != @@SPID`)
큰 결과를 위한 메모리 효율 패턴
누적 없이 행을 스트리밍합니다
전체 결과 세트를 슬라이스로 불러오는 대신 한 행씩 처리합니다:
rows, err := db.QueryContext(ctx, "SELECT Id, Data FROM BigTable")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int
var data string
if err := rows.Scan(&id, &data); err != nil {
return err
}
// Process immediately, don't append to a slice.
process(id, data)
}
return rows.Err()
키셋 페이지네이션을 이용한 배치 처리
큰 테이블 스캔을 관리 가능한 단위로 나누어 메모리 사용량을 줄이고 연결을 장시간 유지하는 것을 방지하세요:
func processBatched(ctx context.Context, db *sql.DB, batchSize int) error {
var lastID int
for {
rows, err := db.QueryContext(ctx, `
SELECT TOP(@batch) Id, Data FROM BigTable
WHERE Id > @lastID ORDER BY Id`,
sql.Named("batch", batchSize),
sql.Named("lastID", lastID))
if err != nil {
return err
}
var count int
for rows.Next() {
var id int
var data string
if err := rows.Scan(&id, &data); err != nil {
return err
}
process(id, data)
lastID = id
count++
}
if err := rows.Err(); err != nil {
return err
}
rows.Close()
if count < batchSize {
break // No more rows.
}
}
return nil
}