PortfoGram에 Redis 도입하기 2탄 ( 심화편 )

Mini_me·2023년 7월 13일
0

공부 [Spring]

목록 보기
23/27

들어가기 전에

분명 전글에 Redis에 관한 포스팅이었는데, 왜 이 사람은 또 Redis에 대해서 글을 쓰는 걸까? 🤔 라고 궁금해하신다면 대답해드리는게 인지상정! 이유는 간단하다. ✨ 비로소 Redis에 대해서 더 깊이 이해하게 됐고 어떻게 구현해야 되는지 이해가 되어서 그 동안 프로젝트에 Redis를 이용한 코드의 동작 과정을 설명할 겸 Redis에 대한 심화편 포스팅을 작성하게 되었다.

🕵️‍♂️ 캐싱

전 글에서 Redis를 캐싱을 위한 용도로 쓴다고 적었다.
사전적인 정의를 인용하여 나중에 요청 할 결과를 미리 저장해 둔 후 빠르게 서비스 해주는 것을 의미한다.
좀 더 짦게 말하자면 캐싱이란 " 접근하는 시간을 단축시키기 위해 더욱 빠른 메모리에 데이터를 저장해두는 것이다." 🚀
그러면 주로 어떤 데이터를 캐싱하는 걸까? " 자주 조회되는 데이터 / 연산에 비용이 많이 드는 데이터 " 를 주로 캐싱한다. 🧐

🔍 Redis의 특징에 대해 심화편으로 알아보자

레디스의 첫 번째 특징은 In-Memory 데이터베이스라는 특징이다.

🤖 In-Memory 데이터베이스?

  • In-Memory 데이터베이스는 주 메모리에서 동작하므로 액세스 속도가 빠르다. 💨
    그렇기 때문에 캐시 목적으로 사용하기에 용이하다.
  • Redis 서버가 종료되면 메모리에서 휘발된다. -> 메모리에 다시 로드하는 기능을 제공한다. 🔁

싱글 스레드로 동작한다. -> 한 번에 하나의 명령만을 처리한다. 💼

다양한 자료구조를 지원한다.📚

이에 관해서는 전글에서 사진 첨부를 통해 짦게 언급하였다. 그래서 이번 글에서는 어떤 자료구조를 지원하는지 좀 더 자세하게 설명하겠다.🔍

  1. 문자열(String): 가장 기본적인 문자열, SET 커맨드로 저장되는 건 기본적으로 String이다.
    만약 Hash 형태로 저장해야 하는 데이터를 String으로 저장하게 되면 어떻게 될까?
    단일 값 조회시, 전체 문자열을 검색하여 파싱한다. 업데이트 시 전체 문자열에서 일부 변경하고 -> 다시 전체를 덮어씌워야 한다. --> 두 개 이상의 앱에서 이 작업을 수행할 경우 결과 덮어씌워진다. ⚠️
  2. 해시(Hash): 문자열 필드와 해당 필드에 대한 값으로 구성된다. 각 필드에 개별적으로 접근 가능하다.
    Hash의 경우, 단일 조회 및 업데이트가 가능하기 때문에 대부분의 해시 명령이 O(1)의 시간복잡도를 가진다.
  3. 정렬된 집합(Sorted sets): Set과 같지만, 이름처럼 Score 값을 가지고 그에 따라 정렬된다.
    예시로는 랭킹 순으로 정렬, 게시시간을 score 로 두고 시간별 정렬이 있다.
  4. 리스트(List): 0부터 시작하는 인덱스를 가지며, 삽입 순으로 유지한다.
  5. 정수(Int): 단순 카운트 용도로 Redis를 사용할 경우 Int를 사용한다.
  6. 스트림(Streams): 다수의 메시지를 시간순서로 저장하고 처리하는데에 용이하다.
    이 외에도 여러 가지 자료구조들이 존재한다.

Twitter의 사례를 PortfoGram에 적용

트위터의 경우 타임라인에 보일 게시글 캐싱 용도로 List를 사용한다.
자주 사용하는 사용자의 타임라인을 캐싱하는 방법에 대한 간단한 과정이다.
다음과 같은 단계로 이루어진다.
마지막 로그인 시간이 N일 이내인 사용자의 Id를 key로 설정합니다.
사용자가 글을 작성하면, 그 사용자의 팔로워들의 Id를 key로 하여, 작성한 트윗의 Id를 추가한다.

Step 1: 마지막 로그인 시간이 N일 이내인 사용자 팔로워 확인
----------------------------------------------------
 사용자 A                ->       사용자 B (A 팔로워) 
 마지막 로그인 시간          마지막 로그인 시간 N일 이내

Step 2: 사용자 A가 글 작성
----------------------------------------------------
 사용자 A                            사용자 B (A 팔로워)
 글 작성 (트윗 X)                트윗 X Id 추가 대기

Step 3: 팔로워 타임라인에 트윗 추가
----------------------------------------------------
 사용자 A                            사용자 B (A 팔로워)
 글 작성 (트윗 X)                타임라인에 트윗 X Id 추가

