Redis 동시성과 해결 방법 4가지

chaean·2025년 1월 19일

Redis

목록 보기
8/8

동시성 문제

Redis는 단일 스레드 기반의 이벤트 루프를 사용하기 때문에, 대부분의 경우 동시성에 문제가 발생하지 않습니다.

하지만 애플리케이션 설계에 따라 동시성 문제가 발생할 수 있습니다.

App1RedisApp2
Count 조회Count = 0
Count = 0Count 조회
Count += 20;Count = 20
Count = 10Count += 10;
나는30을원했는데….

Redis의 동작 방식

  1. 단일 스레드 : Redis는 요청을 하나씩 처리하는 단일 스레드 기반의 모델입니다. 두 개 이상의 명령이 동시에 실행되는 경우는 없으며, 이는 Redis가 기본적으로 동시성 안전하다는 것을 의미합니다.
  2. 원자성 보장 : Redis 명령은 기본적으로 원자성을 보장하며, 트랜잭션이 완료되기 전에 다른 명령이 개입하지 않습니다.

동시성 문제가 발생할 수 있는 상황 with Java

1. Race Condition (경쟁 상태)

Race Condition은 프로그램의 결과값이나 동작이 실행 순서에 따라 달라질 수 있는 상태를 말합니다.

여러 스레드(또는 프로세스)가 동시에 동일한 자원(예: 변수, 메모리, 파일 등)에 접근하고 변경하려고 할 때 발생합니다.

자원을 공유하거나 비동기 방식으로 실행했을 때 발생할 수 있습니다.

@Autowired
private RedisTemplate<String, String> redisTemplate;

public void raceCondition() {
		// Thread 1
		String val = jedis.get("counter"); // 현재 값 읽기
		int newVal = Integer.parseInt(val) + 1; // 값 증가
		redisTemplate.opsForValue().set("counter", String.valueOf(newVal)); // 저장
		
		// Thread 2
		String val2 = jedis.get("counter"); // 동일한 시점에서 읽음
		int newVal2 = Integer.parseInt(val2) + 1; // 값 증가
		redisTemplate.opsForValue().set("counter", String.valueOf(newVal2)); // 저장
}

'
'

원했던 값 : 2
실제 값 : 1

해결 방법 (Spring Data Redis)

1. Redis 원자적 명령 사용

Redis에서 제공하는 원자적 명령 (INCR, DECR, HSETNX 등)을 사용할 수 있습니다.

원자적 명령을 사용한다고 모든 동시성을 해결할 수 있지는 않습니다.

@Autowired
private RedisTemplate<String, String> redisTemplate;

public void incrementCounter() {
    String key = "counter";
    
    // INCR 명령은 원자적으로 실행됨
    redisTemplate.opsForValue().increment(key, 1);
}

2. Redis Transactions

Redis의 트랜잭션 기능 (MULTI와 EXEC)을 사용하여 여러 명령을 원자적으로 실행할 수 있으며, 여러 명령이 중간에 방해받지 않고 실행되도록 보장합니다.

Redis에서는 트랜잭션을 체크 포인팅 수단으로 사용하거나, 나중에 취소해야 할 수도 있는 작업들에 사용하지 않습니다.

@Autowired
private RedisTemplate<String, String> redisTemplate;

public void executeTransaction() {
    String key = "counter";

    // 트랜잭션 시작
    redisTemplate.execute(new SessionCallback<Object>() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            // WATCH로 키 감시
            operations.watch(key);

            // 값 읽기
            String val = (String) operations.opsForValue().get(key);
            if (val != null) {
                int currentValue = Integer.parseInt(val);
                int newValue = currentValue + 1; // 값 증가

                // 트랜잭션 시작
                operations.multi(); // MULTI 명령 호출
                operations.opsForValue().set(key, String.valueOf(newValue)); // 값 설정

                // 트랜잭션 실행
                return operations.exec(); // EXEC 명령 호출
            }
            return null; // 키가 없을 경우 null 반환
        }
});
}

