Go에서 데이터베이스 트랜잭션을 간소화하여 비즈니스 로직 깔끔하게 작성하기
Olivia Novak
Dev Intern · Leapcell

소개
현대 애플리케이션에서 데이터베이스 상호 작용은 어디에나 존재합니다. 자금 이체, 새 사용자 등록, 주문과 같은 많은 중요 작업에는 모든 것이 성공하거나 모두 실패해야 하는 일련의 데이터베이스 수정이 포함됩니다. 이 "전부 아니면 전무" 원칙은 데이터 무결성과 일관성을 보장하는 데이터베이스 트랜잭션의 초석입니다. 그러나 애플리케이션 코드에서 직접 트랜잭션을 관리하는 것은 금방 번거로워져 중복되는 상용구 코드, 오류 발생 가능성이 있는 롤백 로직, 얽힌 비즈니스 로직으로 이어질 수 있습니다. 이 기사에서는 데이터베이스 트랜잭션 관리를 캡슐화하는 깔끔하고 간결한 Go 함수를 설계하는 방법을 탐색하여 개발자가 트랜잭션 내에서 순수하게 비즈니스 작업에 집중할 수 있도록 지원합니다. 따라서 코드가 단순화되고 유지보수성이 향상됩니다.
시작하기 전 핵심 개념
구현에 들어가기 전에 논의할 접근 방식을 이해하는 데 필수적인 몇 가지 핵심 개념을 간략하게 정의해 보겠습니다.
- 데이터베이스 트랜잭션: 일련의 작업이 전체로 처리되도록 보장하는 단일 작업 단위입니다. ACID 속성(원자성, 일관성, 격리성, 지속성)을 따릅니다.
- 원자성: 트랜잭션 내의 모든 작업이 성공적으로 완료되도록 보장합니다. 그렇지 않으면 트랜잭션은 실패 지점에서 중단되고 모든 작업은 트랜잭션 시작 전 상태로 롤백됩니다.
- 롤백: 트랜잭션의 일부라도 실패하면 트랜잭션 중에 발생한 모든 변경 사항을 실행 취소하는 프로세스입니다.
- 커밋: 트랜잭션 중에 발생한 모든 변경 사항을 데이터베이스에 영구적으로 적용하는 프로세스입니다.
- Go에서의 컨텍스트:
context.Context는 API 경계를 넘어 고루틴으로 마감 시간, 취소 신호 및 기타 요청 범위 값을 전달합니다. 트랜잭션 내에서 시간 초과 및 취소를 관리하는 데 중요합니다. *sql.Tx및*sql.DB: Go의database/sql패키지에서*sql.DB는 데이터베이스에 대한 연결 풀을 나타내고*sql.Tx는 진행 중인 데이터베이스 트랜잭션을 나타냅니다.
비즈니스 로직 단순화를 위한 트랜잭션 캡슐화
주요 목표는 트랜잭션 시작, 커밋 및 롤백과 관련된 상용구 코드를 추상화하는 것입니다. 비즈니스 로직을 인수로 받는 함수를 만들고 그 주위로 트랜잭션 수명을 관리하고 싶습니다. 이렇게 하면 비즈니스 로직이 깔끔하고 선언적이며 트랜잭션 관리 세부 정보에서 벗어날 수 있습니다.
수동 트랜잭션 관리의 문제점
적절한 캡슐화 없이 일반적인 시나리오를 고려해 보세요.
func transferFundsManual(db *sql.DB, fromAccountID, toAccountID int, amount float64) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if r := recover(); r != nil { tx.Rollback() // 패닉 시 롤백 panic(r) } }() _, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromAccountID) if err != nil { tx.Rollback() return fmt.Errorf("failed to debit account: %w", err) } _, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toAccountID) if err != nil { tx.Rollback() return fmt.Errorf("failed to credit account: %w", err) } if err := tx.Commit(); err != nil { tx.Rollback() // 커밋 오류는 이상적으로 DB에 의해 롤백되어야 함 return fmt.Errorf("failed to commit transaction: %w", err) } return nil }
이 간단한 함수에는 이미 상당한 상용구 코드가 포함되어 있습니다. db.Begin(), 여러 tx.Rollback() 호출 및 tx.Commit(). 추가 작업에는 또 다른 if err != nil { tx.Rollback() } 블록이 필요합니다. 이 반복적인 코드는 추상화의 주요 후보입니다.
트랜잭션 래퍼 함수 설계
context.Context, *sql.DB 인스턴스 및 트랜잭션으로 구성된 비즈니스 로직을 나타내는 함수를 인수로 받는 고차 함수를 만들 수 있습니다. 이 비즈니스 로직 함수는 *sql.Tx 인스턴스에서 작동합니다.
package database import ( "context" "database/sql" "fmt" ) // TxFunc는 트랜잭션 내에서 작업을 수행하는 함수의 시그니처를 정의합니다. // 트랜잭션 객() *sql.Tx)를 받고 작업 실패 시 오류를 반환합니다. type TxFunc func(ctx context.Context, tx *sql.Tx) error // WithTransaction은 새 데이터베이스 트랜잭션 내에서 제공된 TxFunc를 실행합니다. // 트랜잭션을 시작하고 성공 시 커밋하며 오류 시 롤백하는 것을 처리합니다. // 제공된 컨텍스트는 TxFunc로 전달되며 가능한 경우 트랜잭션 작업에 사용됩니다. func WithTransaction(ctx context.Context, db *sql.DB, fn TxFunc) error { tx, err := db.BeginTx(ctx, nil) // 컨텍스트로 트랜잭션 시작 if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } // 함수의 결과에 따라 커밋 또는 롤백을 처리하는 함수를 지연합니다. // 이것은 `fn`(반환, 패닉)이 어떻게 종료되든 트랜잭션 해결을 보장합니다. defer func() { if p := recover(); p != nil { // 패닉이 발생했으므로 트랜잭션을 롤백하고 다시 패닉합니다. // 다시 패닉하면 원래 패닉이 전파됩니다. if rollbackErr := tx.Rollback(); rollbackErr != nil { fmt.Printf("panic during transaction, rollback failed: %v, original panic: %v\n", rollbackErr, p) } else { fmt.Printf("panic during transaction, transaction rolled back, original panic: %v\n", p) } panic(p) } }() // 트랜잭션으로 비즈니스 로직 함수를 실행합니다. err = fn(ctx, tx) if err != nil { // 비즈니스 로직이 오류를 반환했으므로 트랜잭션을 롤백합니다. if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("transaction failed and rollback also failed: %w (original error: %w)", rollbackErr, err) } return fmt.Errorf("transaction rolled back: %w", err) } // 비즈니스 로직이 성공했으므로 트랜잭션을 커밋합니다. if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil // 트랜잭션이 성공적으로 커밋되었습니다. }
WithTransaction 함수 설명:
func WithTransaction(ctx context.Context, db *sql.DB, fn TxFunc) error:- 컨텍스트 전파(예: 시간 초과)를 위해
ctx를 사용합니다. - 트랜잭션을 시작하기 위해
db *sql.DB를 사용합니다. - 실제로 실행할 비즈니스 로직인
fn TxFunc를 사용합니다.
- 컨텍스트 전파(예: 시간 초과)를 위해
tx, err := db.BeginTx(ctx, nil): 새 트랜잭션을 시작합니다.BeginTx는 컨텍스트를 허용하므로 트랜잭션 시작이 마감 시간이나 취소를 존중할 수 있으므로Begin보다 선호됩니다.defer func() { ... }(): 이defer블록은 매우 중요합니다.fn(비즈니스 로직) 내에서 발생할 수 있는 패닉을 가로채서 패닉이 전파되기 전에 트랜잭션이 롤백되도록 보장합니다. 이는 예기치 않은 런타임 오류에 직면하더라도 트랜잭션 처리를 강력하게 만듭니다.err = fn(ctx, tx):context와*sql.Tx객체를 전달하여 사용자 제공 비즈니스 로직을 실행합니다.- 오류 처리(롤백 대 커밋):
fn이 오류를 반환하면tx.Rollback()을 사용하여 트랜잭션이 명시적으로 롤백됩니다. 그런 다음 원래 오류를 래핑하여 반환합니다.fn이 오류 없이 완료되면tx.Commit()을 사용하여 트랜잭션이 커밋됩니다.Rollback및Commit호출 자체에 대한 오류 처리도 포함되어 더 유익한 오류 메시지를 제공합니다.
래퍼를 비즈니스 로직에 적용하기
이제 WithTransaction을 사용하여 transferFundsManual을 리팩터링해 봅시다.
package main import ( "context" "database/sql" "fmt" _ "github.com/lib/pq" // 예: PostgreSQL 드라이버 "log" "your_module_path/database" // 가정: 데이터베이스 패키지가 모듈에 있음 ) // Account 모델(이 예제에서는 간단함) type Account struct { ID int Balance float64 } // transferFunds는 트랜잭션 내에서 자금 이체 로직을 캡슐화합니다. func transferFunds(db *sql.DB, fromAccountID, toAccountID int, amount float64) error { return database.WithTransaction(context.Background(), db, func(ctx context.Context, tx *sql.Tx) error { // 1. 송금 계좌 출금 result, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1", amount, fromAccountID) if err != nil { return fmt.Errorf("failed to debit account %d: %w", fromAccountID, err) } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { // 잔액 부족 또는 유효하지 않은 계좌 ID일 수 있습니다. return fmt.Errorf("failed to debit account %d: insufficient funds or account not found", fromAccountID) } // 2. 수신 계좌 입금 _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toAccountID) if err != nil { return fmt.Errorf("failed to credit account %d: %w", toAccountID, err) } // 여기에 도달하면 두 작업 모두 트랜잭션 내에서 성공했으며 // WithTransaction이 커밋을 처리합니다. return nil }) } func main() { // --- 데이터베이스 설정(PostgreSQL 예제) --- // 실제 애플리케이션에서는 구성 또는 종속성 주입에서 가져옵니다. connStr := "user=user dbname=testdb password=password host=localhost sslmode=disable" db, err := sql.Open("postgres", connStr) if err != nil { log.Fatalf("Error opening database: %v", err) } defer db.Close() // 데이터베이스에 연결하여 연결이 설정되었는지 확인합니다. err = db.Ping() if err != nil { log.Fatalf("Error connecting to the database: %v", err) } // 테이블이 존재하지 않으면 초기 데이터와 함께 설정합니다. setupDB(db) ctx := context.Background() // --- 성공적인 이체 테스트 --- fmt.Println("--- 성공적인 이체 시도 ---") err = transferFunds(db, 1, 2, 50.0) if err != nil { log.Printf("Transfer successful (as expected): %v", err) } else { log.Println("Transfer successful!") } printAccountBalances(db) // --- 실패한 이체 테스트(잔액 부족) --- fmt.Println("\n--- 실패한 이체 시도(잔액 부족) ---") err = transferFunds(db, 1, 2, 2000.0) // 계정 1은 처음에 100만 있음 if err != nil { log.Printf("Transfer failed (as expected): %v", err) } else { log.Println("Transfer unexpectedly succeeded!") } printAccountBalances(db) // --- 실패한 이체 테스트(입금 시 시뮬레이션 오류) --- fmt.Println("\n--- 실패한 이체 시도(시뮬레이션 오류) ---") // 시연을 위해 특정 조건에 대해 입금 시 오류를 강제하도록 TxFunc를 수정해 보겠습니다. // 실제 앱에서는 실제 비즈니스 규칙 또는 데이터베이스 오류가 됩니다. err = database.WithTransaction(ctx, db, func(ctx context.Context, tx *sql.Tx) error { // 출금 작업 result, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 10.0, 1) if err != nil { return fmt.Errorf("debit failed: %w", err) } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { return fmt.Errorf("debit failed: account 1 not found or insufficient funds") } // 입금 작업 중 오류 시뮬레이션 return fmt.Errorf("simulated error during credit operation") // 이것은 롤백을 트리거합니다. }) if err != nil { log.Printf("Simulated transfer failed (as expected): %v", err) } else { log.Println("Simulated transfer unexpectedly succeeded!") } printAccountBalances(db) } // 데이터베이스 및 초기 데이터를 설정하는 유틸리티 함수 func setupDB(db *sql.DB) { _, err := db.Exec(` CREATE TABLE IF NOT EXISTS accounts ( id SERIAL PRIMARY KEY, balance NUMERIC(10, 2) NOT NULL DEFAULT 0.00 ); TRUNCATE TABLE accounts RESTART IDENTITY CASCADE; INSERT INTO accounts (id, balance) VALUES (1, 100.00), (2, 50.00), (3, 0.00); `) if err != nil { log.Fatalf("Failed to setup database: %v", err) } fmt.Println("Database setup complete with initial accounts.") } // 현재 계정 잔액을 인쇄하는 유틸리티 함수 func printAccountBalances(db *sql.DB) { rows, err := db.Query("SELECT id, balance FROM accounts ORDER BY id") if err != nil { log.Printf("Error querying balances: %v", err) return } defer rows.Close() fmt.Println("Current Account Balances:") for rows.Next() { var acc Account if err := rows.Scan(&acc.ID, &acc.Balance); err != nil { log.Printf("Error scanning account: %v", err) continue } fmt.Printf(" Account %d: %.2f\n", acc.ID, acc.Balance) } if err = rows.Err(); err != nil { log.Printf("Error iterating account rows: %v", err) } }
transferFunds 함수에서 비즈니스 로직은 이제 훨씬 깔끔합니다. *sql.Tx 객체를 직접 받는 출금 및 입금 작업에만 집중합니다. 모든 트랜잭션 수명 주기 관리(시작, 커밋, 롤백)는 외부에서 WithTransaction에 의해 처리됩니다. 이렇게 하면 가독성이 크게 향상되고 tx.Rollback() 호출을 잊는 것과 같은 오류 가능성이 줄어듭니다.
이 접근 방식의 이점
- 깔끔한 비즈니스 로직: 핵심 비즈니스 작업은 트랜잭션 관리 상용구 코드와 분리됩니다.
- 중복 감소: 트랜잭션 관리 로직은
WithTransaction으로 한 번 작성되고 모든 곳에서 재사용됩니다. - 개선된 안정성: 오류와 패닉을 제대로 처리하여 트랜잭션이 항상 올바르게 닫히도록 합니다(커밋 또는 롤백).
- 쉬운 테스트: 비즈니스 로직 함수는 격리되어 잠재적으로 모의 트랜잭션 객체와 함께 테스트하기가 더 쉬워집니다.
- 일관성: 모든 트랜잭션 작업은 동일한 관리 패턴을 따르므로 코드베이스가 더 예측 가능해집니다.
- 컨텍스트 인식: 취소 및 시간 초과를 위해
context.Context를 통합하여 트랜잭션을 분산 시스템에서 더 복원력 있게 만듭니다.
애플리케이션 시나리오
이 패턴은 여러 시나리오에서 매우 효과적입니다.
- 서비스 계층 작업: 서비스 메서드가 원자적이어야 하는 여러 데이터베이스 쓰기를 수행해야 할 때.
- 명령 처리기: CQRS 아키텍처에서 상태를 수정하는 명령 처리기는 종종 트랜잭션 보증의 이점을 누립니다.
- 배치 처리: 각 항목의 처리가 원자적이거나 항목 그룹이 트랜잭션으로 처리되어야 하는 항목 배치 처리 시.
- ACID 속성이 필요한 모든 작업: 자금 이체, 주문 처리, 복잡한 데이터 마이그레이션 등
결론
WithTransaction과 같은 전용의 간결한 Go 함수 내에서 데이터베이스 트랜잭션을 캡슐화하면 반복적인 상용구 코드를 추상화하여 애플리케이션 코드가 크게 단순화됩니다. 이 패턴은 깔끔한 비즈니스 로직을 촉진하고, 오류 처리를 개선하며, ACID 속성의 일관된 적용을 보장하여 더 강력하고 유지보수 가능한 데이터 중심 애플리케이션을 만듭니다. 이 접근 방식을 채택함으로써 개발자는 트랜잭션 관리의 "방법"보다는 비즈니스 프로세스의 "무엇"에 집중할 수 있어 코드가 더 읽기 쉬워지고 트랜잭션 관련 오류가 발생할 가능성이 줄어듭니다.

