코드는 깃허브에 있습니다.

 

서론

요구사항에 따라 로직을 구현하다 보면, 종종 최대 구매 수량 제한과 같이 동시성 이슈에 민감한 사항들이 있습니다. 애플리케이션 단에서도 동시성을 제어할 순 있지만, 분산 환경에서는 제약적인 방법이죠.

이럴 때 레디스를 고려하면 좋은 선택이 될 수 있습니다. 레디스는 인메모리 기반으로 동작하기 때문에 성능이 매우 빠르고, 또한 싱글 쓰레드 기반으로 동작하기 때문입니다.

 

이번 포스팅에서는 레디스의 incr와 decr를 활용해서 동시성 이슈가 발생하지 않는지 테스트 해보려고 합니다. 실제 작성할 코드와 유사한 형태로 테스트를 진행해보려고 해요.

 

 

코드로 바로 넘어가기 전에! 단점을 먼저 파악해보면 좋을 것 같습니다.

보통 운영 환경이라면 레디스가 클러스터 구조로 이루어져 있을 텐데요. 이 경우 아래와 같은 상황에서 write 손실이 발생할 수 있습니다. 여기서 write는 incr와 decr가 될 수 있겠죠.

  • write가 마스터에 도달하고 정상 응답을 반환하였으나, 슬레이브로 write가 전파되기 전에 다운된 경우.
  • 파티션으로 인해 마스터로 연결할 수 없는 경우
  • ...

이를 위해 분산락이라는 기능이 존재하는데요. 간략하게 말하자면, 3대 이상의 마스터 중에서 과반수 이상이 락이 걸려야 실질적으로 락이 걸렸다고 판단하는 기능이라고 이해하면 좋을 것 같습니다. 위에 서술한 단점을 효과적으로 방어할 수 있지만, 각각의 마스터가 시간이 서로 틀어질 수 있다는 한계점이 있더라고요.

 

아무튼 이런 기능이 있음에도 다음의 이유로 incr, decr를 사용하려고 합니다.

  • 분산락을 위해 마스터를 3대 이상으로 증설하는 건 리소스 측면에서 부담스럽다.
  • 분산락 자체도 한계점이 있긴 하다.
  • 분산락으로 인한 성능 저하가 우려된다.
  • 사용 사례를 보았을 때 크게 문제 된 경우는 없었던 것 같다.

 

서론이 길었습니다. 바로 코드를 확인해 보죠

 

레디스를 활용한 동시성 테스트

1. 환경 세팅

build.gradle.kts

    // redis
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    testImplementation("com.github.codemonstur:embedded-redis:1.4.3")

    // test
    testImplementation("io.kotest:kotest-runner-junit5:4.4.3")
    testImplementation("io.kotest:kotest-assertions-core:4.4.3")
    implementation("io.kotest:kotest-extensions-spring:4.4.3")

 

로컬이나 도커에 레디스를 띄워서 테스트를 하는 건 개인적으로 선호하지 않기 때문에, embedded redis 의존성을 추가해 주었습니다.

test 관련 의존성은, 코틀린 환경에서 테스트 코드를 직관적으로 작성하기 위한 kotest 의존성을 추가해 주었습니다.

 

TestRedisConfiguration.kt

@TestConfiguration
class TestRedisConfiguration {

    lateinit var redisServer: RedisServer
    private val testRedisHost = "127.0.0.1"
    private val testRedisPort = 6370

    companion object {
        const val DEFAULT_DATABASE = 0
    }

    @PostConstruct
    fun postConstruct() {
        println("embedded redis start!")
        redisServer = RedisServer(testRedisPort)
        redisServer.start()
    }

    @PreDestroy
    fun preDestroy() {
        println("embedded redis stop!")
        redisServer.stop()
    }

    fun lettuceConnectionFactory(database: Int): LettuceConnectionFactory {
        val redisConfig = RedisStandaloneConfiguration().apply {
            this.hostName = testRedisHost
            this.port = testRedisPort
            this.database = database
        }

        return LettuceConnectionFactory(redisConfig).apply {
            this.afterPropertiesSet()
        }
    }

    @Bean
    fun testStringRedisTemplate(): StringRedisTemplate {
        return StringRedisTemplate().apply {
            this.setConnectionFactory(lettuceConnectionFactory(DEFAULT_DATABASE))
            this.afterPropertiesSet()
        }
    }
}

 

Jedis 대신 Lettuce를 사용해 주었습니다. 그 이유는 Lettuce vs Jedis 글을 참고하면 좋을 것 같아요.

@PostConstruct와 @PreDestroy를 사용하여 레디스 실행과 중단을 제어해 주었습니다.

 

ConcurrentTaskExecutor.kt

@Component
class ConcurrentTaskExecutor {
    fun runConcurrentTasks(numberOfThreads: Int, repeatCountPerThread: Int, task: () -> Unit) {
        val startLatch = CountDownLatch(1) // 모든 쓰레드가 준비될 때까지 대기시키기 위한 용도
        val doneLatch = CountDownLatch(numberOfThreads)

        val executor = Executors.newFixedThreadPool(numberOfThreads)

        repeat(numberOfThreads) {
            executor.submit() {
                try {
                    startLatch.await() // 모든 스레드가 준비될 때까지 대기
                    repeat(repeatCountPerThread) {
                        task()
                    }
                } finally {
                    doneLatch.countDown() // 작업 완료 처리
                }
            }
        }

        startLatch.countDown() // 모든 스레드가 동시에 작업을 시작하도록 함
        doneLatch.await() // 모든 스레드가 작업을 마칠 때까지 대기

        executor.shutdown()
        executor.awaitTermination(1, TimeUnit.MINUTES)
    }
}

