DB

동시성? 낙관적락? 비관적락? 도대체 무슨말일까

깨굴딱지 2024. 10. 30. 21:48

개요

우리는 왜 낙관적 락과 비관적 락을 도입해야 할까요? 동시성 제어 방식으로서 두 락을 도입하는 이유는 여러 가지가 있지만,
주된 이유는 데이터의 일관성을 보장하고 동시성 문제를 해결하기 위해서입니다.

 

락? 동시성제어? wwe 더락 아저씨는 아는데.....

 

옛날과는 다르게 요즘은 모두 디지털화 되어있기 때문에 어떤 일이든 다 웹을 통해서 진행을 하게 됩니다. 그에 따라 수많은 사람들이

한 서비스에 몰릴때도 많은데요, 예를 들어, 인터파크 같은 대형 서비스의 티켓팅 서비스에서 동일한 좌석을 여러 사용자가 동시에

예매하려 할 때, 또는 특정 시간대의 예매 폭주로 인해 실제 좌석 수보다 더 많은 예약이 발생하는 등 데이터의 정합성이

깨질 수 있는 상황이 빈번하게 발생합니다.

 

이러한 동시성 문제를 해결하기 위한 전략으로 낙관적 락과 비관적 락이 사용되며, 각각의 상황과 요구사항에 맞는 적절한

락 전략을 선택하는 것이 중요합니다.

 

1. 동시성 문제란?

1.1 동시성 문제의 정의

동시성 문제는 여러 트랜잭션이 동시에 같은 데이터에 접근할 때 발생하는 데이터 불일치 현상을 말합니다. 이는 데이터의 일관성(Consistency)과 무결성(Integrity)을 해치는 심각한 문제를 초래할 수 있습니다.

 

1.2 주요 동시성 문제 유형

1) Lost Update (갱신 손실)

여러 트랜잭션이 동시에 같은 데이터를 수정할 때 나중에 수정한 내용이 이전 수정 내용을 덮어쓰는 현상

실제 시나리오: 콘서트 예매 시스템의 좌석 수정

@Entity
public class Concert {
    @Id
    private Long id;
    private int availableSeats;  // 남은 좌석 수
}

// 예매 로직
public void bookTicket() {
    Concert concert = concertRepository.findById(1L);  // 현재 남은 좌석: 100석
    if (concert.getAvailableSeats() > 0) {
        concert.setAvailableSeats(concert.getAvailableSeats() - 1);
        concertRepository.save(concert);
    }
}
문제 상황 설명

사용자 A, B가 동시에 예매 시도
A, B 모두 남은 좌석을 100석으로 확인
A가 99석으로 업데이트
B도 99석으로 업데이트
결과적으로 두 명이 예매했는데 좌석은 1석만 감소

 

2) Dirty Read (더티 리드)

 

한 트랜잭션에서 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상

실제 시나리오: 포인트 충전 시스템

// 트랜잭션 A
@Transactional
public void chargePoint() {
    User user = userRepository.findById(1L);  // 포인트: 1000
    user.setPoint(1500);  // DB에 반영은 되었지만, 아직 커밋은 안 된 상태
    
    // 결제 API 실패
    throw new RuntimeException("결제 실패");  // 롤백 발생
}

// 트랜잭션 B (격리 수준이 READ_UNCOMMITTED인 경우)
@Transactional
public void processPointEvent() {
    User user = userRepository.findById(1L);
    int point = user.getPoint();  // 1500 읽음 (커밋되지 않은 데이터)
    
    // 1500포인트라고 생각하고 이벤트 처리
    if (point >= 1500) {  // true로 판단
        // 실제로는 1000포인트인데 잘못된 처리가 수행됨
        giveEventReward();  // 보상 지급
    }
}
초기 상태: 사용자 포인트 1000점

[트랜잭션 A: 포인트 충전]
1. 포인트 1000 -> 1500으로 업데이트
2. 아직 커밋하지 않은 상태
3. 결제 API 호출 실패
4. 롤백 수행

[트랜잭션 B: 포인트 조회]
- A가 커밋하지 않은 1500점을 읽음 (더티 리드 발생)
- 이 값으로 후속 처리를 진행

 

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

한 트랜잭션에서 같은 데이터를 여러 번 조회할 때, 다른 트랜잭션의 커밋으로 인해 값이 변경되어 다른 결과가 조회되는 현상

실제 시나리오: 좌석 예약 시스템

@Service
@RequiredArgsConstructor
public class ConcertService {
    private final SeatRepository seatRepository;

    @Transactional
    public void processReservation(String seatNumber) {
        // 첫 번째 좌석 상태 확인
        Seat seat = seatRepository.findBySeatNumber(seatNumber);
        String initialStatus = seat.getStatus();

        // 복잡한 예약 로직 수행
        processSeatReservation(seat);  // 시간이 좀 걸리는 작업

        // 최종 좌석 상태 다시 확인
        seat = seatRepository.findBySeatNumber(seatNumber);
        String finalStatus = seat.getStatus();

        // 상태가 변경되었는지 확인
        if (!initialStatus.equals(finalStatus)) {
            throw new ConcurrentModificationException("좌석 상태가 변경되었습니다.");
        }
    }

