🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

ACID 트랜잭션과 데이터 무결성 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 12. · 15 Views

ACID 트랜잭션과 데이터 무결성 완벽 가이드

데이터베이스의 핵심 원리인 ACID 트랜잭션부터 동시성 제어, 충돌 해결, 격리 수준까지 실무에서 꼭 알아야 할 트랜잭션 관리 기법을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다.


목차

  1. ACID 트랜잭션의 4가지 특성
  2. 낙관적 동시성 제어(OCC)
  3. 충돌 해결과 재시도 메커니즘
  4. 트랜잭션 격리 수준
  5. 원자적 쓰기 작업
  6. 다중 사용자 환경에서의 안정성

1. ACID 트랜잭션의 4가지 특성

어느 날 이커머스 회사에 입사한 신입 개발자 김데이터 씨는 결제 시스템 코드를 보다가 궁금해졌습니다. "트랜잭션이 실패하면 어떻게 되나요?" 선배 박디비 씨가 웃으며 대답합니다.

"바로 그게 ACID의 핵심이죠."

ACID는 데이터베이스 트랜잭션의 신뢰성을 보장하는 4가지 핵심 특성입니다. 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), **지속성(Durability)**이 그것입니다.

이 특성들이 지켜져야 데이터가 안전하게 보호됩니다.

다음 코드를 살펴봅시다.

from delta.tables import DeltaTable
from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") \
    .getOrCreate()

# 원자성: 모두 성공하거나 모두 실패
try:
    # 재고 차감과 주문 생성이 하나의 트랜잭션
    delta_table = DeltaTable.forPath(spark, "/data/inventory")
    delta_table.update(
        condition="product_id = 'P001'",
        set={"quantity": "quantity - 1"}
    )
    # 실패 시 자동 롤백
    spark.sql("INSERT INTO orders VALUES (1001, 'P001', 1)")
except Exception as e:
    print(f"트랜잭션 실패: {e}")
    # 모든 변경사항 자동 롤백

김데이터 씨는 이커머스 회사에 입사한 지 일주일째입니다. 오늘은 결제 시스템을 담당하게 되었습니다.

코드를 살펴보던 중 "transaction"이라는 단어가 계속 등장합니다. 대충 알 것 같지만 정확히 무엇인지 모르겠습니다.

점심시간에 선배 박디비 씨에게 물어봤습니다. "트랜잭션이 정확히 뭔가요?" 박디비 씨는 커피를 한 모금 마시고는 이야기를 시작합니다.

"김데이터 씨, 은행에서 계좌이체를 한다고 생각해봐요. A 계좌에서 돈이 빠져나가고, B 계좌에 돈이 들어와야 하죠.

만약 A 계좌에서는 돈이 빠져나갔는데 B 계좌에는 입금이 안 되면 어떻게 될까요?" 김데이터 씨는 깜짝 놀랐습니다. "그럼 돈이 사라지는 거 아닌가요?" 박디비 씨가 고개를 끄덕입니다.

"맞아요. 그래서 트랜잭션이 필요합니다." 트랜잭션이란 한마디로 여러 작업을 하나의 묶음으로 처리하는 것입니다.

마치 택배 상자에 물건을 여러 개 넣어서 한 번에 배송하는 것과 같습니다. 상자가 도착하면 모든 물건이 함께 도착하고, 상자가 분실되면 모든 물건이 함께 분실됩니다.

그렇다면 좋은 트랜잭션은 어떤 특성을 가져야 할까요? 바로 여기서 ACID가 등장합니다.

첫 번째는 **원자성(Atomicity)**입니다. 이것은 "전부 아니면 전무"라는 원칙입니다.

트랜잭션 안의 모든 작업이 성공하거나, 하나라도 실패하면 전부 취소됩니다. 위의 코드에서 재고 차감은 성공했는데 주문 생성이 실패하면 재고 차감도 자동으로 롤백됩니다.

두 번째는 **일관성(Consistency)**입니다. 트랜잭션이 끝나면 데이터베이스는 항상 일관된 상태여야 합니다.

예를 들어 재고가 음수가 될 수 없다는 규칙이 있다면, 트랜잭션이 끝난 후에도 이 규칙은 반드시 지켜져야 합니다. 세 번째는 **격리성(Isolation)**입니다.

여러 트랜잭션이 동시에 실행되더라도 서로 영향을 주지 않아야 합니다. 마치 은행 창구가 여러 개 있어도 각 창구에서 처리하는 업무는 독립적인 것과 같습니다.

네 번째는 **지속성(Durability)**입니다. 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 저장되어야 합니다.

시스템이 갑자기 꺼져도 데이터는 사라지지 않습니다. 위의 코드를 자세히 살펴보겠습니다.

먼저 Delta Lake를 사용해서 트랜잭션을 처리합니다. Delta Lake는 ACID 트랜잭션을 기본으로 지원하는 오픈소스 스토리지 레이어입니다.

코드의 try 블록 안에서 재고를 차감하고 주문을 생성합니다. 이 두 작업은 하나의 트랜잭션으로 묶입니다.

만약 중간에 에러가 발생하면 except 블록으로 들어갑니다. 이때 Delta Lake는 자동으로 모든 변경사항을 롤백합니다.

재고 차감도 취소되고 주문도 생성되지 않습니다. 이것이 바로 원자성입니다.

실제 현업에서는 어떻게 활용할까요? 전자상거래 사이트에서 고객이 상품을 주문할 때를 생각해봅시다.

재고 확인, 재고 차감, 주문 생성, 결제 처리, 배송 정보 저장 등 여러 단계가 있습니다. 이 모든 단계가 ACID 트랜잭션으로 보호되어야 합니다.