좀 더 이해하기 쉽게 그림을 그려왔다.

사용자가 글을 작성한다. 새글은 ID = 1111 로 저장된다.

그리고나서 DB에 저장이되고, REDIS의 타임라인 캐시에 트윗 데이터가 저장된다.

팔로워들의 ID를 KEY로 갖는 타임라인 캐시에 새로 작성된 트윗 ID가 추가된다.

팔로우한 유저가 트위터에 접속하게 되면, 미리 캐시해둔 타임라인의 트윗을 불러온다.

그렇다면 만약에 팔로우가 많은 제니나 손흥민의 경우에도 타임라인 리스트에 모두 추가할까?

답은 x다.

팔로워가 매우 많은 사용자의 경우, 사용자의 타임라인에 따로 셀럽의 게시글 타임라인을 합친다.

그렇다면 PortfoGram에 트위터의 타임라인 캐시 방법을 적용한 과정을 설명하겠다.

팔로워한 포트폴리오 조회 및 포트폴리오 저장 시 , 과정은 위에 설명했던 것처럼 비슷하게 구현했다.

코드를 보며 구현한 과정을 설명하겠다.

먼저 사용자가 글을 작성한다.

    @Transactional
    public void savePortfolio(String content, List<MultipartFile> imageFiles) {
        UserEntity user = userService.getMyUserWithAuthorities();
        PortfolioEntity portfolioEntity = PortfolioEntity.builder()
                .user(user)
                .content(content)
                .build();

        PortfolioEntity savedPortfolioEntity = portfolioRepository.save(portfolioEntity);

        Images uploadedImages = portfolioImageService.uploadImage(imageFiles);
        List<Image> imageList = uploadedImages.getImages();
        for (Image image : imageList) {
            PortfolioImageEntity portfolioImageEntity = PortfolioImageEntity.builder()
                    .image(image.toImageEntity())
                    .portfolio(savedPortfolioEntity)
                    .build();
            savedPortfolioEntity.addImage(portfolioImageEntity);
        }

        cacheLatestPortfolioForUser(user.getId().toString(), savedPortfolioEntity.getId());

        List<Long> followerIds = user.getFollowerIds();
        cacheNewPortfolioForFollowers(savedPortfolioEntity.getId().toString(), followerIds, savedPortfolioEntity.getCreatedAt().getTime());   }
    private void cacheLatestPortfolioForUser(String userId, Long portfolioId) {
        String redisKey = "user:" + userId+ ":portfolios";
        double timestamp = System.currentTimeMillis();

        stringRedisTemplate.opsForZSet().add(redisKey, portfolioId.toString(), timestamp);
    }
    private void cacheNewPortfolioForFollowers(String newPortfolioId, List<Long> followerIds, double timestamp) {
        followerIds.forEach(followerId -> {
            String redisKey = "user:" + followerId + ":portfolios";
            stringRedisTemplate.opsForZSet().add(redisKey, newPortfolioId, timestamp);
        });
    }

코드의 길이가 꽤 길어 복잡해 보인다. 하나하나 자세히 살펴보자


        UserEntity user = userService.getMyUserWithAuthorities();
        PortfolioEntity portfolioEntity = PortfolioEntity.builder()
                .user(user)
                .content(content)
                .build();

        PortfolioEntity savedPortfolioEntity = portfolioRepository.save(portfolioEntity);

        Images uploadedImages = portfolioImageService.uploadImage(imageFiles);
        List<Image> imageList = uploadedImages.getImages();
        for (Image image : imageList) {
            PortfolioImageEntity portfolioImageEntity = PortfolioImageEntity.builder()
                    .image(image.toImageEntity())
                    .portfolio(savedPortfolioEntity)
                    .build();
            savedPortfolioEntity.addImage(portfolioImageEntity);
        }

이부분은 작성한 포트폴리오가 DB에 저장되는 코드이다.

이후

cacheLatestPortfolioForUser(user.getId().toString(), savedPortfolioEntity.getId());

redis에 작성한 포트폴리오를 저장하는 함수를 호출한다.

   private void cacheLatestPortfolioForUser(String userId, Long portfolioId) {
        String redisKey = "user:" + userId+ ":portfolios";
        double timestamp = System.currentTimeMillis();

        stringRedisTemplate.opsForZSet().add(redisKey, portfolioId.toString(), timestamp);
    }

