From 15063e6e1c3cc31647308ec2de2ea05da90e220d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Wed, 3 Jun 2026 22:15:22 +0900 Subject: [PATCH] docs: deepen audit + cache guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the audit-logging and caching guides to the same beginner, step-by-step depth as the tutorial, with verified APIs and full file paths in every code block. audit: - fix the incomplete example: the previous AuditEvent.builder() snippet was comment-only ("action / target / outcome — see the builder"); now a complete, compiling call with real fields - correct a wrong claim: occurredAt is REQUIRED (build() throws on null) and actor is NOT auto-filled from context — the publisher just forwards to an async executor and the listener stores actor as-is - field table (action/occurredAt/target/outcome/actor/metadata/ip/ua), AuditOutcome is SUCCESS|FAILURE - read-the-trail: console + REST tabs (curl to /admin/api/v1/audit-logs) cache: - plain-language intro (what a cache is, one-property swap) - application.yml path + the spring.data.redis block redis needs - @Cacheable AND @CacheEvict example with file path - "confirm it's caching" tip (log on miss / redis-cli KEYS) Both guides EN + KO. Also pin { #audit-logs } on admin-console.ko.md so the EN-style fragment link resolves in the KO build. Verification: mkdocs build --strict clean, no anchor/link warnings. --- docs/guides/admin-console.ko.md | 2 +- docs/guides/audit.ko.md | 99 ++++++++++++++++++++++++++------- docs/guides/audit.md | 98 ++++++++++++++++++++++++++------ docs/guides/cache.ko.md | 73 +++++++++++++++++------- docs/guides/cache.md | 51 ++++++++++++++--- 5 files changed, 255 insertions(+), 68 deletions(-) diff --git a/docs/guides/admin-console.ko.md b/docs/guides/admin-console.ko.md index dae34e7..24fe537 100644 --- a/docs/guides/admin-console.ko.md +++ b/docs/guides/admin-console.ko.md @@ -95,7 +95,7 @@ kit의 **적용된 설정**(`devslab.kit.*`)을 읽기 전용으로 보는 화 - **권한 체크** — **사용자**와 **권한**을 드롭다운으로 골라(UUID 타이핑 없음) 허용 여부·이유 확인. - **메뉴 가시성** — 사용자를 고르면 그 사용자가 볼 메뉴 **트리**(권한 필터링). -### Audit Logs (감사 로그) +### Audit Logs (감사 로그) { #audit-logs } 모든 관리 작업을 비동기로 기록. - 테넌트·행위자·액션·대상 타입·결과·기간으로 **필터**; 결과는 **지연 페이지네이션**. diff --git a/docs/guides/audit.ko.md b/docs/guides/audit.ko.md index 75b5238..3459c2a 100644 --- a/docs/guides/audit.ko.md +++ b/docs/guides/audit.ko.md @@ -1,50 +1,111 @@ # 감사 로깅 -kit은 감사 추적을 **비동기**로 기록하므로, 로깅이 요청의 임계 경로에 끼어들지 않습니다. -이벤트는 메타데이터를 JSONB로 하여 PostgreSQL에 영속화됩니다. +**감사 추적(audit trail)**은 누가·무엇을·언제·성공했는지를 영구히 남기는 기록입니다 — "사용자 +`admin`이 14:03에 테넌트 `acme`를 정지함, 성공". kit은 이 추적을 요청의 임계 경로 밖에서 +**비동기**로 기록하고, 각 이벤트를 메타데이터까지 JSONB로 PostgreSQL에 저장합니다. -## 이벤트 발행 +처음이면 [튜토리얼](../getting-started/tutorial.md)부터 — 이 가이드는 실행 중인 앱이 있다고 +가정합니다. -`AuditEventPublisher`를 주입하고 `AuditEvent`를 `publish`하세요. `AuditEvent`는 **actor**, -**action**, **target**, **outcome**, 타임스탬프, 그리고 자유 형식 **metadata** 맵을 -담습니다. `AuditEvent.builder()`로 만듭니다: +## 흐름 + +``` +내 코드 kit PostgreSQL +─────── ─── ────────── +audit.publish(event) ──► publish()가 비동기 executor에 ──► 감사 테이블 한 행, + 넘김(요청 스레드 밖) 메타데이터는 JSONB +``` + +쓰기가 비동기이므로, 느리거나 실패하는 감사 쓰기가 그것을 유발한 요청을 느리게 하거나 실패 +시키지 않습니다. + +## 이벤트 기록 + +`AuditEventPublisher`를 주입하고 `AuditEvent`를 `publish`합니다. 이벤트는 +`AuditEvent.builder()`로 만듭니다: ```java +// src/main/java/com/example/myapp/TenantAdminService.java +import java.time.Instant; +import java.util.Map; +import kr.devslab.kit.audit.AuditAction; import kr.devslab.kit.audit.AuditEvent; import kr.devslab.kit.audit.AuditEventPublisher; +import kr.devslab.kit.audit.AuditOutcome; +import kr.devslab.kit.audit.AuditTarget; @Service class TenantAdminService { + private final AuditEventPublisher audit; TenantAdminService(AuditEventPublisher audit) { this.audit = audit; } void suspend(String tenantId, String reason) { // … 변경 수행 … - audit.publish( - AuditEvent.builder() - // action / target / outcome / metadata — AuditEvent 빌더 참고 + + audit.publish(AuditEvent.builder() + .action(AuditAction.of("tenant.suspend")) // 필수 + .target(new AuditTarget("tenant", tenantId)) + .outcome(AuditOutcome.SUCCESS) // SUCCESS | FAILURE + .occurredAt(Instant.now()) // 필수 + .metadata(Map.of("reason", reason)) // 자유 형식 → JSONB .build()); } } ``` -actor와 타임스탬프는 생략하면 현재 컨텍스트에서 채워지고, 발행기는 이벤트를 비동기 -리스너에 넘겨 기록합니다. +### 필드 + +| 필드 | 필수 | 의미 | +| --- | --- | --- | +| `action` | **예** | 무슨 일인지, 고정 코드로: `AuditAction.of("tenant.suspend")`. | +| `occurredAt` | **예** | 언제 — `Instant.now()`. | +| `target` | 아니오 | 대상: `new AuditTarget(type, id)`, 예 `("tenant", "acme")`. | +| `outcome` | 아니오 | `AuditOutcome.SUCCESS` 또는 `AuditOutcome.FAILURE`. | +| `actor` | 아니오 | 행위자: `new AuditActor(userId, tenantId, displayName)`. | +| `metadata` | 아니오 | 추가 컨텍스트 `Map` — JSONB로 저장. | +| `ip` / `userAgent` | 아니오 | 요청 출처(있을 때). | + +!!! warning "`occurredAt`은 필수, `actor`는 자동으로 안 채워짐" + `occurredAt`이 없으면 `build()`가 예외를 던집니다 — 항상 설정하세요. `actor`는 선택이지만, + kit이 보안 컨텍스트에서 **대신 채워주지 않습니다**: 행위자를 남기려면 `actor`를 직접 + 설정하세요(예: `CurrentUser`에서). ## 추적 조회 -감사 로그는 관리자 API(`audit-logs`)로 검색·필터합니다 — actor, action, target type, -outcome, 기간 기준. [관리자 REST API](../reference/admin-api.md) 참고. -[관리자 콘솔](https://github.com/devslab-kr/devslab-kit-admin-ui)은 상세 드로어가 딸린 -검색 가능한 감사 로그 뷰를 제공합니다. +=== "관리자 콘솔" + + [관리자 콘솔](admin-console.md) → **Audit Logs**: 필터(행위자 / 액션 / 대상 유형 / 결과 / + 기간)와 JSON 페이로드 상세 드로어를 갖춘, 검색 가능한 lazy 페이지 테이블. + [관리자 콘솔 가이드 → Audit Logs](admin-console.md#audit-logs) 참고. + +=== "REST API" + + ```bash + # 액션 + 결과 + 기간으로 필터: + curl -G localhost:8080/admin/api/v1/audit-logs \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'tenantId=default' \ + --data-urlencode 'action=tenant.suspend' \ + --data-urlencode 'outcome=FAILURE' \ + --data-urlencode 'from=2026-06-01T00:00:00Z' + ``` + + 페이지 목록을 반환합니다. 전체 `audit-logs` 쿼리 파라미터는 + [관리자 REST API](../reference/admin-api.md) 참고. ## 튜닝 | 키 | 기본값 | | | --- | --- | --- | -| `audit.enabled` | `true` | 필요 없으면 서브시스템 전체를 끔. | -| `audit.async-queue-capacity` | `1024` | 비동기 라이터에 공급하는 bounded 큐. | +| `audit.enabled` | `true` | 필요 없으면 전체 서브시스템 끄기. | +| `audit.async-queue-capacity` | `1024` | 비동기 writer로 들어가는 bounded 큐. | + +큐는 의도적으로 **bounded**입니다 — 폭주 시 메모리를 소진하기보다 감사 쓰기를 버립니다. +처리량에 맞게 크기를 잡으세요. [설정 레퍼런스](../reference/configuration.md#audit) 참고. + +## 더 보기 -큐는 의도적으로 **bounded**입니다 — 폭주 시 메모리를 소진하는 대신 감사 쓰기를 흘려보냅니다. -처리량에 맞게 크기를 정하세요. [설정 레퍼런스](../reference/configuration.md#audit) 참고. +- [관리자 콘솔 → Audit Logs](admin-console.md#audit-logs) — 검색 가능한 뷰어. +- [관리자 REST API](../reference/admin-api.md) — `audit-logs` 리소스. diff --git a/docs/guides/audit.md b/docs/guides/audit.md index 6fc2c1a..71955e9 100644 --- a/docs/guides/audit.md +++ b/docs/guides/audit.md @@ -1,45 +1,102 @@ # Audit Logging -The kit records an audit trail **asynchronously**, so logging never sits on the -request's critical path. Events are persisted to PostgreSQL with their metadata as -JSONB. +An **audit trail** is a permanent record of who did what, when, and whether it +worked — "user `admin` suspended tenant `acme` at 14:03, success". The kit records +this trail **asynchronously**, off the request's critical path, and persists each +event to PostgreSQL with its metadata as JSONB. -## Emitting an event +New here? Do the [Tutorial](../getting-started/tutorial.md) first. This guide assumes +you have a running app. -Inject `AuditEventPublisher` and `publish` an `AuditEvent`. An `AuditEvent` carries -an **actor**, an **action**, a **target**, an **outcome**, a timestamp, and a -free-form **metadata** map; build it with `AuditEvent.builder()`: +## How it flows + +``` +your code kit PostgreSQL +───────── ─── ────────── +audit.publish(event) ──► publish() hands it to an ──► one row in the + async executor (off the audit table, metadata + request thread) stored as JSONB +``` + +Because the write is async, a slow or failing audit write never slows down — or +fails — the request that triggered it. + +## Emit an event + +Inject `AuditEventPublisher` and `publish` an `AuditEvent`. Build the event with +`AuditEvent.builder()`: ```java +// src/main/java/com/example/myapp/TenantAdminService.java +import java.time.Instant; +import java.util.Map; +import kr.devslab.kit.audit.AuditAction; import kr.devslab.kit.audit.AuditEvent; import kr.devslab.kit.audit.AuditEventPublisher; +import kr.devslab.kit.audit.AuditOutcome; +import kr.devslab.kit.audit.AuditTarget; @Service class TenantAdminService { + private final AuditEventPublisher audit; TenantAdminService(AuditEventPublisher audit) { this.audit = audit; } void suspend(String tenantId, String reason) { // … perform the change … - audit.publish( - AuditEvent.builder() - // action / target / outcome / metadata — see the AuditEvent builder + + audit.publish(AuditEvent.builder() + .action(AuditAction.of("tenant.suspend")) // required + .target(new AuditTarget("tenant", tenantId)) + .outcome(AuditOutcome.SUCCESS) // SUCCESS | FAILURE + .occurredAt(Instant.now()) // required + .metadata(Map.of("reason", reason)) // free-form → JSONB .build()); } } ``` -The actor and timestamp are filled from the current context when omitted; the -publisher hands the event to an async listener that writes it. +### The fields -## Reading the trail +| Field | Required | What it is | +| --- | --- | --- | +| `action` | **yes** | What happened, as a stable code: `AuditAction.of("tenant.suspend")`. | +| `occurredAt` | **yes** | When — `Instant.now()`. | +| `target` | no | What was acted on: `new AuditTarget(type, id)`, e.g. `("tenant", "acme")`. | +| `outcome` | no | `AuditOutcome.SUCCESS` or `AuditOutcome.FAILURE`. | +| `actor` | no | Who did it: `new AuditActor(userId, tenantId, displayName)`. | +| `metadata` | no | Any extra context as a `Map` — stored as JSONB. | +| `ip` / `userAgent` | no | Request origin, when you have it. | -Search and filter audit logs through the admin API (`audit-logs`) — by actor, -action, target type, outcome and time range. See -[Admin REST API](../reference/admin-api.md). The -[admin console](https://github.com/devslab-kr/devslab-kit-admin-ui) ships a -searchable audit-log view with a detail drawer. +!!! warning "`occurredAt` is required, `actor` is not auto-filled" + `build()` throws if `occurredAt` is missing — always set it. `actor` is optional, + but the kit does **not** fill it from the security context for you: if you want + the acting user recorded, set `actor` explicitly (e.g. from your `CurrentUser`). + +## Read the trail + +=== "Admin console" + + Open the [admin console](admin-console.md) → **Audit Logs**: a searchable, + lazily-paginated table with filters (actor / action / target type / outcome / + time range) and a JSON-payload detail drawer. See the + [Admin Console guide → Audit Logs](admin-console.md#audit-logs). + +=== "REST API" + + ```bash + # filter by action + outcome + time range: + curl -G localhost:8080/admin/api/v1/audit-logs \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'tenantId=default' \ + --data-urlencode 'action=tenant.suspend' \ + --data-urlencode 'outcome=FAILURE' \ + --data-urlencode 'from=2026-06-01T00:00:00Z' + ``` + + Returns a paged list. See the [Admin REST API](../reference/admin-api.md) for the + full `audit-logs` query parameters. ## Tuning @@ -51,3 +108,8 @@ searchable audit-log view with a detail drawer. The queue is **bounded** on purpose — under a flood, audit writes shed rather than exhaust memory. Size it for your throughput. See the [Configuration reference](../reference/configuration.md#audit). + +## See also + +- [Admin Console → Audit Logs](admin-console.md#audit-logs) — the searchable viewer. +- [Admin REST API](../reference/admin-api.md) — the `audit-logs` resource. diff --git a/docs/guides/cache.ko.md b/docs/guides/cache.ko.md index ae52a5b..9283453 100644 --- a/docs/guides/cache.ko.md +++ b/docs/guides/cache.ko.md @@ -1,65 +1,96 @@ # 캐시 -`devslab-kit`은 Spring의 `CacheManager` 뒤에 **플러그형 캐시**를 제공합니다. 속성 하나로 -백엔드를 고르면 나머지는 kit이 배선합니다 — Redis용 JSON 직렬화 포함이라 `Serializable` -구현이나 직렬화기 설정이 필요 없습니다. (배경: [ADR 0002](../adr/0002-distributed-cache.md).) +**캐시**는 비싼 호출의 결과를 저장해, 다음에 같은 호출이 오면 다시 계산하지 않고 즉시 +돌려줍니다. `devslab-kit`은 Spring의 `CacheManager` 뒤에 **플러그형 캐시**를 제공합니다: +**속성 하나**로 백엔드를 고르면 나머지는 kit이 엮습니다 — Redis용 JSON 직렬화 포함이라 +`Serializable`을 구현하거나 직렬화기를 설정할 일이 없습니다. (배경: [ADR 0002](../adr/0002-distributed-cache.md).) + +처음이면 [튜토리얼](../getting-started/tutorial.md)부터 — 이 가이드는 실행 중인 앱이 있다고 +가정합니다. ## 백엔드 | `cache.type` | `CacheManager` | 용도 | | --- | --- | --- | -| `in-memory` | `ConcurrentMapCacheManager` | 단일 노드 앱과 로컬 개발(기본값). | -| `redis` | `RedisCacheManager` | 여러 replica — 엔트리가 인스턴스 간 공유·일관됨. | -| `none` | `NoOpCacheManager` | 캐시 완전 비활성(모든 조회 재계산). | +| `in-memory` | `ConcurrentMapCacheManager` | 단일 노드 앱·로컬 개발(기본). | +| `redis` | `RedisCacheManager` | 여러 인스턴스 — 항목이 인스턴스 간 공유·일관. | +| `none` | `NoOpCacheManager` | 캐시 완전 비활성(매 조회 재계산). | + +기준: **인스턴스가 하나일 동안은 `in-memory`**, 둘 이상 띄우면 `redis`로 바꿔 한 인스턴스가 +캐시한 값을 다른 인스턴스도 보게 합니다. + +## 설정 ```yaml +# src/main/resources/application.yml devslab: kit: cache: type: redis ttl: PT10M key-prefix: "myapp:" + +# redis 백엔드는 Spring이 Redis를 가리키게도 해야 합니다: +spring: + data: + redis: + host: localhost + port: 6379 ``` -`redis`를 쓸 때는 Spring도 Redis를 가리키게 하세요(`spring.data.redis.*`). +`type: in-memory`(기본)면 `spring.data.redis` 블록은 필요 없습니다. ## 사용 -표준 Spring 캐시이므로 `@Cacheable` / `@CacheEvict`와 주입된 `CacheManager`가 모두 -동작합니다: +표준 Spring 캐시이므로 `@Cacheable` / `@CacheEvict`와 주입된 `CacheManager`가 모두 동작합니다: ```java +// src/main/java/com/example/myapp/PriceService.java +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; + @Service class PriceService { - @Cacheable("prices") + @Cacheable("prices") // 미스일 때만 계산, 이후 캐시 public Price lookup(String sku) { - // miss일 때만 계산; 설정된 백엔드 + TTL에 따라 캐시 - return expensiveLookup(sku); + return expensiveLookup(sku); // 예: 느린 DB·외부 호출 + } + + @CacheEvict(value = "prices", key = "#sku") // 변경 시 항목 하나 제거 + public void priceChanged(String sku) { + // 다음 lookup(sku)는 재계산 후 다시 캐시 } } ``` -kit 자체의 사용자별 [메뉴](menus.md) 트리도 이 같은 캐시 매니저를 사용합니다. +첫 `lookup("ABC")`는 메서드를 실행하고, 두 번째는 메서드 본문에 들어가지 않고 캐시된 +`Price`를 돌려줍니다 — TTL이 만료되거나 `priceChanged("ABC")`가 비울 때까지. + +!!! tip "정말 캐시되는지 확인" + `expensiveLookup` 안에 로그를 찍으면 **첫 호출에만** 찍혀야 합니다. `redis`면 키가 + 생기는 것도 볼 수 있습니다: `redis-cli KEYS 'myapp:prices*'`. + +kit의 사용자별 [메뉴](menus.md) 트리도 이 같은 cache manager를 탑니다. ## Redis 직렬화 -Redis 백엔드가 직렬화를 책임집니다: 값은 JSON으로 저장되고, 안전한 다형 타이핑은 -허용 목록(`java.*` + `cache.allowed-package`, 기본 `kr.devslab`)으로 제한됩니다. 따라서: +Redis 백엔드가 직렬화를 책임집니다: 값은 JSON으로 저장되고, 안전한 다형 타이핑은 allow-list +(`java.*` + `cache.allowed-package`, 기본 `kr.devslab`)로 제한됩니다. 즉: - 캐시 타입에 `implements Serializable` 불필요, -- 등록할 직렬화기 빈 불필요, -- `redis-cli`에서 읽을 수 있는 값. +- 등록할 직렬화기 빈 없음, +- `redis-cli`에서 값이 읽힘. -캐시 타입은 자신의 패키지로 유지하세요(또는 `cache.allowed-package`를 넓히세요). +캐시 타입은 자신의 패키지에 두세요(또는 `cache.allowed-package`를 넓히세요). ## 튜닝 | 키 | 기본값 | | | --- | --- | --- | -| `cache.type` | `in-memory` | 백엔드 선택자. | -| `cache.ttl` | `PT10M` | 엔트리 TTL(Redis). | +| `cache.type` | `in-memory` | 백엔드 선택. | +| `cache.ttl` | `PT10M` | 항목 TTL(Redis). | | `cache.key-prefix` | `devslab:` | Redis 키 네임스페이스. | -| `cache.allowed-package` | `kr.devslab` | 다형 JSON 타이핑 허용 목록. | +| `cache.allowed-package` | `kr.devslab` | 다형 JSON 타이핑 allow-list. | [설정 레퍼런스](../reference/configuration.md#cache) 참고. diff --git a/docs/guides/cache.md b/docs/guides/cache.md index 1a113df..2cef3c9 100644 --- a/docs/guides/cache.md +++ b/docs/guides/cache.md @@ -1,9 +1,13 @@ # Caching -`devslab-kit` ships a **pluggable cache** behind Spring's `CacheManager`. You pick -the backend with one property; the kit wires the rest — including JSON -serialization for Redis, so you never implement `Serializable` or configure a -serializer. (Background: [ADR 0002](../adr/0002-distributed-cache.md).) +A **cache** stores the result of an expensive call so the next identical call returns +instantly instead of recomputing. `devslab-kit` ships a **pluggable cache** behind +Spring's `CacheManager`: you pick the backend with **one property**, and the kit wires +the rest — including JSON serialization for Redis, so you never implement +`Serializable` or configure a serializer. (Background: [ADR 0002](../adr/0002-distributed-cache.md).) + +New here? Do the [Tutorial](../getting-started/tutorial.md) first. This guide assumes +you have a running app. ## Backends @@ -13,34 +17,63 @@ serializer. (Background: [ADR 0002](../adr/0002-distributed-cache.md).) | `redis` | `RedisCacheManager` | Multiple replicas — entries are shared and correct across instances. | | `none` | `NoOpCacheManager` | Disable caching entirely (every read recomputes). | +The rule of thumb: **`in-memory` until you run more than one instance**, then `redis` +so a value cached by one replica is seen by the others. + +## Configure + ```yaml +# src/main/resources/application.yml devslab: kit: cache: type: redis ttl: PT10M key-prefix: "myapp:" + +# the redis backend also needs Spring pointed at Redis: +spring: + data: + redis: + host: localhost + port: 6379 ``` -With `redis`, also point Spring at Redis (`spring.data.redis.*`). +With `type: in-memory` (the default) you need none of the `spring.data.redis` block. -## Using it +## Use it It's a standard Spring cache, so `@Cacheable` / `@CacheEvict` and an injected `CacheManager` all work: ```java +// src/main/java/com/example/myapp/PriceService.java +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; + @Service class PriceService { - @Cacheable("prices") + @Cacheable("prices") // computed only on a miss, then cached public Price lookup(String sku) { - // computed only on a miss; cached per the configured backend + TTL - return expensiveLookup(sku); + return expensiveLookup(sku); // e.g. a slow DB or upstream call + } + + @CacheEvict(value = "prices", key = "#sku") // drop one entry on change + public void priceChanged(String sku) { + // next lookup(sku) recomputes and re-caches } } ``` +The first `lookup("ABC")` runs the method; the second returns the cached `Price` +without entering the method body, until the TTL expires or `priceChanged("ABC")` +evicts it. + +!!! tip "Confirm it's actually caching" + Log inside `expensiveLookup` — it should print on the **first** call only. With + `redis`, you can also watch the keys appear: `redis-cli KEYS 'myapp:prices*'`. + The kit's own per-user [menu](menus.md) tree rides this same cache manager. ## Redis serialization