들어가기 전에
서비스 배포 전 성능 개선을 하면서 왜 캐싱을 써야했는지 캐싱을 쓰면서 어떤 이슈들이 발생했는지에 대해 작성해보겠습니다.
모든 코드는 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를 사용하였습니다.
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는 아래와 같습니다.
- Parameter가 요청왔을 때 Redis에 값이 없으면 Method 로직 수행
- Redis에 Paramter, Response 값 저장
- 동일한 Parameter가 요청왔을 때 Redis에서 값을 조회하여 Return
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 너무 좋네!? 하면서 모든 데이터를 레디스에 올리면 안됩니다!
휘발되도 상관없는 데이터, 변동이 자주 일어나지 않는 데이터 등 정말 필요한 상황에서만 사용해주세요.
'Develop > spring-data' 카테고리의 다른 글
[kotlin] Querydsl-JPA GroupBy 사용했을 경우 Paging처리 방법 (1) | 2021.08.06 |
---|---|
[Querydsl-JPA] 자주 사용하는 기능 정리 (Kotlin) (3) | 2021.05.10 |
Querydsl Join Table Sort 적용 ( 번외로 Pageable와 비슷한 것을 구현해보자! ) (0) | 2021.03.14 |
[Querydsl] 성능개선 - 3편 ( group by, 커버링 인덱스, update ) (2) | 2021.02.04 |
[Querydsl] 성능개선 - 2편 ( N + 1 ) (0) | 2021.02.01 |