멀티 쓰레드 환경으로 task를 실행해 주는 로직입니다. 모든 쓰레드를 동시에 실행시키기 위해 CountDownLatch로 대기 시간을 확보한 게 포인트입니다. 단순 for문으로 실행시키면, 마지막 쓰레드가 작업을 실행하려 할 때 이미 첫 번째 쓰레드는 마쳤을 수 있기 때문이에요.

 

2. 테스트 코드

RedisConcurrencyTest.kt

@SpringBootTest(classes = [TestRedisConfiguration::class])
class RedisConcurrencyTest {
    @Autowired
    private lateinit var stringRedisTemplate: StringRedisTemplate

    @Autowired
    private lateinit var concurrentTaskExecutor: ConcurrentTaskExecutor

    private val numberOfThreads = 10
    private val repeatCountPerThread = 1000

    @Test
    fun `증감 연산자만을 사용하면 동시성 이슈가 발생한다`() {
        // given
        var count = 0

        // when
        concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
            count++
        }

        // then
        count shouldNotBe numberOfThreads * repeatCountPerThread
        println(count)
    }

    @Test
    fun `레디스 increment를 사용하면 동시성 이슈가 발생하지 않는다`() {
        // given
        val incrTestKey = "incr-test-key"
        stringRedisTemplate.delete(incrTestKey)

        // when
        concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
            stringRedisTemplate.opsForValue().increment(incrTestKey)
        }

        // then
        val count = stringRedisTemplate.opsForValue().get(incrTestKey)!!.toInt()
        count shouldBe numberOfThreads * repeatCountPerThread
    }

    @Test
    fun `레디스 decrement를 사용하면 동시성 이슈가 발생하지 않는다`() {
        // given
        val decrTestKey = "decr-test-key"
        stringRedisTemplate.delete(decrTestKey)

        // when
        concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
            stringRedisTemplate.opsForValue().decrement(decrTestKey)
        }

        // then
        val count = stringRedisTemplate.opsForValue().get(decrTestKey)!!.toInt()
        count shouldBe -(numberOfThreads * repeatCountPerThread)
    }

    @Test
    fun `레디스 incr와 decr를 사용하여 로직을 구현하면 동시성 이슈가 발생하지 않는다`() {
        // given
        val incrDecrTestKey = "incr-decr-test-key"
        val maxCount = 100
        stringRedisTemplate.delete(incrDecrTestKey)

        // when
        concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
            val successIncrement = incrementIfPossible(incrDecrTestKey, maxCount) // 실제 사용할 로직과 동일한 구조로 실행

            if (successIncrement) {
                Thread.sleep(Random.nextLong(0, 300)) // 0 ~ 300ms 사이의 랜덤한 시간을 멈춤. 실제 로직이 들어갈 자리
            }
        }

        // then
        val currentCount = stringRedisTemplate.opsForValue().get(incrDecrTestKey)!!.toInt()
        currentCount shouldBe maxCount
    }

    private fun incrementIfPossible(key: String, maxCount: Int): Boolean {
        val incrementedCount = stringRedisTemplate.opsForValue().increment(key)!!.toInt()

        if (incrementedCount > maxCount) {
            decrementCount(key)
            return false
        }

        return true
    }

    private fun decrementCount(key: String) {
        stringRedisTemplate.opsForValue().decrement(key)
    }

 

마지막 테스트 코드에서 실제 작성하려는 코드와 유사하게 구현하였는데요. 10개의 쓰레드가 1000번씩 수행하더라도, maxCount를 넘지 않은 것을 확인할 수 있었습니다.

 

 

참고자료

'데이터베이스 > 레디스' 카테고리의 다른 글

Lettuce vs Jedis  (1) 2023.12.03
Spring Data Redis 공식문서 읽어보기  (2) 2023.12.03
레디스 사용 시 주의사항  (0) 2023.10.29
레디스란  (0) 2023.10.25

Lettuce란?

Lettuce란, 동기와 비동기 통신을 모두 지원하는 논블로킹 레디스 자바 클라이언트이다.

 

 

Jedis란?

Jedis란 사용 편의성을 위해 설계된 레디스 자바 클라이언트이다. 동기식으로만 작동하며, 다른 Redis 클라이언트에 비해 가볍다는 특징을 가지고 있다.

 

 

Lettuce vs Jedis

  • 성능 : Lettuce는 동기와 비동기 통신 모두를 지원하지만, Jedis는 동기식 통신만 지원한다. 따라서 대량의 요청과 응답 처리에 있어서, Lettuce가 더욱 유리하다. TPS, CPU, Connection 수, 응답속도 등 모든 면에서 Lettuce가 우위에 있다.
  • thread safe : Lettuce는 thread safe 하기 때문에, 멀티 쓰레드 환경에서 단일 Lettuce 인스턴스를 사용해도 문제없다. 하지만 Jedis는 thread safe 하지 않기 때문에, 여러 쓰레드 간에 단일 Jedis 인스턴스를 공유하지 말아야 한다. 
    • Jedis를 멀티 쓰레드 환경에서 사용하려면 커넥션 풀을 사용해야 한다. 하지만 1) 레디스 자체의 커넥션 수에 제한이 걸리거나 2) 합리적인 커넥션 수를 초과하지 않기 위해, 커넥션 수를 제한해야 하는 상황이 있다는 점을 유의해야 한다.

 

즉, 성능과 쓰레드 안정성의 측면을 고려하면 Lettuce가 더 나은 선택이다.

 

 

참고자료

목차

