동시성 문제를 해결하기 위해서는 공유자원에 대한 접근을 직렬화(순차처리) 하는 방법이 필요하다.
단일 JVM인 경우 자바의 synchronized 키워드 또는 ReentrantLock, Atomic Class을 활용해서 동시성을 제어할 수 있다.
자바 단에서 락을 사용하면 간단하고 빠르게 동시성을 해결할 수 있다. 하지만 같은 락 객체를 기준으로 동기화되며, 단일 JVM 내에서만 안전하다. MSA 환경인 여러 서버가 있을 경우에는 각 서버가 독립된 JVM을 가지므로 해당 키워드로 동시성 문제를 해결할 수 없다.
→ 단일 서버(JVM), 그리고 해당 애플리케이션 레벨에서만 동시성 이슈가 있는 간단한 케이스에 사용한다.
데이터베이스 자체의 락 기능을 사용하여 동시성을 제어하는 방식이다. 실제 DB 레코드를 점유하는 비관적 락과 커밋 시점에 충돌을 감지하여 해결하는 낙관적 락이 있다.
비관적락은 실제로 DB레코드를 점유 하기 때문에 락이 해제될때까지 다른 트랜잭션은 대기상태에 들어간다. 때문에 락의 범위가 지나치게 넓거나 잘못된 락을 걸면 데드락(교착상태)가 발생할수 있다.
JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 레포지토리 메서드에 붙히면 비관적 쓰기락을 쉽게 사용할수있다. 이 방식은 멀티 인스턴스 환경에서도 안전하게 동작한다. (DB자체가 락을 관리하기때문)
데이터 정합성 보장이 확실하다는 장점과 실제 DB리소스를 점유하기때문에 DB부하가 증가한다는 단점이있다. 경합이 많으면 락을 대기가 많아지고 성능저하로 이어질수있다.
해당 도메인의 일관성이 정말 중요(재고갯수 등)하고 동시에 접근 할 가능성이 높고 경합구간(레코드 범위)가 좁은경우에 사용한다.
// JPA Repository 예시
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdForUpdate(@Param("id") Long id);
// Service: 트랜잭션 내에서 사용
@Transactional
public void decrease(Long stockId, int n) {
Stock s = repo.findByIdForUpdate(stockId); // SELECT ... FOR UPDATE
if (s.getQty() < n) throw new IllegalStateException("재고 부족");
s.setQty(s.getQty() - n);
}
낙관적락은 동시에 접근이 발생해도 일단 락을 점유하지않고 자유롭게 읽고 쓸수있다. 단 최종 커밋 시점에 버전 컬럼을 비교하여 충돌을 감지한다. 충돌이 발생되면 해당 트랜잭션은 롤백된다.
JPA에서는 @Version이라는 어노테이션을 엔티티 컬럼에 붙혀서 쉽게 적용할수있다.
동작 원리
1. 엔티티를 조회할 때 version 값도 함께 읽는다.
2. 업데이트 시점에 WHERE id=? AND version=? 조건으로 실행된다.
3. 성공 시 version 값이 증가한다.
4. 다른 트랜잭션이 먼저 커밋해서 version 값이 바뀌면, 내 쿼리는 영향받은 row 수=0 → 실패 처리. 스프링(JPA)에서는 이 경우를 OptimisticLockException 하나로 추상화해 알려준다.
재시도 전략
충돌 가능성이 낮고, 충돌 발생시 재시도/실패를 허용하는 도메인의 경우 사용한다. 충돌시 무조건 실패이고 경합이 많은경우에는 재시도 로직이 계속 돌아서 오히려 성능이 저하될수있다. 그렇기 때문에 충돌 감지후 반드시 비지니스적으로 재시도를 해야하는지를 고민해야한다. 또한 반드시 버전 컬럼을 둬야한다.
낙관적 락에서는 결국 최종 일관성 보장은 DB의 제약조건(UNOQUE, CHECK등)과 함께 사용해야한다.
@Entity
class Stock {
@Id Long id;
int qty;
@Version long version; // JPA 버전 컬럼
// ...
}
@Transactional
public void decrease(Long id, int n) {
Stock s = repo.findById(id).orElseThrow();
if (s.getQty() < n) throw new IllegalStateException("재고 부족");
s.setQty(s.getQty() - n);
// flush 시 version 비교 → 충돌 시 OptimisticLockException
}
// 호출부(또는 AOP)에서 재시도 전략
public void decreaseWithRetry(Long id, int n) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
service.decrease(id, n);
return;
} catch (OptimisticLockException e) {
// backoff 후 재시도
sleep(10 * (i + 1));
}
}
throw new RuntimeException("재시도 초과");
}
Redis, MySQL, Zookeeper 같은 외부 시스템을 활용하여 여러 서버 간 동시성을 제어하는 방식이다. 여기서는 Redis를 활용한 방법을 알아보도록 하자.
단일 서버 환경에서는 자바의 synchronized나 Lock으로 충분하지만, 서버가 여러 대인 경우에는 작동하지 않는다. 서버 A의 synchronized와 서버 B의 synchronized는 완전 별개인 것이기 때문이다. 따라서 분산락을 구현하여 중앙 집중식 락 관리자를 두고 모든 서버가 이를 통해 락을 획득/해제하도록 해야한다.
1. [Server A] lock.tryLock() 호출
→ Redis에 "stock:lock:123" 키 생성 성공 ✅
2. [Server B] lock.tryLock() 호출
→ Redis에 이미 키가 존재 → 대기 ⏳
3. [Server A] 비즈니스 로직 수행 후 unlock()
→ Redis에서 "stock:lock:123" 키 삭제
4. [Server B] 락 획득 성공 ✅
→ 비즈니스 로직 수행
Redis는 외부 Resource를 활용하므로 불필요한 DB Connection까지 차단이 가능하다. 관리주체가 데이터베이스 + Redis로 늘어남에 따라 다른 문제점이 발생할 수있긴하다. (Redis 장애, 네트워크 지연, TTL 설정 오류 등)
락 해제 보장(finally 블록 처리), 락 만료 시간 설정(leaseTime), 고가용성 구성(Redis 클러스터/레플리카)이 필수적이다.
하지만 프로세스 처리단위에 대해 동일한 Lock을 여러 인스턴스에 대해 적용 할 수 있으므로 동시성 문제를 효과적으로 해결 가능하다.
Redisson은 Redis 기반의 자바 클라이언트 라이브러리로, Redis를 더 쉽고 강력하게 사용할 수 있게 해주는 도구이다.
실제로 트랜잭션 내부에서 레디스 분산락을 사용한다고 하면 순서가 굉장히 중요하다.
락 획득 -> 트랜잭션 시작 -> 비지니스 로직 수행 -> 트랜잭션 종료 -> 락해제
하지만 우선 단순하게 레디스 분산락자체에 대해서만 설명한다.
Redis 분산락의 장점으로는 락의 범위를 비지니스 단위로 유연하게 설계할수있다. 아래의 다양한 패턴들을 활용해 비지니스 요구사항에 맞춘 락 전략을 만들수있다.
트랜잭션과의 관계
Redis 락과 DB 트랜잭션을 어떻게 묶을지가 중요하다. 락을 트랜잭션 시작 전/후 어느 시점에 잡을지, 락을 얼마나 오래 유지할지에 따라 성능과 안정성이 크게 달라진다. 트랜잭션 범위가 넓을수록 락 경쟁이 심해져 성능 저하나 데드락 가능성이 높아진다.
키 전략
락 키는 반드시 자원(Resource) 단위로 설정해야 한다.
잘못된 예시:
올바른 예시:
public void decreaseWithRedisLock(Long stockId, int n) {
String lockKey = "lock:stock:" + stockId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(3, 2, TimeUnit.SECONDS); // 대기 3s, 점유 2s
if (!locked) throw new IllegalStateException("락 획득 실패");
// 여기서는 일반 JPA 업데이트 or 비관/낙관적 락 혼용 가능
service.decrease(stockId, n);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (locked && lock.isHeldByCurrentThread()) lock.unlock();
}
}