    private void processSeatReservation(Seat seat) {
        try {
            // 예약 처리 로직 수행 중...
            Thread.sleep(1000);  // 실제로는 복잡한 비즈니스 로직
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

 

[문제 상황 시나리오]

초기 상태: A1 좌석 status = "AVAILABLE"

[트랜잭션 A: 예약 프로세스]
1. A1 좌석 첫 번째 조회: status = "AVAILABLE"
2. 예약 로직 처리 중...

[트랜잭션 B: 다른 예약]
1. A1 좌석 예약 수행
2. status를 "RESERVED"로 변경
3. 커밋 완료

[트랜잭션 A 계속]
3. A1 좌석 두 번째 조회: status = "RESERVED"
4. 상태 불일치 발생

결과: 같은 트랜잭션 내에서 조회한 데이터가 다름

 

4) Phantom Read (팬텀 리드)

한 트랜잭션에서 조건으로 데이터를 조회할 때, 다른 트랜잭션에 의해 해당 조건에 부합하는 데이터가 추가/삭제되어

결과 집합이 달라지는 현상

실제 시나리오: 콘서트 좌석 조회 시스템

@Service
@RequiredArgsConstructor
public class ConcertService {
    private final SeatRepository seatRepository;

    @Transactional
    public void processZoneReservation(String zoneCode) {
        // 첫 번째 구역 좌석 목록 조회
        List<Seat> seats = seatRepository.findByZoneCode(zoneCode);
        int initialCount = seats.size();  // 처음 조회한 좌석 수
        
        // 예약 가능 좌석 수 계산 및 검증
        validateZoneCapacity(initialCount);
        
        // 복잡한 예약 로직 처리 중...
        processZoneBooking(zoneCode);
        
        // 최종 좌석 수 다시 확인
        List<Seat> updatedSeats = seatRepository.findByZoneCode(zoneCode);
        int finalCount = updatedSeats.size();
        
        if (initialCount != finalCount) {
            throw new ConcurrentModificationException(
                "좌석 구성이 변경되었습니다. 다시 시도해주세요."
            );
        }
    }

    private void processZoneBooking(String zoneCode) {
        try {
            // 구역 예약 처리 중...
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
    List<Seat> findByZoneCode(String zoneCode);
}
[문제 상황 시나리오]

초기 상태: A구역 좌석 목록 [A1, A2, A3] (총 3석)

[트랜잭션 A: 구역 예약 처리]
1. A구역 좌석 목록 첫 번째 조회: 3석 확인
2. 3인 단체 예약 가능 여부 확인
3. 예약 로직 처리 중...

[트랜잭션 B: 구역 좌석 추가]
1. A구역에 임시좌석 A4 추가
2. 커밋 완료

[트랜잭션 A 계속]
4. A구역 좌석 목록 다시 조회: 4석 확인 [A1, A2, A3, A4]
5. 좌석 수 불일치 발생

결과: 같은 조건으로 조회했으나 결과 집합이 변경됨
- 처음에는 3석으로 예약 진행했는데 중간에 좌석이 추가되어
- 예약 처리 로직이 의도한 대로 동작하지 않을 수 있음

주요 차이점:

  • Non-Repeatable Read: 기존 데이터의 수정으로 인한 문제
  • Phantom Read: 데이터의 추가/삭제로 인한 문제

 

2. 트랜잭션 격리 수준의 이해

데이터베이스는 동시성 문제를 해결하기 위해 다양한 격리 수준을 제공하고 있는데요,

각 격리 수준별로 어떤 문제들이 방지되는지 알아보겠습니다.

 

2.1 격리 수준별 방지 효과

1) READ_UNCOMMITTED (레벨 0)

가장 낮은 격리 수준으로, 커밋되지 않은 데이터도 읽을 수 있습니다.

- 방지되는 문제: 없음
- 발생 가능한 문제: Dirty Read, Non-Repeatable Read, Phantom Read
- 장점: 동시 처리 성능이 가장 좋음
- 단점: 데이터 정합성을 전혀 보장할 수 없음

 

2) READ_COMMITTED (레벨 1)

대부분의 데이터베이스가 기본적으로 사용하는 격리 수준입니다.

- 방지되는 문제: Dirty Read
- 발생 가능한 문제: Non-Repeatable Read, Phantom Read
- 특징: 커밋된 데이터만 읽을 수 있음

 

3) REPEATABLE_READ (레벨 2)

동일 트랜잭션 내에서 동일한 결과를 보장합니다.

- 방지되는 문제: Dirty Read, Non-Repeatable Read
- 발생 가능한 문제: Phantom Read
- 특징: InnoDB의 경우 MVCC(Multi Version Concurrency Control)를 통해 
       Phantom Read도 일부 방지

 

4) SERIALIZABLE (레벨 3)

가장 높은 격리 수준으로, 완벽한 데이터 일관성을 제공합니다.

- 방지되는 문제: 모든 동시성 문제 방지
- 단점: 동시 처리 성능이 매우 떨어짐

 