하나라도 실패하면 모두 취소되어야 하기 때문입니다. 많은 대형 이커머스 회사들이 Delta Lake나 Apache Iceberg 같은 트랜잭션을 지원하는 데이터 레이크 기술을 사용합니다.

이를 통해 대용량 데이터에서도 ACID 보장이 가능합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 트랜잭션을 너무 크게 만드는 것입니다. 한 트랜잭션에 수십 개의 작업을 넣으면 처리 시간이 길어지고 다른 트랜잭션을 막을 수 있습니다.

따라서 트랜잭션은 꼭 필요한 작업만 포함하도록 작게 유지해야 합니다. 다시 김데이터 씨의 이야기로 돌아가 봅시다.

박디비 씨의 설명을 들은 김데이터 씨는 고개를 끄덕였습니다. "아, 그래서 결제 시스템 코드에 try-except가 그렇게 많았군요!" ACID를 제대로 이해하면 데이터 무결성을 보장하는 안전한 시스템을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 트랜잭션은 가능한 한 작게 유지하세요

  • Delta Lake나 Iceberg 같은 ACID 지원 스토리지를 활용하세요
  • 롤백 시나리오를 항상 테스트하세요

2. 낙관적 동시성 제어(OCC)

김데이터 씨는 이제 재고 관리 시스템을 개발하고 있습니다. 그런데 여러 명이 동시에 같은 상품을 주문하면 어떻게 될까요?

박디비 씨가 말합니다. "그럴 때는 낙관적 동시성 제어를 사용하면 됩니다."

**낙관적 동시성 제어(Optimistic Concurrency Control, OCC)**는 충돌이 드물게 발생한다고 가정하고 일단 작업을 진행한 뒤 커밋할 때 충돌을 검사하는 방식입니다. 버전 번호타임스탬프를 활용해서 충돌을 감지합니다.

충돌이 없으면 빠르게 처리되지만 충돌 시 재시도가 필요합니다.

다음 코드를 살펴봅시다.

from delta.tables import DeltaTable
from pyspark.sql.functions import col

# 낙관적 동시성 제어: 버전 기반 업데이트
def update_inventory_optimistic(product_id, quantity_to_deduct):
    delta_table = DeltaTable.forPath(spark, "/data/inventory")

    # 1단계: 현재 데이터 읽기 (버전 확인)
    current_data = delta_table.toDF().filter(col("product_id") == product_id).first()
    current_version = current_data["version"]
    current_quantity = current_data["quantity"]

    # 2단계: 작업 수행 (락 없이)
    new_quantity = current_quantity - quantity_to_deduct

    # 3단계: 커밋 시 버전 확인 (충돌 감지)
    delta_table.update(
        condition=f"product_id = '{product_id}' AND version = {current_version}",
        set={"quantity": new_quantity, "version": current_version + 1}
    )
    # 버전이 바뀌었다면 업데이트 실패 (충돌 발생)

김데이터 씨는 재고 관리 시스템을 개발하고 있습니다. 시스템이 잘 작동하는 것 같았는데, 갑자기 재고가 음수로 떨어지는 버그가 발견되었습니다.

어떻게 된 걸까요? 박디비 씨가 코드를 살펴보더니 말합니다.

"아, 동시성 문제네요. 두 명이 거의 동시에 마지막 남은 상품을 주문하면 이런 일이 생길 수 있어요." 김데이터 씨는 고개를 갸우뚱했습니다.

"그럼 어떻게 해야 하나요?" 박디비 씨가 화이트보드에 그림을 그리며 설명을 시작합니다. "동시성 문제를 해결하는 방법에는 크게 두 가지가 있어요.

**비관적 잠금(Pessimistic Locking)**과 **낙관적 동시성 제어(Optimistic Concurrency Control)**입니다." 비관적 잠금은 이름처럼 비관적입니다. 마치 은행 금고처럼 한 사람이 사용할 때 다른 사람은 전혀 접근할 수 없습니다.

안전하지만 느립니다. 한 명이 작업하는 동안 다른 모든 사람이 기다려야 하기 때문입니다.

반면 낙관적 동시성 제어는 낙관적입니다. 마치 도서관 책처럼 여러 사람이 동시에 읽을 수 있습니다.

다만 누군가 책을 수정하려고 할 때만 충돌을 확인합니다. 낙관적 동시성 제어는 어떻게 작동할까요?

먼저 데이터를 읽을 때 버전 번호타임스탬프를 함께 기억합니다. 마치 책을 빌릴 때 "제3판"이라고 적힌 것을 확인하는 것과 같습니다.

그리고 자유롭게 작업을 합니다. 이때는 어떤 잠금도 걸지 않습니다.

작업이 끝나고 데이터를 저장할 때가 중요합니다. 이때 버전 번호를 다시 확인합니다.

만약 아직도 "제3판"이라면 충돌이 없다는 뜻입니다. 안심하고 저장하고 버전을 "제4판"으로 올립니다.

하지만 만약 누군가 먼저 수정해서 이미 "제4판"이 되었다면? 충돌이 발생한 것입니다.

이때는 저장을 포기하고 다시 시도해야 합니다. 위의 코드를 단계별로 살펴보겠습니다.

첫 번째 단계에서는 현재 재고 데이터를 읽으면서 버전 번호도 함께 가져옵니다. 이 버전 번호가 나중에 충돌 감지의 핵심이 됩니다.

두 번째 단계에서는 아무 잠금 없이 자유롭게 계산을 수행합니다. 새로운 재고량을 계산합니다.

세 번째 단계가 가장 중요합니다. 업데이트 조건에 버전 번호 검사를 포함시킵니다.

"product_id가 일치하고 버전이 아직도 원래 버전일 때만" 업데이트하라는 의미입니다. 만약 다른 트랜잭션이 먼저 수정했다면 버전이 바뀌어 있을 것이고, 이 업데이트는 실패합니다.