더보기
  1. 스프링 데이터 레디스란
    1. 학습 순서
      1. 스프링 학습
      2. NoSQL과 Key Value 저장소 학습
  2. why spring data redis
  3. Redis Support
    1. Redis Support High-level View
    2. 레디스와 연결
    3. RedisConnection and RedisConnection Factory
    4. Working with Objects through RedisTemplate
    5. Serializers
  4. Redis Repositories
    1. 사용 예시
    2. 객체 매핑 기초 - 추후 다시보자
      1. 권장 사용 방법
      2. 코틀린 지원
        1. 코틀린 객체 생성
        2. 코틀린 데이터 클래스의 프로퍼티 채우기
    3. Object to Hash 매핑
    4. keyspaces
    5. 보조 인덱스
      1. 간단한 인덱스
      2. geospatial 인덱스
    6. Query by Example
    7. Time to Live
    8. Persisting References
    9. Persisting Partial Updates
    10. Queris and Query Method
    11. Redis Repositories Running on a Cluster
    12. CDI Integration

 

1. 스프링 데이터 레디스란

스프링 데이터 레디스란, key-value 저장소를 사용한 솔루션 개발에 핵심 스프링 개념을 적용한 프로젝트이다. 메세지 전송과 수신을 위해 추상화된 템플릿을 제공한다.

 

1.1 학습 순서

1.1.1 스프링 학습

스프링 데이터를 스프링의 핵심 기능을 사용하기 때문에 최소한 IoC에 익숙해야 한다.

 

레디스 지원의 핵셤 기능은, 스프링 컨테이너의 도움 없이 직접 호출이 가능하다는 점이다. 이는 Spring 컨테이너의 다른 서비스 없이 독립형으로 사용할 수 있는 JdbcTemplate과 매우 유사하다.

 

repository 지원과 같은 스프링 데이터 레디스의 모든 기능을 활용하기 위해서는, 스프링을 사용하도록 라이브러리 일부를 구성해야 한다.

 

1.1.2 NoSQL과 key value 저장소 학습

 

2. why spring data redis

스프링 데이터 레디스를 사용하면 레디스 key-value 저장소를 사용한 스프링 프로젝트를 쉽게 구축할 수 있다. 스프링 인프라 지원을 통해, 저장소와 상호작용하기 위한 보일러 플레이트 코드와 불필요한 작업들을 제거할 수 있다.

 

3. Redis support

스프링 데이터가 지원하는 Key-value 저장소 중에 하나는 레디스이다. 스프링 레디스 데이터는 스프링 애플리케이션에서 레디스로의 액세스와 간단한 설정을 제공한다. 저장소 접근을 위해 저수준과 고수준 추상화를 모두 제공한다. 따라서 사용자는 인프라 우려로부터 자유로울 수 있다.

 

3.1 Redis Support High-level View

redis support는 여러 구성 요소를 제공한다. 대부분의 작업에서는 고수준의 추상화와 지원 서비스를 사용하는게 최선의 선택이다. 물론, 필요하다면 레디스와 직접 통신할 수 있는 저수준 커넥션(또는 네이티브 라이브러리)을 얻을 수 있다.

 

3.2 레디스와 연결

레디스와 스프링을 사용하기 위해서는 먼저 IoC 컨테이너를 통해 저장소와 연결해야 한다. 이를 자바 커넥터나 바인딩이 필요하다. 어떤 라이브러리를 선택하든 하나의 spring data redis api를 사용하면 된다. : redis.connection 하위의 RedisConnection와 RedisConnectionFactory 인터페이스.

 

3.3 RedisConnection and RedisConnectionFactory

RedisConnection 가 제공하는 핵심 빌딩 블록을 통해 레디스와 통신할 수 있다. 또한, 기본 연결 라이브러리 예외를 스프링 dao 예외로 변환해 준다.

 

참고로 네이티브 라이브러리 api가 필요하다면 레디스 커넥션이 제공하는 getNativeConnection 메서드를 사용하면 된다. 그러면 통신에 사용되는 원 기반 객체를 반환받을 수 있다.

 

활성 RedisConnection 객체는 RedisConnectionFactory를 통해 생성된다. 또한 RedisConnectionFactory는 PersistenceExceptionTranslator로써 작동하기도 한다. 그러므로 @Repository와 AOP 사용을 통해 예외 자동 변환이 가능하다.

 

참고로 설정에 따라, 커넥션 팩토리는 새로운 커넥션을 반환하거나 기존 커넥션을 반환하게 할 수 있다.

 

RedisConnectionFactory로 작업하는 가장 쉬운 방법은, IoC 컨테이너를 통해 적절한 커넥터를 구성하고 이를 사용 클래스에 주입하는 것이다.

 

현재 모든 커넥터들이 레디스의 모든 기능을 제공하고 있지는 않다. 지원하지 않는 기능을 호출하면 UnsupportedOperationException 예외를 던진다. 대표적인 커넥터는 Jedis와 Lettuce가 있으며, 두 커넥터가 지원하는 기능은 해당 문서를 참고한다.

 

3.4 Working with Objects through RedisTemplate

대부분의 사용자들은 RedisTemplate과 해당 패키지인 org.springframework.data.redis.core 사용하려 할 것이다. 실제로, 해당 템플릿은 풍부한 기능을 제공하기 때문에 Redis 모듈의 중심 클래스이다.

RedisConnection은 이진값을 허용하고 반환하는 저수준 메서드를 제공한다. 반면에, 레디스 템플릿은 레디스 상호작용을 위한 고수준의 추상화를 제공한다. 직렬화 및 연결 관리를 처리해 주기 때문에 사용하는 이런 세부 사항을 다루지 않아도 된다. 또한 템플릿은 SetOperations나 BoundGeoOperations와 같이, 특정 유형이나 키에 대한 작업에 대해 풍부한 기능을 제공한다.

 

