본문으로 건너뛰기

트랜잭션과 커넥션 풀

이 챕터에서 다루는 것

트랜잭션은 "여러 쿼리를 묶는 기능"이 아니라 동시성 제어 수단이다. 파트 7에서 뮤텍스로 풀었던 문제가 DB에서는 왜 뮤텍스로 안 풀리는지, 그 자리에 무엇이 들어가는지를 계좌 이체로 확인한다. 그리고 *sql.DB의 풀 설정이 실제로 무엇을 제한하는지 본다.

문제 — 중간에 죽으면 돈이 사라진다

계좌 이체는 두 번의 UPDATE다. 출금하고 입금한다. 그 사이에 프로세스가 죽으면?

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger.go
// TransferBroken은 트랜잭션 없이 같은 일을 한다.
//
// 입금 직전에 실패하면 출금만 반영되어 돈이 사라진다. 따라 쓰지 마라.
func (l *Ledger) TransferBroken(ctx context.Context, from, to string, amount int64, failBeforeDeposit bool) error {
if _, err := l.db.ExecContext(ctx,
`UPDATE accounts SET balance = balance - ? WHERE id = ?`, amount, from); err != nil {
return fmt.Errorf("ledger: 출금: %w", err)
}
if failBeforeDeposit {
return errors.New("ledger: 입금 직전에 프로세스가 죽었다고 치자")
}
if _, err := l.db.ExecContext(ctx,
`UPDATE accounts SET balance = balance + ? WHERE id = ?`, amount, to); err != nil {
return fmt.Errorf("ledger: 입금: %w", err)
}
return nil
}
== 트랜잭션 없이 중간 실패 ==
에러: ledger: 입금 직전에 프로세스가 죽었다고 치자
alice=700 bob=500 합계=1200

합계가 1500에서 1200으로 줄었다. 300원이 세상에서 사라졌다. 되돌리는 보상 코드를 직접 쓸 수도 있지만, 그 보상 코드가 실행되기 전에 또 죽으면? 이 재귀는 끝나지 않는다. 그래서 DB가 대신 해 준다.

Begin, Commit, Rollback

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger.go
func (l *Ledger) Transfer(ctx context.Context, from, to string, amount int64) (err error) {
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ledger: 트랜잭션 시작: %w", err)
}
// 커밋에 성공했든 중간에 return 했든 이 defer는 항상 실행된다.
// 이미 커밋된 트랜잭션의 Rollback은 sql.ErrTxDone을 줄 뿐 아무 일도
// 하지 않으므로, 그 경우만 무시하면 된다.
defer func() {
rbErr := tx.Rollback()
if rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("ledger: 롤백: %w", rbErr))
}
}()

src, err := balance(ctx, tx, from)
if err != nil {
return err
}
if src < amount {
return fmt.Errorf("%w: %s의 잔액 %d < %d", ErrInsufficient, from, src, amount)
}
if _, err := balance(ctx, tx, to); err != nil {
return err
}

if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance - ? WHERE id = ?`, amount, from); err != nil {
return fmt.Errorf("ledger: 출금: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE accounts SET balance = balance + ? WHERE id = ?`, amount, to); err != nil {
return fmt.Errorf("ledger: 입금: %w", err)
}

if err := tx.Commit(); err != nil {
return fmt.Errorf("ledger: 커밋: %w", err)
}
return nil
}

세 가지가 이 코드의 요점이다.

defer tx.Rollback()이 안전한 이유

성공 경로에서도 이 defer가 실행된다. 이미 커밋된 트랜잭션을 롤백하려 하면 database/sqlsql.ErrTxDone을 돌려주고 아무 일도 하지 않는다. 테스트로 못 박아 두면 좋다.

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger_test.go
if err := tx.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}
if err := tx.Rollback(); !errors.Is(err, sql.ErrTxDone) {
t.Fatalf("sql.ErrTxDone을 기대했으나 %v", err)
}

덕분에 "정상 종료면 커밋, 아니면 롤백"을 분기 없이 쓸 수 있다. Commit을 마지막에 두고, 그 앞의 모든 return은 자동으로 롤백이 된다. 이 패턴을 쓰지 않으면 나중에 추가되는 return 하나가 트랜잭션을 열어 둔 채로 함수를 빠져나간다.

:::warning Rollback의 에러를 그냥 버리지 마라 defer tx.Rollback() 한 줄로 끝내는 코드를 흔히 본다. 짧아서 좋지만, 롤백 자체가 실패한 경우(커넥션이 끊겼다든가)를 알 수 없게 된다. 이름 붙인 반환값과 errors.Join으로 원래 에러에 덧붙이는 것이 정직하다. go vet은 이것을 잡아 주지 않으므로 습관으로 만들어야 한다. :::

*sql.Tx는 커넥션 하나에 고정된다

