Database에서 ACID
ACID는 데이터베이스 트랜잭션의 신뢰성과 무결성을 보장하기 위한 4가지 주요 특성을 나타내는 약어입니다. 트랜잭션은 데이터베이스에서 일어나는 작업의 단위로, 하나의 트랜잭션이 성공하면 그 작업이 영구적으로 반영되고, 실패하면 전체 작업이 취소됩니다. ACID는 이러한 트랜잭션이 안전하게 처리되도록 하는 기본 원칙을 의미합니다.
1. ACID의 4가지 특성
원자성(Atomicity)
- 원자성은 트랜잭션이 모두 반영되거나 전혀 반영되지 않음을 보장하는 특성입니다. 즉, 트랜잭션 내에서 실행된 작업들이 모두 성공해야 하며, 하나라도 실패하면 전체 트랜잭션이 취소되고, 데이터베이스는 트랜잭션이 실행되기 전 상태로 돌아갑니다.
- 예시: 은행에서 A 계좌에서 B 계좌로 돈을 이체하는 경우, 돈이 A 계좌에서 빠져나가고 B 계좌에 도착하는 것이 하나의 트랜잭션입니다. 만약 A 계좌에서 돈이 빠져나갔으나 B 계좌에 도착하지 못하면, 트랜잭션은 실패하고 A 계좌에서도 돈이 빠져나가지 않은 상태로 롤백(취소)됩니다.
일관성(Consistency)
- 일관성은 트랜잭션이 완료되었을 때 데이터베이스가 일관된 상태를 유지함을 의미합니다. 즉, 트랜잭션 전후의 데이터가 데이터베이스의 무결성 제약 조건을 항상 만족해야 합니다.
- 예시: 트랜잭션이 완료되면 외래 키 제약, 유니크 제약, 체크 제약 등이 여전히 유지되어 데이터베이스가 유효한 상태임을 보장합니다.
고립성(Isolation)
- 고립성은 동시에 실행되는 트랜잭션들이 서로의 영향을 받지 않도록 보장하는 특성입니다. 즉, 여러 트랜잭션이 동시에 수행되더라도, 각 트랜잭션이 독립적으로 수행된 것처럼 데이터베이스는 동작해야 합니다.
- 예시: 두 사용자가 동시에 같은 계좌에서 돈을 인출하려는 트랜잭션이 있을 때, 한 트랜잭션이 완료될 때까지 다른 트랜잭션이 그 데이터를 볼 수 없도록 해야 합니다. 이를 통해 데이터가 불일치 상태에 빠지지 않도록 합니다.
지속성(Durability)
- 지속성은 트랜잭션이 성공적으로 완료된 후에는 그 결과가 영구적으로 저장된다는 특성입니다. 서버가 중단되거나 시스템에 문제가 발생하더라도, 커밋된 트랜잭션의 결과는 사라지지 않습니다.
- 예시: 트랜잭션이 완료되면 해당 변경 사항이 로그 파일이나 디스크에 영구적으로 기록되어 시스템 충돌 후에도 복구될 수 있습니다.
2. Atomcity 원자성
Golang에서 ACID의 원자성(Atomicity)을 보장하기 위해 사용할 수 있는 방법들을 설명하겠습니다.
Golang은 데이터베이스와의 상호작용을 위한 여러 패키지를 제공하며, 이를 통해 원자성을 유지하는 트랜잭션을 효과적으로 관리할 수 있습니다.
1. 트랜잭션 관리
- Golang에서 SQL 데이터베이스와 트랜잭션을 관리하기 위해 database/sql 패키지를 사용합니다. 트랜잭션은 Begin, Commit, Rollback 메서드를 통해 처리됩니다.
- BEGIN TRANSACTION: 트랜잭션의 시작을 명시적으로 선언합니다.
- COMMIT: 모든 작업이 성공적으로 완료되었을 때 트랜잭션을 확정하고 변경 사항을 데이터베이스에 저장합니다.
- ROLLBACK: 오류가 발생하거나 작업이 실패한 경우 트랜잭션을 취소하고, 데이터베이스를 트랜잭션 시작 이전 상태로 되돌립니다.
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq" // PostgreSQL 드라이버
)
func main() {
db, err := sql.Open("postgres", "user=username dbname=mydb sslmode=disable")
if err != nil {
log.Fatal(err)
}
tx, err := db.Begin() // 트랜잭션 시작
if err != nil {
log.Fatal(err)
}
// 여러 쿼리 실행
_, err = tx.Exec("INSERT INTO users (name) VALUES ($1)", "Alice")
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
log.Fatal(err)
}
_, err = tx.Exec("INSERT INTO accounts (user_id, balance) VALUES ($1, $2)", 1, 100)
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
log.Fatal(err)
}
err = tx.Commit() // 모든 쿼리가 성공적으로 실행되면 커밋
if err != nil {
log.Fatal(err)
}
}
2. 에러 처리 및 예외 처리
- 트랜잭션 처리 중 발생할 수 있는 오류나 예외를 적절히 처리하여, 오류가 발생할 경우 롤백이 수행되도록 합니다. 예를 들어, try-catch 문을 사용하여 오류를 감지하고, 오류 발생 시 자동으로 ROLLBACK을 호출하도록 설정할 수 있습니다.
- Golang은 기본적으로 에러 처리를 통해 예외 상황을 관리합니다. 위의 예제에서도 각 쿼리 실행 후 에러를 체크하여, 오류가 발생하면 Rollback을 호출하여 트랜잭션을 취소합니다.
3. 두 단계 커밋(Two-Phase Commit)
- 분산 데이터베이스 환경에서 여러 데이터베이스에 걸쳐 트랜잭션을 수행하는 경우, 두 단계 커밋 프로토콜을 사용하여 원자성을 보장합니다. 이 방법은 다음과 같은 두 단계로 진행됩니다:
- 준비 단계(Prepare Phase): 모든 참여 노드가 트랜잭션을 수용할 준비가 되었는지 확인합니다.
- 커밋 단계(Commit Phase): 모든 노드가 준비가 되었을 경우 트랜잭션을 커밋하고, 하나라도 준비가 되지 않은 경우 모든 노드에 롤백을 요청합니다.
- Golang에서 두 단계 커밋을 구현하려면, 여러 데이터베이스에 대한 트랜잭션을 수동으로 관리해야 합니다. 각 데이터베이스에 대해 Prepare와 Commit 메서드를 호출하여 일관성을 유지해야 합니다. Golang의 database/sql 패키지는 기본적으로 두 단계 커밋을 지원하지 않으므로, 수동으로 관리해야 합니다.
4. 트랜잭션 격리 수준(Isolation Levels) 설정
- 트랜잭션의 격리 수준을 적절히 설정하여 동시 실행되는 트랜잭션이 서로 영향을 미치지 않도록 합니다. 일반적으로 사용되는 격리 수준은 다음과 같습니다:
- READ UNCOMMITTED: 다른 트랜잭션의 변경 사항을 읽을 수 있지만, 원자성이 보장되지 않습니다.
- READ COMMITTED: 커밋된 데이터만 읽을 수 있으며, 데이터의 원자성을 보장합니다.
- REPEATABLE READ: 트랜잭션이 시작된 이후의 상태를 유지하여 원자성을 보장합니다.
- SERIALIZABLE: 가장 높은 격리 수준으로, 모든 트랜잭션을 직렬적으로 수행하는 것처럼 동작하여 완벽한 원자성을 제공합니다.
- Golang에서 SQL 쿼리를 통해 트랜잭션의 격리 수준을 설정할 수 있습니다. 트랜잭션을 시작할 때 SQL 문을 사용하여 격리 수준을 지정합니다. 예를 들어:
_, err := db.Exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
if err != nil {
log.Fatal(err)
}
5. 원자적 연산(Atomic Operations) 사용
- 데이터베이스에 따라 특정 연산을 원자적으로 처리할 수 있는 방법이 있습니다. 예를 들어, SQL의 INSERT, UPDATE, DELETE와 같은 명령어는 원자적이며, 이러한 연산을 통해 데이터베이스의 무결성을 유지할 수 있습니다.
- Golang에서 SQL 쿼리(예: INSERT, UPDATE, DELETE)는 원자적입니다. 데이터베이스에 요청을 보내는 순간, 해당 연산은 다른 트랜잭션과 격리되어 처리됩니다. Golang의 database/sql 패키지를 사용하여 이러한 원자적 연산을 수행할 수 있습니다.
6. 트랜잭션 로그 사용
- Golang에서 트랜잭션 로그는 일반적으로 데이터베이스 관리 시스템(DBMS)에서 처리합니다. 하지만, 애플리케이션에서 이러한 로그를 활용하려면 데이터베이스에 맞는 설정을 해야 합니다. 예를 들어 PostgreSQL은 기본적으로 WAL(Write Ahead Logging)을 사용하여 트랜잭션 로그를 관리합니다.
7. 적절한 아키텍처 설계
- Golang 애플리케이션의 아키텍처를 설계할 때 트랜잭션을 그룹화하여 원자성을 유지하도록 설계합니다. 데이터베이스 접근 레이어를 설계하여 모든 데이터 조작을 트랜잭션 내에서 수행하도록 하는 방법이 있습니다. 예를 들어, 서비스 레이어에서 데이터베이스 쿼리 호출을 트랜잭션 내에서 수행하도록 구현합니다.
예제 아키텍처 설계
type UserService struct {
db *sql.DB
}
func (us *UserService) CreateUser(name string, balance float64) error {
tx, err := us.db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("INSERT INTO users (name) VALUES ($1)", name)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec("INSERT INTO accounts (user_id, balance) VALUES ((SELECT id FROM users WHERE name = $1), $2)", name, balance)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
이 예제에서 UserService 구조체는 사용자 생성과 관련된 모든 작업을 트랜잭션 내에서 수행하여 원자성을 보장합니다.
3. Consistency 일관성
- ACID의 일관성(Consistency)은 트랜잭션이 성공적으로 완료될 때 데이터베이스의 모든 규칙, 제약 조건, 그리고 비즈니스 규칙이 항상 만족되는 상태를 의미합니다.
- 즉, 트랜잭션 전후의 데이터가 유효한 상태로 유지되어야 합니다. 이를 보장하기 위해 개발자는 여러 가지 방법을 사용할 수 있습니다.
- 아래는 Golang을 사용하여 ACID의 일관성을 유지하는 예시를 보여드리겠습니다. 이 예시에서는 은행 계좌 시스템을 다룰 것이며, 고객이 계좌에 돈을 입금하거나 출금할 때 데이터베이스의 일관성을 보장합니다.
예제: 은행 계좌 시스템
1. 데이터베이스 구조 설정
우선, 데이터베이스에 필요한 테이블을 설정합니다. 여기서는 고객(customers)과 계좌(accounts) 두 개의 테이블을 사용합니다.
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
customer_id INT REFERENCES customers(id),
balance DECIMAL(10, 2) NOT NULL CHECK (balance >= 0) -- 잔고가 0 이상이어야 함
);
2. Golang 코드 예시
다음은 Golang에서 고객의 계좌에 대한 입금 및 출금 기능을 구현한 예시입니다. 이 코드에서는 트랜잭션을 사용하여 일관성을 보장합니다.
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq" // PostgreSQL 드라이버
)
type BankService struct {
db *sql.DB
}
// 계좌에 돈을 입금하는 메서드
func (bs *BankService) Deposit(accountID int, amount float64) error {
tx, err := bs.db.Begin() // 트랜잭션 시작
if err != nil {
return err
}
// 계좌 잔고 업데이트 쿼리
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, accountID)
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
return err
}
return tx.Commit() // 트랜잭션 성공적으로 완료 시 커밋
}
// 계좌에서 돈을 출금하는 메서드
func (bs *BankService) Withdraw(accountID int, amount float64) error {
tx, err := bs.db.Begin() // 트랜잭션 시작
if err != nil {
return err
}
// 현재 잔고를 가져오기 위한 쿼리
var balance float64
err = tx.QueryRow("SELECT balance FROM accounts WHERE id = $1", accountID).Scan(&balance)
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
return err
}
// 잔고가 부족할 경우 오류 발생
if balance < amount {
tx.Rollback() // 롤백
return fmt.Errorf("insufficient funds")
}
// 계좌 잔고 업데이트 쿼리
_, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, accountID)
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
return err
}
return tx.Commit() // 트랜잭션 성공적으로 완료 시 커밋
}
func main() {
db, err := sql.Open("postgres", "user=username dbname=mydb sslmode=disable")
if err != nil {
log.Fatal(err)
}
bankService := &BankService{db: db}
// 입금
if err := bankService.Deposit(1, 100.00); err != nil {
log.Println("Deposit error:", err)
} else {
log.Println("Deposit successful")
}
// 출금
if err := bankService.Withdraw(1, 50.00); err != nil {
log.Println("Withdraw error:", err)
} else {
log.Println("Withdraw successful")
}
}
3. 일관성 유지 방법 설명
- 제약 조건: 데이터베이스 스키마에서 정의한 CHECK 제약 조건을 통해 잔고가 0 이상이어야 한다는 규칙을 설정했습니다. 이로 인해 출금 시 잔고가 부족한 경우 오류를 발생시키고 트랜잭션을 롤백합니다.
- 트랜잭션 사용: Deposit 및 Withdraw 메서드에서 트랜잭션을 사용하여 일관성을 유지합니다. 트랜잭션 내에서 모든 작업이 완료되어야만 데이터베이스에 반영됩니다.
- 에러 처리: 입금 및 출금 작업 중 오류가 발생할 경우 Rollback을 호출하여 이전 상태로 되돌립니다. 이를 통해 데이터의 일관성이 유지됩니다.
4. 시나리오 예시
- 입금: 사용자가 1번 계좌에 100.00을 입금하려고 할 때:
- Deposit 메서드가 호출되어 트랜잭션이 시작됩니다.
- 계좌의 잔고가 100.00 증가합니다.
- 트랜잭션이 성공적으로 완료되면 잔고가 업데이트됩니다.
- 출금: 사용자가 1번 계좌에서 50.00을 출금하려고 할 때:
- Withdraw 메서드가 호출되어 트랜잭션이 시작됩니다.
- 현재 잔고를 확인하고, 잔고가 충분한 경우에만 출금이 진행됩니다.
- 출금이 성공하면 잔고가 50.00 감소하고 트랜잭션이 커밋됩니다.
이 예제는 ACID의 일관성을 보장하는 방법을 잘 보여주며, 비즈니스 규칙을 반영하여 데이터베이스의 상태가 항상 유효하도록 합니다. 이러한 방식으로 데이터베이스의 무결성을 유지하고, 비즈니스 로직이 올바르게 수행될 수 있도록 할 수 있습니다.
4. Isolation 고립성
ACID의 고립성(Isolation)은 트랜잭션이 동시에 수행될 때 서로의 영향을 받지 않도록 보장하는 특성입니다. 즉, 각 트랜잭션은 다른 트랜잭션의 작업과 격리되어 실행되어야 하며, 중간 결과가 다른 트랜잭션에 노출되지 않아야 합니다.
이 고립성을 보장하기 위해 다양한 격리 수준이 정의되어 있으며, 트랜잭션이 서로 독립적으로 처리될 수 있도록 합니다. 아래는 Golang을 사용하여 고립성을 유지하는 예제를 보여드리겠습니다.
예제: 고립성 보장하는 은행 계좌 시스템
이번 예제에서는 동시에 두 개의 트랜잭션이 발생할 때 고립성을 유지하는 방법을 보여줍니다. 한 트랜잭션이 잔고를 조회하는 동안 다른 트랜잭션이 잔고를 변경하는 경우를 가정합니다.
1. 데이터베이스 구조 설정
기본적인 데이터베이스 테이블 구조는 앞서 설명한 예제와 동일합니다. 계좌에 대한 정보를 저장하는 accounts 테이블을 사용합니다.
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
balance DECIMAL(10, 2) NOT NULL CHECK (balance >= 0) -- 잔고가 0 이상이어야 함
);
2. Golang 코드 예시
다음은 두 개의 고립된 트랜잭션을 동시에 실행하는 예시입니다. 첫 번째 트랜잭션은 잔고를 조회하고, 두 번째 트랜잭션은 잔고를 변경하는 작업을 수행합니다.
package main
import (
"database/sql"
"fmt"
"log"
"sync"
"time"
_ "github.com/lib/pq" // PostgreSQL 드라이버
)
type BankService struct {
db *sql.DB
}
// 잔고 조회 메서드
func (bs *BankService) CheckBalance(accountID int) (float64, error) {
var balance float64
err := bs.db.QueryRow("SELECT balance FROM accounts WHERE id = $1", accountID).Scan(&balance)
if err != nil {
return 0, err
}
return balance, nil
}
// 계좌에 돈을 입금하는 메서드
func (bs *BankService) Deposit(accountID int, amount float64) error {
tx, err := bs.db.Begin() // 트랜잭션 시작
if err != nil {
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, accountID)
if err != nil {
tx.Rollback() // 오류 발생 시 롤백
return err
}
return tx.Commit() // 트랜잭션 성공적으로 완료 시 커밋
}
func main() {
db, err := sql.Open("postgres", "user=username dbname=mydb sslmode=disable")
if err != nil {
log.Fatal(err)
}
bankService := &BankService{db: db}
// 두 개의 고립된 트랜잭션을 동시에 실행하기 위해 WaitGroup을 사용
var wg sync.WaitGroup
// 트랜잭션 1: 잔고 조회
wg.Add(1)
go func() {
defer wg.Done()
balance, err := bankService.CheckBalance(1)
if err != nil {
log.Println("Error checking balance:", err)
return
}
log.Printf("Current balance (Transaction 1): %.2f\\n", balance)
}()
// 잠시 대기하여 트랜잭션 1이 먼저 실행되도록 함
time.Sleep(1 * time.Second)
// 트랜잭션 2: 잔고에 50.00을 입금
wg.Add(1)
go func() {
defer wg.Done()
err := bankService.Deposit(1, 50.00)
if err != nil {
log.Println("Error depositing:", err)
return
}
log.Println("Deposit successful (Transaction 2)")
}()
wg.Wait()
}
3. 고립성 유지 방법 설명
- 동시성 문제: 위의 예제에서는 두 개의 트랜잭션이 동시에 실행됩니다. 첫 번째 트랜잭션은 잔고를 조회하고, 두 번째 트랜잭션은 잔고를 변경합니다. 이 경우 트랜잭션 1이 실행되는 동안 트랜잭션 2가 잔고를 업데이트하게 됩니다. 이 상황에서 고립성을 보장하기 위해 격리 수준을 설정해야 합니다.
- 트랜잭션 격리 수준 설정: 아래와 같이 SQL 쿼리를 통해 트랜잭션의 격리 수준을 설정할 수 있습니다.
_, err := db.Exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
if err != nil {
log.Fatal(err)
}
이 설정은 모든 트랜잭션이 직렬적으로 실행되는 것처럼 동작하도록 하여, 두 개의 트랜잭션이 서로 영향을 미치지 않도록 보장합니다.
4. 시나리오 예시
- 트랜잭션 1: 잔고 조회
- 트랜잭션 1이 실행되어 계좌의 현재 잔고를 조회합니다. 이 트랜잭션이 실행되는 동안 다른 트랜잭션의 변경 사항을 볼 수 없습니다.
- 트랜잭션 2: 잔고에 입금
- 트랜잭션 2가 실행되어 계좌에 50.00을 입금합니다. 이 트랜잭션의 결과는 트랜잭션 1이 커밋된 후에만 적용됩니다.
5. 고립성의 중요성
고립성은 데이터의 무결성을 유지하고, 동시에 수행되는 트랜잭션 간의 간섭을 방지하는 데 매우 중요합니다. 이러한 특성을 통해 비즈니스 로직이 일관되게 유지되며, 오류나 예기치 않은 결과를 방지할 수 있습니다.
위의 예제를 통해 Golang에서 ACID의 고립성을 보장하는 방법을 이해할 수 있으며, 적절한 트랜잭션 관리와 격리 수준 설정이 필수적임을 알 수 있습니다.
5. Durability 지속성
ACID의 지속성(Durability)은 트랜잭션이 성공적으로 완료된 후, 그 결과가 영구적으로 저장되어 시스템 오류나 장애가 발생하더라도 데이터가 손실되지 않는 것을 의미합니다. 즉, 한 번 커밋된 트랜잭션의 결과는 데이터베이스의 영구적인 상태에 반영되어야 하며, 어떠한 이유로도 그 결과가 소실되지 않아야 합니다.
추천 아키텍처
일반적으로, 데이터베이스와 메시지 큐를 조합한 아키텍처를 사용하는 것이 가장 이상적입니다. 데이터베이스는 영구적인 데이터 저장을 제공하고, 메시지 큐는 비동기 처리 및 확장성을 제공합니다. 예를 들어:
- 사용자가 요청을 보내면, 해당 요청을 메시지 큐에 전송합니다.
- 백엔드 서비스는 메시지 큐에서 요청을 소비하고, 데이터베이스에 필요한 트랜잭션을 수행합니다.
- 트랜잭션이 성공적으로 완료되면, 결과를 다시 메시지 큐에 전송하거나 사용자에게 알림을 보냅니다.
결론
ACID 특성은 데이터베이스의 무결성과 일관성을 보장하기 위한 핵심 개념입니다. 예를 들어, 전자 상거래에서 주문 처리 중에 문제가 발생해도 고객의 결제가 완료되지 않거나 상품 재고가 잘못 반영되지 않도록 ACID 속성이 중요한 역할을 합니다.
- 원자성은 트랜잭션이 절대 부분적으로 완료되지 않도록 하며,
- 일관성은 데이터베이스의 규칙과 제약이 항상 유지되도록 합니다.
- 고립성은 동시 작업 간의 충돌을 방지하고,
- 지속성은 트랜잭션 결과가 확실하게 반영됨을 보장합니다.
ACID 속성을 잘 지키면 데이터베이스의 신뢰성과 안정성이 크게 향상됩니다.
'Interview > DB' 카테고리의 다른 글
Database Select Query (0) | 2024.10.17 |
---|---|
Database Commit vs Rollback (0) | 2024.10.17 |
DB에서 데이터 무결성 (1) | 2024.10.14 |
Database에서 DDL DML DCL (0) | 2024.10.14 |
Database View 가상 테이블 (1) | 2024.10.14 |