일단 템플릿을 구성해 두면, thread-safe 하면서 여러 인스턴스에서 재사용될 수 있다.

 

레디스 템플릿은 대부분의 작업에서, 자바 기반의 serializer를 사용한다. 즉, 템플릿에서 쓰거나 읽는 모든 객체가 자바를 통해 직렬화 및 역직렬화됨을 의미한다. 물론 템플릿에서 직렬화 메커니즘을 변경할 수 있다.

 

3.5 String-focused Convenience Classes

보통 레디스에 저장된 키와 값은 String인 경우가 많다. 따라서 레디스 모듈은 string에 특화된 레디스 커넥션(StringRedisConnection)과 레디스 템플릿(StringRedisTemplate)을 제공한다.

String 키를 바운딩하는 것 외에도, 템플릿과 커넥션은 StringRedisSerializer를 기반으로 사용한다. 이는 저장된 키와 값을 사람이 읽을 수 있음을 의미한다.(레디스와 코드에서 동일한 인코딩을 사용한다는 가정)

 

다른 스프링 템플릿과 마찬가지로, 레디스 템플릿과 스프링 레디스 템플릿 모두 RedisCallback 인터페이스를 통해 레디스와 직접 통신이 가능하다. 이 기능은 레디스 커넥션과 직접 통신하므로 완전한 제어를 제공한다.

public void useCallback() {

  redisTemplate.execute(new RedisCallback<Object>() {
    public Object doInRedis(RedisConnection connection) throws DataAccessException {
      Long size = connection.dbSize();
      // Can cast to StringRedisConnection if using a StringRedisTemplate
      ((StringRedisConnection)connection).set("key", "value");
    }
   });
}

3.6 Serializers

프레임워크 관점에서, 레디스에 저장되는 데이터는 오직 바이트뿐이다. 레디스는 다양한 타입을 제공하지만, 대부분의 경우 데이터가 표현되는 것보다는 저장되는 방식을 나타낸다. 사용자는 정보가 문자열이나 다른 객체로 변환되는지 여부를 결정할 수 있다.

 

org.springframework.data.redis.serializer 패키지에는 두 유형의 serializers가 있다.

  1. RedisSerializer를 기반으로 하는 양방향 serializers - 주로 byte[]로 직렬화한다.
  2. element writers(RedisElementWriter)와 element readers(RedisElementReader) - byteBuffer를 사용한다.

 

serializers은 아래와 같이 다양한 종류가 있다.

  • JdkSerializationRedisSerializer : RedisCache와 RedisTemplate의 기본 Serializer이다. 바이트 코드 그대로 보여진다.
  • StringRedisSerializer : String 타입만 저장할 수 있으며, 레디스에서도 문자열 그대로 보여진다.
  • Jackson2JsonRedisSerializer : object를 json 형태로 저장한다.

 

serializer 실습 코드

더보기

serializer 테스트 코드

@Autowired
    lateinit var redisTemplate: RedisTemplate<Any, Any>

    @BeforeEach
    fun init() {
        redisTemplate.keySerializer = StringRedisSerializer()
    }

    @Test
    fun contextLoads() {
    }

    @Test
    fun testJdkSerializationRedisSerializer() {
        // JdkSerializationRedisSerializer는 default다.
        val user: User = User("stella", 123, User.Level.GOLD)

        redisTemplate.opsForValue().set("key1", user)
    }

    @Test
    fun testStringRedisSerializer() {
        // JdkSerializationRedisSerializer는 default다.
        redisTemplate.valueSerializer = StringRedisSerializer()
        val user: User = User("stella", 123, User.Level.GOLD)

//        redisTemplate.opsForValue().set("key2", user) // user가 문자열이 아니라서 에러 발생
        redisTemplate.opsForValue().set("key2", "name : stella, id : 123, level : GOLD")
    }

    @Test
    fun testJackson2JsonRedisSerializer() {
        // JdkSerializationRedisSerializer는 default다.
        redisTemplate.valueSerializer = Jackson2JsonRedisSerializer(User::javaClass.javaClass)
        val user: User = User("stella", 123, User.Level.GOLD)

        redisTemplate.opsForValue().set("key3", user)
    }

 

테스트 결과(터미널)

  • JdkSerializationRedisSerializer(default)
  • StringRedisSerializer
  • testJackson2JsonRedisSerializer

 

tip

참고로 기본 Serializer인 JdkSerializationRedisSerializer를 그대로 사용하는 것은 지양하는 게 좋다. 애플리케이션의 역직렬화 단계에서 조작된 입력으로 인해 원하지 않는 코드가 실행될 수 있기 때문이다. 따라서 신뢰할 수 없는 환경에서는 직렬화를 사용하지 말고, JSON이나 다른 메세지 형식을 사용한다.

 

4. Redis Repositories

Redis Repository를 사용하면, 도메인 객체를 레디스 해시로 원활하게 전환 및 저장할 수 있다. 사용자 지정 매핑 전략을 적용할 수도 있고, 보조 인덱스도 사용할 수 있다.

 

참고로 Redis Repository는 레디스 서버 버전 2.8 이상이 필요하다. 그리고 트랜잭션과 함께 작동하지 않으므로, 트랙잭션 지원이 비활성화된 RedisTemplate을 사용해야 한다.

 

4.1 사용 예시

crud를 할 수 있는 Repository 인터페이스는 다음과 같다.

public interface PersonRepository extends CrudRepository<Person, String> {

}

스프링 데이터 레디스를 사용하면 아래와 같이 도메인 객체를 쉽게 해시로 변환할 수 있다.

@RedisHash("people")
public class Person {

  @Id String id;
  String firstname;
  String lastname;
  Address address;
}

