본문 바로가기
BackEnd/Spring

[SpringDB1] 트랜잭션 이해

by 개발 Blog 2026. 6. 26.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

데이터를 파일이 아닌 데이터베이스에 저장하는 가장 큰 이유 중 하나가 트랜잭션이다. 트랜잭션을 사용하면 여러 SQL을 하나의 작업처럼 묶어서, 모두 성공하면 반영하고 중간에 하나라도 실패하면 거래 전 상태로 되돌릴 수 있다.

트랜잭션과 ACID

A의 5000원을 B에게 계좌이체한다고 하면 ① A 잔고 5000원 감소 ② B 잔고 5000원 증가, 두 작업이 하나처럼 동작해야 한다. 1번만 성공하고 2번에서 실패하면 A의 돈만 사라지는 심각한 문제가 생긴다.

 

모든 작업이 성공해서 DB에 반영하는 것을 커밋(commit), 하나라도 실패해서 거래 이전으로 되돌리는 것을 롤백(rollback) 이라 한다.

트랜잭션은 ACID를 보장해야 한다.

  • 원자성(Atomicity): 트랜잭션 내 작업은 모두 성공하거나 모두 실패해야 한다.
  • 일관성(Consistency): 무결성 제약 같은 일관된 DB 상태를 유지해야 한다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션이 서로 영향을 주지 않도록 격리한다.
  • 지속성(Durability): 성공한 트랜잭션 결과는 항상 기록되며, 장애가 나도 DB 로그로 복구한다.

문제는 격리성이다. 완벽히 격리하려면 트랜잭션을 거의 순서대로 실행해야 해서 동시 처리 성능이 나빠진다. 그래서 ANSI 표준은 격리 수준을 4단계로 나눈다.

READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE

보통 READ COMMITTED 를 기준으로 사용한다.

DB 연결 구조와 세션

클라이언트(WAS, DB 툴)가 DB 서버에 연결을 요청하면 커넥션을 맺고, DB 서버는 내부에 세션 을 만든다. 이후 해당 커넥션을 통한 모든 요청은 이 세션이 실행한다.

사용자 ──▶ ┌─ 커넥션 ─┐ ──▶ ┌─ 커넥션 ─▶ 세션 ─┐
          │ 클라이언트 │     │  트랜잭션 시작     │
          │ (WAS 등)  │     │  SQL 실행         │
          └──────────┘     │  커밋 / 롤백       │
                           └─ 데이터베이스 서버 ─┘

세션은 트랜잭션을 시작하고 커밋·롤백으로 종료한 뒤, 새 트랜잭션을 다시 시작할 수 있다. 커넥션 풀이 커넥션을 10개 만들면 세션도 10개 생긴다.

커밋·롤백과 데이터 가시성

커밋을 호출하기 전까지 변경 데이터는 임시 상태 다. 트랜잭션을 시작한 세션에게만 변경 데이터가 보이고 다른 세션에는 보이지 않는다.

[커밋 전]  세션1이 수동 커밋 모드로 newId1, newId2 insert (아직 미커밋)
  세션1 조회 → oldId, newId1, newId2   ← 본인은 보임
  세션2 조회 → oldId                   ← 커밋 전이라 안 보임

[커밋 후]  세션1이 commit
  세션1 조회 → oldId, newId1, newId2
  세션2 조회 → oldId, newId1, newId2   ← 이제 보임

커밋 전 데이터가 다른 세션에 보이면 안 되는 이유가 있다. 세션2가 미커밋 데이터를 보고 어떤 로직을 수행했는데, 세션1이 롤백하면 그 데이터가 사라져 데이터 정합성이 깨진다. 롤백을 호출하면 등록·수정·삭제한 모든 데이터가 트랜잭션 시작 직전 상태로 복구된다.

자동 커밋과 수동 커밋

트랜잭션을 제대로 쓰려면 자동 커밋과 수동 커밋을 이해해야 한다. 자동 커밋은 쿼리 하나를 실행할 때마다 즉시 커밋한다. 편리하지만 여러 쿼리를 하나의 트랜잭션으로 묶을 수 없다.

set autocommit true;   -- 자동 커밋: 쿼리마다 바로 커밋
insert into member(member_id, money) values ('data1',10000);  -- 커밋됨

commit, rollback을 직접 호출하려면 자동 커밋을 끄고 수동 커밋으로 전환한다.

set autocommit false;  -- 수동 커밋 = 트랜잭션 시작
insert into member(member_id, money) values ('data3',10000);
commit;                -- 직접 커밋

보통 자동 커밋이 기본이므로, 수동 커밋으로 바꾸는 것을 트랜잭션을 시작한다 고 표현한다. 한 번 설정하면 해당 세션에서 계속 유지된다.

계좌이체로 보는 원자성

memberA가 memberB에게 2000원 이체하는 도중, 두 번째 update에 컬럼 오타(member_iddd)가 있어 예외가 발생한다고 하자.

set autocommit false;
update member set money=10000 - 2000 where member_id   = 'memberA';  -- 성공 (8000)
update member set money=10000 + 2000 where member_iddd = 'memberB';  -- 예외! (컬럼 없음)

이 시점의 선택이 결과를 가른다.

여기서 commit  →  memberA 8000, memberB 10000  (이체 실패 + A 돈만 증발)  ✕
여기서 rollback →  memberA 10000 복구           (거래 전으로 안전 복구)   ○

