Spring Boot/트랜잭션

정의(ACID)

재윤 2025. 8. 28. 14:53
반응형
  • 트랜잭션이 반드시 지켜야하는 4가지 특성

Atomicity(원자성)

  • 트랜잭션의 모든 작업이 전부 수행되거나(All), 전혀 수행되지 않아야 함(None)
  • 중간에 일부만 반영된 상태가 있으면 안 됨
  • 예: 계좌 이체
    • A → B 송금할 때 A 계좌에서 돈이 빠지고 B 계좌에 입금이 동시에 완료되어야 함
    • A에서만 돈이 빠지고 B에 입금 안 되면 원자성이 깨짐 → 롤백 필요

Consistency(일관성)

  • 트랜잭션이 수행된 후에도 DB의 무결성 제약 조건이 항상 만족되어야 함
  • 즉, 데이터베이스는 유효한 상태에서 또 다른 유효한 상태로만 변해야 함
  • 예: 은행 계좌 시스템
    • 전체 계좌 잔액 합계가 트랜잭션 전후로 동일해야 함
    • 한쪽 계좌에서 빠진 만큼 다른 쪽에 반드시 더해져야 함

Isolation (고립성, 격리성)

  • 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않아야 함
  • 다른 트랜잭션이 진행 중인 데이터를 읽거나 변경하지 못하게 해야 함
  • 이를 보장하기 위해 DB는 격리 수준(Isolation Level)을 제공 (예: Read Committed, Serializable 등)
  • 예:
    • A가 상품 구매 중인데 B가 동시에 그 상품 재고를 수정하면 데이터 불일치 발생 가능 → 격리 필요

Durability (지속성, 영속성)

  • 트랜잭션이 성공적으로 커밋되면, 그 결과는 DB에 영구히 반영되어야 함
  • 시스템 장애, 전원 꺼짐, 서버 다운이 발생해도 데이터가 보존되어야 함
  • DB는 이를 위해 로그 기록 (Write-Ahead Logging, WAL) 등을 사용

 

정리

  • A == 원자성이며 All or Nothing
  • C == 일관성이며 무결성 규칙 항상 유지
  • I == 고립성이며 트랜잭션 간 간섭 방지
  • D == 지속성이며 커밋된 데이터는 영구 저장

 

코드로 한 번 실행해보자

entity

package com.springboot.transaction.entity;

import jakarta.persistence.*;
import lombok.*;

import java.math.BigDecimal;

@Entity
@Getter
@AllArgsConstructor
public class Account {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String owner;

    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal balance;

    @Version
    private Long version; // 낙관적 락(동시성 안전을 조금 더 확보)

    public Long getId() { return id; }
    public String getOwner() { return owner; }
    public BigDecimal getBalance() { return balance; }
    public Long getVersion() { return version; }

    public void withdraw(BigDecimal amount) {
        if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
        BigDecimal next = this.balance.subtract(amount);
        if (next.signum() < 0) throw new IllegalStateException("잔액 부족");
        this.balance = next;
    }

    public void deposit(BigDecimal amount) {
        if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
        this.balance = this.balance.add(amount);
    }

    protected Account() {}
    
    public Account(String owner, BigDecimal balance) {
        this.owner = owner;
        this.balance = balance;
    }
}

 

controller

package com.springboot.transaction.controller;

import com.springboot.transaction.dto.AccountDto;
import com.springboot.transaction.entity.Account;
import com.springboot.transaction.repository.AccountRepository;
import com.springboot.transaction.service.TransferService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/accounts")
public class AccountController {

    @Autowired
    private AccountRepository accountRepository;
    @Autowired
    private TransferService transferService;

    @PostMapping("/init")
    public ResponseEntity<?> init() {
        transferService.init("alice", new BigDecimal("100000"), "bob", new BigDecimal("50000"));
        return ResponseEntity.ok(Map.of("ok", true));
    }

    @GetMapping("/select")
    public List<AccountDto> list() {
        return transferService.findAllAccounts();
    }

    @PostMapping("/transfer")
    public ResponseEntity<?> transfer(@RequestBody TransferRequest req) {
        transferService.transfer(req.from(), req.to(), req.amount(), req.makeError());
        return ResponseEntity.ok(Map.of("ok", true));
    }

