Develop/spring-data

[Redis] 성능개선 - 캐싱 처리 3가지 방법

에디개발자 2021. 8. 17. 07:00
반응형

나를 닮았다고 한다...

들어가기 전에

서비스 배포 전 성능 개선을 하면서 왜 캐싱을 써야했는지 캐싱을 쓰면서 어떤 이슈들이 발생했는지에 대해 작성해보겠습니다.

모든 코드는 Kotlin으로 진행하였으며 Github에 올려두었습니다. 

 

캐싱을 사용했던 이유

서비스를 배포하기 전 성능 테스트를 진행하던 중 특정 API에서 성능이 매우 떨어지고 있었습니다. 5초 이상?!

@Service
class TempService(
    val storeRepository: StoreRepository,
    val categoryRepository: CategoryRepository
) {

    fun init(storeId: Long): ResInitDTO {
        // 1. DB에서 특정 상점 데이터 조회
        val store: Store = storeRepository.findById(storeId)
        
        // 2. DB에서 storeId로 카테고리 데이터 조회
        val categories: List<Category> = categoryRepository.findByStoreId(storeId)
        
        // 3. 카테고리 정보 재귀함수 사용하여 가공
        val newCategories = generateGategory()
        
        // 4. 상점 데이터와 카테고리 정보 병합
        val response = ResInitDTO(store, newCategories)
        
        // 5. 데이터 리턴
        return response
    }

    // 카테고리 데이터 가공 재귀
    fun generateCategory(categories: List<CategoryInfo>) {
        // 재귀를 사용한 가공 로직
    }
}

위 코드는 문제가 발생하는 코드를 주석으로 간략하게 표현한 코드입니다.

 

개선 가능한 포인트

2번 항목에서 카테고리 정보는 변경될 일이 아주 적었으며 대부분 같은 카테고리 정보를 조회하여 재귀처리( 3번 항목 )하고 있었습니다. 이 부분을 캐싱처리하여 빠르게 처리할 수 있었습니다. 그럼 캐싱 처리를 하기 위한 구성을 살펴보겠습니다.

 

Redis 구성

먼저 구성은 AWS의 ElastiSearch를 사용하였습니다. ( Local PC에 Redis를 띄워 구성하는 것과 동일하게 사용할 수 있습니다. )

Primary Redis, Replica Redis 2개를 띄웠고 Write는 Primary Redis, Read는 Replica Redis를 사용하였습니다.

Redis Redis Write
Redis Data Read

Service에 Redis 구현

build.gradle.kts

dependencies {
    // redis
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}

 

Application.kt

@SpringBootApplication
@EnableCaching    // 캐싱 사용 선언
class SampleApplication

fun main(args: Array<String>) {
    runApplication<SampleApplication>(*args)
}

 

 

RedisConfig.kt

저는 Primary, Reader 구성으로 진행하였기 때문에 RedisStaticMasterReplicaConfiguration를 사용합니다.

@Configuration
class RedisMasterReplicaConfig {

    private val primaryHost = ""
    private val primaryPort = 0
    private val readerHost = ""
    private val readerPort = 0

    /**
     * Redis Connection 설정
     *   - Write: Master Redis
     *   - Read: Replica Redis
     * @return LettuceConnectionFactory
     */
    @Bean
    fun redisConnectionFactory(): LettuceConnectionFactory {
        // Primary, Replica Config
        val elastiCache = RedisStaticMasterReplicaConfiguration(primaryHost, primaryPort)
        elastiCache.addNode(readerHost, readerPort)

        // 1) lettuce 사용
        val clientConfig = LettuceClientConfiguration.builder()
            .readFrom(ReadFrom.REPLICA_PREFERRED)  // 2)
            .build()

        return LettuceConnectionFactory(elastiCache, clientConfig)
    }
}

1) ConnectionFactory는 Jedis(동기), Lettuce(비동기) 중 성능 이슈로 Lettuce를 선택하였습니다. 

2) Read는 Replica Redis를 바라보도록 설정

 


아래는 단일 Redis로 구성된 경우입니다.

