컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
db · 04

트랜잭션

DB트랜잭션ACID

트랜잭션은 여러 개의 SQL 문을 하나의 작업 단위로 묶는 것입니다. 계좌 이체처럼 "출금"과 "입금"이 각각 하나의 SQL문이어도, 두 문장이 모두 성공하거나 모두 실패해야만 데이터가 올바른 상태를 유지합니다. 이걸 보장하기 위한 4가지 성질이 ACID이고, 트랜잭션은 보통 COMMIT(확정) 또는 ROLLBACK(취소)으로 끝납니다.

핵심 개념

원자성 (Atomicity)

트랜잭션 안의 문장은 전부 성공하거나 전부 실패합니다. "반만 적용"되는 상태는 존재하지 않습니다.

핵심 개념

일관성 (Consistency)

트랜잭션 전후로 데이터는 정해진 규칙(제약조건과 업무 규칙)을 만족해야 합니다. 예: 두 계좌 잔액의 합은 이체 전후로 같아야 합니다.

핵심 개념

고립성 (Isolation)

동시에 여러 트랜잭션이 실행돼도 서로의 영향을 받지 않게 합니다. 커밋되지 않은 중간 상태가 얼마나 보이는지는 격리 수준에 따라 다릅니다(아래 격리 수준 참고).

핵심 개념

지속성 (Durability)

기본 설정에서 COMMIT이 끝나면 그 결과는 디스크(로그)에 남습니다. 이후 시스템이 다운되어도 사라지지 않습니다.

핵심 개념

COMMIT

트랜잭션 안의 모든 문장이 성공했을 때, 임시 반영된 변경을 영구적으로 확정합니다. COMMIT 이후에는 되돌릴 수 없습니다.

핵심 개념

ROLLBACK

문장 중 하나라도 실패해 ROLLBACK하면(또는 DB가 트랜잭션을 중단하면), 이미 실행됐던 것까지 포함해 트랜잭션 시작 시점 상태로 되돌립니다.

Spring의 @Transactional이 바로 이것 — 이 프로젝트가 Service 메서드 대부분에 @Transactional을 붙이는 이유가 여기 있습니다. 메서드 안에서 런타임 예외가 발생하면 Spring이 기본적으로 ROLLBACK을 호출해줍니다(검사 예외는 기본적으로 롤백하지 않습니다).
"임시 반영"이라는 표현에 주목하세요 — BEGIN 이후 UPDATE는 실제로 실행되지만, 다른 트랜잭션(고립성) 눈에는 보통 아직 보이지 않고(READ UNCOMMITTED를 실제로 지원하는 DB는 예외), COMMIT 전이라면 언제든 취소될 수 있는 상태입니다.
합계로 일관성을 확인하세요 — 커밋 탭과 롤백 탭 모두, 두 계좌 잔액의 합이 트랜잭션 전후로 정확히 같습니다(80,000원). 이것이 "일관성"이 실제로 지켜지는 모습입니다.
이 시각화에서 확인할 것 — "ACID" 탭에서 네 성질을 먼저 훑고, "커밋(성공)"과 "롤백(실패)" 탭에서 같은 송금 시나리오가 어떻게 다르게 끝나는지, 특히 롤백 후 잔액이 시작 전과 완전히 같아지는 과정을 확인하세요.

트랜잭션 개념의 배경

트랜잭션 개념은 1970~80년대 데이터베이스 연구에서 다듬어졌습니다. 짐 그레이(Jim Gray)는 1981년 글에서 트랜잭션의 개념과 한계를 정리했고, ACID라는 이름은 1983년 헤어더(Haerder)와 로이터(Reuter)의 논문에서 붙은 것으로 알려져 있습니다. 지속성은 보통 WAL(Write-Ahead Logging)으로 구현하고, 원자성은 로그(undo 정보)나 다중 버전 같은 방식으로 구현하며 DB마다 다릅니다. WAL은 데이터 페이지를 고치기 전에 변경 내용을 로그에 먼저 기록하고, 기본 설정에서 커밋은 그 로그가 디스크에 남은 뒤에 확정합니다.

격리 수준과 이상 현상

고립성을 완전히 지키면 성능이 떨어지므로, SQL 표준은 네 가지 격리 수준을 정의하고 수준마다 허용하는 이상 현상이 다릅니다. 더티 리드는 커밋되지 않은 값을 읽는 것, 반복 불가능한 읽기는 같은 행을 두 번 읽었는데 값이 달라지는 것, 팬텀 리드는 같은 조건으로 두 번 조회했는데 행의 집합이 달라지는 것입니다.

