FreeHand

[Redis] 처음 만난 레디스 2 본문

Database

[Redis] 처음 만난 레디스 2

Jinn 2024. 6. 17. 00:21

 

 

 

시작하며

지난번에 Redis의 특징과 데이터 타입에 대해 정리했었다. 이번에는 캐시에 대한 전반적인 개념과 스프링 부트 프로젝트에서 다양한 상황에 적용할 수 있는 redis사용법을 정리하려고 한다. 스프링 (부트) 기반으로 간단한 프로젝트를 해봤다면 쉽게 이해할 수 있을 것 같다.

 

 

프로젝트 설정

Spring Boot에서 Redis를 사용하려면 역시 의존성을 추가해야 한다.

나는 Spring Initializr에서 Spring Data Redis 의존성을 추가했다.

 

 

build.gradle의 dependencies에 아래 코드를 추가해도 된다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

의존성 추가 이후에는 application설정 파일에 설정을 추가해야 한다.

나는 yml파일에 다음 코드를 작성했다.

spring:
  redis:
    host: localhost
    port: 6379

 

설정이 끝나면 컨트롤러를 하나 생성해서 redis를 사용할 수 있다.

@RequiredArgsConstructor
@RestController
public class HelloController {

    private final StringRedisTemplate redisTemplate;

    @GetMapping("/setFruit")
    public String setFruit(@RequestParam String name) {
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        ops.set("fruit", name);
        return "saved";
    }

    @GetMapping("/getFruit")
    public String setFruit() {
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        return ops.get("fruit");
    }
}

StringRedisTemplate을 주입받고 연산을 하기 위한 ValueOperations를 가져와서 사용한다.

 

set 메서드를 통해 redis의 String 타입의 set 명령을 수행할 수 있다. fruit이라는 key에 쿼리파라미터로 받은 name을 value로 저장하고 있다.

 

get도 실제 redis의 사용법과 동일하다. key를 지정해서 value를 가져올 수 있다.

key-value 구조이기 때문에 자바의 Map과 유사하게 사용한다.

 

localhost:8080/setFruit?name=apple로 get 요청을 보내면 화면에 saved가 출력된다.

그리고 localhost:8080/getFruit으로 요청을 보내면 apple이 화면이 보이게 된다.

 

 

분산 환경에서의 Session Store

웹 애플리케이션에서 클라이언트가 로그인을 하면 서버는 유저 정보를 확인 후 세션을 생성/저장하고 세션 ID를 클라이언트에게 http의 set-cookie필드를 통해 전달한다. 그리고 로그인된 유저는 요청을 보낼 때 해당 세션 ID를 같이 보낸다.

http의 무상태 특징 때문에 서버는 세션을 통해 클라이언트를 구분하는 것이다.

 

그런데 서버가 여러 개인 분산 환경을 생각해 보자.

분산 환경에서는 로그인한 서버가 아닌 서버는 세션 정보를 알 수 없다.

@RestController
public class LoginController {

    private final HashMap<String, String> sessionMap = new HashMap<>();

    @GetMapping("/login")
    public String login(HttpSession session, @RequestParam String name) {
        sessionMap.put(session.getId(), name);
        return "saved";
    }

    @GetMapping("/myName")
    public String myName(HttpSession session) {
        return sessionMap.get(session.getId());
    }
}

위 코드는 간단히 테스트를 하기 위한 코드이다.

/login으로 요청을 보내면 JSESSIONID가 생성되고 쿼리파라미터로 보낸 name이 sessionMap에 저장된다.

/myName으로 요청을 보내면 sessionMap에서 해당 JSESSIONID의 값(name)을 반환한다.

 

그냥 실행하면 문제가 없지만 다른 포트로 2개를 실행하면 서로 세션 정보를 공유하지 못한다.

따라서 분산 환경에서는 세션 정보를 서버 간에 공유할 방법이 필요하다.

 

서버는 달라도 동일한 DB에 세션을 저장하고 가져오면 같은 세션 정보를 사용할 수 있다.

이때 다음과 같은 이유로 RDB에 저장하는 것이 아니라 redis 저장소에 저장하는 것이 좋다.

 

1. 세션 정보는 영속성이 필요 없다.

세션 정보는 계속해서 DB에 저장되어 있을 필요가 없는 데이터이다.

그리고 장애가 발생해서 데이터를 손실해도 크게 지장이 없다. 다시 로그인만 하면 된다.

 

2. 세션 정보는 복잡한 구조의 데이터일 필요 없다.

세션 정보는 세션 ID에 유저 정보를 담고 있는 데이터로, 복잡한 관계형 모델을 사용하지 않고 간단한 key-value 구조로 충분하다.

 

3. 빠른 속도

세션 정보는 로그인 후에 요청을 보낼 때 계속해서 사용한다.

