SQL · PostgreSQL · SQLite

트랜잭션으로 변경 묶기

여러 데이터 변경을 한 처리 단위로 묶고 COMMIT과 ROLLBACK의 차이를 확인합니다.

무료 공개 · 최근 수정

전원이 꺼져도 돈이 증발하지 않는 이유

사용자 A가 사용자 B에게 10만 원을 송금하는 상황을 가정해 봅니다. 애플리케이션 뒤편의 데이터베이스에서는 최소 두 번의 SQL 명령이 연속으로 실행되어야 합니다. 먼저 A의 계좌 잔고에서 10만 원을 빼는 UPDATE 문이 수행되고, 곧이어 B의 계좌 잔고에 10만 원을 더하는 UPDATE 문이 뒤따릅니다.

그런데 첫 번째 명령이 성공하여 A의 잔고가 차감된 직후, 데이터센터에 예기치 못한 정전이 발생하거나 데이터베이스 서버 프로세스가 강제 종료되었다고 가정해 봅니다. 두 번째 명령은 아예 실행되지 못했습니다. 서버가 다시 켜졌을 때 A의 통장에서 빠져나간 10만 원은 어디로 사라진 것일까요?

만약 데이터베이스가 각각의 SQL 문을 실행하는 즉시 디스크에 영구히 새겨 넣는 구조라면, 사라진 10만 원은 시스템 어디에서도 찾을 수 없게 됩니다. A의 잔고는 줄어들었는데 B의 잔고는 늘어나지 않아 두 계좌의 총합이 영원히 맞지 않는 치명적인 금융 사고로 이어집니다. 반대로 B의 계좌에 먼저 돈을 입금하고 A에게서 돈을 빼려다가 실패한다면, 세상에 없던 돈 10만 원이 무에서 창조되는 왜곡이 발생합니다.

이러한 재앙을 방지하기 위해 데이터베이스 엔진은 여러 개의 쿼리를 쪼갤 수 없는 단 하나의 작업 단위로 묶어 다루는 트랜잭션(Transaction) 기술을 제공합니다. 중간에 전원이 나가든, 네트워크 케이블이 단선되든, 애플리케이션 코드에서 예외가 터지든 상관없이, 전체 작업이 온전히 성공하지 못했다면 아무런 일도 일어나지 않았던 처음 상태로 깨끗하게 되돌립니다.

원자성과 All-or-Nothing 원칙

트랜잭션이 보장하는 여러 성질 가운데 가장 기본이 되는 개념은 원자성(Atomicity)입니다. 화학에서 더 이상 쪼갤 수 없는 입자를 원자라고 부르듯, 트랜잭션으로 묶인 연산 묶음은 중간 상태로 쪼개져 남을 수 없다는 뜻입니다. 소프트웨어 엔지니어링에서는 이를 흔히 "전부 실행되거나 아니면 전혀 실행되지 않는다(All or Nothing)"는 규칙으로 설명합니다.

원자성을 구현하기 위해 데이터베이스 엔진은 표준 SQL 제어 명령어를 사용합니다. 작업을 시작할 때는 BEGIN 명령을 선언하여 트랜잭션의 시작을 시스템에 알립니다. 이후 수행되는 모든 INSERT, UPDATE, DELETE 명령의 결과는 공유 디스크 영역에 즉시 기록되지 않고, 해당 연결만의 격리된 임시 로그 공간(Write-Ahead Logging 또는 Undo 로그)에 보관됩니다.

모든 쿼리가 정상적으로 끝나고 데이터 무결성 검증을 통과하면, 애플리케이션은 커밋(Commit) 명령을 내려 변경 사항을 디스크에 영구적으로 반영합니다. 커밋이 선언되는 순간 비로소 다른 사용자와 외부 세션이 변경된 최종 데이터를 읽을 수 있게 됩니다.

반대로 작업 도중 단 하나의 쿼리라도 외래 키 제약 조건이나 잔고 부족 검사에서 실패하거나 시스템 예외가 발생하면, 즉시 롤백(Rollback) 명령을 실행합니다. 롤백이 호출되면 트랜잭션 시작 이후 이루어진 모든 임시 변경이 일괄 폐기되며, 데이터베이스는 BEGIN을 선언하기 직전의 안정적인 상태로 완벽하게 복원됩니다.

상태 전이와 데이터의 분기

트랜잭션이 진행되는 동안 데이터베이스 내부에서 상태가 어떻게 변화하고 분기하는지 작업 순서에 따라 추적합니다.

BEGIN에서 작업 실행을 거쳐 오류 여부에 따라 COMMIT 또는 ROLLBACK으로 분기하는 트랜잭션 상태도

그림: 트랜잭션의 상태 전이와 오류 분기에 따른 데이터 복구 흐름

작업 흐름을 살펴보면 크게 세 단계의 상태 전이가 일어납니다.

  1. 활성(Active) 상태: BEGIN 명령이 호출되면 새로운 트랜잭션 식별자가 부여되고 독립된 작업 경계가 열립니다. 송금 처리를 위한 잔고 차감과 입금 쿼리가 순차적으로 실행되며, 변경 사항은 임시 로그 버퍼에만 유지됩니다.
  2. 검증 분기점: 모든 연산이 끝난 시점에 시스템은 비즈니스 로직 오류, 제약 조건 위반, 교착 상태(Deadlock) 여부를 최종 판정합니다.
  3. 확정 또는 복원: 검증에 통과하면 COMMIT 상태로 넘어가 변경 내역이 데이터베이스 파일에 영구 기록되어 A 계좌 90만 원, B 계좌 60만 원 상태가 확정됩니다. 반면 오류가 감지되면 즉시 ROLLBACK 상태로 분기하여 임시 변경을 폐기하고 A 계좌 100만 원, B 계좌 50만 원이라는 최초 데이터 상태를 안전하게 복구합니다.

