동시성이란?
동시성이란 여러 스레드 혹은 프로세스가 동일한 공유 자원에 동시에 접근하려고 할 때 발생하는 현상이다.
- 주문 버튼을 빠르게 두 번 클릭하여 주문 API가 중복 호출되는 경우
- 선착순 쿠폰 발급 시 여러 사용자가 동시에 요청 → 초과 발급
- 한정된 재고가 동시에 차감되면서 음수(-) 재고가 되는 경우
- 계좌 잔액이 동시 인출로 인해 음수가 되는 경우
이처럼 동시성 문제는 실패가 항상 재현되지 않고 가끔 발생하기 때문에 더 위험하고 디버깅이 어렵다.
동시성 문제 해결방안
동시성 문제를 해결하기 위해서는 공유자원에 대한 접근을 직렬화(순차처리) 하는 방법이 필요하다.
- 자바의 synchronized 키워드
- JVM 레벨에서 임계 구역(critical section)을 보장
- 단일 인스턴스 환경에서는 간단하고 효과적
- 데이터베이스 락(DB Lock)
- DB가 직접 레코드 단위로 락을 관리 (비관적 락, 낙관적 락)
- 멀티 인스턴스 환경에서도 정합성 보장 가능
- Redis 분산락
- 인메모리 Redis를 활용해 여러 인스턴스 간 공통된 락을 관리
- 비즈니스 단위로 유연한 락 설계 가능 (멀티락, 키 전략 등)
- DB 부하를 줄이고 확장성 확보
문제 코드
1. 재고 차감 코드
단순 재고 차감 코드이다.
`stock` 도메인에서 재고를 차감하는 로직이있고 `RaceDemo`에서 스레드 100개가 재고를 차감하고있다.
이때 qty(재고개수)가 읽고 -> 계산 -> 쓰기 의 과정에서 다른 스레드가 끼어들수있다 -> 레이스 컨디션
class Stock {
private int qty;
public Stock(int initial) {
this.qty = initial;
}
// 문제 intentionally: 원자성 보장 없음
public void decrease(int n) {
if (qty >= n) {
int next = qty - n; // <-- 경쟁 구간 (읽기→계산→쓰기)
sleep(1); // 타이밍 이슈 노출용
qty = next;
} else {
// ignore
}
}
public int getQty() { return qty; }
private void sleep(int ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
public class RaceDemo {
public static void main(String[] args) throws InterruptedException {
Stock stock = new Stock(50);
int threads = 100;
Thread[] workers = new Thread[threads];
for (int i = 0; i < threads; i++) {
workers[i] = new Thread(() -> stock.decrease(1));
workers[i].start();
}
for (Thread t : workers) t.join();
System.out.println("최종 재고 = " + stock.getQty()); // 기대: 0, 현실: 종종 음수/불일치
}
}
해결방안
1. 자바 synchronized 로 해결 (단일 인스턴스 / 단일 JVM한정)
단일 JVM인 경우 자바의 `synchronized`키워드를 활용해서 동시성을 제어할수있다.
해당 키워드를 사용하면 간단하고 빠르게 동시성을 해결 할 수 있다.
하지만 같은 객체를 공유하는 단일 JVM내에서만 안전하다.
MSA환경인 여러서버가 있을경우에는 해당 키워드로 동시성문제를 해결 할 수 없다.
-> 단일 인스턴스, 그리고 해당 어플리케이션레벨에서만 동시성 이슈가 있는 간단한 케이스에 사용한다.
class StockSync {
private int qty;
public StockSync(int initial) { this.qty = initial; }
public synchronized void decrease(int n) { // 진입 자체를 상호배제
if (qty >= n) {
int next = qty - n;
qty = next;
}
}
public synchronized int getQty() { return qty; }
}
2. DB락으로 해결 (비관적락 / 낙관적락)
2-1. 비관적락 (JPA + MySQL)
- 종류
- 쓰기 락(Write Lock): 한 스레드/트랜잭션이 특정 자원에 먼저 접근하면, 다른 스레드/트랜잭션은 접근 자체가 불가능하다. (읽기/쓰기 모두 차단)
- 읽기 락(Read Lock): 동시에 읽기는 가능하지만, 쓰기 작업은 차단된다. 즉, 데이터 정합성 문제를 피할 수 없지만, 조회 성능을 높일 때 활용된다.
비관적락은 실제로 DB레코드를 점유 하기 때문에 락이 해제될때까지 다른 트랜잭션은 대기상태에 들어간다.
때문에 락의 범위가 지나치게 넓거나 잘못된 락을 걸면 데드락(교착상태)가 발생할수 있다.
JPA에서는 `@Lock(LockModeType.PESSIMISTIC_WRITE)` 어노테이션을 레포지토리 메서드에 붙히면 비관적 쓰기락을 쉽게 사용할수있다.
이방식은 멀티 인스턴스 환경에서도 안전하게 동작한다. (DB자체가 락을 관리하기때문)
데이터 정합성 보장이 확실하다는 장점과 실제 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);
}
2-2 낙관적락 (@Version)
낙관적락은 동시에 접근이 발생해도 일단 락을 점윻지않고 자유롭게 읽고 쓸수있다.
단 최종 커밋 시점에 버전 컬럼을 비교하여 충돌을 감지한다.
충돌이 발생되면 해당 트랜잭션은 롤백된다.
JPA에서는 @Version이라는 어노테이션을 엔티티 컬럼에 붙혀서 쉽게 적용할수있다.
- 동작 원리:
- 엔티티를 조회할 때 version 값도 함께 읽는다.
- 업데이트 시점에 WHERE id=? AND version=? 조건으로 실행된다.
- 성공 시 version 값이 증가한다.
- 다른 트랜잭션이 먼저 커밋해서 version 값이 바뀌면, 내 쿼리는 영향받은 row 수=0 → 실패 처리.
- 스프링(JPA)에서는 이 경우를 OptimisticLockException 하나로 추상화해 알려준다. (PSA~ )
재시도 전략
- 락 대기가 없기 때문에 충돌 시 즉시 실패한다.
- 따라서 재시도 로직을 직접 구현해야 한다. (exponential backoff, 최대 재시도 횟수 설정)
- 사용자에게 “실패했으니 다시 시도하세요” 메시지를 주거나, 애플리케이션이 내부적으로 자동 재시도할 수 있다.
-> 충돌 가능성이 낮거, 충돌 발생시 재시도/실패를 허용하는 도메인의 경우 사용한다.
충돌시 무조건 실패이고 경합이 많은경우에는 재시도 로직이 계속 돌아서 오히려 성능이 저하될수있다.
그렇기때문에 충돌감지후 반드시 비지니스적으로 재시도를 해야하는지를 고민해야한다(사용자에게 실패했어 니가 다시해~ 해하고 오류를 보여줘도됨)
또한 반드시 버전 컬럼을 둬야한다.
낙관적 락에서는 결국 최종 일관성 보장은 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("재시도 초과");
}
3. Redis 분산락 (멀티 인스턴스에서 애플리캐이션 레벨 락 )
같은 리소스에 대해 모든 인스턴스가 동일한 락키를 사용해 진입을 직렬화한다.
Redis는 인메모리 기반 NoSql 데이터 베이스이다.
캐시, 세션 스토리지, 메시지 브로커 등 다양한 활용법이있지만 여기서는 분산락 활용에대해서 설명한다.
Redis는 외부 Resource를 활용하므로 불필요한 DB Connection까지 차단이 가능하다.
관리주체가 데이터베이스 + Redis로 늘어남에 따라 다른 문제점이 발생할 수있긴하다. (Redis 장애, 네트워크 지연, TTL 설정 오류 등)
락 해제 보장(finally 블록 처리), 락 만료 시간 설정(leaseTime), 고가용성 구성(Redis 클러스터/레플리카)이 필수적이다.
하지만 프로세스 처리단위에 대해 동일한 Lock을 여러 인스턴스에 대해 적용 할 수 있으므로 동시성 문제를 효과적으로 해결 가능하다.
3-1. Redisson (AOP 없이 단순)
실제로 트랜잭션 내부에서 레디스 분산락을 사용한다고 하면 순서가 굉장히 중요하다.
락 획득 -> 트랜잭션 시작 -> 비지니스 로직 수행 -> 트랜잭션 종료 -> 락해제
하지만 우선 단순하게 레디스 분산락자체에 대해서만 설명한다.
Redis 분산락의 장점으로는 락의 범위를 비지니스 단위로 유연하게 설계할수있다.
아래의 다양한 패턴들을 활용해 비지니스 요구사항에 맞춘 락 전략을 만들수있다.
- 멀티락(Multi Lock): 여러 리소스를 동시에 잡아 교차 순서 데드락 방지
- 키 조합(Key Composition): 복수 조건을 조합해 세밀한 락 설정
- 페어락(Fair Lock): 순차적으로 접근을 보장하는 락
트랜잭션과의 관계
- Redis 락과 DB 트랜잭션을 어떻게 묶을지가 중요하다.
- 락을 트랜잭션 시작 전/후 어느 시점에 잡을지, 락을 얼마나 오래 유지할지에 따라 성능과 안정성이 크게 달라진다.
- 트랜잭션 범위가 넓을수록 락 경쟁이 심해져 성능 저하나 데드락 가능성이 높아진다.
키 전략
- 락 키는 반드시 자원(Resource) 단위로 설정해야 한다.
- 잘못된 예시:
lock:charge:123 (포인트 충전)
lock:use:123 (포인트 사용)
→ 이렇게 설정하면 충전과 사용을 다른 락으로 인식해 동시에 실행될 수 있다. (동시성 문제 해결 불가)
- 올바른 예시:
- lock:point:123 (포인트 관점에서 동일락 사용)
- → 포인트라는 자원 단위에서 동일한 락 키를 사용해야 충전과 사용이 동시에 일어나지 않는다.
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 → JVM 하나(= 인스턴스 하나) 안에서만 유효.
DB 락 → 서버 여러 대여도 같은 DB를 사용한다면 안전.
Redis 분산락 → 서버 여러 대, DB 여러 개 등 분산 환경에서 확장성 있게 제어 가능.
| 방법 | 적용범위 | 특징 |
| 자바 synchronized | 단일 인스턴스(JVM 내부) | - 같은 JVM 안에서만 임계구역 보장 - 서버 1대(싱글 인스턴스)에서는 유효 - 서버가 2대 이상이면 서로 간에 전혀 모름 |
| DB 락 (비관적/낙관적) | 멀티 인스턴스도 가능 (DB 단일 소스) | - 서버가 여러 대라도 같은 DB를 바라보면 DB 레벨에서 락 보장 - 일관성 강력하지만 DB에 부하 집중 - 락 범위 넓으면 교착/성능 저하 위험 |
| Redis 분산락 | 멀티 인스턴스 환경 | - Redis를 외부 공통 자원으로 사용 → 여러 JVM/서버 간 직렬화 가능 - 락 범위를 비즈니스 단위로 유연하게 설계 가능 - 단, Redis 가용성/TTL 설정 등 운영 이슈 관리 필요 |
서버가 한 대일 땐 synchronized 만으로도 충분하지만
서버를 여러 대 띄우는 멀티 인스턴스 환경에서는 DB 락이나 Redis 같은 외부 리소스를 꼭 써야한당!!!!!
'공부일기.. > Java' 카테고리의 다른 글
| [Java] JDK 그놈의 환경변수 설정하는 이유 정리~ (0) | 2025.09.07 |
|---|---|
| [JUnit5] @EnabledIfEnvironmentVariable 이해하기 (0) | 2025.09.05 |
| [TDD](TDD기반 서비스 개발 후) 회고 및 객체지향 설계 고민 (0) | 2025.07.12 |
| [TDD] JUnit 테스트 입문 – Mockito로 실제 객체 vs Mock 비교 (0) | 2025.07.11 |
| [TDD] TDD 테스트 방법론 (0) | 2025.07.11 |