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
159 changes: 159 additions & 0 deletions docs/adr/0002-distributed-cache.ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# ADR 0002 — 끼울 수 있는 분산 캐시 (property 한 줄, 직렬화 고민 0)

- **상태:** 제안(Proposed)
- **날짜:** 2026-05-31
- **언어:** [English](0002-distributed-cache.md) · [한국어](0002-distributed-cache.ko.md)

## 배경

`devslab-kit`은 이미 한 가지를 캐시한다 — 사용자별 메뉴 트리를, 직접 만든 작은
`CachingMenuProvider`(`ConcurrentHashMap` 기반)로. 단일 인스턴스에선 괜찮지만,
consumer가 replica를 둘 이상 띄우는 순간 깨진다: 인스턴스마다 자기 map을 가지므로,
메뉴 수정(또는 캐시된 어떤 값이든)이 그 쓰기를 처리한 노드 외 모든 노드에서 각자의
TTL이 만료될 때까지 stale로 남는다.

공개 첫 릴리스를 준비하며 드러난 더 본질적인 지점: **Spring Boot에서 올바른 분산
캐시를 세팅하는 건 번거롭고, 모든 consumer가 같은 세금을 낸다.** `CacheManager`를
고르고, `RedisConnectionFactory`를 엮고, 그리고 — 모두를 무는 부분 — 직렬화를
설정해야 한다. 기본 JDK 직렬화는 캐시할 모든 타입이 `Serializable`이어야 하고,
불투명한 바이너리를 만들며, record나 marker 인터페이스를 깜빡한 타입을 처음 캐시하는
순간 런타임에 `SerializationException`을 던진다. JSON으로 바꾸려면
`GenericJackson2JsonRedisSerializer`를 손수 설정하고, 다형 값이 round-trip 되도록
default-typing을 정하고, `ObjectMapper`를 제대로 맞춰야 한다.

플랫폼 스타터는 그 결정을 **한 번** 내려 어떤 하위 제품도 다시는 안 하게 만들기에
딱 맞는 자리다. 목표:

> consumer가 **property 한 줄**로 캐시를 인메모리→분산(Redis)으로 바꾸고,
> **직렬화는 절대 안 건든다.** 자기 `@Cacheable` 메서드가 replica 간에 그냥 동작한다.

## 결정

### 1. `type` 스위치를 가진 `devslab.kit.cache.*` 블록

```yaml
devslab.kit.cache:
type: in-memory # none | in-memory | redis (기본: in-memory)
ttl: PT10M # 기본 엔트리 TTL
key-prefix: "devslab:" # 공유 Redis에서 키 네임스페이스
null-values: false # null 캐시? (기본 no — miss 가리는 것 방지)
```

- **`in-memory`** (기본) — `ConcurrentMapCacheManager` 계열. 외부 의존성 없음;
지금과 같은 단일 인스턴스 동작.
- **`redis`** — JSON 직렬화가 kit에 의해 완전히 사전 설정된 `RedisCacheManager`
(§3 참고). consumer는 Redis 연결 설정(`spring.data.redis.*`)만 추가하면 끝.
- **`none`** — `NoOpCacheManager`. 캐싱 애너테이션이 pass-through가 됨; 쓰기를
즉시 봐야 하는 테스트에 유용.

### 2. kit이 `CacheManager` 빈을 조건부로 소유

새 `CacheAutoConfiguration`이 `devslab.kit.cache.type`에 따라 `CacheManager`를
제공하되, 각 빈은 backing 클래스가 있을 때만 활성화되도록 가드:

- `redis`는 classpath에 `RedisConnectionFactory`(`@ConditionalOnClass`) **그리고**
connection factory 빈(`@ConditionalOnBean`)을 요구. `type=redis`인데 Redis가 안
엮였으면, 조용히 인메모리로 격하되는 대신 명확한 메시지와 함께 빠르게 실패.
- 모든 빈은 `@ConditionalOnMissingBean(CacheManager.class)` — 자기 `CacheManager`를
정의한 consumer가 항상 이긴다. kit은 명시적 선택과 싸우지 않는다.
- `@EnableCaching`은 kit이 켜서 consumer가 기억 안 해도 되게 한다(역시
`@ConditionalOnMissingBean(CacheManager.class)` 가드라, consumer가 직접 캐싱을
관리하면 비활성).

