1. 트랜잭션과 데이터 일관성 소개

트랜잭션은 데이터베이스 관리 시스템의 실행 프로세스에서의 논리적인 단위로, 일련의 작업으로 구성됩니다. 이러한 작업은 모두 성공하거나 모두 실패하며, 이들은 하나로 간주됩니다. 트랜잭션의 주요 특성은 ACID로 요약될 수 있습니다:

  • 원자성: 트랜잭션 내의 모든 작업은 완전히 완료되거나 전혀 완료되지 않으며, 부분 완료는 불가능합니다.
  • 일관성: 트랜잭션은 데이터베이스를 일관된 상태에서 다른 일관된 상태로 전환해야 합니다.
  • 고립성: 트랜잭션의 실행은 다른 트랜잭션에 의한 간섭으로부터 고립되어야 하며, 동시에 여러 트랜잭션 간의 데이터는 고립되어야 합니다.
  • 지속성: 한 번 트랜잭션이 커밋되면, 해당 트랜잭션에 의한 수정 사항은 데이터베이스에 지속됩니다.

데이터 일관성은 일련의 작업 이후의 데이터베이스에서 올바르고 유효한 데이터 상태를 유지하는 것을 의미합니다. 동시 접근이나 시스템 장애가 발생하는 시나리오에서, 데이터 일관성은 특히 중요하며, 트랜잭션은 오류나 충돌이 발생해도 데이터 일관성이 저해되지 않도록 보장하는 메커니즘을 제공합니다.

2. ent 프레임워크 개요

ent는 Go 프로그래밍 언어에서 코드 생성을 통해 데이터베이스 작업을 위한 안전한 API를 제공하는 엔터티 프레임워크입니다. 이를 통해 데이터베이스 작업을 보다 직관적이고 안전하게 수행할 수 있으며, SQL Injection과 같은 보안 문제를 피할 수 있습니다. 트랜잭션 처리 측면에서 ent 프레임워크는 강력한 지원을 제공하여 개발자가 간결한 코드로 복잡한 트랜잭션 작업을 수행하고 트랜잭션의 ACID 특성을 충족시킬 수 있습니다.

3. 트랜잭션 초기화

3.1 ent에서 트랜잭션 시작하는 방법

ent 프레임워크에서는 주어진 컨텍스트 내에서 client.Tx 메서드를 사용하여 쉽게 새 트랜잭션을 시작할 수 있으며, 이는 Tx 트랜잭션 객체를 반환합니다. 다음은 코드 예시입니다:

tx, err := client.Tx(ctx)
if err != nil {
    // 트랜잭션 시작 시 에러 처리
    return fmt.Errorf("트랜잭션 시작 중 오류 발생: %w", err)
}
// tx를 사용하여 후속 작업 수행...

3.2 트랜잭션 내에서 작업 수행

Tx 객체가 성공적으로 생성된 후, 해당 객체를 사용하여 데이터베이스 작업을 수행할 수 있습니다. Tx 객체에서 실행되는 모든 생성, 삭제, 업데이트 및 쿼리 작업은 트랜잭션의 일부가 됩니다. 다음 예시는 일련의 작업을 보여줍니다:

hub, err := tx.Group.
    Create().
    SetName("Github").
    Save(ctx)
if err != nil {
    // 오류 발생 시 트랜잭션 롤백
    return rollback(tx, fmt.Errorf("그룹 생성 실패: %w", err))
}
// 여기에 추가적인 작업 추가 가능...
// 트랜잭션 커밋
tx.Commit()

4. 트랜잭션에서의 오류 처리와 롤백

4.1 오류 처리의 중요성

데이터베이스 작업 시 네트워크 문제, 데이터 충돌 또는 제약 조건 위반과 같은 다양한 오류가 언제든지 발생할 수 있습니다. 이러한 오류를 적절하게 처리하는 것은 데이터 일관성을 유지하기 위해 중요합니다. 트랜잭션에서 작업이 실패하면, 데이터베이스 일관성을 저해할 수 있는 부분적으로 완료된 작업이 남아있지 않도록 하기 위해 트랜잭션을 롤백해야 합니다.

4.2 롤백 구현 방법

ent 프레임워크에서는 Tx.Rollback() 메서드를 사용하여 전체 트랜잭션을 롤백할 수 있습니다. 일반적으로 rollback 도우미 함수를 정의하여 롤백과 오류를 처리할 수 있습니다. 아래는 이러한 함수의 예시입니다:

func rollback(tx *ent.Tx, err error) error {
    if rerr := tx.Rollback(); rerr != nil {
        // 롤백 실패 시, 원래의 오류와 롤백 오류를 함께 반환
        err = fmt.Errorf("%w: 롤백 중 오류 발생: %v", err, rerr)
    }
    return err
}

rollback 함수를 사용하면 어떠한 작업이 트랜잭션 내에서 실패하더라도 오류와 트랜잭션 롤백을 안전하게 처리할 수 있습니다. 이를 통해 오류가 발생하더라도 데이터베이스의 일관성에 부정적인 영향을 미치지 않도록 보장할 수 있습니다.

5. 트랜잭션 클라이언트의 사용법

실제 응용 프로그램에서는 비 트랜잭셔널 코드를 빠르게 트랜잭셔널 코드로 변환해야 하는 상황이 발생할 수 있습니다. 이러한 경우에는 트랜잭션 클라이언트를 사용하여 코드를 신속하게 이전할 수 있습니다. 아래는 기존의 비 트랜잭셔널 클라이언트 코드를 트랜잭션을 지원하도록 변환하는 예시입니다:

// 이 예제에서는 원래의 Gen 함수를 트랜잭션 내부에 캡슐화합니다.
func WrapGen(ctx context.Context, client *ent.Client) error {
    // 먼저, 트랜잭션을 생성합니다.
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    // 트랜잭션으로부터 트랜잭션 클라이언트를 획들합니다.
    txClient := tx.Client()
    // 원래의 Gen 코드를 변경하지 않고 트랜잭션 클라이언트를 사용하여 Gen 함수를 실행합니다.
    if err := Gen(ctx, txClient); err != nil {
        // 오류가 발생한 경우, 트랜잭션을 롤백합니다.
        return rollback(tx, err)
    }
    // 성공한 경우, 트랜잭션을 커밋합니다.
    return tx.Commit()
}

위의 코드에서는 트랜잭션 클라이언트 tx.Client()를 사용하여 원래의 Gen 함수가 트랜잭션을 보장하에 실행되도록 했습니다. 이 접근 방식을 통해 기존의 비 트랜잭셔널 코드를 원본 로직에 미미한 영향을 미치도록 트랜잭셔널 코드로 편리하게 변환할 수 있습니다.

6. 트랜잭션의 최선의 실천 방법

6.1 콜백 함수로 트랜잭션 관리

코드 로직이 복잡하고 여러 데이터베이스 작업을 포함하는 경우, 이러한 작업을 트랜잭션 내에서 중앙 집중적으로 관리하는 것이 특히 중요합니다. 아래는 콜백 함수를 통해 트랜잭션을 관리하는 예시입니다:

func WithTx(ctx context.Context, client *ent.Client, fn func(tx *ent.Tx) error) error {
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    // 잠재적인 패닉 시나리오를 처리하기 위해 defer 및 recover를 사용합니다.
    defer func() {
        if v := recover(); v != nil {
            tx.Rollback()
            panic(v)
        }
    }()
    // 제공된 콜백 함수를 호출하여 비즈니스 로직을 실행합니다.
    if err := fn(tx); err != nil {
        // 오류가 발생한 경우, 트랜잭션을 롤백합니다.
        if rerr := tx.Rollback(); rerr != nil {
            err = fmt.Errorf("%w: rolling back transaction: %v", err, rerr)
        }
        return err
    }
    // 비즈니스 로직에 오류가 없는 경우, 트랜잭션을 커밋합니다.
    return tx.Commit()
}

WithTx 함수를 사용하여 비즈니스 로직을 래핑함으로써, 비즈니스 로직 내에서 오류 또는 예외가 발생해도 트랜잭션을 올바르게 처리(커밋 또는 롤백)할 수 있습니다.

6.2 트랜잭션 후크 사용

스키마 후크와 런타임 후크와 유사하게, 활성 트랜잭션 (Tx) 내에서 Tx.Commit 또는 Tx.Rollback시 트리거될 후크를 등록할 수도 있습니다:

func Do(ctx context.Context, client *ent.Client) error {
    tx, err := client.Tx(ctx)
    if err != nil {
        return err
    }
    tx.OnCommit(func(next ent.Committer) ent.Committer {
        return ent.CommitFunc(func(ctx context.Context, tx *ent.Tx) error {
            // 트랜잭션 커밋 전 로직
            err := next.Commit(ctx, tx)
            // 트랜잭션 커밋 후 로직
            return err
        })
    })
    tx.OnRollback(func(next ent.Rollbacker) ent.Rollbacker {
        return ent.RollbackFunc(func(ctx context.Context, tx *ent.Tx) error {
            // 트랜잭션 롤백 전 로직
            err := next.Rollback(ctx, tx)
            // 트랜잭션 롤백 후 로직
            return err
        })
    })
    // 기타 비즈니스 로직 실행
    //
    // 
    //
    return err
}

트랜잭션 커밋과 롤백 시 후크를 추가함으로써, 로깅이나 리소스 정리와 같은 추가 로직을 처리할 수 있습니다.

7. 서로 다른 트랜잭션 격리 수준 이해하기

데이터베이스 시스템에서 트랜잭션 격리 수준 설정은 다양한 동시성 문제 (더티 리드, 반복 불가능한 리드, 그리고 팬텀 리드와 같은)을 방지하는 데 중요합니다. 여기에는 일반적인 격리 수준과 ent 프레임워크에서 이를 설정하는 방법이 있습니다:

  • READ UNCOMMITTED: 가장 낮은 수준으로, 아직 커밋되지 않은 데이터 변경사항을 읽을 수 있으며, 이는 더티 리드, 반복 불가능한 리드, 그리고 팬텀 리드를 발생시킬 수 있습니다.
  • READ COMMITTED: 데이터의 읽기와 커밋을 허용하며, 더티 리드를 방지하지만 반복 불가능한 리드와 팬텀 리드가 여전히 발생할 수 있습니다.
  • REPEATABLE READ: 동일한 트랜잭션 내에서 여러 번 동일한 데이터를 읽을 때 일관된 결과를 보장하여, 반복 불가능한 리드를 방지하지만 팬텀 리드가 여전히 발생할 수 있습니다.
  • SERIALIZABLE: 가장 엄격한 격리 수준으로, 관련된 데이터를 잠그는 방식으로 더티 리드, 반복 불가능한 리드, 그리고 팬텀 리드를 방지하려고 시도합니다.

ent에서 데이터베이스 드라이버가 트랜잭션 격리 수준 설정을 지원하는 경우, 다음과 같이 설정할 수 있습니다:

// 트랜잭션 격리 수준을 반복 가능한 리드로 설정
tx, err := client.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})

트랜잭션 격리 수준의 이해와 데이터베이스에서의 적용은 데이터 일관성과 시스템 안정성을 보장하는 데 중요합니다. 개발자는 특정 응용프로그램 요구에 따라 적절한 격리 수준을 선택하여 데이터 보안을 보장하고 성능을 최적화하는 최상의 방법을 실현하기 위해 노력해야 합니다.