카테고리 없음

redis 활용 동시성 방안

우주로그 2024. 8. 9.

아키텍처 다이어그램 구성 요소:

  1. 사용자 계층 (Users):
    • 여러 사용자 아이콘 (PC, 스마트폰 등)으로 표시.
    • 사용자들이 이벤트 신청 버튼을 클릭하며 요청이 발생.
    • 각 사용자로부터 로드 밸런서로 연결되는 화살표를 표시.
  2. 로드 밸런서 (Load Balancer):
    • 사용자가 보낸 요청을 여러 애플리케이션 서버로 분산.
    • 로드 밸런서에서 각각의 애플리케이션 서버로 연결되는 화살표를 표시.
  3. 애플리케이션 서버 (Application Servers):
    • 여러 개의 서버 (예: 서버 A, 서버 B, 서버 C).
    • 각 서버는 사용자 요청을 처리하고, Redis 클러스터와 데이터베이스에 접근.
    • 로드 밸런서에서 애플리케이션 서버로 연결된 화살표를 표시.
  4. Redis 클러스터 (Redis Cluster):
    • 분산 잠금 처리를 위해 구성된 Redis 클러스터.
    • 고가용성 및 데이터 분산을 위한 여러 Redis 노드(마스터-슬레이브 구조)를 포함.
    • 애플리케이션 서버에서 Redis 클러스터로 연결된 화살표를 표시.
    • 클러스터 내에서 잠금이 설정되고 해제되는 과정을 설명하는 주석이나 화살표 추가.
  5. 데이터베이스 (Database):
    • 이벤트 참가자 정보를 저장하는 데이터베이스 시스템 (예: RDBMS, NoSQL 등).
    • 각 서버가 이벤트 등록 후 참가자 정보를 이 데이터베이스에 기록.
    • 애플리케이션 서버에서 데이터베이스로 연결된 화살표를 표시.
  6. Redis 클러스터 내의 마스터와 슬레이브 (Redis Master and Slaves):
    • Redis 클러스터는 여러 마스터 노드와 각 마스터에 연결된 슬레이브 노드로 구성됨.
    • 마스터 노드는 쓰기 및 읽기 작업을 처리하며, 슬레이브 노드는 마스터의 데이터를 복제하여 읽기 작업을 분산.
    • 각 애플리케이션 서버가 마스터 노드에 요청을 보내고, 필요 시 슬레이브 노드에서 데이터를 읽어오는 흐름을 표시.

 

springboot 초기 설정

1. 종속성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

2. properties 설정

spring:
  redis:
    cluster:
      nodes:
        - 192.168.1.101:6379
        - 192.168.1.102:6379
        - 192.168.1.103:6379
      max-redirects: 3

 

3. 정보 주입

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
@ConfigurationProperties(prefix = "spring.redis.cluster")
public class RedisClusterProperties {

    private List<String> nodes;
    private int maxRedirects;

    // Getters and Setters
    public List<String> getNodes() {
        return nodes;
    }

    public void setNodes(List<String> nodes) {
        this.nodes = nodes;
    }

    public int getMaxRedirects() {
        return maxRedirects;
    }

    public void setMaxRedirects(int maxRedirects) {
        this.maxRedirects = maxRedirects;
    }
}

 

4. 이벤트 redis 등록

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class EventService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;  // Redis 클라이언트 주입

    @Autowired
    private DatabaseClient dbClient;  // 데이터베이스 접근을 위한 클라이언트

    @Autowired
    private RedisClusterProperties redisClusterProperties;  // 클러스터 설정 정보

    public void startEvent(String eventId) {
        // 이벤트 시작 시, DB에서 Redis로 데이터 전환
        int limit = dbClient.getEventLimit(eventId);
        int currentCount = dbClient.getEventCurrentCount(eventId);
        
        String currentKey = "event:" + eventId + ":current";
        String limitKey = ("event:" + eventId + ":limit";

        // Redis에 데이터 저장
        redisTemplate.opsForValue().set(limitKey, String.valueOf(limit));
        redisTemplate.opsForValue().set(currentKey, String.valueOf(currentCount));


    }
}

 

 

5. 카운트 처리
Lua 스크립트 활용

아래의 Lua 스크립트를 사용하여 원자적으로 값을 읽고, 증가시키며 조건을 검사할 수 있습니다. 이 스크립트는 참가자가 100번째 이내에 들었는지 확인하고, 그렇다면 성공적으로 처리됩니다.

 

