DB/Redis

Redis로 캐시 레이어 구현

Code Maestro 2023. 8. 11. 15:01
728x90

1. 캐싱의 원리와 목적

캐시는 성능 향상을 위해 값을 복사한 임시 기억 장치이다. 

위로 갈수록 속도가 빠르며, 가격이 비싸다. 

 

캐시에 복사본을 저장하고 읽음으로써 속도가 느린 장치의 접근 횟수를 줄인다. Cache의 데이터는 원본이 아니라 언제든 사라질 수 있다. 이에 데이터 일관성 문제를 해결해야 한다. 

 

캐시는 어디든 적용이 가능하다. 네트워크 지연 속도를 감소하거나, 서버 리소스 사용 감소, 병목현상을 감소할 수 있다.

 

DB 무한정 늘어날 없어서 병목이 된다. 그런데 캐시는 이런 병목 현상을 줄일 수가 있다.

 

 

캐싱 개념들은 아래와 같다.

  • 캐시 적중(Cache Hit): 캐시에 접근해 데이터를 발견한다.
  • 캐시 미스(Cache miss): 캐시에 접근했으나 데이터를 발견 못 함.
  • 캐시 삭제 정책(Eviction Policy): 캐시의 데이터 공간 확보를 위해 저장된 데이터 삭제.
  • 캐시 전략: 환경에 따라 적합한 캐시 운영 방식을 선택할 수 있다. 

 

1.1 캐싱 전략

1) Cache-Aside(Lazy Loading)

가장 많이 사용되는 전략이다. 항상 캐시를 먼저 체크하고, 없으면 원본에서 읽은 뒤에 캐시에 저장한다.

 

장점으로는 필요한 데이터만 캐시에 저장되고, Cache miss가 있어도 치명적이지 않다. 단점으로는 최초 접근이 느리며, 업데이트 주기가 일정치 않기에 캐시가 최신 데이터가 아닐 수가 있다

 

2) Write-Through

데이터를 쓸 때 항상 캐시를 업데이트하여 최신 상태를 유지한다. 

 

장점으로는 Cache 항상 동기화돼서 DB 달라지지 않는다.(데이터가 최신이다.) 단점으로는 자주 사용하지 않는 데이터도 캐시가 돼서 쓰기 지연시간이 증가한다.

 

3) Write-Back

데이터를 캐시로만 쓰고, 캐시와 데이터를 일정 주기로 DB에 업데이트한다. 

 

장점으로는 쓰기가 많은 경우 DB 부하를 줄일 수 있다. 단점으로는 캐시가 DB에 쓰기 전에 장애가 생기면 데이터 유실이 올 수 있다.

 

1.2 캐시 데이터 제거 방식

캐시에서 어떤 데이터를 언제 제거할 것인가? 2가지 방법이 있다.

 

첫 번째는 Expiration으로 각 데이터에 TTL(Time-To-Live)을 설정하여 시간 기반으로 삭제하는 방법.

 

두 번째는 Eviction Algorithm으로 공간을 확보해야 할 경우 어떤 데이터를 삭제할지 결정하는 방법이다. 크게 3가지로 분류된다.

 

  • LRU(Least Recently Used): 가장 오랫동안 사용되지 않는 데이터 삭제.
  • LFU(Least Frequently Used): 가장 적게 사용된 데이터 삭제. 
  • FIFO(First In First Out): 먼저 들어온 데이터를 삭제. 

 

2. Redis를 사용해 캐싱

Cache-Aside 전략으로 구현을 해보겠다. 요청 캐시를 먼저 확인하고, 없으면 원본 데이터 조회 후 캐시에 저장하는 방식이다. 

 

실습 구조는 /users/{userId}/profile url로 GET 요청 시 사용자의 프로필(이름, 나이)을 얻어오는 형태이다.

 

패키지 구조는 위와 같다. 

 

public class User {

    @JsonProperty
    private String name;

