안녕하딤니카?
또 티스토리 블로그 방치 이슈...
오늘은 저번에 슬렁슬렁 공부하던 동시성 제어를 좀 더 공부해 보기 위해서 글을 작성해 봅니다
바로~ MySQL에서 제공하는 Lock을 이용해서 동시성 제어하기!
시작하겠붐붐띠
1. 개요
이번에는 저번에 선착순 쿠폰 발급 시스템에서 발생한 문제와 비슷하게, 데이터베이스에서 동시성 제어를 위해 사용하는 Lock을 이용해서 여러 트랜잭션이 동일한 데이터에 접근했을 때 발생할 수 있는 충돌을 방지하기 위해 Lock 을 MySQL에서 사용해보려고 합니다.
저는 MySQL과 Redis에서 제공하는 기능을 활용할 예정이며, 이번 글에서는 Lock의 정의부터 Lock을 사용해야 되는 이유,
그리고 Pessimistic Lock, Optimistic Lock, Named Lock으로 동시성 제어 예제를 통해 Lock을 사용하는 방법에 대해 정리할 예정입니다. (Redis는 다음 글에서!
이 내용은 아래 강의를 수강하고 적는 글입니다!
강의 정보 : 인프런 - 재고시스템으로 알아보는 동시성이슈 해결방법 (최상용)
재고시스템으로 알아보는 동시성이슈 해결방법 강의 | 최상용 - 인프런
최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동
www.inflearn.com
2. Lock의 정의와 Lock을 사용해야 하는 이유
2-1. Lock의 정의
Lock은 데이터베이스에서 동시성 제어를 위해 사용되는 메커니즘으로, 여러 트랜잭션이 동일한 데이터에 접근할 때 발생할 수 있는 충돌을 방지하는 역할을 합니다. Lock은 특정 자원(예: 데이터베이스의 레코드, 테이블)에 대한 배타적인 권한을 부여하여, 하나의 트랜잭션이 작업을 수행하는 동안 다른 트랜잭션이 해당 자원에 접근하지 못하게 합니다. 이를 통해 데이터의 일관성과 무결성을 유지할 수 있습니다.
여러 트랜잭션이 동일한 데이터에 접근할 때, Race Condition 이 발생할 수 있습니다.
Race Condition은 여러 트랜잭션이나 프로세스가 동시에 동일한 자원에 접근하여 경쟁할 때 발생하는 상황을 말합니다. 이로 인해 예상치 못한 결과가 발생할 수 있으며, 보통 이는 의도하지 않은 잘못된 상태나 데이터 불일치를 초래합니다.
이 Race Condition을 방지하기 위해 데이터베이스에 Lock 을 사용하면, Race Condition 및 Lock을 사용하지 않았을 때 발생할 수 있는 다양한 문제를 방지할 수 있습니다.
2-2. Lock 을 사용하는 이유, 사용하지 않았을 때 발생하는 문제
Lock을 사용해야 하는 이유는 아래 세 가지로 정리할 수 있습니다.
- 데이터 일관성 보장: 여러 트랜잭션이 동시에 동일한 데이터를 수정하려 할 때, 데이터의 일관성이 깨질 수 있습니다. Lock을 사용하면 이러한 동시 접근을 제어하여 데이터의 무결성을 유지할 수 있습니다.
- Race Condition 방지: 여러 트랜잭션이 동시에 동일한 자원에 접근하려고 경쟁할 때 발생하는 Race Condition은 예상치 못한 잘못된 결과를 초래할 수 있습니다. Lock을 통해 이러한 Race Condition을 방지함으로써 데이터의 일관성을 확보할 수 있습니다.
- 비정상적인 동작 방지: 동시성 제어가 없으면 트랜잭션 간의 간섭이 발생해 예상치 못한 결과를 초래할 수 있습니다. Lock을 통해 이러한 비정상적인 동작을 방지할 수 있습니다.
그리고 Lock 을 사용하지 않으면, Race Condition 을 비롯한 다양한 문제가 발생할 수 있습니다. 이 문제를 동시성 문제라고 하는데, 이들은 모두 Race Condition의 결과로 나타나는 문제입니다.
- Dirty Read: 하나의 트랜잭션이 아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 상황을 말합니다. 이 경우, 이후에 원본 트랜잭션이 롤백되면 읽은 데이터는 유효하지 않게 됩니다.
- Non-repeatable Read: 한 트랜잭션에서 같은 데이터를 여러 번 읽는 동안, 다른 트랜잭션이 해당 데이터를 수정하거나 삭제하면 처음 읽은 데이터와 나중에 읽은 데이터가 달라지는 상황이 발생합니다.
- Phantom Read: 한 트랜잭션에서 특정 조건으로 데이터를 조회하는 동안, 다른 트랜잭션이 해당 조건에 부합하는 데이터를 삽입하면, 처음 조회한 결과와 나중에 조회한 결과가 달라지는 상황이 발생합니다.
- Lost Update: 두 개 이상의 트랜잭션이 같은 데이터를 읽고, 각각 이를 수정한 후 저장할 때, 나중에 저장된 데이터가 이전 데이터에 의존하지 않고 덮어써지는 경우를 말합니다. 이는 데이터 손실로 이어질 수 있습니다.
3. MySQL에서 제공하는 Lock 사용해 보기
위에서 말했던 것처럼, 이번 글에서는 MySQL에서 제공하는 Lock을 사용해서 동시성 이슈를 해결하겠습니다. Pessimistic Lock, Optimistic Lock, Named Lock의 동작원리와 사용해야 되는 상황, 주의사항을 정리하면서 코드 예제를 같이 보여드리겠습니다.
3-1. Pessimistic Lock (비관적 락)
Pessimistic Lock은 데이터베이스의 레코드나 자원에 실제로 락(Lock)을 걸어서 다른 트랜잭션이 접근하지 못하게 하는 방식입니다. 이 방법은 데이터 충돌을 미리 방지하기 위해, 데이터에 접근하는 순간부터 락을 걸어 다른 트랜잭션이 동일한 자원에 접근하지 못하도록 합니다.
- 동작 원리: 트랜잭션이 데이터를 읽거나 수정하려고 할 때, 해당 데이터에 대해 exclusive lock(배타적 락)을 겁니다. 이 락은 트랜잭션이 완료되기 전까지 다른 트랜잭션이 해당 자원에 접근하거나 변경할 수 없게 만듭니다. 이는 특히 여러 트랜잭션이 동일한 데이터를 수정하는 상황에서 유용하며, 데이터 일관성을 강하게 보장합니다.
- 장점: 데이터 충돌이 빈번하게 발생하는 환경에서, 롤백의 횟수를 줄일 수 있기 때문에 아래에서 설명할 Optimistic Lock보다 성능이 더 좋을 수 있습니다. 또한, exclusive lock을 통해 데이터를 제어하기 때문에 데이터 정합성을 확실하게 보장할 수 있습니다. 개발자가 별도의 재시도 로직을 작성할 필요가 없다는 점도 장점입니다.
- 단점:Pessimistic Lock은 락을 장기간 유지할 경우, 다른 트랜잭션들이 해당 자원을 기다리게 되므로 데드락(Deadlock)이 발생할 수 있습니다. 데드락은 두 개 이상의 트랜잭션이 서로 상대방의 락을 기다리는 상황으로, 시스템이 교착 상태에 빠질 위험이 있습니다. 이를 방지하기 위해 락의 사용을 신중히 관리하고, 데드락을 감지하거나 해결할 수 있는 메커니즘을 도입하는 것이 필요합니다.

예를 들어, Server 1 DB 데이터를 가져올 때 Pessimistic Lock을 걸면, 다른 서버에서는 Server1의 작업이 끝나 락이 풀릴 때까지 데이터에 접근하지 못하게 됩니다.

이런 식으로 Pessimistic Lock이 동작하기 때문에, 데이터에 접근하는 시도가 많아지면 데드락 발생으로 성능 저하 이슈가 발생할 수 있습니다. 아래는 Pessimistic Lock을 사용하는 방법입니다.
먼저, 사용할 리포지토리에 @Lock 어노테이션의 PESSIMISTIC_WEITE 옵션으로 쿼리를 작성해서 메서드를 하나 만들어줍니다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
그리고, 서비스 계층에서 방금 리포지토리에 만들어준 메소드를 활용해서 재고 감소 로직을 작성합니다. (Stock 엔티티 작성 코드 생략)
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public PessimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
// PessimisticLock 을 활용해서 재고를 가져오기
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.save(stock);
}
}
그리고, 재고 감소 로직이 정상 동작하는지 테스트를 진행합니다. 여기서 사용한 테스트코드는 다른 락 방식과 비슷하기 때문에, 여기만 첨부하고, 다른 락 사용 방법에서는 생략하겠습니다.
@SpringBootTest
class StockServiceTest {
@Autowired
private PessimisticLockStockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@AfterEach
public void after() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
// 100 - (1 * 100) = 0
assertEquals(0, stock.getQuantity());
}
}

