들어가며
데이터베이스 트랜잭션은 데이터 무결성의 근간입니다. 하지만 동시에 수많은 요청이 들어오는 실무 환경에서는 트랜잭션 격리 수준에 따라 예상치 못한 데이터 이상 현상이 발생합니다. 이 글에서는 4가지 트랜잭션 격리 수준, 각 수준에서 발생할 수 있는 문제, 그리고 낙관적/비관적 락과 MVCC까지 실무에서 반드시 알아야 할 동시성 제어 기법을 체계적으로 정리합니다.
1. 트랜잭션 격리 수준의 이해
SQL 표준은 4가지 격리 수준을 정의하며, 높은 격리 수준일수록 데이터 일관성은 좋아지지만 동시성(성능)은 떨어집니다.
READ UNCOMMITTED - 가장 낮은 격리 수준
다른 트랜잭션이 커밋하지 않은 데이터를 읽을 수 있습니다. Dirty Read가 발생하며, 실무에서는 거의 사용하지 않습니다.
-- 트랜잭션 A: 상품 가격을 10000 → 8000으로 변경 (아직 커밋하지 않음)
BEGIN;
UPDATE products SET price = 8000 WHERE id = 1;
-- 아직 COMMIT 안 함
-- 트랜잭션 B: READ UNCOMMITTED에서는 커밋되지 않은 8000을 읽음
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT price FROM products WHERE id = 1;
-- 결과: 8000 (Dirty Read!)
-- 트랜잭션 A가 ROLLBACK하면 트랜잭션 B는 존재하지 않는 데이터를 읽은 셈
READ COMMITTED - PostgreSQL, Oracle 기본값
커밋된 데이터만 읽을 수 있어 Dirty Read는 방지되지만, 같은 쿼리를 두 번 실행했을 때 다른 결과가 나올 수 있는 Non-Repeatable Read가 발생합니다.
-- 트랜잭션 A
BEGIN;
SELECT price FROM products WHERE id = 1; -- 결과: 10000
-- 이 시점에 트랜잭션 B가 가격을 8000으로 변경하고 커밋
SELECT price FROM products WHERE id = 1; -- 결과: 8000 (Non-Repeatable Read!)
COMMIT;
REPEATABLE READ - MySQL InnoDB 기본값
트랜잭션 시작 시점의 스냅샷을 기준으로 데이터를 읽습니다. Non-Repeatable Read는 방지되지만, Phantom Read가 발생할 수 있습니다.
-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 결과: 5
-- 이 시점에 트랜잭션 B가 새 주문을 INSERT하고 커밋
SELECT COUNT(*) FROM orders WHERE status = 'PENDING';
-- MySQL InnoDB: 여전히 5 (MVCC 덕분에 Phantom Read 방지)
-- SQL 표준상 REPEATABLE READ: 6이 될 수 있음 (Phantom Read)
COMMIT;
참고: MySQL InnoDB는 MVCC와 Next-Key Lock을 사용하여 REPEATABLE READ에서도 Phantom Read를 대부분 방지합니다.
SERIALIZABLE - 가장 높은 격리 수준
트랜잭션을 순차적으로 실행하는 것과 같은 결과를 보장합니다. 모든 이상 현상을 방지하지만 동시성이 크게 저하됩니다.
2. 이상 현상 정리
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | O | O | O |
| READ COMMITTED | X | O | O |
| REPEATABLE READ | X | X | O (MySQL에서는 X) |
| SERIALIZABLE | X | X | X |
3. 낙관적 락 (Optimistic Lock)
낙관적 락은 충돌이 드물다고 가정하고, 데이터를 수정할 때 충돌을 감지하는 방식입니다. 실제 DB 락을 사용하지 않고 버전(version) 컬럼으로 구현합니다.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int price;
private int stockQuantity;
@Version // JPA 낙관적 락 - 수정 시마다 version 자동 증가
private Long version;
public void decreaseStock(int quantity) {
if (this.stockQuantity < quantity) {
throw new InsufficientStockException(
"재고 부족: 현재 " + stockQuantity + ", 요청 " + quantity);
}
this.stockQuantity -= quantity;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
// 낙관적 락을 활용한 재고 차감
@Transactional
public void order(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.decreaseStock(quantity);
productRepository.save(product);
// version이 변경되었으면 OptimisticLockingFailureException 발생
}
// 재시도 로직 포함
@Retryable(
value = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void orderWithRetry(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow();
product.decreaseStock(quantity);
productRepository.save(product);
}
}
낙관적 락이 실행하는 SQL은 다음과 같습니다:
UPDATE products
SET stock_quantity = ?, price = ?, version = version + 1
WHERE id = ? AND version = ?
-- version이 일치하지 않으면 0행 업데이트 → 예외 발생
4. 비관적 락 (Pessimistic Lock)
비관적 락은 충돌이 빈번하다고 가정하고, 데이터를 읽는 시점에 락을 거는 방식입니다. 실제 DB의 행 수준 락(Row-Level Lock)을 사용합니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
// 비관적 쓰기 락 - SELECT ... FOR UPDATE
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
// 비관적 읽기 락 - SELECT ... FOR SHARE
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithSharedLock(@Param("id") Long id);
}
@Service
@RequiredArgsConstructor
public class StockService {
private final ProductRepository productRepository;
// 비관적 락을 활용한 동시성 제어 - 충돌이 잦은 재고 관리에 적합
@Transactional
public void decreaseStock(Long productId, int quantity) {
// SELECT ... FOR UPDATE로 행 락 획득
Product product = productRepository
.findByIdWithPessimisticLock(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.decreaseStock(quantity);
// 트랜잭션 종료 시 락 자동 해제
}
}
낙관적 vs 비관적 락 선택 기준
| 기준 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 충돌 빈도 | 낮을 때 유리 | 높을 때 유리 |
| 성능 | 읽기 성능 우수 | 락 대기로 인한 지연 |
| 구현 | @Version 어노테이션 | SELECT FOR UPDATE |
| 적합한 사례 | 게시글 수정, 프로필 변경 | 재고 차감, 좌석 예약 |
| 데드락 위험 | 없음 | 있음 (타임아웃 설정 필요) |
5. MVCC (Multi-Version Concurrency Control)
MVCC는 데이터의 여러 버전을 유지하여 읽기와 쓰기가 서로를 블로킹하지 않도록 하는 기법입니다. MySQL InnoDB, PostgreSQL 등 대부분의 RDBMS가 채택하고 있습니다.
-- MVCC 동작 원리 (MySQL InnoDB)
-- 트랜잭션 A (시작: trx_id=100)
BEGIN;
SELECT * FROM accounts WHERE id = 1;
-- MVCC: trx_id=100 시점의 스냅샷을 읽음
-- Undo Log에서 해당 시점의 데이터를 재구성
-- 트랜잭션 B (trx_id=101)가 동시에 UPDATE하고 COMMIT
-- → 새로운 버전이 생성되지만, 트랜잭션 A에는 영향 없음
SELECT * FROM accounts WHERE id = 1;
-- 여전히 trx_id=100 시점의 데이터를 읽음 (REPEATABLE READ)
COMMIT;
MVCC 덕분에 읽기 작업은 쓰기를 블로킹하지 않고, 쓰기 작업은 읽기를 블로킹하지 않습니다. 이는 높은 동시성을 달성하는 핵심 원리입니다.
6. Spring @Transactional 전파 속성
Spring의 @Transactional 전파(Propagation) 속성은 트랜잭션 경계를 어떻게 설정할지 결정합니다.
@Service
@RequiredArgsConstructor
public class OrderProcessService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final NotificationService notificationService;
// REQUIRED (기본값): 기존 트랜잭션에 참여, 없으면 새로 생성
@Transactional(propagation = Propagation.REQUIRED)
public void createOrder(OrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// paymentService도 같은 트랜잭션에서 실행
paymentService.processPayment(order);
}
// REQUIRES_NEW: 항상 새로운 트랜잭션 생성 (기존 트랜잭션은 일시 중단)
@Transactional(propagation = Propagation.REQUIRED)
public void processWithNotification(OrderRequest request) {
Order order = Order.create(request);
orderRepository.save(order);
// 알림 실패가 주문을 롤백시키면 안 되므로 별도 트랜잭션
notificationService.sendNotification(order);
}
}
@Service
public class NotificationService {
// 주문 트랜잭션과 독립적으로 실행
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) {
// 이 트랜잭션이 실패해도 주문 트랜잭션에 영향 없음
notificationRepository.save(
new Notification(order.getId(), "주문 완료"));
emailSender.send(order.getUserEmail(), "주문 완료");
}
}
@Service
public class AuditService {
// NOT_SUPPORTED: 트랜잭션 없이 실행
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void log(String action) {
// 로깅은 트랜잭션 범위 밖에서 실행
auditLogRepository.save(new AuditLog(action));
}
}
주요 전파 속성 요약
| 전파 속성 | 기존 트랜잭션 있을 때 | 기존 트랜잭션 없을 때 | 사용 사례 |
|---|---|---|---|
| REQUIRED | 참여 | 새로 생성 | 일반적인 비즈니스 로직 |
| REQUIRES_NEW | 새로 생성 (기존 중단) | 새로 생성 | 독립적인 로깅, 알림 |
| NESTED | 중첩 트랜잭션 | 새로 생성 | 부분 롤백이 필요한 경우 |
| MANDATORY | 참여 | 예외 발생 | 반드시 트랜잭션 내에서 호출 |
| NOT_SUPPORTED | 트랜잭션 없이 실행 | 트랜잭션 없이 실행 | 읽기 전용 대량 조회 |
7. 실무에서 자주 겪는 @Transactional 함정
@Service
public class CommonMistakes {
// 함정 1: private 메서드에 @Transactional은 동작하지 않음
// Spring AOP는 프록시 기반이므로 public 메서드에만 적용
@Transactional // 동작하지 않음!
private void internalMethod() { }
// 함정 2: 같은 클래스 내부 호출은 프록시를 거치지 않음
public void outerMethod() {
this.innerMethod(); // @Transactional이 적용되지 않음!
}
@Transactional
public void innerMethod() {
// 외부에서 호출해야 트랜잭션 적용
}
// 함정 3: checked exception은 기본적으로 롤백하지 않음
@Transactional // IOException 발생 시 롤백되지 않음!
public void riskyMethod() throws IOException {
// rollbackFor를 명시해야 함
}
// 올바른 사용
@Transactional(rollbackFor = Exception.class)
public void correctMethod() throws Exception {
// 모든 예외에 대해 롤백
}
}
정리
트랜잭션 격리 수준과 동시성 제어는 데이터 무결성과 서비스 성능을 모두 좌우하는 핵심 주제입니다. MySQL을 사용한다면 기본 REPEATABLE READ에서 MVCC가 어떻게 동작하는지 이해하고, 동시성이 높은 기능에서는 비관적/낙관적 락을 적절히 선택하세요. 그리고 Spring의 @Transactional 전파 속성과 주의사항을 숙지하면 안정적인 트랜잭션 관리가 가능합니다.
'Database' 카테고리의 다른 글
| Elasticsearch 입문 - Spring Boot로 검색 엔진 구축하기 (0) | 2026.04.06 |
|---|---|
| MongoDB 실전 가이드 - 도큐먼트 설계부터 인덱싱 전략까지 (0) | 2026.04.06 |
| PostgreSQL 성능 튜닝 완벽 가이드 - 쿼리 최적화부터 파티셔닝까지 (0) | 2026.04.03 |
| Redis 캐시 전략 가이드 - 실무에서 바로 쓰는 패턴 (0) | 2026.03.30 |
| 데이터베이스 인덱스 완벽 가이드 - 왜 느린 쿼리가 발생하는가 (0) | 2026.03.25 |