Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat/CK-235] Redis 캐시를 적용한다 #197

Merged
merged 6 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions backend/kirikiri/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,6 @@ dependencies {
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured'

// test-containers
testImplementation "org.testcontainers:testcontainers:1.19.1"
}

def generated = 'build/generated'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,52 @@
package co.kirikiri.common.config;

import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableCaching
@Profile(value = {"prod", "dev", "local"})
public class RedisConfig {

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

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

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}

@Bean
public RedisTemplate<String, String> redisTemplate() {
public RedisTemplate<String, String> redisTemplate(final RedisConnectionFactory redisConnectionFactory) {
final RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}

@Bean
public CacheManager redisCacheManager(final RedisConnectionFactory redisConnectionFactory) {
final ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.EVERYTHING);

final RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

final RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30L))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));

return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,10 @@
package co.kirikiri.persistence.auth;

import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class RefreshTokenRepository {
public interface RefreshTokenRepository {

private final RedisTemplate<String, String> redisTemplate;
private final Long refreshTokenValidityInSeconds;
void save(final String refreshToken, final String memberIdentifier);

public RefreshTokenRepository(final RedisTemplate<String, String> redisTemplate,
@Value("${jwt.refresh-token-validity-in-seconds}") final Long refreshTokenValidityInSeconds) {
this.redisTemplate = redisTemplate;
this.refreshTokenValidityInSeconds = refreshTokenValidityInSeconds;
}

public void save(final String refreshToken, final String memberIdentifier) {
final long timeToLiveSeconds = refreshTokenValidityInSeconds / 1000;

redisTemplate.opsForValue()
.set(refreshToken, memberIdentifier, timeToLiveSeconds, TimeUnit.SECONDS);
}

public Optional<String> findMemberIdentifierByRefreshToken(final String refreshToken) {
final String memberIdentifier = redisTemplate.opsForValue().get(refreshToken);
if (memberIdentifier == null) {
return Optional.empty();
}
return Optional.of(memberIdentifier);
}
Optional<String> findMemberIdentifierByRefreshToken(final String refreshToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package co.kirikiri.persistence.auth;

import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {

private final RedisTemplate<String, String> redisTemplate;
private final Long refreshTokenValidityInSeconds;

public RefreshTokenRepositoryImpl(final RedisTemplate<String, String> redisTemplate,
@Value("${jwt.refresh-token-validity-in-seconds}") final Long refreshTokenValidityInSeconds) {
this.redisTemplate = redisTemplate;
this.refreshTokenValidityInSeconds = refreshTokenValidityInSeconds;
}

@Override
public void save(final String refreshToken, final String memberIdentifier) {
final long timeToLiveSeconds = refreshTokenValidityInSeconds / 1000;

redisTemplate.opsForValue()
.set(refreshToken, memberIdentifier, timeToLiveSeconds, TimeUnit.SECONDS);
}

@Override
public Optional<String> findMemberIdentifierByRefreshToken(final String refreshToken) {
final String memberIdentifier = redisTemplate.opsForValue().get(refreshToken);
if (memberIdentifier == null) {
return Optional.empty();
}
return Optional.of(memberIdentifier);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package co.kirikiri.service;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Collectors;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.stereotype.Component;

@Component
public class CacheKeyGenerator implements KeyGenerator {

private static final String DELIMITER = "_";

@Override
public Object generate(final Object target, final Method method, final Object... params) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 파라미터까지 하나씩 다 받고 key에 추가해줄까 잘 몰랐는데 생각해보니 파라미터까지 동일한 요청인지 판별해야 해서 그런거더라구요. 좋은데요? 😀

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 머리로는 이게 일단 최선이라고 생각했는데 캐시 key를 생성하는 전략에 대해서 좀 고민해봐야할거 같아요. 다 같이 한번 얘기해봤으면 좋겠어요!

return String.format("%s-%s-%s",
target.getClass().getSimpleName(),
method.getName(),
arrayToDelimitedString(params));
}

private Object arrayToDelimitedString(final Object... params) {
return Arrays.stream(params)
.map(o -> o == null ? "" : String.valueOf(o.hashCode()))
.collect(Collectors.joining(DELIMITER));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import co.kirikiri.service.mapper.RoadmapMapper;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -56,6 +57,7 @@ public class RoadmapCreateService {
private final RoadmapCategoryRepository roadmapCategoryRepository;
private final ApplicationEventPublisher applicationEventPublisher;

@CacheEvict(value = "roadmapList", allEntries = true)
public Long create(final RoadmapSaveRequest request, final String identifier) {
final Member member = findMemberByIdentifier(identifier);
final RoadmapCategory roadmapCategory = findRoadmapCategoryById(request.categoryId());
Expand Down Expand Up @@ -156,6 +158,7 @@ private void validateReviewCount(final Roadmap roadmap, final Member member) {
}
}

@CacheEvict(value = {"roadmapList", "roadmap"}, allEntries = true)
public void deleteRoadmap(final String identifier, final Long roadmapId) {
final Roadmap roadmap = findRoadmapById(roadmapId);
validateRoadmapCreator(roadmapId, identifier);
Expand All @@ -172,6 +175,7 @@ private void validateRoadmapCreator(final Long roadmapId, final String identifie
.orElseThrow(() -> new ForbiddenException("해당 로드맵을 생성한 사용자가 아닙니다."));
}

@CacheEvict(value = "categoryList", allEntries = true)
public void createRoadmapCategory(final RoadmapCategorySaveRequest roadmapCategorySaveRequest) {
final RoadmapCategory roadmapCategory = RoadmapMapper.convertToRoadmapCategory(roadmapCategorySaveRequest);
roadmapCategoryRepository.findByName(roadmapCategory.getName())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import java.net.URL;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -68,6 +69,7 @@ public class RoadmapReadService {
private final MemberRepository memberRepository;
private final FileService fileService;

@Cacheable(value = "roadmap", keyGenerator = "cacheKeyGenerator", cacheManager = "redisCacheManager")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로드맵 조회와 로드맵 목록 조회 등등 각각의 API 마다 레디스에 따로 저장하게 되는 방식인거죠?
그렇다면 카테고리와 정렬조건에 따른 모든 경우에 수로 레디스에 저장이 되는건가요?
최대 카테코리(12개) * 정렬조건(3개) * 로드맵 수의 데이터가 저장이 될 수 있을 것 같은데,
만약 그렇다면 각각의 API 마다 저장하는데 그 안에서도 경우의 수가 너무 많아서 한 번에 많은 사용자가 접속한다면 메모리가 터지는 일이 발생하지 않을까요..? 😮 어느 정도 적재되어야 터질지는 잘 모르겠지만 최악의 경우를 생각해봤어요!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

썬샷이 남겨주신 것 처럼 어느 기준치를 넘었을 때는 삭제되거나 어떤 조치가 취해질 것 같은데,
그렇다면 정말 조회가 빈번하게 일어나는 로드맵에 대해서만 레디스에 저장하는 방식이 더 좋을 것 같아요!
예를 들어 로드맵을 최근 100개까지만 저장하는식으로요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런 경우를 대비해서 ttl을 30분으로 잡았는데 더 복잡한 방법은 직접 AOP 메소드를 구현해서 할 수 있을 거 같아요! 다같이 전략을 정해보면 어떨까요

public RoadmapResponse findRoadmap(final Long id) {
final Roadmap roadmap = findRoadmapById(id);
final RoadmapContent recentRoadmapContent = findRecentContent(roadmap);
Expand Down Expand Up @@ -128,6 +130,7 @@ private RoadmapContent findRecentContent(final Roadmap roadmap) {
.orElseThrow(() -> new NotFoundException("로드맵에 컨텐츠가 존재하지 않습니다."));
}

@Cacheable(value = "roadmapList", keyGenerator = "cacheKeyGenerator", cacheManager = "redisCacheManager")
public RoadmapForListResponses findRoadmapsByOrderType(final Long categoryId,
final RoadmapOrderTypeRequest orderTypeRequest,
final CustomScrollRequest scrollRequest) {
Expand All @@ -148,7 +151,7 @@ private RoadmapCategory findCategoryById(final Long categoryId) {
.orElseThrow(() -> new NotFoundException("존재하지 않는 카테고리입니다. categoryId = " + categoryId));
}

public RoadmapForListScrollDto makeRoadmapForListScrollDto(final List<Roadmap> roadmaps, final int requestSize) {
private RoadmapForListScrollDto makeRoadmapForListScrollDto(final List<Roadmap> roadmaps, final int requestSize) {
final List<RoadmapForListDto> roadmapForListDtos = roadmaps.stream()
.map(this::makeRoadmapForListDto)
.toList();
Expand Down Expand Up @@ -200,6 +203,7 @@ public RoadmapForListResponses search(final RoadmapOrderTypeRequest orderTypeReq
return RoadmapMapper.convertRoadmapResponses(roadmapForListScrollDto);
}

@Cacheable(value = "categoryList", keyGenerator = "cacheKeyGenerator", cacheManager = "redisCacheManager")
public List<RoadmapCategoryResponse> findAllRoadmapCategories() {
final List<RoadmapCategory> roadmapCategories = roadmapCategoryRepository.findAll();
return RoadmapMapper.convertRoadmapCategoryResponses(roadmapCategories);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package co.kirikiri.integration.helper;

import co.kirikiri.persistence.helper.RedisTestContainer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -18,7 +17,7 @@
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestConstructor(autowireMode = AutowireMode.ALL)
@Import({TestConfig.class, RedisTestContainer.class})
@Import(TestConfig.class)
public class IntegrationTest {

@LocalServerPort
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package co.kirikiri.integration.helper;

import co.kirikiri.persistence.auth.RefreshTokenRepository;
import co.kirikiri.persistence.goalroom.GoalRoomMemberRepository;
import co.kirikiri.persistence.goalroom.GoalRoomRepository;
import co.kirikiri.service.FileService;
Expand Down Expand Up @@ -27,4 +28,9 @@ public FileService fileService() {
public TestTransactionService testTransactionService() {
return new TestTransactionService(goalRoomRepository, goalRoomMemberRepository);
}

@Bean
public RefreshTokenRepository refreshTokenRepository() {
return new TestRefreshTokenRepository();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package co.kirikiri.integration.helper;

import static co.kirikiri.integration.fixture.MemberAPIFixture.DEFAULT_IDENTIFIER;

import co.kirikiri.persistence.auth.RefreshTokenRepository;
import java.util.Optional;

public class TestRefreshTokenRepository implements RefreshTokenRepository {
younghoondoodoom marked this conversation as resolved.
Show resolved Hide resolved

@Override
public void save(final String refreshToken, final String memberIdentifier) {
}

@Override
public Optional<String> findMemberIdentifierByRefreshToken(final String refreshToken) {
return Optional.of(DEFAULT_IDENTIFIER);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.when;

import co.kirikiri.domain.member.EncryptedPassword;
import co.kirikiri.domain.member.Gender;
Expand All @@ -10,23 +11,39 @@
import co.kirikiri.domain.member.vo.Identifier;
import co.kirikiri.domain.member.vo.Nickname;
import co.kirikiri.domain.member.vo.Password;
import co.kirikiri.persistence.helper.RedisTest;
import java.util.Optional;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

class RefreshTokenRepositoryTest extends RedisTest {
@ExtendWith(MockitoExtension.class)
class RefreshTokenRepositoryTest {

private static final Long refreshTokenValidityInSeconds = 3600000L;

private static Member member;

private RefreshTokenRepository refreshTokenRepository;
@Mock
private RedisTemplate<String, String> redisTemplate;

@Mock
private ValueOperations<String, String> valueOperations;

private RefreshTokenRepositoryImpl refreshTokenRepository;

@BeforeEach
void init() {
refreshTokenRepository = new RefreshTokenRepository(redisTemplate, 2000L);
when(redisTemplate.opsForValue())
.thenReturn(valueOperations);
refreshTokenRepository = new RefreshTokenRepositoryImpl(redisTemplate, refreshTokenValidityInSeconds);
}


@BeforeAll
static void setUp() {
final Identifier identifier = new Identifier("identifier1");
Expand Down Expand Up @@ -55,7 +72,8 @@ static void setUp() {
final String refreshToken = "refreshToken";
final String memberIdentifier = member.getIdentifier().getValue();

refreshTokenRepository.save(refreshToken, memberIdentifier);
when(valueOperations.get(refreshToken))
.thenReturn(memberIdentifier);

//when
final String findMemberIdentifier = refreshTokenRepository.findMemberIdentifierByRefreshToken(refreshToken)
Expand Down

This file was deleted.

Loading
Loading