동시성 문제 해결
문제 상황 :
웹사이트의 특정 탭(화면) 에 1명의 사용자만 접속 가능하도록 기능을 수정해야한다.
생각 :
redis 혹은 외부 queue 활용, 메모리 활용을 생각했지만
redis, queue 는 회사의 정책에 따라 쓸 수 없었고
메모리 활용은 운영 서버에서 WAS 를 2개 띄우기 때문에 메모리로 제어할 수 없었다.
해결 :
2개의 WAS 에서 동시에 접근 가능한 저장소인 DB 를 사용하였다.(조건부 UPDATE 로 해결)
회사의 코드 대신 쇼핑몰에서 샴푸를 사는 상황을 들어 설명하겠다.
Spring 은 하나의 http 요청당 하나의 스레드만 사용한다.
동시에 여러 요청이 들어오면 여러 스레드가 같은 값에 대해 수정할 수 있다.
이 때 데이터의 정합성이 깨질 수 있다.
https://minho-bot.tistory.com/12
데이터의 정합성과 무결성
정합성과 무결성. 언뜻 봐도 중요하다.둘 다 중요하지만 뭐가 다른지 gpt 에게 물어보자나 : 데이터의 정합성과 무결성에 대해 설명해줘GPT : 데이터의 정합성과 무결성은 데이터베이스와 시스템
minho-bot.tistory.com
샴푸 쇼핑몰에서 샴푸의 재고가 1개인데 동시에 2명의 고객이 구매하려고 시도한다면
동시성 문제가 발생할 수 있을것이다.
동시성 문제는 언제 생기나?
예를 들어 샴푸의 재고가 1개 남은 상태에서
고객 A와 B가 샴푸 구매 페이지에서 재고가 1개 있는것을 확인 후 구매 요청을 하면
대부분의 서버에서는 잘 처리돼있겠지만
만약에 코드가 아래처럼 되어있다면 문제가 발생한다.
// DB 에서 샴푸의 재고를 SELECT 문으로 가져온다 --> 결과 샴푸 재고 1개
// 예상 쿼리 SELECT * FROM product WHERE product_name = "shampoo";
Product shampoo = productRepository.findByProductName("shampoo");
// 샴푸의 재고를 java 메모리에서 1 감소
shampoo.setStock(shampoo.getStock() - 1);
// 변경된 재고를 db 에 update
// 예상 쿼리 UPDATE product SET stock = 0 WHERE product_name = "shampoo";
productRepository.save(shampoo);
다들 아시겠지만 2명이 재고가 1개인 상태에서 동시에 발생한 구매요청이 성공할 수 있다.
서로 다른 스레드에서 조회한 1개의 재고를 각각 0개로 감소시키고
db 에 0개로 두 번 UPDATE 쿼리를 날릴것이다.
1명만 구매 성공하고 나머지 1명은 실패해야하는 상황에서
두명 다 구매 성공하는 문제가 발생한다.
그렇다면 어떻게 해결할까?
1. 비관적 락 으로 해결하기
다른 스레드에서 동시에 접근/수정 못하도록
table 혹은 row 에 lock 을 걸 수 있을 것이다.
row 에 lock 걸기 (JPA)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.productName = :productName")
Optional<Product> findByProductNameForUpdate(@Param("productName") String productName);
이렇게 하면 실제 sql 은
SELECT
p.id, p.product_name, p.stock, ...
FROM
product p
WHERE
p.product_name = "shampoo"
FOR UPDATE;
이렇게 나간다!
핵심은 마지막에 있는 FOR UPDATE
이 SELECT ... FOR UPDATE 쿼리는 해당 row 에 베타락(좀 애매함)을 건다.
애매하다고 적은 이유는
A 트랜잭션에서 베타락을 걸면 다른 트랜잭션(B 트랜잭션) 에서는 읽기/쓰기/삭제/수정 등 모두 못하도록 차단한다.
그런데 FOR UPDATE 로 락을 걸면
다른 트랜잭션에서 SELECT FOR UPDATE 는 차단하지만 일반적인 SELECT 는 허용한다!

1번 mariadb client 에서 트랜잭션을 시작 하고 SELECT FOR UPDATE 을 하고

2번 mariadb client 에서 트랜잭션 시작 후 SELECT FOR UPDATE 를 했을 때는 조회가 안되고 계속 기다리는 상태이다.
LOCK 이 걸려서 조회가 안되고 기다리는 상태인것
강제로 Ctrl-C 로 끄고
FOR UPDATE 가 없는 일반적인 SELECT 로는 조회가 잘 되는 모습이다.