    public record TransferRequest(String from, String to, BigDecimal amount, boolean makeError) {}
}

 

service

package com.springboot.transaction.service;

import com.springboot.transaction.dto.AccountDto;
import com.springboot.transaction.entity.Account;
import com.springboot.transaction.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
@RequiredArgsConstructor
public class TransferService {

    @Autowired
    private AccountRepository accountRepository;

    /** Atomicity/Isolation를 체감하기 위한 계좌 이체 */
    @Transactional(isolation = Isolation.READ_COMMITTED) // 기본값 명시(학습용)
    public void transfer(String fromOwner, String toOwner, BigDecimal amount, boolean makeErrorAfterDeposit) {
        Account from = accountRepository.findByOwner(fromOwner)
                .orElseThrow(() -> new IllegalArgumentException("출금 계좌 없음: " + fromOwner));
        Account to = accountRepository.findByOwner(toOwner)
                .orElseThrow(() -> new IllegalArgumentException("입금 계좌 없음: " + toOwner));

        // 비즈니스 규칙(Consistency 보장의 일부)
        if (fromOwner.equals(toOwner)) throw new IllegalArgumentException("동일 계좌 이체 불가");

        from.withdraw(amount);
        to.deposit(amount);

        // 더티체킹으로 커밋 시점에 update 발생
        // 의도적으로 에러를 던져 롤백(원자성) 확인
        if (makeErrorAfterDeposit) {
            throw new RuntimeException("테스트용 강제 예외 → 전체 롤백되어야 함");
        }
    }

    @Transactional(readOnly = true)
    public List<AccountDto> findAllAccounts() {
        return accountRepository.findAll()
                .stream()
                .map(AccountDto::fromEntity) // Entity → DTO 변환
                .toList();
    }

    /** 테스트용 초기화 */
    @Transactional
    public void init(String aOwner, BigDecimal aBal, String bOwner, BigDecimal bBal) {
        accountRepository.findByOwner(aOwner).orElseGet(() ->
                accountRepository.save(new Account(aOwner, aBal)));

        accountRepository.findByOwner(bOwner).orElseGet(() ->
                accountRepository.save(new Account(bOwner, bBal)));
    }

}

repository

package com.springboot.transaction.repository;

import com.springboot.transaction.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByOwner(String owner);
}

 

dto

package com.springboot.transaction.dto;

import com.springboot.transaction.entity.Account;
import lombok.Getter;
import java.math.BigDecimal;

@Getter
public class AccountDto {
    private Long id;
    private String owner;
    private BigDecimal balance;
    public AccountDto() {} // 기본 생성자

    public AccountDto(Long id, String owner, BigDecimal balance) { // 모든 필드 생성자
        this.id = id;
        this.owner = owner;
        this.balance = balance;
    }

    public Long getId() { return id; }
    public String getOwner() { return owner; }
    public BigDecimal getBalance() { return balance; }

    // 엔티티 → DTO 변환
    public static AccountDto fromEntity(Account account) {
        return new AccountDto(
                account.getId(),
                account.getOwner(),
                account.getBalance()
        );
    }
}
  • 이걸로 데이터 만들기
<http://localhost:8080/api/accounts/init>
  • 데이터 조회
<http://localhost:8080/api/accounts/select>

 

 

결과

[
    {
        "id": 1,
        "owner": "alice",
        "balance": 100000.00
    },
    {
        "id": 2,
        "owner": "bob",
        "balance": 50000.00
    }
]
<http://localhost:8080/api/accounts/transfer>
  • 이 api를 본다면 계좌 이체 API 엔드 포인트이다.

 

클라이언트에서

  • from → 출금 계좌 소유자
  • to → 입금 계좌 소유자
  • amount → 이체 금액
  • makeError → true면 강제로 예외 발생 == 롤백 확인용
{
  "from": "alice",
  "to": "bob",
  "amount": 20000,
  "makeError": false
}

 

초기값

 

A — Atomicity (원자성)

  • 아까 위에서 봤을 때 트랜잭션 경계 안의 작업은 전부 성공하거나(Commit) 전부 실패(전부 Rollback) 해야한다.
    • 라는 것이다. 먼저 Service 코드 부터 보면