### 3. 직렬화는 kit 안에서 한 번에 해결

`redis` 경로에서 kit은 `RedisCacheConfiguration`을 이렇게 설정:

- **값:** kit 소유 `ObjectMapper` 위의 `GenericJackson2JsonRedisSerializer` —
JSON, `redis-cli`에서 사람이 읽을 수 있고, `Serializable` 불필요. default-typing은
**안전한** 형태로 활성화(CVE 위험이 있는 `LaissezFaireSubTypeValidator`가 아니라
allow-list validator로 non-final 타입에 제한)해서, consumer가 신경 안 써도 다형·
제네릭 값이 round-trip 된다.
- **키:** 설정된 `key-prefix`를 쓰는 `StringRedisSerializer`.
- **TTL & null 처리:** property 블록에서.

이게 가치 제안의 핵심이다: consumer가 Java record를 캐시하고 다른 노드에서 같은
record로 읽어오며, `SerializationException`도 직렬화 설정 한 줄도 절대 안 본다.

### 4. 모듈 배치

기존 `-api` / `-core` 분리를 따른다:

- **`devslab-kit-cache-api`** — 작은 계약: `CacheNames` 상수 홀더, 그리고 안정적으로
두고 싶은 kit 수준 캐시 추상화. 최소한; 대부분 consumer는 Spring `@Cacheable`만 씀.
- **`devslab-kit-cache-core`** — `CacheAutoConfiguration`은
`devslab-kit-autoconfigure`(다른 auto-config들이 있는 곳)에 두되, Redis 전용 설정
+ `ObjectMapper`/직렬화 wiring은 여기 둬서 Redis 클래스를 선택적·격리된 의존성으로.

Spring의 `spring-boot-starter-data-redis`는 `-cache-core`에 `optional`/`compileOnly`로
선언해, consumer가 opt-in 할 때만 classpath에 오른다. 스타터
(`devslab-kit-spring-boot-starter`)는 `-cache-core`를 끌어오지만, Redis 자체는
transitive 의존성이 **아니다** — consumer가 `type=redis`로 둘 때
`spring-boot-starter-data-redis`를 추가한다. README가 이 한 줄을 문서화한다.

### 5. 메뉴 캐시가 첫 소비자가 됨

기존 `CachingMenuProvider`를 공유 캐시 위에 다시 표현한다:

- `type=in-memory`/`redis` → 메뉴 provider가 자기 private map 대신 kit
`CacheManager`에 위임(`menu:<userId>` 키에 `@Cacheable` 식 읽기). 캐시 스토리는
둘이 아니라 하나.
- `type=none` → 메뉴 provider가 캐싱을 건너뜀. 오늘의 "0/음수 TTL이면 데코레이터
비활성" 특수 케이스를 대체.
- `devslab.kit.menu.cache-ttl`은 선택적 **per-cache 오버라이드**로 유지; 미설정 시
`devslab.kit.cache.ttl`을 상속. 기존 consumer는 기본값에서 동작 변화 없음.

### 6. consumer가 공짜로 얻는 것

kit이 `@EnableCaching`을 켜고 올바르게 직렬화하는 `CacheManager`를 소유하므로,
consumer **자기** 코드가 메서드에 `@Cacheable("their-cache")`를 붙이면 추가 설정
없이 즉시 분산 캐싱을 얻는다 — 대표 혜택. 자기 도메인 타입이 kit의 직렬화 정책으로,
replica 간에, JSON으로 캐시된다.

## 결과(Consequences)