식별자로 사용할 필드에 @Id를 붙여준다. 그러면 save를 할 때 id가 null가 새 id를 세팅해 주고, id 값이 세팅되어 있다면 해당 Id를 그대로 사용한다. 키는 people:5d67b7e1-8640-4475-beeb-c666fab4c0e5 형태를 띨 것이다.

 

4.2 객체 매핑 기초 - 추후 다시 보자

해당 섹션에서는 JPA와 같은 기본 데이터 저장소의 객체 매핑을 사용하지 않는 Spring Data 모듈에만 적용된다.

 

Spring Data 객체 매핑의 핵심 책임은 1) 도메인 객체 인스턴스를 생성하고, 2) 이를 저장소의 데이터 구조에 매핑하는 것이다. 즉, 다음의 두 가지 단계가 필요하다.

  1. 노출된 생성자를 바탕으로 인스턴스 생성
  2. 노출된 모든 속성을 구체화하기 위해 인스턴스를 채우기

 

4.2.1 권장 사용 방법

1. 불변 객체를 고수한다.

불변 객체는 생성자만으로 객체를 생성할 수 있다. 이러면 setter 메서드로 인해 도메인 객체가 흩어지는 것을 피할 수 있다. 필요한 경우에는 package protected 하게 제한을 걸어주는 게 좋다. 생성자 전용 구체화는 프로퍼티틑 채우는 속도가 최대 30%까지 빠르다.

 

2. all args constructor 제공

프로퍼티 채우는 과정을 스킵할 수 있기 때문에 성능이 좋다.

 

3. @PersistenceCreator를 피하기 위해, 생성자 오버로딩 대신 팩토리메서드를 사용한다.

최적의 성능을 위해 all args 생성자를 사용하면, 일반적으로 사용이 편하게 생성자를 오버로딩하는 경우가 많다.(ex. 식별자를 자동 생성하는 생성자) all args 생성자의 변형을 노출하기 위해, 정적 팩토리 메서드를 사용하는 것이 좋은 패턴이다.

 

4. 생성된 instantiator와 property accessor classes가 사용될 수 있도록, 적절한 제약조건을 준수해야 한다.

 

5. 식별자가 생성되기 위해서는, 필드에 final을 걸어주고 all args 생성자를 사용한다.(권장 방법) 또는, with… 메서드를 사용한다.

 

6. 상용구 코드에 롬복을 사용한다.

자바의 경우, lombok의 @AllArgsContructor로 반복적인 작업을 효과적으로 줄일 수 있다.

 

4.2.2 코틀린 지원

스프링 데이터는 코틀린으로의 객체 매핑을 지원한다.

 

4.2.2.1 코틀린 객체 생성

다음처럼 데이터 클래스로 선언한다.

data class Person(val id: String, val name: String)

다른 생성자를 추가하고 싶다면, 다음처럼 @PersistenceCreator와 사용하면 된다.

data class Person(var id: String, val name: String) {

    @PersistenceCreator
    constructor(id: String) : this(id, "unknown")
}

하지만 코틀린은 파라미터에 기본값을 줄 수 있기 때문에 아래처럼 사용하는 게 낫다.

data class Person(var id: String, val name: String = "unknown")

 

4.2.2.2 코틀린 데이터 클래스의 프로퍼티 채우기

다음처럼 데이터 클래스로 클래스를 선언하자.

data class Person(val id: String, val name: String)

이 클래스를 불변이다. 코틀린은 데이터 클래스에 copy 메서드를 자동으로 생성해 주기 때문에 새로운 인스턴스 생성이 가능하다.

 

4.3 object to hash 매핑

스프링 데이터 레디스에서는, 기본적으로 객체와 레더스 해시 간의 매핑을 byte[] 형식으로 지원한다. 해당 방식은 단일 값, 객체, 리스트 등의 타입과 상관없이 모두 flat 형태로 저장한다.

기본 매핑 룰

만약 객체나 리스트, 맵 등을 json 형태로 저장하고 싶다면, Converter를 커스텀하게 구현하면 된다.

 

4.4 Keyspaces

키스페이스로 Redis 해시 키의 prefix를 정의할 수 있다. 기본값으로 접두사로 getClass().getName()이 붙는다. 변경하고 싶다면 @RedisHash를 설정하거나 설정 파일에서 로직을 추가하면 된다. 참고로 @RedisHash방식이 우선순위가 높다.

 

설정 코드는 공식 문서를 참고한다.

 

4.5 보조 인덱스

보조 인덱스는 네이티브 레디스 구조 기반으로 조회 작업을 가능하게 하기 위해 사용된다. 값들은 저장될 때마다 인덱스에 매번 추가된다. 또한 객체가 제거되거나 만료될 때마다 인덱스에서 제거된다.

 

4.5.1 간단한 인덱스

실습 코드 

더보기

Person

@RedisHash("people")
data class Person(
    @Id
    var id: String?,
    @Indexed var firstname: String? = null,
    var lastname: String? = null,
    var address: Address? = null
)

data class Address(
    @Indexed val city: String? = null,
    val country: String? = null
)

 

 

인덱스 테스트 코드

@Test
fun basicTest() {
    val rand = Person("Junyoung", "Choi").apply {
        address = Address("sillim", "korea")
    }

    val saveRand = personRepository.save(rand)
    println(saveRand)

    val findRand = personRepository.findById(saveRand.id!!).get()

    println(findRand)

    println(personRepository.count())

//      println(personRepository.delete(rand))
}

 

 

인덱스 생성 결과

127.0.0.1:6379> keys *
1) "people:Junyoung:idx"
2) "people:address.city:sillim"
3) "people:Junyoung"
4) "people:firstname:Choi"
5) "people"

127.0.0.1:6379> smembers people
1) "Junyoung" // id값