2.2 Spring에서의 격리 수준 설정

@Transactional(isolation = Isolation.READ_COMMITTED)
public void reserveSeat(String seatNumber) {
    // 예약 로직
}

 

3. 낙관적 락과 비관적 락

이제 본격적으로 락(Lock)에 대해 알아보겠습니다. 락은 크게 낙관적 락과 비관적 락으로 나뉘는데, 각각의 특징과

동작 방식이 매우 다릅니다.

 

3.1 낙관적 락 (Optimistic Lock)

낙관적 락은 이름 그대로 데이터 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방식입니다.

@Entity
public class Seat {
    @Id
    private Long id;
    
    @Version  // 낙관적 락을 위한 버전 정보
    private Long version;
    
    private String seatNumber;
    private String status;
}
라인 별 동작 설명

@Version 애노테이션으로 버전 관리할 필드 지정

1. 엔티티가 수정될 때마다 버전이 자동으로 증가
2. 수정 시 현재 버전과 DB의 버전이 일치하는지 확인
3. 버전이 다르면 ObjectOptimisticLockingFailureException 발생
실제 동작 예시

[트랜잭션 A]
1. Seat 조회: version = 1
2. 상태 변경 시도 중...

[트랜잭션 B]
1. 같은 Seat 조회: version = 1
2. 상태 변경 및 커밋: version = 2

[트랜잭션 A]
3. 상태 변경 시도 실패 (version 불일치)


장점

  • 실제 데이터베이스 락을 사용하지 않아 성능이 좋음
  • 데드락이 발생하지 않음
  • 비교적 구현이 단순함
  • 트랜잭션을 짧게 가져갈 수 있음

단점

  • 충돌이 발생하면 롤백 후 재시도가 필요
  • 잦은 충돌 시 계속되는 재시도로 인한 성능 저하
  • 사용자에게 재시도 요청을 해야 할 수 있음

사용하면 좋은 상황

  • 데이터 충돌이 적은 경우
  • 읽기 작업이 많고 쓰기 작업이 적은 경우
  • 실시간성이 중요한 경우

 

3.2 비관적 락 (Pessimistic Lock)

비관적 락은 데이터 충돌이 발생할 것이라고 비관적으로 가정하고 아예 처음부터 락을 거는 방식입니다.

@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Seat s WHERE s.id = :id")
    Optional<Seat> findByIdWithPessimisticLock(@Param("id") Long id);
}

@Service
@Transactional
public class SeatService {
    public void reserveSeat(Long seatId) {
        // 비관적 락으로 좌석 조회
        Seat seat = seatRepository.findByIdWithPessimisticLock(seatId)
            .orElseThrow(() -> new SeatNotFoundException());
            
        // 이 시점에서 다른 트랜잭션은 이 좌석에 접근 불가
        seat.reserve();
    }
}


장점

  • 데이터 정합성을 확실하게 보장
  • 충돌이 빈번한 경우 낙관적 락보다 성능이 좋을 수 있음
  • 업데이트 성공이 보장됨

단점

  • 데이터베이스 락으로 인한 성능 저하
  • 데드락이 발생할 가능성이 있음
  • 락 획득을 위한 대기 시간이 필요
  • 트랜잭션을 길게 가져갈 수 있음

사용하면 좋은 상황

  • 데이터 충돌이 자주 발생하는 경우
  • 정합성이 매우 중요한 경우

 

3.3 실제 사용시 고려사항

1) 낙관적 락 사용시 주의점

  1. 재시도 로직 구현의 필요성
@Service
public class OptimisticLockService {
    private static final int MAX_RETRIES = 3;
    
    @Transactional
    public void updateWithRetry() {
        int retryCount = 0;
        while (retryCount < MAX_RETRIES) {
            try {
                // 업데이트 로직
                return;
            } catch (ObjectOptimisticLockingFailureException e) {
                retryCount++;
                if (retryCount == MAX_RETRIES) {
                    throw new ConcurrentModificationException("재시도 횟수 초과");
                }
                // 잠시 대기 후 재시도
                Thread.sleep(100);
            }
        }
    }
}

 

2) 비관적 락 사용시 주의점

  1. 데드락 방지를 위한 락 획득 순서 고려
  2. 타임아웃 설정의 필요성
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")})
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdWithPessimisticLock(@Param("id") Long id);

 

 

4 결론

 

동시성 제어 방식의 선택은 서비스의 특성과 요구사항을 고려해야 합니다. 충돌이 빈번하고 데이터 정합성이 매우 중요한 경우

(예: 재고 관리, 예약 시스템)에는 비관적 락을, 충돌이 적고 실시간성이 중요한 경우(예: 게시글 수정, 일반적인 데이터 수정)에는

낙관적 락을 사용하는 것이 좋습니다.

 

특히 대규모 동시 접속이 예상되는 서비스에서는 두 방식을 적절히 혼합하여 사용하는 것도 고려해볼 수 있습니다.

예를 들어, 첫 단계에서는 낙관적 락을 시도하고, 실패가 빈번하다면 비관적 락으로 전환하는 방식을 사용할 수 있습니다.