READ UNCOMMITTED — 표준에서는 더티 리드까지 허용합니다. PostgreSQL은 이 수준을 요청해도 READ COMMITTED처럼 동작합니다.
READ COMMITTED — 커밋된 값만 읽습니다. 반복 불가능한 읽기와 팬텀은 생길 수 있습니다. PostgreSQL의 기본값입니다.
REPEATABLE READ — 같은 행을 다시 읽어도 같은 값이 보입니다. 표준에서는 팬텀을 허용하지만 PostgreSQL의 이 수준은 팬텀도 막습니다. MySQL InnoDB의 기본값입니다.
SERIALIZABLE — 트랜잭션을 하나씩 순서대로 실행한 것과 같은 결과를 보장합니다. 그만큼 충돌해서 실패하는 트랜잭션이 늘 수 있어, 실패하면 다시 시도하도록 짜야 합니다.

자주 하는 오해

트랜잭션은 SQL 한 문장이다 — 여러 문장을 하나로 묶는 단위입니다. 트랜잭션을 따로 열지 않아도 DB는 문장 하나를 그 자체로 트랜잭션처럼 자동 커밋(autocommit)으로 처리합니다.
ACID의 일관성은 DB가 알아서 지켜 준다 — DB는 제약조건(기본키, 외래키, CHECK 등)을 어기는 변경을 막을 뿐입니다. 이체 전후의 합계가 같아야 한다 같은 업무 규칙은 개발자가 트랜잭션 안에서 지켜야 합니다.
격리 수준이 높을수록 무조건 좋다 — 높은 수준일수록 동시에 처리할 수 있는 양이 줄거나 충돌 재시도가 늘어납니다. 필요한 이상 현상만 막는 수준을 고릅니다.
@Transactional이면 어떤 예외든 롤백된다 — Spring은 기본적으로 런타임 예외(RuntimeException)와 Error에서만 롤백하고, 검사 예외는 rollbackFor를 지정하지 않으면 롤백하지 않습니다.
같은 클래스 안에서 호출해도 @Transactional이 적용된다 — 프록시 방식에서는 객체 밖에서 들어오는 호출만 가로채므로, 같은 클래스 안에서 자기 메서드를 부르면 어노테이션이 동작하지 않습니다.

면접에서 이렇게 물어요

면접 질문

트랜잭션 격리 수준 네 가지와 각각이 막는 이상 현상은 무엇인가요?

READ UNCOMMITTED는 아무것도 막지 않고, READ COMMITTED는 더티 리드를, REPEATABLE READ는 여기에 반복 불가능한 읽기를, SERIALIZABLE은 팬텀까지 막는다는 것이 표준의 설명입니다. DB마다 구현이 달라 같은 이름이어도 막는 범위가 다를 수 있습니다.

면접 질문

COMMIT 이후 장애가 나도 데이터가 남는 이유는 무엇인가요?

WAL 때문입니다. 변경 내용을 로그에 먼저 기록하고 기본 설정에서는 그 로그가 디스크에 쓰인 뒤에 커밋을 알리므로, 재시작하면 로그를 다시 적용해 커밋된 변경을 복구합니다.

면접 질문

낙관적 락과 비관적 락은 트랜잭션과 어떻게 연결되나요?

둘 다 같은 데이터를 동시에 고치는 트랜잭션끼리의 충돌을 다루는 방법입니다. 비관적 락은 DB 락으로 미리 막고, 낙관적 락은 버전이 바뀌었는지로 충돌을 감지합니다.

면접 질문

@Transactional이 동작하지 않는 흔한 경우는 언제인가요?

같은 클래스 안의 메서드를 직접 호출할 때, private 같은 프록시가 가로챌 수 없는 메서드에 붙였을 때, 검사 예외를 롤백 대상으로 지정하지 않았을 때가 대표적입니다.

실제 시스템에서는

PostgreSQL — BEGIN, COMMIT, ROLLBACK으로 트랜잭션을 다루고, SHOW transaction_isolation으로 현재 격리 수준을 봅니다. 격리 수준은 트랜잭션을 시작할 때 지정할 수 있습니다.
MySQL InnoDB — 기본 격리 수준이 REPEATABLE READ이고 SELECT @@transaction_isolation으로 확인합니다.
Spring — @Transactional(isolation = Isolation.READ_COMMITTED)처럼 격리 수준을 지정할 수 있고, 이 사이트의 서비스 메서드 대부분에도 @Transactional이 붙어 있어, 쓰기를 하는 메서드는 런타임 예외가 나면 롤백됩니다.

함께 보면 좋은 토픽

