공부/Spring

동시성 문제 해결

minho-bot 2025. 6. 18. 16:57

문제 상황 :

웹사이트의 특정 탭(화면) 에 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번 세션에서 트랜잭션 시작 후 SELECT FOR UPDATE 실행

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

이후 다른 세션에서 2번 트랜잭션

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 안붙이면 실제로 저장, 수정 안되는건 트랜잭션이 아니라 영속성 문제이다. 추후에 다른 글로 설명)

여기서는 row 에 거는 lock 만 설명했다.
보통 전체 table 에 lock 을 걸 일은 없을 것 같다.
 
실제 db 에 lock 을 거는 것이다 보니,
확실히 제어된다는 장점이 있지만,
성능 저하가 생길 수 있고,
로직 순서를 잘못 짜면 데드락이 발생할 수 있다.
 
장점:
- 강력한 정합성 보장
- 동시에 갱신 시 충돌 거의 없음
단점:
- 성능 저하(락 대기)
- 데드락 위험
- 락 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 설정으로 오작동 예방 가능

단점:

- 락 획득 실패 처리 로직 필수

- 구성 복잡함

적합 상황:

- 서버가 여러 대인 분산 시스템

 

락 획득/해제에 네트워크 오버헤드알고 있지만 블로그에 정리하는 것은 시간이 오래 걸리고 힘든 일이다.

나중에 내가 까먹었을 때 다시 보면서 도움이 되기를...