@Configuration
class RedisStandaloneConfig(
    val redisProperties: RedisStandaloneProperties
) {

    private val redisHost = ""
    private val redisPort = 0
    private val redisPassword = ""

    /**
     * Redis Connection 설정
     * @return LettuceConnectionFactory
     */
    @Bean
    fun redisConnectionFactory(): LettuceConnectionFactory {
        val configuration = RedisStandaloneConfiguration()
        configuration.hostName = redisHost
        configuration.port = redisPort
//        configuration.password = RedisPassword.of(redisPassword)
        return LettuceConnectionFactory(configuration)
    }
}

 

여기까지 Redis를 사용하기 위한 설정입니다. 다음은 Redis를 사용하는 3가지 방법에 대해서 알아보겠습니다.

  • CrudRepository
  • RedisTemplate
  • RedisManager

CrudRepository, RedisTemplate은 Redis에 직접 Key, Value값을 설정하여 CRUD를 합니다.

RedisManager는 메서드나 타입에 캐싱처리를 합니다.


1. CrudRepository

먼저 CrudRepository를 사용하기 위해선 @RedisHash를 선언한 객체가 필요합니다.

객체 안에 @Id를 기준으로 CrudRepository를 사용할 수 있습니다.
import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash

@RedisHash(value = "member")
class Member(

    @Id
    var id: String? = null,
    var name: String? = null,
    var age: Int? = null
)

 

interface를 생성 후 CrudRepository를 상속 받아 사용할 수 있습니다. 

CrudRepository<Model, Key> 
import org.springframework.data.repository.CrudRepository

interface MemberRepository: CrudRepository<Member, String> {
}

 

생성한 interface의 내장함수를 사용할 수 있습니다.

@Service
class MemberService(
    val repository: MemberRepository
) {

    // 조회
    fun getMemberById(id: String): Member =
        repository.findById(id)

    // 등록
    fun saveMember(member: Member): Member =
        repository.save(member)
        
}

2. RedisTemplate

class RedisConfig {

    @Bean(name = ["memberRedisTemplate"])
    fun memberRedisTemplate(): RedisTemplate<String, String> {
        val redisTemplate = RedisTemplate<String, String>()
        redisTemplate.setConnectionFactory(redisConnectionFactory())

        // 아래의 설정값이 없으면 스프링에서 조회할 때는 값이 정상으로 보이지만 redis-cli로 조회하면 `xec\x83\x98\xed\x94\x8c1` 이런식으로 보여짐
        redisTemplate.keySerializer = StringRedisSerializer()
        redisTemplate.valueSerializer = StringRedisSerializer()

        return redisTemplate
    }

}

redis의 저장방식은 byte array 형식입니다. spring-data에서 redis를 편하게 쓸 수 있도록 keySerializer, valueSerializer 를 제공합니다. keySeializer, valueSerializer를 설정하지 않으면 "\xac\xed\x00\x05t\x00\x03key" 표기됩니다.

RedisTemplate<Key, Value> 객체를 설정하여 Bean에 등록합니다.

 

CustomRepository

CrudRepository를 상속받은 Interface를 사용하여 구성할 수 있습니다.

먼저 새로운 Interface를 생성하고 사용할 메서드를 선언합니다.

interface MemberCustomRepository {
    fun selectById(id: String): String?
    fun saveMember(member: Member)
    fun deleteMember(id: String): Boolean
}

 

CustomRepositoryImpl

위에서 생성한 CustomRepository 뒤에 Impl를 붙혀서 클래스 객체를 생성합니다.

class MemberCustomRepositoryImpl(
    @Qualifier(value = "memberRedisTemplate") val redisTemplate: RedisTemplate<String, String>,
    private val mapper: ObjectMapper
): MemberCustomRepository {

    override fun selectById(id: String): String? =
        redisTemplate.opsForValue().get(id)

    override fun saveMember(member: Member) {
        redisTemplate.opsForValue().set(
            member.id!!,
            mapper.writeValueAsString(member),
            60,       // expire
            TimeUnit.MINUTES
        )
    }

    override fun deleteMember(id: String): Boolean =
        redisTemplate.delete(id)


}

빈에 등록한 RedisTemplate을 사용합니다. 

redisTemplate.opsForValue().get()
redisTemplate.opsForValue().set()

 

Repository

CrudRepository를 상속받는 Interface 객체에 추가적으로 CustomRepository Interface를 상속받습니다.

