동시성 문제 해결 해보기. Feat 분산락의 필요성

BaekGwa·2024년 8월 29일
0

Spring

목록 보기
2/9
post-thumbnail

동시성 문제

해당 글에 사용된 코드는 Github에 있습니다! GITHUB

이전 과정

  • 이전까지 동시성 문제를 해결하기 위한, 순수 Java를 활용한 동시성 문제 회피와, DB에 락(낙관적, 비관적)을 걸어 해결하는 방법을 살펴 보았다.
  • 이전 포스팅

문제점

  • 이전까지 알아본 동시성 문제 해결 방안들은 정말 훌륭하지만, 일부 문제점이 발생할 수 있습니다.
  • 하나의 DB를 보고있는 2개 이상의 서버가 운영되고 있을 경우, 순수 Java를 활용한 Lock 전략은 유효하지 않을 수 있습니다.
    • 회원 서버가 두개 운영되고 있을때, 각각의 서버에서 DB에 접근할 때, 사용되는 Lock은 서버에 종속적이므로, 의미가 없다.
  • 데이터 샤딩, CQRS, 레플리케이션 DB운용 등, 다중 DB를 사용하는 경우 DB 락 전략 또한, 유효하지 않을 수 있습니다.
    • 데이터 샤딩이 적용된 환경에 여러 서비스가 접근을 할 경우, 두 서비스는 각각 다른 DB 인스턴스에 접근 할 수 있습니다. DB락이 적용된다고 해도!
    • 이때, 동시에 데이터를 수정하는 로직이 들어간다면, 동시성 문제가 여전히 발생 합니다.
  • 또한, 많은량의 트래픽이 몰리는 서비스의 경우, DB 락을 사용하게 되면, 해당 DB에 많은 부담을 줄 수 있습니다.

검증해보기

  • 이부분은, MSA 환경은 구축해보았지만, 데이터 샤딩작업을 진행해본적 없어, 추후에 진행 해볼 예정입니다.

해결방법

  • 간단하게 생각해보자, Lock을 주는곳이 다중화 (데이터샤딩, 서비스 다중화)가 된다면 문제가 발생하는 것이다.
  • 그럼 Lock 권한을 한곳에서 관리를 하게 하면 어떨까?
  • 무조건, 해당 DB에 접근할 수 있는 열쇠한곳에서 관리 되도록 하는것이다.
  • 이 방법을 위해, Lock을 관리하는 별도의 DB를 사용하는 전략을 사용해 보겠습니다.

분산 Lock

  • 위에서 설명하였듯이, 서비스가 이용인원이 늘어나고, 복잡해지며 고가용성과 성능을 고려하게 된다면 MSA 아키텍처나, 데이터 샤딩 등을 고려하게 될 것 입니다.
  • 그 경우에, Lock을 관리하는 별도의 DB를 사용하는 전략을 사용하게 되는데, 이를 분산 Lock 이라고 표현한다.
  • Redis와 같은 인메모리 DB를 사용하거나, Consul, Zookeeper와 같은 분산 시스템을 이용해 구현됩니다.
  • 해당 과정은, Redis를 활용하여 분산락을 만들고, 낙관적/비관적 락 전략을 만들어 보도록 하겠습니다.

프로젝트 설정

Redis 설치

  • 구성
    • Docker 가상화 컨테이너 사용
  • Docker와 관련된 포스팅이 아니므로, 사용한 Redis는 간단하게 사용한 명령어만 나열하여 정리합니다.
//redis docker 이미지 pull
docker pull redis

//image 사용 container run
docker run --name myredis -d -p 6379:6379 redis

Spring Boot 의존성 추가

implementation("org.springframework.boot:spring-boot-starter-data-redis")

Redis Lettuce

  • Redis의 라이브러리 중, 하나인 Lettuce 를 사용하여, Redis 서버와 통신을 진행하여 Lock을 구현해보도록 하겠습니다.
  • 이전 포스팅에서 사용한 코드를 활용하여 진행하겠습니다.

코드

package BaekGwa.ConcurrencyIssue.global.redis.repository;

