트랜잭션과 커넥션 풀
이 챕터에서 다루는 것
트랜잭션은 "여러 쿼리를 묶는 기능"이 아니라 동시성 제어 수단이다. 파트 7에서
뮤텍스로 풀었던 문제가 DB에서는 왜 뮤텍스로 안 풀리는지, 그 자리에 무엇이 들어가는지를
계좌 이체로 확인한다. 그리고 *sql.DB의 풀 설정이 실제로 무엇을 제한하는지 본다.
문제 — 중간에 죽으면 돈이 사라진다
계좌 이체는 두 번의 UPDATE다. 출금하고 입금한다. 그 사이에 프로세스가 죽으면?
// 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
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/sql이 sql.ErrTxDone을 돌려주고 아무 일도 하지 않는다. 테스트로 못 박아 두면
좋다.
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은 그것을 제공하지 않는다. 직접 선언한다.
// 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()으로 커넥션을 두 개
직접 잡아 순서를 손으로 겹치면 된다.
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에게 시킨다. 읽어 오지 않으면 분실할 것도 없다.
// 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은 격리 수준을 검사하지 않는다.
드라이버에게 그대로 넘길 뿐이고, 지원하지 않는 값을 거절할 의무는 드라이버에게 있다.
이 강의가 쓰는 드라이버는 거절하지 않는다.
// 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가 성공했으니 그 격리 수준이 걸렸다"고 믿으면
안 된다는 교훈은 남는다. 쓰는 드라이버의 문서를 확인하고, 중요하면 테스트로 확인한다.
커넥션 풀 설정
// 풀 설정. 기본값은 "열린 커넥션 무제한, 유휴 2개"다.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
| 설정 | 기본값 | 무엇을 정하는가 |
|---|---|---|
SetMaxOpenConns | 무제한 | 동시에 열린 커넥션 상한. 넘으면 대기한다 |
SetMaxIdleConns | 2 | 놀고 있어도 살려 두는 커넥션 수 |
SetConnMaxLifetime | 무제한 | 커넥션 하나의 최대 수명 |
SetConnMaxIdleTime | 무제한 | 놀고 있는 커넥션을 닫기까지의 시간 |
몇 가지 실무 기준이 있다.
SetMaxOpenConns는 반드시 정한다. 기본값이 무제한이라, 부하가 올라가면 Go가
DB에 커넥션을 계속 열려 하고 DB의 max_connections에 먼저 부딪힌다. 그 시점의
에러 메시지는 원인을 알려 주지 않는다. 상한을 정해 두면 초과분은 애플리케이션
쪽에서 대기하므로, DB가 아니라 우리 쪽에서 부하가 보인다.
SetMaxIdleConns는 SetMaxOpenConns와 같게 두는 편이 낫다. 기본값 2 그대로
두고 Open을 100으로 올리면, 98개의 커넥션이 매번 열리고 닫힌다. TLS 핸드셰이크까지
포함하면 꽤 비싸다.
SetConnMaxLifetime은 인프라 때문에 필요하다. 클라우드의 로드밸런서나 DB
프록시는 오래된 커넥션을 말없이 끊는다. 애플리케이션이 그것을 모르면 첫 쿼리에서
정체 모를 에러가 난다. 수명을 인프라의 유휴 타임아웃보다 짧게 잡아 두면
애플리케이션이 먼저 정리한다. 30분에서 1시간이 흔한 값이다.
db.Stats()가 지금 상태를 보여 준다.
== 풀 통계 ==
MaxOpenConnections=8
상한을 넘긴 적 없음: true
커넥션을 기다린 적 있음: true
WaitCount와 WaitDuration이 계속 올라간다면 풀이 부족하거나 쿼리가 느린 것이다.
파트 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으로 죽은 커넥션을 미리 정리한다.
연습문제
-
Transfer에서 출금UPDATE다음에 5초짜리time.Sleep을 넣고,SetMaxOpenConns(2)인 상태에서 이체 3건을 동시에 실행해 보자. 세 번째 요청은 어디서 멈추는가?db.Stats()의 어느 필드가 그것을 보여 주는가? 컨텍스트에 1초 타임아웃을 걸면 에러는 어디서 나는가? -
낙관적 잠금을 구현해 보자.
accounts에version INTEGER NOT NULL DEFAULT 0을 추가하고,UPDATE ... SET balance = ?, version = version + 1 WHERE id = ? AND version = ?형태로 바꾼다.RowsAffected()가 0이면 무슨 뜻인가? 호출자는 그때 무엇을 해야 하는가 — 재시도인가 에러인가, 그리고 몇 번까지? -
TransferBroken을 보상 트랜잭션으로 고쳐 보자. 출금 후 입금이 실패하면 출금을 되돌리는 코드를 쓴다. 그 보상 코드가 실패하면 어떻게 되는가? 이 방식이 트랜잭션보다 나은 경우가 하나 있다 — 두 데이터가 서로 다른 DB에 있을 때다. 그 경우 무엇을 포기해야 하는가?