Skip to content

Commit

Permalink
feat: Elasticache for Redis를 도입하여 캐싱 적용 (#86)
Browse files Browse the repository at this point in the history
* feat: redis 의존성 추가

* feat: redis 관련 설정 추가

* feat: 카페 미리보기에 `@Cacheable` 설정 추가

* feat: 로컬 yml redis 설정 추가

* feat: 리뷰 또는 즐겨찾기 등록 시 캐시 삭제

* feat: 즐겨찾기 해제 시 캐시 삭제

* feat: 목적에 따른 CacheManager 분리 및 로그인 시 캐시 적용

* feat: Apple OAuth Public key 캐싱 적용

* refactor: 인증 캐시 ttl 변경 및 주석 추가

* fix: accessToken 캐시 만료 ttl 수정

* feat: 테스트용 내장 임베디드 의존성 및 환경변수 추가

* test: 테스트 임베디드 redis 설정

* test: 테스트 격리를 위한 `DatabaseCleaner` 캐시 제거

* test: 같은 메서드 내에 캐시로 남아있는 현상 제거

* style: 실제값 하나뿐인 변수명 `actual1` -> `actual` 변경
  • Loading branch information
kth990303 authored May 28, 2023
1 parent 1adcec5 commit 9cebc68
Show file tree
Hide file tree
Showing 15 changed files with 213 additions and 29 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.amazonaws:aws-java-sdk-ses:1.12.429'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springdoc:springdoc-openapi-ui:1.6.15'
Expand All @@ -41,6 +42,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'it.ozimov:embedded-redis:0.7.2'
}

test {
Expand Down
79 changes: 79 additions & 0 deletions src/main/java/mocacong/server/config/RedisCacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package mocacong.server.config;

import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
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.Primary;
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.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@EnableCaching
@Configuration
public class RedisCacheConfig {

private static final long DELTA_TO_AVOID_CONCURRENCY_TIME = 30 * 60 * 1000L;

@Value("${security.jwt.token.expire-length}")
private long accessTokenValidityInMilliseconds;

@Bean
@Primary
public CacheManager cafeCacheManager(RedisConnectionFactory redisConnectionFactory) {
/*
* 카페 관련 캐시는 충분히 많이 쌓일 수 있으므로 OOM 방지 차 ttl 12시간으로 설정
*/
RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
.entryTtl(Duration.ofHours(12L));

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

@Bean
public CacheManager oauthPublicKeyCacheManager(RedisConnectionFactory redisConnectionFactory) {
/*
* public key 갱신은 1년에 몇 번 안되므로 ttl 3일로 설정
* 유저가 하루 1번 로그인한다고 가정, 최소 1일은 넘기는 것이 좋다고 판단
*/
RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
.entryTtl(Duration.ofDays(3L));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}

@Bean
public CacheManager accessTokenCacheManager(RedisConnectionFactory redisConnectionFactory) {
/*
* accessToken 시간만큼 ttl 설정하되,
* 만료 직전 캐시 조회하여 로그인 안되는 동시성 이슈 방지를 위해 accessToken ttl 보다 30분 일찍 만료
*/
RedisCacheConfiguration redisCacheConfiguration = generateCacheConfiguration()
.entryTtl(Duration.ofMillis(accessTokenValidityInMilliseconds - DELTA_TO_AVOID_CONCURRENCY_TIME));

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

private RedisCacheConfiguration generateCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
}
}
38 changes: 38 additions & 0 deletions src/main/java/mocacong/server/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package mocacong.server.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
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.StringRedisSerializer;

@Configuration
public class RedisConfig {

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

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

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, redisPort));
}

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

/* Java 기본 직렬화가 아닌 JSON 직렬화 설정 */
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

return redisTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package mocacong.server.security.auth.apple;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;