**긍정**
- property 한 줄(`type: redis`)이 단일 노드 캐시를 올바른 분산 캐시로 바꾼다.
직렬화 설정도, `Serializable`도, 영원히 없음.
- 메뉴 캐시가 multi-replica 정합성 버그이길 멈춘다.
- 혜택이 consumer 자신의 `@Cacheable` 사용으로 확장된다 — 내부 최적화가 아니라
진짜 플랫폼 기능.
- Redis는 선택적·non-transitive로 유지; 인메모리 기본이 단순 배포의 무의존성
스토리를 지킨다.

**부정 / 비용**
- 새 모듈 + auto-config + 신중히 설정한 `ObjectMapper`. default-typing은 안전한
validator로 해야 함 — 보안을 제대로 잡아야 하는 유일한 지점
(`LaissezFaireSubTypeValidator` 금지).
- 메뉴를 Spring `CacheManager`로 캐시하면 맞춤 `invalidate(userId)` 메서드를 잃는다
(동등물을 노출하지 않는 한); admin 메뉴 수정은 문서화된 eviction 경로
(`@CacheEvict` 또는 `Cache.evict` 호출)가 필요.
- 스타터가 기본으로 켜는 게 하나 더(`@EnableCaching`); `@ConditionalOnMissingBean`
가드지만 짚어둘 가치 있음.

## 구현 계획 (PR 분할)

1. **`-cache-api` + `-cache-core` + `CacheAutoConfiguration`** — `type` 스위치,
in-memory + none 매니저, property 블록, 가드된 `@EnableCaching`. Redis는 아직
없음. 조건부 wiring 단위 테스트.
2. **Redis 경로 + 직렬화** — `RedisCacheManager`, 안전한 default-typing
`ObjectMapper`, key prefix/TTL. record가 새 `CacheManager`를 거쳐 round-trip
됨을 증명하는 실 Redis Testcontainers 테스트(직렬화가 리스크).
3. **메뉴 캐시 마이그레이션** — 공유 `CacheManager` 위로; per-cache TTL 오버라이드
유지; admin eviction 보존. 세 `type` 값 전부에서 메뉴 캐싱 회귀 테스트.
4. **sample-app + 문서** — sample-app이 이미 띄우는 compose Redis로 `type=redis`를
보여줌(그래서 Redis가 죽은 짐이길 멈춤); README가 property 한 줄 전환 +
"당신의 @Cacheable이 그냥 된다" 혜택을 문서화.

## 검토한 대안

- **직접 만든 map 유지.** 기각 — replica 2개 이상에서 깨지고, 모든 consumer가 분산
캐싱을 스스로 다시 푼다(이 kit이 없애려는 바로 그 세금).
- **인메모리는 Caffeine, 분산은 Redis, 두 코드 경로.** v1에선 기각 — Spring
`CacheManager` 추상화가 이미 한 인터페이스 뒤로 backend를 바꾸게 해주고,
in-memory 기본엔 `ConcurrentMapCacheManager`로 충분. Caffeine은 나중에 같은 `type`
스위치 뒤로(`type: caffeine`) API 변경 없이 끼울 수 있음.
- **Redis를 hard transitive 의존성으로.** 기각 — 인메모리 기본만 원하는 consumer에게
Redis jar(와 Redis 서버 기대)를 강요. 선택적 유지가 단순 경로를 보존.
- **consumer가 자기 직렬화를 설정하게.** 기각 — 그게 바로 이 ADR이 없애는 고통.
여기서 kit의 가치는 그걸 한 번에 정하는 것.
178 changes: 178 additions & 0 deletions docs/adr/0002-distributed-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# ADR 0002 — Pluggable distributed cache (one property, zero serializer pain)

- **Status:** Proposed
- **Date:** 2026-05-31
- **Languages:** [English](0002-distributed-cache.md) · [한국어](0002-distributed-cache.ko.md)

## Context

`devslab-kit` already caches one thing — the per-user menu tree — via a small
hand-rolled `CachingMenuProvider` backed by a `ConcurrentHashMap` (ADR 0001 era).
That works for a single instance, but the moment a consumer runs more than one
replica it breaks down: each instance has its own map, so a menu edit (or any
cached value) goes stale on every node except the one that handled the write,
until each node's TTL independently lapses.