실제 현업에서는 어떻게 활용할까요? 대부분의 웹 애플리케이션은 낙관적 동시성 제어를 사용합니다.

왜냐하면 실제로 충돌이 발생하는 경우는 드물기 때문입니다. 수천 명의 사용자가 있어도 정확히 같은 데이터를 동시에 수정하는 경우는 많지 않습니다.

예를 들어 인스타그램에서 게시물에 좋아요를 누를 때를 생각해봅시다. 수백 명이 동시에 좋아요를 누를 수 있습니다.

이때 비관적 잠금을 사용하면 한 명씩 순서대로 처리되어 매우 느려집니다. 하지만 낙관적 동시성 제어를 사용하면 대부분 빠르게 처리됩니다.

Delta Lake는 낙관적 동시성 제어를 기본으로 지원합니다. 내부적으로 트랜잭션 로그를 사용해서 충돌을 감지합니다.

두 트랜잭션이 같은 파일을 수정하려고 하면 나중에 커밋하는 쪽이 실패합니다. 하지만 주의할 점이 있습니다.

충돌이 자주 발생하는 경우에는 낙관적 동시성 제어가 오히려 비효율적입니다. 계속 재시도해야 하기 때문입니다.

예를 들어 인기 상품의 마지막 재고를 여러 명이 동시에 주문하는 경우라면 비관적 잠금이 더 나을 수 있습니다. 또한 재시도 로직을 반드시 구현해야 합니다.

충돌이 발생했을 때 자동으로 재시도하지 않으면 트랜잭션이 그냥 실패로 끝나버립니다. 김데이터 씨는 이제 이해가 되었습니다.

"아, 그래서 버전 컬럼이 있었군요!" 박디비 씨가 웃으며 말합니다. "맞아요.

이제 재시도 로직만 추가하면 완벽해질 거예요." 낙관적 동시성 제어를 제대로 이해하면 높은 동시성을 처리하면서도 데이터 무결성을 지킬 수 있습니다.

실전 팁

💡 - 충돌이 드문 경우 낙관적 동시성 제어가 효율적입니다

  • 버전 컬럼이나 타임스탬프를 활용해서 충돌을 감지하세요
  • 충돌이 자주 발생하는 핫스팟 데이터는 비관적 잠금을 고려하세요

3. 충돌 해결과 재시도 메커니즘

낙관적 동시성 제어를 배운 김데이터 씨는 곧바로 새로운 질문이 생겼습니다. "충돌이 발생하면 어떻게 해야 하나요?" 박디비 씨가 대답합니다.

"바로 재시도 메커니즘이 필요하죠."

충돌 해결은 동시성 제어에서 충돌이 발생했을 때 이를 처리하는 전략입니다. 가장 일반적인 방법은 **지수 백오프(Exponential Backoff)**를 사용한 재시도입니다.

실패한 트랜잭션을 일정 시간 대기 후 다시 시도하며, 재시도할 때마다 대기 시간을 늘려갑니다.

다음 코드를 살펴봅시다.

import time
import random
from delta.tables import DeltaTable

def update_with_retry(product_id, quantity_to_deduct, max_retries=5):
    """지수 백오프를 사용한 재시도 메커니즘"""
    for attempt in range(max_retries):
        try:
            delta_table = DeltaTable.forPath(spark, "/data/inventory")

            # 낙관적 잠금으로 업데이트 시도
            current = delta_table.toDF().filter(f"product_id = '{product_id}'").first()

            delta_table.update(
                condition=f"product_id = '{product_id}' AND version = {current['version']}",
                set={"quantity": current["quantity"] - quantity_to_deduct,
                     "version": current["version"] + 1}
            )
            print(f"성공: {attempt + 1}번째 시도")
            return True  # 성공

        except Exception as e:
            if attempt == max_retries - 1:
                raise  # 마지막 시도에서도 실패하면 예외 발생

            # 지수 백오프: 2^attempt * 100ms + 랜덤 지터
            wait_time = (2 ** attempt) * 0.1 + random.uniform(0, 0.1)
            print(f"충돌 발생, {wait_time:.2f}초 후 재시도...")
            time.sleep(wait_time)

김데이터 씨는 낙관적 동시성 제어를 구현했습니다. 하지만 테스트를 해보니 문제가 있습니다.

충돌이 발생하면 그냥 에러가 발생하고 끝나버립니다. 고객 입장에서는 "주문 실패"라는 메시지만 보게 됩니다.

박디비 씨가 코드를 보더니 말합니다. "재시도 로직이 없네요.

충돌이 발생하면 자동으로 다시 시도해야 합니다." 김데이터 씨는 고개를 끄덕입니다. "그냥 while문으로 계속 재시도하면 되나요?" 박디비 씨가 손을 내젓습니다.

"그렇게 하면 안 돼요. **지수 백오프(Exponential Backoff)**를 사용해야 합니다." 지수 백오프란 무엇일까요?

쉽게 비유하자면 엘리베이터를 기다리는 것과 같습니다. 첫 번째로 버튼을 눌렀을 때 엘리베이터가 오지 않으면 잠깐 기다립니다.

두 번째로 눌렀을 때도 오지 않으면 조금 더 오래 기다립니다. 계속 안 오면 점점 더 오래 기다립니다.

무작정 버튼을 연타하지 않는 것입니다. 왜 이런 방식이 필요할까요?

충돌이 발생했다는 것은 다른 트랜잭션이 같은 데이터를 수정 중이라는 의미입니다. 즉시 재시도하면 또 다시 충돌할 가능성이 높습니다.

잠깐 기다려주면 다른 트랜잭션이 완료될 시간을 벌어줍니다. 더 중요한 이유가 있습니다.