127.0.0.1:6379> smembers people:Junyoung:idx
1) "people:address.city:sillim"
2) "people:firstname:Choi"

127.0.0.1:6379> smembers people:address.city:sillim
1) "Junyoung" // id값

127.0.0.1:6379> smembers people:firstname:Choi
1) "Junyoung" // id값

127.0.0.1:6379> hkeys people:Junyoung // 해시에 저장된 필드들
1) "_class"
2) "address.city"
3) "address.country"
4) "firstname"
5) "id"

 

 

필드에 @Indexed 없이, 아래와 같이 Configuration 파일에서 인덱스를 구성할 수 있다.

@Configuration
@EnableRedisRepositories
class RedisConfig {
    //... RedisConnectionFactory and RedisTemplate Bean definitions omitted

    @Bean
    fun keyValueMappingContext(): RedisMappingContext {
        return RedisMappingContext(
            MappingConfiguration(MyIndexConfiguration(), KeyspaceConfiguration())
        )
    }

    class MyIndexConfiguration : IndexConfiguration() {
        override fun initialConfiguration(): Iterable<IndexDefinition> {
            return setOf<IndexDefinition>(SimpleIndexDefinition("people", "firstname"))
        }
    }
}

 

4.5.2 geospatial 인덱스

클래스에 위치 정보를 담고 있는 Point 필드가 있다고 가정해 보자. 해당 필드에 @GeoIndexed을 걸어주면, 위경도 기반(x, y)으로 객체 조회가 가능하다.

 

실습 코드

더보기

Person

@RedisHash("people")
data class Person(
    @Id
    var id: String?,
    var firstname: String? = null,
    var lastname: String? = null,
    var address: Address? = null
)

data class Address(
    val city: String? = null,
    @GeoIndexed val location: Point? = null,
    val country: String? = null
)

 

PersonRepository

interface PersonRepository : CrudRepository<Person, String> {
    fun findByAddressLocationNear(point: Point, distance: Distance): List<Person>
    fun findByAddressLocationWithin(circle: Circle): List<Person?>
}

 

geospatial 인덱스 테스트 코드

@Test
fun geospatialIndexTest() {
    val rand = Person("Junyoung", "Choi").apply {
        address = Address(location = Point(13.361389, 38.115556))
    }

    personRepository.save(rand)

    // GEORADIUS people:address:location 15.0 37.0 200.0 km 와 동일
    println(personRepository.findByAddressLocationNear(Point(15.0, 37.0), Distance(200.0, Metrics.KILOMETERS)))
}

 

Print 결과

[Person(id=Junyoung, firstname=Choi, lastname=null, address=Address(city=null, location=Point [x=13.361389, y=38.115556], country=null))]

 

redis-cli

127.0.0.1:6379> keys *
1) "people:address:location"
2) "people:Junyoung"
3) "people:Junyoung:idx"
4) "people"

127.0.0.1:6379> smembers people:Junyoung:idx
1) "people:address:location"

127.0.0.1:6379> GEORADIUS people:address:location 15.0 37.0 200.0 km
1) "Junyoung" // id값

4.6 Query by Example

Query by Example은 간단한 인터페이스를 가진 사용자 친화 쿼리 기술이다. 동적 쿼리 생성이 가능하며, 필드 이름이 포함된 쿼리를 작성하지 않아도 된다. 그저 예시로 삼을 객체와 매칭 조건을 파라미터로 넘겨주면, 조건에 맞는 결과를 얻을 수 있다.

 

4.7 Time to Live

레디스의 데이터 만료시간을 설정하기 위해서는 Time to Live 값을 세팅해 주면 된다. 만료 시간 세팅은 @RedisHash(timeToLive=…)이나 KeyspaceSettings로 할 수 있다. 또는 @TimeToLive 애노테이션을 사용하면, 숫자 프로퍼티나 메서드 단위로 더욱 유연하게 세팅이 가능하다.

 

참고로 레디스는 생성, 변경 등의 이벤트를 수신하는 기능을 지원한다. 만료 이벤트도 지원하며, RedisMessageListenerContainer를 통해 수신받을 수 있다.

 

4.8 Persisting References

참조 객체를 아래와 같이 id 로만 들고 있게 할 수 있다.

_class = org.example.Person
id = e2c7dcee-b8cd-4424-883e-736ce564363e
firstname = rand
lastname = al’thor
mother = people:a9d4b3a0-50d3-4538-a2fc-f7fc2581ee56

그러기 위해서는 대상 참조 객체에 @Reference를 붙여주고, 참조 객체는 별도로 저장한다.

 

4.9 Persisting Partial Updates

어떤 경우에는 레디스에 저장된 객체를 불러오지 않고 수정 작업을 처리하고 싶을 수 있다. 레디스의 부분 변경 기능을 활용하면, 객체 로딩 없이 수정과 삭제, 만료시간 업데이트 등의 작업을 수행할 수 있다.

 

4.10 Queries and Query Method

jpa와 마찬가지로, 메서드로 쿼리를 생성하는 기능을 제공한다. 참고로 대상 필드에 인덱스가 걸려있는지 체크하자.

public interface PersonRepository extends CrudRepository<Person, String> {

  List<Person> findByFirstname(String firstname);

  List<Person> findByFirstnameOrderByAgeDesc(String firstname); // 정렬 기능도 제공한다.

  List<Person> findByFirstname(String firstname, Sort sort); // 동적 정렬도 가능하다.
}

쿼리 메서드 기능만으로는 원하는 쿼리 생성에 어려움이 있을 수 있다. 그럴 때는 RedisCallback 기능을 고려해 보자.

 

4.11 Redis Repositories Running on a Cluster