서버가 한 대일 땐 synchronized 만으로도 충분하지만 서버를 여러 대 띄우는 멀티 인스턴스 환경에서는 DB 락이나 Redis 같은 외부 리소스를 꼭 써야한다.
자바락은 자바 애플리케이션 상에서 락을 적용하여 동시성 문제를 해결하는 것이다. sychronized 키워드나, ReentrantLock, Atomic class 등을 사용해서 적용할 수 있다. 다만 이 방법은 같은 JVM에서만 유효하고 서버가 여러 개인 경우에는 효과가 없다. 서버가 여러 개이고 같은 DB를 바라본다면 DB단에서의 락을 고려해볼 수 있다. 실제 DB 레코드를 점유하여 다른 트랜잭션은 대기 상태에 들어가는 비관적 락과 락을 점유하지 않고 최종 시점에 데이터 충돌을 확인하여 해결하는 낙관적락이 있다. 낙관적 락은 사실 애플리케이션 단에서 충돌을 해결하는 것이기 때문에 재시도 전략까지 고려해서 구현해야한다. 그리고 애플리케이션 서버와 DB서버 모두 여러개인 환경에서는 분산락을 구현해서 락을 중앙에서 관리하고 모든 서버가 이를 통해 락을 획득하고 해제하도록 해야한다. Redis, Zookeeper 등 외부 서비스를 사용하여 구현할 수 있다. 분산락을 사용하면 락 키를 비즈니스 로직에 맞게 자유롭게 설계할 수 있고 DB부하도 줄일 수 있다. 다만 락 해제 보장, 락 만료 시간 설정, 고가용성 구성을 함께 고려해야한다.