[WATCH]

WATCH 명령은 트랜잭션(MULTI/EXEC)과 함께 사용되는 낙관적 잠금(Optimistic Locking) 메커니즘을 제공합니다. 이를 통해 트랜잭션이 실행되는 동안 특정 키의 변경 여부를 감시하여, 다른 클라이언트가 데이터를 변경하면 트랜잭션이 실패하도록 합니다.

[문제 예시]

ex) 입찰 금액이 5달러인 상황에서 10달러 입찰 15달러 입찰을 요청하게되면 유효한 입찰임에도 동시성 문제로 10달러로 변환하게됨.

→ 일정 횟수 재시도하는 로직을 추가해 해결할 수 있지만 많은 부하가 발생할 것

[관련 메서드]

multi()Redis 트랜잭션 시작
exeu()큐에 저장된 모든 명령 실행 및 트랙잭션 종료. 리스트 반환
discard()현재 트랜잭션 취소 및 큐에 저장된 명령 삭제.
watch(K key)key를 감시. 해당 메서드 호출 이후 감시되고 있는 키의 값이 변경되면 exec() 메서드 호출 시 트랜잭션 실패. 빈 결과를 반환
watch(Collection keys)전달된 key들을 감시.
unwatch()watch()로 설정된 모든 키의 감시를 해제. exec() 호출 시 자동으로 감시되는 키들은 해제된다.
execute(SessionCallback session)SessionCallback 구현체를 인자로 전달 받아 트랜잭션을 실행하고 트랜잭션 실행 결과를 리턴 받는다.
트랜잭션 처리 혹은 파이프라이닝에 사용된다.

3. 분산 락 (Lock)

3-1. Lock - Lettuce

Redis는 싱글 스레드 기반이기 때문에 SET NX 명령어를 통해 간단한 lock 구현이 가능하다.

[Spring Data Redis]

@Service
public class RedisLockService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 락을 걸기 위한 메서드
    public boolean lock(String lockKey, String value) {
        // 직접 RedisTemplate을 사용하여 락을 설정
        Boolean isLockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, value);

        // 락을 걸었다면, TTL(Time to Live)을 설정하여 락이 영구히 유지되지 않도록 합니다.
        if (isLockAcquired != null && isLockAcquired) {
            // TTL 설정: 10초 동안 락 유지
            redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
        }

        return isLockAcquired != null && isLockAcquired;
    }

    // 락을 해제하는 메서드
    public void unlock(String lockKey, String value) {
        // 직접 RedisTemplate을 사용하여 락을 해제
        String currentValue = redisTemplate.opsForValue().get(lockKey);

        // 현재 락을 설정한 값과 동일한 값만 락을 해제할 수 있도록 설정
        if (value.equals(currentValue)) {
            redisTemplate.delete(lockKey);  // 락 해제
        }
    }

    // 락을 사용하여 실행할 작업
    public void performTaskWithLock(String lockKey, String value) {
        boolean isLocked = lock(lockKey, value);

        if (isLocked) {
            try {
                // 락을 획득한 후 작업 수행
                System.out.println("작업을 진행합니다...");
                // 여기서 실제 작업을 진행합니다.
                Thread.sleep(5000); // 예시로 5초 동안 작업을 진행
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                // 작업이 끝난 후 락을 해제
                unlock(lockKey, value);
                System.out.println("작업 완료 후 락 해제.");
            }
        } else {
            System.out.println("이미 다른 프로세스가 락을 보유 중입니다.");
        }
    }
}

3-2. Lock - Redisson

Lettuce(Spring Data Redis)는 ‘스핀락’이라고 불리는 폴링 기법을 통해 Lock 획득을 재시도한다. 이는 Redis에 지속적인 요청을 보내기때문에 부하가 발생할 수 있다.

