Spring

동시성 문제 해결 - 수강신청하기

맛도리얌 2025. 6. 2. 16:58

수강인원이 있는 강의의 수강신청 기능을 개발하며

잔여인원을 알맞게 카운팅 할 필요가 있었다.

 

이 때, 사용자가 같은 버튼을 여러번 누를 때 같은 주문이 여러 번 발생하거나,

A와 B가 동시에 1개 남은 잔여 강의 구매 시-> 재고가 마이너스되는 등의 오류가 발생할 수 있다.

 

 

위와같은 문제를 동시성 문제라고 한다.

 

이런 동시성 문제를 해결하려면, 데이터에 하나의 스레드만 접근이 가능하도록 해야한다.

그렇다면 어떻게 데이터에 동시에 하나의 스레드만 접근이 가능하게 할 수 잇을까?

 

동시성 문제를 해결하는 방법들과, 각각의 장단점에 대해 알아보자.

 

 

 

 

 

 

 

 

 

 

1. JAVA의 synchronized

 

자바에서는 Synchronized 키워드를 통해 데이터에 하나의 스레드만 접근이 가능하도록 만들어준다.

 

 

 

작동 방식

  • JVM 안에서만 유효함 (즉, 프로세스 내에서만 락이 걸림)
  • 같은 인스턴스의 synchronized 블록은 다른 스레드가 동시에 진입할 수 없음
  • OS 레벨의 스레드 락과 연결됨

 

 

 

사용 예시

@Transactional
public synchronized void decrease(final Long id, final Long quantity) {
    // 하나의 스레드만 동시에 이 코드에 들어올 수 있음

	final Stock stock = stockRepository.findById(id)
			.orElseThrow();
	stock.decrease(quantity);

	stockRepository.saveAndFlush(stock);
}

 

그러나 이방식에는 한계가 있다.

 

 

 

 

한계

1. 트랜잭센 레벨에서 동시성을 보장할 수 없음.

@Transactional이 붙은 메소드는 다음과 같이 Proxy 객체를 생성하여 트랜잭션 관련 처리를 해준다.
이때, 재고 감소가 DB에 반영되는 시점은 트랜잭션이 커밋되고 종료되는 시점그러나 . synchronized는 "메소드 선언부"에 사용되어 해당 메소드가 종료되면 다른 스레드에서 해당 메소드를 실행할 수 있게 됨
따라서 재고 감소 로직이 실행되고 트랜잭션이 종료되기 전까지의 시점에서 다른 스레드가 재고 감소 로직을 실행할 수 있게 됨.이때 다른 스레드에서 재고를 조회했을 때는 아직 이전 재고 감소 로직이 실행된 스레드에서 DB에 반영되기 전이므로 감소되지 않은 재고로 조회하여 똑같이 재고 감소가 누락될 수 있다.

 

2. 멀티 프로세스 환경에서 동시성을 보장할 수 없음.

 또한 , 데이터에 동시에 하나의 스레드만 접근이 가능하다는 조건은 하나의 프로세스에서만 보장되는 특징이다.
이러한 특징때문에 Scale-out 시, 즉 서버가 여러 대일 때 동시성이 보장되지 않는다는 치명적인 단점이 있다.

 

 

 

 

 

따라서 이방법은 하나의 JVM 안에서 일어나는 동시성 문제는 해결할 수 있지만, 서버를 2대이상 띄우면 동시성을 보장할 수 없다.

따라서 DB수준의 Lock이나, 분산 락을 사용해야 한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

낙관적 락 (Optimistic Lock) - @Version

자원에 lock을 걸어서 선점하는 것이 아니고, 동시성 문제가 발생하면 그때 가서 처리하는 방이다.

이 특징은 DB에서 동시성을 처리하는 것이 아닌 Application Level에서 처리하는 Lock이다.

 

 

아래 상황과 같이 이해할 수 있다.

 

T1, T2가 같은 row를 읽고 수정 시도 동시성 상황
T1이 먼저 수정하고 Version을 2로 올림 정상 commit
T2가 수정하려고 보니 Version이 안 맞음 OptimisticLockException 발생
→ 수동 롤백 필요 여부 예외 발생 시, 트랜잭션은 rollback 되어야 함

 

 