만약 수십 개의 트랜잭션이 동시에 충돌하면 어떻게 될까요? 모두 즉시 재시도하면 또 다시 모두 충돌합니다.

이것이 반복되면 아무도 성공하지 못합니다. 이런 현상을 **라이브락(Livelock)**이라고 합니다.

지수 백오프는 이 문제를 해결합니다. 각 트랜잭션이 조금씩 다른 시간만큼 기다리므로 재시도 타이밍이 분산됩니다.

그러면 자연스럽게 충돌이 줄어듭니다. 위의 코드를 자세히 살펴보겠습니다.

함수는 최대 재시도 횟수를 파라미터로 받습니다. 기본값은 5번입니다.

for문으로 재시도를 반복하는데, 각 시도는 try-except로 감싸져 있습니다. try 블록 안에서는 일반적인 낙관적 동시성 제어 로직을 수행합니다.

성공하면 즉시 True를 반환하고 함수가 종료됩니다. except 블록이 핵심입니다.

먼저 마지막 시도인지 확인합니다. 마지막 시도에서도 실패하면 예외를 상위로 전달합니다.

무한정 재시도할 수는 없기 때문입니다. 대기 시간 계산이 흥미롭습니다.

"2의 attempt 제곱 곱하기 100밀리초"로 계산합니다. 첫 번째 재시도는 100ms, 두 번째는 200ms, 세 번째는 400ms 이런 식으로 증가합니다.

이것이 지수 백오프입니다. 여기에 랜덤 지터를 추가합니다.

0에서 100ms 사이의 랜덤한 시간을 더합니다. 왜 랜덤 값을 추가할까요?

여러 트랜잭션이 정확히 같은 타이밍에 재시도하는 것을 방지하기 위해서입니다. 실제 현업에서는 어떻게 활용할까요?

AWS DynamoDB는 내부적으로 지수 백오프를 사용합니다. 클라이언트 SDK에 재시도 로직이 이미 구현되어 있습니다.

개발자는 그냥 사용하기만 하면 됩니다. Google Cloud Spanner도 마찬가지입니다.

Delta Lake를 사용할 때는 직접 재시도 로직을 구현해야 합니다. 하지만 위의 코드처럼 함수로 한 번만 작성하면 여러 곳에서 재사용할 수 있습니다.

대형 이커머스 사이트의 플래시 세일을 생각해봅시다. 수천 명이 동시에 같은 상품을 주문합니다.

재시도 메커니즘이 없다면 대부분의 주문이 실패할 것입니다. 하지만 지수 백오프를 사용하면 자연스럽게 주문이 분산되어 처리됩니다.

주의할 점도 있습니다. 재시도 횟수를 너무 많이 설정하면 응답 시간이 길어집니다.

사용자는 계속 기다려야 합니다. 일반적으로 3-5번 정도가 적당합니다.

그 이상 재시도해도 성공 확률은 크게 높아지지 않습니다. 또한 모든 에러에 대해 재시도해서는 안 됩니다.

네트워크 오류나 동시성 충돌은 재시도할 만하지만, 데이터 검증 실패나 권한 오류는 재시도해도 소용없습니다. 에러 타입을 구분해서 처리해야 합니다.

김데이터 씨는 재시도 로직을 추가한 후 다시 테스트했습니다. 이제 동시 주문이 몰려도 대부분 성공합니다.

박디비 씨가 칭찬합니다. "잘했어요.

이제 실전에서도 문제없을 거예요." 충돌 해결과 재시도 메커니즘을 제대로 구현하면 높은 동시성 상황에서도 안정적으로 동작하는 시스템을 만들 수 있습니다.

실전 팁

💡 - 지수 백오프로 대기 시간을 점진적으로 늘리세요

  • 랜덤 지터를 추가해서 재시도 타이밍을 분산시키세요
  • 최대 재시도 횟수를 설정해서 무한 루프를 방지하세요

4. 트랜잭션 격리 수준

어느 날 김데이터 씨는 이상한 버그를 발견했습니다. 재고 조회와 주문 처리 사이에 데이터가 바뀌는 것 같습니다.

박디비 씨가 말합니다. "격리 수준을 확인해봐야겠네요."

**트랜잭션 격리 수준(Isolation Level)**은 동시에 실행되는 트랜잭션들이 서로 얼마나 격리되는지를 정의합니다. Read Uncommitted, Read Committed, Repeatable Read, Serializable 네 단계가 있으며, 수준이 높을수록 격리는 강하지만 동시성은 낮아집니다.

다음 코드를 살펴봅시다.

from pyspark.sql import SparkSession
from delta.tables import DeltaTable

# Delta Lake는 기본적으로 Snapshot Isolation 제공
spark = SparkSession.builder \
    .config("spark.databricks.delta.properties.defaults.isolationLevel", "WriteSerializable") \
    .getOrCreate()

def read_committed_example():
    """Read Committed: 커밋된 데이터만 읽기"""
    # 트랜잭션 1: 재고 조회
    inventory = spark.read.format("delta").load("/data/inventory")
    product_stock = inventory.filter("product_id = 'P001'").first()

    # 이 시점에 다른 트랜잭션이 재고를 변경할 수 있음
    # 하지만 커밋되기 전까지는 보이지 않음

    # 트랜잭션 2에서 변경 후 커밋됨

    # 다시 읽으면 변경된 데이터가 보임
    updated_inventory = spark.read.format("delta").load("/data/inventory")
    updated_stock = updated_inventory.filter("product_id = 'P001'").first()

    # product_stock과 updated_stock은 다를 수 있음 (Non-Repeatable Read)
    print(f"처음 읽은 재고: {product_stock['quantity']}")
    print(f"다시 읽은 재고: {updated_stock['quantity']}")