레디스 클러스터 환경에서 Redis Repository를 사용하려면 몇몇 추가 구성이 필요하다. 왜냐하면 기본 세팅에서는 키들이 전체 클러스터와 그 슬롯들로 분산되기 때문이다. SINTER와 SUNION과 같은 몇몇 명령어들은 동일 슬롯에 한에서만 작동한다.

원하는 슬롯을 설정하고 싶다면 @RedisHash("{yourkeyspace}")를 사용하면 된다.

 

4.12 CDI Integration

repository 인터페이스 인스턴스는 보통 컨테이너에 의해 생성된다. 또한 Spring Data로 작업할 때는 보통 스프링을 많이 선택한다.

Spring Data Redis는 커스텀 CDI 확장 기능을 제공하며, 해당 기능을 통해 CDI 환경에서 repository 추상화가 가능하다.

 

참고 링크

1. O(n) 명령어 사용을 지양하자

레디스는 싱글 스레드기 때문에 한 번에 하나씩 명령에 처리할 수 있다. 따라서 데이터 양이 많은 경우 O(n) 명령어를 호출하면, 꽤 오랜 시간 동안 레디스가 다음 요청을 처리할 수 없게 된다. 그러므로 운영 환경에서는 특히나 오래 걸리는 명령어를 사용하지 말아야 한다.

 

대표적인 O(n) 명령어

  • keys : 패턴에 맞는 모든 key들을 조회할 수 있다. 데이터 양이 많아질수록 느려지므로, 운영 환경에서는 scan 명령어를 사용하자.
  • flushall : 전체 데이터베이스에 있는 모든 키들을 지우는 명령어
  • flushdb : 선택된 데이터베이스에 있는 모든 키들을 지우는 명령어

 

참고로 memcached의 flush_all 명령어는 레디스보다 훨씬 빠르다. 그 이유는 동작 방식에서 차이가 있기 때문이다. 레디스는 데이터를 하나하나 지우는 방식으로 동작하기 때문에 시간 복잡도가 O(n)이다. 하지만 memcached는 모든 데이터를 지우지 않는 대신, 해당 명령어가 실행된 시간만 기록한다. 그리고 get 명령어가 호출된 시점에 유효성을 체크하고 결과를 반환하는 방식이다.(참고 링크)

 

2. persistence에서 주의사항

레디스를 단순 캐시가 아닌 데이터 저장소로 사용하는 경우라면 디스크에 백업하는 기능이 필요할 것이다. 레디스에서는 이를 위해 RDB와 AOF 두 가지 기능을 제공한다.

  • RDB : 지정된 간격으로 데이터의 스냅샷을 파일로 저장한다.
  • AOF : 서버에서 수신한 모든 write 명령을 기록한다. 그리고 서버 시작 시 해당 작업을 재수행하여 데이터를 복구하는 방식이다.

 

여기서 RDB를 사용할 때 주의할 점이 있다.

  1. SAVE 명령어는 모든 작업을 멈추고 RDB 파일 생성 작업을 수행하므로 사용하지 않는다.
  2. BGSAVE 명령어는 fork로 생성한 자식 프로세스에서 RDB 파일을 저장한다. 자식 프로세스로 인해 더 많은 메모리가 필요하므로, maxmemory 설정 시 주의한다.(참고 링크)
  3. RDB 저장이 실패하면 write 명령이 실패할 수 있다. 레디스 설정을 통해 저장이 실패해도 write가 가능하게 바꿀 수 있다.

 

3. Replication에서 주의사항

1. 서버 재부팅 시 레디스가 자동으로 시작되도록 설정되어 있다면, 마스터에서 persistence 사용을 유지하는 것을 권장한다.

  • 마스터 노드 다운 후에 데이터가 유실된 상태에서 재시작이 될 수 있다. 그런 경우 슬레이브 노드는 마스터와 재싱크되면서 데이터가 유실된다.

2. 마스터 노드 장애 시, 슬레이브가 마스터와 싱크 되는 것을 피하기 위해 연결을 끊을 수 있다.

3. 종종 발생하는 full sync를 위해서, 레디스는 사용자 설정과 무관하게 RDB 파일을 생성한다. fork 방식으로 RDB 파일이 생성되므로, maxmemory 설정 시 주의하자.

 

 

참고 링크

목차

  • 레디스란
  • 레디스의 주요 기능과 장단점
  • Memcached vs Redis
  • MongoDB vs Redis

 

레디스란

레디스는 오픈소스 인메모리 데이터 저장소이다. ram에 데이터를 저장하고 응답하기 때문에 성능이 매우 빠르다는 특징이 있다. 또한, 레디스는 string, set, list, hash, sorted set 등의 다양한 자료 구조를 제공한다.

이 외에도 pub/sub, 레플리케이션 등의 다양한 기능을 제공하고 있다. 이런 이유로 캐시, 데이터베이스, 스트리밍 엔진, 메시지 브로커 등의 다양한 영역에서 레디스가 많이 사용되고 있다.

 

레디스의 주요 기능과 장단점

장점 및 기능

  • 메모리에 데이터를 저장하여 빠른 응답이 가능.
    • cpu마다 다르지만, 간단한 작업의 경우 tps가 10만 정도이다.
  • string, set, list, hash, sorted set, geopartial 등의 다양한 자료 구조 제공
  • ttl과 키 제거 정책을 통한 키 관리 기능
  • 다양한 데이터 타입에 대해 원자적 작업 지원 : 문자열 추가, 해시 값 증가, 리스트에 요소 추가, set 교집합, sorted set에서 랭킹 조회 등
  • 레디스 센티넬 또는 클러스터 기능으로 고가용성 구축 가능
  • 디스크에 데이터 영속화 가능 : AOF or RDB
  • Pub/Sub, Transaction, lua scripting 등의 다양한 기능 제공
  • 다양한 언어 지원 : 자바, 파이썬, 자바스크립트, 루비 등

