Coding/Server

[내일배움캠프] Redis와 분산락

kangplay 2025. 5. 1. 19:35

1. Redis란? 

1-1. 3가지 키워드

  • Key:Value 구조의 데이터를 저장하기 위한 비관계형 데이터베이스(NoSQL)
  • Disk가 아닌 Memory에 데이터를 저장하는 In-memory 방식
  • Single-Thread로 동작한다. (순차적)

1-2. 다양한 자료 구조

Redis는 key는 string으로 고정되지만, value에는 다양한 자료 구조를 지원한다. 

  • String: 기본적인 get, set 외에도 mset, mget, setnx 등의 커맨드도 같이 기억하자. (있으면 ~ 없으면 ~)
  • List: 대기열 구현, 최근 방문글 구현 등 다양한 활용이 가능하다. 
  • Set: smembers(전체조회), sismember(포함여부확인), sinter(교집합) 등의 명령어를 지원한다.
  • ** Hash: Value 안에 다시 key:value 를 저장할 수 있는 자료구조이다. 굉장히 빠르고 메모리 효율적이라, 조회 성능을 극대화할 때 많이 사용한다. (코인 시세정보 저장 등)
  • Sorted Set: 구성 요소는 (member, score) 쌍으로 이루어지고, score를 기준으로 정렬된다. 순위를 나타내는 리더보드 구현, 많이 본 주식 구현 등에 활용해볼 수 있다. 

이외에도 다양한 자료구조들이 제공되므로, 꼭 공식문서를 보자.

1-3. 활용 방안

  • 원격 Cache 저장소로써 활용
  • Scale-out 상황에서 중앙 저장소로 활용
    • 세션, Spring scheduler, Web socket .. 등
  • 데이터 저장 목적 
    • Single Thread로 동작하기 때문에 Race Condition 이슈에서 자유롭고 빠르다.
    • 코인시세 수집(3초마다) 기능은 API 서버와 다르게 트래픽과 연관이 없고, 영구적으로 저장될 필요가 없다. 따라서 이런 경우에 데이터 저장 목적으로 사용될 수 있다.
  • 데이터 전송 목적
    • Redis는 Kafka와 유사한 Pub/Sub 기능을 제공해준다. 이를 이용해 Event Driven Architecture를 구현하는 등의 활용이 가능하다.
  • 동시성 문제 해결을 위한 활용
    • Single Thread 기반으로 동작하는 Redis를 활용해 Lock을 구현하면 쉽게 해결할 수 있다. 
    • Redis 명령어 자체는 원자적이지만 스프링에서 여러 명령어를 연달아 실행하면 다른 스레드가 끼어들 수 있다.

1-4. Redis In Spring Boot

항목 RedisTemplate Spring Data Redis Lettuce Redisson
역할 Redis API 제공
(Spring Data Redis에 포함된 클래스)
Spring 연동 지원 저수준 클라이언트 고수준 Redis 기능 클라이언트
제공 주체 Spring Spring 독립(Lettuce) 독립(Redisson)
동기/비동기 동기 동기/비동기 비동기 가능 동기/비동기
스레드 세이프 O (Lettuce 사용 시) O O O
분산 락 지원
추천 용도 기본적인 Redis 연산 스프링 환경에서 자동 설정 빠르고 가벼운 연산 분산 락, 큐, 세마포어 등
  • 단순 캐시, 세션 공유, 최근 본 글 등 기본 Redis 기능 : RedisTemplate + Spring Data Redis + Lettuce
  • 분산 락, 분산 캐시, 분산 큐, 세마포어 필요 : Redisson

1-5. 꼭 기억해두어야 할 것

기술 중심의 사고가 아닌 문제 중심의 사고를 할 수 있어야 한다!

  • 나는 어떤 문제를 가지고 있고, Redis를 통해 어떻게 이 문제를 해결하고자 하는걸까?
  • 이 문제를 해결하는 근본적인 해결책은 --- 인데, 꼭 Redis만이 해결 방법일까?
  • 똑같은 문제를 해결하는 다양한 방법이 있다면, 왜 그 중에 Redis를 선택했을까?
    • Only 기술적인 장단점 x -> 현재 주어진 상황 + 요구사항을 꼭 함께 고려!

즉, 왜 Redis를 사용했는지? 왜 해당 자료 구조를 사용했는지? 에 대한 타당한 근거가 필요하다!

2. 분산락

2-1. 분산락이란?

분산락이란 여러 서버(혹은 프로세스)가 공유하는 데이터를 제어하기 위한 기술로, 락을 획득한 프로세스 혹은 스레드만이 공유 자원에 접근할 수 있도록 하는 것이다.

즉, 공유 자원 자체에 락을 거는 것이 아니라, 어떤 행위가 발생하는 임계 구역에 락을 거는 것이다. 

2-2. 락(lock) 전략 정리 – Synchronized부터 분산 락까지

1. Synchronized: 단일 서버에서는 OK, 그 이상은 NO

synchronized는 JVM 내에서만 유효한 락이다. 하나의 인스턴스(서버) 안에서 실행 중인 여러 스레드의 동시 실행을 막는 데에는 효과적이지만, 서버가 2대 이상으로 늘어나는 순간 더 이상 유효하지 않다. 각 인스턴스는 서로 다른 JVM이기 때문에, synchronized 락은 서버 간 공유되지 않기 때문이다.

 