김데이터 씨는 또 다른 버그를 발견했습니다. 재고를 조회했을 때는 10개가 남아있었는데, 주문을 처리하려고 다시 확인하니 5개로 줄어있었습니다.

분명히 같은 트랜잭션 안에서 읽었는데 값이 달라진 것입니다. 박디비 씨가 코드를 보더니 고개를 끄덕입니다.

"이건 격리 수준(Isolation Level) 문제예요. ACID의 I, 바로 Isolation입니다." 김데이터 씨는 격리 수준이라는 단어를 처음 들어봅니다.

"그게 뭔가요?" 박디비 씨가 설명을 시작합니다. "격리 수준이란 동시에 실행되는 여러 트랜잭션이 서로 얼마나 격리되는지를 나타냅니다.

마치 회의실 벽이 얼마나 두꺼운지와 같아요." 격리 수준에는 네 가지 단계가 있습니다. 가장 낮은 수준은 Read Uncommitted입니다.

이것은 마치 벽이 없는 오픈 오피스와 같습니다. 다른 트랜잭션이 아직 커밋하지 않은 데이터도 볼 수 있습니다.

이를 Dirty Read라고 합니다. 만약 그 트랜잭션이 롤백되면 이미 읽은 데이터는 실제로 존재하지 않는 유령 데이터가 됩니다.

두 번째 수준은 Read Committed입니다. 이것은 얇은 벽이 있는 회의실과 같습니다.

커밋된 데이터만 볼 수 있습니다. Dirty Read는 발생하지 않습니다.

하지만 같은 트랜잭션 안에서 같은 데이터를 두 번 읽으면 다른 값이 나올 수 있습니다. 이를 Non-Repeatable Read라고 합니다.

세 번째 수준은 Repeatable Read입니다. 이것은 두꺼운 방음벽이 있는 회의실입니다.

트랜잭션이 시작된 후에는 같은 데이터를 몇 번을 읽어도 항상 같은 값이 나옵니다. 하지만 범위 조회를 하면 새로운 행이 나타날 수 있습니다.

이를 Phantom Read라고 합니다. 가장 높은 수준은 Serializable입니다.

이것은 완전히 격리된 독립 공간입니다. 마치 트랜잭션들이 순차적으로 하나씩 실행되는 것처럼 동작합니다.

가장 안전하지만 동시성이 가장 낮습니다. 위의 코드를 살펴보겠습니다.

Delta Lake는 기본적으로 Snapshot Isolation을 제공합니다. 이것은 Repeatable Read와 비슷합니다.

트랜잭션이 시작될 때의 스냅샷을 읽으므로 일관된 데이터를 볼 수 있습니다. 코드의 첫 부분에서 재고를 조회합니다.

이때 10개가 있다고 가정합시다. 그 사이에 다른 트랜잭션이 재고를 5개로 변경하고 커밋합니다.

Read Committed 수준에서는 다시 조회하면 5개가 보입니다. 이것이 김데이터 씨가 겪은 문제입니다.

같은 트랜잭션 안에서 읽은 값이 달라진 것입니다. 이를 방지하려면 격리 수준을 높여야 합니다.

Delta Lake에서는 WriteSerializable 옵션을 사용할 수 있습니다. 이렇게 하면 쓰기 작업이 직렬화되어 충돌이 줄어듭니다.

실제 현업에서는 어떻게 활용할까요? 대부분의 데이터베이스는 Read Committed를 기본값으로 사용합니다.

PostgreSQL, MySQL, SQL Server 모두 마찬가지입니다. 이것이 성능과 격리의 적절한 균형점이기 때문입니다.

하지만 금융 시스템처럼 데이터 정확성이 극도로 중요한 경우에는 Serializable을 사용합니다. 계좌 잔액이 같은 트랜잭션 안에서 달라지면 안 되기 때문입니다.

반대로 실시간 분석 시스템처럼 완벽한 정확성보다 속도가 중요한 경우에는 Read Uncommitted를 사용하기도 합니다. 약간의 오차는 허용되지만 빠른 응답이 필요한 경우입니다.

Delta Lake의 Time Travel 기능을 활용하면 특정 시점의 스냅샷을 읽을 수 있습니다. 이를 통해 Repeatable Read와 비슷한 효과를 얻을 수 있습니다.

주의할 점이 있습니다. 격리 수준을 높이면 동시성이 낮아집니다.

Serializable 수준에서는 트랜잭션들이 거의 순차적으로 실행되므로 처리량이 크게 떨어질 수 있습니다. 따라서 꼭 필요한 경우에만 높은 격리 수준을 사용해야 합니다.

또한 격리 수준은 데이터베이스마다 구현 방식이 다릅니다. PostgreSQL의 Repeatable Read와 MySQL의 Repeatable Read는 동작이 조금 다를 수 있습니다.

사용하는 데이터베이스의 문서를 꼭 확인해야 합니다. 김데이터 씨는 이제 격리 수준을 이해했습니다.

박디비 씨가 조언합니다. "재고 확인과 주문 처리를 하나의 트랜잭션으로 묶고, 격리 수준을 Repeatable Read로 높이면 문제가 해결될 거예요." 트랜잭션 격리 수준을 제대로 이해하면 데이터 일관성과 동시성 사이의 적절한 균형을 찾을 수 있습니다.

실전 팁

💡 - 대부분의 경우 Read Committed로 충분합니다

  • 금융 데이터처럼 정확성이 중요하면 Serializable을 고려하세요
  • 격리 수준을 높이면 성능이 저하되므로 신중하게 선택하세요

5. 원자적 쓰기 작업

