기타

Redis란 무엇일까? + lock을 이용한 동시성 제어

열심히 해 2024. 11. 29. 18:07

Redis 란 무엇일까?

Key, Value 구조의 정형화 되지 않은 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터 베이스 관리 시스템. In memory Key-Value NoSQL.

https://www.sunjesoft.co.kr/faq/__faq/in-memory-dbmswa-disk-dbms-yi-caijeomi-mueosingayo

 

DISK DBMS에서는 매번 디스크에 접근해야 하기 때문에 사용자가 많아질수록 부하가 많아져서 느려질 수 있습니다. 서비스 운영 초반이거나 규모가 작거나 사용자가 많지 않은 서비스의 경우에는 데이터 베이스에 무리가 가지 않습니다. 하지만 사용자가 늘어난다면 데이터 베이스가 과부하 될 수 있기 때문에 In-Memory DBMS 를 구축하는 것이 좋습니다. 즉 캐시를 생성하여 액세스 시간을 줄이고 처리량을 늘려서 DB에 부담을 줄일 수 있습니다.

 

 


 

Redis에서는 다양한 자료구조를 지원합니다.

 

https://meetup.toast.com/posts/224

 

 

 lock 을 구현할 때 다른 도구와의 비교

기능 Redis  Memcached  Zookeeper  Etcd
성능 매우 빠름 매우 빠름 느림 중간
데이터 일관성 약한 일관성 약한 일관성 강한 일관성 강한 일관성
설정 및 운영 쉬움 쉬움 복잡함 복잡함
데이터 영속성 지원 가능 지원 안 됨 지원 지원
TTL 지원 있음 있음 Ephemeral Node로 가능 Lease로 가능
장애 복구 설정 필요 설정 필요 자동 복구 자동 복구
분산 환경 Redis Cluster 필요 자체 지원 안 됨 기본적으로 지원 기본적으로 지원

 

 

  • NoSQL DBMS: Redis, Memcached, Etcd
  • 분산 시스템 도구: Zookeeper

 

 


 

Spring 에서는 의존성 추가를 통해 Redis API 를 사용할 수 있습니다.

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

 

 


 

Redis Java 클라이언트: Jedis, Lettuce, Redisson

 

 

더보기

Jedis

  • 가장 오래된 Redis Java 클라이언트 라이브러리
  • 장점:
    • 간단하고 가벼운 라이브러리
    • 대부분의 Redis 기능 지원
    • 스레드 세이프하지 않아 스레드별 전용 Jedis 인스턴스 필요
  • 단점:
    • 비동기 작업 지원 미흡
    • 커넥션 풀링 직접 구현 필요

Lettuce

  • 현대적이고 Netty 기반의 논블로킹 Redis 클라이언트
  • 장점:
    • 완전한 비동기/논블로킹 지원
    • 스프링 부트의 기본 Redis 클라이언트
    • 스레드 세이프
    • 높은 성능과 안정성
    • 클러스터, 센티널 모드 우수한 지원
  • 단점:
    • 설정이 Jedis에 비해 복잡할 수 있음

Redisson

  • 분산락, 분산 컬렉션 등 고급 기능 제공
  • 장점:
    • 분산 시스템을 위한 고급 기능 풍부
    • 분산락, 분산 컬렉션, 분산 스케줄러 등 제공
    • 스프링과 쉬운 통합
    • 복잡한 분산 작업에 최적화
  • 단점:
    • 다른 라이브러리에 비해 무거움
    • 고급 기능 미사용 시 오버헤드 발생

 

상황별 추천

  1. 단순 Redis 사용 (캐싱, 기본 작업)
    • Jedis: 가볍고 심플한 프로젝트
    • Lettuce: 스프링 부트 기본, 높은 성능 필요한 경우
  2. 고성능 비동기 작업
    • Lettuce: 명확한 선택
  3. 분산 시스템, 복잡한 분산 처리
    • Redisson: 분산락, 분산 컬렉션 등 필요한 경우
  4. 대규모 고성능 시스템
    • Lettuce: 추천
    • 클러스터, 센티널 모드 지원 우수
  5. 마이크로서비스 아키텍처
    • Redisson: 분산 관련 기능 때문에 선호
  1.  

 

참고 : https://jojoldu.tistory.com/418

 

Jedis 보다 Lettuce 를 쓰자

Java의 Redis Client는 크게 2가지가 있습니다. Jedis Lettuce 둘 모두 몇천개의 Star를 가질만큼 유명한 오픈소스입니다. 이번 시간에는 둘 중 어떤것을 사용해야할지에 대해 성능 테스트 결과를 공유하

jojoldu.tistory.com

 

 


 

스프링 부트에서는 별도 설정 없이 RedisTemplate 사용 시 Lettuce가 기본으로 사용됩니다. 

 

 

 

