Skip to content

feat: Elasticache for Redis를 도입하여 캐싱 적용 #86

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

Merged
merged 15 commits into from
May 28, 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
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'
Copy link
Member Author

@kth990303 kth990303 May 24, 2023

Choose a reason for hiding this comment

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

테스트 환경에서는 실제 redis를 쓰는 것을 지양해야될 뿐더러, elasticache를 사용할 수도 없습니다. 그렇기 때문에 테스트용 embedded-redis 를 추가했습니다.

최신 버전 0.7.3은 Slf4j를 사용하여 logback과 충돌 이슈가 발생합니다. 따라서 0.7.2로 설정했습니다.
ozimov/embedded-redis#18 (comment)

+) 저 깃허브 이슈 링크 남기니까, 해당 이슈에 제 닉네임이 박제됐네요..ㅋㅋㅋ

}

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분 일찍 만료
Copy link
Member Author

Choose a reason for hiding this comment

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

accessToken 관련 캐시 ttl을 12시간으로 하려다가, 모카콩 접속 시간이 30분 이상일 듯하지 않아서 30분으로 설정했습니다. 괜찮은지 모르겠네요

Copy link
Member

@Ji-soo708 Ji-soo708 May 27, 2023

Choose a reason for hiding this comment

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

사용자가 모카콩을 오래 접속하진 않을 거 같진 않아서 30분 혹은 1시간으로 설정해도 될 거 같아요

*/
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")
Copy link
Member Author

Choose a reason for hiding this comment

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

@Cacheable: cache 값이 있으면 해당 값으로 조회
캐시 매니저는 oauthPublicKeyCacheManager (RedisCacheConfig.class 에 지정한 매니저) 로 설정한다는 의미입니다.

@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")
Copy link
Member Author

Choose a reason for hiding this comment

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

key값으로 서로 다른 이메일에 대해서는 서로 다른 accessTokenCache로 구별되도록 했습니다.

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 @@ -17,6 +17,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 @@ -91,6 +93,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 @@ -177,6 +180,7 @@ public MyCommentCafesResponse findMyCommentCafes(String email, int page, int cou
return new MyCommentCafesResponse(comments.isLast(), responses);
}

@CacheEvict(key = "#mapId", value = "cafePreviewCache")
Copy link
Member Author

Choose a reason for hiding this comment

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

리뷰 작성, 즐겨찾기 작성, 즐겨찾기 삭제 작업이 일어나면 카페 관련 캐시가 삭제되도록 했습니다.

@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.CafeFilterRequest;
import mocacong.server.dto.request.CafeRegisterRequest;
Expand All @@ -13,21 +16,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 @@ -196,15 +194,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());
Copy link
Member Author

Choose a reason for hiding this comment

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

캐싱이 일어나서 actual1이 그대로 반환됩니다.
따라서 제거했습니다.

Copy link
Member

Choose a reason for hiding this comment

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

변수명을 actual로 변경하는 거 어떠세요?

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 @@ -225,15 +221,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