단점

  • 전체 데이터 세트가 메모리에 있어야 한다. 따라서 대규모의 데이터를 redis에 저장하기 위해서는 비용이 많이 들 수 있다.
    • 메모리 크기를 초과하는 경우, 설정에 따라 OOM 에러나 디스크 스왑으로 인한 성능 저하가 발생할 수 있다. (관련 링크)
  • 데이터 영속화 기능을 제공하지만, 해당 작업은 성능에 영향을 준다.
  • 싱글 쓰레드 기반으로 동작한다. 따라서 keys와 같은 O(n) 명령어를 주의해서 사용해야 한다.

 

Redis vs Memcached

Memcached는 사용 편의성과 단순성을 위해 설계된 분산 메모리 캐싱 시스템이며, 캐시나 세션 저장소로 적합하다.

Redis는 다양한 기능 세트를 제공하는 인메모리 데이터 구조 저장소이다. 캐시뿐만 아니라 데이터베이스, 메시지 브로커 및 대기열 등에 유용하다.

 

공통점

  1. 밀리초 미만의 지연 시간 : 둘 다 밀리초 미만의 빠른 응답시간을 제공한다. 메모리에 데이터를 유지하고 있기 때문에 디스크 기반의 데이터베이스보다 응답시간이 훨씬 짧다.
  2. 데이터 분산 : 둘 모두 여러 노드에 데이터를 분산시킬 수 있다. 확장에 용이하기 때문에 트래픽이 증가하더라도 이를 효과적으로 감당할 수 있다.
  3. 다양한 프로그래밍 언어 지원 : 개발자가 사용할 수 있는 오픈 소스 클라이언트들이 많이 있다. 지원 언어는 Java, Python, PHP, C, C++, javascript, Node.js, Ruby, Go 등이 있다.

차이점

  1. architecture
    • Memcached는 다중 코어를 활용하여 멀티 스레드 아키텍처를 구현한다. 따라서 대규모의 데이터 세트를 저장하는 경우, Redis보다 더 나은 성능을 발휘할 수 있다. 높은 확장성이라는 장점도 있다.
    • Redis는 단일 코어를 사용하며, 코어 측면에서는 memcached 보다 더 나은 성능을 보여준다. 클러스터를 통해 수평적 확장이 가능하다.
  2. 자료 구조
    • Memcached는 키-값 쌍을 문자열로 저장하며 값당 크기 제한이 1MB이다.
    • Redis는 문자열을 최대 512MB 크기로 저장할 수 있다. 또한 list, set, hash, sorted set 등의 다양한 자료 구조를 지원한다.
  3. replication
    • Memcached는 복제를 지원하지 않는다. Repcached와 같은 오픈 소스를 사용하면 가능하다.
    • Redis에서는 여러 복제본을 생성할 수 있다. 이를 통해 read 작업을 확장하고 고가용성을 유지할 수 있다.
  4. Transaction
    • Memcached는 작업이 원자적이지만 트랜잭션을 지원하지는 않는다.
    • Redis는 트랜잭션을 지원한다. multi, exec, discard, watch 명령어를 사용하여 실행할 수 있다.
  5. Pub/Sub
    • Memcached는 pub/sub을 지원하지 않는다.
    • Redis는 pub/sub을 지원한다. 고성능 채팅방, 실시간 댓글 스트림, 소셜 미디어 피드, 서버 상호 통신 등에서 활용할 수 있다.

 

Redis vs MongoDB

Redis는 데이터를 키-값 쌍으로 저장하는 오픈소스 인메모리 데이터베이스이다. 고성능을 위해 ram에 데이터를 저장하고, 추가 기능으로 디스크에 파일 백업이 가능하다.

MongoDB는 데이터를 직렬화된 JSON 형식으로 저장하는 도큐먼트 데이터베이스이다. 기본적으로 디스크에 데이터를 저장하며, 엔터프라이즈 에디션에서는 인메모리 스토리지 엔진이 포함되어 있다.

 

차이점

  1. 성능
    • Redis는 RAM에 데이터를 저장하므로 메모리에서 직접 데이터를 액세스 할 수 있다. 비록 저장할 수 있는 데이터의 양이 제한적이지만, 그만큼 응답 지연 시간이 빠르다는 장점을 가진다.
    • MongoDB는 기본적으로 디스크 기반 스토리지이다. 메모리 스토리지와 결합하여 속도 향상이 가능하지만, 일반적으로 Redis보다 느리다.
  2. 데이터 모델
    • Redis는 데이터를 키-값 쌍으로 저장한다. 키는 최대 길이가 512MB인 바이너리 안전 문자열이다. 또한 sorted set, set, hash, list 등의 다양한 데이터 유형을 지원한다.
    • MongoDB는 데이터를 직렬화된 JSON 문서로 저장한다. 문서의 최대 크기는 16MB이다.
  3. 스케일 아웃
    • MongoDB는 수평적 크기 조정을 통해 대용량의 데이터를 효과적으로 처리할 수 있다. 샤딩을 사용하면 여러 리전 및 노드에 데이터 배포가 가능하다. 또한 샤딩 간 작업을 통해 여러 샤드에 쿼리와 업데이트가 가능하다.
    • Redis는 MongoDB와 동일한 수준의 확장성을 제공하지 않는다. 기본적으로 Redis는 다중 샤드 간 작업을 지원하지 않는다. 또한 해시 샤딩만 가능하고, 수동으로 유지 관리해야 한다는 불편함이 있다.

 

참고자료

+ Recent posts