Skip to content
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 devslab-kit-autoconfigure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies {
api(project(":devslab-kit-menu-core"))
api(project(":devslab-kit-audit-api"))
api(project(":devslab-kit-audit-core"))
api(project(":devslab-kit-cache-api"))
api(project(":devslab-kit-cache-core"))
api(project(":devslab-kit-admin-api"))

implementation("org.springframework.boot:spring-boot-autoconfigure")
Expand Down
5 changes: 5 additions & 0 deletions devslab-kit-cache-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
description = "devslab-kit :: cache public contracts"

dependencies {
api(project(":devslab-kit-core"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.devslab.kit.cache;

/**
* Stable cache-name constants for the kit's own caches.
*
* <p>Consumers generally use Spring's {@code @Cacheable("their-own-name")}
* directly and never need this. It exists so the kit's internal caches (and any
* cross-module references to them) share one source of truth instead of
* scattering string literals — e.g. an admin tool that evicts the menu cache
* references {@link #MENU} rather than re-typing {@code "devslab-kit-menu"}.
*/
public final class CacheNames {

/** Per-user, permission-filtered menu tree. Keyed by user id. */
public static final String MENU = "devslab-kit-menu";

private CacheNames() {
}
}
21 changes: 21 additions & 0 deletions devslab-kit-cache-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
description = "devslab-kit :: cache default implementation"

dependencies {
api(project(":devslab-kit-cache-api"))

// Spring's cache abstraction (CacheManager, @EnableCaching). Always needed.
api("org.springframework:spring-context")
api("org.springframework.boot:spring-boot-autoconfigure")

// Redis is OPTIONAL. The Redis-backed CacheManager (PR 2) only wires up when
// the consumer puts these on the classpath themselves (type=redis). Keeping
// them compileOnly here means the kit never forces Redis as a transitive
// dependency — the in-memory default stays zero-dependency. See ADR 0002 §4.
compileOnly("org.springframework.boot:spring-boot-starter-data-redis")
compileOnly("com.fasterxml.jackson.core:jackson-databind")

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package kr.devslab.kit.cache.core;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.cache.support.NoOpCacheManager;
import org.springframework.context.annotation.Bean;

/**
* Provides a {@link CacheManager} chosen by {@code devslab.kit.cache.type}
* (ADR 0002) and turns on Spring's caching annotations — so a consumer flips
* one property to get distributed caching and writes zero serializer config.
*
* <p>This class wires the {@code none} and {@code in-memory} backends. The
* {@code redis} backend lives in {@link RedisCacheConfiguration}, imported here
* but inert unless Redis is on the classpath (ADR 0002 §2–§3).
*
* <p>Everything is {@link ConditionalOnMissingBean} on {@link CacheManager}: a
* consumer who defines their own cache manager always wins, and the kit's
* {@link EnableCaching} stays out of the way too.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(CacheProperties.class)
@org.springframework.context.annotation.Import(RedisCacheConfiguration.class)
public class CacheAutoConfiguration {

/**
* Enable Spring's caching annotations, but only when the kit is the one
* providing the {@link CacheManager}. If the consumer manages caching
* themselves (their own {@code CacheManager} bean), this stays inert so we
* never double-enable or fight their setup.
*/
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
@EnableCaching
@ConditionalOnMissingBean(CacheManager.class)
static class KitCachingEnabled {
}

/**
* {@code type: none} → a {@link NoOpCacheManager}. Caching annotations
* resolve but never store, so every call runs the underlying method.
*/
@Bean
@ConditionalOnMissingBean(CacheManager.class)
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "none")
public CacheManager devslabKitNoOpCacheManager() {
return new NoOpCacheManager();
}

/**
* {@code type: in-memory} (default) → a {@link ConcurrentMapCacheManager}.
* Single-JVM; see {@link CacheType#IN_MEMORY} for the multi-replica caveat.
*/
@Bean
@ConditionalOnMissingBean(CacheManager.class)
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "in-memory", matchIfMissing = true)
public CacheManager devslabKitInMemoryCacheManager() {
// Non-fixed cache names: created on first use, so consumers' own
// @Cacheable("anything") works without pre-declaring cache names.
return new ConcurrentMapCacheManager();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package kr.devslab.kit.cache.core;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* Configuration for the kit's pluggable cache (ADR 0002), bound from
* {@code devslab.kit.cache.*}.
*
* <p>The headline knob is {@link #type}: flip it to {@code redis} and a
* single-node cache becomes a correct distributed one, with the kit owning
* serialization so consumers never configure it.
*/
@ConfigurationProperties(prefix = "devslab.kit.cache")
public class CacheProperties {

/** Master switch for the kit-provided CacheManager. */
private boolean enabled = true;

/** Backing store: {@code none}, {@code in-memory} (default), or {@code redis}. */
private CacheType type = CacheType.IN_MEMORY;

/**
* Default time-to-live for cache entries. Applied by the Redis backend;
* the in-memory backend ignores per-entry TTL (Spring's
* {@code ConcurrentMapCacheManager} has no expiry) — documented limitation,
* use {@code redis} when TTL matters.
*/
private Duration ttl = Duration.ofMinutes(10);

/** Key namespace prefix, so multiple apps can share one Redis instance safely. */
private String keyPrefix = "devslab:";

/** Whether to cache {@code null} return values. Off by default so a cached null doesn't mask a real miss. */
private boolean cacheNullValues = false;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public CacheType getType() {
return type;
}

public void setType(CacheType type) {
this.type = type;
}

public Duration getTtl() {
return ttl;
}

public void setTtl(Duration ttl) {
this.ttl = ttl;
}

public String getKeyPrefix() {
return keyPrefix;
}

public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

public boolean isCacheNullValues() {
return cacheNullValues;
}

public void setCacheNullValues(boolean cacheNullValues) {
this.cacheNullValues = cacheNullValues;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.devslab.kit.cache.core;

/**
* Backing store for the kit's {@code CacheManager}, selected via
* {@code devslab.kit.cache.type}. See ADR 0002.
*/
public enum CacheType {

/**
* No-op caching. Annotations become pass-throughs; every call hits the
* underlying method. Useful in tests that must observe writes immediately.
*/
NONE,

/**
* Single-JVM {@code ConcurrentMapCacheManager}. No external dependency.
* Correct for a single instance; goes stale across replicas (each JVM has
* its own map) — use {@link #REDIS} when running more than one node.
*/
IN_MEMORY,

/**
* Distributed {@code RedisCacheManager} with kit-owned JSON serialization
* (ADR 0002 §3). Requires {@code spring-boot-starter-data-redis} on the
* classpath and a configured connection; the kit fails fast otherwise.
*/
REDIS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kr.devslab.kit.cache.core;

import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;

/**
* Redis-backed {@code CacheManager} with kit-owned JSON serialization
* (ADR 0002 §3) — a consumer sets {@code type: redis}, adds
* {@code spring-boot-starter-data-redis}, and never touches serialization.
*
* <p><strong>PR 1 placeholder.</strong> This class is intentionally a no-op
* scaffold right now: it carries the {@link ConditionalOnClass} /
* {@link ConditionalOnProperty} guards so the wiring is in place, but the actual
* {@code RedisCacheManager} + the safe-default-typing {@code ObjectMapper}
* (the part that needs a real-Redis Testcontainers round-trip test) lands in
* PR 2. Until then, {@code type: redis} resolves no {@code CacheManager} here —
* documented so we don't ship a half-built serializer.
*
* <p>Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}:
* when Redis isn't on the classpath this whole configuration is skipped, so the
* {@code compileOnly} Redis dependency never leaks onto a consumer who stays
* in-memory.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisConnectionFactory.class)
@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis")
class RedisCacheConfiguration {

// PR 2:
// @Bean @ConditionalOnMissingBean(CacheManager.class)
// @ConditionalOnBean(RedisConnectionFactory.class)
// CacheManager devslabKitRedisCacheManager(RedisConnectionFactory cf, CacheProperties props) { ... }
// with GenericJackson2JsonRedisSerializer over a kit ObjectMapper using a
// SAFE default-typing validator (allow-list, not LaissezFaireSubTypeValidator).
//
// The unused imports below are referenced in the PR-2 signature; keeping the
// guard annotations active now means the conditional contract is testable.
@SuppressWarnings("unused")
private static final Class<?> GUARD_MISSING = ConditionalOnMissingBean.class;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
kr.devslab.kit.cache.core.CacheAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package kr.devslab.kit.cache.core;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.cache.support.NoOpCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Verifies the {@code devslab.kit.cache.type} switch wires the right
* {@link CacheManager} and that a consumer-defined manager always wins
* (ADR 0002 §2). No Redis here — that path needs a real Redis and lands with
* its serialization test in PR 2.
*
* <p>Registers {@link CacheAutoConfiguration} as a plain user configuration
* rather than via {@code AutoConfigurations.of(...)} on purpose: Spring Boot 4
* ships the {@code AutoConfigurations} helper in a package that is split across
* {@code spring-boot-autoconfigure} and {@code spring-boot-test-autoconfigure},
* which fails to compile ("package not visible"). The condition semantics under
* test still hold; for the one order-sensitive case ({@link #consumerCacheManagerWins})
* the consumer config is registered <em>before</em> the auto-config so
* {@code @ConditionalOnMissingBean} sees it.
*/
class CacheAutoConfigurationTest {

private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(CacheAutoConfiguration.class);

@Test
void defaultsToInMemory() {
runner.run(ctx -> {
assertThat(ctx).hasSingleBean(CacheManager.class);
assertThat(ctx.getBean(CacheManager.class)).isInstanceOf(ConcurrentMapCacheManager.class);
});
}

@Test
void inMemoryWhenRequestedExplicitly() {
runner.withPropertyValues("devslab.kit.cache.type=in-memory").run(ctx ->
assertThat(ctx.getBean(CacheManager.class)).isInstanceOf(ConcurrentMapCacheManager.class));
}

@Test
void noOpWhenTypeNone() {
runner.withPropertyValues("devslab.kit.cache.type=none").run(ctx ->
assertThat(ctx.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class));
}

@Test
void backsOffEntirelyWhenDisabled() {
runner.withPropertyValues("devslab.kit.cache.enabled=false").run(ctx ->
assertThat(ctx).doesNotHaveBean(CacheManager.class));
}

@Test
void consumerCacheManagerWins() {
// Consumer config registered FIRST so the auto-config's
// @ConditionalOnMissingBean(CacheManager) sees it and backs off.
new ApplicationContextRunner()
.withUserConfiguration(CustomCacheManagerConfig.class, CacheAutoConfiguration.class)
.run(ctx -> {
assertThat(ctx).hasSingleBean(CacheManager.class);
assertThat(ctx).hasBean("myCustomCacheManager");
assertThat(ctx.getBean(CacheManager.class)).isInstanceOf(NoOpCacheManager.class);
});
}

@Test
void propertiesBind() {
runner.withPropertyValues(
"devslab.kit.cache.ttl=PT30M",
"devslab.kit.cache.key-prefix=app:",
"devslab.kit.cache.cache-null-values=true"
).run(ctx -> {
CacheProperties props = ctx.getBean(CacheProperties.class);
assertThat(props.getTtl()).hasMinutes(30);
assertThat(props.getKeyPrefix()).isEqualTo("app:");
assertThat(props.isCacheNullValues()).isTrue();
});
}

@Configuration(proxyBeanMethods = false)
static class CustomCacheManagerConfig {
@Bean
CacheManager myCustomCacheManager() {
// A distinctly-typed manager (NoOp) so the test can tell it apart
// from the kit's default ConcurrentMapCacheManager.
return new NoOpCacheManager();
}
}
}
9 changes: 7 additions & 2 deletions docs/adr/0002-distributed-cache.ko.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# ADR 0002 — 끼울 수 있는 분산 캐시 (property 한 줄, 직렬화 고민 0)

- **상태:** 제안(Proposed)
- **날짜:** 2026-05-31
- **상태:** 수락(Accepted)
- **날짜:** 2026-05-31 (2026-05-31 수락)
- **언어:** [English](0002-distributed-cache.md) · [한국어](0002-distributed-cache.ko.md)
- **구현:** `devslab-kit-cache-api` + `devslab-kit-cache-core` —
`CacheProperties`(`devslab.kit.cache.*`), `CacheAutoConfiguration`(`type`
스위치 + 가드된 `@EnableCaching`, none/in-memory 백엔드). Redis 백엔드 + JSON
직렬화(PR 2), 메뉴 캐시 마이그레이션(PR 3), sample-app/문서(PR 4)는 아래 구현
계획대로 이어진다.

## 배경

Expand Down
Loading
Loading