본문 바로가기
항해 플러스 백엔드/대용량 트래픽 처리

[항해] 6주차, 동시성 제어 방식 분석

by 코딩맛 2025. 2. 16.

 

항해 6주차에는 동시성 제어 방식에 대해 학습하였다~!

콘서트 예약 서비스의 주요 시나리오에서 동시성 이슈가 발생하는 상황을 분석하고,

기능별 성격에 따라 적합한 동시성 제어 방식을 적용하는 것까지 진행해보았다!

 

분산락도 락이다😁

목차
1. 동시성 문제란?
2. DB의 동시성 문제
3. 데이터베이스락
4. 분산락
5. 시나리오에 락 적용
6. 회고

 

1. 동시성 문제란?

동시성 문제(Concurrency Issue)란 다수의 프로세스나 스레드가 동시에 공유 자원(데이터, 메모리, 데이터베이스 등)에 접근하거나 수정하는 과정에서 발생하는 문제이다. 이를 제대로 해결하지 못하면 데이터의 일관성이 깨지거나 예상치 못한 오류가 발생할 수 있다.

 

예를 들어서 인기 콘서트 티켓 예매를 생각해보자. 유저 A와 B가 동시에 "예매하기" 버튼을 눌렀을 때, 마지막 남은 좌석이 하나라고 가정하면 과연 누구에게 좌석이 배정될까? 동시성 처리를 제대로 하지 않으면, 두 명 모두 예매가 완료되어 실제 좌석보다 많은 티켓이 판매될 수도 있다!😮😨

이런 문제를 방지하려면 동시성을 관리하는 기법이 필요하다.


2. DB의 동시성 문제

 

데이터베이스에서 동시성 문제가 빈번하게 발생하는데, 대표적인 문제로는 다음과 같다.

  • Lost Update (갱신 손실): 두 개의 트랜잭션이 같은 데이터를 수정할 때, 한 트랜잭션의 변경 사항이 다른 트랜잭션에 의해 덮어씌워지는 문제이다.
  • Dirty Read (더티 리드): 하나의 트랜잭션이 아직 커밋되지 않은 데이터를 읽었을 때 발생하는 문제로, 만약 해당 트랜잭션이 롤백된다면, 읽은 데이터는 잘못된 정보가 된다.
  • Non-repeatable Read (비반복적 읽기): 같은 데이터를 두 번 읽었을 때, 중간에 다른 트랜잭션이 값을 변경하여 처음과 다른 결과가 나오는 문제이다.
  • Phantom Read (팬텀 리드): 같은 조건으로 여러 번 조회했을 때, 중간에 다른 트랜잭션이 데이터를 삽입 또는 삭제하여 결과가 달라지는 현상이다.

이러한 문제를 방지하기 위해 데이터베이스에서는 트랜잭션 격리 수준과 락(Lock) 메커니즘을 제공한다.

 

3. 데이터베이스락

데이터베이스에서 동시성을 제어하는 대표적인 방법 중 하나가 락(Lock)이다.

락을 걸면 특정 트랜잭션이 완료되기 전까지 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 제한할 수 있다.

DB Lock 을 이용해 동시성 제어하는 방식에서 중요한 점은 트랜잭션 간 '충돌' 의 빈도이다.

락의 종류에는 다음과 같은 것들이 있다.


낙관적 락 (Optimistic Lock)

  • 데이터 충돌이 거의 발생하지 않는다고 가정하고, 트랜잭션이 끝날 때 충돌이 있는지 확인하는 방식이다.
    일반적으로 버전 번호(versioning) 를 사용하여 변경 여부를 체크한다.
  • 주의) 충돌 발생 시 재시도 로직을 반드시 구현해야 하며, 데이터 변경이 많은 환경에서는 성능 저하가 발생할 수 있다.

 

 

비관적 락 (Pessimistic Lock)

  • 데이터 충돌이 자주 발생할 것으로 예상하고, 데이터를 읽거나 수정할 때 다른 트랜잭션이 접근하지 못하도록 미리 락을 거는 방식이다. 일반적으로 SELECT ... FOR UPDATE 같은 쿼리를 사용한다.
  • 주의) 데드락(Deadlock) 방지를 위해 트랜잭션 시간을 최소화하고, 필요한 최소 범위에서만 Lock을 사용해야 한다.

  • 공유락(S-lock)
    • 읽기(READ) 작업이 발생할 때 설정된다.
    • 여러 트랜잭션이 동일한 데이터에 대해 공유락을 가질 수 있다.
    • select ..from.. where ... for share
  • 배타락(X-lock)
    • 쓰기(Write) 작업이 발생할 때 설정된다.
    • 배타락이 설정된 데이터는 다른 트랜잭션이 읽기(READ)나 쓰기(WRITE) 작업을 할 수 없다.
    • select ..from .. where... for update


