아키텍처 다이어그램 구성 요소:
- 사용자 계층 (Users):
- 여러 사용자 아이콘 (PC, 스마트폰 등)으로 표시.
- 사용자들이 이벤트 신청 버튼을 클릭하며 요청이 발생.
- 각 사용자로부터 로드 밸런서로 연결되는 화살표를 표시.
- 로드 밸런서 (Load Balancer):
- 사용자가 보낸 요청을 여러 애플리케이션 서버로 분산.
- 로드 밸런서에서 각각의 애플리케이션 서버로 연결되는 화살표를 표시.
- 애플리케이션 서버 (Application Servers):
- 여러 개의 서버 (예: 서버 A, 서버 B, 서버 C).
- 각 서버는 사용자 요청을 처리하고, Redis 클러스터와 데이터베이스에 접근.
- 로드 밸런서에서 애플리케이션 서버로 연결된 화살표를 표시.
- Redis 클러스터 (Redis Cluster):
- 분산 잠금 처리를 위해 구성된 Redis 클러스터.
- 고가용성 및 데이터 분산을 위한 여러 Redis 노드(마스터-슬레이브 구조)를 포함.
- 애플리케이션 서버에서 Redis 클러스터로 연결된 화살표를 표시.
- 클러스터 내에서 잠금이 설정되고 해제되는 과정을 설명하는 주석이나 화살표 추가.
- 데이터베이스 (Database):
- 이벤트 참가자 정보를 저장하는 데이터베이스 시스템 (예: RDBMS, NoSQL 등).
- 각 서버가 이벤트 등록 후 참가자 정보를 이 데이터베이스에 기록.
- 애플리케이션 서버에서 데이터베이스로 연결된 화살표를 표시.
- 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 활아키텍처의 흐름:
- 사용자가 요청: 사용자가 이벤트 신청 버튼을 클릭하면 요청이 로드 밸런서로 전송됩니다.
- 로드 밸런서가 요청 분배: 로드 밸런서는 요청을 여러 애플리케이션 서버 중 하나로 분배합니다.
- 서버에서 Redis 클러스터로 잠금 요청: 각 서버는 Redis 클러스터에 잠금 요청을 보내고, 가장 먼저 잠금을 획득한 서버만 이벤트 등록을 처리합니다.
- 잠금 획득: Redis 클러스터는 잠금 요청을 처리하며, 하나의 서버만 잠금을 성공적으로 획득합니다.
- 데이터베이스 업데이트: 잠금을 획득한 서버는 데이터베이스에 참가자 정보를 기록합니다.
- 잠금 해제: 작업이 완료되면 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 클러스터에서의 키 분산 처리
- 슬롯 계산 (Hash Slot Calculation):
- Redis 클러스터는 각 키의 CRC16 해시 값을 계산하고, 이 해시 값을 16384로 나눈 나머지 값을 사용하여 해당 키가 저장될 슬롯을 결정합니다.
- 예를 들어, "event:124:limit"이라는 키가 CRC16 해시 알고리즘에 의해 계산된 슬롯 번호(예: 5234)에 할당될 수 있습니다.
- 슬롯과 마스터 노드 매핑:
- Redis 클러스터는 전체 16384개의 슬롯을 여러 마스터 노드에 분배합니다.
- 특정 슬롯(예: 5234번 슬롯)은 클러스터 내의 하나의 마스터 노드에 할당되며, 이 마스터 노드는 해당 슬롯에 속하는 모든 키를 관리합니다.
- 키의 저장 위치 결정:
- "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)을 설정하여 자동으로 만료되게 할 수 있습니다. 예를 들어, 일정 시간이 지나면 자동으로 대기자 명단에서 제외하는 등의 로직을 쉽게 구현할 수 있습니다.
댓글