@Transactional(isolation = Isolation.READ_COMMITTED) // 기본값 명시(학습용)
public void transfer(String fromOwner, String toOwner, BigDecimal amount, boolean makeErrorAfterDeposit) {
    Account from = accountRepository.findByOwner(fromOwner)
            .orElseThrow(() -> new IllegalArgumentException("출금 계좌 없음: " + fromOwner));
    Account to = accountRepository.findByOwner(toOwner)
            .orElseThrow(() -> new IllegalArgumentException("입금 계좌 없음: " + toOwner));

    // 비즈니스 규칙(Consistency 보장의 일부)
    if (fromOwner.equals(toOwner)) throw new IllegalArgumentException("동일 계좌 이체 불가");

    from.withdraw(amount);
    to.deposit(amount);

    // 더티체킹으로 커밋 시점에 update 발생
    // 의도적으로 에러를 던져 롤백(원자성) 확인
    if (makeErrorAfterDeposit) {
        throw new RuntimeException("테스트용 강제 예외 → 전체 롤백되어야 함");
    }
}

 

 

1. Transcational 메서드 안에 from.withdraw, to.deposit이 둘 다 성공해야 커밋이 됨

public void withdraw(BigDecimal amount) {
    if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
    BigDecimal next = this.balance.subtract(amount); // 잔액 - 출금액
    if (next.signum() < 0) throw new IllegalStateException("잔액 부족");
    this.balance = next;
}

public void deposit(BigDecimal amount) {
    if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
    this.balance = this.balance.add(amount);
}

 

2. 도중 예외

  • 잔액 부족 또는 RuntimeException가 던져지면 전체 롤백
        // 더티체킹으로 커밋 시점에 update 발생
        // 의도적으로 에러를 던져 롤백(원자성) 확인
        if (makeErrorAfterDeposit) {
            throw new RuntimeException("테스트용 강제 예외 → 전체 롤백되어야 함");
        }

 

위를 실행시키면 결과

 

여기서 의문인 거는 save를 안했는데 자동 저장됨

JPA의 영속성 컨텍스트(persistence context) + 더티 체킹(dirty checking)

  • 이거 덕분에 save를 하지 않아도 자동적으로 실행됨
Account from = accountRepository.findByOwner(fromOwner).orElseThrow(...);
  • JPA가 위를 실행한 후 Account 엔티티는 연속성 컨텍스트(EntityManager 1차 캐시)에 저장됨
    • 즉 DB와 연결된 관리되는 상태
from.withdraw(amount);
  • 자바 객체의 balance 필드값(돈)을 바꾸는 것 뿐
  • 아직 update SQL은 실행되지 않음 하지만 JPA가 영속성 컨텍스트 안에서 “이 엔티티의 값이 바뀌었다”를 기록해둠
to.deposit(amount);
  • 자바 객체 필드만 변경됨
  • 영속성 켄텍스트가 “변경된 스냅샷”을 계속 추적

트랜잭션 종료 시점(@Transactional)

  • @Transactional 때문에 Spring이 트랜잭션을 시작/종료 관리
  • 메서드가 정상 종료 → 스프링이 commit 호출
  • 그 순간 Hibernate(JPA 구현체)가 영속성 컨텍스트의 “변경된 엔티티 목록”을 확인 → DB에 UPDATE SQL을 실행
update account set balance=?, version=? where id=? and version=?;

→ 이게 바로 더티 체킹 == 자바 객체를 바꿨는데 JPA가 알아서 DB에 반영해줌

 

@Transactional이 붙은 메서드가 끝나고 commit될 때 JPA가 자동으로 flush()하면서 발생

여기서 이제 왜 All-or-nothing?

 

💡

  • JPA는 트랜잭션 커밋 시점에 엔티티 변경을 감지(더티 체킹)해 UPDATE SQL을 내보낸다
  • 예외가 나면 스프링이 현재 트랜잭션을 rollback-only 로 표시하고 실제로 롤백해 두 계좌의 변경이 모두 취소 </aside>

 

여기서 중요한 것은

  • 런타임 예외 (RuntimeException과 그 하위, 예: NullPointerException, IllegalStateException …)
  • 👉 롤백됨
  • 체크 예외 (Exception이지만 RuntimeException이 아닌 것, 예: IOException, SQLException)
  • 👉 롤백되지 않고 커밋됨