    @JsonProperty
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

DTO 패키지에서 User class를 만들었다. @JsonProperty는 JSON으로 변환하기 위해 붙었다.

 

외부 API 호출 메서드를 불러온다는 가정하에 service 패키지 안에 External ApiService 클래스를 생성해 보자.

 

@Service
@Slf4j
public class ExternalApiService {

    public String getUserName(String userId){

        try {
            Thread.sleep(500);
        }catch(InterruptedException e){
        }

        log.info("Getting User name from other service");

        if(userId.equals("A")){
            return "Kim";
        }
        if(userId.equals("B")){
            return "Son";
        }

        return "";
    }

    public int getUserAge(String userId){

        try {
            Thread.sleep(500);
        }catch(InterruptedException e){
        }

        log.info("Getting User age from other service");

        if(userId.equals("A")){
            return 28;
        }
        if(userId.equals("B")){
            return 32;
        }

        return 0;
    }


}

 

캐싱 효과를 보기 위해 외부 서비스나 DB를 호출한다는 가정하에 Thread.sleep을 넣었다. 

 

@Service
public class UserService {

    @Autowired
    private ExternalApiService externalApiService;

    public User getUser(String userId){
        String userName = externalApiService.getUserName(userId);
        int userAge = externalApiService.getUserAge(userId);

        return new User(userName,userAge);
    }
}

External Service를 사용하는 UserService 클래스를 생성했다.

 

public class ApiController {

    @Autowired
    private UserService userService;

    @GetMapping("/users/{userId}/profile")
    public User getUser(@PathVariable(value="userId") String userId){
        return userService.getUser(userId);
    }
}

Controller 패키지에 ApiContorller 클래스를 생성.

 

 

그러면 캐싱이 들어가지 않은 상태에서 실험을 해보겠다. 

 

http://localhost:8080/users/A/profile URL을 요청하고, 개발자 도구(F12)에서 

Timing 탭을 들어가 보면 1.23s 걸린 걸 확인할 수 있다. 

여러 번 새로고침해도 1s가 걸린다.

 

이 상태에서 Redis를 활용해서 Cache를 걸어보겠다. 

 

@Service
public class UserService {

    @Autowired
    private ExternalApiService externalApiService;

    @Autowired
    StringRedisTemplate redisTemplate;

    public User getUser(String userId){

        String userName =null;

        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        String cachedName = ops.get("nameKey:" + userId);

        if(cachedName!=null){
            userName = cachedName;
        } else{
            userName = externalApiService.getUserName(userId);
            ops.set("nameKey:"+userId,userName,5, TimeUnit.SECONDS);
        }
        int userAge = externalApiService.getUserAge(userId);

        return new User(userName,userAge);
    }
}

userName에 캐싱을 적용시켰다.

 

userName을 처음 호출할 때는 5초 동안 Redis에 nameKey를 저장하고, 기존에 userName이 있다면 캐싱을 적용시켜 외부 Service API가 호출하지 않도록 설정하였다. 

 

 

http://localhost:8080/users/B/profile. 이번에는 B를 넣어서 테스트해보겠다.

 

처음에는 1.25초 걸린 게

 

이후에는 0.5초로 줄어든 걸 확인할 수 있다.

 

로그를 확인해 보면 외부 서비스의 Username 함수가 호출이 안 된 걸 볼 수 있다.

 

Redis를 확인해보면 이전에는 없던 keys들이, URL에 접속하면 5초 동안 nameKey:B가 생성된 걸 확인할 수 있다.

 

이렇게 cash-aside가 간편해 보이지만, 많이 사용된다.

 

3. SpringBoot로 캐싱 구현하기

이제 cachedManager를 통해 캐시 인터페이스를 구현해 보자. 

 

스프링은 메서드에 어노테이션을 붙이면 캐시를 손쉽게 적용할 수 있다. 아래는 캐싱 관련 어노테이션의 설명.

