동시 접근으로 인해 발생한 동시성 문제

대영·2024년 10월 9일
2

트러블 슈팅

목록 보기
5/6

🙏내용에 대한 피드백은 언제나 환영입니다!!🙏

동시성 문제❓

동시성 문제는 여러 개의 프로세스나 스레드가 동시에 같은 자원에 접근하거나 수정하려고 할 때 발생하는 문제이다. 이런 경우 데이터가 꼬이거나 예상치 못한 결과가 나올 수 있다. 그로 인해 발생하는 문제로는 대표적으로 데이터 손실, 중복 처리, 오류 등이 발생할 수 있다.

🚨 동시성 문제가 발생한 코드

아래 코드는, 내가 진행했던 프로젝트의 코드이고, 식당 예약 서비스이다.
식당 예약을 하기위해, 번호표 예약, 시간대 예약 등이 있을 텐데, 아래는 시간대 예약부분이고, 아래의 코드에서 동시성 문제가 발생했다.

	/* 가게 예약하기 (번호표) */
    public void saveQueueReservation(String username, Long restaurantId) {

        String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;

        if (isUserAlreadyInQueue(username, restaurantId))    // 중복 예약 확인.
            throw new CustomException(ErrorCode.ALREADY_USER_RESERVATION);

        int size = getQueueWaitingCount(restaurantId); // redis 사이즈를 통해 예약 번호 지정

        QueueReservation queueReservation = QueueReservation.builder()
                .username(username)
                .booking(size + 1)
                .build();

        redisUtil.hashPutQueue(key, queueReservation);
        redisUtil.expire(key, QUEUE_RESERVATION_TTL);
        .
        .
        .
    }
        
    /* 가게 예약자 수 읽기 */
    public int getQueueWaitingCount(Long restaurantId) {

        String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;
        return redisUtil.hashSize(key); // redis 사이즈를 통해 예약 번호 지정
    }

발생한 부분은 int size = getQueueWaitingCount(restaurantId); 이 부분이다.
만약, 여러 개의 프로세스 또는 스레드가 동시에 접근을 한다면, 모두 같은 값을 반환하여 예약하는데 있어서 문제가 발생한다.

🧐 테스트

<< 동시성 문제 발견 테스트 (저장 및 불러오기) >> (눈에 쉽게 보이는 결과를 위해 10개만 입력)

	/* 예약 테스트 (여러 개의 스레드) */
	@Test
    public void testSaveQueueReservationConcurrency() throws InterruptedException {
        // 스레드 풀 생성
        ExecutorService executorService = Executors.newFixedThreadPool(5); // 스레드 5개 지정
        Long restaurantId = 1L;
        String baseUsername = "user";
        
        String key = "reservation:queue:" + restaurantId;
        redisUtil.del(key);     // 기존 값 제거

        // 여러 사용자가 동시에 예약을 추가하는 것을 시뮬레이션
        for (int i = 0; i < 10; i++) {  // 10개의 예약 요청을 동시에 실행
            String username = baseUsername + i;
            executorService.submit(() -> {
                try {
                    queueReservationService.saveQueueReservation(username, restaurantId);
                } catch (Exception e) {
                    System.out.println("에러 발생 " + username + " - " + e.getMessage());
                }
            });
        }

        // 스레드 풀 종료
        executorService.shutdown();
        // 스레드 풀에 남아 있는 작업 끝날 때 까지 대기 (최대 1분)
        executorService.awaitTermination(1, TimeUnit.MINUTES);  
    }
    
    /* 예약 번호 들고오기 */
    @Test
    public void testVerifyQueueReservation() {
        Long restaurantId = 1L;
        String key = "reservation:queue:" + restaurantId;

        // Redis에서 예약된 모든 데이터를 리스트로 가져오기
        List<QueueReservationResDto> reservations = redisUtil.getQueueEntries(key);

        // Redis에 저장된 예약 번호 및 사용자 확인
        reservations.forEach(queueReservation -> {
            System.out.println("사용자: " + queueReservation.getUsername() +
                    ", 예약 번호: " + queueReservation.getBooking());
        });
    }

<< 테스트 결과 >>

사용자: user6, 예약 번호: 6
사용자: user2, 예약 번호: 1
사용자: user4, 예약 번호: 1
사용자: user7, 예약 번호: 6
사용자: user3, 예약 번호: 1
사용자: user1, 예약 번호: 1
사용자: user0, 예약 번호: 1
사용자: user5, 예약 번호: 6
사용자: user9, 예약 번호: 6
사용자: user8, 예약 번호: 6

위와 같이, 5개 씩 동시에 스레드가 접근하니 동일한 공유 자원에 접근을 하여, 값이 중복으로 발생하게 되었다.

👉 해결방법 (Redis 분산락 - Redisson)

우선, 동시성 문제를 해결할 수 있는 방법은 여러가지이다.
하지만, 나는 여기서는 Redis client인 Redisson을 선택하였다.

그 이유는 자바에서 제공하는 synchronized는 여러 서버라면 동기화되지 않는다는 문제점 (하나의 서버라면, 빈은 싱글톤이라기에 문제 x),
DB를 사용하는 것이 아니기 때문에 DB에서의 동시성 제외,
Redis의 Lua스크립트 사용은 사용하기 어려움이 그 이유이다.

다른 동시성 문제 해결 방법은 다음 글에 포스팅하려고 한다.