따라서 RDB에 저장하면 계속해서 디스크 I/O가 발생하므로 느리다.

 

세션 정보를 redis 저장소에 저장하는 것은 간단하다.

spring:
  session:
    storage-type: redis
  redis:
    host: localhost
    port: 6379

yml 파일에 session.storage-type=redis를 추가하면 세션 정보가 redis를 통해 저장된다.

@RestController
public class LoginController {

    @GetMapping("/login")
    public String login(HttpSession session, @RequestParam String name) {
        session.setAttribute("name", name);
        return "saved";
    }

    @GetMapping("/myName")
    public String myName(HttpSession session) {
        return (String)session.getAttribute("name");
    }
}

 

 

이렇게 작성하면 서로 다른 포트로 실행을 해도 같은 세션을 사용하는 것을 확인할 수 있다.

 

 

서비스 속도를 높이기 위한 캐싱

캐시(Cache)는 CPU에 비해 비교적 느린 RAM에 접근하는 횟수를 줄이기 위해 CPU 안에 존재하는 기억 장치이다.

이를 바탕으로 일반적으로 캐시는 성능 향상을 위해 값을 복사해 놓는 임시 기억 장치를 말한다.

 

데이터를 캐시에 복사해서 읽음으로써 속도가 느린 장치로의 접근(네트워크 요청, 디스크 I/O 등) 횟수를 줄여서 속도를 개선할 수 있다. 브라우저와 서버 사이, 서버와 서버 사이, 서버와 DB 사이에서 캐시는 사용될 수 있다.

하지만 캐시의 데이터는 원본이 아니며 언제든 사라질 수 있다는 주의사항이 있다.

 

캐시를 사용하기에 앞서 관련 개념들을 알 필요가 있다.

  • 캐시 적중(Cache Hit): 캐시에 접근해 데이터를 발견함
  • 캐시 미스(Cache Miss): 캐시에 접근했지만 데이터를 찾지 못함
  • 캐시 삭제 정책(Eviction Policy): 캐시의 데이터 공간 확보를 위해 저장된 데이터를 삭제
  • 캐시 전략: 환경에 따라 적합한 캐시 운영 방식을 선택(Cache-Aside, Write-Through 등)

Cache-Aside(Lazy Loading)

항상 캐시를 먼저 확인하고 없으면 원본(DB) 데이터를 읽은 후에 캐시에 저장하는 방식이다.

 

장점: 필요한(자주 사용되는) 데이터만 캐시에 저장되고 Cache Miss가 있어도 치명적이지 않다.

단점: 최초 접근이 느리다, 업데이트 주기가 일정하지 않아 캐싱된 데이터가 최신 데이터가 아닐 수 있다.

 

Write-Through

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

 

장점: 캐시가 항상 최신 데이터이다.

단점: 자주 사용되지 않는 데이터도 캐싱되고 쓰기 지연 시간이 증가한다.

 

Write-Back

데이터를 캐시에만 쓰고 캐시 데이터를 일정 주기로 DB에 업데이트하는 방식이다.

 

장점: 쓰기가 많은 경우 DB 부하를 줄일 수 있다.

단점: DB에 쓰기 전에 장애 발생 시 데이터가 유실될 수 있다.

 

데이터 삭제 방식

'캐시에서 어떤 데이터를 언제 삭제할 것인가'에 대한 방식이다.

  • Expiration: 각 데이터에 TTL(Time To Live)을 설정해 시간 기반으로 삭제
  • Eviction Algorithm: 공간 확보가 필요할 때 어떤 데이터를 삭제할지 결정하는 방식
    • LRU(Least Recently Used): 가장 오랫동안 사용되지 않은 데이터를 삭제
    • LFU(Least Frequently Used): 가장 적게 사용된 데이터를 삭제
    • FIFO(First In First Out): 먼저 저장된 데이터를 삭제

 

이제 Cache-Aside 방식으로 캐시를 적용해 본다.

@RequiredArgsConstructor
@RestController
public class ApiController {

    private final UserService userService;

    @GetMapping("/users/{userId}/profile")
    public UserProfile getUserProfile(@PathVariable String userId) {
        return userService.getUserProfile(userId);
    }
}
@RequiredArgsConstructor
@Service
public class UserService {

    private final ExternalApiService externalApiService;

    public UserProfile getUserProfile(String userId) {
        String userName = externalApiService.getUserName(userId);
        int userAge = externalApiService.getUserAge(userId);
        
        return UserProfile.builder()
                .name(userName)
                .age(userAge)
                .build();
    }
}
@Service
public class ExternalApiService {

    public String getUserName(String userId) {
        // 외부 서비스 또는 DB 호출
        try {
            Thread.sleep(500);
        }
        catch (InterruptedException e) {
        }

        System.out.println("Getting user name from other service...");

        if (userId.equals("A")) {
            return "Adam";
        }
        if (userId.equals("B")) {
            return "Bob";
        }

        return "";
    }