이 떄 list로도 사용할 수 있겠지만, 나는 Sorted Sets를 이용했다.
그 이유는 다음과 같다.

  1. 포트폴리오의 정렬된 순서 유지: Sorted Sets을 사용하면 포트폴리오를 생성 시간 순서나 스코어에 따라 쉽게 정렬하여 저장할 수 있다. 따라서 사용자에게 최신 포트폴리오를 제공하기에 적합한 방법이다.
  2. 시간 복잡도: Sorted Sets는 적은 시간 복잡도(O(log(N)))를 가진 연산을 제공하기에 효율적이다. 예를 들어, getLatestPortfolios()에서 redisTemplate.opsForZSet().reverseRange(redisKey, 0, -1)를 사용하면 최신 포트폴리오를 O(log(N))에 가져올 수 있어 빠른 결과를 얻는다.
  3. 효율적인 범위 조회: Sorted Sets의 reverseRange, rangeByScore, range 등의 메서드를 사용하여 특정 범위의 값 또는 스코어를 가진 포트폴리오를 쉽게 가져올 수 있다.
 List<Long> followerIds = user.getFollowerIds();
   private void cacheNewPortfolioForFollowers(String newPortfolioId, List<Long> followerIds, double timestamp) {
        followerIds.forEach(followerId -> {
            String redisKey = "user:" + followerId + ":portfolios";
            stringRedisTemplate.opsForZSet().add(redisKey, newPortfolioId, timestamp);
        });
    }

받은 팔로워들의 ID 목록인 followerIds를 forEach를 사용해 순회하며 해당 사용자의 Redis 캐시에 새로운 포트폴리오 정보를 저장한다.

실행시키게 되면

올바르게 저장이 된다.

트러블 슈팅

구현과정이 순탄치 만은 않았다.

redis를 이용해 redis/ db에 저장하는 로직은 금방구현했으나, 자바 직렬화, 역직렳화

그니깐 내부적으로 바이트스트림으로 저장돼서, 자바 어플리케이션 -> redis서버, redis->자바
이 사이에 직렬화 , 역직렬화가 필요한데 설정을 이상하게 해줘서 자꾸 오류가 났다.

RedisConfig에서 <String,Object> redistemplate 이것만 빈 주입해줘서
<String, String> 의 형식을 이용하는 경우에는 value값이 직렬화되지않고 저장되었다.

그래서 <String,String> stringRedisTemplate를 작성하여 빈 주입해서

직렬화 , 역직렬화에 성공하였다.

RedisConfig.java



@Configuration
public class RedisConfig {

    @Value("${spring.redis.port}")
    private int redisPort;

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.password}")
    private String redisPassword;



    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(redisHost, redisPort);
        redisConfig.setPassword(redisPassword);
        return new LettuceConnectionFactory(redisConfig);
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(fromSerializer(new Jackson2JsonRedisSerializer<>(Object.class)))
                .entryTtl(Duration.ofMinutes(3L)); //임시 설정값

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }



    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.deactivateDefaultTyping(); // 타입 정보 비활성화

        return objectMapper;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // Key Serializer configuration
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());

        // Value Serializer configuration
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));

        return redisTemplate;
    }

    @Bean
    public RedisTemplate<String, String> StringredisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, String> StringredisTemplate = new RedisTemplate<>();
        StringredisTemplate.setConnectionFactory(redisConnectionFactory);

        // Key Serializer configuration
        StringredisTemplate.setKeySerializer(new StringRedisSerializer());
        StringredisTemplate.setHashKeySerializer(new StringRedisSerializer());

        // Value Serializer configuration
        StringredisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        StringredisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));

        return StringredisTemplate;
    }
}

PortfolioService.java


@Service
@RequiredArgsConstructor
public class PortfolioService {
    private final PortfolioRepository portfolioRepository;
    private final UserService userService;
    private final PortfolioImageService portfolioImageService;
    private final RedisTemplate redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;
    @Transactional
    public void savePortfolio(String content, List<MultipartFile> imageFiles) {
        UserEntity user = userService.getMyUserWithAuthorities();
        PortfolioEntity portfolioEntity = PortfolioEntity.builder()
                .user(user)
                .content(content)
                .build();

        PortfolioEntity savedPortfolioEntity = portfolioRepository.save(portfolioEntity);

        Images uploadedImages = portfolioImageService.uploadImage(imageFiles);
        List<Image> imageList = uploadedImages.getImages();
        for (Image image : imageList) {
            PortfolioImageEntity portfolioImageEntity = PortfolioImageEntity.builder()
                    .image(image.toImageEntity())
                    .portfolio(savedPortfolioEntity)
                    .build();
            savedPortfolioEntity.addImage(portfolioImageEntity);
        }

        cacheLatestPortfolioForUser(user.getId().toString(), savedPortfolioEntity.getId());

        List<Long> followerIds = user.getFollowerIds();
        cacheNewPortfolioForFollowers(savedPortfolioEntity.getId().toString(), followerIds, savedPortfolioEntity.getCreatedAt().getTime());   }
    private void cacheLatestPortfolioForUser(String userId, Long portfolioId) {
        String redisKey = "user:" + userId+ ":portfolios";
        double timestamp = System.currentTimeMillis();

        stringRedisTemplate.opsForZSet().add(redisKey, portfolioId.toString(), timestamp);
    }
    private void cacheNewPortfolioForFollowers(String newPortfolioId, List<Long> followerIds, double timestamp) {
        followerIds.forEach(followerId -> {
            String redisKey = "user:" + followerId + ":portfolios";
            stringRedisTemplate.opsForZSet().add(redisKey, newPortfolioId, timestamp);
        });
    }

이렇게 Redis의 심화편이 끝이났다.

Hits

0개의 댓글