String script = 
    "local current = redis.call('GET', KEYS[1]) " +
    "local limit = redis.call('GET', KEYS[2]) " +
    "if tonumber(current) < tonumber(limit) then " + 
    "    return redis.call('INCR', KEYS[1]) " +
    "else " + 
    "    return tonumber(current) " +
    "end";
List<String> keys = Arrays.asList(currentKey, limitKey);

Object result = redisClient.eval(script, keys, Collections.emptyList());

if (result != null && Integer.parseInt(result.toString()) <= limit) {
    // DB 저장 처리
    return true;
} else {
    // 참가자 수 초과, 신청 실패 처리
    return false;
}

 

 // Lua 스크립트를 정의
        String script = 
            "local current = redis.call('GET', KEYS[1]) " +
            "local limit = redis.call('GET', KEYS[2]) " +
            "if tonumber(current) < tonumber(limit) then " + 
            "    return redis.call('INCR', KEYS[1]) " +
            "else " + 
            "    return tonumber(current) " +
            "end";

        // Redis 키 목록
        List<String> keys = Arrays.asList(
            "event:" + eventId + ":current",
            "event:" + eventId + ":limit"
        );

        // Lua 스크립트를 실행
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(script);
        redisScript.setResultType(Long.class);

        Long result = redisTemplate.execute(redisScript, keys);

        int limit = Integer.parseInt(redisTemplate.opsForValue().get("event:" + eventId + ":limit"));

        if (result != null && result <= limit) {
            // 성공적으로 등록 처리
            return true;
        } else {
            // 참가자 수 초과, 신청 실패 처리
            return false;
        }
  • 현재 값 읽기:
    • local current = redis.call('GET', KEYS[1])에서 현재 참가자 수를 읽어옵니다.
    • local limit = redis.call('GET', KEYS[2])에서 참가자 제한 수를 읽어옵니다.
  • 조건 확인 및 증가:
    • 현재 참가자 수가 제한 수보다 작다면 INCR 명령을 실행하여 currentKey 값을 증가시킵니다.
    • 증가된 값은 반환되며, 이 값이 제한 수 이내라면 성공을 반환합니다.
  • 참가자 수 초과 시 현재 값 반환:
    • 현재 참가자 수가 제한 수에 도달하거나 초과한 경우, 단순히 현재 값을 반환하고 증가하지 않습니다.
    • 반환된 값이 제한 수를 초과한 경우에만 실패 처리가 이루어집니다.
  • 결과 처리:
    • 스크립트의 결과로 반환된 값이 여전히 제한 수 이내인 경우에는 성공적으로 등록되었음을 의미하며, 이 값을 기준으로 처리됩니다.
    • 그렇지 않다면, 참가자 수 초과로 인해 실패 처리됩니다.

 

 

6. Lock로 처리하는 방법

SETNX를 이용한 간단한 잠금 구현

SETNX (SET if Not eXists) 명령어를 사용하면, 특정 키에 대해 잠금을 구현할 수 있습니다. 이 방식은 간단하지만 특정 시나리오에서 효과적으로 사용할 수 있습니다.

String lockKey = "lock:event:" + eventId;
String lockValue = UUID.randomUUID().toString(); // 잠금 소유자를 식별하기 위한 고유 값
boolean acquired = redisClient.setnx(lockKey, lockValue);

if (acquired) {
    try {
        // 잠금을 획득했으므로 작업을 수행
        String limitKey = "event:" + eventId + ":limit";
        String currentKey = "event:" + eventId + ":current";

        int limit = redisClient.getInt(limitKey);
        int currentCount = redisClient.getInt(currentKey);

        if (currentCount < limit) {
            redisClient.increment(currentKey);
            // 성공적으로 등록 처리
            return true;
        } else {
            // 참가자 수 초과, 신청 실패 처리
            return false;
        }
    } finally {
        // 작업이 완료되면 잠금을 해제
        redisClient.del(lockKey);
    }
} else {
    // 잠금 획득 실패 시, 잠금이 해제될 때까지 기다리거나 실패 처리
    return false;
}

 

 

 redis lock 활아키텍처의 흐름:

  1. 사용자가 요청: 사용자가 이벤트 신청 버튼을 클릭하면 요청이 로드 밸런서로 전송됩니다.
  2. 로드 밸런서가 요청 분배: 로드 밸런서는 요청을 여러 애플리케이션 서버 중 하나로 분배합니다.
  3. 서버에서 Redis 클러스터로 잠금 요청: 각 서버는 Redis 클러스터에 잠금 요청을 보내고, 가장 먼저 잠금을 획득한 서버만 이벤트 등록을 처리합니다.
  4. 잠금 획득: Redis 클러스터는 잠금 요청을 처리하며, 하나의 서버만 잠금을 성공적으로 획득합니다.
  5. 데이터베이스 업데이트: 잠금을 획득한 서버는 데이터베이스에 참가자 정보를 기록합니다.
  6. 잠금 해제: 작업이 완료되면 Redis 클러스터에서 잠금이 해제되어 다른 서버가 남은 요청을 처리할 수 있게 합니다.