import java.time.Duration;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class RedisRepository {

    private final RedisTemplate<String, String> redisTemplate;

    /**
     *
     * @param key
     * key는 트랜잭션 범위를 지정한다.
     * 같은 트랜잭션은 같은 key를 사용해야한다.
     * @return
     */
    public Boolean lock(Long key) {
        return redisTemplate.opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
    }

    /**
     *
     * @param key
     * key는 트랜잭션 범위를 지정한다.
     * 같은 트랜잭션은 같은 key를 사용해야한다.
     * @return
     */
    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key){
        return key.toString();
    }
}

package BaekGwa.ConcurrencyIssue.domain.item.service;

import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import BaekGwa.ConcurrencyIssue.global.redis.repository.RedisRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

/**
 * Facade 패턴 적용 ItemServiceImplV1 -> ItemServiceImplV6
 */
@Service
@RequiredArgsConstructor
public class ItemServiceImplV6 implements ItemService {

    private final RedisRepository repository;
    private final ItemServiceImplV1 itemService;
    private final RedisRepository redisRepository;

    //해당 key 는 분산환경에서 공유되어야함.
    //트랜잭션의 범위를 설정하는 역할을 함.
    //이렇게 사용하기 보다, 명확한 KEY 네이밍 규칙과 관리가 필요함.
    private static final Long ITEM_SERVICE_LOCK_KEY = 1L;

    @Override
    public Boolean RegisterItem(NewItem newItem) {
        getLock(ITEM_SERVICE_LOCK_KEY);

        try {
            return itemService.RegisterItem(newItem);
        } finally {
            unLock(ITEM_SERVICE_LOCK_KEY);
        }
    }

    @Override
    public Boolean buyItem(BuyItem buyItem) throws InterruptedException {
        getLock(ITEM_SERVICE_LOCK_KEY);

        try {
            return itemService.buyItem(buyItem);
        } finally {
            unLock(ITEM_SERVICE_LOCK_KEY);
        }
    }

    private void getLock(Long key) {
        while (!redisRepository.lock(key)) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    private void unLock(Long key) {
        redisRepository.unlock(key);
    }
}

package BaekGwa.ConcurrencyIssue.domain.item.service;

import static org.junit.jupiter.api.Assertions.assertEquals;

import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import BaekGwa.ConcurrencyIssue.domain.item.entity.Item;
import BaekGwa.ConcurrencyIssue.domain.item.repository.ItemRepository;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ItemServiceImplV6Test {

    @Autowired
    private ItemServiceImplV6 itemService;

    @Autowired
    private ItemRepository itemRepository;

    @BeforeEach
    public void init() {
        NewItem newItem = new NewItem("상품A", 1000L, 100L);
        itemService.RegisterItem(newItem);
    }

    @AfterEach
    public void clear() {
        itemRepository.deleteAll();
    }

    @Test
    void 단일_구매_요청() throws InterruptedException {
        BuyItem ItemA = new BuyItem("상품A", 1L);
        Boolean isSuccess = itemService.buyItem(ItemA);

        Item findItem = itemRepository.findAllByName("상품A");

        assertEquals(isSuccess, true);
        assertEquals(99, findItem.getStock());
    }

    @Test
    void 다중_구매_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService es = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            es.submit(() -> {
                BuyItem ItemA = new BuyItem("상품A", 1L);
                Boolean isSuccess = null;
                try {
                    isSuccess = itemService.buyItem(ItemA);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                assertEquals(isSuccess, true);
            });
        }

        es.shutdown();
        es.awaitTermination(10, TimeUnit.SECONDS);

        Item findItem = itemRepository.findAllByName("상품A");
        assertEquals(0, findItem.getStock());
    }
}

구조

  • 구조는 간단합니다. 동기화 처리가 없는 ItemServiceImplV1 서비스 로직을 Facade 패턴을 사용하여 락을 획득해야지만 실행 되도록 하였습니다.
  • 해당 락은, DB나 내부 락이 아닌, Redis에 저장하여 관리 하도록 하였습니다.
  • getLock() 메서드는, key를 입력하면, 해당 키가 없으면 데이터를 생성하고, 있다면 무한 반복을 실행 하게 됩니다.
    • 이때, sleep()을 실행하여 Lock을 재획득 하는 시간을 조금 미뤘습니다. 너무 빠른 시간에 재시도를 하면, 성능적 이슈가 발생할 수 있습니다.
  • getLock()을 성공적으로 통과한 다음, 핵심 임계 영역 코드가 실행되는 메서드를 호출 할 수 있습니다.
  • 이후, try-finally 구문을 활용하여, 실패를 하더라도, 무조건 적으로 unlock()을 실행하게 하여, Lock을 반납 하도록 합니다.
    • 반드시, lock을 성공/실패 여부 상관없이 반납 하도록 하여야 합니다.
    • 반납을 하지 않고, 종료가 된다면, 나머지 Lock 획득을 대기하고 있는 Thread 들은 무한정 돌아올리 없는 Lock을 기다리게 됩니다.