같은 row에 대해 각기 다른 2개의 수정 요청이 있었지만, 1개가 업데이트가 이미 되어서 Version이 변경되었기 때문에 그 뒤의 수정 요청은 반영되지 않는다.


이때 여러 작업이 묶인 트랜잭션으로 요청이 간 경우가 실패한 경우, 개발자가 직접 롤백 처리를 해줘야 한다.

(단, JPA의 경우 @Transactional이 붙어 있고, OptimisticLockException이 발생하면 Spring AOP가 자동으로 rollback 처리해줌. (예외가 unchecked라면 기본적으로 rollback됨))

 


이렇게 낙관적 락은 version 등의 구분 컬럼을 이용해서 충돌을 예방한다.

 

JPA에서는 충돌이 "일어나지 않을 것"이라고 가정할 엔티티에 version 필드를 두고, 변경 시점에 버전 불일치 여부로 충돌 감지한다.

 

 

 

 

사용 예시

@Entity
public class Product {
  @Id
  private Long id;

  private int stock;

  @Version
  private int version;
}

 

// Service 내에서
Product product = productRepository.findById(1L).orElseThrow();
// 재고 차감
product.setStock(product.getStock() - 1);
// 저장 시 version 충돌 나면 예외 발생 (OptimisticLockException)

 

 

SQL은 다음과 같이 나감

 

-- SELECT 먼저 실행
select p.id, p.name, p.stock, p.version
from product p
where p.id = 1;

-- 이후 UPDATE 실행 (버전 조건 포함!)
update product
set stock = ?, version = ?
where id = ? and version = ?;

 

 

 

 

 

❓언제 쓰나?

  • 읽기 위주 트래픽이 많고, 충돌 가능성이 낮을 때
  • ex) 주문 수정, 후기 수정 등 변경 건수가 적은 작업

 

 

 

 

😀장점

충돌이 안난다는 가정하에, 동시 요청에 대해서 처리 성능이 좋다.


😢단점

잦은 충돌이 일어나는 경우, 롤백 처리에 대한 비용이 많이 들어 성능에서 손해볼 수도 있다.
롤백 처리를 구현하는게 복잡할 수 있다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

비관적 락 (Pessimistic lock)  - @Lock(LockModeType.PESSIMISTIC_WRITE)

Repeatable_Read 또는 Serializable 정도의 격리성 수준을 제공한다.

 

❗️격리성
트랜잭션의 특성 중 한 가지인 격리성(Isolation)은 현재 수행 중인 트랜잭션이 완료될 때까지 트랜잭션이 생성된 중간 연산 결과에 다른 트랜잭션들이 접근할 수 없음을 의미한다.격리성은 여러 개의 격리 수준으로 나뉘어 격리성을 보장한다.격리 수준은 아래와 같이 4개가 있다.

위로 갈수록 동시성이 강해지지만 격리성은 약해지고, 아래로 갈수록 동시성은 약해지지만 격리성은 강해진다.또한 각 단계마다 나타나는 현상이 있다.


1. 격리수준
격리 수준 설명 발생 가능 문제 성능
READ UNCOMMITTED 커밋 안 된 데이터도 읽음 더티 리드 빠름 ⚡️
READ COMMITTED 커밋된 데이터만 읽음 반복 불가 읽기 보통
REPEATABLE READ 같은 쿼리 반복 시 항상 같은 결과 팬텀 리드 느려짐
SERIALIZABLE 완전 직렬화, 가장 안전 ❌ 문제 없음 가장 느림 🐢




2. 발생 가능 문제
문제 설명 예시
더티리드 커밋되지 않은 데이터를 읽음 A가 아직 커밋 안한 데이터를 B가 읽음
반복 불가 읽기 같은 row를 두번 읽는데 값이 달라짐 첫번째 읽을땐 100, 두번째 읽을 땐 150
팬텀 리드 조건에 맞는 row 수가 바뀜 where price > 100 했는데, 중간에 insert돼서 row 수 늘어남


실무에서 주로 쓰는 건?

