Skip to content

Commit 9d96d85

Browse files
authored
Merge pull request #106 from nettee-space/feature/redis-core
☀️ Core > Redis 코어 모듈 + 도메인별 캐싱 전략 수립
2 parents 9c0ebee + 8f619b1 commit 9d96d85

File tree

12 files changed

+351
-3
lines changed

12 files changed

+351
-3
lines changed

core/core.settings.gradle.kts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ val core = rootDir.resolve("core")
44
.filter(File::isDirectory)
55
.associateBy(File::getName)
66

7-
87
include(
98
":time-util",
109
":jpa-core",
@@ -14,7 +13,10 @@ include(
1413
":snowflake-id-api",
1514
":snowflake-id-hibernate",
1615
":client-api",
17-
":rest-client"
16+
":rest-client",
17+
":redis-api",
18+
":redis-template",
19+
":redis-cache",
1820
)
1921

2022
project(":time-util").projectDir = core["time-util"]!!
@@ -25,4 +27,7 @@ project(":cors-api").projectDir = core["nettee-cors-api"]!!
2527
project(":snowflake-id-api").projectDir = core["nettee-snowflake-id-api"]!!
2628
project(":snowflake-id-hibernate").projectDir = core["nettee-snowflake-id-hibernate"]!!
2729
project(":client-api").projectDir = core["nettee-client-api"]!!
28-
project(":rest-client").projectDir = core["nettee-rest-client"]!!
30+
project(":rest-client").projectDir = core["nettee-rest-client"]!!
31+
project(":redis-api").projectDir = core["nettee-redis-api"]!!
32+
project(":redis-template").projectDir = core["nettee-redis-template"]!!
33+
project(":redis-cache").projectDir = core["nettee-redis-cache"]!!
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
dependencies{
2+
api("org.springframework.boot:spring-boot-autoconfigure")
3+
api("org.springframework.boot:spring-boot-starter-data-redis")
4+
compileOnly("org.springframework:spring-context")
5+
// test
6+
testImplementation("org.springframework.boot:spring-boot-starter-test")
7+
testImplementation("com.fasterxml.jackson.core:jackson-databind")
8+
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin")
9+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package nettee.redis.config;
2+
3+
import nettee.redis.properties.RedisProperties;
4+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.data.redis.connection.RedisClusterConfiguration;
8+
import org.springframework.data.redis.connection.RedisConnectionFactory;
9+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
10+
11+
@Configuration
12+
@EnableConfigurationProperties(RedisProperties.class)
13+
public class RedisConnectionConfig {
14+
15+
@Bean
16+
public RedisConnectionFactory redisConnectionFactory(RedisProperties redisProperties) {
17+
if (redisProperties.useClusterMode()) {
18+
// cluster connection
19+
return new LettuceConnectionFactory(new RedisClusterConfiguration(redisProperties.nodes()));
20+
} else {
21+
// standalone connection
22+
var node = redisProperties.nodes().getFirst().split(":");
23+
24+
return new LettuceConnectionFactory(node[0], Integer.parseInt(node[1]));
25+
}
26+
}
27+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package nettee.redis.properties;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import nettee.redis.properties.cache.RedisCacheProperties;
5+
import nettee.redis.properties.cache.domain.DomainCacheProperties;
6+
import org.springframework.boot.context.properties.ConfigurationProperties;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
@Slf4j
12+
@ConfigurationProperties("app.redis")
13+
public record RedisProperties(
14+
Boolean useClusterMode,
15+
List<String> nodes,
16+
RedisCacheProperties cache
17+
) {
18+
public RedisProperties {
19+
if (nodes == null || nodes.isEmpty()) {
20+
nodes = List.of("localhost:6379");
21+
22+
log.warn("redis nodes is null");
23+
}
24+
25+
nodes = nodes.stream().map(String::strip).toList();
26+
27+
if (useClusterMode == null || !useClusterMode) {
28+
useClusterMode = false;
29+
30+
log.info("Redis Connection Mode is Standalone");
31+
32+
if (nodes.size() > 1) {
33+
throw new RuntimeException("More than one Redis node is configured in standalone mode");
34+
}
35+
} else {
36+
log.info("Redis Connection Mode is Cluster");
37+
}
38+
39+
if (cache == null) {
40+
cache = new RedisCacheProperties(
41+
Map.of("app", new DomainCacheProperties(null, null, null))
42+
);
43+
44+
log.warn("Redis cache is null");
45+
}
46+
}
47+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package nettee.redis.properties.cache;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import nettee.redis.properties.cache.domain.DomainCacheProperties;
5+
6+
import java.util.Map;
7+
import java.util.stream.Collectors;
8+
9+
@Slf4j
10+
public record RedisCacheProperties(
11+
Map<String, DomainCacheProperties> domains
12+
) {
13+
public RedisCacheProperties {
14+
if(domains == null || domains.isEmpty()) {
15+
log.warn("redis cache domains is empty");
16+
} else {
17+
domains = domains.entrySet().stream()
18+
.collect(Collectors.toMap(
19+
domain -> domain.getKey().strip(),
20+
Map.Entry::getValue
21+
));
22+
}
23+
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package nettee.redis.properties.cache.domain;
2+
3+
public record DomainCacheProperties(
4+
Long ttl,
5+
Boolean disableNull,
6+
String prefix
7+
) {
8+
public DomainCacheProperties {
9+
if(ttl != null && ttl < 0L) {
10+
throw new IllegalArgumentException("TTL must be zero or a positive number. Negative TTL is not allowed.");
11+
}
12+
13+
// Default NO Cache NULL Value
14+
if (disableNull == null) {
15+
disableNull = true;
16+
}
17+
18+
if (prefix == null) {
19+
prefix = "";
20+
}
21+
22+
prefix = prefix.strip();
23+
}
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package nettee.main;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
6+
7+
@SpringBootApplication
8+
@ConfigurationPropertiesScan(basePackages = "nettee")
9+
public class RedisApiTestApplication {
10+
public static void main(String[] args) {
11+
SpringApplication.run(RedisApiTestApplication.class, args);
12+
}
13+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package nettee.redis.properties
2+
3+
import com.fasterxml.jackson.databind.DeserializationFeature
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
6+
import io.kotest.assertions.throwables.shouldThrow
7+
import io.kotest.core.spec.style.FreeSpec
8+
import io.kotest.extensions.spring.SpringExtension
9+
import io.kotest.matchers.shouldBe
10+
import org.springframework.boot.test.context.TestConfiguration
11+
import org.springframework.context.annotation.Bean
12+
import org.springframework.context.annotation.Import
13+
14+
@Import(RedisPropertiesTest.RedisPropertiesTest::class)
15+
class RedisPropertiesTest(
16+
private val redisProperties: RedisProperties
17+
) : FreeSpec({
18+
19+
"[초기화 검증] Redis 설정 값을 반환" - {
20+
"application.yml 모든 변수를 설정했을 때" - {
21+
"useClusterMode는 정상 반환" {
22+
redisProperties.useClusterMode shouldBe true
23+
}
24+
25+
"nodes 정상 반환" {
26+
redisProperties.nodes.size shouldBe 3
27+
}
28+
29+
"cache.domains.article 정상 반환" {
30+
val article = redisProperties.cache.domains["article"]!!
31+
article.ttl() shouldBe 300
32+
article.disableNull() shouldBe true
33+
article.prefix() shouldBe "article::"
34+
}
35+
36+
"cache.domains.comments 정상 반환" {
37+
val comments = redisProperties.cache.domains["comments"]!!
38+
comments.ttl() shouldBe 60
39+
comments.disableNull() shouldBe false
40+
comments.prefix() shouldBe "comments::"
41+
}
42+
}
43+
44+
"application.yml 일부 변수를 누락했을 때" - {
45+
val nullRedisProperties = RedisProperties(null, null, null)
46+
47+
"useClusterMode는 기본값 false 반환" {
48+
nullRedisProperties.useClusterMode shouldBe false
49+
}
50+
51+
"nodes 기본값 localhost:6379 반환" {
52+
nullRedisProperties.nodes[0] shouldBe "localhost:6379"
53+
}
54+
55+
"cache 기본값 cache 반환" {
56+
val app = nullRedisProperties.cache.domains["app"]!!
57+
app.ttl() shouldBe 60
58+
app.disableNull() shouldBe true
59+
app.prefix() shouldBe ""
60+
}
61+
}
62+
}
63+
64+
"[예외 검증] useClusterMode 예외 반환" - {
65+
"useClusterMode가 false(standalone 모드) 일때" - {
66+
"node가 다건이면 에러 반환" {
67+
shouldThrow<RuntimeException> {
68+
RedisProperties(false, listOf("localhost:7001", "localhost:7002"), null)
69+
}
70+
}
71+
}
72+
}
73+
}) {
74+
override fun extensions() = listOf(SpringExtension)
75+
76+
@TestConfiguration
77+
class RedisPropertiesTest {
78+
79+
@Bean
80+
fun redisProperties(): RedisProperties {
81+
val redisYmlJson = """
82+
{
83+
"useClusterMode": true,
84+
"nodes": ["localhost:7000", "localhost:7001", "localhost:7002"],
85+
"cache": {
86+
"domains": {
87+
"article": {
88+
"ttl": 300,
89+
"disableNull": true,
90+
"prefix": "article::"
91+
},
92+
"comments": {
93+
"ttl": 60,
94+
"disableNull": false,
95+
"prefix": "comments::"
96+
}
97+
}
98+
}
99+
}
100+
""".trimIndent()
101+
102+
val objectMapper = ObjectMapper().registerKotlinModule()
103+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
104+
105+
return objectMapper.readValue(redisYmlJson, RedisProperties::class.java)
106+
}
107+
}
108+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dependencies{
2+
api(project(":redis-api"))
3+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package nettee.redis.config;
2+
3+
import nettee.redis.properties.RedisProperties;
4+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
5+
import org.springframework.cache.annotation.EnableCaching;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.data.redis.cache.RedisCacheConfiguration;
9+
import org.springframework.data.redis.cache.RedisCacheManager;
10+
import org.springframework.data.redis.connection.RedisConnectionFactory;
11+
12+
import java.time.Duration;
13+
import java.util.Map;
14+
import java.util.stream.Collectors;
15+
16+
@Configuration
17+
@EnableCaching
18+
@EnableConfigurationProperties(RedisProperties.class)
19+
public class RedisCacheConfig {
20+
21+
@Bean
22+
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisProperties redisProperties) {
23+
// 도메인 별 캐시 적용
24+
Map<String, RedisCacheConfiguration> domainCacheConfigurations = redisProperties.cache().domains().entrySet().stream()
25+
.collect(Collectors.toMap(
26+
// 도메인명 (ex: article)
27+
Map.Entry::getKey,
28+
entry -> {
29+
var domainCacheProperties = entry.getValue();
30+
var config = RedisCacheConfiguration.defaultCacheConfig();
31+
32+
if(domainCacheProperties.ttl() != null){
33+
config = config.entryTtl(Duration.ofSeconds(domainCacheProperties.ttl()));
34+
}
35+
36+
if (domainCacheProperties.disableNull()) {
37+
config = config.disableCachingNullValues();
38+
}
39+
40+
config = config.computePrefixWith(cacheName -> domainCacheProperties.prefix());
41+
42+
return config;
43+
}
44+
));
45+
46+
return RedisCacheManager.builder(connectionFactory)
47+
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
48+
.transactionAware()
49+
.withInitialCacheConfigurations(domainCacheConfigurations)
50+
.build();
51+
}
52+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dependencies{
2+
api(project(":redis-api"))
3+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package nettee.redis.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.data.redis.connection.RedisConnectionFactory;
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.data.redis.core.StringRedisTemplate;
8+
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
9+
import org.springframework.data.redis.serializer.StringRedisSerializer;
10+
11+
@Configuration
12+
public class RedisTemplateConfig {
13+
14+
@Bean
15+
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
16+
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
17+
18+
redisTemplate.setConnectionFactory(connectionFactory);
19+
redisTemplate.setKeySerializer(new StringRedisSerializer());
20+
redisTemplate.setHashKeySerializer(new GenericJackson2JsonRedisSerializer());
21+
22+
return redisTemplate;
23+
}
24+
25+
@Bean
26+
StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
27+
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
28+
stringRedisTemplate.setConnectionFactory(connectionFactory);
29+
30+
return stringRedisTemplate;
31+
}
32+
}

0 commit comments

Comments
 (0)