본문 바로가기
  • O의 정보창고
전공 공부/spring

동시성(Concurrency) 제어 - 비관적 락, 낙관적 락, 분산 락

by 창고지기 O 2026. 1. 6.

개발을 하다보면, 하나의 자원에 다수의 사용자가 동시에 접근하는 상황이 꽤나 자주 발생한다.

같은 자원(예를 들어, DB)에 동시에 접근할 경우, 충돌이 발생하여 데이터 일관성이 깨지거나 시스템 상 치명적인 오류를 발생시키기도 한다.

 

대표적인 예시로는 은행 시스템이 있다.

나의 계좌(100,000원)에서 입금(20,000원)과 출금(10,000원)이 동시에 발생하고 있다고 생각해보자.

내가 출금 요청을 하는 중에 입금 요청이 들어왔고, 요청이 완료되었다. 그 후에 출금 요청이 완료되었다.

이 경우에 기댓값은 100,0000 - 10,000 + 20,000 = 110,000원이 될 것이다.

 

그러나 이 경우에는 90,000원이 결과로 나오게 된다.

왜 그럴까?

 

 

출금 연산을 시작했을 때, 서버는 DB에서 100,000원이라는 금액을 데이터로 가져왔을 것이다.

그리고 여기서 -10,000원이라는 연산을 수행하게 된다. 그러면 결과가 90,000원이 된다.

원래라면 20,000원이 입금된 것이 추가로 반영이 되어야겠지만,

입금 요청은 별개로 처리되었고, 나의 출금 요청이 입금 요청보다 이후에 완료되었기 때문에 최종적으로 DB에 반영되는 것은 90,000원이 되는 것이다!

반대로 출금 기록은 반영되지 않고, 입금만 처리될 수도 있다.

 

즉, 데이터 정합성이 훼손된 것이다.

 

이를 해결하기 위한 방법으로 '동시성 제어'가 거론된다.

 

다익스트라 알고리즘으로 유명한 다익스트라가 제안한 '상호 배제' 개념을 기반으로 동시성 제어가 구현되는데,

운영체제에서 다루는 '뮤텍스(Mutex)', '스핀락(Spin-Lock)', '세마포(Semaphore)'와 같은 기본 개념은 추후에 더 세세하게 다루는 것으로 하고, 여기서는 DB와 관련한 동시성 제어만 다뤄보도록 하겠다.

 


 

비관적 락 vs. 낙관적 락 vs. 분산 락

 

비관적 락

비관적 락은 "충돌이 반드시 일어날 것"이라고 비관적으로 가정한다.

반드시 일어나는 것 뿐만 아니라, "매우 빈번하게 일어날 것"이라고 가정한다.

따라서 적극적으로 해결하는 것이 핵심 로직이다.

 

DB의 특성/성질을 이용하는데, 데이터를 읽는 시점에서 DB 수준에서 물리적인 락을 걸어버리는 것이다.

테스트 과정에서는 select 후 update를 바로 진행하기 때문에, select에서 락을 걸어버린다.

	@Lock(LockModeType.PESSIMISTIC_WRITE) // select ~ for update
	@Query("select c from Coupon c where c.id = :id")
	Optional<Coupon> findByIdWithLock(@Param("id") Long id);

 

그러면 update를 하는 동안 다른 것들은 이 데이터에 접근할 수 없다. 읽지도 쓰지도 못하게 된다.

 

 

낙관적 락

낙관적 락은 "충돌이 별로 안 일어날 것이다"라고 낙관적으로 가정한다.

따라서 충돌이 발생했을 때 어떻게 해결할 것인지를 코드로 작성한다.

 

// #2. 낙관적 락을 위한 버전 관리 필드
@Version
private Long version;

@Component
@RequiredArgsConstructor
public class CouponFacade {
	
	private final CouponService couponService;
	
	public void issue(Long couponId) throws InterruptedException{
		// 특정 Thread 가 호출하면 성공할 때까지 반복적으로 재시도
		while(true) {
			try {
				couponService.issue(couponId);
				// 예외발생하지 않고 issue() 가 수행되면 성공
				break;
			}catch(ObjectOptimisticLockingFailureException e) {
				// 버전 충돌 발생
				// 잠깐 대기 후 다시 while 을 통해서 재시도
				System.out.println(e.getMessage());
				Thread.sleep(50);
			}catch(Exception e) {
				// 시스템 이상으로 인한 예외
				break;
			}
		}
	}
}

 