import org.springframework.data.repository.CrudRepository

interface MemberRepository: CrudRepository<Member, String>, MemberCustomRepository {
}

 

Repository를 이용하여 사용

@Service
class MemberService(
    val repository: MemberRepository
) {

    // 조회
    fun getMemberById(id: String): Member =
        repository.selectById(id)

    // 등록
    fun saveMember(member: Member): Member =
        repository.saveMember(member)
        
    // 삭제
    fun deleteMember(id: String): Boolean =
        repository.deleteMember(id)
        
}

3. RedisManager

@Configuration
class RedisStandaloneConfig {

    private expire = 8L
    
    /**
     * Redis Cache를 관리
     * @return RedisCacheManager
     */
    @Primary
    @Bean(name = ["memberManager"])
    fun memberManager(): RedisCacheManager {
        val configuration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()))   // json 형식으로 value 저장
            .computePrefixWith(CacheKeyPrefix.simple())             // key앞에 '::'를 삽입
            .disableCachingNullValues()                             // null 값 금지
            .entryTtl(Duration.ofHours(expire))                     // 캐싱 유지 시간 설정

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

Bean Container에 RedisCacheManager를 설정하고 올립니다.

 

@Cacheable

Method에 @Cacheable 선언하여 캐싱처리합니다. 캐싱 처리 Process는 아래와 같습니다.

  1. Parameter가 요청왔을 때 Redis에 값이 없으면 Method 로직 수행
  2. Redis에 Paramter, Response 값 저장
  3. 동일한 Parameter가 요청왔을 때 Redis에서 값을 조회하여 Return

RedisManager Data Process

Example

캐싱 처리할 메서드에 Cacheable Annotation 선언

@Service
class MemberService {
    
    @Cacheable(value = ["member"], key = "#id", cacheManager = "memberRedisTemplate")
    fun getMember(id: String?): Member? {
        println("getMember!!")
        // Logic
    }
    
}

 

다른 클래스에서 Cacheable 선언된 메서드 호출

@Component
class CacheTest(
    val service: MemberService
) {
    
    fun getMember(): Member? {
        println("Cache Test Start!!")
        val memberId: String = "123"
        return service.getMember(memberId)  // Call CacheManager Method
    }
    
}
Redis에 저장된 Key값:: member::123

 

결과

// first call
Cache Test Start!!
getMember!!

// second call
Cache Test Start!!

 

 

@Cacheable 주의사항

Cacheable이 작동하지 않아요!! 제가 삽질했던 내용을 공유드립니다. 

작동하지 않는 코드

@Service
class MemberService {
    
    fun getMember(id: String?): Member? {
        println("getMember!!")
        val memberDetail = getMemberDetail(id)
        // logic
        return member
    }
    
    @Cacheable(value = ["member"], key = "#id", cacheManager = "memberRedisTemplate")
    fun getMemberDetail(id: String): MemberDetail {
        // get member detail logic
        return memberDetail
    }
    
}

이전과는 다르게 getMember() 메서드에서 getMemberDetail() 메서드를 호출합니다. 이럴경우 작동하지 않습니다.

 

@Cacheable Annotation은 메서드 내에서 호출할 메서드에 선언하면 작동하지 않습니다. 클래스에서 1 Depth인 메서드에서 선언해야만 정상작동됩니다.

 

정상 작동하는 코드

@Service
class MemberService(
    val service: MemberDetailService
) {
    
    fun getMember(id: String?): Member? {
        println("getMember!!")
        val memberDetail = service.getMemberDetail(id)
        // logic
        return member
    }
    
}
@Service
class MemberDetailService {
    
    @Cacheable(value = ["member"], key = "#id", cacheManager = "memberRedisTemplate")
    fun getMemberDetail(id: String): MemberDetail {
        // get member detail logic
        return memberDetail
    }
    
}

 

결론

캐싱처리하지 않았을 경우 5초 이상 걸리는 API가 캐싱 처리하여 0.1초대로 변경되었습니다.

Redis 너무 좋네!? 하면서 모든 데이터를 레디스에 올리면 안됩니다!
휘발되도 상관없는 데이터, 변동이 자주 일어나지 않는 데이터 등 정말 필요한 상황에서만 사용해주세요. 
반응형