김데이터 씨는 대용량 배치 작업을 개발하고 있습니다. 수백만 건의 데이터를 업데이트하는데 중간에 실패하면 어떻게 될까요?

박디비 씨가 말합니다. "원자적 쓰기를 사용하면 걱정 없어요."

**원자적 쓰기(Atomic Write)**는 여러 개의 쓰기 작업을 하나의 단위로 묶어서 전부 성공하거나 전부 실패하도록 보장하는 기법입니다. Delta Lake는 트랜잭션 로그를 활용해서 대용량 데이터 쓰기도 원자적으로 처리할 수 있습니다.

다음 코드를 살펴봅시다.

from delta.tables import DeltaTable
from pyspark.sql.functions import col, when

# 원자적 쓰기: 100만 건 업데이트도 한 번에
def atomic_batch_update():
    """대용량 배치 업데이트를 원자적으로 처리"""
    delta_table = DeltaTable.forPath(spark, "/data/orders")

    # 조건: 주문 상태가 'PENDING'인 주문들을 'PROCESSING'으로 변경
    # 100만 건이 있어도 전부 성공하거나 전부 실패
    try:
        delta_table.update(
            condition="status = 'PENDING' AND created_at < '2024-01-01'",
            set={
                "status": "'PROCESSING'",
                "updated_at": "current_timestamp()",
                "processor_id": "'BATCH_001'"
            }
        )
        print("100만 건 업데이트 성공 (원자적)")

    except Exception as e:
        # 실패하면 아무것도 변경되지 않음
        print(f"업데이트 실패, 롤백됨: {e}")

# 원자적 대량 삽입
def atomic_bulk_insert(new_data_df):
    """DataFrame 전체를 원자적으로 삽입"""
    # 10만 건의 새 데이터를 한 번에 삽입
    new_data_df.write.format("delta").mode("append").save("/data/orders")
    # 모두 삽입되거나 아무것도 삽입되지 않음

김데이터 씨는 새로운 과제를 받았습니다. 매일 밤 배치 작업으로 수백만 건의 주문 데이터를 업데이트해야 합니다.

코드를 작성하고 테스트를 돌렸는데, 중간에 서버가 재시작되면서 작업이 중단되었습니다. 다음 날 아침, 데이터를 확인한 김데이터 씨는 깜짝 놀랐습니다.

일부 데이터는 업데이트되었고 일부는 그대로였습니다. 이런 상태를 **부분 실패(Partial Failure)**라고 합니다.

데이터가 일관성 없는 상태에 빠진 것입니다. 박디비 씨가 모니터를 보더니 고개를 저었습니다.

"이렇게 하면 안 돼요. **원자적 쓰기(Atomic Write)**를 사용해야 합니다." 원자적 쓰기란 무엇일까요?

쉽게 비유하자면 택배 배송과 같습니다. 상자 안에 물건이 10개 있다면 10개 모두 배송되거나 아무것도 배송되지 않아야 합니다.

물건 3개만 배송되고 나머지는 분실되면 안 됩니다. 원자적 쓰기도 마찬가지입니다.

100만 건을 쓴다면 100만 건 모두 성공하거나 아무것도 쓰지 않아야 합니다. 전통적인 파일 시스템에서는 이것이 어렵습니다.

파일에 데이터를 쓰다가 중간에 실패하면 반쯤 쓰인 파일이 남습니다. 그럼 데이터가 망가집니다.

Delta Lake는 이 문제를 어떻게 해결할까요? 비밀은 **트랜잭션 로그(Transaction Log)**에 있습니다.

Delta Lake는 데이터를 직접 수정하지 않습니다. 대신 새로운 파일을 만들고, 트랜잭션 로그에 "새로운 파일을 추가했다"는 기록을 남깁니다.

이 로그 기록이 완료되어야 트랜잭션이 커밋됩니다. 트랜잭션 로그는 작은 JSON 파일입니다.

파일 쓰기는 원자적입니다. 파일이 완전히 쓰이거나 아예 쓰이지 않습니다.

따라서 트랜잭션 로그가 성공적으로 쓰이면 전체 트랜잭션이 성공한 것이고, 실패하면 아무 일도 일어나지 않은 것입니다. 위의 코드를 살펴보겠습니다.

첫 번째 함수는 대량 업데이트를 보여줍니다. 조건에 맞는 주문들을 한 번에 업데이트합니다.

100만 건이 있어도 Delta Lake는 이를 하나의 트랜잭션으로 처리합니다. 내부적으로 어떤 일이 일어날까요?

Delta Lake는 업데이트할 데이터를 새로운 Parquet 파일로 씁니다. 그리고 트랜잭션 로그에 "이 파일을 추가하고 저 파일을 제거한다"는 기록을 남깁니다.

이 로그가 성공적으로 쓰이는 순간 100만 건의 업데이트가 모두 완료됩니다. 만약 중간에 실패하면 어떻게 될까요?

새로 쓴 Parquet 파일은 그냥 고아 파일로 남습니다. 트랜잭션 로그에 기록되지 않았으므로 아무도 그 파일을 보지 않습니다.

나중에 VACUUM 명령으로 정리하면 됩니다. 두 번째 함수는 대량 삽입을 보여줍니다.

DataFrame 전체를 한 번에 Delta 테이블에 추가합니다. 10만 건이든 1000만 건이든 모두 원자적으로 처리됩니다.

실제 현업에서는 어떻게 활용할까요? 데이터 웨어하우스의 ETL 파이프라인을 생각해봅시다.

매시간 수백만 건의 로그 데이터를 수집해서 가공합니다. 이 작업이 중간에 실패하면 데이터가 중복되거나 누락될 수 있습니다.

Delta Lake의 원자적 쓰기를 사용하면 이런 문제가 없습니다. 작업이 성공하면 모든 데이터가 정확히 한 번 저장됩니다.