Python SQLite로 재현하는 롤백과 커밋

별도의 상용 데이터베이스 서버를 설치하지 않고도, Python 내장 모듈인 sqlite3를 사용하면 메모리 데이터베이스(:memory:) 환경에서 트랜잭션의 롤백과 커밋 동작을 직접 검증할 수 있습니다.

다음 코드는 Alice와 Bob의 계좌를 만든 뒤, 고의로 예외를 발생시켜 변경을 취소하는 롤백 시나리오와 정상적으로 잔고를 이체하는 커밋 시나리오를 차례로 실행합니다.

import sqlite3
 
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
 
cursor.execute("CREATE TABLE accounts (user TEXT PRIMARY KEY, balance INTEGER)")
cursor.execute("INSERT INTO accounts VALUES ('Alice', 1000), ('Bob', 500)")
conn.commit()
 
try:
    cursor.execute("UPDATE accounts SET balance = balance - 200 WHERE user = 'Alice'")
    raise RuntimeError("결제 서버 연결 시간 초과")
    cursor.execute("UPDATE accounts SET balance = balance + 200 WHERE user = 'Bob'")
    conn.commit()
except Exception as error:
    print(f"예외 발생 감지: {error}")
    conn.rollback()
 
cursor.execute("SELECT user, balance FROM accounts ORDER BY user")
print("롤백 후 계좌 상태:", cursor.fetchall())
 
cursor.execute("UPDATE accounts SET balance = balance - 200 WHERE user = 'Alice'")
cursor.execute("UPDATE accounts SET balance = balance + 200 WHERE user = 'Bob'")
conn.commit()
 
cursor.execute("SELECT user, balance FROM accounts ORDER BY user")
print("커밋 후 계좌 상태:", cursor.fetchall())
 
conn.close()

터미널에서 위 코드를 실행하면 다음과 같은 예시 출력을 확인할 수 있습니다.

예외 발생 감지: 결제 서버 연결 시간 초과
롤백 후 계좌 상태: [('Alice', 1000), ('Bob', 500)]
커밋 후 계좌 상태: [('Alice', 800), ('Bob', 700)]

첫 번째 장애 시나리오에서는 Alice의 계좌에서 200원을 깎은 직후 예외가 발생했습니다. 만약 롤백이 없었다면 Alice의 잔고만 800원으로 줄어들었겠지만, conn.rollback()이 호출됨으로써 Alice의 잔고가 원래의 1000원으로 완벽하게 복구되었습니다. 반면 두 번째 정상 시나리오에서는 두 UPDATE 문이 모두 성공한 뒤 conn.commit()이 호출되어 Alice는 800원, Bob은 700원으로 잔고 변경이 영구 확정되었습니다.

외부 시스템 부수 효과와 보상 트랜잭션

트랜잭션 설계에서 반드시 주의해야 할 한계는 원자성의 효력이 오직 데이터베이스 엔진 내부 영역에만 머문다는 점입니다.

만약 트랜잭션 블록 안에서 외부 이메일 발송 API를 호출하거나, 푸시 알림을 보내거나, 타사 결제 대행사(PG)에 결제 승인 요청을 전송했다면 어떻게 될까요? 이후 데이터베이스 쿼리가 실패하여 ROLLBACK을 실행하더라도, 이미 통신망을 타고 사용자 휴대폰으로 발송된 문자 메시지나 결제 대행사에 승인된 청구 내역은 SQL 롤백으로 되돌릴 수 없습니다.

따라서 되돌리기 어려운 외부 I/O 작업은 데이터베이스 트랜잭션이 성공적으로 커밋된 이후로 실행 시점을 미루어야 합니다. 여러 외부 마이크로서비스가 얽혀 단일 데이터베이스 트랜잭션을 적용할 수 없는 분산 환경에서는, 실패 시 사후 취소 요청을 보내는 보상 트랜잭션(Compensating Transaction) 패턴이나 트랜잭셔널 아웃박스(Transactional Outbox) 패턴을 활용해 최종 일관성을 달성합니다.

이러한 트랜잭션의 상태 격리와 롤백 메커니즘은 장기 실행 워크플로우를 다루는 AI 에이전트 아키텍처에도 중요한 시사점을 줍니다. LangGraph에서 사람의 개입 다루기에서 활용하는 체크포인터(Checkpointer)와 상태 복구 역시, 에이전트의 도구 호출과 추론 경로를 단계별로 기록해 두었다가 오류나 의도치 않은 분기가 발생했을 때 안전한 직전 시점으로 되돌리는 데이터베이스 트랜잭션의 원리를 응용한 것입니다. 작업의 안전한 경계를 세우고 오류 시 깨끗하게 이전 상태로 되돌리는 아키텍처 원리는 데이터베이스를 넘어 복잡한 지능형 시스템 전체의 신뢰성을 지탱하는 근간입니다.

[참고] 격리 수준(Isolation Level)과 동시성 제어

둘 이상의 트랜잭션이 동일한 데이터에 동시에 접근할 때 발생하는 이상 현상을 방지하기 위해 데이터베이스는 Read Committed, Repeatable Read, Serializable 같은 격리 수준을 제공합니다. 격리 수준을 높일수록 데이터 정합성은 강력해지지만, 잠금(Lock) 대기로 인해 처리량이 줄어들 수 있으므로 서비스 특성에 맞춰 적절한 수준을 선택합니다.

참고 문서

설명이 어렵거나 잘못된 부분을 발견하셨나요?

문서 수정 의견 보내기