Redisson은 Redis의 Pub/Sub 기능을 사용해 Lock 획득을 재시도한다.

[Redisson]

@Service
public class LockService {
		// RedissonClient에 대한 설정 파일이 따로 있어야한다
    @Autowired
    private RedissonClient redissonClient;

    public void performLockedOperation() {
        RLock lock = redissonClient.getLock("myLock");

        try {
            // 락을 획득
            lock.lock();
            // 락을 획득한 후 수행할 작업
            System.out.println("작업을 진행 중입니다...");
        } finally {
            // 락 해제
            lock.unlock();
        }
    }
}

[다중 인스턴스 환경]

Redis 클러스터에서 여러 샤드에 락을 걸려면 Redisson의 RedLock과 같은 분산 락 알고리즘을 이용해야한다.

4. LUA Script

LUA 스크립트는 Redis 서버에서 실행되므로, 스크립트 내의 모든 명령어는 원자적으로 실행됩니다.

Redis에서 루아 스크립트를 실행시키면 Redis는 다른 어떠한 명령도 처리하지 않게 된다. → Why? 단일 스레드

@Autowired
private RedisTemplate<String, String> redisTemplate;

public boolean acquireLock(String lockKey, String lockValue, long timeout) {
    // LUA 스크립트: key를 설정하고 값이 없으면 성공적으로 설정
    String script = 
        "if redis.call('exists', KEYS[1]) == 0 then " +
        "  redis.call('setex', KEYS[1], ARGV[1], ARGV[2]); " +
        "  return 1; " +
        "else " +
        "  return 0; " +
        "end";

    // executeScript를 사용하여 LUA 스크립트 실행
    Long result = (Long) redisTemplate.execute(
        (redisConnection) -> redisConnection.eval(
            script.getBytes(),
            ReturnType.INTEGER,
            1, // key 갯수
            lockKey.getBytes(),
            String.valueOf(timeout).getBytes(),
            lockValue.getBytes()
        )
    );

    // 1이면 락 획득 성공, 0이면 락이 이미 획득됨
    return result != null && result == 1;
}

public void releaseLock(String lockKey, String lockValue) {
    // LUA 스크립트: key가 lockValue와 일치하면 삭제
    String script = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  redis.call('del', KEYS[1]); " +
        "  return 1; " +
        "else " +
        "  return 0; " +
        "end";

    redisTemplate.execute(
        (redisConnection) -> redisConnection.eval(
            script.getBytes(),
            ReturnType.INTEGER,
            1, // key 갯수
            lockKey.getBytes(),
            lockValue.getBytes()
        )
    );
}

[Lua Script를 사용하는 경우]

  1. Redis에서 지원하지 않는 쿼리를 실행하려고 할 때
  2. 동시성 문제를 해결할 때
  3. Redis의 접근을 줄이고 싶을 때 (한번에 요청으로 데이터를 받아올 수 있음)

[단점]

  1. 모든 키를 알아야 실행 가능 (EVALSHA)
  2. 스크립트 테스트가 어려움
  3. 루아는 동적이고 약한 유형의 언어 (타입 체크 등)
  4. 다른 언어(Lua)를 배워야 함

결론

  • 트랜잭션은 여러 작업을 하나의 논리적인 단위로 묶어서 실행할 수 있는 방법으로, 주로 데이터의 일관성과 무결성을 보장하는 데 사용됩니다.
  • 은 자원에 대한 동시 접근을 제어하는 방법으로, 여러 프로세스나 스레드가 동일한 자원에 동시에 접근할 때 발생할 수 있는 경쟁 상태데이터 충돌을 방지합니다.

Spring Data Redis(Lettuce) vs Redisson

  1. Spring 애플리케이션과의 통합 및 리포지토리 패턴 활용 시: Spring Data Redis
  2. 분산 데이터 구조, 고수준 추상화 및 고급 기능이 필요한 경우: Redisson
profile
백엔드 개발자

0개의 댓글