*sql.DB는 풀이지만 *sql.Tx커넥션 하나다. BeginTx가 풀에서 커넥션을 하나 빼내 잡아 두고, Commit이나 Rollback이 돌려준다. 결과가 둘이다.

  • 트랜잭션이 오래 열려 있으면 그동안 커넥션 하나가 묶여 있다. 트랜잭션 안에서 외부 API를 호출하는 코드가 위험한 이유다. HTTP 타임아웃 3초짜리 호출이 트랜잭션 안에 있으면, 커넥션이 3초씩 잡힌다.
  • 트랜잭션 안에서 l.db를 쓰면 다른 커넥션으로 나간다. 그 쿼리는 트랜잭션 밖에서 실행되고 롤백되지 않는다. 컴파일도 되고 테스트도 통과하는데 운영에서만 데이터가 어긋난다. 11-5와 11-7에서 이 실수를 구조적으로 막는 방법을 다룬다.

*sql.DB*sql.Tx를 같이 받기

쿼리 코드를 트랜잭션 안팎에서 재사용하려면 인터페이스가 필요한데, database/sql은 그것을 제공하지 않는다. 직접 선언한다.

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger.go
// balance는 *sql.DB와 *sql.Tx 양쪽에서 쓴다.
//
// 두 타입이 공유하는 메서드 집합을 인터페이스로 뽑아 두면 같은 쿼리 코드를
// 트랜잭션 안팎에서 그대로 쓸 수 있다. database/sql이 이 인터페이스를
// 제공하지는 않으므로 직접 선언한다.
type querier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

파트 4와 6에서 말한 "인터페이스는 쓰는 쪽에 둔다"의 전형적인 예다. *sql.DB, *sql.Tx, *sql.Conn 셋 다 이것을 만족한다.

갱신 분실 — 뮤텍스로는 못 막는다

파트 10의 TaskStore는 읽고-바꾸고-쓰기를 뮤텍스 하나로 묶었다.

func (s *TaskStore) Update(ownerID string, id int, mutate func(*Task)) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
// 읽고, mutate하고, 쓴다
}

메모리 저장소에서는 옳다. DB에서는 통하지 않는다. 뮤텍스는 프로세스 안에서만 보이기 때문이다. 서버 인스턴스를 두 개로 늘리는 순간, 두 프로세스의 뮤텍스는 서로를 모른다.

이 실패를 고루틴 없이 결정적으로 재현할 수 있다. db.Conn()으로 커넥션을 두 개 직접 잡아 순서를 손으로 겹치면 된다.

examples/11-database-and-orm/02-transactions-and-pool/main.go
read1, _, err := ledger.ReadModifyWrite(ctx, c1, "alice", 0)
if err != nil {
log.Fatalf("read1: %v", err)
}
read2, _, err := ledger.ReadModifyWrite(ctx, c2, "alice", 0)
if err != nil {
log.Fatalf("read2: %v", err)
}
fmt.Printf("세션1이 읽은 값=%d, 세션2가 읽은 값=%d\n", read1, read2)

if _, err := c1.ExecContext(ctx,
`UPDATE accounts SET balance = ? WHERE id = 'alice'`, read1+100); err != nil {
log.Fatalf("write1: %v", err)
}
if _, err := c2.ExecContext(ctx,
`UPDATE accounts SET balance = ? WHERE id = 'alice'`, read2+100); err != nil {
log.Fatalf("write2: %v", err)
}
fmt.Println("둘 다 +100 했는데")
== 갱신 분실 ==
세션1이 읽은 값=1000, 세션2가 읽은 값=1000
둘 다 +100 했는데
alice=1100 bob=500 합계=1600

1200이 나와야 하는데 1100이다. 두 세션이 같은 1000을 읽었으니 둘 다 1100을 썼고, 나중 것이 앞의 것을 덮었다. 파트 7-6의 n++ 경쟁 조건과 정확히 같은 모양인데, -race가 잡아 주지 않는다. 경쟁이 Go 메모리가 아니라 DB에서 일어나기 때문이다.

해법은 두 가지다.

