모종닷컴

Spring에서 Redis에 동적데이터 저장하기 본문

Programming/Spring

Spring에서 Redis에 동적데이터 저장하기

모종 2023. 1. 14. 16:57
반응형

Spring에서 Redis을 사용할 때 동적인 데이터를 삽입해야 하는 경우가 있습니다. spring-data-redis 버전에 따라 살짝 다르긴 하지만 현재 사용 중인 2.2.13.RELEASE 버전을 기준으로 포스팅하도록 하겠습니다.

프로젝트 세팅 (gradle, kotlin, spring boot)

그레이들 스크립트

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.2.13.RELEASE"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.github.microutils:kotlin-logging:3.0.4")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

레디스 설정

@Configuration
@EnableCaching
class RedisConfig {
    @Bean
    fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager {
        val config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .disableCachingNullValues()

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(config)
            .build()
    }
}

서비스 코드

private val log = KotlinLogging.logger {  }

private const val PACKAGE_CACHE_NAME = "package"

@Service
class PackageRegisterService {
    @Cacheable(cacheManager = "cacheManager", cacheNames = [PACKAGE_CACHE_NAME], key = "{#serialNumber}")
    fun <T> register(serialNumber: String, item: T): T {
        log.info { "register $serialNumber" }
        return item
    }

    @Cacheable(cacheManager = "cacheManager", cacheNames = [PACKAGE_CACHE_NAME], key = "{#serialNumber}")
    fun search(serialNumber: String): Any {
        throw IllegalArgumentException("접수된 택배를 찾을 수 없습니다.")
    }
}

/**
 * @param model 모델명
 * */
class AppleIPad(val model: String)

/**
 * @param dustCollectionEfficiency 분진포집효율
 * */
class MedicalMask(val dustCollectionEfficiency: Int)

컨트롤러 코드

@RestController
class PackageRegisterController(
    private val packageRegisterService: PackageRegisterService
) {

    @ExceptionHandler(IllegalArgumentException::class)
    fun handleIllegalException(e: IllegalArgumentException) = ResponseEntity.badRequest().body(e.message)

    @PostMapping("/package/{productId}")
    fun register(@PathVariable productId: Int): ResponseEntity<String> {
        val serialNumber = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddhhmmss"))
        val item = productIdToItem[productId] ?: throw IllegalArgumentException("상품을 찾을 수 없습니다")

        packageRegisterService.register(serialNumber, item)
        return ResponseEntity.ok(serialNumber)
    }

    @GetMapping("/package/{serialNumber}")
    fun search(@PathVariable serialNumber: String): ResponseEntity<*> {
        return ResponseEntity.ok(packageRegisterService.search(serialNumber))
    }

    private val productIdToItem = mapOf(
        1 to AppleIPad("아이패드 에어"),
        2 to AppleIPad("아이패드 프로"),
        3 to MedicalMask(80),
        4 to MedicalMask(94)
    )
}

테스트 시작

본격적으로 테스트를 해보도록 하겠습니다. 먼저 상품을 등록해 보도록 하겠습니다. 아래 스펙으로 api 호출하고 나면 아래와 같은 에러를 마주할 수 있습니다.

POST {{hostUrl}}/package/3

 

DefaultSerializer requires a Serializable payload but received an object of type [com.example.springredis.pkg.MedicalMask]

이 에러는 RedisConfig에서 디폴트 설정값과 관련이 되어있습니다. RedisCacheConfiguration.defaultCacheConfig()를 들어가 보면 Value Serializer에 아래의 설정이 들어가 있는 것을 볼 수 있는데 해당 내용은 Value에 해당하는 Object에 Serializable 인터페이스가 상속되어 있어야 합니다. 

따라서 아래와 같이 Serializable 인터페이스를 구현하도록 수정해주어야 합니다. 

class AppleIPad(val model: String): Serializable

class MedicalMask(val dustCollectionEfficiency: Int): Serializable

코드를 수정했다면 다시한번 이전 api 요청을 쏴보면 성공이 되었고 SerialNumber를 받아볼 수 있습니다. 

이제 redis에 들어가서 값이 어떤 식으로 저장이 되어있는지 보겠습니다. redis-cli에 들어가서 저장되어 있는 키를 전부 검색해 봅니다(로컬이라 무지성으로 그냥 keys * 로 검색하도록 하겠습니다.) 방금 전 발급받은 시리얼넘버로 캐시가 저장되어 있는 모습을 볼 수 있고 해당 value 값은 아래 사진과 같이 들어가 있습니다.