@FeignClient(name = "apple-public-key-client", url = "https://appleid.apple.com/auth")
public interface AppleClient {

@Cacheable(value = "oauthPublicKeyCache", cacheManager = "oauthPublicKeyCacheManager")
@GetMapping("/keys")
ApplePublicKeys getApplePublicKeys();
}
2 changes: 2 additions & 0 deletions src/main/java/mocacong/server/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import mocacong.server.security.auth.OAuthPlatformMemberResponse;
import mocacong.server.security.auth.apple.AppleOAuthUserProvider;
import mocacong.server.security.auth.kakao.KakaoOAuthUserProvider;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

Expand All @@ -28,6 +29,7 @@ public class AuthService {
private final AppleOAuthUserProvider appleOAuthUserProvider;
private final KakaoOAuthUserProvider kakaoOAuthUserProvider;

@Cacheable(key = "#request.email", value = "accessTokenCache", cacheManager = "accessTokenCacheManager")
public TokenResponse login(AuthLoginRequest request) {
Member findMember = memberRepository.findByEmail(request.getEmail())
.orElseThrow(NotFoundMemberException::new);
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/mocacong/server/service/CafeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import mocacong.server.service.event.DeleteNotUsedImagesEvent;
import mocacong.server.service.event.MemberEvent;
import mocacong.server.support.AwsS3Uploader;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -88,6 +90,7 @@ public FindCafeResponse findCafeByMapId(String email, String mapId) {
);
}

@Cacheable(key = "#mapId", value = "cafePreviewCache", cacheManager = "cafeCacheManager")
@Transactional(readOnly = true)
public PreviewCafeResponse previewCafeByMapId(String email, String mapId) {
Cafe cafe = cafeRepository.findByMapId(mapId)
Expand Down Expand Up @@ -174,6 +177,7 @@ public MyCommentCafesResponse findMyCommentCafes(String email, int page, int cou
return new MyCommentCafesResponse(comments.isLast(), responses);
}

@CacheEvict(key = "#mapId", value = "cafePreviewCache")
@Transactional
public CafeReviewResponse saveCafeReview(String email, String mapId, CafeReviewRequest request) {
Cafe cafe = cafeRepository.findByMapId(mapId)
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/mocacong/server/service/FavoriteService.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import mocacong.server.repository.FavoriteRepository;
import mocacong.server.repository.MemberRepository;
import mocacong.server.service.event.MemberEvent;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
Expand All @@ -28,6 +29,7 @@ public class FavoriteService {
private final MemberRepository memberRepository;
private final CafeRepository cafeRepository;

@CacheEvict(key = "#mapId", value = "cafePreviewCache")
@Transactional
public FavoriteSaveResponse save(String email, String mapId) {
Cafe cafe = cafeRepository.findByMapId(mapId)
Expand All @@ -47,6 +49,7 @@ private void validateDuplicateFavorite(Long cafeId, Long memberId) {
});
}

@CacheEvict(key = "#mapId", value = "cafePreviewCache")
@Transactional
public void delete(String email, String mapId) {
Cafe cafe = cafeRepository.findByMapId(mapId)
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ spring:
core-size: ${THREAD_POOL_CORE_SIZE}
max-size: ${THREAD_POOL_MAX_SIZE}
queue-capacity: ${THREAD_POOL_QUEUE_CAPACITY}
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}

security.jwt.token:
secret-key: ${JWT_SECRET_KEY}
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ spring:
core-size: 2
max-size: 10
queue-capacity: 20
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}

h2:
console:
Expand Down
3 changes: 3 additions & 0 deletions src/test/java/mocacong/server/acceptance/AcceptanceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import io.restassured.RestAssured;
import mocacong.server.support.DatabaseCleanerCallback;
import mocacong.server.support.TestRedisConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(DatabaseCleanerCallback.class)
@Import(TestRedisConfig.class)
public class AcceptanceTest {

@LocalServerPort
Expand Down
42 changes: 18 additions & 24 deletions src/test/java/mocacong/server/service/CafeServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package mocacong.server.service;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;
import mocacong.server.domain.*;
import mocacong.server.dto.request.*;
import mocacong.server.dto.response.*;
Expand All @@ -10,21 +13,16 @@
import mocacong.server.repository.*;
import mocacong.server.service.event.DeleteNotUsedImagesEvent;
import mocacong.server.support.AwsS3Uploader;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;

@ServiceTest
class CafeServiceTest {
Expand Down Expand Up @@ -193,15 +191,13 @@ void previewCafeWithScore() {
scoreRepository.save(new Score(5, member2, cafe));
favoriteRepository.save(new Favorite(member1, cafe));

PreviewCafeResponse actual1 = cafeService.previewCafeByMapId(member1.getEmail(), cafe.getMapId());
PreviewCafeResponse actual2 = cafeService.previewCafeByMapId(member2.getEmail(), cafe.getMapId());
PreviewCafeResponse actual = cafeService.previewCafeByMapId(member1.getEmail(), cafe.getMapId());

assertAll(
() -> assertThat(actual1.getFavorite()).isTrue(),
() -> assertThat(actual2.getFavorite()).isFalse(),
() -> assertThat(actual1.getScore()).isEqualTo(4.5),
() -> assertThat(actual1.getStudyType()).isNull(),
() -> assertThat(actual1.getReviewsCount()).isEqualTo(0)
() -> assertThat(actual.getFavorite()).isTrue(),
() -> assertThat(actual.getScore()).isEqualTo(4.5),
() -> assertThat(actual.getStudyType()).isNull(),
() -> assertThat(actual.getReviewsCount()).isEqualTo(0)
);
}

Expand All @@ -222,15 +218,13 @@ void previewCafeWithScoreAndReview() {
"깨끗해요", "없어요", null, "보통이에요"));
favoriteRepository.save(new Favorite(member1, cafe));

PreviewCafeResponse actual1 = cafeService.previewCafeByMapId(member1.getEmail(), cafe.getMapId());
PreviewCafeResponse actual2 = cafeService.previewCafeByMapId(member2.getEmail(), cafe.getMapId());
PreviewCafeResponse actual = cafeService.previewCafeByMapId(member1.getEmail(), cafe.getMapId());

assertAll(
() -> assertThat(actual1.getFavorite()).isTrue(),
() -> assertThat(actual2.getFavorite()).isFalse(),
() -> assertThat(actual1.getScore()).isEqualTo(2.5),
() -> assertThat(actual1.getStudyType()).isEqualTo("group"),
() -> assertThat(actual1.getReviewsCount()).isEqualTo(2)
() -> assertThat(actual.getFavorite()).isTrue(),
() -> assertThat(actual.getScore()).isEqualTo(2.5),
() -> assertThat(actual.getStudyType()).isEqualTo("group"),
() -> assertThat(actual.getReviewsCount()).isEqualTo(2)
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/test/java/mocacong/server/service/ServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import mocacong.server.support.DatabaseCleanerCallback;
import mocacong.server.support.TestRedisConfig;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(DatabaseCleanerCallback.class)
@Import(TestRedisConfig.class)
public @interface ServiceTest {
}
21 changes: 16 additions & 5 deletions src/test/java/mocacong/server/support/DatabaseCleaner.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package mocacong.server.support;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.Table;
import javax.persistence.metamodel.Type;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class DatabaseCleaner {
Expand All @@ -21,6 +23,9 @@ public class DatabaseCleaner {
@PersistenceContext
EntityManager entityManager;

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private List<String> tableNames;

@PostConstruct
Expand All @@ -36,6 +41,7 @@ public void findTableNames() {

@Transactional
public void execute() {
/* DB Truncate */
entityManager.flush();
entityManager.createNativeQuery(String.format(FOREIGN_KEY_RULE_UPDATE_FORMAT, "FALSE"))
.executeUpdate();
Expand All @@ -45,5 +51,10 @@ public void execute() {
}
entityManager.createNativeQuery(String.format(FOREIGN_KEY_RULE_UPDATE_FORMAT, "TRUE"))
.executeUpdate();

/* Redis Cache 제거 */
Objects.requireNonNull(redisTemplate.getConnectionFactory())
.getConnection()
.flushAll();
}
}
Loading

0 comments on commit 9cebc68

Please sign in to comment.