1223v

낙관적 락(Optimistic Lock) 비관적 락(Pessimistic Lock) [feat. 레디베리 Race Condition 문제] 본문

개발/개발 고찰

낙관적 락(Optimistic Lock) 비관적 락(Pessimistic Lock) [feat. 레디베리 Race Condition 문제]

1223v 2025. 2. 13. 12:44

고민의 시작


 

  • 프로젝트을 진행하면서, 다른 사람들이 구현한 기능에는 간단한 지식만으로 알고 넘어간 부분이 있었다.
  • 하지만, 팀원이 구현한 코드 일지라도 우리 서비스에 대한 문제 해결 방식을 설명하기 위해 팀원이 구현한 해결 방법은 설명할 수 있어야 한다고 생각한다.
  • 번외로 만약 내가 구현했다면 어떻게 해결할 것인가? 라는 질문이 왔을때, 명확하게 대답하지 못하는 내 자신을 보고 아는것과 본 것에 대한 명확한 구분선을 지어야 겠다고 생각했다.

 

 

 

 

왜 이 고민이 필요해?


 

  • Redis 도입 시, 관리포인트 문제를 고려하지 않을 수 없다.
  • 만약, mysql로 쿠폰 발급을 한다면 어떻게 해결할 수 있을까?

 

 

기존 방식


 

  • 확인해보니 우리 서비스는 쿠폰 방식은 @Lock(PESSIMISTIC_WRITE) 방식을 사용하고, 주문 번호를 INCR로 해결했다.. 
  • 다만, 두 상황 모두 race condition이 발생했고, 발생하는 원인에 대한 구현 방식이 비슷함으로 각각의 해결론에 대한 차이만 알아두면 좋을거 같다
  •  

 

 

 

변경 방식


  • 만약 redis 도입이 불가능할 때, MYSQL로 문제를 해결할 수 있는 방법을 제안해보겠다.
  • 그 전에 다양한 락 방식과 전략에 대해 배워보겠다.

 

 

 

낙관적 락 / 비관적 락


낙관적 락(optimistic lock)

낙관적 락은 동시에 여러 사용자가 같은 데이터를 수정할 가능성이 낮다고 가정하고,

수정할때, 버전 정보를 비교하여 충돌을 감지하는 방식

  • 수정할 때 버전 정보를 비교하여 충돌을 감지하는 방식

즉, 데이터가 변경될 때만 충돌을 감지하고, 충돌이 발생하면 예외를 던져 수정 실패 처리

비관적 락처럼 미리 락을 걸지 않기 때문에, 동시성을 높일 수 있다.

 

 

 

낙관적 락이 필요한 이유

  1. 동시성 문제 해결
  • 여러 사용자가 동시에 데이터를 수정하면 최신 데이터가 덮어씌어질 가능성이 존재
  • 비관적 락은 미리 락을 걸어 다른 트랜잭션의 접근을 차단하지만, 낙관적락은 수정할 때 버전 검사를 통해 충돌 감지
-- Thread-1이 먼저 사용자 A의 계좌 잔액을 조회
SELECT balance, version FROM account WHERE user_id = 1;

-- Thread-2도 동시에 사용자 A의 계좌 잔액을 조회
SELECT balance, version FROM account WHERE user_id = 1;

-- Thread-1이 계좌에서 100원을 출금
UPDATE account SET balance = balance - 100, version = version + 1
WHERE user_id = 1 AND version = 1;

-- Thread-2도 계좌에서 200원 출금 (그러나 version이 1 이라 실패)
UPDATE account SET balance = balance - 200, version = version + 1
WHERE user_id = 1 AND version = 1;

result.

  • Thread-2의 UPDATE 문이 version 충돌로 실패하면서 데이터 정합성이 유지됨.
  • 비관적 락처럼 다른 트랜잭션을 차단하지 않으므로 동시성이 높음.

 

 

낙관적 락의 동작 원리

  • 낙관적 락은 보통 Version 또는 Timestamp를 사용하여 동작합니다.

 

  1. Version 기반 낙관적 락
  • 데이터를 조회할 때, 현재 버전을 함께 조회
  • 데이터를 수정할 때 현재 버전이 그대로인지 확인한 후 업데이트
  • 버전이 변경되었으면 업데이트 실패(충돌 발생)
-- 데이터 조회
SELECT balance, version FROM account WHERE user_id = 1;
f
-- 데이터 수정 시, 버전이 변경되지 않았는지 확인 후, 업데이트
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE user_id = 1 AND version  = 1;

 

 

JPA에서 낙관적 락 적용 방법

  1. @Version 사용하여 낙관적 락 적용
import jakarata.persistence.*;

@Entity
public class Account {

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

    private int balance;

    @Version
    private int version;