1. 분산락(Distributed Lock)이란?

분산락은 여러 서버가 동시에 접근하는 공유 자원에 대해 하나의 프로세스 또는 스레드만 접근할 수 있도록 제어하는 메커니즘이다. 즉, 여러 클라이언트가 동시에 공유 자원에 접근할 때 발생하는 동시성 문제를 해결하기 위한 방법이다. 이를 통해 데이터 무결성을 보장할 수 있다.

<< 이러한 경우 여러 스레드나 서버가 동시에 자원에 접근하지 않도록 락(Lock)을 설정하여 해결할 수 있다. >>

2. 왜 Redisson을 이용하는가?

Redis Client인 Redisson, 분산 환경에서 락을 제공하여 다중 서버나 다중 스레드 환경에서도 안정적으로 동시성 문제를 해결할 수 있는 라이브러리이다.

Redisson 이용하여 분산락(Distributed Lock)을 구현하면, 여러 서버에서 동일한 자원에 접근할 때에도 하나의 자원만 동시에 수정할 수 있도록 제어할 수 있다.

3. 설정

1. build.gradle에 의존성 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'

2. Bean 등록

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;
    
    private static final String REDISSON_HOST_PREFIX = "redis://";
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);

        return Redisson.create(config);
    }
}

3. 동시성 제어가 필요한 부분에 설정

	private final RedissonClient redissonClient;

    private static final String QUEUE = "queue:";
    private static final long QUEUE_RESERVATION_TTL = 10*60*60;

    /* 가게 번호표 예약자 추가 */
    public void saveQueueReservation(String username, Long restaurantId) {

        String key = RESERVATION_KEY_PREFIX + QUEUE + restaurantId;
        String lockKey = "lock:" + key;  // 락을 걸 키
        RLock lock = redissonClient.getLock(lockKey);  // Redisson 락을 가져옴

        try {
            boolean available = lock.tryLock(5L, 5L, TimeUnit.SECONDS);
            if (!available) {
                throw new CustomException(ErrorCode.LOCK_ACQUISITION_ERROR);
            }

            if (isUserAlreadyInQueue(username, restaurantId))    // 중복 예약 확인.
                throw new CustomException(ErrorCode.ALREADY_USER_RESERVATION);

            int size = getQueueWaitingCount(restaurantId); // redis 사이즈를 통해 예약 번호 지정

            QueueReservation queueReservation = QueueReservation.builder()
                    .username(username)
                    .booking(size + 1)
                    .build();

            redisUtil.hashPutQueue(key, queueReservation);
            redisUtil.expire(key, QUEUE_RESERVATION_TTL);

           
           .
           .
           .
           
        } catch (InterruptedException e) {
            throw new CustomException(ErrorCode.THREAD_INTERRUPTED);
        } finally {
            lock.unlock();
        }
    }

주요 코드는 아래와 같다.

  1. RLock lock = redissonClient.getLock(lockKey);
    을 통해 락을 생성. 이 락을 통해 특정 자원에 대한 접근을 제한.

  2. boolean available = lock.tryLock(5L, 5L, TimeUnit.SECONDS);
    첫 번째 매개변수 : 대기시간 (다른 스레드가 이미 락을 가지고 있는 경우)
    두 번째 매개변수 : 독점시간 (스레드가 락을 들고 있을 수 있는 시간)
    세 번째 매개변수 : 시간 타입

독점시간의 경우, 너무 짧게하면 작업이 덜 끝났는데 스레드가 종료될 수 있고, 너무 길게한다면 스레드에 문제가 생겨 해제가 안되는 상황이 있을 수 있는데, 이 때 데드락이 발생할 수 있다.

나의 경우에는, 예약 외에도 알림 전송 과정이 있기 때문에 독점 시간을 5초로 하였다.

  1. lock.unlock();
    작업이 완료되면 락을 해제하여 다른 스레드가 사용할 수 있도록 한다.
    finally에 사용함으로써, 오류가 발생해도 락은 해제되도록 하였다.

🔥 분산락 적용 후 실행 결과

사용자: user2, 예약 번호: 1
사용자: user3, 예약 번호: 7
사용자: user7, 예약 번호: 4
사용자: user8, 예약 번호: 5
사용자: user1, 예약 번호: 8
사용자: user6, 예약 번호: 3
사용자: user5, 예약 번호: 2
사용자: user9, 예약 번호: 6
사용자: user4, 예약 번호: 10
사용자: user0, 예약 번호: 9

이 전과는 달리 예약 번호가 중복된 결과 값이 없다.

💡 느낀점

동시성 문제는, 운영체제를 공부할 때 알게 되었다. 이론과 결과를 알 뿐, 실제로 발생하는 경우는 보지 못해서, '시스템적으로 잘 처리가 되는구나'라는 생각을 했던 적이 있다.

하지만, 내가 동시성 문제에 대해 직면해보지 못했거나, 위의 동시성 문제 코드처럼 문제가 있지만 지나간 경우가 있다고 생각한다.

이번 계기로 테스트의 중요성을 다시 느끼게 되었고, 다양한 문제에 대해서 직면해보고 싶다는 생각 또한 하게 되었다.

그리고, 동시성 문제를 해결하는 방법은 여러가지인데, 다음 글을 통해 다뤄보고자 한다.

<참고>

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

분산락을 사용하여 동시성 문제 해결하기

profile
Better than yesterday.

0개의 댓글