계산을 DB에게 시킨다. 읽어 오지 않으면 분실할 것도 없다.

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger.go
// AddAtomic은 같은 증가를 DB에게 시킨다. 읽어 오지 않으므로 분실이 없다.
func (l *Ledger) AddAtomic(ctx context.Context, id string, delta int64) error {
res, err := l.db.ExecContext(ctx,
`UPDATE accounts SET balance = balance + ? WHERE id = ?`, delta, id)
if err != nil {
return fmt.Errorf("ledger: 증감: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("ledger: 영향 행 수: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: %s", ErrNoAccount, id)
}
return nil
}
== DB에 계산을 맡기면 ==
50개 고루틴이 각각 +10
alice=1500 bob=500 합계=2000

balance = balance + ?는 DB 안에서 원자적으로 실행된다. 50개 고루틴이 동시에 불러도 정확히 500이 늘었다.

Go에서 계산해야 한다면 트랜잭션으로 묶는다. 조건을 확인해야 하거나 여러 값을 조합해야 하면 DB 표현식으로 옮길 수 없다. 그때는 읽기와 쓰기를 한 트랜잭션에 넣고, 격리 수준이 그것을 지켜 주도록 한다. 또는 UPDATE ... WHERE version = ? 형태의 낙관적 잠금을 쓴다.

:::tip 어느 쪽이 좋은가 가능하면 첫 번째다. UPDATE ... SET x = x + ?, WHERE stock >= ? 같은 조건부 갱신은 트랜잭션 없이도 원자적이고, 락을 짧게 잡는다. 11-5의 재고 차감이 그 형태다. 트랜잭션은 "여러 행을 함께 바꿔야 할 때" 쓴다. :::

격리 수준 — 드라이버가 조용히 무시할 수 있다

BeginTx의 두 번째 인자로 격리 수준을 지정한다.

tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: true,
})
수준막아 주는 것
Read Uncommitted(아무것도)
Read Committed커밋 안 된 값 읽기
Repeatable Read+ 같은 행을 두 번 읽을 때 값이 바뀌는 것
Serializable+ 팬텀 리드

PostgreSQL의 기본은 Read Committed, MySQL(InnoDB)의 기본은 Repeatable Read다. 같은 코드를 옮기면 동작이 달라진다는 뜻이다.

그런데 여기 함정이 하나 있다. database/sql은 격리 수준을 검사하지 않는다. 드라이버에게 그대로 넘길 뿐이고, 지원하지 않는 값을 거절할 의무는 드라이버에게 있다. 이 강의가 쓰는 드라이버는 거절하지 않는다.

examples/11-database-and-orm/02-transactions-and-pool/ledger/ledger_test.go
// TestIsolationLevelsAreAcceptedSilently는 이 드라이버가 격리 수준을
// 전혀 검사하지 않는다는 것을 고정한다.
//
// database/sql은 격리 수준을 드라이버에게 그대로 넘길 뿐이고, 지원하지 않는
// 값을 거절할 의무는 드라이버에게 있다. modernc.org/sqlite는 여덟 가지를
// 모두 받아들인다. "BeginTx가 성공했으니 그 격리 수준이 걸렸다"고 믿으면 안 된다.
func TestIsolationLevelsAreAcceptedSilently(t *testing.T) {
db := openFileDB(t)

levels := []sql.IsolationLevel{
sql.LevelDefault, sql.LevelReadUncommitted, sql.LevelReadCommitted,
sql.LevelWriteCommitted, sql.LevelRepeatableRead, sql.LevelSnapshot,
sql.LevelSerializable, sql.LevelLinearizable,
}
for _, lv := range levels {
tx, err := db.BeginTx(t.Context(), &sql.TxOptions{Isolation: lv})
if err != nil {
t.Fatalf("%v: 거절될 줄 몰랐다: %v", lv, err)
}
if err := tx.Rollback(); err != nil {
t.Fatalf("%v: Rollback: %v", lv, err)
}
}
}

여덟 개 전부 통과한다. sql.LevelLinearizable은 어떤 관계형 DB도 제공하지 않는 수준인데도 그렇다. SQLite는 쓰기가 한 번에 하나뿐이라 실질적으로 직렬화되므로 결과적으로 문제는 없지만, "BeginTx가 성공했으니 그 격리 수준이 걸렸다"고 믿으면 안 된다는 교훈은 남는다. 쓰는 드라이버의 문서를 확인하고, 중요하면 테스트로 확인한다.

커넥션 풀 설정

examples/11-database-and-orm/02-transactions-and-pool/main.go
// 풀 설정. 기본값은 "열린 커넥션 무제한, 유휴 2개"다.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
설정기본값무엇을 정하는가
SetMaxOpenConns무제한동시에 열린 커넥션 상한. 넘으면 대기한다
SetMaxIdleConns2놀고 있어도 살려 두는 커넥션 수
SetConnMaxLifetime무제한커넥션 하나의 최대 수명
SetConnMaxIdleTime무제한놀고 있는 커넥션을 닫기까지의 시간

몇 가지 실무 기준이 있다.

SetMaxOpenConns는 반드시 정한다. 기본값이 무제한이라, 부하가 올라가면 Go가 DB에 커넥션을 계속 열려 하고 DB의 max_connections에 먼저 부딪힌다. 그 시점의 에러 메시지는 원인을 알려 주지 않는다. 상한을 정해 두면 초과분은 애플리케이션 쪽에서 대기하므로, DB가 아니라 우리 쪽에서 부하가 보인다.