The deeper point, raised while reviewing the kit for its first public release:
**setting up a correct distributed cache in Spring Boot is fiddly, and every
consumer pays the same tax.** They have to pick a `CacheManager`, wire a
`RedisConnectionFactory`, and — the part that bites everyone — configure
serialization. The default JDK serializer requires every cached type to be
`Serializable`, produces opaque binary blobs, and throws
`SerializationException` at runtime the first time someone caches a record or a
type that forgot the marker interface. Switching to JSON means hand-configuring
`GenericJackson2JsonRedisSerializer`, deciding on default-typing so polymorphic
values round-trip, and getting the `ObjectMapper` right.

A platform starter is exactly the right place to make that decision **once**, so
no downstream product ever has to. The goal:

> A consumer flips **one property** to turn the cache from in-memory to
> distributed (Redis), and **never touches serialization**. Their own
> `@Cacheable` methods just work across replicas.

## Decision

### 1. A `devslab.kit.cache.*` block with a `type` switch

```yaml
devslab.kit.cache:
type: in-memory # none | in-memory | redis (default: in-memory)
ttl: PT10M # default entry TTL
key-prefix: "devslab:" # namespace keys in shared Redis
null-values: false # cache nulls? (default no — avoids masking misses)
```

- **`in-memory`** (default) — a `ConcurrentMapCacheManager`-style manager. No
external dependency; same single-instance behaviour we have today.
- **`redis`** — a `RedisCacheManager` with JSON serialization fully
pre-configured by the kit (see §3). The consumer adds Redis connection
settings (`spring.data.redis.*`) and nothing else.
- **`none`** — a `NoOpCacheManager`. Caching annotations become pass-throughs;
useful in tests that must see writes immediately.

### 2. The kit owns the `CacheManager` bean, conditionally

A new `CacheAutoConfiguration` provides a `CacheManager` keyed on
`devslab.kit.cache.type`, each guarded so it only activates when its backing
classes are present:

- `redis` requires `RedisConnectionFactory` on the classpath
(`@ConditionalOnClass`) **and** a connection factory bean
(`@ConditionalOnBean`). If `type=redis` but Redis isn't wired, the kit fails
fast with a clear message rather than silently degrading to in-memory.
- All beans are `@ConditionalOnMissingBean(CacheManager.class)` — a consumer
who defines their own `CacheManager` always wins. The kit never fights an
explicit choice.
- `@EnableCaching` is switched on by the kit so consumers don't have to
remember it (also `@ConditionalOnMissingBean(CacheManager.class)`-guarded so
it's inert if they manage caching themselves).

### 3. Serialization is solved once, in the kit

For the `redis` path the kit configures `RedisCacheConfiguration` with:

- **Values:** `GenericJackson2JsonRedisSerializer` over a kit-owned
`ObjectMapper` — JSON, human-readable in `redis-cli`, no `Serializable`
requirement. Default typing is enabled in a **safe** form
(`activateDefaultTyping` restricted to non-final types via an allow-list
validator, not the CVE-prone `LaissezFaireSubTypeValidator`) so polymorphic
and generic values round-trip without the consumer thinking about it.
- **Keys:** `StringRedisSerializer` with the configured `key-prefix`.
- **TTL & null handling:** from the property block.

This is the crux of the value proposition: the consumer caches a Java record and
reads it back as the same record on another node, and never sees a
`SerializationException` or writes a serializer config line.

### 4. Module placement

Follow the existing `-api` / `-core` split:

- **`devslab-kit-cache-api`** — the small contract: a `CacheNames` constants
holder, and any kit-level cache abstraction we want stable. Minimal; most
consumers just use Spring's `@Cacheable`.
- **`devslab-kit-cache-core`** — `CacheAutoConfiguration` lives in
`devslab-kit-autoconfigure` (where the other auto-configs are), but the
Redis-specific config + the `ObjectMapper`/serializer wiring live here so the
Redis classes are an optional, isolated dependency.

Spring's `spring-boot-starter-data-redis` is declared `optional`/`compileOnly`
in `-cache-core` so it's only on the classpath when the consumer opts in. The
starter (`devslab-kit-spring-boot-starter`) pulls in `-cache-core`; Redis itself
is **not** a transitive dependency — the consumer adds
`spring-boot-starter-data-redis` when they set `type=redis`. README documents
this one-liner.

### 5. Menu cache becomes the first consumer

The existing `CachingMenuProvider` is re-expressed on top of the shared cache:

- `type=in-memory`/`redis` → the menu provider delegates to the kit
`CacheManager` (a `@Cacheable`-style read on a `menu:<userId>` key) instead of
its private map. One cache story, not two.
- `type=none` → the menu provider skips caching, replacing today's
"zero/negative TTL disables the decorator" special case.
- `devslab.kit.menu.cache-ttl` is kept as an optional **per-cache override**;
when unset it inherits `devslab.kit.cache.ttl`. Existing consumers see no
behaviour change at the default.

### 6. What consumers get for free

Because the kit turns on `@EnableCaching` and owns a correctly-serializing
`CacheManager`, a consumer's **own** code can annotate methods with
`@Cacheable("their-cache")` and immediately get distributed caching with no
further setup — the headline benefit. Their domain types cache as JSON, across
replicas, with the kit's serialization policy.

## Consequences

**Positive**
- One property (`type: redis`) turns a single-node cache into a correct
distributed one. No serializer config, no `Serializable`, ever.
- The menu cache stops being a multi-replica correctness bug.
- The benefit extends to the consumer's own `@Cacheable` usage — a real
platform feature, not just an internal optimization.
- Redis stays optional and non-transitive; in-memory default keeps the
zero-dependency story for simple deploys.

**Negative / cost**
- New modules + an auto-config + a carefully-configured `ObjectMapper`. Default
typing must be done with a safe validator — this is the one place to get
security right (no `LaissezFaireSubTypeValidator`).
- Caching the menu through Spring's `CacheManager` loses the bespoke
`invalidate(userId)` method unless we expose an equivalent; admin menu edits
need a documented eviction path (`@CacheEvict` or a `Cache.evict` call).
- One more thing the starter turns on by default (`@EnableCaching`); guarded by
`@ConditionalOnMissingBean` but worth calling out.

## Implementation plan (PR breakdown)

1. **`-cache-api` + `-cache-core` + `CacheAutoConfiguration`** — the `type`
switch, in-memory + none managers, property block, `@EnableCaching` guarded.
No Redis yet. Unit tests for the conditional wiring.
2. **Redis path + serialization** — `RedisCacheManager`, the safe-default-typing
`ObjectMapper`, key prefix/TTL. Real-Redis Testcontainers test proving a
record round-trips across a fresh `CacheManager` (serialization is the risk).
3. **Migrate the menu cache** onto the shared `CacheManager`; keep the
per-cache TTL override; preserve admin eviction. Regression-test menu caching
under all three `type` values.
4. **sample-app + docs** — sample-app shows `type=redis` with the compose Redis
it already starts (so Redis stops being dead weight); README documents the
one-property switch + the "your own @Cacheable just works" benefit.

## Alternatives considered

- **Leave the hand-rolled map.** Rejected — breaks on >1 replica, and every
consumer re-solves distributed caching themselves, which is the tax this kit
exists to remove.
- **Caffeine for in-memory, Redis for distributed, two code paths.** Rejected
for v1 — Spring's `CacheManager` abstraction already lets us swap backends
behind one interface; a `ConcurrentMapCacheManager` is enough for the
in-memory default. Caffeine can slot in later behind the same `type` switch
(`type: caffeine`) without an API change.
- **Make Redis a hard transitive dependency.** Rejected — forces the Redis jar
(and a Redis server expectation) on consumers who just want the in-memory
default. Keeping it optional preserves the zero-dependency simple path.
- **Let consumers configure their own serializer.** Rejected — that's exactly
the pain this ADR removes. The kit's whole value here is deciding it once.
Loading