DB에 물리적인 락을 거는 대신 버전(version) 번호를 이용하는데, 수정 시에 기존에 읽었던 버전과 일치하는 버전인지 확인하는 작업을 거친다. 이때, 일치하지 않으면 충돌이 난 것으로 판단하여 해결 로직을 가동하게 된다.

해결 로직은 다양하게 나타날 수 있는데, 단순히 실패로 처리하고 끝낼 수도 있고, 실패한 스레드를 다시 시도할 수도 있다.

테스트 과정에서는 실패한 스레드를 다시 시도하였다.

 

 

낙관적 락은 기본적으로 충돌이 많이 발생하지 않을 것으로 예상되는, 읽기 위주의 시스템에 적용된다.

만약 동시성 문제가 많이 발생하면 수행 시간이 비관적 락보다 오래 걸리게 되며,

반대로 동시성 문제가 거의 발생하지 않으면 수행 시간이 훨씬 적게 소요된다.

 

 

분산 락

분산 락은 DB가 아닌 외부 시스템을 이용해 동시성 처리를 진행한다.

일반적으로 "하나의 자원에 여러 개의 서버에서 동시에 접근할 경우"에 사용하는 방식이다.

// build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation("org.redisson:redisson-spring-boot-starter:4.1.0")
}

@Component
@RequiredArgsConstructor
public class CouponFacade {
	private final RedissonClient redissonClient; // redis 를 통한 분산락 구현 라이브러리 클라이언트
	private final CouponService couponService;
	
	public void issue(Long couponId){
		RLock lock = redissonClient.getLock("coupon_lock:" + couponId); // 락 이름 설정 ( couponId 별 유일 )
		
		try {
			// 락 획득 시도 ( 줄 서기 )
			// waitTime : ~ 동안 기다릴거냐
			// leaseTime : 락을 얻고난 후 자동 반납 시간 (길면 락을 통한 작업을 안정적으로 할 수 있지만, 시간이 오래 걸리고, 반대면 시간은 짧게 걸리지만 덜 안정적으로 처리 )
			boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 10 초 대기
			
			// 대기 결과 확인
			if( ! available ) {
				// 비즈니스 로직별 처리
				System.out.println("현재 사용자가 너무 많습니다.");
				// 재시도 또는 종료
				return;  // 이 Thread 는 실패로 종결
			}
			
			// 락 획득 성공
			couponService.issue(couponId);
			
		}catch(Exception e) {
			
			throw new IllegalArgumentException("시스템 예외 발생");
			
		}finally {
			// 락 반납
			// 락이 잠겨있고, 현재 Thread 에 의한 것이면
			if( lock.isLocked() && lock.isHeldByCurrentThread()) {
				lock.unlock();
			}
		}
	}
}

 

Redis와 같은 외부 저장소를 사용하게 되는데, A 서버가 자원을 선점하여 작업 중이라고 깃발을 꽂으면, B 서버는 A 서버가 모든 작업을 마치고 깃발을 내릴 때까지 기다려야 한다.

즉, 자원 접근 자체를 막고 모든 처리를 한 뒤에 락을 해제하면 다른 서버가 자원에 접근할 수 있게 되는 것이다.

이를 다시 정리해보면

락 획득 -> 트랜잭션 시작 -> 비즈니스 로직 -> 트랜잭션 종료 -> 락 해제

순서로 설계하는 것이다.

 

 

앞서 다루었던 AOP를 적용하면 이를 구현할 수 있다.

 

[Spring] AOP(Aspect-Oriented Programming) 와 Logging

1. AOP와 LoggingAOP (Aspect-Oriented Programming, 관점 지향 프로그래밍)코드 중복을 제거하고, 핵심 로직을 깔끔하게 유지하여 모듈성 및 유지보수성을 높이는 것을 목적으로 하는, 횡단 관심사(부가 기능

once-storage.tistory.com