
SpringBoot
0. 들어가며
최근 실시간 쿠폰 발급과 조회에 대해서 스터디 겸 코드로 아주 간단하게 구현해 보았습니다. 간단한 요구사항에도 불구하고, 동시성에 대해서 되게 생각해야 될 점들이 많았습니다. 구현하면서 동시성과 관련된 여러 영상과 블로그들을 참고하면서 Lock과 동시성 제어에 대해서 너무나도 재밌게 공부했던 것 같습니다. 너무 많은 정보가 머릿 속에 있다 보니 정리가 잘 되어있지 않은 것 같아 이번 글을 통해서 제 스스로도 정리하고, 관심 있어하시는 분들께도 조금이나마 도움을 드리고자 합니다. 그럼 시작해 보도록 하겠습니다!
1. 내용 정리
구현 상황을 간단하게 드리도록 하겠습니다. 우선, 쿠폰을 발급하는 순서와는 상관없이 1,000명의 유저에 대해서 정확히 100명에게만 쿠폰을 발급하는 상황을 해결해야 하고, 1명의 유저는 특정 쿠폰에 대해서 단 1개의 쿠폰만 발급받을 수 있는 상황입니다. 정리해 보자면 다음과 같습니다.
- 1,000명의 유저의 동시 요청에 대해서 정확히 100명에게만 쿠폰 발급
- 1명의 유저의 100번의 동시 요청에 대해서 정확히 단 1번만 쿠폰 발급
그럼 이 상황을 어떻게 Lock을 활용하여 동시성 제어를 할 수 있는지 차근차근 알아보도록 하겠습니다.
1.1 락과 동시성 문제
락(Lock)에 대한 정의로 시작해보도록 하겠습니다. 락이란 무엇일까요? 락은 공유 자원에 대해서 여러 요청이 있을 때, 요청이 동시에 실행되는 것을 막는 접근 제어 방법이라고 생각합니다. 그럼 왜 이런 접근 제어 방법을 사용하는 걸까요? 그것은 다음과 같은 4가지 문제가 발생할 수 있기 때문입니다.
- Lost Update (갱신 유실)
- Dirty Read (더티 리드)
- Non-Repeatable Read (비반복 읽기)
- Phantom Read (팬텀 리드)
각 내용에 대해서 하나씩 살펴보도록 하겠습니다.
1.1.1 Lost Update
Lost Update는 말 그대로 "업데이트 되면서 잃어버린다"는 상황입니다. 업데이트되면서 어떻게 데이터를 잃어버릴 수 있을까요? 다음과 같은 상황을 생각해 보겠습니다.

위 플로우 차트를 보면 단번에 이해가 될 것입니다. 두 사용자에 동시 요청에 의해서 잔액 데이터에 동시에 2개의 트랜잭션을 통해서 조회를 시도합니다. 동시에 진입했기 때문에 값은 1,000원을 가져갔고, 각자 다른 금액을 추가했다면, 먼저 커밋된 내용이 나중에 커밋된 내용으로 덮어 써지는 것이죠. 즉, 정리하면 Lost Update는 동시 요청으로 인해서 두 트랜잭션이 같은 데이터를 읽고, 수정한 뒤 저장하면서 한 트랜잭션의 변경이 다른 트랜잭션에 의해 덮어써지는 현상입니다.
1.1.2 Dirty Read
Dirty Read는 "읽기가 더럽혀졌다."라고 이해하시면 편할 것 같습니다. 바로 플로우 차트로 확인해 보도록 하겠습니다.

Dirty Read는 읽기가 더럽혀져서 데이터 일관성이 깨진 상황을 의미합니다. 위 플로우 차트에서 "Transaction 1"에서 먼저 잔액을 조회하여 2,000원으로 변경합니다. 이 내용을 커밋하기 전에 다른 "Transaction 2"가 2,000원 데이터를 읽어갑니다. 여기서 "Transaction 1"이 롤백되면서 값이 다시 1,000원으로 변경되었지만, "Transaction 2" 입장에서는 이미 2,000원이라는 데이터를 읽어갔기 때문에 존재한 적이 없는 값을 활용한 꼴이 됩니다.
1.1.3 Non-Repeatable Read
Non-Repeatable Read는 "반복해서 읽었더니 이상하다."라고 이해하시면 될 것 같습니다.