락 — 격리를 락으로 구현할 때 어떤 일이 생기는지, 데드락은 무엇인지 봅니다.
복제 — 커밋이 복제본 확인과 어떻게 이어지는지 봅니다.
NoSQL — 트랜잭션 범위를 다르게 잡는 저장소들의 선택을 봅니다.
RDBMS 기초 — 트랜잭션이 다루는 관계형 DB의 기본을 봅니다.
백업 / 복구(WAL) — 변경을 로그에 먼저 기록해 장애 뒤에도 커밋된 변경을 복구하는 방식을 봅니다.
저장 프로시저 / 트리거 — 여러 SQL 문을 서버에 저장해 두고 한 번의 호출로 실행하는 기능을 봅니다.

참고 자료

논문 — Haerder, Reuter, "Principles of transaction-oriented database recovery", ACM Computing Surveys, 1983
교재 — Silberschatz, Korth, Sudarshan, "Database System Concepts" 7판, "Transactions" 장
트랜잭션 개념 — Gray, "The Transaction Concept: Virtues and Limitations", VLDB, 1981

Java — @Transactional로 이체 서비스 구현

@Service
@RequiredArgsConstructor
public class TransferService {

    private final AccountRepository accountRepository;

    @Transactional
    public void transfer(Long fromId, Long toId, long amount) {
        Account from = accountRepository.findById(fromId)
                .orElseThrow(() -> new NotFoundException("계좌 없음: " + fromId));
        Account to = accountRepository.findById(toId)
                .orElseThrow(() -> new NotFoundException("계좌 없음: " + toId));

        from.withdraw(amount);   // 첫 번째 UPDATE에 해당

        if (to.isFrozen()) {
            throw new IllegalStateException("계좌가 정지 상태라 입금할 수 없습니다.");
        }
        to.deposit(amount);      // 두 번째 UPDATE에 해당
    }
}

withdraw() 이후 예외가 던져지면, @Transactional이 감싸고 있는 이 메서드 전체가 RuntimeException 기준으로 자동 ROLLBACK됩니다 — 이미 반영된 from.withdraw()도 함께 취소됩니다.
이 시각화의 "롤백(실패)" 탭에서 두 번째 UPDATE가 "계좌가 정지 상태"로 실패하는 것과 정확히 같은 상황입니다.

SQL — @Transactional이 내부적으로 실행하는 것

START TRANSACTION;

UPDATE accounts SET balance = balance - 20000 WHERE id = 1;
UPDATE accounts SET balance = balance + 20000 WHERE id = 2;

-- 둘 다 성공하면:
COMMIT;

-- 하나라도 실패하면(애플리케이션 예외 포함):
ROLLBACK;

@Transactional이 붙은 메서드에 진입하는 순간 START TRANSACTION이, 정상 종료되면 COMMIT이, 예외가 던져지면 ROLLBACK이 자동으로 실행됩니다.
즉 개발자가 직접 COMMIT/ROLLBACK을 호출하지 않아도, 메서드의 정상/예외 종료 여부만으로 트랜잭션 경계가 결정됩니다.

Java — 흔한 함정: 같은 클래스 안에서 호출하면 롤백이 안 걸림

@Service
public class OrderService {

    public void placeOrder(Long userId) {
        // this.saveOrder(userId) — 프록시를 거치지 않는 내부 호출!
        saveOrder(userId);          // @Transactional이 적용되지 않는다
    }

    @Transactional
    public void saveOrder(Long userId) {
        // ...
    }
}

@Transactional은 스프링이 만든 프록시 객체를 거쳐야 동작하는데, 같은 클래스 안에서 this.saveOrder()처럼 직접 호출하면 프록시를 우회해서 트랜잭션이 아예 시작되지 않습니다.
해결책은 saveOrder()를 별도의 @Service로 분리해서 주입받아 호출하는 것입니다 — 이 프로젝트의 생성자 주입(@RequiredArgsConstructor) 규칙과도 자연스럽게 맞물립니다.

Q1

ACID 중 '원자성(Atomicity)'의 의미는?

'더 이상 쪼갤 수 없다'는 원자(atom)의 의미와 비슷합니다.
원자성은 트랜잭션 내의 모든 연산이 전부 성공적으로 반영되거나, 하나라도 실패하면 전체가 롤백되어 아무 일도 없었던 것처럼 되어야 한다는 성질입니다.

Q2

COMMIT과 ROLLBACK의 차이는?

하나는 '확정', 하나는 '되돌리기'입니다.
COMMIT은 트랜잭션에서 수행한 변경사항을 데이터베이스에 영구적으로 반영하고, ROLLBACK은 트랜잭션 시작 이전 상태로 변경사항을 취소합니다.

Q3

ACID 중 '고립성(Isolation)'이 필요한 이유는?

여러 트랜잭션이 동시에 실행되어도 결과가 꼬이면 안 됩니다.
고립성은 동시에 실행되는 트랜잭션들이 서로의 중간(커밋되지 않은) 상태를 보거나 간섭받지 않도록 해, 마치 순차적으로 실행된 것과 같은 결과를 보장합니다.