티스토리 뷰
동시성 문제란?
여러 프로세스나 스레드가 공유자원에 동시에 접근하면서 발생하는 오류 상황
대표적 문제 유형
1. Race Condition
둘 이상의 작업이 동시에 실행될 때, 작업의 실행 순서에 따라 결과가 달라지거나 예기치 못한 오류를 발생시키는 현상
공유 자원에 대해 여러 개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태(https://ko.wikipedia.org/wiki/경쟁_상태)
Race Condition 예시
다수의 스레드가 동시에 실행 될 때 재고 확인과 감소의 시간차이로 동시성 문제 발생
select 와 decrease(update)간의 시간 차이로 동시성 문제가 발생할 수 있음
public void purchase(Long productId) {
Product product = productMapper.selectProduct(productId);
if(product == null) return;
if(product.getStock() <= 0) return;
int result = productMapper.decreaseStock(productId);
}
동시성 테스트 코드
ExecutorService : n개의 스레드를 관리하는 스레드 풀을 생성하여 submit으로 스레드 실행
CountDownLatch
- 스레드가 모두 종료될 때 까지 대기하는 장치
- 각 스레드의 작업이 종료되면 countDown을 호출하여 스레드 수 감소
- 실행해야할 스레드의 수가 0이 될 때까지 await 대기
private static final int THREAD_COUNT = 20;
private static final long PRODUCT_ID = 1L;
@Test
public void conCurrentTest() {
ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
executorService.submit(() -> {
try {
productService.purchase(PRODUCT_ID);
} finally {
latch.countDown();
}
});
}
//모든 스레드 종료 대기
latch.await();
//결과 조회
Product product = productService.getProduct(PRODUCT_ID);
System.out.println("최종 재고 수량: " + product.getStock());
//재고는 0보다 작을 수 없다.
assertTrue(product.getStock() >= 0);
}
해결 방법
1. Lock 사용
비관적 락
- 공유 자원에 대한 경합이 발생할 가능성이 높다고 가정하고 자원을 처음부터 잠궈 접근하지 못하도록 처리하는 방법
동작 원리
- 데이터를 읽을 때 부터 잠금(Lock)을 걸어 다른 트랜잭션이 접근하지 못하도록 함
- `SELECT ... FOR UPDATE` 구문을 사용하여 해당 row에 Exclusive Lock(배타적 락)을 걸음
- 락이 걸린 자원은 트랜잭션이 끝날 때 까지 다른 트랜잭션에서 읽기/쓰기 불가
- 자원 사용이 끝나면 트랜잭션을 Commit 또는 Rollback 하여 락 해제
구현 예제
BEGIN;
SELECT * FROM product WHERE product_id = 1 FOR UPDATE;
UPDATE product SET stock = stock - 1 WHERE product_id = 1;
COMMIT;
public void purchasePessimistic(Long productId) {
Product product = productMapper.selectProductForUpdate(productId);
//SELECT * FROM product WHERE id = #{productId} FOR UPDATE;
if(product == null) return;
if(product.getStock() <= 0) return;
productMapper.decreaseStock(productId);
//UPDATE product SET stock = stock - 1 WHERE id = #{productId}
}
낙관적 락
- 공유 자원에 대한 경합이 발생할 가능성이 낮다고 가정하여 모든 로직을 수행한 후 마지막에 체크하는 방법
동작 원리
- 데이터에 버전 정보를 추가하여 관리 (version 컬럼, update 시간 등)
- 데이터를 읽을 때 버전 정보도 함께 조회
- 데이터 수정 시 조회했던 버전과 현재 DB의 버전이 일치하는지 확인
- 버전이 일치하면 업데이트하고 버전 증가, 불일치하면 충돌로 간주하고 재시도
구현 예제
BEGIN;
SELECT * FROM product WHERE product_id = 1;
UPDATE product SET stock = stock - 1 WHERE product_id = 1 AND version = #{version};
COMMIT;
public void purchaseOptimistic(Long productId) {
Product product = productMapper.selectProduct(productId);
...생략
productMapper.decreaseStockByVersion(productId, version);
//UPDATE product SET stock = stock - 1 WHERE id = #{productId} AND version = #{version}
}
재시도 프로세스
낙관적 락 적용시에는 작업이 성공하지 못하는 경우 재시도 처리가 있어야 함
- 트랜잭션 시작
- 예외 발생
- 트랜잭션 rollback
- retry 전체 재실행
낙관적 락 재시도 기본 구현
private static final RETRIAL_COUNTS = 3;
public void purchaseOptimisticWithRetry(Long productId) {
int retryCount = 0;
while(retryCount < RETRIAL_COUNTS) {
Product product = productMapper.selectProduct(productId);
...생략
int result = productMapper.decreaseStockByVersion(productId, version);
if(result > 0) {
break; // 또는 return;
} else {
retryCount++;
}
}
}
AOP를 활용한 재시도 처리
낙관적 락 적용 시 재시도 처리는 AOP를 활용하여 처리하는게 코드 생산성 면에서 좋습니다.
@Retry
@Transactional
public void purchaseOptimisticWithAOP(Long productId) {
Product product = productMapper.selectProduct(productId);
...생략
int result = prodcutMapper.decreaseStockByVersion(productId, version);
if(result == 0) throw new CustomOptimisticLockException("낙관적 락 충돌 발생");
}
@Aspect
@Component
public class OptimisticLockRetryAspect {
@Around("@annotation(retry)")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable {
Exception exceptionHolder = null;
for(int attempt = 0; attempt < retry.maxAttempts(); attempt++) {
try {
System.out.println("[재시도 로직] 시도 횟수: " + (attempt + 1));
//joinPoint.proceed = 주요 비즈니스 로직이 에러없이 잘 수행되면 return 구문으로 재시도 없이 메서드 종료
return joinPoint.proceed();
} catch (CustomOptimisticLockException e) {
// 실패했을 때만 여기를 타고 재시도
exceptionHolder = e;
Thread.sleep(retry.retryDelay());
}
}
// 위 루프를 다 돌고도 성공하지 못하면
throw exceptionHolder;
}
}
Spring-Retry 라이브러리 활용@Retryable 어노테이션이 @Transactional보다 바깥에 위치해야 정상 재시도 동작함
@Retryable(
value = { CustomOptimisticLockException.class }, //재시도 케이스
maxAttempts = 3, //재시도 횟수
backOff = @Backoff(delay = 5000) //대기 시간
)
@Transactional
public void purchaseOptimisticWithRetry(Long productId) {
Product product = productMapper.selectProduct(productId);
...생략
int result = productMapper.decreaseStock(productId, version);
if(result == 0) throw new CustomOptimisticLockException("낙관적 락 충돌 발생");
}
비관적 락과 낙관적 락 비교
| 구분 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 개념 | 충돌이 발생할 것이라 가정 | 충돌이 드물게 발생한다고 가정 |
| 락 획득 시점 | 데이터 읽기 전 | 데이터 수정 시 |
| 성능 | 동시성 높은 환경에서 성능 저하 | 충돌 적을 때 성능 우수 |
| 구현 복잡도 | 단순 | 재시도 로직 필요 |
| 데드락 위험 | 있음 | 없음 |
| 적합한 상황 | 경합이 자주 발생하는 환경 충돌 비용이 높은 경우 |
읽기가 많은 환경 충돌이 적은 경우 |
| 구현 방법 | FOR UPDATE 구문 | 버전 컬럼 활용 |
| DB 부하 | 높음 (락 유지) | 낮음 (락 없음) |
주의 사항
1. Self-invocation
self-invocation 이 발생하는 경우 Retry가 적용되지 않으므로 주의해서 사용해야 한다.
Spring AOP는 프록시 객체를 통해서 메서드를 호출하는 경우에만 적용된다. 그런데 this.inner() 처럼 자기 자신을 통해서 메서드를 호출하는 경우 프록시 객체를 거치지 않기 때문에 의도한 대로 AOP가 적용되지 않는다.
self-invocation이란?
클래스 내부에서 자기 자신이 가진 메서드를 직접 호출하는 것
@Service
public class MyService {
@Transactional
public void outer() {
inner(); // ← self-invocation (this.inner()) → AOP 미적용
}
@Retrial
@Transactional
public void inner() {
...
}
}
해결 방법
@Service
public class MyService {
private final InnerService innerService;
// 생성자 주입
public MyService(InnerService innerService) {
this.innerService = innerService;
}
@Transactional
public void outer() {
innerService.inner(); // 프록시를 통한 호출로 AOP 적용됨
}
}
@Service
public class InnerService {
@Retry
@Transactional
public void inner() {
// 정상 동작
}
}
2. 전파 레벨
@Retryable 프로세스가 적용된 기능은 독립적으로 수행이 보장되지 않으면 트랜잭션의 전파레벨을 REQUIRES_NEW로 설정해야 한다. 기본값인 REQUIRED를 사용하는 경우 RuntimeException 발생 시 Transction 상태가 rollback-only 로 변경되어 재시도 프로세스를 통해 결과적으로 요청이 성공할지라도 UnexpectedRollbackException을 발생시킬 수 있다.
@Retryable(...)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void purchaseOptimisticWithRetrial(Long productId) {
// 구현
}
예제 케이스
@Service
public class OuterServiceImpl implements OuterService {
@Autowired
private InnerService innerService;
@Transactional
public void outer() {
System.out.println("OuterService > outer() 시작");
try {
innerService.inner();
} catch (Exception e) {
System.out.println("예외 발생");
}
System.out.println("OuterService > outer() 완료");
// 여기서 아무 DB 작업이 없어도 커밋 시도
// rollbackOnly 감지 → 예외 발생
}
}
@Service
public class InnerServiceImpl implements InnerService {
int attempt = 0;
@Transactional
public void inner() {
attempt++;
System.out.println("InnerService > inner() : start");
if (attempt == 1) throw new RuntimeException("예외 발생 - 재시도 필요");
System.out.println("InnerService > inner() : end");
}
}
트랜 잭션 및 재시도 처리 흐름도