4. 분산락

분산 시스템에서는 여러 서버가 같은 데이터를 공유하는 경우가 많기 때문에, 단순한 데이터베이스 락만으로 동시성을 해결하기 어렵다. 이런 경우 분산 락을 활용할 수 있다.

분산 락(distributed lock)을 구현을 위해 Redis가 자주 활용되는데 이는 경량의 키-값 저장소로, 주로 SET 명령어와 TTL(Time-to-Live)을 조합하여 동시성 문제를 해결한다.

 

Redis를 활용한 분산락의 대표적인 방식으로는 스핀락과 Pub/Sub이 있다.

 

스핀락(Spin Lock)

  • 락 획득 방식은 락을 획득하지 못한 경우 락을 획득하기 위해 Redis에 계속해서 요청을 보낸다.
  • Redis에 부하가 생길 수 있다는 단점이 있다.
재시도 횟수 = 0
while (true) {
  락 획득 여부 = redis 에서 key 로 확인 // SETNX key "1"
  if (락 획득) {
		try {
		  로직 실행
		} finally {
		  lock 제거
		}
		break;
  } else {
	  재시도 횟수 ++
	  if (재시도 횟수 == 최대 횟수) throw Lock 획득 실패 예외
	  시간 지연 ( 대기 )
  }
}

 

Pub/Sub

  • 레디스 Pub/Sub 기능을 활용해 락 획득을 실패 했을 시에, '구독' 하고 차례가 될 때까지 이벤트를 기다리는 방식을 이용해 효율적인 Lock 관리가 가능하다.
락 획득 여부 = redis 에서 lock 데이터 에 대한 subscribe 요청 및 획득 시 값 반환
// 특정 시간 동안 Lock 에 대해 구독
if (락 획득) {
	로직 실행
} else {
	// 정해진 시간 내에 Lock 을 획득하지 못한 경우
	throw Lock 획득 실패 예외
}

 

 

추가로 분산락은 데이터 무결성을 보장하기 위해서 아래와 같은 순서에 맞게 수행됨을 보장하여야 한다.

락(lock) 획득 이전에 트랜잭션이 시작되거나 트랜잭션 종료 전에 락이 해제되는 경우, 데이터 불일치 문제가 발생할 수 있다.

락 획득 ➡️ 트랜잭션 시작 ➡️ 비즈니스 로직 수행 ➡️ 트랜잭션 종료 ➡️ 락 해제

 

트랜잭션이 먼저 시작된 뒤 락을 획득할 때

  • 트랜잭션이 시작되어 데이터를 조회한 이후에 락을 획득하면, 동시에 처리되고 있는 앞단 트랜잭션의 커밋 결과를 확인하지 못한 채 재고를 조회해와 차감하는 로직을 수행하게 되므로, 정상적으로 재고를 차감할 수 없다.

락이 먼저 해제된 뒤 트랜잭션이 커밋될 때

  • 락이 해제된 이후에 트랜잭션이 커밋된다면, 대기하던 요청이 락 해제 이후 트랜잭션의 커밋 반영 이전의 재고를 조회해와 차감하는 로직을 수행하게 되므로, 마찬가지로 정상적으로 재고를 차감할 수 없다.


5. 시나리오에 락 적용

동시성 문제 발생이 가능한 상황으로는 포인트 충전과 좌석 예약이 있다.

두 시나리오의 문제 상황을 예상하고, 상황 분석한 뒤 락을 비교하고 코드에 적용해보았다.

 

포인트 충전 - 동일한 사용자가 여러 번 충전을 시도하거나 조회하는 경우

 

문제 상황

  • 사용자 A가 1000 포인트 충전
  • 충전 중 다른 스레드가 사용자 A 계정을 조회하여 이전 값 반환
  • 충전 이전 포인트를 조회하여 잘못된 값을 읽거나 충전 금액이 유실될 수 있음

상황분석 

  • 충돌 가능성 : 낮음 - 동일한 사용자가 포인트를 동시에 충전할 가능성 적음
  • 동시성 요구 : 높음 - 다수의 사용자가 동시에 포인트 충전 가능해야 함

