Skip to content

☀️ Core > Redis 코어 모듈 + 도메인별 캐싱 전략 수립 #106

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 31 commits into from
Jun 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
da8c122
build(redis): add redis api and standalone module
silberbullet May 29, 2025
8b04fc3
feat(redis): add redis properties
silberbullet May 29, 2025
43abc69
feat(redis): add redis standalone connect factory
silberbullet May 29, 2025
7062dff
build(redis): rename redis-standalone to redis core
silberbullet May 30, 2025
f419830
build(redis): rename redis-standalone to redis core
silberbullet May 30, 2025
2e70432
fix(redis): rename RedisStandaloneConfig to RedisConfig
silberbullet May 30, 2025
bd0fcea
fix(redis): redis add use cluster mode
silberbullet May 30, 2025
47b61dc
fix(redis): redis property check validation
silberbullet May 31, 2025
4f04b0c
feat(redis): redis connect and template bean
silberbullet May 31, 2025
cfb80b0
build(redis): add test library
silberbullet May 31, 2025
ab41ba5
feat(redis): add cache properties per domain
silberbullet May 31, 2025
bf4101c
feat(redis): add cache properties in redis properties
silberbullet May 31, 2025
98649fd
feat(redis): add test code redis api
silberbullet May 31, 2025
2eeb210
refactor(redis): add redis default validation
silberbullet May 31, 2025
5e82a8b
feat(redis): add redis cache manager bean
silberbullet May 31, 2025
b8547f1
feat(redis): add EnableCaching annotation
silberbullet May 31, 2025
b63723b
style(core): remove one of continuous blank lines
merge-simpson Jun 1, 2025
e7bdf5a
fix(redis): fix redis properties validate
silberbullet Jun 2, 2025
f9f76b9
build(redis): remove version spring boot autoconfigure
silberbullet Jun 2, 2025
b4ae42e
refactor(redis): change nodes from host and port
silberbullet Jun 2, 2025
f25e84f
fix(redis): fix redis property test code
silberbullet Jun 2, 2025
f1ab79a
refactor(redis): add nodes in redis config
silberbullet Jun 2, 2025
1502fa4
build(redis): classify redis core to redis template and cache
silberbullet Jun 3, 2025
b2f3f4b
build(redis): add spring data redis library in api module
silberbullet Jun 3, 2025
d892992
feat(redis): add base RedisConnectionFactory bean to redis-api module…
silberbullet Jun 3, 2025
7abb99e
chore(redis): delete redis core directory
silberbullet Jun 3, 2025
d4e335d
feat(redis): add redis template module
silberbullet Jun 3, 2025
589e09b
feat(redis): add redis cache module
silberbullet Jun 3, 2025
9e399cc
chore(redis): add blank line
silberbullet Jun 3, 2025
cba62ca
refactor(redis): ttl fix for expire
silberbullet Jun 3, 2025
8f619b1
Merge branch 'main' into feature/redis-core
silberbullet Jun 20, 2025
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
11 changes: 8 additions & 3 deletions core/core.settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ val core = rootDir.resolve("core")
.filter(File::isDirectory)
.associateBy(File::getName)


