대기열을 이용한 [콘서트 예약 서비스] - 동시성 문제 돌아보기

Kyungmin·2024년 11월 5일
1

Spring

목록 보기
30/39
post-thumbnail

저번 포스트에서는 내가 구현하고 있는 프로젝트에서 어떠한 동시성 문제가 생길 수 있을까에 대해 고민해보았다. 나는 결제에 대해서는 많은 접근이 일어나지 않을 것이라고 판단하였고 때문에 성능적으로 더 이점을 줄 수 있는 낙관적 락을 채택하였다.(통합테스트 통과 못해서 힘들었...)

또한 동시에 여러 사람이 동일 좌석 예약을 시도할 때는 서비스 특성 상 가장 많이 사람들이 접근할 것이라고 생각하였고, 조회가 빈번할 것이기 때문에 비관적 락을 채택하였다.

✅ 짚고 넘어가자 - 낙관적 락 고려요소

수정에 실패했을 때, 해당 비즈니스 로직의 실패로 이어져도 되는 경우여야 한다.

✅ 짚고 넘어가자 - 비관적 락으로 해소 가능한가?

비관적 락은 낙관적 락에 비해 잠금을 위한 범위가 커질 수 있다.
수정과 조회가 빈번하게 이루어지는 경우 쿼리에서는 락 경합이 빈번하게 발생할 수 있다.
또한 락이 잠그는 범위가 큰 경우는, 개발자가 의도하지 않은 다른 테이블에 대한 조회에서도 비관적 락에 대한 대기 문제가 발생할 수 있다.
하지만 비관적 락은 '빠르게 처리 가능'하며 '반드시 순서대로 성공해야 하는 작업'에 대해서 매우 효과적인 해결책이 될 수 있다.

✅ 짚고 넘어가자 - 분산 락으로 해소 가능한가?

Redis 등을 활용한 외부 Resource 를 통해 불필요한 DB Connection 까지 차단 가능하다.
하지만 관리주체가 DB + Redis 와 같이 늘어남에 따라 다양한 문제 파생으로 이어진다.
또한 Lock 의 관리주체가 다운되면 서비스 전체의 Down 으로 이어질 수 있는 문제가 있다.
하지만 Redis 의 높은 원자성을 활용해 프로세스 처리단위에 대한 동일한 Lock 을
여러 인스턴스에 대해 적용할 수 있으므로 매우 효과적일 수 있고, DB 의 Conection 이나
오래 걸리는 I/O 에 대한 접근 자체를 차단할 수 있으므로 DB 에 가해지는 직접적인 부하를
원천 차단할 수 있으므로 효과적이다.

✅ 짚고 넘어가자 - DB Transaction 과 Lock 의 범위에 따른 처리 고려

🤔 분산락을 구현할때 아래와 같이 하면..?

@Transactional
public void charge(Long userId, BigDecimal point) {
	RLock lock = redissonClient.getLock("userChargeLock")

	try {
		if(lock.tryLock() == true) {
			User user = userRepository.findById(userId)
			user.charge(point)
		} else {
			throw new LockAccruedFailedException();
		}
	} finally {
		lock.unlock();
	}
}

@Transactional
public void pay(Long userId, BigDecimal point) {
	RLock lock = redissonClient.getLock("userPayLock")

	try {
		if(lock.tryLock() == true) {
			User user = userRepository.findById(userId)
			user.pay(point)
		} else {
			throw new LockAccruedFailedException();
		}
	} finally {
		lock.unlock();
	}
}
  • 발생 가능 현상 ( 우리의 의도대로 구현이 되었는가? )
    1. 트랜잭션과 락의 순차보장이 실패해요.
      • 트랜잭션이 시작된 후에 락을 획득하므로, 락을 획득하기 전에 데이터 정합성이 깨질 수 있습니다.
        • @Transactional 어노테이션을 내부 메소드로 이동하여 락 획득 후 트랜잭션이 시작되도록 수정
    2. 충전과 결제가 동시에 수행 가능해요.
      • 각각 다른 락키를 사용하므로 충전과 결제가 동시에 발생할 수 있습니다.
        동일한 사용자에 대해서는 같은 락키를 사용하도록 수정 (예: "user:{userId}")
    3. 충전과 결제 기능 자체에 걸리는 락.
      • 현재는 모든 사용자의 충전/결제가 하나의 락으로 관리되어 불필요한 병목현상 발생
        사용자별로 락을 분리하여 관리

🛠️ 해결 예시

public void charge(Long userId, BigDecimal point) {
    RLock lock = redissonClient.getLock("user:" + userId);
    try {
        if (lock.tryLock()) {
            executeInTransaction(() -> {
                User user = userRepository.findById(userId);
                user.charge(point);
            });
        } else {
            throw new LockAcquireFailedException();
        }
    } finally {
        lock.unlock();
    }
}

public void pay(Long userId, BigDecimal point) {
    RLock lock = redissonClient.getLock("user:" + userId);
    try {
        if (lock.tryLock()) {
            executeInTransaction(() -> {
                User user = userRepository.findById(userId);
                user.pay(point);
            });
        } else {
            throw new LockAcquireFailedException();
        }
    } finally {
        lock.unlock();
    }
}

@Transactional
private void executeInTransaction(Runnable runnable) {
    runnable.run();
}


참고

profile
Backend Developer

0개의 댓글

관련 채용 정보