왜 Lettuce가 기본 클라이언트인가?

  • 성능 이점
    • 논블로킹 I/O 모델 (Netty 기반)
    • 높은 처리량
    • 더 나은 리소스 활용
  • 기술적 장점
    • 스레드 세이프
    • 연결 풀링 기본 지원
    • 클러스터, 센티널 모드 우수한 지원

 


 

Redis에서 키와 값에 String을 사용하는 주요 이유

  • 범용성
    • 대부분의 데이터를 문자열로 직렬화 가능
    • 정수, UUID, JSON 등 다양한 형태의 데이터를 문자열로 변환 가능
  • 간단한 직렬화
// 예시
String lockValue = UUID.randomUUID().toString(); // UUID를 문자열로 
String userJson = objectMapper.writeValueAsString(user); // 객체를 JSON 문자열로
// JSON 직렬화
String value = objectMapper.writeValueAsString(complexObject);
// 역직렬화
ComplexObject obj = objectMapper.readValue(value, ComplexObject.class);

 

  • Redis의 기본 데이터 타입
    • Redis는 기본적으로 문자열을 가장 기본적인 데이터 타입으로 지원
    • 문자열은 가장 가볍고 빠른 연산 지원
  • 분산 락 구현에 적합
// 락 키: "reservation:lock:1" (좌석 번호)
// 락 값: UUID 문자열 
String lockKey = "reservation:lock:" + seatNum;
String lockValue = UUID.randomUUID().toString();

 

 


실제 구현

 

동작 시나리오:

  1. 스레드 A: 좌석 1에 대한 락 획득 시도
  2. 성공하면 UUID 값으로 락 생성
  3. 다른 스레드들은 이미 락 있어서 실패
  4. 스레드 A가 작업 완료 후 자신의 UUID로만 락 해제 가능

핵심:

  • 한 번에 하나의 스레드만 특정 자원(좌석)에 접근 가능
  • 타임아웃으로 데드락 방지
  • 오직 락을 건 주체만 락 해제 가능

 

RedisTemplate 사용 

 

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

/*        // 직렬화 설정 (선택사항)
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());*/

        return template;
    }
}

 

더보기

RedisConfig 클래스

 

Redis 연결 및 RedisTemplate 설정을 담당하는 구성 클래스입니다.

메서드

  1. RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory):
    • 역할: RedisTemplate을 생성하여 Redis와의 상호작용을 지원합니다.
    • 매개변수:
      • RedisConnectionFactory: Redis 연결 정보를 포함한 팩토리 객체.
    • 리턴값: RedisTemplate<String, String>.
    • 설명:
      1. RedisTemplate 인스턴스를 생성합니다.
      2. setConnectionFactory(connectionFactory)로 Redis 연결 팩토리를 설정.
      3. (선택사항) 직렬화 설정 가능:
        • RedisTemplate<String, String>: 이미 문자열 형식이므로 불필요
        • 필요할 때: JSON 저장 또는 키/값이 객체인 경우 등

 

@Repository
@RequiredArgsConstructor
public class LockRedisRepository {
    private final RedisTemplate<String, String> redisTemplate;

    // 락 획득 메서드
    public String acquireLock(String lockKey, long expireTimeMillis) {
        // 고유한 락 값 생성
        String lockValue = UUID.randomUUID().toString();

        // setIfAbsent() 메서드는 nx() 옵션과 동일
        // Duration을 사용해 만료 시간 설정
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(
                lockKey,
                lockValue,
                Duration.ofMillis(expireTimeMillis)
        );

        // 락 획득 성공 시 lockValue 반환
        return acquired != null && acquired ? lockValue : null;
    }

    // 락 해제 메서드
    public boolean releaseLock(String lockKey, String lockValue) {
        // Lua 스크립트로 원자적 락 해제 수행
        String luaScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "   return redis.call('del', KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";

        // RedisScript 생성
        RedisScript<Long> script = RedisScript.of(luaScript, Long.class);

        // 스크립트 실행
        Long result = redisTemplate.execute(
                script,
                Collections.singletonList(lockKey),
                lockValue
        );

        // 락 해제 성공 여부 반환
        return result != null && result > 0;
    }
}

 

더보기

 LockRedisRepository 클래스

 

필드

  • RedisTemplate<String, String> redisTemplate:
    • 역할: Redis와의 상호작용을 담당하는 Spring Data Redis의 핵심 컴포넌트입니다.
    • 타입: 키와 값을 모두 String으로 설정한 RedisTemplate.

