diff --git a/core/core.settings.gradle.kts b/core/core.settings.gradle.kts index 09ab240..90ef71a 100644 --- a/core/core.settings.gradle.kts +++ b/core/core.settings.gradle.kts @@ -4,7 +4,6 @@ val core = rootDir.resolve("core") .filter(File::isDirectory) .associateBy(File::getName) - include( ":time-util", ":jpa-core", @@ -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"]!! @@ -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"]!! \ No newline at end of file +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"]!! \ No newline at end of file diff --git a/core/redis/nettee-redis-api/build.gradle.kts b/core/redis/nettee-redis-api/build.gradle.kts new file mode 100644 index 0000000..5f38354 --- /dev/null +++ b/core/redis/nettee-redis-api/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/core/redis/nettee-redis-api/src/main/java/nettee/redis/config/RedisConnectionConfig.java b/core/redis/nettee-redis-api/src/main/java/nettee/redis/config/RedisConnectionConfig.java new file mode 100644 index 0000000..d485a64 --- /dev/null +++ b/core/redis/nettee-redis-api/src/main/java/nettee/redis/config/RedisConnectionConfig.java @@ -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])); + } + } +} diff --git a/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/RedisProperties.java b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/RedisProperties.java new file mode 100644 index 0000000..e48f136 --- /dev/null +++ b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/RedisProperties.java @@ -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 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"); + } + } +} diff --git a/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/RedisCacheProperties.java b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/RedisCacheProperties.java new file mode 100644 index 0000000..248e4bb --- /dev/null +++ b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/RedisCacheProperties.java @@ -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 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 + )); + } + + } +} diff --git a/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/domain/DomainCacheProperties.java b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/domain/DomainCacheProperties.java new file mode 100644 index 0000000..0c82330 --- /dev/null +++ b/core/redis/nettee-redis-api/src/main/java/nettee/redis/properties/cache/domain/DomainCacheProperties.java @@ -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(); + } +} diff --git a/core/redis/nettee-redis-api/src/test/java/nettee/main/RedisApiTestApplication.java b/core/redis/nettee-redis-api/src/test/java/nettee/main/RedisApiTestApplication.java new file mode 100644 index 0000000..de65ce9 --- /dev/null +++ b/core/redis/nettee-redis-api/src/test/java/nettee/main/RedisApiTestApplication.java @@ -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); + } +} diff --git a/core/redis/nettee-redis-api/src/test/kotlin/nettee/redis/properties/RedisPropertiesTest.kt b/core/redis/nettee-redis-api/src/test/kotlin/nettee/redis/properties/RedisPropertiesTest.kt new file mode 100644 index 0000000..eb1c8ce --- /dev/null +++ b/core/redis/nettee-redis-api/src/test/kotlin/nettee/redis/properties/RedisPropertiesTest.kt @@ -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 { + 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) + } + } +} diff --git a/core/redis/nettee-redis-cache/build.gradle.kts b/core/redis/nettee-redis-cache/build.gradle.kts new file mode 100644 index 0000000..2203592 --- /dev/null +++ b/core/redis/nettee-redis-cache/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies{ + api(project(":redis-api")) +} \ No newline at end of file diff --git a/core/redis/nettee-redis-cache/src/main/java/nettee/redis/config/RedisCacheConfig.java b/core/redis/nettee-redis-cache/src/main/java/nettee/redis/config/RedisCacheConfig.java new file mode 100644 index 0000000..73a8d5f --- /dev/null +++ b/core/redis/nettee-redis-cache/src/main/java/nettee/redis/config/RedisCacheConfig.java @@ -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 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(); + } +} diff --git a/core/redis/nettee-redis-template/build.gradle.kts b/core/redis/nettee-redis-template/build.gradle.kts new file mode 100644 index 0000000..2203592 --- /dev/null +++ b/core/redis/nettee-redis-template/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies{ + api(project(":redis-api")) +} \ No newline at end of file diff --git a/core/redis/nettee-redis-template/src/main/java/nettee/redis/config/RedisTemplateConfig.java b/core/redis/nettee-redis-template/src/main/java/nettee/redis/config/RedisTemplateConfig.java new file mode 100644 index 0000000..f297f32 --- /dev/null +++ b/core/redis/nettee-redis-template/src/main/java/nettee/redis/config/RedisTemplateConfig.java @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate 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; + } +}