"Transaction 1"이 현재 1,000원인 잔액 데이터를 읽어갑니다. 커밋 이전에 새로운 "Transaction 2"가 잔액 데이터에 접근해서 값을 변경하고 커밋까지 완료합니다. 여기서 다시 "Transaction 1"이 데이터를 다시 한번 읽어갑니다. "Transaction 1"입장에서는 동일한 트랜잭션 내에서 같은 값을 그저 2번을 읽었을 뿐인데, 한 번은 1,000원, 다른 한번은 5,000원으로 조회된 상황이 벌어진 것입니다. 즉, "반복해서 읽었더니 이상하다."가 바로 그 뜻입니다.
1.1.4 Phantom Read
Phantom Read는 환상적인 읽기 즉, "다시 읽었더니 오잉? 환상적이네"라고 느껴지는 현상입니다. 플로우 차트를 확인해 보겠습니다.

"Transaction 1"이 나이 값을 조건으로 범위 조회를 실행합니다. 3개의 행을 결과로 받습니다. 아직 커밋되기 전에 새로운 "Transaction 2"가 "Transaction 1"에서 실행한 나이 조건에 부합하는 데이터를 하나 삽입하고, 커밋합니다. "Transaction 1"에서 다시 조회를 하게 되면 데이터가 1개 더 늘어난 값을 결과로 받게 됩니다. 다른 3개의 동시성 문제는 특정 속성 값에 대한 문제라면, Phantom Read는 테이블 관점에서 봤을 때, 행의 개수가 줄어들거나, 늘어나거나 하는 점에서 차이가 있습니다.
1.2 Lock 모드: 공유 락(S-Lock)과 베타 락(X-Lock)
지금까지 락의 개념과 동시성 문제에는 어떤 것들이 있는지 알아봤습니다. 이번에는 락의 모드에 대해서 알아보도록 하겠습니다. 락은 크게 공유 락(S-Lock, Shared Lock) 모드와 베타 락(X-Lock, Exclusive Lock) 모드로 구분할 수 있습니다. DBMS는 이런 락 모드를 활용해서 동시다발적으로 실행되는 트랜잭션들을 제어할 수 있습니다.
1.2.1 공유 락(S-Lcok, Shared Lock)
공유 락은 읽기 전용 락입니다. 읽기 작업에서는 데이터의 변경이 일어나지 않기 때문에, 공유 락이 걸려있는 트랜잭션에 대해서 다른 트랜잭션에서도 공유 락을 획득할 수 있습니다. 단, 베타 락은 획득할 수 없습니다. DBMS는 공유 락을 사용해서 같은 트랜잭션 내에서 조회한 데이터가 변경되지 않는다는 것을 보장합니다.
1.2.2 베타 락(X-Lock, Exclusive Lock)
베타 락은 쓰기 전용 락입니다. 쓰기 작업은 데이터의 변경이 일어나는 작업이기 때문에 여러 트랜잭션이 하나의 데이터에 접근해서 값을 변경하는 것을 방지해야 합니다. 따라서, 베타 락을 확보한 트랜잭션이 접근하는 데이터에 대해서는 다른 트랜잭션에서 공유 락과 배타 락을 획득할 수 없습니다. 만약에 베타 락을 잡고 있는 데이터에 대해서 다른 데이터도 접근하려고 한다면 대기(Blocking)하고 있어야 합니다.
1.3 Lock 방식: 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)
우선, 이런 락이 왜 필요한 지, 락의 소중함을 이해하기 위해 문제가 발생하는 테스트 결과 먼저 살펴보겠습니다. 특정 쿠폰이 100장이 존재하고, 스레드 1000개를 생성해서 한번 동시에 쿠폰 발급 로직에 접근해 보도록 하겠습니다.


쿠폰은 총 100장으로 한정되어 있지만, 실제로 아무 락 없이 쿠폰 발급 로직에 동시 접근한 결과 887장이 발급된 것을 확인할 수 있었습니다. 이게 실제 배포된 환경이라면 787장 정도의 손해를 보게 될 것입니다. 또한, 네트워크나 서버 환경에 따라 더 많은 쿠폰이 발급될 수도 있습니다. 이런 상황이 발생하지 않도록 필요한 장치가 바로 "Lock(락)"인 것이죠. 이런 락을 구현하는 방식에는 비관적 락, 낙관적 락 이렇게 2가지가 존재합니다. 한번 알아보도록 하겠습니다.
1.3.1 비관적 락(Pessimistic Lock)
비관적 락은 말 그대로 상황을 비관적으로 생각해서 더욱 강력하게 락을 거는 방식입니다. 즉, 동시에 접근하는 요청이 많아 충돌이 발생할 확률이 높은 경우에 주로 활용되는 방식입니다. 특정 트랜잭션이 해당 데이터를 조회함과 동시에 락을 걸어 다른 트랜잭션에서는 접근하지 못하고, 락이 풀릴 때까지 대기해야 합니다. SpringBoot 환경에서 Spring Data JPA를 사용하는 환경에서는 Repository에 조회 쿼리에 다음과 같이 "@Lock(LockModeType.PESSIMISTIC_WRITE)"을 적어주면 비관적 락이 적용이 됩니다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Query("select c from Coupon c where c.id = :id and c.status = :status")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Coupon> findById(@Param(value = "id") Long id, @Param("status") Status status);
}
그럼 다시 테스트를 돌려보도록 하겠습니다.