만약 자동 커밋 모드였다면 첫 update가 바로 커밋되어 memberA만 2000원 줄어드는 사고가 난다. 이런 작업은 반드시 수동 커밋으로 시작해서, 중간 실패 시 롤백할 수 있어야 한다. 이것이 원자성이다.

DB 락

세션1이 데이터를 수정하는 중(커밋 전)에 세션2가 같은 데이터를 수정하면 원자성이 깨진다. 이를 막기 위해 DB는 락(Lock) 을 제공한다. 데이터를 변경하려면 해당 로우의 락을 먼저 획득해야 한다.

세션1: set autocommit false; update memberA money=500   → memberA 락 획득, update
세션2: set autocommit false; update memberA money=1000  → 락 없음, 대기...
세션1: commit                                           → 트랜잭션 종료, 락 반납
세션2: (대기 끝) 락 획득 → update 반영 → commit → 락 반납

세션2는 무한정 기다리지 않는다. SET LOCK_TIMEOUT 60000(60초)처럼 대기 시간을 설정할 수 있고, 시간을 넘기면 락 타임아웃 오류가 발생한다.

 

일반 조회는 락을 획득하지 않고 바로 읽을 수 있다. 단, 트랜잭션이 끝날 때까지 해당 데이터를 다른 곳에서 변경하지 못하게 막아야 할 때는 select ... for update 를 사용한다.

select * from member where member_id='memberA' for update;

트랜잭션과 락은 DB마다 동작이 조금씩 다르므로, 매뉴얼을 확인하고 의도대로 동작하는지 테스트한 뒤 사용한다.

트랜잭션 적용 — 트랜잭션이 없으면 (V1)

먼저 트랜잭션 없이 계좌이체 로직만 만든다.

public class MemberServiceV1 {
    private final MemberRepositoryV1 memberRepository;

    public void accountTransfer(String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember   = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);  // from 감소
        validation(toMember);                                            // toId가 "ex"면 예외
        memberRepository.update(toId, toMember.getMoney() + money);      // to 증가
    }
}

toId가 "ex"면 중간에 예외가 발생한다. 이때 from의 update는 이미 반영됐는데 to의 update는 실행되지 않아 memberA의 돈만 2000원 줄어든다. 트랜잭션이 없어서 원자성이 깨진 것이다.

트랜잭션 적용 — 커넥션 파라미터로 동기화 (V2)

트랜잭션은 서비스 계층 에서 시작해야 한다. 비즈니스 로직이 잘못되면 그 단위로 함께 롤백해야 하기 때문이다. 그리고 트랜잭션을 유지하려면 같은 커넥션 = 같은 세션 을 써야 한다.

클라이언트 ──▶   ┌─ 서비스 ──────────────────┐ ──▶ ┌─ 리포지토리 ─┐ ──▶ DB
              │ 1. 커넥션 생성             │     │ update     │
              │ 2. 트랜잭션 시작            │     │ (같은 con   │
              │ 3. 비즈니스 로직(con) ──────┼───▶ │  전달받음)   │
              │ 4. 커밋 / 5. 롤백          │     └────────────┘
              │ 6. 커넥션 종료 (풀 반납)     │
              └─────────────────────────┘

가장 단순한 방법은 커넥션을 파라미터로 넘기는 것이다. 리포지토리에 커넥션을 받는 메서드를 추가한다(MemberRepositoryV2). 단, 전달받은 커넥션을 쓰는 메서드는 ① 내부에서 getConnection()을 다시 호출하면 안 되고 ② 커넥션을 닫으면 안 된다. 서비스 로직이 끝날 때 닫아야 하기 때문이다.

public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    Connection con = dataSource.getConnection();
    try {
        con.setAutoCommit(false);           // 트랜잭션 시작
        bizLogic(con, fromId, toId, money); // 같은 con으로 비즈니스 로직
        con.commit();                       // 성공 시 커밋
    } catch (Exception e) {
        con.rollback();                     // 실패 시 롤백
        throw new IllegalStateException(e);
    } finally {
        release(con);                       // 커넥션 정리
    }
}

private void release(Connection con) {
    if (con != null) {
        try {
            con.setAutoCommit(true);  // 풀 반납 전 자동 커밋으로 복구
            con.close();              // 풀 사용 시 close = 풀에 반납
        } catch (Exception e) {
            log.info("error", e);
        }
    }
}

트랜잭션 관리 로직(con 생성, setAutoCommit, commit/rollback, release)과 실제 비즈니스 로직(bizLogic)을 분리한 점이 핵심이다. 커넥션 풀을 쓰면 con.close()가 커넥션을 진짜 끊는 게 아니라 풀에 반납하는 것이므로, 반납 전에 기본값인 자동 커밋 모드로 되돌려 두는 것이 안전하다.

 

이제 예외가 발생하면 롤백되어 memberA의 돈이 10000원으로 복구된다.

정리와 남은 문제

트랜잭션의 원자성 덕분에 여러 SQL을 하나의 작업처럼 처리할 수 있다. 트랜잭션은 서비스 계층에서 시작하고, 그 동안 같은 커넥션을 유지해 같은 세션을 쓴다.

 

문제는 이렇게 직접 트랜잭션을 적용하면 서비스 계층이 커넥션 생성·setAutoCommit·commit·rollback·close 같은 코드로 지저분해지고, 커넥션을 파라미터로 넘기는 구조도 번거롭다는 점이다. 다음 포스트에서 스프링의 트랜잭션 추상화와 @Transactional 로 이 문제를 하나씩 걷어낸다.