100개로 재고를 한정한 뒤 테스트를 진행했는데, Pessimistic Lock을 사용했더니 2.80초가 소요되었습니다. 데이터에 직접 락을 거는 거라, 다른 방식과 성능 비교를 하면서 사용해야겠다~라는 생각입니다.
3-2. Optimistic Lock (낙관적 락)
Optimistic Lock은 실제로 락을 사용하지 않고 데이터의 버전 정보를 이용하여 충돌을 감지하고 해결하는 방법입니다. 이 방식은 데이터 충돌이 드물 것이라는 가정하에 동작하며, 주로 읽기 작업이 많고, 쓰기 작업이 적은 환경에서 유리합니다.
- 동작 원리: Optimistic Lock에서는 데이터를 읽을 때 버전 번호나 타임스탬프 같은 추가 정보를 함께 읽습니다. 이후 데이터를 업데이트할 때, 현재 데이터의 버전이 트랜잭션이 처음 데이터를 읽을 때의 버전과 동일한지 확인합니다. 만약 동일하다면, 업데이트를 수행하며 버전 번호를 증가시킵니다. 그렇지 않다면, 누군가가 데이터를 갱신한 것이므로 트랜잭션은 실패하게 되고, 애플리케이션은 데이터를 다시 읽어 최신 버전으로 재시도해야 합니다.
- 장점: 락을 사용하지 않기 때문에 데드락(Deadlock) 문제가 발생하지 않으며, 동시성이 높은 환경에서 성능이 우수합니다. 주로 데이터 충돌이 적을 것으로 예상되는 상황에서 사용되며, 특히 읽기 작업이 많고, 쓰기 작업이 상대적으로 적은 시나리오에서 유리합니다.
- 단점: 데이터 갱신이 빈번히 발생하는 환경에서는 트랜잭션 충돌이 자주 발생할 수 있어, 성능 저하가 발생할 수 있습니다. 또한, 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해 주어야 합니다. 이로 인해 코드가 복잡해질 수 있으며, 충돌 발생 시 애플리케이션의 응답 시간이 길어질 수 있습니다.


