본문 바로가기
백엔드 개발

Spring 동시성 문제 해결 (synchronized부터 Redis Lock, Lua, Redisson까지 정리)

by collenkim 2026. 4. 6.
반응형

개발하다 보면 한 번쯤 이런 상황을 겪는다.

“분명 재고는 1개였는데 왜 -1이 되지?”

처음에는 단순한 로직 문제나 DB 문제라고 생각하기 쉽다.
하지만 실제 원인은 동시성 문제인 경우가 많다.

이 글에서는 실제로 겪으면서 정리한
synchronized부터 Redis Lock, Lua Script, Redisson까지의 흐름을 정리해 본다.


1. 동시성 문제는 왜 발생할까?

재고가 1개 남아있는 상황에서 동시에 두 개의 요청이 들어온다고 가정해 보자.

  • A 요청 → 재고 조회 (1)
  • B 요청 → 재고 조회 (1)

두 요청 모두 재고가 있다고 판단하고 동시에 차감하면 결과는 -1이 된다.

이 문제는 코드 자체의 오류라기보다
동시에 실행되는 상황에서 발생하는 문제다.


2. synchronized로 해결해 보기

가장 먼저 떠올릴 수 있는 방법은 synchronized다.

public synchronized void decreaseStock() { stock--; }

 

또는

synchronized (this) { stock--; }

 

이 방식은 한 번에 하나의 스레드만 접근하도록 제한하기 때문에
동시성 문제를 해결할 수 있다.

로컬 환경에서는 정상적으로 동작한다.


3. 하지만 운영 환경에서는 한계가 있다

운영 환경에서는 서버가 여러 대로 구성되는 경우가 많다.

  • 서버 A에서 synchronized 적용
  • 서버 B에서 synchronized 적용

이 두 서버는 서로 다른 JVM에서 동작하기 때문에
락을 공유하지 못한다.

결국 동시에 실행되면서 동일한 문제가 다시 발생한다.

즉, synchronized는 단일 서버 환경에서만 유효하다.


4. Redis Lock이 필요한 이유

멀티 서버 환경에서는 모든 서버가 공유할 수 있는 락이 필요하다.

이때 사용하는 것이 Redis 기반 분산 락이다.


5. 가장 단순한 구현: SETNX

Redis에서는 setIfAbsent(SETNX)를 이용해 간단하게 락을 구현할 수 있다.

Boolean success = redisTemplate.opsForValue()
.setIfAbsent("lock:stock", "1", 3, TimeUnit.SECONDS); 

if (!success) { 
	throw new RuntimeException("이미 처리 중입니다."); 
} 

try { 
    decreaseStock();  
}
finally {
	redisTemplate.delete("lock:stock"); 
}

 

키가 존재하지 않을 때만 값을 설정하기 때문에
락을 획득한 것처럼 사용할 수 있다.


6. SETNX 방식의 문제점

이 방식은 특정 상황에서 문제가 발생할 수 있다.

  1. A가 락을 획득
  2. 처리 중 TTL 만료
  3. B가 락을 획득
  4. A가 finally에서 delete 실행

이 경우 B가 획득한 락이 삭제되는 문제가 발생한다.


7. value를 이용한 락 소유자 검증

이 문제를 해결하기 위해 보통 고유 값을 사용한다.

String lockValue = UUID.randomUUID().toString(); 

Boolean success = redisTemplate.opsForValue() 
	.setIfAbsent("lock:stock", lockValue, 3, TimeUnit.SECONDS); 
    
try { 
	decreaseStock(); 
} finally { 
	String currentValue = redisTemplate.opsForValue().get("lock:stock"); 

	if (lockValue.equals(currentValue)) { 
		redisTemplate.delete("lock:stock"); 
	} 
}

 

이렇게 하면 자신이 획득한 락인지 확인 후 삭제할 수 있다.

하지만 이 방식도 완전히 안전하지는 않다.


8. Lua Script가 필요한 이유

위 방식은 다음과 같은 순서로 동작한다.

  • 값 조회
  • 값 비교
  • 삭제

이 세 단계는 하나의 작업으로 실행되지 않는다.
그 사이에 다른 요청이 개입할 수 있다.


9. Lua Script로 원자성 보장하기

Redis에서는 Lua Script를 통해 여러 명령을 한 번에 실행할 수 있다.

if redis.call("get", KEYS[1]) == ARGV[1] then 
	return redis.call("del", KEYS[1]) 
else 
	return 0 
end

 

이 스크립트는 값 비교와 삭제를 하나의 작업으로 처리한다.
따라서 중간에 다른 요청이 끼어들 수 없다.


10. Spring에서 Lua Script 적용

DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); 

redisScript.setScriptText( 
	"if redis.call('get', KEYS[1]) == ARGV[1] then " + 
    "return redis.call('del', KEYS[1]) " + 
    "else return 0 end" ); 
    
redisScript.setResultType(Long.class); 

redisTemplate.execute( 
	redisScript, 
    Collections.singletonList("lock:stock"), 
    lockValue );

 

이 방식으로 안전한 락 해제가 가능하다.


11. 결국 Redisson을 사용하게 된다

여기까지 직접 구현해 보면 느끼는 점이 있다.
락 관련 로직을 모두 직접 관리하는 것은 생각보다 복잡하다.

그래서 실무에서는 Redisson을 많이 사용한다.

RLock lock = redissonClient.getLock("lock:stock"); 

try { 
	if (lock.tryLock(5, 3, TimeUnit.SECONDS)) { 
    	decreaseStock(); 
    } 
} finally { 
	lock.unlock(); 
}

12. Redisson을 사용하는 이유

  • 락 자동 연장 (watchdog)
  • 재시도 로직 지원
  • 안정적인 락 해제

직접 구현한 Redis Lock을 더 안정적으로 사용할 수 있도록 도와준다.


13. Redis Session으로 락을 구현할 수 있을까?

결론적으로 권장되지 않는다.

Redis Session은 사용자 상태를 관리하기 위한 기능이며
락을 위한 기능이 부족하다.

  • TTL 제어가 제한적
  • 동시성 제어 어려움
  • 목적이 다름

락은 별도로 구현하는 것이 적절하다.


14. 전체 흐름 정리

동시성 문제를 해결하는 과정은 보통 다음과 같다.

  1. synchronized 사용
  2. Redis SETNX 적용
  3. value 검증 추가
  4. Lua Script 적용
  5. Redisson 사용

15. 결론

단일 서버 환경에서는 synchronized로도 해결 가능하다.
하지만 멀티 서버 환경에서는 Redis 기반 락이 필요하다.

실무에서는 직접 구현보다 Redisson을 사용하는 것이 일반적이다.

반응형