MySQL (InnoDB) 기본: REPEATABLE READ
PostgreSQL 기본: READ COMMITTED
보통 READ COMMITTED 정도면 실무에선 충분하고, 높은 격리 수준은 필요한 부분에만 선택적으로 씀.

 

 

  • DB 수준에서 row 자체를 잠금 (SELECT ... FOR UPDATE)
  • 충돌 가능성이 높거나, 반드시 동시 수정이 불가능할 때 사용

 

 

트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작

Shared Lock (S Lock) 읽을 수 있지만 수정은 안 됨 다른 S Lock은 허용, X Lock은 막음
Exclusive Lock (X Lock) 읽기도, 쓰기도 독점함 S Lock, X Lock 둘 다 막음

 

만약 Shared Lock이 다른 트랜잭션에 의해 걸려 있으면 해당 Lock을 얻지 못하므로 업데이트를 할 수 없다.
데이터를 수정하기 위해서는 해당 트랜잭션을 제외한 모든 트랜잭션이 종료(Commit)되어야 한다.
Shared Lock을 걸면 read 연산밖에 못하기 때문에, write 연산을 하기 위해서는 Exclusive Lock을 얻어야 한다.

 

 

낙관적 락 중 shared lock 의 흐름에 대해 알아보자

T1이 Table Id 2를 읽음 Shared Lock(S Lock) 획득
T2도 같은 row를 읽음 읽기는 가능 (S Lock끼리는 공유 가능)
T2가 해당 row를 수정하려 함 X Lock 필요 → S Lock과 충돌 → Blocking 상태
T1이 트랜잭션을 Commit함 S Lock 해제
T2의 Update가 이어서 수행됨 X Lock 획득 → 정상 Update (Name = Fancy)


이렇게 비관적 락은 Transaction 과정을 통해 충돌을 예방한다.

 

 

 

 

사용예시

JPA에서 Shared Lock은 직접적으로 지정하는 방식은 없지만,
MySQL에서는 LockModeType.PESSIMISTIC_READ가 Shared Lock(S Lock) 으로 동작한다

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithSharedLock(@Param("id") Long id);
}

 

참고 ) PostGreSQL은 다르게 동작!

PESSIMISTIC_READ 실제로는 FOR SHARE가 아닌 FOR KEY SHARE 사용됨 (제한적 Shared Lock)
PESSIMISTIC_WRITE FOR UPDATE 사용됨 (명확한 행 잠금, X Lock)
OPTIMISTIC 버전 기반 충돌 감지, JPA 차원에서 처리됨
NONE 락 없음

 

@Transactional
public void updateWithSharedLock(Long id) {
    Product product = productRepository.findByIdWithSharedLock(id)
            .orElseThrow(() -> new RuntimeException("Not found"));

    // 이 시점에서 다른 트랜잭션은 UPDATE에 대해 Block될 수 있음

    // 일부 로직 처리 후...
    product.setName("Fancy");
    // 트랜잭션 끝날 때까지 Shared Lock 유지됨
}

 

 

 

 

 

 

❓언제 쓰나?

  • 재고 차감, 좌석 예약, 포인트 사용처럼 경쟁이 치열한 작업
  • 실시간 데이터 정합성이 중요한 비즈니스

 

 

 

 

😀장점

- 읽기는 병렬 허용하면서도 수정은 제어 가능
- 데이터 충돌 확률 줄임
- 명시적 제어 가능 (정합성 확보)


😢단점

- 락 해제 전까지 다른 트랜잭션이 BLOCK됨 → 지연 가능성
- 데드락 위험 존재
- Shared Lock은 DB 종류마다 동작 방식 다름 (이식성 ↓)
- 실수로 오래 잡고 있으면 성능 이슈 -> 특히 읽기가 많이 이루어지는 DB의 경우 손해가 심각

 

 

DeadLock 이란?
 각 트랜잭션들이 lock를 획득하기 위해 상대가 독점하고 있는 데이터에 unlock 연산이 실행되기를 서로 기다리면서 수행을 중단하고 있는 상태
교착 상태에 빠지면 트랜잭션들은 더 이상 수행하지 못하고 상대 트랜잭션이 먼저 unlock 연산을 실행해주기를 한없이 기다리게 된다.

ex) 1번 트랜잭션에서는 2번 데이터의 Lock을 획득하고, 2번 트랜잭션에서는 1번 데이터의 Lock을 획득한 상태이다.이때, 동시에 상대방의 데이터를 접근하려면 unlock 연산이 실행될 때까지 기다려야 한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