Optimistic Lock은 데이터 버전 정보를 이용하기 때문에 별도의 버전 정보를 입력할 수 있는 추가적인 테이블 마이그레이션을 해줘야 합니다. 아래처럼 @Version 어노테이션을 사용해 주면 됩니다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
public Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public Long getQuantity() {
return quantity;
}
// 재고 감소 로직
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
그리고, 리포지토리에서 @Lock 어노테이션의 OPTIMISTIC 옵션으로 쿼리를 작성해서 메서드를 하나 만들어줍니다. 이 메소드를 사용해서 Pessimistic Lock에서 사용했던 서비스 계층의 로직과 비슷하게 재고 감소 로직을 작성해 줍니다. (findByIdWithOptimisticLock 메소드 사용)
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
Optimistic Lock은 데이터 업데이트 실패 시 재시도 로직을 직접 작성해줘야 합니다. OptimisticLockStockFacade에 재시도 로직을 간단하게 작성해줍니다.
@Component
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
그리고, 재고 감소 로직이 정상 동작하는지 테스트를 진행해 줍니다. 테스트 코드는 위에 작성했던 Pessimistic Lock 테스트 코드와 비슷해서 여기서는 생략해 주겠습니다!

아까와 동일하게 재고 100개를 기준으로 테스트를 진행했는데, 데이터 갱신이 많이 발생하는 환경이라 Optimistic Lock을 사용하는 환경에서는 테스트 완료까지 4.302초가 소요되었습니다.
Pessimistic Lock과 Optimistic Lock은 각각의 장단점이 있으니까, 성능 비교를 해보고 사용하는 것이 좋을 것 같습니다.
3-3. Named Lock (명명된 락)
Named Lock은 MySQL에서 제공하는 메타데이터 락으로, 특정 이름을 가진 락을 획득하고 해제할 수 있는 기능을 제공합니다. 이는 자원의 이름을 기준으로 락을 걸 수 있어, 코드나 애플리케이션 레벨에서 동시성 제어를 위한 커스텀 락을 구현할 때 유용합니다.
- 동작 원리: GET_LOCK(name, timeout) 함수를 사용하여 이름이 지정된 락을 획득합니다. 이 락은 동일한 이름을 사용하는 다른 세션에서 락을 해제하거나 타임아웃이 발생하기 전까지는 락을 획득할 수 없습니다. 락을 해제할 때는 RELEASE_LOCK(name)을 호출합니다. 이 락은 트랜잭션과 연관되지 않으며, 트랜잭션이 종료되어도 자동으로 해제되지 않습니다.
- 장점:
- 커스텀 동시성 제어: Named Lock은 이름을 기반으로 락을 걸 수 있으므로, 특정 자원이나 작업에 대해 커스텀 동시성 제어를 쉽게 구현할 수 있습니다. 이를 통해 코드나 애플리케이션 수준에서 보다 정교한 락 메커니즘을 설계할 수 있습니다.
- 트랜잭션과 독립적: Named Lock은 트랜잭션과 별도로 관리되므로, 특정 자원에 대해 트랜잭션 외부에서 락을 유지하거나 제어할 수 있는 유연성을 제공합니다. 이는 트랜잭션이 종료되더라도 락을 유지할 필요가 있는 특정 시나리오에서 유용할 수 있습니다.
- 단점:
- 자동 해제 없음: Named Lock은 트랜잭션과 독립적으로 작동하기 때문에, 트랜잭션이 종료되어도 락이 자동으로 해제되지 않습니다. 이로 인해 락이 장기간 유지될 수 있으며, 시스템 자원을 잠그는 문제가 발생할 수 있습니다. 따라서 개발자가 적절한 타이밍에 락을 명시적으로 해제해야 하며, 타임아웃 설정을 통해 영구적인 락을 방지하는 것이 중요합니다.
- 커넥션 풀이 부족해질 가능성: 동일한 데이터소스를 사용하는 경우, Named Lock으로 인해 커넥션 풀이 부족해질 수 있습니다. 이는 다른 서비스의 성능에 부정적인 영향을 미칠 수 있으므로, 필요하다면 데이터소스를 분리해서 사용하는 것이 권장됩니다.
- 타임아웃 설정 필요: 락을 걸 때 타임아웃을 신중하게 설정해야 합니다. 타임아웃이 너무 길면 락이 오래 유지되어 자원 낭비가 발생할 수 있고, 너무 짧으면 락이 필요한 상황에서 제대로 활용되지 못할 수 있습니다.