이렇게 만든 이유는

  • 런타임 예외는 대부분 “프로그래밍 오류/데이터 무결성 문제” → 회복 불가 → 롤백하는 게 안전
  • 체크 예외는 보통 “외부 리소스 문제” (예: 네트워크 오류, 파일 읽기 실패) →
  • 개발자가 직접 잡아서 처리할 수 있도록 두는 게 합리적 → 롤백 안 함

스프링은 안전하게 하기 위해 RuntimeException만 자동 롤백하도록 설계한 것임

 

C — Consistency (일관성)

  • 트랜잭션 전/후로 데이터는 유효한 상태(무결성 제약)를 지켜야 한다

코드에서는

@Entity
public class Account {
    @Column(nullable = false, unique = true, length = 50)
    private String owner;

    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal balance;

    public void withdraw(BigDecimal amount) {
        if (amount.signum() <= 0) throw new IllegalArgumentException("amount must be positive");
        BigDecimal next = this.balance.subtract(amount);
        if (next.signum() < 0) throw new IllegalStateException("잔액 부족"); // ← 무결성 수호
        this.balance = next;
    }
}
  • 음수 잔액을 금지
  • DB제약에서 @Column(nullable = false, unique = true)→
    • owner의 NULL 금지/중복 금지
    • balance에 precision/scale 지정

 

I — Isolation (격리성)

  • 동시에 실행되는 트랜잭션끼리 서로의 미완료 변경을 보지 못한다.
@Transactional(isolation = Isolation.READ_COMMITTED) // 기본값 명시(학습용)
  • @Transactional(isolation = Isolation.READ_COMMITTED) (기본값과 동일)

→ 커밋된 것만 읽음. 다른 트랜잭션이 아직 커밋하지 않은 변경은 안 보임

@Entity
public class Account {
    @Version
    private Long version; // ← 동시성 충돌 시 예외 발생 → 트랜잭션 롤백
}
  • 이거는 JPA는 update시 자동으로 이 값을 중가시킴 즉 DB 레코드에 버전 번호가 기록되고, 같은 행을 동시에 여러 트랜잭션이 수정할 때 충돌을 감지하는 데 쓰인다.

예를 들어 두 사용자가 동시에 같은 계좌를 불러온다고 했을 때

  • 트랜잭션 A: balance=1000, version=1
  • 트랜잭션 B: balance=1000, version=1

A가 200원 출금 → 커밋

  • JPA는 update 시 아래 SQL을 날립니다:
update account
   set balance = 800, version = 2
 where id = 1 and version = 1;

성공적으로 반영됨 (버전 1 → 2)

B도 300원 출금 → 커밋 시도

  • B가 본 balance=1000, version=1 기준으로 update하려고 합니다:
update account
   set balance = 700, version = 2
 where id = 1 and version = 1;
  • 하지만 지금 DB에는 version = 2라서 조건(version=1)이 안 맞음 → 업데이트된 행이 0개
  • JPA가 이걸 감지하고 OptimisticLockException 발생 → 트랜잭션 롤백

이걸 왜 하냐

→ 이걸 안 쓰면 “Lost Update(갱신 손실)” 문제가 발생

  • 동시에 읽은 값(1000)을 기준으로 각각 수정하면, 나중에 저장한 값이 앞선 변경을 덮어써버림 → 데이터 손실
  • @Version이 있으면 충돌을 감지하고 롤백 → 개발자가 재시도 로직을 작성해서 안전하게 처리 가능

 

D — Durability (지속성)

  • 커밋된 결과는 장애가 나도 사라지지 않는다.
  • 서비스 메서드가 정상 종료되면 스프링이 커밋 → JPA가 UPDATE/INSERT → MariaDB(InnoDB)가 로그/디스크에 반영
  • 애플리케이션를 재시작해도 데이터가 남아있음(실제로 /select로 다시 확인 가능)

💡 DB 레벨에선 InnoDB의 WAL/redo log 정책에 의해 커밋의 영속성이 보장됨 (학습 포인트로만 기억해두면 충분하다

반응형

'Spring Boot > 트랜잭션' 카테고리의 다른 글

Isolation Level  (1) 2025.08.28
Propagation  (1) 2025.08.28
트랜잭션 범위 (Class, Method)  (3) 2025.08.28