동시성(DB)

동시성(Concurrency)이란 데이터베이스에서 여러 트랜잭션이 동시에 같은 데이터에 접근할 때 발생하는 충돌을 제어하는 것을 의미합니다.

왜 문제가 되는가

[단일 사용자 — 문제 없음]
사용자A: 잔액 조회 → 10,000원 → 출금 5,000원 → 잔액 5,000원 O

[동시 접근 — 문제 발생]
시간  사용자A                    사용자B
 │   잔액 조회 → 10,000원
 │                              잔액 조회 → 10,000원
 │   출금 5,000원
 │   잔액 = 10,000 - 5,000 = 5,000원 저장
 │                              출금 3,000원
 │                              잔액 = 10,000 - 3,000 = 7,000원 저장
 ▼
결과: 잔액 7,000원 (사용자A의 출금이 사라짐 X)
정상: 잔액 2,000원 (10,000 - 5,000 - 3,000)

동시성 문제의 종류

1. 갱신 분실 (Lost Update)

두 트랜잭션이 같은 데이터를 동시에 수정하여 하나의 수정이 사라지는 문제

트랜잭션A: READ 재고 = 10
트랜잭션B: READ 재고 = 10
트랜잭션A: UPDATE 재고 = 10 - 1 = 9
트랜잭션B: UPDATE 재고 = 10 - 1 = 9  ← A의 수정을 덮어씀

결과: 재고 9 (실제로는 8이어야 함) X

2. 더티 리드 (Dirty Read)

커밋되지 않은 데이터를 다른 트랜잭션이 읽는 문제

트랜잭션A: UPDATE 가격 = 50000 (아직 COMMIT 안 함)
트랜잭션B: READ 가격 = 50000 ← 커밋 안 된 데이터를 읽음
트랜잭션A: ROLLBACK (가격 원복 → 45000)

결과: 트랜잭션B는 존재하지 않는 가격(50000)으로 처리함 X

3. 반복 불가능 읽기 (Non-Repeatable Read)

같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 다른 문제

트랜잭션A: READ 가격 = 45000
트랜잭션B: UPDATE 가격 = 39000 + COMMIT
트랜잭션A: READ 가격 = 39000 ← 같은 트랜잭션인데 값이 다름 X

4. 팬텀 리드 (Phantom Read)

같은 조건으로 조회했는데 행의 수가 달라지는 문제

트랜잭션A: SELECT COUNT(*) FROM orders WHERE date = '2025-02-13' → 10건
트랜잭션B: INSERT INTO orders (...) + COMMIT
트랜잭션A: SELECT COUNT(*) FROM orders WHERE date = '2025-02-13' → 11건 X
          → 없던 행이 유령(Phantom)처럼 등장

해결 방법 1: 트랜잭션 격리 수준 (Isolation Level)

DB가 동시성 문제를 어디까지 허용할지 설정하는 수준입니다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read성능
READ UNCOMMITTED발생발생발생가장 빠름
READ COMMITTEDO 방지발생발생빠름
REPEATABLE READO 방지O 방지발생보통
SERIALIZABLEO 방지O 방지O 방지가장 느림
-- MySQL 기본 격리 수준: REPEATABLE READ
-- PostgreSQL, Oracle 기본: READ COMMITTED

-- 격리 수준 확인
SELECT @@transaction_isolation;

-- 격리 수준 변경
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
격리 수준이 높을수록 → 동시성 문제 방지 O but 성능 저하 X
격리 수준이 낮을수록 → 성능 좋음 O but 동시성 문제 발생 가능 X

→ 서비스 요구사항에 따라 적절한 수준 선택

해결 방법 2: 락 (Lock)

비관적 락 (Pessimistic Lock)

"충돌이 발생할 것이다"라고 가정하고, 데이터를 읽는 시점에 락을 걸어 다른 트랜잭션의 접근을 차단합니다.

