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: 1 addition & 1 deletion docs/guides/admin-console.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ kit의 **적용된 설정**(`devslab.kit.*`)을 읽기 전용으로 보는 화
- **권한 체크** — **사용자**와 **권한**을 드롭다운으로 골라(UUID 타이핑 없음) 허용 여부·이유 확인.
- **메뉴 가시성** — 사용자를 고르면 그 사용자가 볼 메뉴 **트리**(권한 필터링).

### Audit Logs (감사 로그)
### Audit Logs (감사 로그) { #audit-logs }
모든 관리 작업을 비동기로 기록.

- 테넌트·행위자·액션·대상 타입·결과·기간으로 **필터**; 결과는 **지연 페이지네이션**.
Expand Down
99 changes: 80 additions & 19 deletions docs/guides/audit.ko.md
Original file line number Diff line number Diff line change
@@ -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<String,Object>` — 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 <token>' \
--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` 리소스.
98 changes: 80 additions & 18 deletions docs/guides/audit.md
Original file line number Diff line number Diff line change
@@ -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<String,Object>` — 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 <token>' \
--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

Expand All @@ -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.
73 changes: 52 additions & 21 deletions docs/guides/cache.ko.md
Original file line number Diff line number Diff line change
@@ -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) 참고.
Loading
Loading