다음은 “트랜잭션이란 무엇인가?”와 “데이터 일관성을 어떻게 보존하는가?”를 백엔드 관점(특히 RDB + 스프링 중심)에서 딱 정리한 답이에요.
트랜잭션이란?
여러 개의 데이터 연산을 “한 덩어리(단위 작업, Unit of Work)”로 묶어 전부 성공하거나(Commit) 전부 실패(롤백, Rollback) 하게 보장하는 메커니즘입니다. 고전 예시는 계좌이체: 출금 −100, 입금 +100이 둘 다 성공해야만 실제 반영.
핵심 속성(ACID)
Atomicity: 전부 아니면 전무(부분 성공 없음)
Consistency: 트랜잭션 전후로 **정합 조건(무결성 제약)**이 유지
Isolation: 동시에 실행돼도 서로 간섭 최소화
Durability: 커밋된 결과는 장애에도 보존
일관성을 해치는 현상(동시성 이슈)
Dirty Read: A가 커밋 전 임시값을 B가 읽음
Non-Repeatable Read: 같은 행을 두 번 읽는데 중간에 값이 바뀜
Phantom Read: 같은 조건의 조회인데 중간에 행 수가 바뀜(삽입/삭제)
Lost Update: 둘이 동시에 수정해서 한쪽 변경이 유실
DB 격리 수준 정리 (일반적 보장)
Read Uncommitted: Dirty Read 허용
Read Committed: Dirty Read 방지(기본: Oracle, PostgreSQL)
Repeatable Read: Non-Repeatable Read 방지(기본: MySQL InnoDB)
Serializable: 직렬 실행과 동일(가장 안전, 성능 비용 큼)
> 현대 RDB는 주로 MVCC로 읽기-쓰기 경합을 줄이며 스냅샷을 제공합니다.
일관성 보존 방법 (실무 체크리스트)
1. 스키마/제약으로 1차 방어
PK/UK, FK, CHECK로 불변조건을 DB가 강제
꼭 필요한 곳에만 NULL 허용
적절한 정규화와 인덱싱
2. 트랜잭션 경계 설계
“무엇을 한 번에 커밋해야 하는가?”를 도메인 규칙 기준으로 결정
(계좌이체, 주문→재고차감→결제예약 등)
너무 긴 트랜잭션(사용자 입력 대기, 외부 API 대기) 피하기
3. 격리 수준/잠금 전략
기본은 Read Committed(혹은 MySQL 기본인 Repeatable Read)로 시작
→ 문제 구간에서만 상향 또는 명시적 잠금 사용
행 충돌 우려가 큰 갱신 시:
비관적 잠금: SELECT ... FOR UPDATE
낙관적 잠금: version 컬럼(+ 예외 시 재시도)로 Lost Update 방지
4. 트랜잭션 내 부수효과 금지
같은 트랜잭션 안에서 외부 시스템 호출(결제, 메일, 메시지 발행 등) 지양
→ 커밋 성공과 외부 부수효과의 원자성 깨짐
→ 대안: Outbox 패턴(+CDC) 로 커밋 후 메시지 발행 보장
5. Idempotency & Retry
네트워크/타임아웃로 인해 중복요청 발생 가능
멱등키(Idempotency-Key)로 동일 요청 한 번만 처리
결과 재시도 정책은 멱등성과 함께
6. 에러/데드락 대응
데드락은 피할 수 없을 수 있음 → 짧은 트랜잭션, 일관된 잠금 순서, 재시도 로직 도입
7. 분산(마이크로서비스) 트랜잭션
2PC(이중 커밋)는 복잡/제약 많음
권장: 사가(Saga) 패턴
오케스트레이션 또는 코레오그래피로 단계별 커밋 + 보상 트랜잭션
예: 주문 생성→재고 예약→결제 승인, 중간 실패 시 이전 단계 보상(재고 해제, 주문 취소 등)
스프링에서의 실전 포인트
서비스 계층 메서드에 경계를 잡고 @Transactional 사용
읽기 전용은 readOnly = true로 힌트 부여(플랫폼에 따라 최적화)
필요한 곳만 격리수준/전파 레벨 조정
@Service
public class TransferService {
private final AccountRepo repo;
@Transactional(
isolation = Isolation.REPEATABLE_READ, // 상황에 맞게 조정
propagation = Propagation.REQUIRED,
rollbackFor = Exception.class
)
public void transfer(long fromId, long toId, BigDecimal amount) {
Account from = repo.findByIdForUpdate(fromId); // 비관적 잠금 예시
Account to = repo.findByIdForUpdate(toId);
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
from.withdraw(amount);
to.deposit(amount);
// 무결성은 DB 제약이 2차 방어 (ex. balance >= 0 CHECK)
// 커밋은 메서드 정상 종료 시점에 수행
}
}
낙관적 잠금 예시 (JPA @Version):
@Entity
class ProductStock {
@Id Long id;
@Version Long version; // 충돌 시 OptimisticLockException
int quantity;
}
명시적 트랜잭션(SQL):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 둘 중 하나라도 실패면 ROLLBACK
요약 (바로 적용)
DB 제약으로 1차 방어 → 짧고 명확한 트랜잭션 경계 → 격리 수준/잠금 전략 정확히 → Outbox/사가로 분산 일관성 → 멱등성 + 재시도로 실전 내구성 확보.
이 흐름대로 설계하면 데이터 일관성을 안정적으로 지킬 수 있어요.
'Spring & Backend' 카테고리의 다른 글
| 스레드 풀 포화 정책이란 무엇인가요? (6) | 2025.08.12 |
|---|---|
| TCP 3-way handshake 과정에 대해서 설명해주세요. (4) | 2025.08.12 |
| Canary 배포(Canary Deployment) (2) | 2025.08.10 |
| JWT (2) | 2025.08.09 |
| GC 로그 (Garbage Collection Log)와 힙덤프 (8) | 2025.08.08 |