락 비교

  • 낙관적락 - 충돌 가능성이 낮으므로 높은 성능은 유지함

  • ✅ 비관적락 - 포인트 충전은 데이터 무결성과 안정성이 매우 중요하므로, DB 트랜잭션과 비관적 락을 활용하는 것이 가장 적합함

  • 분산락 - 높은 동시성을 제공하며 충돌 발생 시 빠르게 재시도가 가능하다는 이점이 있으나, 포인트 충전 시엔 충돌이 적기 때문에 DB 락으로 충분히 제어 가능함

 

코드 적용

 @Transactional
 public User charge(Long userId, BigDecimal amount) {
     User user = userRepository.findByIdWithPessimisticLock(userId)
             .orElseThrow(()-> new UserNotFoundException("사용자의 정보가 없습니다."));

     BigDecimal balanceBefore = user.getPointBalance();

     User userUpdate = user.charge(amount);
}
@Repository
 public interface UserJpaRepository extends JpaRepository<User, Long> {
 
     @Lock(LockModeType.PESSIMISTIC_WRITE)
     @Query("SELECT l FROM User l WHERE l.id = :userId")
     Optional<User> findByIdWithPessimisticLock(@Param("userId") Long userId);
 }

 


좌석 예약 - 동일한 좌석을 여러 사용자가 동시에 예약하려고 시도하는 경우

 

문제 상황

  • 사용자 A, B가 동시에 같은 좌석 선택
  • 두 사용자가 동시에 예약을 시도하면, 데이터베이스에 충돌 발생

상황분석

  • 충돌 가능성 : 매우 높음 - 동일 좌석에 대한 여러 사용자의 경쟁 예약
  • 동시성 요구 : 높음 - 다른 좌석은 동시에 예약 가능

락 비교

  • 낙관적 락 : 충돌 발생이 잦은 좌석 예약의 경우엔 재시도로 성능이 저하될 수 있음

  • 비관적 락 : 충돌 방지에는 적합하지만, 데드락이 발생할 수 있어 동시성 처리 효율이 저하됨

  •  분산 락 : 좌석별로 락을 설정하고 TTL을 통해 데드락을 방지할 수 있는 Redis가 충돌 방지와 성능 측면에서 가장 적합함

 

코드 적용 (핵심 부분만)

@DistributedLock(key = "#reservationParam.scheduleId")
public ReservationResult createSeatReservation(ReservationParam reservationParam) {
   //1. 좌석(seat) 상태 업데이트 (점유)
   Seat updatedSeat = concertService.updateSeatStatus(reservationParam.seatId(), SeatStatus.OCCUPIED);
}
@Around("@annotation(kr.hhplus.be.server.interfaces.aop.DistributedLock)")
     public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
         MethodSignature signature = (MethodSignature) joinPoint.getSignature();
         Method method = signature.getMethod();
         DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
 
         String key = REDISSON_LOCK_KEY + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); // 키 이름 생성
         RLock rLock = redissonClient.getLock(key);
         log.info("key : {}, rLock : {}", key, rLock);
         try {
             boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeunit());
 
             if (!available) {
                 log.error("lock timeout");
                 return false;
             }
             return aopTransaction.proceed(joinPoint);
         } catch (InterruptedException e) {
             throw new RuntimeException(e);
         } finally {
             try {
                 rLock.unlock();
             } catch (IllegalMonitorStateException e) {
                 log.info("Redisson Lock Already Unlock serviceName = {}, Key = {}", method.getName(), REDISSON_LOCK_KEY);
             }
         }
     }
 
 }
@Component
 public class AopTransaction {
     @Transactional(propagation = Propagation.REQUIRES_NEW)
     public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
         return joinPoint.proceed();
     }
 }

 

 

6. 회고

이번 주차엔 동시성을 제어할 수 있는 DB락과 분산락의 종류를 알아보았는데, 락의 특성을 잘 이해하여 시나리오 별로 빠른 성능과 데이터 정합성 둘 중에 더 중요하다고 판단하는 포인트에 따라 락을 적절히 걸어주어야 했다. 동시성 문제가 발생 가능한 예측 상황을 분석하고 비교하는 과정에서 많은 고민이 필요했다.

특히, 비관적 락의 배타 락만 사용하면 데드락이 발생할 위험이 있어, 낙관적 락과 비관적 락의 공유 락을 함께 사용하는 방식도 고려할 수 있었다. 직접 적용해보지는 않았지만, 실제로 데드락 이슈가 발생하면 활용할 수 있을 것 같다.

DB 락과 분산 락을 비교·분석하는 과정을 통해 동시성 제어 방식을 더욱 깊이 있게 학습할 수 있었고, 6주차는 전체 과정 중 가장 집중했던 주차라고 말할 수 있을 것 같다! 💡