이제 redis에서 검색해보도록 하겠습니다. 아래의 스펙으로 API 요청해 보면 

GET {{hostUrl}}/package/{{serialNumber}}

위처럼 Redis에서 정상적으로 검색이 됩니다. "접수된 택배를 찾을 수 없습니다." 응답 메시지를 받으신 분이라면 캐시 만료 시간(5분)이 지나서 redis에서 삭제된 거라 등록 후 다시 한번 시도해 주시면 될 겁니다.

GenericJaskson2JsonRedisSerializer 이용하기

Redis의 디폴트 설정값을 사용하지 않고 GenericJaskson2JsonRedisSerializer을 이용하는 방법도 있습니다. 디폴트 설정을 사용하지 않는다면 Serializable도 계속 붙여줄 필요도 없습니다. GenericJaskson2JsonRedisSerializer는 Object를 내부의 ObjectMapper를 사용하여 json 형태로 저장하고, 꺼내올 때는 json을 ObjectMapper를 이용하여 Object 형태로 만들어주는 클래스입니다. 

class AppleIPad(val model: String)

class MedicalMask(val dustCollectionEfficiency: Int)

클래스에 Serializable을 떼주시면 됩니다. 그리고 RedisConfig는 아래와 같이 설정합니다.

@Bean
    fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager {
        val om = ObjectMapper()
            .registerModule(KotlinModule())
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                    .allowIfBaseType(Any::class.java)
                    .build(), ObjectMapper.DefaultTyping.EVERYTHING
            )

        val config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .disableCachingNullValues()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    GenericJackson2JsonRedisSerializer(om)
                )
            )

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(config)
            .build()
    }

GenericJaskson2JsonRedisSerializer에 ObjectMapper를 왜 세팅해 주나요?

2.2.13.RELEASE 버전의 경우 GenericJaskson2JsonRedisSerializer 디폴트 설정이 아래처럼 되어있습니다. (모든 버전을 확인해보지는 않았고 2.7.5 버전에는 이 부분이 Everything으로 바뀌었습니다)

따라서 ObjectMapper 세팅 없이 GenericJaskson2JsonRedisSerializer()만 사용하게 되면 value가 아래와 같이 저장이 되는데 이렇게 되면 redis가 json 문자를 객체로 변환할 때 어떠한 타입 힌트도 없기 때문에 아래와 같은 오류를 마주할 수 있습니다.

Missing type id when trying to resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'

그래서 ObjectMapper가 json으로 변환할 때 타입정보도 같이 남겨주도록 activateDefaultTyping과 같은 설정이 필요합니다. DefaultTyping.NON_FINAL은 final이 붙어있지 않는 모든 타입에 타입 정보를 남기도록 하는 것인데, 코틀린은 대부분 자바로 변환하면서 final이 붙게 되므로 클래스를 open으로 열어주어야 합니다. (추가적으로 default 생성자도 있어야 합니다..)

암튼 GenericJaskson2JsonRedisSerializer 기본설정을 사용하려면 저장해야 하는 클래스를 규칙에 맞게 계속 만들어줘야 하므로, 그냥 위의 RedisConfig와 같이 kotlinModule과 DefaultTyping.EVERYTHING을 설정한 ObjectMapper를 설정해 주는 게 제일 맘 편합니다.

테스트 및 기본 설정과의 차이점

이제 다시 테스트를 시작해 보죠. 이전과 같이 먼저 상품을 redis에 등록해 주고 검색을 해보면 정상적으로 동작이 되고 있음을 볼 수 있습니다. 여기서 저는 Redis 기본 설정과 비교해서 장점이 있다는 걸 하나 알 수가 있었는데요. Redis에 저장된 value를 보도록 하겠습니다.

ObjectMapper를 이용해서 저장

레디스에 저장된 value를 보니 Serializable을 이용해서 넣은 문자보다 훨씬 human readable 하다는 것이 느껴졌습니다. redis-cli --bigkeys 명령어를 통해 확인해 본 결과 똑같은 데이터를 GenericJaskson2JsonRedisSerializer를 이용한 경우 75 bytes, Java Serializable을 이용하면 91 bytes 정도의 메모리를 사용하고 있음을 알았습니다. 따라서 GenericJaskson2JsonRedisSerializer 를 사용하는 게 기본 설정보다 메모리를 더 적게 사용합니다.

반응형