ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 동시성 문제 해결
    공부/Spring 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 설정으로 오작동 예방 가능

    단점:

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

    - 구성 복잡함

    적합 상황:

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

     

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

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

    '공부 > Spring' 카테고리의 다른 글

    Facade 패턴과 Factory 패턴  (1) 2024.11.10
    Jmeter 로 테스트를 해보자  (8) 2024.10.31
Designed by Tistory.