실패하면 아무것도 저장되지 않으므로 다시 시도하면 됩니다. 이를 정확히 한 번(Exactly Once) 시맨틱이라고 합니다.

Uber나 Netflix 같은 대형 테크 기업들이 Delta Lake를 사용하는 이유 중 하나가 바로 이 원자적 쓰기 기능입니다. 페타바이트 규모의 데이터를 다루면서도 ACID 보장이 가능합니다.

주의할 점도 있습니다. 원자적 쓰기라고 해서 무한정 큰 트랜잭션을 만들면 안 됩니다.

트랜잭션이 너무 크면 메모리가 부족할 수 있고, 실패 시 재시도 비용이 큽니다. 일반적으로 수백만 건에서 수천만 건 정도가 적당합니다.

또한 동시에 같은 테이블에 쓰는 여러 트랜잭션이 있으면 충돌이 발생할 수 있습니다. 이때는 낙관적 동시성 제어와 재시도 메커니즘이 함께 작동합니다.

파티셔닝을 잘 설계하면 충돌을 줄일 수 있습니다. 예를 들어 날짜별로 파티션을 나누면 오늘 데이터를 쓰는 트랜잭션과 어제 데이터를 쓰는 트랜잭션은 충돌하지 않습니다.

김데이터 씨는 코드를 수정해서 Delta Lake를 사용하도록 바꿨습니다. 이제 배치 작업이 중간에 실패해도 걱정이 없습니다.

박디비 씨가 칭찬합니다. "이제 프로덕션에 배포해도 되겠네요." 원자적 쓰기를 제대로 활용하면 대용량 데이터 파이프라인도 안전하게 운영할 수 있습니다.

실전 팁

💡 - Delta Lake의 트랜잭션 로그는 자동으로 원자적 쓰기를 보장합니다

  • 대량 업데이트도 하나의 트랜잭션으로 처리하세요
  • 트랜잭션 크기는 수백만~수천만 건 정도가 적당합니다

6. 다중 사용자 환경에서의 안정성

김데이터 씨의 시스템이 드디어 프로덕션에 배포되었습니다. 그런데 여러 팀이 동시에 같은 데이터를 읽고 쓰면서 충돌이 발생하기 시작했습니다.

박디비 씨가 말합니다. "다중 사용자 환경을 고려해야 합니다."

다중 사용자 환경에서는 여러 사용자나 프로세스가 동시에 같은 데이터에 접근합니다. 이때 읽기-쓰기 충돌, 쓰기-쓰기 충돌이 발생할 수 있습니다.

Delta Lake는 **MVCC(Multi-Version Concurrency Control)**를 통해 읽기는 차단하지 않으면서 쓰기의 일관성을 보장합니다.

다음 코드를 살펴봅시다.

from delta.tables import DeltaTable
from pyspark.sql.functions import current_timestamp
import concurrent.futures

def concurrent_read_write_example():
    """다중 사용자 환경: 읽기와 쓰기 동시 처리"""

    # 읽기 작업: 쓰기 중에도 계속 읽을 수 있음
    def reader_task(reader_id):
        for i in range(5):
            df = spark.read.format("delta").load("/data/analytics")
            count = df.count()
            print(f"Reader {reader_id}: {count} rows at attempt {i}")
            time.sleep(0.5)

    # 쓰기 작업: 새로운 버전 생성
    def writer_task(writer_id):
        delta_table = DeltaTable.forPath(spark, "/data/analytics")
        for i in range(3):
            delta_table.update(
                condition=f"user_id % 2 = {writer_id}",
                set={"last_updated": "current_timestamp()"}
            )
            print(f"Writer {writer_id}: updated at attempt {i}")
            time.sleep(1)

    # 동시 실행: 3개의 읽기, 2개의 쓰기
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        # 읽기 작업 제출
        read_futures = [executor.submit(reader_task, i) for i in range(3)]
        # 쓰기 작업 제출
        write_futures = [executor.submit(writer_task, i) for i in range(2)]

        # 모든 작업 완료 대기
        concurrent.futures.wait(read_futures + write_futures)

    print("모든 작업 완료: 읽기는 차단되지 않았고 쓰기는 일관성 유지")

김데이터 씨의 시스템이 인기를 끌면서 사용자가 급증했습니다. 데이터 분석팀, 머신러닝팀, BI팀이 모두 같은 데이터를 사용합니다.

분석가들은 계속 데이터를 읽고, 엔지니어들은 새로운 데이터를 쓰고, 배치 작업은 주기적으로 업데이트합니다. 어느 날 분석가에게서 불만이 들어왔습니다.

"쿼리가 계속 실패해요." 김데이터 씨가 로그를 확인하니 "concurrent modification" 에러가 가득합니다. 여러 사용자가 동시에 접근하면서 충돌이 발생한 것입니다.

박디비 씨가 화이트보드 앞에 섭니다. "이건 다중 사용자 환경(Multi-User Environment) 문제예요.

여러 명이 동시에 사용하면 이런 일이 생깁니다." 다중 사용자 환경에서는 어떤 문제가 발생할까요? 첫 번째는 읽기-쓰기 충돌입니다.

누군가 데이터를 읽고 있는데 다른 사람이 그 데이터를 수정하면 어떻게 될까요? 전통적인 데이터베이스에서는 쓰기가 완료될 때까지 읽기가 차단됩니다.

분석 쿼리가 1시간씩 걸린다면 그동안 아무도 데이터를 업데이트할 수 없습니다. 두 번째는 쓰기-쓰기 충돌입니다.

두 명이 동시에 같은 데이터를 수정하려고 하면 하나는 실패해야 합니다. 그렇지 않으면 변경사항이 섞여서 데이터가 망가집니다.