-- 비관적 락 — 다른 트랜잭션이 이 행을 수정/읽기 불가
SELECT * FROM products WHERE id = 1 FOR UPDATE;
// JPA 비관적 락
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public void decreaseStock(Long productId, int quantity) {
    // 락을 걸고 조회 → 다른 트랜잭션은 대기
    Product product = productRepository.findByIdForUpdate(productId)
            .orElseThrow(() -> new IllegalArgumentException("상품 없음"));

    product.decreaseStock(quantity);
}
트랜잭션A: SELECT ... FOR UPDATE (락 획득)
트랜잭션B: SELECT ... FOR UPDATE (대기 ⏳)
트랜잭션A: UPDATE + COMMIT (락 해제)
트랜잭션B: 이제 락 획득 → 최신 데이터로 처리 O
장점단점
데이터 정합성 확실히 보장락 대기로 인한 성능 저하
구현이 직관적데드락 발생 가능
충돌이 잦은 경우 적합동시 처리량 감소

낙관적 락 (Optimistic Lock)

"충돌이 거의 없을 것이다"라고 가정하고, 락을 걸지 않고 수정 시점에 버전을 비교하여 충돌을 감지합니다.

// JPA 낙관적 락 — @Version 사용
@Entity
public class Product {

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

    private String name;
    private int stock;

    @Version  // 수정할 때마다 자동 증가
    private Long version;
}
[동작 원리]

초기 상태: id=1, stock=10, version=0

트랜잭션A: SELECT → {stock: 10, version: 0}
트랜잭션B: SELECT → {stock: 10, version: 0}

트랜잭션A: UPDATE SET stock=9, version=1 WHERE id=1 AND version=0
           → 성공 O (version이 0에서 1로 변경)

트랜잭션B: UPDATE SET stock=9, version=1 WHERE id=1 AND version=0
           → 실패 X (version이 이미 1이므로 WHERE 조건 불일치)
           → OptimisticLockException 발생
// 낙관적 락 — 충돌 시 재시도 로직
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public void decreaseStock(Long productId, int quantity) {
        int maxRetry = 3;

        for (int i = 0; i < maxRetry; i++) {
            try {
                Product product = productRepository.findById(productId)
                        .orElseThrow(() -> new IllegalArgumentException("상품 없음"));

                product.decreaseStock(quantity);
                productRepository.save(product);
                return;  // 성공 시 종료

            } catch (OptimisticLockException e) {
                if (i == maxRetry - 1) {
                    throw new RuntimeException("재고 차감 실패: 충돌이 지속됨");
                }
                // 잠시 대기 후 재시도
            }
        }
    }
}
장점단점
락을 걸지 않아 성능 좋음충돌 시 재시도 로직 필요
동시 처리량 높음충돌이 잦으면 재시도 비용 증가
충돌이 드문 경우 적합실패 처리 로직이 복잡해질 수 있음

비관적 락 vs 낙관적 락 비교

항목비관적 락낙관적 락
가정충돌이 자주 발생충돌이 거의 없음
락 시점읽기 시점에 락락 없음 (수정 시 버전 비교)
충돌 처리대기 후 순차 처리예외 발생 → 재시도
성능동시성 낮음동시성 높음
적합 상황재고 차감, 좌석 예약게시글 수정, 설정 변경
데드락발생 가능발생 안 함

해결 방법 3: 분산 락 (Redis)

서버가 여러 대인 경우 DB 락만으로는 부족할 수 있어 Redis를 활용한 분산 락을 사용합니다.

// Redis 분산 락 — Redisson 사용
@Service
@RequiredArgsConstructor
public class ProductService {

    private final RedissonClient redissonClient;
    private final ProductRepository productRepository;

    public void decreaseStock(Long productId, int quantity) {
        RLock lock = redissonClient.getLock("lock:product:" + productId);

        try {
            // 락 획득 시도 (최대 5초 대기, 3초 후 자동 해제)
            boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS);

            if (!acquired) {
                throw new RuntimeException("락 획득 실패");
            }

            Product product = productRepository.findById(productId)
                    .orElseThrow(() -> new IllegalArgumentException("상품 없음"));

            product.decreaseStock(quantity);
            productRepository.save(product);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 획득 중 인터럽트");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();  // 반드시 해제
            }
        }
    }
}

상황별 선택 가이드

상황권장 방식이유
재고 차감비관적 락충돌 빈번, 정합성 최우선
좌석 예약비관적 락동시 선점 방지 필수
게시글 수정낙관적 락충돌 드묾, 성능 우선
설정 변경낙관적 락동시 수정 가능성 낮음
다중 서버 재고분산 락 (Redis)서버 간 동기화 필요
결제 처리비관적 락 + 멱등키이중 결제 방지

면접 포인트

CSLOG에서 보기