본문 바로가기

CS

Redis는 싱글 스레드인데 동시성 관리가 필요할까?

 

Redis 로고

 

 

여러분도 알다시피 Redis는 기본적으로 싱글스레드로 작동하지만, 그렇다고 해서 동시성 관리가 전혀 필요하지 않은 것은 아닙니다. 우선 Redis는 인메모리 DB이기에 빠른 성능을 자랑하며 단일 스레드로 많은 양의 요청을 빠르게 처리합니다

 

하지만 특정 상황에서는 동시성 관리가 필요할 수 있으며, 특히 여러 사용자가 동일한 자원에 접근하거나 변경하려고 할 때 문제가 발생할 수 있으며 오늘은 해당 문제에 대해서 포스팅 하려고 합니다

 

 

 

Redis에서 동시성 관리가 필요한 이유

 

 

Redis는 단일 스레드에서 처리되지만, 여러 클라이언트가 동시에 요청을 보낼 수 있기 때문에 서로의 요청이 충돌할 수 있습니다. 예를 들어 동일한 지원 Redis는 키와 벨류 구조이기에 "키"가 되겠죠? 해당 키에 대해 동시에 여러 수정 추가 등 쓰기 작업이 발생할 경우 의도하지 않은 데이터 덮어쓰기가 일어날 수 있습니다.

 

정리하자면 싱글 스레드라 하더라도, 여러 클라이언트가 동시에 Redis 서버에 요청을 보낼 수 있고, 이 요청들은 Redis 서버가 빠르게 순차적으로 처리합니다. 문제는 이 순차 처리 동안 여러 클라이언트의 요청이 자원을 공유하거나 같은 키에 접근하여 동시에 데이터를 변경하려고 시도할 때 발생할 수 있

 

이러한 경우 Redis의 기본적인 명령어 만으로는 데이터 무결성을 보장하기 어렵기 때문에 동시성 관리가 꼭 필요해지며 Redis는 원자적 명령을 제공하여 동시성 문제의 해결을 지원합니다.


 

원자적 명령어

 

 

- SETNX (Set if Not Exists)

Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value)

 

해당 메서드를 활용하면, 주어진 키가 존재하지 않을 경우에만 값을 설정하고, 성공하면 true, 실패하면 false를 반환합니다. 이메일 인증 코드 요청 시 setIfAbsent를 사용해 중복 요청을 방지할 수도 있죠 즉 락을 구현하는 것이라고 보시면 됩니다

 

 

- INCR (Increment)

redisTemplate.opsForValue().increment(key);

 

INCR 명령을 사용하여 카운터 값을 증가시키는 예제로 다수의 클라이언트가 동시에 해당 메서드를 호출해도, Redis는 안전하게 값을 증가시킬 수 있게 됩니다. 트래픽이 높은 웹사이트에서 페이지 방문자 수를 카운트하거나, 재고 수량을 관리할 때 유용하게 작용할 수 있습니다

 

 

- MULTI / EXEC (Transaction)

@Service @RequiredArgsConstructor
public class RedisTransactionService {

    private final StringRedisTemplate redisTemplate;

    @Transactional
    public List<Object> executeTransaction() {
    	// 트랜젝션 지원 활성화
        redisTemplate.setEnableTransactionSupport(true);
        
        // 트랜잭션 시작
        redisTemplate.multi();
        redisTemplate.opsForValue().set("key1", "value1");
        redisTemplate.opsForValue().increment("counter");
        
        return redisTemplate.exec();
    }
}

 

해당 코드는 MULTI/EXEC 트랜잭션 기능을 사용하여 여러 작업을 하나의 트랜잭션으로 묶어 실행하는 예제로 기본적으로 RedisTemplate는 트랜잭션 기능을 지원하지 않습니다. 그렇기에 

 

redisTemplate.setEnableTransactionSupport(true) 해당 코드를 기입함으로써, 트랜잭션을 활성화 시킬 수 있습니다. 트랜잭션이 활성화 됨에 따라, multi() 로 트랜잭션을 시작하고, 여러 명령을 큐에 넣은 후 exec()로 한 번에 실행시킵니다. 만일 이벤트가 발생했을 때 여러 Redis 값을 동시에 수정하거나, 상태를 변경할 때 유용할 것 같습니다


Redis에서 락 구현

 