요약
- 동시성 문제는 다중 스레드 환경에서 공유 자원 접근 시 발생할 수 있는 데이터 일관성 문제입니다.
- 비관적 락(Pessimistic Lock)은 데이터 접근 전 락을 획득하여 동시성 문제를 예방하지만, 성능 저하가 발생할 수 있습니다.
- 낙관적 락(Optimistic Lock)은 충돌이 적다고 가정하고 버전 등을 통해 변경을 감자히는 방식으로, 성능은 좋지만 재시도 로직이 필요합니다.
- 재시도 로직은 단순 반복문, AOP 직접 구현 또는 spring-retry 라이브러리를 통해 구현할 수 있습니다.
- 주의사항으로 self-invocation 문제와 트랜잭션 전파 레벨 설정이 있으며, 낙관적 락 재시도 시 트랜잭션 전파 레벨
REQUIRES_NEW사용을 권장합니다.
사례
우아한 테크코스 : https://tecoble.techcourse.co.kr/post/2023-08-16-concurrency-managing/
springBoot 사례: https://xxeol.tistory.com/57#spring-retry%20%40Retrayble%EB%A5%BC%20%ED%99%9C%EC%9A%A9%ED%95%9C%20%EC%9E%AC%EC%8B%9C%EB%8F%84-1
UnexpectedRollbackException 사례 : https://techblog.woowahan.com/2606/
spring-retry : https://github.com/spring-projects/spring-retry
예제 코드
https://github.com/kimjunyoung90/concurrency-examples
GitHub - kimjunyoung90/concurrency-examples: 동시성 처리 전략(비관적 락 vs 낙관적 락)
동시성 처리 전략(비관적 락 vs 낙관적 락). Contribute to kimjunyoung90/concurrency-examples development by creating an account on GitHub.
github.com
'라이브러리&프레임워크 > Spring' 카테고리의 다른 글
| Spring Cache 사용법 정리 (0) | 2025.07.27 |
|---|---|
| Spring Boot Metric 수집 및 시각화 방법 (Prometheus + Grafana) (0) | 2025.07.20 |
| AOP란? (0) | 2025.05.02 |
| CustomException 설계와 Spring 예외 처리 전략 (0) | 2025.04.25 |
| XML 설정 시 반드시 알아야 할 XSD와 namespace 개념 정리 (0) | 2025.04.24 |