또한, @Transactional과 synchronized를 함께 사용할 경우에도 주의가 필요하다. @Transactional은 스프링 프록시 기반으로 작동하며, 커밋 시점은 메서드 리턴 이후입니다. 그런데 synchronized는 메서드 실행 중에는 동기화를 보장하지만, 트랜잭션 커밋이 되기 전에 락이 풀릴 수 있어 동시성 문제가 발생할 수 있다.

 

2. 비관적/낙관적 락의 공통적인 한계

비관적 락과 낙관적 락 모두 DB 레벨에서 작동한다. 멀티 서버 인스턴스 환경에서 여러 요청이 하나의 DB에 몰리면, DB 부하가 커지고 잠금 경합이 발생하게 된다. 특히 정산, 쿠폰 발급, 선착순 처리처럼 동시 접근이 많고 민감한 작업은 DB 성능 병목의 원인이 될 수 있습니다.

 

3. Redis 분산 락: 멀티 인스턴스 환경에 최적화

Redis 기반의 분산 락은 DB row가 아니라 "접근 자체"를 제어합니다. 즉, 하나의 자원을 처리하는 동안 다른 인스턴스가 해당 자원에 접근하는 것을 사전에 막아, DB의 부담을 덜 수 있다. 이 방식은 선점 기반 락으로, 낙관적 락처럼 충돌을 감지하는 것이 아니라 애초에 작업 진입 자체를 제어하기 때문에 훨씬 예측 가능한 동작을 보장한다. 즉, Redis를 사용한 분산락은 트랜잭션이 여러 개가 확 덤벼들었을 때, 처리하는 구조 자체가 분산 환경에서 이점이 많다. 

 

또한 Redis는 pub/sub, TTL, Lua 스크립트 기반의 원자성 보장 등 부가 기능도 다양하여, 이벤트 기반 확장이나 장애 대응이 용이하다.

 

4. 분산 락의 단점: 외부 시스템 의존

단점도 분명히 있다. Redis는 별도의 외부 시스템이기 때문에, Redis 자체가 죽으면 락이 작동하지 않으며 잘못 구성된 TTL이나 재시도 로직은 교착 상태(deadlock)를 초래할 수 있습니다. 따라서, 단일 서버인 경우 @Transactional, 낙관적 락, 비관적 락이 더 효율적인 방법일 수 있다. 

2-2. Redis 분산락 사용과 트랜잭션

@Transactional은 트랜잭션 커밋이 메서드 리턴 직후 일어나기 때문에, finally 블록에서 락을 먼저 해제해버리면 아직 커밋이 안 된 상태에서 다른 인스턴스가 진입할 수 있다.

public void decreaseStockWithLock() {
    RLock lock = redissonClient.getLock("stock:lock");

    try {
        lock.lock(); // 락 획득
        decreaseStock(); // DB 재고 감소 (@Transactional)
    } finally {
        lock.unlock(); // ⚠️ 락이 커밋 전에 풀림!
    }
}

스프링은 트랜잭션의 commite 이후 실행할 작업을 등록할 수 있는 콜백을 제공한다: TransactionSynchronizationManager 

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
    @Override
    public void afterCommit() {
        lock.unlock(); // 트랜잭션 커밋 이후에 락 해제
    }
});

2-3. Redis의 Lua Script

Redis는 여러 명령을 따로 보내면 원자성(atomic)을 보장하지 않기 때문에, TTL이 만료된 이후 락을 잡은 다른 인스턴스를 첫 인스턴스가 모르고 해제할 때 문제가 생길 수 있다. 

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

→ 위와 같이 Lua Script를 작성하면, get + del을 한 번에 실행하므로 race condition이 없음

 

Redisson을 쓰고 있다면 내부적으로 Lua 스크립트 기반의 안전한 락 로직이 이미 구현되어 있어서 직접 Lua를 다룰 필요가 없고, 동시성과 원자성을 신경 쓰지 않고도 안정적인 분산 락을 사용할 수 있다.

2-4. Redis와 ObjectMapper

Redis는 기본적으로 문자열(String), 바이트 배열(byte[]), 숫자, List/Set 같은 단순한 타입만 저장 가능하다. 따라서 자바 객체를 저장하려면 직렬화(serialize)가 필요하고, 다시 꺼내올 땐 역직렬화(deserialize) 해야한다. 

ObjectMapper는 자바에서 JSON을 다룰 때 거의 표준처럼 쓰이는 Jackson 라이브러리의 핵심 클래스이다. writeValueAsString(obj)를 통해 Java 객체를 JSON 문자열로 직렬화하고, readValue(json, Class<T>)를 통해 JSON 문자열을 Java 객체로 역직렬화한다. 또한, 다양한 커스터마이징을 지원하여 날짜 포맷, null 처리, 필드 네이밍 전략 등에도 사용된다.

 

매번 ObjectMapper를 이용해 Redis에서 데이터를 직렬화, 역직렬화할 수 있다.

// 직접 JSON 직렬화해서 저장
String json = objectMapper.writeValueAsString(user);
redisTemplate.opsForValue().set("user:1", json);

// 꺼낼 때도 직접 역직렬화
String jsonFromRedis = redisTemplate.opsForValue().get("user:1");
User user = objectMapper.readValue(jsonFromRedis, User.class);

하지만, 귀찮고, 실수할 가능성도 있기 때문에 (클래스 타입 실수, 누락 등) 다음과 같은 클래스를 제공한다. 

  • RedisTemplate: RedisTemplate의 직렬화기 (내부에 Jackson2JsonRedisSerializer가 들어있음)
  • Redisson: Codec 설정 (config.setCodec(new JsonJacksonCodec());)