Redis는 직접적인 락 명령어는 제공하지 않지만, SETNXEXPIRE를 활용하여 락을 구현할 수 있습니다. 이 방식은 특정 자원에 대해 한 번에 하나의 클라이언트만 접근할 수 있도록 해주며, 유효 시간도 설정할 수 있습니다.

 

 

- 간단한 락 구현 예시 (SETNX 사용)

public boolean acquireLock(String key, String value, long seconds) {
    Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(key, value, Duration.ofSeconds(seconds));
    return isLocked != null && isLocked;
} // 락 설정

public void releaseLock(String key, String value) {
    String currentValue = redisTemplate.opsForValue().get(key);
    if (value.equals(currentValue)) {
        redisTemplate.delete(key);
    } // 락 해제
}

 

 

 

- 분산 환경에서의 락 (Redlock 사용)

 

분산 환경에서 안전하게 락을 관리하기 위해 Redis는 Redlock 알고리즘을 제공합니다 Redlock은 여러 Redis 노드에서 락을 설정하고, 과반수 노드에서 성공적으로 락을 설정했을 때만 자원 접근을 허용합니다. 이는 단일 Redis 인스턴스가 장애를 일으킬 수 있는 분산 시스템에서 매우 유용하게 작용하죠

 

@Service @RequiredArgsConstructor
public class RedissonLockService {

    private final RedissonClient redissonClient;

    public boolean acquireLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(10, 30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            return false;
        }
    }

    public void releaseLock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

해당 코드에서 lockKey는 락을 걸기 위한 고유한 키이며, Redis에서는 해당 키를 기준으로 락을 설정하고 해제하고 자원의 식별자로 사용합니다.

 

tryLock은 락을 시도하는 메서드로 락을 획득하려고 시도하지만, 실패할 경우 일정 시간 동안 대기를 해야 할텐데, 해당 메서드가 락을 얻기 위한 시간과 락이 자동으로 해제될 시간을 지정하게 합니다

1 번째 매개변수는 락을 얻기위해 기다리는 시간
2 번째 매개변수는 자동으로 락이 해제되는 시간
3 번째 매개변수는 시간단위

 

isHeldByCurrentThread()

 

위 코드는 현재 스레드가 락을 소유하고 있는지 확인하는 메서드입니다. 락은 여러 클라이언트나 스레드 간에 공유될 수 있기 때문에, 특정 자원에 대한 락을 해제하려면 그 락을 소유한 스레드가 직접 해제해야 합니다.

 

이 메서드를 사용하여 현재 스레드가 해당 락을 소유하고 있을 때만 해제하도록 제어할 수 있습니다. 이를 통해 다른 클라이언트나 스레드가 잘못된 락 해제를 하지 못하도록 방지할 수 있습니다.


 

동시성 관리가 필요한 상황

 

 

중복 요청 방지

 

만일 여러 사용자가 동일한 리소스에 대해 거의 동시에 요청을 보내는 상황에서는, 중복된 작업이 일어나지 않도록 관리해야 합니다. 특히 인증 코드 발송 같은 경우에는 짧은 시간 안에 다수의 중복 요청이 발생할 수 있고 아래에 제가 인증코드 로직에서 Redis로 동시성 관리를 어떻게 해결을 하였는지 코드로 보여드리겠습니다


데이터 무결성 유지

 

데이터 무결성 유지는 한 사용자가 특정 작업을 수행하는 동안 다른 사용자의 요청이 해당 작업에 영향을 미치지 않도록 하는 것을 의미합니다.

 

예를 들어, 한 사용자가 상품 재고를 수정하는 동안 다른 사용자가 동시에 같은 상품을 구매하려고 시도할 경우, 재고 부족이나 주문 실패 같은 문제가 발생할 수 있습니다. 이러한 상황에서 동시성 관리가 필요하며, Redis를 사용해 자원을 잠그거나 처리 순서를 제어함으로써 데이터의 일관성을 유지할 수 있습니다

 

예시 상황을 구체적으로 들어가보자면,

 

예를 들어, Redis를 사용해 상품 재고를 관리하는 시스템을 생각해봅시다, 두 명의 사용자가 동시에 동일한 상품을 구매하려고 한다면, 두 클라이언트는 각각 상품의 재고를 확인하고 동시에 재고를 감소시키려고 할 수 있습니다.

 