테스트가 성공했습니다 :> 정확히 100장의 쿠폰만 발급되고, 900장의 요청에서는 발급에 실패하는 것을 확인할 수 있었습니다. 그렇다면 비관적 락은 무적일까요? 아닙니다. 락을 거는 순간 다른 트랜잭션에서 데이터에 접근하지 못하고, 락이 풀릴 때까지 기다려야 하기 때문에 대기 시간이 길어질 수 있습니다. 대기 시간이 길어지면 처리량도 적어지게 되는 단점이 있습니다. 락은 조회와 동시에 걸리고, 트랜잭션이 커밋되는 순간에 풀리기 때문에 락의 범위를 짧게 가져가는 것이 매우 중요하다고 할 수 있습니다!
1.3.2 낙관적 락(Optimistic Lock)
낙관적 락은 이름에서도 느껴지듯이 충돌이 드물게 일어난다고 가정하는 락 방식입니다. 특이한 점은 실제로 락을 걸지 않고, "version"이라는 컬럼을 활용해서 version 값이 바뀌어 일치하지 않게 되면 충돌이 발생했다고 판단합니다. 코드로 한번 살펴보도록 하겠습니다.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
@Query("select c from Coupon c where c.id = :id and c.status = :status")
@Lock(LockModeType.OPTIMISTIC)
Optional<Coupon> findById(@Param(value = "id") Long id, @Param("status") Status status);
}
LockModeType 값을 "OPTIMISTIC"으로 변경해 줍니다. 그리고, Entity 객체에 @Version 어노테이션과 함께 version 컬럼을 새롭게 만들어줍니다.
@Entity
@Getter
public class Coupon extends BaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
...
@Version
private Long version;
}
마찬가지로 테스트를 돌려보겠습니다.

비관적 락과 마찬가지로 테스트에 성공하는 모습을 확인할 수 있었습니다. 낙관적 락은 락을 실제로 걸지 않고, version 컬럼의 일치 여부를 판단하기 때문에 대기하는 시간이 없어 처리량이 높다는 장점이 있지만, version 값이 일치하지 않아 접근에 실패했을 때, 예외 처리 및 재시도 로직을 별도로 구현해야 한다는 단점이 있습니다. 두 락의 특징을 생각해서 상황에 맞게 사용하면 될 것 같습니다!
2. 정리하며
지금까지 락의 개념, 동시성 문제, 락의 방식 및 모드에 대해서 알아보았습니다. 사실 Lock 개념은 더 심오하고, DB 단에서 걸 수 있는 MySQL Named Lock도 존재합니다. 더 나아가서 하나의 서버가 아닌 다중 서버, 다중 DB 인스턴스로 구성되어 있는 분산 환경에서는 "분산 락"이라는 개념도 존재합니다. 이 부분에 대해서도 공부할 양이 많기 때문에 오늘은 단일 서버에서 시도해 보면 좋을 내용들로만 구성해 보았습니다.
이제 상반기가 거의 끝나가고 있는데, 다들 화이팅 하고 있으신가요? 저도 좋은 소식이 조만간 찾아오길 바라는 하루하루를 지내고 있습니다! 이 글을 혹시나 보시는 분들 모두 행운이 가득하길 기도드리겠습니다 :> 그럼 또 유익한 글로 찾아뵙도록 하겠습니다. 감사합니다!
3. Reference
'Developer > SpringBoot' 카테고리의 다른 글
| [SpringBoot] @Value 어노테이션에 대해서 (1) | 2025.12.26 |
|---|---|
| [SpringBoot] CORS 개념과 그 해결법에 대해서 (1) | 2025.12.05 |