메서드

  1. String acquireLock(String lockKey, long expireTimeMillis):
    • 역할: Redis에 지정된 키로 락을 생성합니다.
    • 매개변수:
      • lockKey: 락을 식별하는 Redis 키.
      • expireTimeMillis: 락의 만료 시간 (밀리초 단위).
    • 리턴값: 락 획득 성공 시 고유한 lockValue, 실패 시 null.
    • 설명:
      1. UUID.randomUUID().toString()으로 고유한 락 값을 생성.
      2. redisTemplate.opsForValue().setIfAbsent(...) 메서드를 호출:
        • NX 명령과 유사하게, 키가 없을 때만 값을 설정합니다.
        • Duration.ofMillis(expireTimeMillis)로 TTL(만료 시간)을 설정.
      3. 반환 값(Boolean)이 true면 락 획득 성공으로 lockValue 반환, 아니면 null.

  1. boolean releaseLock(String lockKey, String lockValue):
    • 역할: Redis에 저장된 락을 원자적으로 해제합니다.
    • 매개변수:
      • lockKey: 락을 식별하는 Redis 키.
      • lockValue: 락 생성 시 반환된 고유 값.
    • 리턴값: 락 해제 성공 여부 (true/false).
    • 설명:
      1. Lua 스크립트를 사용해 락 해제:
        • redis.call('get', KEYS[1]) == ARGV[1]로 현재 저장된 값이 lockValue와 같은지 확인.
        • 값이 같으면 redis.call('del', KEYS[1])로 키 삭제.
        • 다르면 0을 반환.
      2. RedisScript<Long>를 통해 스크립트를 실행:
        • redisTemplate.execute()로 Lua 스크립트를 실행.
        • 반환 값이 1 이상이면 락 해제 성공.
    • RedisTemplate을 사용하여 Redis에서 분산 락을 관리하는 저장소 클래스입니다.

 

 

"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"   return redis.call('del', KEYS[1]) " +
"else " +
"   return 0 " +
"end"

 

더보기

이 스크립트의 의미:

  • 현재 락의 값(redis.call('get', KEYS[1]))이
  • 우리가 전달한 락 값(ARGV[1])과 일치하면
  • 해당 키를 삭제(redis.call('del', KEYS[1]))
  • 일치하지 않으면 0을 반환

목적: 락을 생성한 주체만 락을 해제할 수 있게 하는 안전한 메커니즘


 

Redis Client 사용

 

@Configuration
public class RedisConfig {
    @Bean // redis 에 연결하기 위한 객체 생성
    public RedisClient redisClient() {
        return RedisClient.create("redis://localhost:6379"); // Redis URL 설정
    }

    @Bean // redis 서버와의 상태 기반 연결,  Stateful (상태유지). 클라이언트-서버 관계에서 서버가 클라이언트의 상태를 보존함을 의미
    public StatefulRedisConnection<String, String> redisConnection(RedisClient redisClient) {
        return redisClient.connect();
    }

    @Bean // Redis 서버에서 동기식 명령을 수행하기 위한 인터페이스를 제공
    public RedisCommands<String, String> redisCommands(StatefulRedisConnection<String, String> connection) {
        return connection.sync(); // 동기식 명령 실행 객체를 반환
    }

    @Bean // RedisLock 객체를 생성하여 Redis를 활용한 분산 락 구현
    public RedisLock redisLock(RedisCommands<String, String> redisCommands) {
        return new RedisLock(redisCommands);
    }
}

 

더보기

RedisConfig 클래스

  • 역할: Spring Bean을 등록하여 Redis 연결과 관련된 의존성을 설정하는 구성 클래스입니다.

필드 및 메서드 설명:

  1. RedisClient redisClient():
    • 역할: Redis에 연결하기 위한 RedisClient 객체를 생성합니다.
    • 매개변수: 없음.
    • 리턴값: RedisClient 객체.
    • 설명: RedisClient.create("redis://localhost:6379")는 Redis 서버 URL을 기반으로 클라이언트를 초기화합니다.
  2. StatefulRedisConnection<String, String> redisConnection(RedisClient redisClient):
    • 역할: Redis 서버와의 상태 기반 연결을 생성합니다.
    • 매개변수: RedisClient (위에서 생성된 Redis 클라이언트).
    • 리턴값: StatefulRedisConnection<String, String>.
    • 설명: connect() 메서드를 호출해 Redis 서버에 연결합니다.
  3. RedisCommands<String, String> redisCommands(StatefulRedisConnection<String, String> connection):
    • 역할: Redis 서버에서 동기식 명령을 수행하기 위한 인터페이스를 제공합니다.
    • 매개변수: StatefulRedisConnection.
    • 리턴값: RedisCommands<String, String>.
    • 설명: connection.sync()를 호출해 동기식 명령 실행 객체를 반환합니다.
  4. RedisLock redisLock(RedisCommands<String, String> redisCommands):
    • 역할: RedisLock 객체를 생성하여 Redis를 활용한 분산 락 구현을 제공합니다.
    • 매개변수: RedisCommands<String, String>.
    • 리턴값: RedisLock.
    • 설명: Redis 기반의 락을 관리하는 RedisLock 클래스를 Bean으로 등록합니다.

 