SetMaxIdleConnsSetMaxOpenConns와 같게 두는 편이 낫다. 기본값 2 그대로 두고 Open을 100으로 올리면, 98개의 커넥션이 매번 열리고 닫힌다. TLS 핸드셰이크까지 포함하면 꽤 비싸다.

SetConnMaxLifetime은 인프라 때문에 필요하다. 클라우드의 로드밸런서나 DB 프록시는 오래된 커넥션을 말없이 끊는다. 애플리케이션이 그것을 모르면 첫 쿼리에서 정체 모를 에러가 난다. 수명을 인프라의 유휴 타임아웃보다 짧게 잡아 두면 애플리케이션이 먼저 정리한다. 30분에서 1시간이 흔한 값이다.

db.Stats()가 지금 상태를 보여 준다.

== 풀 통계 ==
MaxOpenConnections=8
상한을 넘긴 적 없음: true
커넥션을 기다린 적 있음: true

WaitCountWaitDuration이 계속 올라간다면 풀이 부족하거나 쿼리가 느린 것이다. 파트 12에서 이 값을 메트릭으로 내보내는 것을 다룬다.

:::note 얼마로 잡아야 하는가 정답은 측정이다. 다만 출발점은 있다. DB의 max_connections를 애플리케이션 인스턴스 수로 나눈 값보다 작아야 한다. 인스턴스 4개가 각각 25개를 열면 100개다. 그리고 대개 그 수는 생각보다 작아도 된다 — 커넥션 100개가 동시에 쿼리를 돌리면 DB의 CPU가 먼저 포화된다. 커넥션 수를 늘려 처리량이 늘지 않는 지점이 있고, 그 너머는 대기 시간만 늘어난다. :::

흔한 실수

트랜잭션 안에서 원본 db를 쓴다. 그 쿼리만 트랜잭션 밖에서 실행된다. 콜백 인자로 tx를 받는 구조를 만들면 구조적으로 막힌다(11-5, 11-7).

트랜잭션 안에서 외부 API를 호출한다. 커넥션이 그 시간만큼 묶인다. 그리고 롤백해도 외부 호출은 되돌아가지 않는다.

Commit 후에 tx를 계속 쓴다. sql.ErrTxDone이다.

읽고-바꾸고-쓰기를 트랜잭션 없이 한다. 갱신 분실. -race가 잡아 주지 않는다.

SetMaxOpenConns를 안 정한다. DB 쪽에서 먼저 터진다.

격리 수준을 지정하고 걸렸다고 믿는다. 드라이버가 무시할 수 있다.

정리

  • 트랜잭션은 여러 쿼리를 묶는 기능이기 이전에 동시성 제어 수단이다.
  • defer tx.Rollback()은 커밋 후에도 안전하다. sql.ErrTxDone만 무시하면 되고, 덕분에 모든 조기 return이 자동으로 롤백이 된다.
  • *sql.Tx는 커넥션 하나에 고정된다. 오래 열어 두면 그만큼 풀이 줄어든다.
  • 뮤텍스는 DB의 동시성을 막지 못한다. 프로세스가 둘이면 서로를 모른다. 갱신 분실은 -race에 잡히지 않는다.
  • 가능하면 계산을 DB 표현식으로 옮긴다. SET x = x + ?, WHERE stock >= ?.
  • 격리 수준은 드라이버가 무시할 수 있다. BeginTx 성공은 보장이 아니다.
  • SetMaxOpenConns는 반드시 정하고, MaxIdleConns를 같게 두고, ConnMaxLifetime으로 죽은 커넥션을 미리 정리한다.

연습문제

  1. Transfer에서 출금 UPDATE 다음에 5초짜리 time.Sleep을 넣고, SetMaxOpenConns(2)인 상태에서 이체 3건을 동시에 실행해 보자. 세 번째 요청은 어디서 멈추는가? db.Stats()의 어느 필드가 그것을 보여 주는가? 컨텍스트에 1초 타임아웃을 걸면 에러는 어디서 나는가?

  2. 낙관적 잠금을 구현해 보자. accountsversion INTEGER NOT NULL DEFAULT 0을 추가하고, UPDATE ... SET balance = ?, version = version + 1 WHERE id = ? AND version = ? 형태로 바꾼다. RowsAffected()가 0이면 무슨 뜻인가? 호출자는 그때 무엇을 해야 하는가 — 재시도인가 에러인가, 그리고 몇 번까지?

  3. TransferBroken을 보상 트랜잭션으로 고쳐 보자. 출금 후 입금이 실패하면 출금을 되돌리는 코드를 쓴다. 그 보상 코드가 실패하면 어떻게 되는가? 이 방식이 트랜잭션보다 나은 경우가 하나 있다 — 두 데이터가 서로 다른 DB에 있을 때다. 그 경우 무엇을 포기해야 하는가?