코드의 여백

[스프링] 동시성(Concurrency) 문제 알아보기

by rowing0328

Intro

동시성 문제는 꼭 거대한 시스템에서만 생기는 건 아니다.

멀티스레드 환경이거나, 서버 인스턴스가 두 개만 돼도 충분히 발생할 수 있다.

 

이 글에서는 자바 스프링 기반 시스템에서 겪었던 동시성 문제들과,

그걸 어떻게 풀어갔는지에 대한 사례를 정리해봤다.

정답이라기보단 하나의 참고 사례 정도로 봐주면 좋겠다.

 

 

동시성 문제란

여러 스레드 또는 프로세스가 동시에 공유 자원에 접근하거나 변경하려고 할 때 발생하는 문제다.

단일 환경에서는 문제없이 돌아가던 코드가, 멀티스레드 / 다중 인스턴스 / 분산 환경에 들어서면
예상치 못한 오류를 일으키는 대표적 원인이다.

 

대표적인 증상

  • 재고가 마이너스가 됨
  • 쿠폰이 초과 발급됨
  • 포인트 중복 적립
  • 은행 계좌에서 잔액이 음수로 빠져나감

 

이런 문제는 흔히 동시 요청이 거의 동시에 처리되면서 중간 상태를 덮어쓰거나 무시할 때 발생한다.

 

 

자바 & 스프링에서 동시성 문제 해결 전략

동시성 문제를 해결하려면 기본적으로 다음과 같은 두 가지 중 하나로 접근해야 한다:

 

  • 동기화 (Synchronization)
    동시에 접근하지 못하게 만든다.
  • 버전 관리 (Optimistic Lock)
    충돌을 감지하고 처리한다.

 

자바와 스프링에서는 이를 위한 여러 도구와 방법론이 있다.

 

자바의 synchronized

public synchronized void update() {
    // 단일 인스턴스에서만 유효
}

 

[ 장점 ]

  • 구현이 간단하다.

 

[ 단점 ]

  • 분산 시스템에서는 무의미 (인스턴스마다 JVM이 다름).
  • 서비스 규모가 커지면 확장성 문제가 생긴다.

 

운영 환경에서는 거의 사용되지 않으며, 테스트나 단일 WAS 환경에서 임시 대응 정도로만 사용한다.

 

데이터베이스 기반 락

 

비관적 락 (Pessimistic Lock)

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select i from Item i where i.id = :id")
Item findByIdForUpdate(@Param("id") Long id);

이 방식은 데이터를 읽는 시점부터 트랜잭션이 끝날 때까지,
테이블/로우에 락을 걸어 다른 트랜잭션의 접근을 막는 방식이다.

 

[ 장점 ]

  • 충돌이 확실히 방지된다.
  • 동시 수정이 거의 불가능한 중요 자원에 적합 (예: 계좌, 재고)

 

[ 단점 ]

  • 성능 저하
  • 데드락 가능성
  • 트래픽이 많을수록 병목이 생긴다.

 

낙관적 락 (Optimistic Lock)

스프링 데이터 JPA에서는 @Version 어노테이션으로 쉽게 적용할 수 있다.

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

    private int stock;

    @Version
    private Long version;

    public void decrease(int quantity) {
        if (stock < quantity) throw new RuntimeException("재고 부족");
        stock -= quantity;
    }
}

 

[ 작동 방식 ]

  • 엔티티에 version 컬럼을 추가
  • DB 업데이트 시 WHERE 조건에 version 포함
  • version이 맞지 않으면 OptimisticLockingFailureException 발생

 

[ 장점 ]

  • 락을 안 걸기 때문에 성능에 부담이 적음
  • 충돌이 자주 발생하지 않는 경우 매우 효율적

 

[ 단점 ]

  • 충돌이 자주 발생하면 예외 + 재시도가 많아져 오히려 비효율
  • 예외 핸들링 및 재시도 로직이 필요함
for (int i = 0; i < 3; i++) {
    try {
        service.decreaseStock(id, quantity);
        break;
    } catch (ObjectOptimisticLockingFailureException e) {
        // retry
    }
}

 

분산 락 - Redis 기반

 

Redisson 라이브러리 사용 예시

RLock lock = redissonClient.getLock("lock:item:" + itemId);
boolean isLocked = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (isLocked) {
    try {
        // 재고 감소 로직
    } finally {
        lock.unlock();
    }
}

 

[ 장점 ]

  • 다중 AWS 환경에서도 적용 가능 (분산 락)
  • 락 점유 시간 및 타임아웃 조정 가능

 

[ 단점 ]

  • Redis 장애 시 서비스 전체 영향
  • 락 해제 실패/누락 가능성 (try - finally 철저히 필요)

 

Lua Script를 통한 원자성 확보

if redis.call("get", KEYS[1]) >= ARGV[1] then
  return redis.call("decrby", KEYS[1], ARGV[1])
else
  return -1
end
  • 여러 연산을 하나의 트랜잭션처럼 실행 가능
  • 락이 아니라도 원자성이 중요할 때 사용 (예: 쿠폰 수량 감소)

 

 

운영 환경 실전 적용 사례

 

재고 감소 API - 낙관적 락 + 재시도

 

[ 배경 ]

  • 쇼핑몰의 인기 상품에서 주문 경쟁이 심함
  • 재고는 1인데 주문이 동시에 3건 들어오면서 재고가 음수로 내려가는 현상 발생

 

[ 해결 ]

  • JPA의 @Version 사용
  • 재고 감소 실패 시 3회까지 재시도
  • 모니터링 시스템과 함께 예외 케이스 추적
@Transactional
public void decreaseStock(Long itemId, int quantity) {
    Item item = itemRepository.findById(itemId)
        .orElseThrow(() -> new ItemNotFoundException());

    item.decrease(quantity); // 버전 체크 내장
}

 

 

쿠폰 발급 시스템 - Redis + Lua 스크립트

 

[ 배경 ]

  • 특정 시간에 1,000명에게 한정 쿠폰 제공
  • 사용자 수가 10만명 이상 접속 → 중복 발급 발생

 

[ 해결 ]

  • Redis에 coupon:count 키 저장
  • Lua 스크립트로 발급 시점에 감소 시도
  • 실패 시 예외 처리 및 사용자 알림

 

[ 효과 ]

  • 과발급 없이 정확한 수량 관리
  • 기존 DB 락 대비 10배 이상 성능 향상

 

 

동시성 처리 시 고려해야 할 점

  • 락의 범위는 최소화해야 한다.
  • 비즈니스적으로 중요한 자원만 락을 적용한다.
  • 장애 대비 타임아웃, 재시도, fallback 전략을 반드시 함께 설계해야 한다.
  • 장기적으로는 CQRS나 이벤트 기반 구조를 고려한 필요도 있다.

 

 

마무리

동시성 문제는 단순한 코드 실수가 아니라, 구조적이고 아키텍처적인 관점에서 접근해야 한다.

스프링 기반 환경에서는 제공되는 다양한 도구들을 적절히 조합하여 문제를 해결할 수 있으며,
각 방법의 특성과 한계를 잘 이해하고 사용하는 것이 핵심이다.

 

코드 한 줄 바꾸는 것보다 중요한 건, 시스템 전반의 흐름을 이해하는 것이다.

 

블로그의 정보

코드의 여백

rowing0328

활동하기