여담으로 AOP 기능을 활용하면, Annotation을 사용하여 별도의 Facade 객체를 생성하지 않아도 될 것 같습니다. Like @Transactional

Going Deep

  • 해당 Lock 방법을 Spin Lock이라고 표현합니다.
  • 순수 Java에서의 ReentrantLock() 과 유사한 구조를 가지고 있습니다.
  • Spin Lock?
    • Lock을 획득해야 하는 Thread가, 지속/반복 적으로 Lock 획득 가능 여부를 확인하며 무한정으로 반복하는 형태.

결과

의문점

  • 내부적으로 Thread.sleep()을 사용해서, Redis에 Lock을 획득하는 텀을 주도록 하였는데, 이게 과연 올바른 방법일까?
    • Thread.sleep()을 사용하면 thread state는 waiting(timed) 상태로 변경되어 진행중인 작업을 멈추고, 코어의 작업을 다른쪽으로 넘길 것이다.
    • 이후, 다시 wakeup이 되며 실행을 위해 컨텍스트 스위칭이 이뤄지게 될 것인데, 이 과정이 과연 Redis에 부하를 줄이는 것과 대비해서 현명한 방법일까..?
  • 또한, 트랜잭션간 동기화를 위해 key를 설정해야하는데, 이는 어떤 방식으로 설정하는 것이 좋을까?
    • 예제 코드에서는 단순이 1L이라는 key를 사용하였는데, 이는 해당 트랜잭션을 설명하기엔 너무 부족한 key이다.
    • 또한, 해당 key는 중복되지 않도록 관리를 해야하는데, 이는 어떻게 관리를 하면 좋을까?

-> 모든 의문점에 대한 해답은 아니지만, Redlock 이라는 알고리즘을 Redis 에서는 제공한다. 이를 java에서 사용할 수 있도록 Redisson 구현체가 제공된다.


Redis Redisson (Redlock)

  • Redis에서는 Redisson 이라는 라이브러리를 지원합니다. 이는 위에서 사용한 Lock 전략을 더 사용성 있게 지원하고 있습니다.
  • 또한, 단일 노드로 구성되지 않고, 다중 노드로 구성된 Redis 환경에서도 Lock을 사용할 수 있도록 Redlock 알고리즘을 개발/적용 하였습니다.

Redis 다중 노드가 필요한 경우

  • Redis 또한, DB의 일종으로, 고가용성과 확정성, 데이터 일관성의 문제로 Replication (Mater-slave) 모드로 실행하는 경우가 존재합니다.
  • Redlock은, 다중 노드로 구성되었을 때, 발생할 수 있는 다양한 문제점을 해결합니다. 문제점의 하나의 현상을 소개해 드리겠습니다.
    1. 클라이언트 A가 마스터에서 잠금을 획득한다.
    2. 키에 대한 쓰기가 복제본으로 전송되기 전에 마스터가 다운된다.
    3. 복제본이 마스터로 승격된다.
    4. 클라이언트 B는 A가 이미 잠금을 보유하고 있는 동일한 리소스에 대한 잠금을 획득한다.
      결과. 이때, A와 B는 모두 Lock과 관계없이 접근하여 데이터 정합성 문제, 동시성 문제가 발생 할 수 있습니다.

Redis Redisson 사용 (기본)

  • Redisson의 사용 역시, Facade 패턴을 적용하여 구성하였습니다.
  • ItemServiceV7 -> ItemServiceV1

의존성 추가

implementation("org.redisson:redisson-spring-boot-starter:3.23.2")

코드 작성

package BaekGwa.ConcurrencyIssue.domain.item.service;

import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.BuyItem;
import BaekGwa.ConcurrencyIssue.domain.item.dto.ItemDto.NewItem;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