public class RedisLock {
    private final RedisCommands<String, String> redisCommands;

    public RedisLock(RedisCommands<String, String> redisCommands) {
        this.redisCommands = redisCommands;
    }

    public String acquireLock(String lockKey, long expireTimeMillis) {
        String lockValue = UUID.randomUUID().toString(); // 고유 값 생성
        String result = redisCommands.set(
                lockKey,
                lockValue,
                SetArgs.Builder.nx().px(expireTimeMillis)
        );
        return "OK".equals(result) ? lockValue : null; // 성공 시 lockValue 반환
    }

    public boolean releaseLock(String lockKey, String lockValue) {
        String luaScript =
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del', KEYS[1]) " +
                        "else return 0 end";

        Long result = redisCommands.eval(luaScript, ScriptOutputType.INTEGER, new String[]{lockKey}, lockValue);
        return result != null && result > 0;
    }
}

 

더보기

RedisLock 클래스

  • 역할: Redis를 이용한 분산 락을 구현하여 락의 획득과 해제를 관리합니다.

필드 및 메서드 설명:

  1. 필드:
    • RedisCommands<String, String> redisCommands:
      • 역할: Redis 서버와 통신하여 명령을 실행하는 객체.
      • 초기화: 생성자 주입으로 설정됨.
  2. String acquireLock(String lockKey, long expireTimeMillis):
    • 역할: 주어진 키로 락을 획득합니다.
    • 매개변수:
      • lockKey: 락을 나타내는 키.
      • expireTimeMillis: 락의 유효 기간(밀리초 단위).
    • 리턴값: 성공 시 고유 lockValue 문자열, 실패 시 null.
    • 설명:
      • UUID.randomUUID().toString()으로 고유 값을 생성.
      • redisCommands.set(lockKey, lockValue, SetArgs.Builder.nx().px(expireTimeMillis))를 호출해 Redis에 키-값과 유효 기간을 설정.
      • NX: 키가 없을 때만 설정, PX: 만료 시간 설정.
  3. boolean releaseLock(String lockKey, String lockValue):
    • 역할: 주어진 키와 값으로 락을 해제합니다.
    • 매개변수:
      • lockKey: 락을 나타내는 키.
      • lockValue: 락 획득 시 반환된 고유 값.
    • 리턴값: 락 해제 성공 여부 (true/false).
    • 설명:
      • Lua 스크립트를 사용해 원자적으로 락을 해제합니다.
      • 스크립트:
        1. redis.call('get', KEYS[1]) == ARGV[1]: 저장된 값이 lockValue와 같은지 확인.
        2. 같으면 redis.call('del', KEYS[1])로 키 삭제.
      • 원자성을 보장하기 위해 Redis의 eval 메서드를 사용.

 

@Service
@RequiredArgsConstructor  // final 이 붙은 필드만 생성자로 만들어줌.
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final EventRepository eventRepository;
    private final RedisLock redisLock;
    private final String lockKeyPrefix = "reservation:lock:";

    public String reserve(User user, Long eventId, ReservationRequestDTO requestDTO) {
        Long seatNum = requestDTO.getSeatNum();
        String lockKey = lockKeyPrefix + seatNum;
        String lockValue = redisLock.acquireLock(lockKey, 5000); // 5초 유효기간

        if (reservationRepository.existsBySeatNum(seatNum)) {
            throw new IllegalArgumentException("해당 좌석은 예매가 완료되었습니다.");
        }

        if (lockValue == null) {
            throw new IllegalStateException("현재 다른 사용자가 해당 좌석을 예약 중입니다. 다시 시도하세요.");
        }

        Event event = eventRepository.findById(eventId).orElseThrow(
                () -> new IllegalArgumentException("해당 콘서트 정보가 존재하지 않습니다")
        );

        try {
            Reservation reservation = new Reservation(seatNum, user, event);
            reservationRepository.save(reservation);

            return "예매 성공";
        } finally {
            // 락 해제
            redisLock.releaseLock(lockKey, lockValue);
        }
    }
}

// 락을 획득하지 못했을 때의 동작 방식은 정하기 나름,
// 예를 들어 대기, 재시도, 포기 등

 

'기타' 카테고리의 다른 글

WSL, Git bash, Terminal  (0) 2024.12.17
InfluxDB, grafana  (0) 2024.12.17
[개인과제] 플러스 과제 - 회고  (1) 2024.11.21
백엔드 개발 첫 팀프로젝트 회고.....  (2) 2024.10.24
1주차 팀 소개 페이지 프로젝트 KPT  (0) 2024.08.30