    public void decreaseBalance(int amount) {
        if (this.balance < amount) {
                throw new IllegalStateException("잔액 부족");
        }
        this.balance -= amount;
    }
}

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {

}

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;

    @Transactional
    public void withdraw(Long accountId, int amount) {
        // 계좌 조회(버전 정보 포함)
        Account account = accountRepository.findById(accountId)
                        .orElseThrow(() -> new IllegalArgumentException("계좌 없음"));
        // 잔액 차감                
        account.decreaseBalance(amount);
        // 자동으로 버전 업데이트 & 동시성 충돌 감지
        accountRepository.save(account);

 

result.

JPA는 @Version 필드가 있는 엔티티를 저장할 때, 자동으로 version 을 증가시키고 충돌을 감지

만약 버전이 변경되었으면 OptimisticLockException 발생 → 충돌 해결 필요

 

 

낙관적 락의 장단점

 

 

장점

비관적 락보다 성능이 좋음

데이터 충돌이 적은 경우 락을 미리 걸지 않으므로 동시성이 높아짐

  • 데이터 정합성을 보장하면서도 락을 최소화

비관적 락은 SELECT ... FOR UPDATE 로 인해 동시성이 낮아질 수 있음

  • 데드락 가능성 없음

트랜잭션이 대기하지 않으므로 교착 상태 발생 가능성 없음

 

 

 

단점

동시 충돌이 많으면 성능 저하 가능

  • UPDATE 시 자주 충돌이 발생하면 재시도가 많아지고 성능이 저하될 수 있음. (충돌이 발생하면 수동으로 해결해야 함)

OptimisticLockException 이 발생하면 재시도 로직을 추가해야 함

 

낙관적 락을 사용해야 하는 경우

충돌 가능성이 낮지만 데이터 정합성이 중요한 경우

읽기 비중이 높고, 쓰기 비중이 낮은 경우

대기 시간 없이 빠른 동시 처리가 필요한 경우

하지만, 충돌이 많으면 성능이 저하될 수 있으므로 신중히 선택해야 함.

 

 

 

비관적 락(pessimistic lock)

비관적 락은 다른 트랜잭션이 데이터를 동시에 수정할 가능성이 높다고 가정했을 때,

조회하는 순가부터 데이터에 락을 걸어서 다른 트랜젝션이 해당 데이터를 수정하지 못하도록 하는 방식이다.

즉, 트랜잭션이 데이터를 수정하기 전에 미리 락을 걸어 다른 트랜젝션이 접근하지 못하게 한다.

 

비관적 락이 필요한 이유

  1. 동시성(Race Condition) 문제 해결
  • 다중 사용자가 같은 데이터를 동시에 수정하려고 하면 데이터 정합성이 깨질 가능성이 있다.
  • 이를 방지하기 위해 트랜젝션이 데이터를 조회하는 순간부터 다른 트랜젝션이 접근하지 못하도록 락을 거는 것.
-- Thread-1이 먼저 사용자 A의 계좌 잔액을 조회
SELECT balance FROM account WHERE user_id = 1;

-- Thread-2도 동시에 사용자 A의 계좌 잔액을 조회
SELECT balance FROM account WHERE user_id = 1;

-- Thread-1이 계좌에서 100원을 출금
UPDATE account SET balance = balance - 100 WHERE user_id = 1;

-- Thread-2도 계좌에서 200원을 출금 (하지만, 아직 Thread-1의 업데이트가 반영되지 않음)
UPDATE account SET balance = balance - 200 WHERE user_id = 1;

 

result.

  • Thread-1이 balance = 900 으로 설정하고 Thread-2가 balance = 800 으로 설정하면서 정확한 출금 내역이 반영되지 않음
  • 이러한 경쟁 조건을 방지하려면 락이 필요하다.

 

 

비관적 락 종류

비관적 락은 트랜잭션이 데이터에 접근하는 방식에 따라 여러 유형으로 나뉜다

  1. 공유 락 (Shared Lock, READ Lock)
  • 다른 트랜잭션이 데이터를 읽을 수 는 있지만 수정은 불가능하게 만드는 락
  • 일반적으로 SELECT ... LOCK IN SHARE MODE 사용
SELECT balance FROM account WHERE user_id = 1 LOCK IN SHARE MODE;
  • 다른 트랜잭션이 읽을 수 있지만, 데이터를 변경할 수 없음.
  • 공유 락이 걸려 있는 동안 UPDATE 또는 DELETE 실행 시 대기 또는 실패

 

  1. 배타적 락(Exclusive Lock, WRITE Lock)
  • 현재 트랜잭션이 데이터를 수정할 때, 다른 트랜잭션이 읽기 및 수정을 하지 못하도록 하는 락
  • 가장 강력한 락이지만 동시성이 떨어질 수 있다.
  • SELECT … FOR UPDATE 를 사용하면 해당 데이터에 대해 쓰기 락이 걸림
SELECT balance FROM account WHERE user_id = 1 FOR UPDATE;
  • 다른 트랜잭션이 동일한 데이터를 읽거나 수정하려고 하면 대기(Block) 됨.
  • 트랜잭션이 종료되기 전까지 다른 트랜잭션은 해당 행을 수정할 수 없음.

 

JPA에서 비관적 락 적용 방법

비관적 락은 JPA에서도 @Lock 어노테이션을 사용하여 쉽게 적용할 수 있다.

 

  1. PESSIMISTIC_READ (공유 락)
  • 다른 트랜잭션이 해당 데이터를 읽을 수 있지만 수정은 불가
  • 읽기 전용 데이터를 보호하고 싶을 때 사용
@Lock(LockModeType.PESSIMISTIC(READ)
@Query("SELECT a FROM Account a WHERE a.userId = :userId")
Optional<Account> findByUserIdWithReadLock(Long userId);
  • 읽기 가능 (다른 트랜잭션도 데이터 조회 가능)
  • 쓰기 불가 (다른 트랜잭션이 해당 데이터를 수정할 수 없음)
  • 트랜잭션이 종료될 때까지 수정이 불가능하므로 데이터 정합성을 보장

 

  1. PESSIMISTIC_WRITE (배타적 락)
  • 다른 트랜잭션이 데이터를 읽거나 수정하지 못하도록 강한 락을 설정
  • 트랜잭션이 종료될 때까지 해당 데이터에 접근 불가능
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.userId = :userId")
Optional<Account> findByUserIdWithWriteLock(Long userId)
  • 읽기 불가능(다른 트랜잭션이 데이터를 조회할 수 없음
  • 쓰기 불가능 (다른 트랜잭션이 데이터를 수정할 수 없음
  • 트랜잭션이 종료될 때까지 다른 요청을 차단

 

 

비관적 락의 장단점

 

장점

데이터 정합성을 강력하게 보장

데이터 충돌을 사전에 방지(낙관적 락과 달리 UPDATE 충돌 없음)

중복 작업을 방지할 수 있음(예: 같은 계좌에서 여러번 출금하는 문제 해결 가능)

 

 

단점

성능 저하 가능성

  • 락이 걸려 있는 동안 다른 트랜잭션은 대기(Block)해야 하므로 성능이 저하될 수 있음.
  • 동시에 많은 사용자가 접근하면 Deadlock(교착상태)이 발생할 가능성 증가

트랜잭션 길이가 길어지면 락이 오래 유지될 수 있음

  • 트랜잭션이 오래 지속되면 락이 풀리지 않아 서비스 장애로 이어질 수도 있음

 

 

비관적 락을 사용해야 하는 경우

  • 동시에 여러 사용자가 데이터를 수정할 가능성이 높은 경우
  • 데이터 정합성이 최우선인 경우 (예: 계좌 이체, 좌석 예약, 재고 감소)
  • 충돌이 발생했을 때 롤백하는 것보다, 애초에 충돌이 발생하지 않도록 방지하는 것이 중요한 경우

단, 성능저하를 고려해야 하므로 필요할 때만 사용

 

 

고찰


  • 우리와 반대로 기업의 서비스가 해당 기술과 반대되는 기술을 선호하는 이유 고찰
  • 위 내용을 보았을 때, Redis보다 MySQL의 락을 사용하는 이유는 트랜잭션과 함께 락을 걸어야 할 경우, 기존 데이터 베이스 시스템을 활용해야 하는 경우 라고 생각한다.
  • 즉, MySQL 인가 Redis 인가 역시 서버의 상태를 보고 결정하는 것이 맞다고 생각한다.
  • 쿠폰 같은 경우는 1인당 1개만 발급이여야 하기 때문에 데이터의 일관성이 중요하고, 트랜잭션과 함께 락이 걸리는 경우가 많다. 때문에 기존 데이터 베이스 시스템을 활용해야 하는게 적합하다.
  • 하지만 주문번호는 단순히 증가하는 숫자이고, 하루단위로 가게별 주문번호는 리셋되어야 하므로 Redis의 TTL과 INCR로 원자적 연산을 통해 간단하게 구현할 수 있으므로 적절하다고 생각한다.
  • 또한, 우리 서비스는 redis를 도입할 당시 도입으로 인해 발생하는 side effect나 관리 포인트가 과하게 늘지않을것으로 확인되었기에 해당 방식으로 해결하는 것이 적합하다고 생각했다.

 

 

느낀점


  • 기술에는 정답이 없는것 같다.
  • 해당 문제를 MySQL이나 Redis 모두 각각의 스펙에 맞게 해결하는 방법이 존재했다. 늘 고민해야하는 것은 우리 서비스에 대한 상태와 발생한 상황을 명확하게 판단하는 것이다.
  • 우리는 현재 자원으로 최고의 방법을 적용해야하는 것이 아닌 최선의 방법을 적용해야한다.
  • 결국 서비스를 안정적으로 구동하기 위한 선택을 해야한다는 것이다.

신기술에 관심이 있고 적용하여 배우는 것은 좋지만, 결코 기술에 매몰되서는 안되며, 우리 서비스에 맞는 치료법을 적용해야한다는 것을 항상 마음 속에 새겨야 한다.

728x90