** Lock보다 lua 가 좋은 이유

1. 원자성 보장

  • Lua 스크립트:
    • Redis에서 Lua 스크립트를 실행하는 동안, 해당 스크립트는 단일 명령어로 처리됩니다. Redis는 기본적으로 단일 스레드로 동작하므로, Lua 스크립트를 실행하는 동안 다른 명령어가 끼어들 수 없습니다.
    • 이는 원자성을 보장하므로, 중간에 다른 클라이언트가 같은 키에 접근하여 값을 변경할 가능성을 차단합니다.
  • 잠금 메커니즘:
    • 잠금을 사용하는 경우, 잠금을 획득하고 값을 읽고 비교하며 증가시키는 일련의 작업이 여러 명령어로 나뉘어 수행됩니다.
    • 이 과정에서 잠금이 예상치 않게 해제되거나, 네트워크 지연 등으로 인해 잠금이 제대로 관리되지 않을 경우 **경쟁 상태(Race Condition)**가 발생할 수 있습니다.
    • 또한, 분산 잠금은 구현이 더 복잡하며, 잠금 해제를 잊거나 오류가 발생할 경우 데드락(Deadlock) 문제가 발생할 수 있습니다.

2. 성능 측면

  • Lua 스크립트:
    • Lua 스크립트를 사용하는 방법은 고성능을 제공합니다. 모든 작업이 Redis 서버 내에서 처리되므로, 클라이언트와 서버 간의 네트워크 통신이 최소화됩니다.
    • 스크립트 실행이 매우 빠르며, 단일 스레드 환경에서 일관되게 동작합니다.
  • 잠금 메커니즘:
    • 잠금 메커니즘은 잠금을 획득하고 해제하는 오버헤드가 발생합니다. 특히 분산 잠금을 사용하는 경우, 여러 노드 간의 통신과 동기화가 필요하므로 지연이 발생할 수 있습니다.
    • 또한, 여러 클라이언트가 동일한 리소스를 잠그려 할 때 병목 현상이 발생할 가능성이 있습니다.

3. 안전성 및 구현 난이도

  • Lua 스크립트:
    • Lua 스크립트는 Redis의 내장 기능을 그대로 활용하므로, 추가적인 구현 복잡성이 거의 없습니다. 코드가 간결하며, Redis 서버 내에서 실행되기 때문에 추가적인 분산 환경 설정이 필요하지 않습니다.
    • 오류 처리도 간단하며, 스크립트 전체가 하나의 트랜잭션으로 취급되므로 오류가 발생하면 스크립트가 실패로 처리됩니다.
  • 잠금 메커니즘:
    • 잠금은 잘못된 사용으로 인한 데드락(Deadlock) 문제, 잠금 경쟁 문제 등 다양한 문제가 발생할 수 있습니다. 이를 방지하기 위해 복잡한 오류 처리와 타임아웃 관리가 필요합니다.
    • 특히 분산 환경에서 잠금을 구현하는 것은 더 복잡하며, 잠금 해제 시점이나 타임아웃 처리 등을 신중하게 고려해야 합니다.

 

** redis 클러스터 장점

Redis 클러스터에서는 각 키가 자동으로 클러스터 내의 적절한 마스터 노드에 분산 저장됩니다. Redis 클러스터는 키 공간을 16384개의 슬롯으로 나누고, 각 슬롯을 여러 마스터 노드에 분배하여 데이터를 관리합니다. 이 과정에서 event:124:limit과 같은 키는 자동으로 클러스터 내의 특정 마스터 노드에 할당됩니다.