편의성을 위해서 이번에는 Stock 엔티티를 사용해서 Named Lock을 사용해 볼 거지만, 실제로 사용할 경우 데이터소스를 분리해서 사용하는 것을 권장합니다. 아래에 LockRepository를 만들어서 getLock과 releaseLock 메서드를 작성해 줍니다.
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
그리고, 락 시작과 락을 해제하는 로직을 NamedLockStockFacade에 별도로 작성해 줍니다.
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
public NamedLockStockFacade(LockRepository lockRepository, StockService stockService) {
this.lockRepository = lockRepository;
this.stockService = stockService;
}
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
위에 다른 Lock 이랑 동일한 로직의 서비스를 사용할 건데, 부모 트랜잭션과 별도로 실행되어야 하기 때문에 propergation 옵션을 변경해 줍니다.
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity) {
// Stock 조회
// 재고를 감소시킨 뒤
// 갱신된 값을 저장
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
그리고 이번 예제에서는 같은 데이터소스를 사용할 거라, 커넥션 풀 수를 40으로 늘려줬습니다. (application.yml에서 설정)
이렇게 Named Lock 설정을 마친 뒤, 위에 다른 Lock과 테스트코드를 동일하게 작성해서 재고 감소 로직이 정상 동작하는지 확인해 줍니다.

Named Lock 테스트 결과, 테스트에 12.119초가 소요되었습니다. 시간은 많이 걸리는데 다른 Lock 방식과 다르게 어느 정도 유연성을 가진 방식이라, 성능 비교와 상황에 따라 사용해 주면 될 것 같습니다.
4. 정리
MySQL에서 제공하는 Lock의 종류와 사용 방법에 대해 알아봤습니다. MySQL의 Lock을 사용하면, MySQL을 사용하고 있다는 가정 하에 별도의 비용 추가 없이 동시성 제어를 구현할 수 있다는 장점이 있습니다. Pessimistic Lock, Optimistic Lock, 그리고 Named Lock 각각은 특정 상황에서 데이터 일관성과 무결성을 보장하기 위해 유용하게 활용될 수 있습니다.
다만, MySQL의 Lock 메커니즘은 특정한 시나리오에서 성능상의 제약이 있을 수 있으며, 특히 Redis와 같은 인메모리 데이터 스토어가 제공하는 락보다 성능이 떨어질 수 있습니다. 따라서, 시스템의 요구 사항에 맞는 락 메커니즘을 선택하는 것이 중요합니다.
동시성 제어는 데이터의 일관성과 무결성을 유지하는 데 필수적인 요소이므로, 각 Lock의 특성을 잘 이해하고 적절한 상황에서 사용하는 것이 필요합니다. 다음 글에서는 Redis에서 제공하는 Lock에 대해 알아보며, MySQL과의 성능 비교 및 사용 사례를 살펴보겠습니다~
🍀
좋아하는 것을 계속 좋아하세요!
반드시 행복해집니다
[Github] https://github.com/chujaeyeong
[E-mail] chujy1224@gmail.com
'Study > Spring' 카테고리의 다른 글
| Spring MVC에서 사용자 입력 검증(Validation)과 HTML5 유효성 검사의 차이점 (6) | 2024.11.06 |
|---|---|
| Redis 라이브러리를 활용해서 동시성 제어하기 (Lettuce, Redisson) (2) | 2024.08.17 |
| 선착순 쿠폰 발급 시스템 로직 변경으로 Redis의 Set 자료구조 찍먹해보기 (0) | 2024.07.02 |
| 선착순 쿠폰 발급 시스템 개발을 통해 Redis 랑 Kafka 찍먹해보기 (2) | 2024.07.01 |
| [Spring] Validation (검증) 처리 방법 및 properties 파일의 한글이 출력되지 않는 문제 해결 방법 (2) | 2023.10.12 |