    public int getUserAge(String userId) {
        // 외부 서비스 또는 DB 호출
        try {
            Thread.sleep(500);
        }
        catch (InterruptedException e) {
        }

        System.out.println("Getting user age from other service...");

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

        return 0;
    }
}

먼저 getUserName과 getUserAge 메서드는 각각 0.5초 이상의 시간이 걸리도록 코드를 작성했다.

UserProfile을 컨트롤러가 리턴하기까지 1초 이상의 시간이 예상된다.

 

실제로 Postman에서 호출했을 때 대략 1.17초가 걸렸다.

 

여기에 캐시를 적용하고 다시 시간을 확인해 보자.

@RequiredArgsConstructor
@Service
public class UserService {

    private final ExternalApiService externalApiService;
    private final StringRedisTemplate redisTemplate;

    public UserProfile getUserProfile(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); // 5초 동안 저장
        }

        int userAge = externalApiService.getUserAge(userId);

        return UserProfile.builder()
                .name(userName)
                .age(userAge)
                .build();
    }
}

name에 대해서만 캐시를 적용했다.

캐시에 있으면(null이 아니면) 캐시 데이터를 사용하고, 없으면 원본 데이터(여기선 externalApiService 호출)를 가져와 캐시에 저장한다.

처음 호출
캐싱된 후 호출

캐싱되기 전에 첫 호출은 1.67초로 캐시를 적용하기 전보다 조금 더 오래 걸렸다.

하지만 캐싱된 후에는 0.5초 정도 소요되었다. 

 

그리고 캐시를 적용한 name은 처음 한 번만 로그가 기록된 것을 볼 수 있다.

즉 0.5초가 걸리는 getUserName을 호출하지 않고 캐시에서 가져온 것이다.

 

 

Spring의 캐싱

위에서 redis를 사용해서 캐시를 직접 구현했는데 스프링이 제공하는 어노테이션으로 캐시를 쉽게 적용할 수 있다.

스프링은 다음 어노테이션을 제공한다.

  • @Cacheable: 메서드에 캐시를 적용(Cache-Aside 방식)
  • @CachePut: 메서드의 반환값을 캐시에 설정
  • @CacheEvict: 메서드의 키값으로 캐시를 삭제

캐시를 적용하지 않았던 age를 어노테이션으로 캐시를 적용해 보자.

spring:
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379
@EnableCaching
@SpringBootApplication
public class RedisPracticeApplication {
	public static void main(String[] args) {
		SpringApplication.run(RedisPracticeApplication.class, args);
	}
}

우선 yml 파일에 cache.type을 redis로 지정하고 main메서드가 있는 클래스에 @EnableCaching 어노테이션을 추가한다.

@Service
public class ExternalApiService {

    public String getUserName(String userId) {
        // 생략
    }

    @Cacheable(cacheNames = "userAgeCache", key = "#userId") // 캐시 적용
    public int getUserAge(String userId) {
        // 외부 서비스 또는 DB 호출
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
        }

        System.out.println("Getting user age from other service...");

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

        return 0;
    }
}

@Cacheable 어노테이션으로 쉽게 캐시를 적용했다.

 

캐싱된 후

name과 age 둘 다 캐시가 적용되었을 때는 24ms로 매우 빠른 응답 속도를 확인할 수 있다.

 

아래와 같이 Config 클래스를 통해 캐시와 관련된 여러 설정을 직접 작성할 수도 있다.

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

        HashMap<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("userAgeCache", RedisCacheConfiguration.defaultCacheConfig())
                .entryTtl(Duration.ofSeconds(5));
        
        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(connectionFactory)
                .cacheDefaults(configuration)
                .withInitialCacheConfigurations(configMap)
                .build();
    }
}

 

 

마치며

스프링 부트에서 Redis를 사용해서 간단하게 캐시를 적용해 봤다.

캐시 전략, 저장 시간, 데이터 삭제 방식 등 캐시를 사용할 때 고려해야 할 내용이 많은 것 같다.

그래도 필요한 곳에 캐시를 적용하면 서비스의 속도 개선에 도움이 많이 되는 것을 느꼈으니 유용하게 사용할 것 같다.

 

Redis에 대해서는 다룰 내용이 더 있어서 다음 글에서 추가적으로 정리할 예정이다.

'Database' 카테고리의 다른 글

옵티마이저와 실행계획  (4) 2024.09.24
[MongoDB] 몽고디비 입문  (0) 2024.06.22
[Redis] 처음 만난 레디스 1  (2) 2024.06.15
[MySQL] 데이터 타입과 형 변환  (0) 2023.11.05
[MySQL] WITH절과 CTE  (0) 2023.11.05