LOCK 이 걸린 ROW 에 SELECT FOR UPDATE 를 하고 계속 기다리면 뜨는 에러이다.
시간 초과가 뜬다.
참고로 2번 클라이언트에서 락 획득을 위해 계속 기다리는 도중에 1번 클라이언트에서 commit; 쿼리를 날려서 트랜잭션을 종료하면 2번에서 락을 획득하여 조회가 된다.
또, 참고로 service 에서 보통 @Transactional 어노테이션을 쓰는데
내부적으로 START TRANSACTION; COMMIT; 그리고 예외 발생 시 ROLLBACK 쿼리를 날려준다고 한다.
(@Transactional 안붙이면 실제로 저장, 수정 안되는건 트랜잭션이 아니라 영속성 문제이다. 추후에 다른 글로 설명)
- 동시에 갱신 시 충돌 거의 없음
- 데드락 위험
- 락 timeout 고려 필요
적합 상황:
- 갱신 충돌이 자주 발생하는 경우
- 강한 데이터 일관성이 중요한 경우
2. 낙관적 락 으로 해결하기
비관적 락은 실제 db 에 락을 걸었다면
낙관적 락은 애플리케이션 단에서 충돌을 감지하는 방식이다.
테이블에 버전 관리용 칼럼을 만들어놓고
update 로직에서 db 에 쿼리를 날릴 때
WHERE 절에 version 이 일치하는지 조건을 추가하는 방법이다.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
private String productName;
private int stock;
@Version
private int version; // 버전 관리용 칼럼
}
version 조건이 맞지 않으면, 즉 다른 트랜잭션이 먼저 업데이트했다면 UPDATE가 실패하고
OptimisticLockException 예외가 발생한다.
예외를 catch 하여 개발자가 직접 예외처리를 하면 되겠다.(재시도 혹은 실패)
만약 충돌이 많다면 애플리케이션의 부하가 증가할 것이다.
장점:
- 락 없이 처리 가능 (경쟁 적음)
- 데드락 없음
단점:
- 충돌 시 예외 발생 → 재시도 필요
- 버전 컬럼 필수
- 최종 업데이트 시점까지 정합성 미보장
적합 상황:
- 충돌 가능성 낮은 상황
- 읽기 위주의 시스템
3. 조건부 UPDATE 로 해결하기
애초에 문제는 SELECT 한 다음 값을 애플리케이션 메모리에 저장 한 후
값을 애플리케이션 메모리에서 수정 후 db 에 UPDATE 하여 정합성이 깨지는 것이다.
SELECT 문 없이 UPDATE 문 하나만 사용한다면 동시성 문제가 일어나지 않는다.
조회 없이 값을 - 1 하고
stock 이 0보다 클 때만 실행한다.
// ProductRepository
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - 1 WHERE p.productName = :productName AND p.stock > 0")
int decreaseStockIfAvailable(@Param("productName") String productName);
// ProductService
int updatedRows = productRepository.decreaseStockIfAvailable("shampoo");
if (updatedRows == 1) {
// 재고 감소 성공
} else {
// 재고 없음 or 실패
}
@Modifying 이 뭐지?
GPT : @Modifying은 JPA에서 @Query로 작성된 JPQL 또는 Native SQL이 SELECT가 아닐 때 반드시 필요합니다.
라고 한다.
조건부 UPDATE 로 항상 해결 가능한 것은 아닐것이다.
위의 예시는 간단한 상황이라 가능하지만
복잡한 비즈니스 조건과 결합된다면 쓰기 힘들어질 것이다.
4. 메모리 락 으로 해결하기
Java 에서는 synchoronized 키워드를 사용해서 메모리에 있는 객체에 락을 걸 수 있다.
서버가 1대일 경우엔 가능하지만
나의 경우 서버가 2대라서 불가능했다.
GPT:
단일 서버 환경에서 synchronized로 쓰고 싶다면?
@Component public class InventoryService { private final Object lock = new Object(); public void decreaseStock() { synchronized (lock) { // 재고 조회 및 감소 로직 } } } // 또는 static 락으로 public class GlobalLock { public static final Object LOCK = new Object(); } // 사용 예시 synchronized (GlobalLock.LOCK) { // 동기화할 로직 }
단, 서버가 여러 대이거나 클러스터 환경이면 절대 금물. 이 경우에는 분산락 or DB 락 필요.
빠르고 쉽지만, 단일 서버에서만 가능하다.
장점:
- 구현 간단
- 성능 좋음(JVM 메모리 내에서 동기화 처리)
단점:
- 멀티 서버에서는 불가능
- 서버가 죽으면 락도 소멸
적합 상황:
- 단일 서버에서만 돌아가는 시스템
- 빠른 락 성능이 필요한 경우
5. 분산락 으로 해결하기
지금 까지 DB 가 1개 일 때 락을 걸어 해결한다고 생각했지만
DB 가 여러 개 라면 DB 락으로도 해결이 안된다. (Master DB 에만 쓰기/수정 한다면 DB 락으로 가능)
이럴 때 분산락으로 해결 가능하다.

Redis 는 싱글 스레드에 명령 순서를 보장하기 때문에
이런식의 구조로 해결할 수 있다.
GPT :
Redisson으로 Redis 락 사용 예시 (Spring Boot)
// 의존성 추가 implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' // Bean 등록 @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer().setAddress("redis://localhost:6379"); return Redisson.create(config); } // 사용 예시 @Autowired private RedissonClient redissonClient; public void doSomethingWithLock() { RLock lock = redissonClient.getLock("lock:shampoo"); boolean acquired = lock.tryLock(10, 3, TimeUnit.SECONDS); if (acquired) { try { // 자원 작업 } finally { lock.unlock(); } } }
redis 에서는 TTL 설정을 해놓으면
만약 락을 잡은 서버가 죽었을 때 TTL 설정 시간 이후에 락이 해제되고
다음 서버가 처리가능하도록 할 수 있겠다.
장점:
- 여러개의 서버, DB 간 동기화 가능
- TTL 설정으로 오작동 예방 가능
단점:
- 락 획득 실패 처리 로직 필수
- 구성 복잡함
적합 상황:
- 서버가 여러 대인 분산 시스템
락 획득/해제에 네트워크 오버헤드알고 있지만 블로그에 정리하는 것은 시간이 오래 걸리고 힘든 일이다.
나중에 내가 까먹었을 때 다시 보면서 도움이 되기를...