  • @Cacheable: 메소드에 캐시를 적용. (Cache-Aside 패턴)
  • @CachePut: 메서드의 리턴값을 캐시에 설정.
  • @CacheEvict: 메소드의 키값을 기반으로 캐시를 삭제.

먼저 yml 파일에 위와 같이 설정해 준다. Redis 캐시 구현체를 사용한다는 의미이다.

 

메인 클래스에 가서 @EnableCaching 어노테이션을 붙여서 캐싱을 사용한다는 걸 설정한다.

 

package com.example.pratice.caching.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class ExternalApiService {


    public String getUserName(String userId){

        try {
            Thread.sleep(500);
        }catch(InterruptedException e){
        }

        log.info("Getting User name from other service");

        if(userId.equals("A")){
            return "Kim";
        }
        if(userId.equals("B")){
            return "Son";
        }

        return "";
    }

    @Cacheable(cacheNames = "userAgeCache",key= "#userId")
    public int getUserAge(String userId){

        try {
            Thread.sleep(500);
        }catch(InterruptedException e){
        }

        log.info("Getting User age from other service");

        if(userId.equals("A")){
            return 28;
        }
        if(userId.equals("B")){
            return 32;
        }

        return 0;
    }


}

이번에는 외부 age 함수 호출에 cache를 적용시켰다.@Cacheable(cacheNames = "userAgeCache",key= "#userId") 에서 캐시 이름과 key를 지정했다.

 

http://localhost:8080/users/A/profile URL에 실험을 해보면 

 

2번 호출할 때 11.4ms로 속도가 확연히 준 것을 확인할 수 있다. 

 

redis를 확인해 보면 userAgeCache라는 key값이 정상적으로 등록된 것을 확인할 수 있다. 

 

이제 age에 관한 cache 시간을 설정해 보겠다.

 

먼저 config 패키지를 추가하자.

 

@Configuration
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory){
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(10)) // 기본 TTL
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
                );

        HashMap<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("userAgeCache",RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5))); //특정 캐시에 대한 TTL

        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(connectionFactory)
                .cacheDefaults(configuration)
                .withInitialCacheConfigurations(configMap)
                .build();
    }
}

다음과 같은 Redis 설정을 해줬다. 여기서 중요한 점은 configMap을 통해 내가 원하는 대로 커스터마이징 할 수 있다

        HashMap<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("userAgeCache",RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5)));
설정으로 userAgeCache에만 유효시간을 5초 지정했다. 

 

 

먼저 redis의 데이터를 삭제한다.

 

 

http://localhost:8080/users/A/profile URL에 실험을 해보면 

먼저 Redis에 남아있었지만, 5초 이후부터는 Redis에 사라진 걸 확인할 수 있다.

 

 

 

그러면 둘 중에 어떤 방식이 좋을까? 스프링이 제공하는 캐싱을 사용하는 게 좋다

 

    public User getUser(String userId){

        String userName =null;

        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        String cachedName = ops.get("nameKey:" + userId);

        if(cachedName!=null){
            userName = cachedName;
        } else{
            userName = externalApiService.getUserName(userId);
            ops.set("nameKey:"+userId,userName,5, TimeUnit.SECONDS);
        }
   }

서비스 계층에서는 순수한 비즈니스 로직만 있으면 좋다. 그러나 위의 코드는 해싱에 대한 코드가 섞여있다. 이러면 가독성이 떨어지고, 코드의 응집력이 떨어진다

 

 

    @Cacheable(cacheNames = "userAgeCache",key= "#userId")
    public int getUserAge(String userId){

        try {
            Thread.sleep(500);
        }catch(InterruptedException e){
        }

        log.info("Getting User age from other service");

        if(userId.equals("A")){
            return 28;
        }
        if(userId.equals("B")){
            return 32;
        }

        return 0;
    }

반면 age 함수는 내부에는 함수가 하는 역할만 구현되고, 캐싱 적용 유무는 어노테이션이 제공하는 별도의 설정을 통해서 손쉽게 적용할 수 있다. 

728x90