분산 락

 

여러 서버나 인스턴스에서 동일한 리소스에 동시에 접근하지 못하게 막는 락

일반적인 DB 락은 같은 DB 내에서만 유효하지만,
멀티 서버 / 멀티 인스턴스 환경에서는
서버 A, B, C가 같은 자원(예: 쿠폰 발급, 재고 차감 등)에 동시에 접근할 수 있기 때문에
DB 락만으로는 막을 수 없음. → 이때 분산 락 필요!

 

현재 서비스는 NginX로 로드밸런싱 중이기에 이 방식을 채택했다

 

 

Redis 기반 (Redisson) SETNX, TTL 이용 빠르고 많이 씀, 설정 간편
Zookeeper 기반 ephemeral + sequential node 이용 안정적이지만 복잡하고 무거움
Database 기반 SELECT GET_LOCK() (MySQL) DB 부하 큼, 잘 안 씀
Etcd 기반 Raft 기반 락 대규모 마이크로서비스용

 

 

 

 

사용 방법 (Redission)

설정 추가

// Gradle
implementation 'org.redisson:redisson-spring-boot-starter:3.24.3'

 

빈 직접 등록해줘야 RedissionClient 주입받아 사용 가능

//config같은거 만들어서 빈 등록


@Bean
  public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6379");
    return Redisson.create(config);
  }

 

 

@Autowired
private RedissonClient redissonClient;

public void issueCoupon(Long userId) {
    String lockKey = "coupon:lock:" + userId;
    RLock lock = redissonClient.getLock(lockKey);

    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS); // 3초 기다리고, 락 획득 후 최대 10초 유지

        if (isLocked) {
            // 락 획득 성공 → 쿠폰 발급 로직
        } else {
            // 락 획득 실패 → 이미 다른 요청 진행 중
        }

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        if (lock != null && lock.isHeldByCurrentThread()) { //본인 스레드에서 생성한 락일 떄
            lock.unlock(); // 락 해제
        }
    }
}

 

기본적인 사용법은 다음과 같고 이런 기능은 추후 유틸로 빼도 좋을 것 같다.

 

 

 

 

 

 

😀장점

멀티 인스턴스 환경에서 안전 여러 서버 간 자원 접근 제어 가능 (ex. 쿠폰 중복 발급 방지)
구현 쉬움 Redisson은 API가 직관적이고, AOP로 감싸기도 쉬움
TTL 설정 가능 락이 풀리지 않아도 일정 시간 지나면 자동 해제됨 (leaseTime)
비교적 빠름 Redis 기반이라 지연이 거의 없음 (ms 단위)
기타 기능 풍부 ReadWriteLock, Semaphore, FairLock 등도 지원함

 

 


😢단점

Redis 장애 시 전체 락 실패 Redis 다운되면 락 자체가 무력화됨 (고가용성 구성 필수)
네트워크 딜레이 민감 락 획득/해제 실패가 네트워크 환경에 따라 영향을 받음
잘못된 TTL 설정은 재앙 너무 짧으면 중간에 락 풀림, 너무 길면 자원 낭비 & 데드락
분산 환경 기준 맞추기 어려움 클럭 시간차, failover 시 락 정합성 문제가 생길 수 있음
트랜잭션과 분리되어 있음 DB 트랜잭션 rollback돼도 락은 해제 안 됨 → 개발자가 직접 관리해야 함

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

총정리

@Version (Optimistic) 버전 기반 충돌 감지 수정 충돌 적은 작업 트랜잭션 빠름 충돌 시 예외 발생
PESSIMISTIC_WRITE DB row 잠금 재고/예약/순번 데이터 강한 정합성 성능 이슈 가능
Redis Lock 분산 락 멀티 서버, JPA 밖 서버 간 충돌 방지 별도 구현 필요

 

 

 

 

 

 

 

 

 

 

 

 

참고 -

https://product.kyobobook.co.kr/detail/S000001743852
https://devfancy.github.io/DB-Locking/

https://unluckyjung.github.io/db/2022/03/07/Optimistic-vs-Pessimistic-Lock/

https://velog.io/@hgs-study/redisson-distributed-lock