Redis 클러스터에서의 키 분산 처리

  1. 슬롯 계산 (Hash Slot Calculation):
    • Redis 클러스터는 각 키의 CRC16 해시 값을 계산하고, 이 해시 값을 16384로 나눈 나머지 값을 사용하여 해당 키가 저장될 슬롯을 결정합니다.
    • 예를 들어, "event:124:limit"이라는 키가 CRC16 해시 알고리즘에 의해 계산된 슬롯 번호(예: 5234)에 할당될 수 있습니다.
  2. 슬롯과 마스터 노드 매핑:
    • Redis 클러스터는 전체 16384개의 슬롯을 여러 마스터 노드에 분배합니다.
    • 특정 슬롯(예: 5234번 슬롯)은 클러스터 내의 하나의 마스터 노드에 할당되며, 이 마스터 노드는 해당 슬롯에 속하는 모든 키를 관리합니다.
  3. 키의 저장 위치 결정:
    • "event:124:limit" 키는 계산된 슬롯 번호에 따라 자동으로 클러스터 내의 특정 마스터 노드에 저장됩니다.
    • 클라이언트는 이 과정을 통해 해당 키가 저장된 마스터 노드에 접근하여 작업을 수행합니다.

 

 

 

동시성에서 redis의 장점

1. 고속 성능

  • 빠른 예약 처리: 예약 시스템에서 100명 제한을 관리하는 것은 매우 실시간성이 중요한 작업입니다. Redis는 메모리 기반의 데이터 저장소이므로, 예약을 처리할 때 매우 빠른 응답 시간을 제공합니다. 이는 다수의 사용자가 동시에 예약을 시도할 때도 시스템이 신속하게 응답할 수 있게 합니다.
  • 낮은 지연 시간: 예약 처리 시, 각 사용자의 요청이 최소한의 지연 시간으로 처리되며, 대기 시간이 거의 없습니다. 이는 특히 예약이 급속히 마감될 수 있는 이벤트에서 중요한 장점입니다.

2. 동기적 처리와 원자성 보장

  • 경쟁 상태 방지: 여러 사용자가 동시에 예약을 시도할 때, INCR 및 Lua 스크립트를 사용하면 모든 예약 처리가 원자적으로 이루어집니다. 즉, 한 사용자가 예약하는 동안 다른 사용자가 중간에 값을 변경할 수 없으므로, 예약 인원의 일관성이 보장됩니다.
  • 복잡한 로직 처리: Lua 스크립트를 사용하여 예약 가능 인원과 현재 인원을 비교하고, 조건에 따라 예약 인원을 증가시키는 작업을 한 번에 처리할 수 있습니다. 이 모든 작업이 하나의 트랜잭션처럼 동작하여 경쟁 상태나 데이터 불일치 문제가 발생하지 않습니다.

3. 간단한 잠금 메커니즘 대체

  • 잠금 없이 안전한 처리: Redis의 Lua 스크립트를 사용하면 잠금을 설정하지 않고도 안전하게 여러 명령어를 원자적으로 실행할 수 있습니다. 이 방식은 잠금 메커니즘을 사용하는 것보다 간단하고, 잠금 관련 문제(예: 데드락)를 피할 수 있습니다.
  • 효율적인 자원 사용: 잠금 메커니즘은 일반적으로 성능 오버헤드가 발생할 수 있지만, Redis의 원자적 연산은 이러한 오버헤드 없이 예약 시스템을 효율적으로 관리할 수 있습니다.

4. 확장성 및 가용성

  • 확장 가능한 시스템: 만약 예약 시스템이 더욱 큰 규모로 확장되어야 한다면, Redis 클러스터링을 통해 쉽게 확장할 수 있습니다. 클러스터링을 통해 데이터를 여러 노드에 분산하여 처리 성능을 향상시키고, 높은 가용성을 유지할 수 있습니다.
  • 장애 복구: Redis의 복제와 페일오버 기능을 통해 예약 시스템의 신뢰성을 높일 수 있습니다. 만약 마스터 노드에 장애가 발생해도, 슬레이브 노드가 자동으로 승격되어 서비스를 지속할 수 있습니다.

5. 다양한 데이터 구조 지원

  • 예약 정보 관리: Redis는 단순히 예약 인원 수를 관리하는 것뿐만 아니라, 리스트(List), 해시(Hash), 정렬된 집합(Sorted Set) 등을 사용하여 대기자 명단, 우선순위 예약, 사용자 정보 등을 효율적으로 관리할 수 있습니다. 이를 통해 복잡한 예약 로직을 구현할 수 있습니다.
  • TTL 설정: 예약 정보를 TTL(Time-to-Live)을 설정하여 자동으로 만료되게 할 수 있습니다. 예를 들어, 일정 시간이 지나면 자동으로 대기자 명단에서 제외하는 등의 로직을 쉽게 구현할 수 있습니다.

댓글