세 번째는 장시간 트랜잭션입니다. 배치 작업이 몇 시간씩 걸리는 동안 다른 사용자들은 기다려야 할까요?

그럼 시스템을 사용할 수 없게 됩니다. Delta Lake는 이 모든 문제를 **MVCC(Multi-Version Concurrency Control)**로 해결합니다.

MVCC란 무엇일까요? 쉽게 비유하자면 위키백과와 같습니다.

위키백과에는 문서의 여러 버전이 있습니다. 누군가 문서를 수정하는 동안에도 다른 사람들은 이전 버전을 계속 볼 수 있습니다.

수정이 완료되면 새 버전이 만들어집니다. Delta Lake도 똑같이 작동합니다.

데이터의 여러 버전을 유지합니다. 쓰기 작업은 새로운 버전을 만들고, 읽기 작업은 자신이 시작한 시점의 버전을 봅니다.

따라서 읽기와 쓰기가 서로 차단하지 않습니다. 위의 코드를 자세히 살펴보겠습니다.

reader_task 함수는 읽기 작업을 시뮬레이션합니다. 반복적으로 데이터를 읽고 카운트합니다.

실제로는 복잡한 분석 쿼리일 수 있습니다. writer_task 함수는 쓰기 작업을 시뮬레이션합니다.

주기적으로 데이터를 업데이트합니다. 각 writer는 서로 다른 조건으로 업데이트하므로 충돌이 덜 발생합니다.

핵심은 ThreadPoolExecutor를 사용해서 동시에 실행하는 부분입니다. 3개의 읽기 작업과 2개의 쓰기 작업이 동시에 진행됩니다.

전통적인 시스템이라면 서로 차단하겠지만, Delta Lake에서는 모두 원활하게 진행됩니다. 읽기 작업들은 쓰기가 진행되는 동안에도 차단되지 않습니다.

각 읽기는 자신이 시작한 시점의 스냅샷을 봅니다. 쓰기가 새로운 버전을 만들어도 이미 시작된 읽기에는 영향을 주지 않습니다.

쓰기 작업들은 서로 충돌할 수 있습니다. 만약 같은 파일을 수정하려고 하면 낙관적 동시성 제어에 의해 하나는 실패하고 재시도됩니다.

하지만 위의 코드처럼 서로 다른 조건으로 업데이트하면 충돌이 적습니다. 실제 현업에서는 어떻게 활용할까요?

Airbnb의 데이터 플랫폼을 예로 들어봅시다. 수천 명의 데이터 과학자와 엔지니어가 같은 데이터 레이크를 사용합니다.

누군가는 실시간으로 데이터를 쓰고, 누군가는 배치로 집계하고, 누군가는 머신러닝 모델을 학습시킵니다. Delta Lake의 MVCC 덕분에 이 모든 작업이 동시에 가능합니다.

읽기는 절대 차단되지 않으므로 분석가들은 언제든 쿼리를 실행할 수 있습니다. 쓰기는 격리되므로 데이터 무결성이 보장됩니다.

Time Travel 기능도 큰 도움이 됩니다. 실수로 데이터를 잘못 업데이트했다면 이전 버전으로 돌아갈 수 있습니다.

30일 전 데이터를 보고 싶다면 버전을 지정해서 조회하면 됩니다. 주의할 점도 있습니다.

너무 많은 버전이 쌓이면 스토리지 비용이 증가합니다. 주기적으로 VACUUM 명령을 실행해서 오래된 버전을 정리해야 합니다.

일반적으로 7일이나 30일 이상 된 버전은 삭제합니다. 쓰기가 너무 자주 발생하면 작은 파일이 많이 생깁니다.

이를 small file problem이라고 합니다. OPTIMIZE 명령으로 주기적으로 작은 파일들을 병합해야 합니다.

파티셔닝 전략도 중요합니다. 날짜별로 파티션을 나누면 오늘 데이터에 대한 쓰기와 어제 데이터에 대한 쓰기가 충돌하지 않습니다.

사용자별로 파티션을 나누는 것도 좋은 전략입니다. 김데이터 씨는 이제 시스템을 다중 사용자 환경에 최적화했습니다.

파티셔닝을 개선하고, VACUUM과 OPTIMIZE를 스케줄링했습니다. 박디비 씨가 만족스럽게 웃습니다.

"이제 진짜 엔터프라이즈급 시스템이 됐네요." 다중 사용자 환경에서의 안정성을 확보하면 대규모 조직에서도 안심하고 사용할 수 있는 데이터 플랫폼을 만들 수 있습니다. 김데이터 씨도 이제 자신감이 생겼습니다.

며칠 후 박디비 씨가 김데이터 씨를 불렀습니다. "다음 주부터 ACID 트랜잭션 교육을 맡아줄 수 있겠어요?" 김데이터 씨는 놀라면서도 뿌듯했습니다.

몇 주 전만 해도 트랜잭션이 뭔지 몰랐는데, 이제는 다른 사람을 가르칠 수 있을 만큼 성장한 것입니다. 여러분도 오늘 배운 ACID 트랜잭션의 원리를 실제 프로젝트에 적용해 보세요.

처음에는 어렵게 느껴질 수 있지만, 하나씩 이해하다 보면 어느새 데이터 무결성 전문가가 되어 있을 것입니다.

실전 팁

💡 - MVCC를 활용해서 읽기와 쓰기를 동시에 처리하세요

  • 주기적으로 VACUUM과 OPTIMIZE를 실행해서 성능을 유지하세요
  • 파티셔닝 전략을 잘 설계해서 쓰기 충돌을 최소화하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Python#ACID#Transaction#Concurrency#DataIntegrity#Data Engineering,Big Data,Delta Lake

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.