include(
":time-util",
":jpa-core",
Expand All @@ -14,7 +13,10 @@ include(
":snowflake-id-api",
":snowflake-id-hibernate",
":client-api",
":rest-client"
":rest-client",
":redis-api",
":redis-template",
":redis-cache",
)

project(":time-util").projectDir = core["time-util"]!!
Expand All @@ -25,4 +27,7 @@ project(":cors-api").projectDir = core["nettee-cors-api"]!!
project(":snowflake-id-api").projectDir = core["nettee-snowflake-id-api"]!!
project(":snowflake-id-hibernate").projectDir = core["nettee-snowflake-id-hibernate"]!!
project(":client-api").projectDir = core["nettee-client-api"]!!
project(":rest-client").projectDir = core["nettee-rest-client"]!!
project(":rest-client").projectDir = core["nettee-rest-client"]!!
project(":redis-api").projectDir = core["nettee-redis-api"]!!
project(":redis-template").projectDir = core["nettee-redis-template"]!!
project(":redis-cache").projectDir = core["nettee-redis-cache"]!!
9 changes: 9 additions & 0 deletions core/redis/nettee-redis-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
dependencies{
api("org.springframework.boot:spring-boot-autoconfigure")
api("org.springframework.boot:spring-boot-starter-data-redis")
compileOnly("org.springframework:spring-context")
// test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.fasterxml.jackson.core:jackson-databind")
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package nettee.redis.config;

import nettee.redis.properties.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedisConnectionConfig {

@Bean
public RedisConnectionFactory redisConnectionFactory(RedisProperties redisProperties) {
if (redisProperties.useClusterMode()) {
// cluster connection
return new LettuceConnectionFactory(new RedisClusterConfiguration(redisProperties.nodes()));
} else {
// standalone connection
var node = redisProperties.nodes().getFirst().split(":");

return new LettuceConnectionFactory(node[0], Integer.parseInt(node[1]));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package nettee.redis.properties;

import lombok.extern.slf4j.Slf4j;
import nettee.redis.properties.cache.RedisCacheProperties;
import nettee.redis.properties.cache.domain.DomainCacheProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;
import java.util.Map;

@Slf4j
@ConfigurationProperties("app.redis")
public record RedisProperties(
Boolean useClusterMode,
List<String> nodes,
RedisCacheProperties cache
) {
public RedisProperties {
if (nodes == null || nodes.isEmpty()) {
nodes = List.of("localhost:6379");

log.warn("redis nodes is null");
}

nodes = nodes.stream().map(String::strip).toList();

if (useClusterMode == null || !useClusterMode) {
useClusterMode = false;

log.info("Redis Connection Mode is Standalone");

if (nodes.size() > 1) {
throw new RuntimeException("More than one Redis node is configured in standalone mode");
}
} else {
log.info("Redis Connection Mode is Cluster");
}

if (cache == null) {
cache = new RedisCacheProperties(
Map.of("app", new DomainCacheProperties(null, null, null))
);

log.warn("Redis cache is null");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nettee.redis.properties.cache;

import lombok.extern.slf4j.Slf4j;
import nettee.redis.properties.cache.domain.DomainCacheProperties;

import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
public record RedisCacheProperties(
Map<String, DomainCacheProperties> domains
) {
public RedisCacheProperties {
if(domains == null || domains.isEmpty()) {
log.warn("redis cache domains is empty");
} else {
domains = domains.entrySet().stream()
.collect(Collectors.toMap(
domain -> domain.getKey().strip(),
Map.Entry::getValue
));
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package nettee.redis.properties.cache.domain;

public record DomainCacheProperties(
Long ttl,
Boolean disableNull,
String prefix
) {
public DomainCacheProperties {
if(ttl != null && ttl < 0L) {
throw new IllegalArgumentException("TTL must be zero or a positive number. Negative TTL is not allowed.");
}

// Default NO Cache NULL Value
if (disableNull == null) {
disableNull = true;
}

if (prefix == null) {
prefix = "";
}

prefix = prefix.strip();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package nettee.main;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan(basePackages = "nettee")
public class RedisApiTestApplication {
public static void main(String[] args) {
SpringApplication.run(RedisApiTestApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package nettee.redis.properties

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FreeSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import

@Import(RedisPropertiesTest.RedisPropertiesTest::class)
class RedisPropertiesTest(
private val redisProperties: RedisProperties
) : FreeSpec({

"[초기화 검증] Redis 설정 값을 반환" - {
"application.yml 모든 변수를 설정했을 때" - {
"useClusterMode는 정상 반환" {
redisProperties.useClusterMode shouldBe true
}

"nodes 정상 반환" {
redisProperties.nodes.size shouldBe 3
}

"cache.domains.article 정상 반환" {
val article = redisProperties.cache.domains["article"]!!
article.ttl() shouldBe 300
article.disableNull() shouldBe true
article.prefix() shouldBe "article::"
}

"cache.domains.comments 정상 반환" {
val comments = redisProperties.cache.domains["comments"]!!
comments.ttl() shouldBe 60
comments.disableNull() shouldBe false
comments.prefix() shouldBe "comments::"
}
}

"application.yml 일부 변수를 누락했을 때" - {
val nullRedisProperties = RedisProperties(null, null, null)

"useClusterMode는 기본값 false 반환" {
nullRedisProperties.useClusterMode shouldBe false
}

"nodes 기본값 localhost:6379 반환" {
nullRedisProperties.nodes[0] shouldBe "localhost:6379"
}

"cache 기본값 cache 반환" {
val app = nullRedisProperties.cache.domains["app"]!!
app.ttl() shouldBe 60
app.disableNull() shouldBe true
app.prefix() shouldBe ""
}
}
}

"[예외 검증] useClusterMode 예외 반환" - {
"useClusterMode가 false(standalone 모드) 일때" - {
"node가 다건이면 에러 반환" {
shouldThrow<RuntimeException> {
RedisProperties(false, listOf("localhost:7001", "localhost:7002"), null)
}
}
}
}
}) {
override fun extensions() = listOf(SpringExtension)

@TestConfiguration
class RedisPropertiesTest {

@Bean
fun redisProperties(): RedisProperties {
val redisYmlJson = """
{
"useClusterMode": true,
"nodes": ["localhost:7000", "localhost:7001", "localhost:7002"],
"cache": {
"domains": {
"article": {
"ttl": 300,
"disableNull": true,
"prefix": "article::"
},
"comments": {
"ttl": 60,
"disableNull": false,
"prefix": "comments::"
}
}
}
}
""".trimIndent()

val objectMapper = ObjectMapper().registerKotlinModule()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)

return objectMapper.readValue(redisYmlJson, RedisProperties::class.java)
}
}
}
3 changes: 3 additions & 0 deletions core/redis/nettee-redis-cache/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies{
api(project(":redis-api"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package nettee.redis.config;

import nettee.redis.properties.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;

import java.time.Duration;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
@EnableCaching
@EnableConfigurationProperties(RedisProperties.class)
public class RedisCacheConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisProperties redisProperties) {
// 도메인 별 캐시 적용
Map<String, RedisCacheConfiguration> domainCacheConfigurations = redisProperties.cache().domains().entrySet().stream()
.collect(Collectors.toMap(
// 도메인명 (ex: article)
Map.Entry::getKey,
entry -> {
var domainCacheProperties = entry.getValue();
var config = RedisCacheConfiguration.defaultCacheConfig();

if(domainCacheProperties.ttl() != null){
config = config.entryTtl(Duration.ofSeconds(domainCacheProperties.ttl()));
}

if (domainCacheProperties.disableNull()) {
config = config.disableCachingNullValues();
}

config = config.computePrefixWith(cacheName -> domainCacheProperties.prefix());

return config;
}
));

return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig())
.transactionAware()
.withInitialCacheConfigurations(domainCacheConfigurations)
.build();
}
}
3 changes: 3 additions & 0 deletions core/redis/nettee-redis-template/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies{
api(project(":redis-api"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package nettee.redis.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisTemplateConfig {

@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new GenericJackson2JsonRedisSerializer());

return redisTemplate;
}

@Bean
StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(connectionFactory);

return stringRedisTemplate;
}
}