이때 첫 번째 클라이언트가 재고를 감소시키는 작업이 완료되기 전에, 두 번째 클라이언트가 기존 재고 값을 참조하여 또 다른 구매를 진행한다면, 실제 재고는 감소했음에도 불구하고 두 번째 클라이언트는 여전히 재고가 충분하다고 판단하고 구매를 완료하게 될 수 있습니다. 이러한 상황은 동시성 관리가 제대로 이루어지지 않으면 재고가 음수로 내려가는 등의 문제가 발생할 수 있습니다.

 


 

내가 사용한 동시성 관리 예시

 

저는  현재 프로젝트의 가입절차 로직 중 이메일 및 휴대전화 인증 코드 발송 시스템에서 Redis를 활용하고 Redis에서 동시성 관리를 활용하였습니다.

 

저는 이러한 상황을 우려하였는데, 만일 여러 사용자가 동일한 이메일 주소나 휴대전화 번호로 동시에 인증을 요청할 경우 충돌이 났을 때를 대비해야 겠다라는 생각과, 만일 사용자가 발송버튼을 연달아 요청했을 때 등울 방지하기 위해 Redis의 setIfAbsent 기능을 활용했습니다.

 

public void sendEmailVerificationCode(String email) {
    if (memberRepository.existsByUserEmail(email)) {
        throw new BadRequestTelException("이미 등록된 이메일 입니다.");
    }

    if (redisService.getData(email + ":verified").equals("true")) {
        throw new IllegalStateException("이메일 인증이 이미 완료되었습니다.");
    }

    if (redisService.setIfAbsent(email + ":verification", "pending", 30)) {
        throw new IllegalStateException("이미 인증 코드를 보냈습니다, 30초 뒤 재요청 보내십시오.");
    }

    String verificationCode = generateVerificationCode();
    emailSendCode(email, verificationCode);
    redisService.setData(email + ":code", verificationCode, 600);
}

 

코드에서의 핵심은 setIfAbsent를 사용해 30초 동안 해당 이메일에 대해 중복된 인증 코드 요청이 발생하지 않도록 방지하는 부분입니다

 

setIfAbsent는 키가 존재하지 않을 때만 값을 설정하며, 이는 Redis의 원자성을 활용한 동시성 관리 방식입니다. 이를 통해 여러 사용자가 동시에 동일한 이메일로 인증 요청을 시도하더라도, 한 번만 인증 코드가 발송되게끔 제어할 수 있었습니다 그리고 휴대전화 인증 코드 발송 로직도 비슷하게 구현되었습니다

 

public void sendPhoneVerificationCode(String phoneNum) {
    if (memberAddressRepository.existsByTel(phoneNum)) {
        throw new BadRequestTelException("이미 등록된 휴대전화 번호입니다.");
    }

    if (redisService.getData(phoneNum + ":verified").equals("true")) {
        throw new IllegalStateException("휴대전화 인증이 이미 완료되었습니다.");
    }

    if (redisService.setIfAbsent(phoneNum + ":verification", "pending", 30)) {
        throw new IllegalStateException("이미 인증코드를 발송하였습니다 30초 뒤에 재요청 보내십시오.");
    }

    String verificationCode = generateVerificationCode();
    smsUtil.sendCode(phoneNum, verificationCode);
    redisService.setData(phoneNum + ":code", verificationCode, 600); // 인증번호 10분 유효
}

 

물론 사용자가 잘못 인증번호를 기입하였을 경우에는 곧바로 해당 상태를 delete 함으로써, 바로 인증코드를 재요청 할 수 있도록 사용자 편의성을 개선하였습니다.


 

마무리

 

Redis는 빠르고 강력한 성능을 자랑하는 인메모리 데이터베이스이지만, 여러 사용자가 동시에 자원에 접근할 때는 동시성 관리가 필수적입니다. 이를 해결하기 위해 Redis는 다양한 원자적 명령어와 트랜잭션 기능을 제공하며, SETNX와 같은 명령어를 이용해 락을 구현할 수 있습니다.

 

더 나아가, 분산 환경에서는 Redlock과 같은 알고리즘을 사용하여 더욱 안전하게 자원을 관리할 수 있습니다. 이와 같은 동시성 관리 기법을 적절히 활용하면, Redis의 빠른 성능을 유지하면서도 안정성과 데이터 무결성을 확보할 수 있습니다.