/**
 * Facade 패턴 적용 ItemServiceImplV7 -> ItemServiceImplV1
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class ItemServiceImplV7 implements ItemService {

    private final ItemServiceImplV1 itemService;
    private final RedissonClient redissonClient;

    //해당 key 는 분산환경에서 공유되어야함.
    //트랜잭션의 범위를 설정하는 역할을 함.
    //이렇게 사용하기 보다, 명확한 KEY 네이밍 규칙과 관리가 필요함.
    private static final String ITEM_SERVICE_LOCK_KEY = "1";

    @Override
    public Boolean RegisterItem(NewItem newItem) {

        RLock lock = redissonClient.getLock(ITEM_SERVICE_LOCK_KEY);

        try {
            boolean available = lock.tryLock(10, 2, TimeUnit.SECONDS);

            if (!available) {
                log.info("{} : lock 획득 실패", Thread.currentThread().getName());
                return false;
            }

            return itemService.RegisterItem(newItem);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

    @Override
    public Boolean buyItem(BuyItem buyItem) throws InterruptedException {

        RLock lock = redissonClient.getLock(ITEM_SERVICE_LOCK_KEY);

        try {
            boolean available = lock.tryLock(10, 2, TimeUnit.SECONDS);

            if (!available) {
                log.info("{} : lock 획득 실패", Thread.currentThread().getName());
                return false;
            }

            return itemService.buyItem(buyItem);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
test코드는, 받아오는 Bean만 다르고, TemServiceImplV6Test 와 동일 합니다.

결과

Redis Lettuce 와 다른점

  • Lettuce 방식에서는, lock을 획득하기 위해서 코드 레벨로 반복 lock 획득을 반복 시도 합니다. 그리고, 실패가 되면 Thread.sleep()을 통해 thread를 Waiting 상태로 변경하게 됩니다.
    • Running -> (lock 획득 실패) -> Timed Waiting -> Running 반복
  • Redisson 에서는, tryLock()을 통해 lock을 획득할 시, 실패의 경우 해당 thread는 blocking 상태로 변경되게 됩니다.
  • 또한, 추가적으로 최대 대기 시간과, 획득 후, 무조건 반납 시간 등이 정해져 있어 추가적인 구현이 필요가 없는점이 아주 사용성이 좋아 보입니다.
    • Running -> (lock 획득 실패) -> Blocking

Lettuce vs Redisson

항목LettuceRedisson
락 획득 및 대기 방식- lock() 호출로 락 시도
- 락 실패 시 반복적으로 재시도
- Thread.sleep()으로 Timed Waiting
- tryLock() 호출로 락 시도
- 락 실패 시 Blocking 상태로 대기
락 관리 및 자동 해제- 수동으로 락 해제 코드 필요
- 잘못된 코드로 인해 데드락 위험
- leaseTime 이후 락 자동 해제
- 최대 대기 시간과 해제 시간 설정 가능, 편리한 관리
복잡성 및 코드 간결성- 추가 구현 필요 (재시도 로직, 락 해제 등)
- 코드 복잡도 증가
- 간결한 API, 자동 관리
- 빠르고 쉽게 락 구현 가능
다중 노드 사용- 불가능- 가능

Redis Redisson 사용 (다중 노드) (Redlock)

  • 아직 Redis 다중 클러스터 구성을 해본적 없어 추후에 진행 하도록 하겠습니다.
  • 간단하게, 이론만 알아보고 가겠습니다.

Redlock 알고리즘

  • Redlock은 Redis의 다중 노드 환경에서 분산 락을 구현하기 위해 고안된 알고리즘입니다. 이 알고리즘은 다음과 같은 단계를 따릅니다:

  • 다중 노드에 락 시도: 클라이언트는 다섯 개의 서로 다른 Redis 노드(일반적으로 홀수 개)에서 동시에 락을 시도합니다.

  • 락 획득 조건: 클라이언트는 과반수 이상의 노드(예: 5개의 노드 중 3개)에서 락을 획득해야 합니다. 이는 특정 노드에서 장애가 발생하더라도 락이 안전하게 유지되도록 보장합니다.

  • 락 유지 시간: 락을 획득한 후에는 설정된 leaseTime 동안만 유지됩니다. 이 시간이 지나면 락은 자동으로 해제됩니다.

  • 락 해제: 클라이언트가 작업을 완료한 후, 락을 해제합니다. 모든 노드에서 락이 해제되면, 다른 클라이언트가 락을 획득할 수 있습니다.

profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글