diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 605fd90..bd945f6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,7 +16,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -26,26 +26,44 @@ jobs:
with:
distribution: temurin
java-version: 21
- cache: maven
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ gradle-home-cache-cleanup: true
+ # Configuration cache occasionally trips up the Vanniktech publish
+ # plugin's property-driven configuration — keep CI off it.
+ cache-read-only: ${{ github.ref != 'refs/heads/master' }}
- name: Build and test
- run: ./mvnw -B verify
+ # `build` runs compileJava + compileTestJava + test on every subproject.
+ # `jacocoTestReport` is wired in each module's build.gradle.kts but
+ # repeating it here makes the dependency explicit for the next step.
+ run: ./gradlew build jacocoTestReport --no-configuration-cache --stacktrace
- name: Upload coverage to Codecov
if: success() && github.event_name == 'push'
uses: codecov/codecov-action@v6
with:
- files: ./target/site/jacoco/jacoco.xml
+ # Glob across all subprojects so adding a future module (e.g.
+ # api-log-jdbc) picks up its coverage report without another edit.
+ files: |
+ ./core/build/reports/jacoco/test/jacocoTestReport.xml
+ ./jpa/build/reports/jacoco/test/jacocoTestReport.xml
+ ./r2dbc/build/reports/jacoco/test/jacocoTestReport.xml
+ ./mybatis/build/reports/jacoco/test/jacocoTestReport.xml
flags: unittests
- fail_ci_if_error: false # don't break CI if Codecov upload glitches
- token: ${{ secrets.CODECOV_TOKEN }} # optional for public repos
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test reports on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-reports
+ # `**` so any future module's reports are also captured.
path: |
- target/surefire-reports/
- target/failsafe-reports/
+ **/build/reports/tests/
+ **/build/test-results/
+ **/build/reports/jacoco/
retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b11dfc3..a830b95 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,6 +13,7 @@ jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 30
+ environment: maven-central
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -22,26 +23,31 @@ jobs:
with:
distribution: temurin
java-version: 21
- cache: maven
- server-id: central # matches central in settings.xml below
- server-username: MAVEN_CENTRAL_USERNAME
- server-password: MAVEN_CENTRAL_PASSWORD
- gpg-private-key: ${{ secrets.SIGNING_KEY }}
- gpg-passphrase: MAVEN_GPG_PASSPHRASE # maven-gpg-plugin 3.x convention
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v5
- name: Derive version from tag
id: ver
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- - name: Set release version
- run: ./mvnw -B versions:set -DnewVersion=${{ steps.ver.outputs.version }} -DgenerateBackupPoms=false
+ - name: Build and test
+ run: ./gradlew build --no-configuration-cache --stacktrace -PVERSION=${{ steps.ver.outputs.version }}
- - name: Build, sign, and publish to Maven Central
+ - name: Publish to Maven Central
env:
- MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
- MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
- MAVEN_GPG_PASSPHRASE: ${{ secrets.SIGNING_KEY_PASSWORD }}
- run: ./mvnw -B -P release deploy
+ # Sonatype Central Portal credentials (https://central.sonatype.com/account)
+ ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ # ASCII-armored private key (`gpg --armor --export-secret-keys `)
+ ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
+ run: |
+ ./gradlew publishAndReleaseToMavenCentral \
+ --no-configuration-cache \
+ --stacktrace \
+ -PVERSION=${{ steps.ver.outputs.version }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
@@ -49,6 +55,8 @@ jobs:
generate_release_notes: true
name: ${{ github.ref_name }}
fail_on_unmatched_files: false
+ # Glob across every module's build/libs/ so the release page picks up
+ # api-log-core / -jpa / -r2dbc / -mybatis without per-module entries.
files: |
- target/*.jar
- target/*.asc
+ **/build/libs/*.jar
+ **/build/libs/*.asc
diff --git a/.gitignore b/.gitignore
index 54d84eb..68089bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,20 @@
-HELP.md
-target/
-.mvn/wrapper/maven-wrapper.jar
-!**/src/main/**/target/
-!**/src/test/**/target/
+# Gradle
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
-### STS ###
+# IDE - IntelliJ
+.idea/
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+# IDE - Eclipse / STS
.apt_generated
.classpath
.factorypath
@@ -12,33 +22,47 @@ target/
.settings
.springBeans
.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
-### IntelliJ IDEA ###
-.idea
-*.iws
-*.iml
-*.ipr
+# IDE - VS Code
+.vscode/
-### NetBeans ###
+# NetBeans
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
-build/
-!**/src/main/**/build/
-!**/src/test/**/build/
-### VS Code ###
-.vscode/
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
-### PostgreSQL data ###
+# Secrets / signing
+*.gpg
+secring.*
+local.properties
+gradle-local.properties
+*.env
+.env*
+!.env.example
+
+# PostgreSQL data
data/
-### Claude ###
+# Claude
.claude/
-### Application properties (root level duplicate) ###
+# Application properties (root level duplicate; test fixtures are kept)
application.properties
-# Test fixtures should be tracked though
!src/test/resources/application.properties
+
+# Test results
+/test-results/
+/reports/
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
deleted file mode 100644
index c0bcafe..0000000
--- a/.mvn/wrapper/maven-wrapper.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-wrapperVersion=3.3.4
-distributionType=only-script
-distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6719876..85bc82a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,35 @@ The source of truth for the entries below is [docs/changelog.md](docs/changelog.
## [Unreleased]
+## [0.6.0] — Multi-module split (Gradle), pluggable JPA / R2DBC / MyBatis backends
+
+### Changed
+
+- **The single `api-log-spring-boot-starter` artifact is split.** Consumers now add `kr.devslab:api-log-core` plus exactly one backend artifact: `api-log-jpa` (drop-in for v0.5.x), `api-log-r2dbc` (reactive), or `api-log-mybatis`.
+- **Build system: Maven → Gradle 8.10** with Vanniktech maven-publish per module.
+- **Package renames**: `model.dto` → `dto`, `model.ApiLogEntity` → `jpa.model.ApiLogEntity`, `service.ApiLogService` → `jpa.writer.JpaApiLogWriter`. Full mapping in [docs/changelog.md](docs/changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends).
+
+### Added
+
+- **`ApiLogWriter` SPI** — backend-agnostic three-method interface (`writeInitiated` / `writeSuccess` / `writeError`). Each backend artifact registers one implementation; the core listener routes events through it.
+- **`api-log-r2dbc`** — reactive backend using R2DBC's `DatabaseClient`. Pure-reactive schema initializer; no JDBC pull-in.
+- **`api-log-mybatis`** — MyBatis mapper backend with `::jsonb` cast on inserts.
+
+### Fixed
+
+- `V1.0__create_api_log.sql` now uses `IF NOT EXISTS` on both CREATE TABLE and CREATE INDEX — idempotent across boots under BUILTIN mode.
+
+Full migration notes in [docs/changelog.md](docs/changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends).
+
+## [0.5.2] — Fix bean registration in real consumer apps
+
+### Fixed
+
+- `RestApiClientUtil` + four `@Configuration` classes were never registered in consumer apps (relied on `@ComponentScan` reaching the starter's package). Fixed by splitting into three `@AutoConfiguration` classes registered via `META-INF/spring/.../AutoConfiguration.imports`.
+- `spring-boot-starter-web` is now `true` — pure-WebFlux apps no longer get a Servlet stack forced onto their classpath.
+
+Full notes in [docs/changelog.md](docs/changelog.md#052--fix-bean-registration-in-real-consumer-apps).
+
## [0.5.1] — Reactive (WebFlux) client + end-to-end HTTP tests
### Added
@@ -75,7 +104,9 @@ See [docs/changelog.md](docs/changelog.md#020--schema-management-opt-in) for the
First public release. See [docs/changelog.md](docs/changelog.md#010--initial-release) for details.
-[Unreleased]: https://github.com/devslab-kr/api-log/compare/v0.5.1...HEAD
+[Unreleased]: https://github.com/devslab-kr/api-log/compare/v0.6.0...HEAD
+[0.6.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.6.0
+[0.5.2]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.2
[0.5.1]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.1
[0.5.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.5.0
[0.4.0]: https://github.com/devslab-kr/api-log/releases/tag/v0.4.0
diff --git a/README.ko.md b/README.ko.md
index a63d19c..a41ccc4 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -4,7 +4,7 @@
> Spring Boot용 이벤트 드리븐 API 호출 로깅. 비동기 이벤트 파이프라인 + PostgreSQL JSONB. 요청 경로를 막지 않고 외부 API 호출을 모두 기록합니다.
-[](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter)
+[](https://central.sonatype.com/artifact/kr.devslab/api-log-core)
[](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml)
[](https://codecov.io/gh/devslab-kr/api-log)
[](LICENSE)
@@ -57,35 +57,64 @@ public class UserService {
```
Caller code
↓
-RestApiClientUtil (또는 자체 HTTP 클라이언트)
+RestApiClientUtil / ReactiveApiClientUtil (또는 자체 HTTP 클라이언트)
↓ publishEvent
ApplicationEventPublisher
- ↓ @EventListener (async)
-ApiEventListener
- ↓
-ApiLogService
- ↓
-ApiLogRepository (JPA)
- ↓
-PostgreSQL (api_log · JSONB columns)
+ ↓ @EventListener (virtual threads)
+ApiEventListener (api-log-core)
+ ↓ ApiLogWriter (SPI)
+ ├─ JpaApiLogWriter (api-log-jpa)
+ ├─ R2dbcApiLogWriter (api-log-r2dbc)
+ └─ MybatisApiLogWriter (api-log-mybatis)
+ ↓
+ PostgreSQL (api_log · JSONB columns)
```
## 설치
+v0.6.0부터 스타터가 4개 아티팩트로 분리됐습니다 — 백엔드 비종속 코어 1개 +
+영속화 백엔드 1개. **`api-log-core` 1개 + 백엔드 1개**를 직접 골라 추가:
+
+| 좌표 | 언제 쓰나 |
+| --- | --- |
+| `kr.devslab:api-log-jpa` | Servlet / JPA 앱 (v0.5.x 드롭인) |
+| `kr.devslab:api-log-r2dbc` | WebFlux / R2DBC 앱 — JDBC 의존성 없음 |
+| `kr.devslab:api-log-mybatis` | 이미 MyBatis를 쓰고, JPA를 원치 않을 때 |
+
+백엔드 아티팩트 각각이 `api-log-core`를 transitive하게 가져오므로
+좌표 하나만 추가하면 됩니다.
+
### Maven
```xml
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+
+
+
+ kr.devslab
+ api-log-r2dbc
+ 0.6.0
+
+
+
kr.devslab
- api-log-spring-boot-starter
- 0.5.1
+ api-log-mybatis
+ 0.6.0
```
### Gradle
```kotlin
-implementation("kr.devslab:api-log-spring-boot-starter:0.5.1")
+implementation("kr.devslab:api-log-jpa:0.6.0")
+// 또는 "kr.devslab:api-log-r2dbc:0.6.0"
+// 또는 "kr.devslab:api-log-mybatis:0.6.0"
```
## 설정
diff --git a/README.md b/README.md
index 319b446..07cc6c7 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
> Event-driven API call logging for Spring Boot. Async event pipeline with PostgreSQL JSONB storage.
-[](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter)
+[](https://central.sonatype.com/artifact/kr.devslab/api-log-core)
[](https://github.com/devslab-kr/api-log/actions/workflows/ci.yml)
[](https://codecov.io/gh/devslab-kr/api-log)
[](LICENSE)
@@ -57,35 +57,65 @@ Bodies are stored as JSONB, so you can query them with `->`, `->>`, and GIN inde
```
Caller code
↓
-RestApiClientUtil (or your own HTTP client)
+RestApiClientUtil / ReactiveApiClientUtil (or your own HTTP client)
↓ publishEvent
ApplicationEventPublisher
- ↓ @EventListener (async)
-ApiEventListener
- ↓
-ApiLogService
- ↓
-ApiLogRepository (JPA)
- ↓
-PostgreSQL (api_log · JSONB columns)
+ ↓ @EventListener (virtual threads)
+ApiEventListener (api-log-core)
+ ↓ ApiLogWriter (SPI)
+ ├─ JpaApiLogWriter (api-log-jpa)
+ ├─ R2dbcApiLogWriter (api-log-r2dbc)
+ └─ MybatisApiLogWriter (api-log-mybatis)
+ ↓
+ PostgreSQL (api_log · JSONB columns)
```
## Installation
+v0.6.0 splits the starter into four artifacts: a backend-agnostic core, plus
+one of three persistence backends. Add **`api-log-core` plus exactly one
+backend** to your build:
+
+| Coordinate | When to use it |
+| --- | --- |
+| `kr.devslab:api-log-jpa` | Servlet / JPA app (the v0.5.x drop-in) |
+| `kr.devslab:api-log-r2dbc` | WebFlux / R2DBC app — no JDBC pull-in |
+| `kr.devslab:api-log-mybatis` | Already on MyBatis, don't want JPA |
+
+Each backend artifact transitively depends on `api-log-core`, so one
+coordinate is enough.
+
### Maven
```xml
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+
+
+
+ kr.devslab
+ api-log-r2dbc
+ 0.6.0
+
+
+
kr.devslab
- api-log-spring-boot-starter
- 0.3.0
+ api-log-mybatis
+ 0.6.0
```
### Gradle
```kotlin
-implementation("kr.devslab:api-log-spring-boot-starter:0.3.0")
+implementation("kr.devslab:api-log-jpa:0.6.0")
+// or "kr.devslab:api-log-r2dbc:0.6.0"
+// or "kr.devslab:api-log-mybatis:0.6.0"
```
## Configuration
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..2cad94b
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,18 @@
+// Multi-module orchestration. The root project is not published — each
+// publishable artifact lives under its own subproject (see settings.gradle.kts)
+// and applies the publishing plugin itself.
+//
+// Plugin versions are declared here with `apply false` so subprojects can
+// apply them without repeating version numbers, and so the version drift
+// between modules stays at zero.
+
+plugins {
+ id("org.springframework.boot") version "3.5.6" apply false
+ id("io.spring.dependency-management") version "1.1.6" apply false
+ id("com.vanniktech.maven.publish") version "0.30.0" apply false
+}
+
+allprojects {
+ group = providers.gradleProperty("GROUP").get()
+ version = providers.gradleProperty("VERSION").get()
+}
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 0000000..ad88614
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,182 @@
+// :core — backend-agnostic foundation for the api-log starter.
+//
+// Published as `kr.devslab:api-log-core`. Holds:
+// - The event objects (ApiCallInitiatedEvent / SuccessEvent / ErrorEvent)
+// - The `ApiLogWriter` SPI that backend modules implement
+// - The async event listener that drives writers off the event bus
+// - The HTTP client utilities (RestApiClientUtil for sync, ReactiveApiClientUtil for reactive)
+// - The shared Jackson customizer (Blackbird), retry config, and properties
+//
+// Backend modules (`:jpa`, `:r2dbc`, `:mybatis`) depend on this and each
+// register exactly one `ApiLogWriter` bean — consumers pick by adding the
+// backend artifact they want.
+
+plugins {
+ `java-library`
+ jacoco
+ id("org.springframework.boot") apply false
+ id("io.spring.dependency-management")
+ id("com.vanniktech.maven.publish")
+}
+
+base {
+ // On-disk jar filename. Vanniktech overrides the *publish* coordinates
+ // separately via `mavenPublishing.coordinates(...)`; this controls only
+ // the local `build/libs/*.jar` name so GitHub Release assets are readable.
+ archivesName.set("api-log-core")
+}
+
+// Vanniktech's javadoc jar task hardcodes its archive base name to
+// `-maven-javadoc` and only sets it inside its plugin's own
+// `afterEvaluate`. Without this override the GitHub Release ends up with a
+// confusing `core-maven-javadoc-X.Y.Z-javadoc.jar` next to the properly named
+// main jar. Configuring inside `afterEvaluate` makes us the last writer.
+afterEvaluate {
+ tasks.named("mavenPlainJavadocJar").configure {
+ archiveBaseName.set("api-log-core")
+ }
+}
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+}
+
+tasks.withType().configureEach {
+ options.encoding = "UTF-8"
+ // -parameters: keep AOP-readable param names. -Xlint enabled but the noisy
+ // categories (classfile/processing/serial) are excluded so -Werror stays
+ // usable for real code issues without tripping on annotation-processor noise.
+ options.compilerArgs.addAll(listOf(
+ "-parameters",
+ "-Xlint:all,-classfile,-processing,-serial",
+ "-Werror"
+ ))
+}
+
+tasks.withType().configureEach {
+ options.encoding = "UTF-8"
+ (options as StandardJavadocDocletOptions).apply {
+ addBooleanOption("Xdoclint:none", true)
+ addBooleanOption("html5", true)
+ locale = "en_US"
+ }
+}
+
+dependencyManagement {
+ imports {
+ mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.6")
+ }
+}
+
+dependencies {
+ // Pulled in transitively for every consumer of any api-log-* artifact:
+ // spring-context + spring-boot give us @EventListener, @EnableAsync,
+ // ApplicationEventPublisher, @ConditionalOnProperty, etc.
+ api("org.springframework.boot:spring-boot-starter")
+
+ // The listener's @Retryable wraps each persistence attempt — without
+ // spring-retry on the classpath consumers can't use the retry semantics.
+ api("org.springframework.retry:spring-retry")
+ // @Retryable needs Spring AOP at runtime to weave the proxy.
+ api("org.springframework.boot:spring-boot-starter-aop")
+
+ // Events / payloads use Jackson directly (JsonNode in payloads, error JSON).
+ api("com.fasterxml.jackson.core:jackson-databind")
+
+ // Blackbird = ~30-50% Jackson serialization speedup. The Jackson2ObjectMapperBuilderCustomizer
+ // we register installs it into Spring Boot's default ObjectMapper.
+ api("com.fasterxml.jackson.module:jackson-module-blackbird")
+
+ // Lombok — compile + annotation-processor only.
+ compileOnly("org.projectlombok:lombok")
+ annotationProcessor("org.projectlombok:lombok")
+
+ // Auto-configuration metadata processor.
+ annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
+
+ // HTTP client API surfaces — `compileOnly` because consumers may have
+ // either, both, or neither. The corresponding @AutoConfigurations gate
+ // themselves with @ConditionalOnClass so absence is silent.
+ compileOnly("org.springframework.boot:spring-boot-starter-web")
+ compileOnly("org.springframework:spring-webflux")
+ compileOnly("io.projectreactor.netty:reactor-netty-http")
+
+ // Silences cosmetic "cannot find javax.annotation.Nonnull" warnings from
+ // resolving Spring's @Nullable. Not exposed to consumers.
+ compileOnly("com.google.code.findbugs:jsr305:3.0.2")
+
+ // Test
+ testImplementation("org.springframework.boot:spring-boot-starter-test")
+ testImplementation("org.springframework.boot:spring-boot-starter-web")
+ testImplementation("org.springframework.boot:spring-boot-starter-webflux")
+ testImplementation("io.projectreactor:reactor-test")
+ testImplementation("org.assertj:assertj-core")
+
+ // MockWebServer drives the HTTP-client utils against a real socket.
+ testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+
+ // Explicit launcher pin. JUnit Jupiter 5.11+ requires junit-platform-launcher
+ // >= 1.11; Gradle 8.10 still bundles 1.10.x. Without this declaration the
+ // BOM's 1.11 doesn't make it onto the test runtime classpath.
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+tasks.test {
+ useJUnitPlatform()
+ testLogging {
+ events("passed", "skipped", "failed")
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ showStandardStreams = false
+ }
+ systemProperty("file.encoding", "UTF-8")
+ finalizedBy(tasks.jacocoTestReport)
+}
+
+jacoco {
+ toolVersion = "0.8.13"
+}
+
+tasks.jacocoTestReport {
+ dependsOn(tasks.test)
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ csv.required.set(false)
+ }
+}
+
+mavenPublishing {
+ coordinates(
+ providers.gradleProperty("GROUP").get(),
+ "api-log-core",
+ providers.gradleProperty("VERSION").get()
+ )
+
+ pom {
+ name.set("API Log Spring Boot Starter - Core")
+ description.set("Backend-agnostic core for api-log: events, SPI, async listener, HTTP client utilities. Pair with api-log-jpa / api-log-r2dbc / api-log-mybatis.")
+
+ developers {
+ developer {
+ id.set(providers.gradleProperty("POM_DEVELOPER_ID"))
+ name.set(providers.gradleProperty("POM_DEVELOPER_NAME"))
+ url.set(providers.gradleProperty("POM_DEVELOPER_URL"))
+ email.set(providers.gradleProperty("POM_DEVELOPER_EMAIL"))
+ organization.set(providers.gradleProperty("POM_ORGANIZATION_NAME"))
+ organizationUrl.set(providers.gradleProperty("POM_ORGANIZATION_URL"))
+ }
+ }
+
+ organization {
+ name.set(providers.gradleProperty("POM_ORGANIZATION_NAME"))
+ url.set(providers.gradleProperty("POM_ORGANIZATION_URL"))
+ }
+
+ issueManagement {
+ system.set(providers.gradleProperty("POM_ISSUE_SYSTEM"))
+ url.set(providers.gradleProperty("POM_ISSUE_URL"))
+ }
+ }
+}
diff --git a/core/src/main/java/kr/devslab/apilog/Constants.java b/core/src/main/java/kr/devslab/apilog/Constants.java
new file mode 100644
index 0000000..44b5727
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/Constants.java
@@ -0,0 +1,19 @@
+package kr.devslab.apilog;
+
+/**
+ * Shared string constants for the {@code event_type} column written to
+ * {@code api_log}. Kept as plain string constants (rather than an enum) so
+ * downstream queries — e.g., {@code WHERE event_type = 'SUCCESS'} from a BI
+ * tool — match what the application writes.
+ */
+public final class Constants {
+
+ public static final String INITIATED = "INITIATED";
+ public static final String SUCCESS = "SUCCESS";
+ public static final String RETRY_ERROR = "RETRY_ERROR";
+ public static final String ERROR = "ERROR";
+
+ private Constants() {
+ // utility class — no instances
+ }
+}
diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java
similarity index 56%
rename from src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java
rename to core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java
index 5c4ee4f..8b24a73 100644
--- a/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogAutoConfiguration.java
+++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogCoreAutoConfiguration.java
@@ -2,85 +2,85 @@
import kr.devslab.apilog.config.RetryConfig;
import kr.devslab.apilog.listener.ApiEventListener;
-import kr.devslab.apilog.repository.ApiLogRepository;
-import kr.devslab.apilog.service.ApiLogService;
+import kr.devslab.apilog.spi.ApiLogWriter;
+import kr.devslab.apilog.spi.PayloadJsonMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.blackbird.BlackbirdModule;
import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
-import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.core.task.TaskExecutor;
import org.springframework.core.task.VirtualThreadTaskExecutor;
-import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
-import javax.sql.DataSource;
-
/**
- * Core auto-configuration — always loads when the starter is on the classpath.
+ * Backend-agnostic core auto-configuration. Loads whenever the starter is on
+ * the classpath; registers everything that doesn't depend on a persistence
+ * backend.
*
- *
Wires the event pipeline (service, listener), schema initializer, async
- * executor, and a BlackbirdModule-equipped {@link ObjectMapper}. Does not
- * register either HTTP client — those live in
- * {@link RestApiClientAutoConfiguration} (Web/Servlet) and
- * {@link ReactiveApiClientAutoConfiguration} (WebFlux), each gated by
- * {@code @ConditionalOnClass} so consumers only pay for what's on their
- * classpath.
+ *
Wires the async event listener, the {@link PayloadJsonMapper} helper, the
+ * Blackbird-enabled Jackson customizer, the retry config, and a virtual-thread
+ * / platform-thread executor for the listener.
+ *
+ *
The actual {@link ApiLogWriter} bean comes from whichever backend module
+ * the consumer added — {@code api-log-jpa}, {@code api-log-r2dbc}, or
+ * {@code api-log-mybatis}. {@link ApiEventListener} just declares
+ * {@code ApiLogWriter} as a constructor parameter so Spring DI resolves the
+ * backend writer lazily; if no backend is on the classpath, the context fails
+ * fast at startup with a clear {@code NoSuchBeanDefinitionException}.
+ *
+ *
{@code after = JacksonAutoConfiguration.class} guarantees Spring Boot's
+ * {@code ObjectMapper} is registered before this class is evaluated, so the
+ * {@code PayloadJsonMapper} bean can take it as a parameter without timing
+ * games. Backend auto-configs declare {@code after = ApiLogCoreAutoConfiguration.class}
+ * to chain the ordering further.
*/
-@AutoConfiguration
-@ConditionalOnClass({ApiEventListener.class, ApiLogService.class})
+@AutoConfiguration(after = JacksonAutoConfiguration.class)
+@ConditionalOnClass({ApiEventListener.class, ApiLogWriter.class})
@EnableConfigurationProperties(ApiLogProperties.class)
@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true)
-@EntityScan(basePackages = "kr.devslab.apilog.model")
-@EnableJpaRepositories(basePackages = "kr.devslab.apilog.repository")
-@Import({RetryConfig.class, ApiLogFlywayConfig.class})
+@Import(RetryConfig.class)
@EnableAsync
-public class ApiLogAutoConfiguration {
-
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnBean(ObjectMapper.class)
- public ApiLogService apiLogService(ApiLogRepository repository, ObjectMapper objectMapper) {
- return new ApiLogService(repository, objectMapper);
- }
+public class ApiLogCoreAutoConfiguration {
+ /**
+ * Shared JSON helper used by every backend writer. Lifted out of the old
+ * {@code ApiLogService} so backend modules don't each re-implement it.
+ *
+ *
{@code ObjectMapper} comes from Spring Boot's
+ * {@code JacksonAutoConfiguration} (we run after it via the
+ * {@code @AutoConfiguration(after = ...)} hint on the class).
+ */
@Bean
@ConditionalOnMissingBean
- @ConditionalOnBean(ApiLogService.class)
- public ApiEventListener apiEventListener(ApiLogService apiLogService) {
- return new ApiEventListener(apiLogService);
+ public PayloadJsonMapper apiLogPayloadJsonMapper(ObjectMapper objectMapper) {
+ return new PayloadJsonMapper(objectMapper);
}
/**
- * Creates the api_log table at startup when the consumer hasn't picked a
- * different management strategy (the default — BUILTIN). The CREATE TABLE
- * statements use IF NOT EXISTS, so this is idempotent and safe to re-run
- * on every boot.
+ * Event-bus listener that routes events to the consumer's chosen
+ * {@link ApiLogWriter}. Spring DI resolves the writer lazily — if no
+ * backend artifact is present the application context fails fast at
+ * startup with {@code NoSuchBeanDefinitionException}, which is more
+ * actionable than silently dropping events.
*/
@Bean
@ConditionalOnMissingBean
- @ConditionalOnProperty(
- prefix = "api.log.schema",
- name = "management",
- havingValue = "builtin",
- matchIfMissing = true
- )
- public ApiLogSchemaInitializer apiLogSchemaInitializer(DataSource dataSource) {
- return new ApiLogSchemaInitializer(dataSource);
+ public ApiEventListener apiEventListener(ApiLogWriter writer) {
+ return new ApiEventListener(writer);
}
/**
* Adds the Blackbird module to Spring Boot's auto-configured
* {@code ObjectMapper} — ~30-50% Jackson serialization speedup, which
- * matters because every API call writes JSONB payloads to {@code api_log}.
+ * matters because every API call writes JSON payloads to {@code api_log}.
*
*
Using {@link Jackson2ObjectMapperBuilderCustomizer} (rather than
* defining our own {@code @Primary ObjectMapper} bean) keeps Spring Boot's
diff --git a/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java
new file mode 100644
index 0000000..8a5e32a
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ApiLogProperties.java
@@ -0,0 +1,87 @@
+package kr.devslab.apilog.autoconfigure;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Top-level configuration namespace for the api-log starter.
+ *
+ *
v0.6.0 — the {@code schema.management} property still applies to the
+ * JPA / MyBatis backends (both run a JDBC initializer). The R2DBC backend has
+ * its own toggle ({@code api.log.r2dbc.schema.enabled}) because reactive
+ * initialization runs against a {@code ConnectionFactory} rather than a
+ * {@code DataSource}.
+ */
+@ConfigurationProperties(prefix = "api.log")
+public class ApiLogProperties {
+
+ /**
+ * Master switch — when false no api-log beans are registered (listener,
+ * writer, schema initializer, HTTP utilities). Default: true.
+ */
+ private boolean enabled = true;
+
+ /**
+ * How the {@code api_log} table's schema is provisioned. See {@link Schema}.
+ * Applies to the JPA + MyBatis backends. The R2DBC backend uses its own
+ * reactive initializer keyed off {@code api.log.r2dbc.schema.enabled}.
+ */
+ private Schema schema = new Schema();
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public Schema getSchema() {
+ return schema;
+ }
+
+ public void setSchema(Schema schema) {
+ this.schema = schema;
+ }
+
+ public static class Schema {
+ /**
+ * Schema management strategy for the {@code api_log} table.
+ *
+ *
+ *
BUILTIN (default) — the starter runs the bundled DDL on
+ * application startup. The SQL uses {@code IF NOT EXISTS}, so it's
+ * idempotent and safe to leave on every boot.
+ * Use this if you don't have (or don't want) Flyway / Liquibase
+ * in your project.
+ *
NONE — the starter does not touch the schema. Apply the
+ * DDL yourself (see api-log.devslab.kr/reference/schema).
+ * Use this if your team's policy is that third-party libraries
+ * must never touch the schema.
+ *
FLYWAY — the starter registers a
+ * {@code FlywayConfigurationCustomizer} that appends
+ * {@code classpath:db/api-log} to Flyway's locations, so the
+ * bundled {@code V1.0__create_api_log.sql} runs alongside your
+ * own migrations and gets recorded in
+ * {@code flyway_schema_history}. Requires
+ * {@code org.flywaydb:flyway-core} on the classpath (the starter
+ * declares it as optional, so the consumer must add it). Only
+ * applies when the JPA backend is in use.
+ *
+ */
+ private Management management = Management.BUILTIN;
+
+ public Management getManagement() {
+ return management;
+ }
+
+ public void setManagement(Management management) {
+ this.management = management;
+ }
+
+ public enum Management {
+ BUILTIN,
+ NONE,
+ FLYWAY
+ }
+ }
+}
diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java
similarity index 96%
rename from src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java
rename to core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java
index 294be63..d20a6a6 100644
--- a/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java
+++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/ReactiveApiClientAutoConfiguration.java
@@ -22,7 +22,7 @@
* codecs, etc.) flow through. Provide your own {@link ReactiveApiClientUtil}
* bean to fully replace the wiring.
*/
-@AutoConfiguration(after = ApiLogAutoConfiguration.class)
+@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class)
@ConditionalOnClass(WebClient.class)
@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true)
public class ReactiveApiClientAutoConfiguration {
diff --git a/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java
similarity index 94%
rename from src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java
rename to core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java
index 2eeb00c..4866396 100644
--- a/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java
+++ b/core/src/main/java/kr/devslab/apilog/autoconfigure/RestApiClientAutoConfiguration.java
@@ -28,8 +28,8 @@
*
*
{@link ClientHttpRequestFactory} with configurable timeouts
* ({@code rest.client.connect-timeout}, {@code rest.client.read-timeout})
- *
{@link MappingJackson2HttpMessageConverter} using the core
- * {@code apiLogObjectMapper} (Blackbird-enabled)
+ *
{@link MappingJackson2HttpMessageConverter} using the Spring Boot
+ * {@link ObjectMapper} (Blackbird-enabled by the core customizer)
*
{@link RestClient} with the converter wired in, optional base URL via
* {@code rest.client.base-url}
*
{@link RestApiClientUtil} — the actual API surface consumers inject
@@ -38,7 +38,7 @@
*
Each bean is {@link ConditionalOnMissingBean} so the consumer can swap any
* piece (e.g., provide their own {@code RestClient} with auth headers).
*/
-@AutoConfiguration(after = ApiLogAutoConfiguration.class)
+@AutoConfiguration(after = ApiLogCoreAutoConfiguration.class)
@ConditionalOnClass(RestClient.class)
@ConditionalOnProperty(name = "api.log.enabled", havingValue = "true", matchIfMissing = true)
public class RestApiClientAutoConfiguration {
diff --git a/src/main/java/kr/devslab/apilog/config/RetryConfig.java b/core/src/main/java/kr/devslab/apilog/config/RetryConfig.java
similarity index 69%
rename from src/main/java/kr/devslab/apilog/config/RetryConfig.java
rename to core/src/main/java/kr/devslab/apilog/config/RetryConfig.java
index 949cbaf..2f83845 100644
--- a/src/main/java/kr/devslab/apilog/config/RetryConfig.java
+++ b/core/src/main/java/kr/devslab/apilog/config/RetryConfig.java
@@ -1,9 +1,9 @@
package kr.devslab.apilog.config;
+import kr.devslab.apilog.dto.ApiRequest;
import kr.devslab.apilog.event.ApiCallErrorEvent;
import kr.devslab.apilog.event.ApiCallInitiatedEvent;
import kr.devslab.apilog.event.ApiCallSuccessEvent;
-import kr.devslab.apilog.model.dto.ApiRequest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -12,6 +12,15 @@
import org.springframework.retry.RetryListener;
import org.springframework.retry.annotation.EnableRetry;
+/**
+ * Enables Spring Retry across the api-log starter and registers a
+ * {@link RetryListener} that re-publishes a {@link ApiCallErrorEvent} marked
+ * {@code isRetry=true} on every failed attempt past the first, so each retry
+ * is recorded as its own {@code RETRY_ERROR} row in {@code api_log}.
+ *
+ *
Imported by {@code ApiLogCoreAutoConfiguration} so consumers don't need to
+ * declare {@code @EnableRetry} on their own {@code @SpringBootApplication}.
+ */
@Configuration
@EnableRetry
public class RetryConfig {
@@ -30,7 +39,7 @@ public boolean open(RetryContext context, RetryCallback
@Override
public void onSuccess(RetryContext context, RetryCallback callback, T result) {
- // 성공 시 추가 작업 없음
+ // no-op
}
@Override
@@ -46,19 +55,19 @@ public void onError(RetryContext context, RetryCallback
@Override
public void close(RetryContext context, RetryCallback callback, Throwable throwable) {
- // 종료 시 추가 작업 없음
+ // no-op
}
private ApiRequest extractRequest(Object event) {
- if (event instanceof ApiCallInitiatedEvent) {
- return ((ApiCallInitiatedEvent) event).getRequest();
- } else if (event instanceof ApiCallSuccessEvent) {
- return ((ApiCallSuccessEvent) event).getRequest();
- } else if (event instanceof ApiCallErrorEvent) {
- return ((ApiCallErrorEvent) event).getRequest();
+ if (event instanceof ApiCallInitiatedEvent initiated) {
+ return initiated.getRequest();
+ } else if (event instanceof ApiCallSuccessEvent success) {
+ return success.getRequest();
+ } else if (event instanceof ApiCallErrorEvent error) {
+ return error.getRequest();
}
return null;
}
};
}
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java b/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java
new file mode 100644
index 0000000..b5c2a71
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/dto/ApiRequest.java
@@ -0,0 +1,27 @@
+package kr.devslab.apilog.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.UUID;
+
+/**
+ * Caller-supplied request descriptor handed to the HTTP client utilities and
+ * carried across the event pipeline.
+ *
+ *
{@code requestId} defaults to a fresh UUID so each call has a unique
+ * correlation key in {@code api_log}. Override it when multiple calls form a
+ * logical group (e.g., a retry sequence) and you want them to share an id.
+ *
+ *
v0.6.0 note — moved from {@code kr.devslab.apilog.model.dto} as part of
+ * the multi-module split (the {@code model/} package now belongs to the
+ * backend modules, which carry their own entity types).
+ */
+@Getter
+@Builder
+public class ApiRequest {
+ @Builder.Default
+ private final String requestId = UUID.randomUUID().toString();
+ private final String payload;
+ private final String endpoint;
+}
diff --git a/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java b/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java
new file mode 100644
index 0000000..219b9c7
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/dto/ApiResponse.java
@@ -0,0 +1,16 @@
+package kr.devslab.apilog.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+
+/**
+ * Wrapper for the response body + HTTP status code emitted by the HTTP client
+ * utilities. Stored verbatim in {@code api_log.response} (body) +
+ * {@code api_log.status_code} on success.
+ */
+@Getter
+@Builder
+public class ApiResponse {
+ private final String data;
+ private final int statusCode;
+}
diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java
similarity index 67%
rename from src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java
rename to core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java
index e5d122d..6f18385 100644
--- a/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java
+++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallErrorEvent.java
@@ -1,11 +1,19 @@
package kr.devslab.apilog.event;
-import kr.devslab.apilog.model.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiRequest;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import java.time.LocalDateTime;
+/**
+ * Fired when an HTTP call fails (exception thrown by RestClient / WebClient,
+ * or a non-2xx response surfaced as an exception).
+ *
+ *
{@code retryCount} + {@code isRetry} let downstream consumers distinguish
+ * a first-attempt failure ({@code ERROR}) from a retry failure
+ * ({@code RETRY_ERROR}).
+ */
@Getter
public class ApiCallErrorEvent extends ApplicationEvent {
private final ApiRequest request;
@@ -22,4 +30,4 @@ public ApiCallErrorEvent(Object source, ApiRequest request, Throwable error, int
this.retryCount = retryCount;
this.isRetry = isRetry;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java
similarity index 68%
rename from src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java
rename to core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java
index a6755f3..c4b819a 100644
--- a/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java
+++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallInitiatedEvent.java
@@ -1,12 +1,16 @@
package kr.devslab.apilog.event;
-import kr.devslab.apilog.model.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiRequest;
import lombok.Getter;
-import lombok.Setter;
import org.springframework.context.ApplicationEvent;
import java.time.LocalDateTime;
+/**
+ * Fired just before an outbound HTTP call leaves the client. The listener
+ * persists an {@code INITIATED} row so the call is traceable even if the
+ * response never arrives.
+ */
@Getter
public class ApiCallInitiatedEvent extends ApplicationEvent {
private final ApiRequest request;
@@ -18,4 +22,4 @@ public ApiCallInitiatedEvent(Object source, ApiRequest request) {
this.eventTimestamp = LocalDateTime.now();
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java b/core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java
similarity index 71%
rename from src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java
rename to core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java
index 5fdea28..3120706 100644
--- a/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java
+++ b/core/src/main/java/kr/devslab/apilog/event/ApiCallSuccessEvent.java
@@ -1,12 +1,16 @@
package kr.devslab.apilog.event;
-import kr.devslab.apilog.model.dto.ApiRequest;
-import kr.devslab.apilog.model.dto.ApiResponse;
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import java.time.LocalDateTime;
+/**
+ * Fired after a successful (2xx) HTTP response. The listener persists a
+ * {@code SUCCESS} row carrying the response body + status code.
+ */
@Getter
public class ApiCallSuccessEvent extends ApplicationEvent {
private final ApiRequest request;
@@ -20,4 +24,4 @@ public ApiCallSuccessEvent(Object source, ApiRequest request, ApiResponse respon
this.eventTimestamp = LocalDateTime.now();
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java
similarity index 55%
rename from src/main/java/kr/devslab/apilog/listener/ApiEventListener.java
rename to core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java
index 2220e04..9db2cfe 100644
--- a/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java
+++ b/core/src/main/java/kr/devslab/apilog/listener/ApiEventListener.java
@@ -3,28 +3,54 @@
import kr.devslab.apilog.event.ApiCallErrorEvent;
import kr.devslab.apilog.event.ApiCallInitiatedEvent;
import kr.devslab.apilog.event.ApiCallSuccessEvent;
-import kr.devslab.apilog.service.ApiLogService;
+import kr.devslab.apilog.spi.ApiLogWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Propagation;
-import org.springframework.transaction.annotation.Transactional;
+import org.springframework.scheduling.annotation.Async;
+/**
+ * Drives the {@link ApiLogWriter} off the application event bus.
+ *
+ *
v0.6.0 change — this used to call {@code ApiLogService} directly (JPA-only).
+ * It now talks to whichever {@link ApiLogWriter} the consumer's chosen backend
+ * registered ({@code api-log-jpa} → JpaApiLogWriter,
+ * {@code api-log-r2dbc} → R2dbcApiLogWriter,
+ * {@code api-log-mybatis} → MybatisApiLogWriter). The listener stays
+ * backend-agnostic; routing happens by which jar is on the classpath.
+ *
+ *
{@code @Async} so the persistence hop runs on the executor configured in
+ * {@code :core} (virtual-thread by default, platform-thread pool as fallback)
+ * — the HTTP caller never waits for a {@code api_log} write. The R2DBC writer
+ * also bridges its reactive {@code Mono} to a blocking call inside this
+ * executor thread; nothing on the request path blocks.
+ *
+ *
{@code @Retryable} wraps each write in up to three attempts with 1s
+ * backoff so transient persistence failures (connection blips, dead pool
+ * connection on first use) don't drop a log row. Caught exceptions are logged,
+ * never rethrown — losing one {@code api_log} row must never break the actual
+ * outbound API call.
+ *
+ *
Transaction semantics are intentionally not declared here. The JPA
+ * + MyBatis writers wrap their own writes in {@code REQUIRES_NEW} (so the log
+ * write doesn't pollute the consumer's surrounding tx). The R2DBC writer
+ * relies on the driver's auto-commit. Keeping the tx boundary inside the
+ * writer lets each backend pick the semantics that make sense.
+ */
@Slf4j
-@Component
@RequiredArgsConstructor
public class ApiEventListener {
- private final ApiLogService apiLogService;
+
+ private final ApiLogWriter writer;
@EventListener
- @Transactional(propagation = Propagation.REQUIRES_NEW)
+ @Async
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void handleApiCallInitiated(ApiCallInitiatedEvent event) {
try {
- apiLogService.saveApiCallInitiated(event);
+ writer.writeInitiated(event);
log.debug("Saved API Call Initiated: RequestId={}, Endpoint={}",
event.getRequest().getRequestId(), event.getRequest().getEndpoint());
} catch (Exception e) {
@@ -34,11 +60,11 @@ public void handleApiCallInitiated(ApiCallInitiatedEvent event) {
}
@EventListener
- @Transactional(propagation = Propagation.REQUIRES_NEW)
+ @Async
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void handleApiCallSuccess(ApiCallSuccessEvent event) {
try {
- apiLogService.saveApiCallSuccess(event);
+ writer.writeSuccess(event);
log.debug("Saved API Call Success: RequestId={}, Endpoint={}, Status={}",
event.getRequest().getRequestId(), event.getRequest().getEndpoint(),
event.getResponse().getStatusCode());
@@ -49,11 +75,11 @@ public void handleApiCallSuccess(ApiCallSuccessEvent event) {
}
@EventListener
- @Transactional(propagation = Propagation.REQUIRES_NEW)
+ @Async
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void handleApiCallError(ApiCallErrorEvent event) {
try {
- apiLogService.saveApiCallError(event);
+ writer.writeError(event);
log.info("Saved API Call {}: RequestId={}, Endpoint={}, RetryCount={}",
event.isRetry() ? "Retry Error" : "Error",
event.getRequest().getRequestId(), event.getRequest().getEndpoint(),
@@ -64,4 +90,4 @@ public void handleApiCallError(ApiCallErrorEvent event) {
event.getRequest().getRequestId(), e.getMessage(), e);
}
}
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java b/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java
new file mode 100644
index 0000000..c744e38
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/spi/ApiLogWriter.java
@@ -0,0 +1,40 @@
+package kr.devslab.apilog.spi;
+
+import kr.devslab.apilog.event.ApiCallErrorEvent;
+import kr.devslab.apilog.event.ApiCallInitiatedEvent;
+import kr.devslab.apilog.event.ApiCallSuccessEvent;
+
+/**
+ * Backend-agnostic SPI that each {@code api-log-*} persistence module implements.
+ *
+ *
One {@code ApiLogWriter} bean is expected per application context — provided
+ * by the backend artifact the consumer chose (see {@code api-log-jpa},
+ * {@code api-log-r2dbc}, {@code api-log-mybatis}). The core listener
+ * ({@code ApiEventListener}) routes every event through the registered writer
+ * so the wire format (events) stays backend-independent.
+ *
+ *
Append-only semantics. Every call writes a new row keyed by an
+ * auto-generated {@code id}. The same {@code request_id} can show up multiple
+ * times — once for {@code INITIATED}, once for {@code SUCCESS} or {@code ERROR},
+ * and once per {@code RETRY_ERROR} — because the table is a chronological log,
+ * not a state machine.
+ *
+ *
Threading. Calls arrive on the executor configured in {@code :core}
+ * (virtual threads by default; falls back to a platform thread pool). Reactive
+ * implementations may subscribe inline — the listener does not consume any
+ * returned reactive type, so writers own their own subscription lifecycle.
+ */
+public interface ApiLogWriter {
+
+ /** Persist an {@code INITIATED} row when a request leaves the client. */
+ void writeInitiated(ApiCallInitiatedEvent event);
+
+ /** Persist a {@code SUCCESS} row when a 2xx response arrives. */
+ void writeSuccess(ApiCallSuccessEvent event);
+
+ /**
+ * Persist an {@code ERROR} or {@code RETRY_ERROR} row when the call fails.
+ * The {@code event_type} is selected from {@link ApiCallErrorEvent#isRetry()}.
+ */
+ void writeError(ApiCallErrorEvent event);
+}
diff --git a/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java
new file mode 100644
index 0000000..cfcd0a3
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorExtractor.java
@@ -0,0 +1,51 @@
+package kr.devslab.apilog.spi;
+
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.RestClientResponseException;
+
+/**
+ * Pulls HTTP status + response body off a thrown exception so backend writers
+ * can record them into {@code api_log.status_code} / {@code api_log.error_message}.
+ *
+ *
Direct {@code instanceof} works for the Spring Web (blocking) hierarchy —
+ * we depend on {@code spring-web} via {@code compileOnly}, so the symbols
+ * resolve when consumers have it on their classpath. For Spring WebFlux's
+ * {@link RestClientResponseException} cousins ({@code WebClientResponseException}
+ * and its concrete subclasses), we duck-type via reflection because
+ * {@code spring-webflux} is also optional and we don't want to force its
+ * classpath presence just to identify it.
+ *
+ *
Used by every backend writer ({@code JpaApiLogWriter},
+ * {@code R2dbcApiLogWriter}, {@code MybatisApiLogWriter}). Kept stateless +
+ * thread-safe so it can be invoked from any async/reactive context.
+ */
+public final class HttpErrorExtractor {
+
+ private HttpErrorExtractor() {
+ // utility class — no instances
+ }
+
+ public static HttpErrorInfo extract(Throwable error) {
+ if (error instanceof HttpStatusCodeException ex) {
+ return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString());
+ }
+ if (error instanceof RestClientResponseException ex) {
+ return new HttpErrorInfo(ex.getStatusCode().value(), ex.getResponseBodyAsString());
+ }
+ // Match WebClientResponseException + its concrete subclasses
+ // (NotFound, BadRequest, etc.) by package prefix so unrelated
+ // exceptions that happen to share method names don't get matched.
+ if (error.getClass().getName()
+ .startsWith("org.springframework.web.reactive.function.client.WebClientResponseException")) {
+ try {
+ Object status = error.getClass().getMethod("getStatusCode").invoke(error);
+ Integer statusValue = (Integer) status.getClass().getMethod("value").invoke(status);
+ Object body = error.getClass().getMethod("getResponseBodyAsString").invoke(error);
+ return new HttpErrorInfo(statusValue, body == null ? null : body.toString());
+ } catch (ReflectiveOperationException ignored) {
+ // Shape didn't match — fall through to EMPTY.
+ }
+ }
+ return HttpErrorInfo.EMPTY;
+ }
+}
diff --git a/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java
new file mode 100644
index 0000000..a291581
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/spi/HttpErrorInfo.java
@@ -0,0 +1,12 @@
+package kr.devslab.apilog.spi;
+
+/**
+ * HTTP error metadata pulled off a thrown exception by {@link HttpErrorExtractor}.
+ * Both fields may be {@code null} when the exception isn't an HTTP error
+ * carrier (e.g., a timeout or network error before the response landed).
+ */
+public record HttpErrorInfo(Integer statusCode, String responseBody) {
+
+ /** Sentinel for the no-HTTP-context case — avoids spraying null checks at call sites. */
+ public static final HttpErrorInfo EMPTY = new HttpErrorInfo(null, null);
+}
diff --git a/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java b/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java
new file mode 100644
index 0000000..99c5d62
--- /dev/null
+++ b/core/src/main/java/kr/devslab/apilog/spi/PayloadJsonMapper.java
@@ -0,0 +1,80 @@
+package kr.devslab.apilog.spi;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/**
+ * Shared JSON conversion used by every backend writer when building rows for
+ * the {@code payload} / {@code response} / {@code error_message} JSONB columns.
+ *
+ *
The two methods cover the two patterns every writer needs:
+ *
+ *
{@link #toJsonNode(String)} — turn a string body into a {@code JsonNode}.
+ * Falls back to {@code { "raw": "..." }} when parsing fails so non-JSON
+ * payloads still land in the column intact.
+ * The {@code responseBody} field only appears when {@link HttpErrorExtractor}
+ * found one — saves a few bytes per row for non-HTTP failures.
+ *
+ */
+public final class PayloadJsonMapper {
+
+ private final ObjectMapper objectMapper;
+
+ public PayloadJsonMapper(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public JsonNode toJsonNode(String data) {
+ if (data == null) {
+ return objectMapper.createObjectNode();
+ }
+ try {
+ return objectMapper.readTree(data);
+ } catch (Exception e) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("raw", data);
+ return node;
+ }
+ }
+
+ public JsonNode buildErrorJson(Throwable error, String responseBody) {
+ ObjectNode node = objectMapper.createObjectNode();
+ node.put("type", error.getClass().getName());
+ node.put("message", error.getMessage());
+ if (responseBody != null && !responseBody.isEmpty()) {
+ node.put("responseBody", responseBody);
+ }
+ return node;
+ }
+
+ /**
+ * String form of {@link #toJsonNode(String)} — JSON canonical form for
+ * backends (R2DBC, MyBatis) that store the column as text rather than as
+ * Jackson's {@code JsonNode}.
+ */
+ public String toJsonString(String data) {
+ try {
+ return objectMapper.writeValueAsString(toJsonNode(data));
+ } catch (Exception e) {
+ // Should be impossible — toJsonNode always returns a valid JsonNode
+ // and ObjectMapper.writeValueAsString of one can't throw a parse error.
+ // Wrap as RuntimeException so the call site doesn't need to declare.
+ throw new IllegalStateException("Failed to serialize JsonNode to String", e);
+ }
+ }
+
+ /**
+ * Convenience for backends that store {@code error_message} as JSON text.
+ */
+ public String buildErrorJsonString(Throwable error, String responseBody) {
+ try {
+ return objectMapper.writeValueAsString(buildErrorJson(error, responseBody));
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to serialize error JsonNode to String", e);
+ }
+ }
+}
diff --git a/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java
similarity index 95%
rename from src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java
rename to core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java
index c99977e..37e5f5c 100644
--- a/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java
+++ b/core/src/main/java/kr/devslab/apilog/util/ReactiveApiClientUtil.java
@@ -1,10 +1,10 @@
package kr.devslab.apilog.util;
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
import kr.devslab.apilog.event.ApiCallErrorEvent;
import kr.devslab.apilog.event.ApiCallInitiatedEvent;
import kr.devslab.apilog.event.ApiCallSuccessEvent;
-import kr.devslab.apilog.model.dto.ApiRequest;
-import kr.devslab.apilog.model.dto.ApiResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.ApplicationEventPublisher;
@@ -27,9 +27,10 @@
* {@code api_log} rows asynchronously — no reactor-vs-Spring-AOP friction
* because the event publish is fire-and-forget.
*
- *
Registered automatically when {@code org.springframework:spring-webflux} is
- * on the classpath (the starter declares it as optional); see
- * {@code ReactiveApiClientConfig}.
+ *
Registered automatically by
+ * {@link kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration}
+ * when {@code org.springframework:spring-webflux} is on the classpath (the
+ * starter declares it as optional).
*/
public class ReactiveApiClientUtil {
diff --git a/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
similarity index 99%
rename from src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
rename to core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
index f06820b..f6ded6e 100644
--- a/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
+++ b/core/src/main/java/kr/devslab/apilog/util/RestApiClientUtil.java
@@ -1,10 +1,10 @@
package kr.devslab.apilog.util;
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
import kr.devslab.apilog.event.ApiCallErrorEvent;
import kr.devslab.apilog.event.ApiCallInitiatedEvent;
import kr.devslab.apilog.event.ApiCallSuccessEvent;
-import kr.devslab.apilog.model.dto.ApiRequest;
-import kr.devslab.apilog.model.dto.ApiResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.ApplicationEventPublisher;
diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
similarity index 68%
rename from src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
rename to core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 679bb13..c4d22b2 100644
--- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1,3 +1,3 @@
-kr.devslab.apilog.autoconfigure.ApiLogAutoConfiguration
+kr.devslab.apilog.autoconfigure.ApiLogCoreAutoConfiguration
kr.devslab.apilog.autoconfigure.RestApiClientAutoConfiguration
kr.devslab.apilog.autoconfigure.ReactiveApiClientAutoConfiguration
diff --git a/src/main/resources/db/api-log/V1.0__create_api_log.sql b/core/src/main/resources/db/api-log/V1.0__create_api_log.sql
similarity index 69%
rename from src/main/resources/db/api-log/V1.0__create_api_log.sql
rename to core/src/main/resources/db/api-log/V1.0__create_api_log.sql
index e00ef02..3e8134a 100644
--- a/src/main/resources/db/api-log/V1.0__create_api_log.sql
+++ b/core/src/main/resources/db/api-log/V1.0__create_api_log.sql
@@ -1,4 +1,4 @@
-CREATE TABLE api_log
+CREATE TABLE IF NOT EXISTS api_log
(
id BIGSERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
@@ -13,5 +13,5 @@ CREATE TABLE api_log
is_retry BOOLEAN DEFAULT FALSE
);
-CREATE INDEX idx_request_id ON api_log (request_id);
-CREATE INDEX idx_timestamp ON api_log (timestamp);
\ No newline at end of file
+CREATE INDEX IF NOT EXISTS idx_request_id ON api_log (request_id);
+CREATE INDEX IF NOT EXISTS idx_timestamp ON api_log (timestamp);
diff --git a/core/src/test/java/kr/devslab/apilog/TestApp.java b/core/src/test/java/kr/devslab/apilog/TestApp.java
new file mode 100644
index 0000000..460d7de
--- /dev/null
+++ b/core/src/test/java/kr/devslab/apilog/TestApp.java
@@ -0,0 +1,14 @@
+package kr.devslab.apilog;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Bootstrap class for {@code @SpringBootTest} in the :core module.
+ *
+ *
The library itself is not a Spring Boot application, so it ships no
+ * {@code @SpringBootApplication} of its own. Tests need one for context
+ * lookup — this empty class satisfies that requirement.
+ */
+@SpringBootApplication
+public class TestApp {
+}
diff --git a/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java b/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java
new file mode 100644
index 0000000..73ea4f5
--- /dev/null
+++ b/core/src/test/java/kr/devslab/apilog/listener/ApiEventListenerTest.java
@@ -0,0 +1,116 @@
+package kr.devslab.apilog.listener;
+
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
+import kr.devslab.apilog.event.ApiCallErrorEvent;
+import kr.devslab.apilog.event.ApiCallInitiatedEvent;
+import kr.devslab.apilog.event.ApiCallSuccessEvent;
+import kr.devslab.apilog.spi.ApiLogWriter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Verifies the listener routes events through whatever {@link ApiLogWriter}
+ * bean is injected — the v0.6.0 SPI seam. The writer is a {@link RecordingWriter}
+ * that captures calls; tests assert ordering / arguments / fault-tolerance.
+ *
+ *
The {@code @Retryable} behavior is exercised in a separate Spring-context
+ * test in the {@code :jpa} module — here we only need the writer wiring to
+ * work, so no Spring context.
+ */
+class ApiEventListenerTest {
+
+ private RecordingWriter writer;
+ private ApiEventListener listener;
+
+ @BeforeEach
+ void setUp() {
+ writer = new RecordingWriter();
+ listener = new ApiEventListener(writer);
+ }
+
+ @Test
+ void initiatedEvent_routesToWriter() {
+ ApiRequest request = ApiRequest.builder()
+ .endpoint("/x")
+ .payload("{}")
+ .requestId("r-1")
+ .build();
+
+ listener.handleApiCallInitiated(new ApiCallInitiatedEvent(this, request));
+
+ assertThat(writer.initiated).hasSize(1);
+ assertThat(writer.initiated.get(0).getRequest().getRequestId()).isEqualTo("r-1");
+ }
+
+ @Test
+ void successEvent_routesToWriter() {
+ ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-2").build();
+ ApiResponse response = ApiResponse.builder().statusCode(201).data("{}").build();
+
+ listener.handleApiCallSuccess(new ApiCallSuccessEvent(this, request, response));
+
+ assertThat(writer.success).hasSize(1);
+ assertThat(writer.success.get(0).getResponse().getStatusCode()).isEqualTo(201);
+ }
+
+ @Test
+ void errorEvent_routesToWriter_andCarriesRetryFlag() {
+ ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-3").build();
+ RuntimeException boom = new RuntimeException("boom");
+
+ listener.handleApiCallError(new ApiCallErrorEvent(this, request, boom, 2, true));
+
+ assertThat(writer.errors).hasSize(1);
+ assertThat(writer.errors.get(0).isRetry()).isTrue();
+ assertThat(writer.errors.get(0).getRetryCount()).isEqualTo(2);
+ }
+
+ @Test
+ void writerThrows_listenerSwallows_soOutboundCallIsNotBroken() {
+ // The whole point of catching inside the listener: losing one audit row
+ // must never propagate up and break the consumer's outbound API call.
+ ApiLogWriter exploding = mock(ApiLogWriter.class);
+ doThrow(new RuntimeException("db down"))
+ .when(exploding).writeSuccess(org.mockito.ArgumentMatchers.any());
+
+ ApiEventListener fragileListener = new ApiEventListener(exploding);
+ ApiRequest request = ApiRequest.builder().endpoint("/x").requestId("r-4").build();
+ ApiResponse response = ApiResponse.builder().statusCode(200).build();
+
+ // Must not throw.
+ fragileListener.handleApiCallSuccess(new ApiCallSuccessEvent(this, request, response));
+ }
+
+ // ------------------------------------------------------------------ //
+ // Test doubles //
+ // ------------------------------------------------------------------ //
+
+ static class RecordingWriter implements ApiLogWriter {
+ final List initiated = new ArrayList<>();
+ final List success = new ArrayList<>();
+ final List errors = new ArrayList<>();
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ initiated.add(event);
+ }
+
+ @Override
+ public void writeSuccess(ApiCallSuccessEvent event) {
+ success.add(event);
+ }
+
+ @Override
+ public void writeError(ApiCallErrorEvent event) {
+ errors.add(event);
+ }
+ }
+}
diff --git a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java
similarity index 95%
rename from src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java
rename to core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java
index e77140d..88f930a 100644
--- a/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java
+++ b/core/src/test/java/kr/devslab/apilog/util/ReactiveApiClientUtilRoutingTest.java
@@ -1,7 +1,7 @@
package kr.devslab.apilog.util;
-import kr.devslab.apilog.model.dto.ApiRequest;
-import kr.devslab.apilog.model.dto.ApiResponse;
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
@@ -95,10 +95,6 @@ void sendCore_respectsCallerProvidedRequestId() {
record TestUser(String name, String email) {}
- /**
- * Captures {@code send()} arguments so the verb routing can be asserted
- * without a real WebClient.
- */
static class RecordingReactiveClient extends ReactiveApiClientUtil {
HttpMethod lastMethod;
ApiRequest lastRequest;
diff --git a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java
similarity index 86%
rename from src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java
rename to core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java
index 25039ce..f89af7c 100644
--- a/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java
+++ b/core/src/test/java/kr/devslab/apilog/util/RestApiClientUtilRoutingTest.java
@@ -1,19 +1,17 @@
package kr.devslab.apilog.util;
-import kr.devslab.apilog.model.dto.ApiRequest;
-import kr.devslab.apilog.model.dto.ApiResponse;
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionException;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * Verifies that the v0.5.0 convenience methods (PUT, DELETE, PATCH plus the
+ * Verifies that the convenience methods (PUT, DELETE, PATCH plus the
* pre-existing GET/POST wrappers) route through the core {@code send*} methods
* with the correct {@link HttpMethod} and a properly-built {@link ApiRequest}.
*
@@ -45,7 +43,6 @@ void postSyncTyped_serializesObjectBodyToJson() {
var body = new TestUser("Ada", "ada@example.com");
util.postSyncTyped("/users", body, TestUser.class);
assertThat(util.lastMethod).isEqualTo(HttpMethod.POST);
- // Body went through Jackson, came out as JSON.
assertThat(util.lastRequest.getPayload()).contains("\"name\":\"Ada\"");
assertThat(util.lastRequest.getPayload()).contains("\"email\":\"ada@example.com\"");
}
@@ -94,7 +91,6 @@ void patchSyncTyped_routesToPatchWithSerializedBody() {
@Test
void sendCore_respectsCallerProvidedRequestId() {
- // The whole point of the v0.5.0 core API: same requestId across retries.
RecordingClient util = new RecordingClient();
ApiRequest req = ApiRequest.builder()
.endpoint("/users")
@@ -109,8 +105,6 @@ void sendCore_respectsCallerProvidedRequestId() {
void getAsync_routesToGet() {
RecordingClient util = new RecordingClient();
CompletableFuture future = util.getAsync("/users/1");
- // The CompletableFuture.supplyAsync wraps the call, but INITIATED fires synchronously.
- // Our recording stub completes the future immediately.
future.join();
assertThat(util.lastMethod).isEqualTo(HttpMethod.GET);
}
@@ -126,12 +120,6 @@ void deleteAsync_routesToDelete() {
// Test fixtures
// -------------------------------------------------------------------------
- /**
- * Subclass that overrides the four core send methods to record the
- * {@code (HttpMethod, ApiRequest)} pair instead of doing any HTTP I/O.
- * The constructor passes nulls for the collaborators we don't use; only the
- * ObjectMapper is real because {@code serialize()} runs at the wrapper level.
- */
static class RecordingClient extends RestApiClientUtil {
HttpMethod lastMethod;
ApiRequest lastRequest;
diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md
index ad74dbb..7fc4227 100644
--- a/docs/changelog.ko.md
+++ b/docs/changelog.ko.md
@@ -6,6 +6,77 @@
## [Unreleased]
+## [0.6.0] — 멀티모듈 분리 (Gradle), JPA / R2DBC / MyBatis 백엔드 선택 지원
+
+### Changed
+
+- **단일 `api-log-spring-boot-starter` 아티팩트가 분리됐습니다.** 이제는 멀티모듈 Gradle 빌드입니다. 사용자는 `api-log-core` + 백엔드 1개를 직접 골라 추가:
+
+ | 아티팩트 (Maven 좌표) | 역할 |
+ | --- | --- |
+ | `kr.devslab:api-log-core` | 이벤트, SPI, async 리스너, HTTP 클라이언트 유틸 |
+ | `kr.devslab:api-log-jpa` | JPA + Hibernate 영속화 (v0.5.x 동작 그대로) |
+ | `kr.devslab:api-log-r2dbc` | 리액티브 R2DBC 영속화 — JDBC 의존성 없음 |
+ | `kr.devslab:api-log-mybatis` | MyBatis mapper 영속화 |
+
+ v0.5.x에서 가장 비슷한 드롭인은 `api-log-jpa`입니다 (자동으로 `api-log-core`를 가져옴).
+
+- **빌드 시스템: Maven → Gradle 8.10**. easy-paging 컨벤션 적용 — 모듈마다 Vanniktech maven-publish, CI에서는 publish 플러그인의 property-driven 설정과 충돌하지 않도록 configuration cache 비활성화. Maven 파일은 사라졌고, `./gradlew build`가 유일한 빌드 경로입니다.
+
+- **패키지 이름 변경** (구조 정리 차원):
+ - `kr.devslab.apilog.model.dto.ApiRequest` → `kr.devslab.apilog.dto.ApiRequest`
+ - `kr.devslab.apilog.model.dto.ApiResponse` → `kr.devslab.apilog.dto.ApiResponse`
+ - `kr.devslab.apilog.model.ApiLogEntity` → `kr.devslab.apilog.jpa.model.ApiLogEntity` (`api-log-jpa`로 이동)
+ - `kr.devslab.apilog.repository.ApiLogRepository` → `kr.devslab.apilog.jpa.repository.ApiLogRepository`
+ - `kr.devslab.apilog.service.ApiLogService` → `kr.devslab.apilog.jpa.writer.JpaApiLogWriter`로 대체 (새 `ApiLogWriter` SPI 구현)
+
+### Added
+
+- **`ApiLogWriter` SPI** (`kr.devslab.apilog.spi.ApiLogWriter`) — 모든 백엔드가 구현하는 3-메서드 인터페이스 (`writeInitiated`, `writeSuccess`, `writeError`). 코어 리스너는 사용자가 추가한 백엔드 아티팩트가 등록한 writer 빈으로 이벤트를 라우팅합니다.
+- **`api-log-r2dbc`** — R2DBC의 `DatabaseClient`로 PostgreSQL과 통신하는 리액티브 백엔드. JSONB 바인딩은 R2DBC PostgreSQL 드라이버의 묵시적 `TEXT → JSONB` 캐스트를 활용, 별도의 `::jsonb` 작업이 필요 없음. 순수 리액티브 스키마 초기화 (`R2dbcScriptDatabaseInitializer`) 제공 — JDBC 끌어들이지 않음.
+- **`api-log-mybatis`** — `@Mapper` 인터페이스 기반 MyBatis 백엔드. JSONB 컬럼은 `CAST(#{...,jdbcType=VARCHAR} AS jsonb)` 구문으로 처리해서 별도의 `TypeHandler`가 필요 없음.
+- **`:core`에서 공유하는 SPI 헬퍼**:
+ - `HttpErrorExtractor` — 던져진 예외에서 HTTP 상태 / 본문 추출 (기존 `ApiLogService` 내부 로직 분리).
+ - `PayloadJsonMapper` — 모든 writer가 사용하는 JSON 문자열 / `JsonNode` 변환.
+
+### Fixed
+
+- **`V1.0__create_api_log.sql`이 이제 멱등합니다.** `CREATE TABLE`과 `CREATE INDEX` 둘 다 `IF NOT EXISTS` 추가. 기존에는 BUILTIN 모드에서 두 번째 부팅 시 "relation already exists" 에러가 발생할 수 있었음 (Hibernate `ddl-auto`가 잡아주지 않으면).
+
+### v0.5.2에서 마이그레이션
+
+의존성 좌표 변경:
+
+```xml
+
+
+ kr.devslab
+ api-log-spring-boot-starter
+ 0.5.2
+
+
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+```
+
+이동된 타입을 직접 import 하던 경우 패키지 업데이트:
+
+```java
+// Before
+import kr.devslab.apilog.model.dto.ApiRequest;
+import kr.devslab.apilog.model.ApiLogEntity;
+
+// After
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.jpa.model.ApiLogEntity;
+```
+
+리액티브 (R2DBC) 또는 MyBatis로 전환하려는 경우: `api-log-jpa` 대신 `api-log-r2dbc` / `api-log-mybatis`만 바꿔 끼우면 됩니다 — 동일한 `ApiLogWriter` 계약, 동일한 `api_log` 테이블.
+
## [0.5.2] — 실제 consumer 앱에서 빈 등록 문제 픽스
### Fixed
diff --git a/docs/changelog.md b/docs/changelog.md
index 4dea443..3d6a34c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,6 +6,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
+## [0.6.0] — Multi-module split (Gradle), pluggable JPA / R2DBC / MyBatis backends
+
+### Changed
+
+- **The single `api-log-spring-boot-starter` artifact is gone.** The starter is now a multi-module Gradle build. Consumers add `api-log-core` plus exactly one backend artifact:
+
+ | Artifact (Maven coordinate) | What it provides |
+ | --- | --- |
+ | `kr.devslab:api-log-core` | Events, SPI, async listener, HTTP client utilities |
+ | `kr.devslab:api-log-jpa` | JPA + Hibernate persistence (the v0.5.x behavior) |
+ | `kr.devslab:api-log-r2dbc` | Reactive R2DBC persistence — no JDBC dependency |
+ | `kr.devslab:api-log-mybatis` | MyBatis mapper persistence |
+
+ Adding `api-log-jpa` is the closest drop-in for v0.5.x users; it pulls `api-log-core` transitively.
+
+- **Build system: Maven → Gradle 8.10.** Adopting easy-paging's convention: Vanniktech maven-publish per module, configuration cache disabled in CI to play nice with the publishing plugin. The Maven build files are gone — `./gradlew build` is the only path now.
+
+- **Package renames** to reflect the layout:
+ - `kr.devslab.apilog.model.dto.ApiRequest` → `kr.devslab.apilog.dto.ApiRequest`
+ - `kr.devslab.apilog.model.dto.ApiResponse` → `kr.devslab.apilog.dto.ApiResponse`
+ - `kr.devslab.apilog.model.ApiLogEntity` → `kr.devslab.apilog.jpa.model.ApiLogEntity` (now lives in `api-log-jpa`)
+ - `kr.devslab.apilog.repository.ApiLogRepository` → `kr.devslab.apilog.jpa.repository.ApiLogRepository`
+ - `kr.devslab.apilog.service.ApiLogService` → replaced by `kr.devslab.apilog.jpa.writer.JpaApiLogWriter` (implements the new `ApiLogWriter` SPI)
+
+### Added
+
+- **`ApiLogWriter` SPI** (`kr.devslab.apilog.spi.ApiLogWriter`) — three-method interface that every backend implements (`writeInitiated`, `writeSuccess`, `writeError`). The core listener routes events through whatever writer bean the consumer's backend artifact registered.
+- **`api-log-r2dbc`** — reactive backend that talks to PostgreSQL via R2DBC's `DatabaseClient`. JSONB binding uses the R2DBC PostgreSQL driver's implicit `TEXT → JSONB` cast, no manual `::jsonb` needed. Ships a pure-reactive schema initializer (`R2dbcScriptDatabaseInitializer`) — zero JDBC pull-in.
+- **`api-log-mybatis`** — MyBatis backend with a `@Mapper`-annotated interface. JSONB columns use `CAST(#{...,jdbcType=VARCHAR} AS jsonb)` in the `@Insert` SQL so no custom `TypeHandler` is required.
+- **Shared SPI helpers in `:core`**:
+ - `HttpErrorExtractor` — pulls HTTP status + body off thrown exceptions (was inline in the old `ApiLogService`).
+ - `PayloadJsonMapper` — JSON string / `JsonNode` conversion used by every writer.
+
+### Fixed
+
+- **`V1.0__create_api_log.sql` is now idempotent.** Both `CREATE TABLE` and `CREATE INDEX` got `IF NOT EXISTS`. Previously the second boot under BUILTIN mode could fail with "relation already exists" if Hibernate's `ddl-auto` wasn't catching it.
+
+### Migration from v0.5.2
+
+Update your dependency coordinates:
+
+```xml
+
+
+ kr.devslab
+ api-log-spring-boot-starter
+ 0.5.2
+
+
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+```
+
+If you import any of the moved types directly, update the package:
+
+```java
+// Before
+import kr.devslab.apilog.model.dto.ApiRequest;
+import kr.devslab.apilog.model.ApiLogEntity;
+
+// After
+import kr.devslab.apilog.dto.ApiRequest;
+import kr.devslab.apilog.jpa.model.ApiLogEntity;
+```
+
+Reactive (R2DBC) or MyBatis adopters: swap `api-log-jpa` for `api-log-r2dbc` / `api-log-mybatis` instead — same `ApiLogWriter` contract, same `api_log` table.
+
## [0.5.2] — Fix bean registration in real consumer apps
### Fixed
diff --git a/docs/getting-started/installation.ko.md b/docs/getting-started/installation.ko.md
index a2ea8fa..3cd1bbe 100644
--- a/docs/getting-started/installation.ko.md
+++ b/docs/getting-started/installation.ko.md
@@ -8,13 +8,38 @@
## 의존성 추가
+v0.6.0부터 스타터가 백엔드 비종속 코어 + 영속화 백엔드 1개로 분리됐습니다.
+**아래 표에서 한 줄만 고르면 됩니다 — 해당 백엔드 아티팩트가
+`api-log-core`를 transitive하게 가져옵니다.**
+
+| 환경 | 추가할 좌표 |
+| --- | --- |
+| Spring MVC + JPA (v0.5.x 기본) | `kr.devslab:api-log-jpa` |
+| WebFlux + R2DBC (end-to-end 리액티브) | `kr.devslab:api-log-r2dbc` |
+| MyBatis (어떤 웹 스택이든) | `kr.devslab:api-log-mybatis` |
+
=== "Maven"
```xml
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+
+
kr.devslab
- api-log-spring-boot-starter
- 0.3.0
+ api-log-r2dbc
+ 0.6.0
+
+
+
+
+ kr.devslab
+ api-log-mybatis
+ 0.6.0
```
@@ -22,7 +47,10 @@
```kotlin
dependencies {
- implementation("kr.devslab:api-log-spring-boot-starter:0.3.0")
+ // JPA — v0.5.x 드롭인
+ implementation("kr.devslab:api-log-jpa:0.6.0")
+ // 또는 "kr.devslab:api-log-r2dbc:0.6.0"
+ // 또는 "kr.devslab:api-log-mybatis:0.6.0"
}
```
@@ -30,24 +58,49 @@
```groovy
dependencies {
- implementation 'kr.devslab:api-log-spring-boot-starter:0.3.0'
+ implementation 'kr.devslab:api-log-jpa:0.6.0'
+ // 또는 'kr.devslab:api-log-r2dbc:0.6.0'
+ // 또는 'kr.devslab:api-log-mybatis:0.6.0'
}
```
!!! tip "최신 버전"
- `0.3.0`은 [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter)의 최신 버전으로 교체.
+ `0.6.0`은 [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-core)의 최신 버전으로 교체.
+
+!!! info "v0.5.x에서 업그레이드?"
+ 기존 `api-log-spring-boot-starter` 좌표를 `api-log-jpa`로 바꾸면 됩니다
+ (동일 JPA 백엔드, 동일 `api_log` 행). 일부 패키지 이름이 바뀌었으니
+ [v0.6.0 변경 이력](../changelog.md#060--멀티모듈-분리-gradle-jpa--r2dbc--mybatis-백엔드-선택-지원)에서
+ 매핑 표 참고.
-## 스타터가 자동으로 가져오는 의존성
+## 각 아티팩트가 가져오는 의존성
+
+**`api-log-core`** (백엔드 아티팩트가 자동으로 가져옴):
+
+- `spring-boot-starter` (`@EventListener`, `@EnableAsync`, `ApplicationEventPublisher`)
+- `spring-retry` + `spring-boot-starter-aop` (리스너의 `@Retryable` 로그 쓰기 재시도)
+- `jackson-databind` + `jackson-module-blackbird` (JSONB 페이로드 직렬화)
+- `spring-web` / `spring-webflux` (compile-only — HTTP 유틸이 참조하지만, 사용자 classpath에 실제로 있어야 활성화)
+
+**`api-log-jpa`** 추가:
- `spring-boot-starter-data-jpa` (`ApiLogRepository`)
-- `spring-boot-starter-web` (내장 `RestApiClientUtil`)
-- `spring-retry` (`ApiEventListener`가 로그 쓰기 실패 시 3회까지 재시도)
-- `jackson-module-blackbird` (고성능 JSON 직렬화)
- `postgresql` JDBC 드라이버 (runtime)
+- `flyway-core` (compile-only — `api.log.schema.management=flyway`일 때만 활성화)
+
+**`api-log-r2dbc`** 추가:
+
+- `spring-r2dbc` (`DatabaseClient`)
+- `r2dbc-postgresql` (runtime)
+- `reactor-core`
-Flyway는 **옵셔널** — `api.log.schema.management=flyway`로 설정할 때만 필요합니다 (아래 [스키마 관리](#schema-management) 참고). 기본값(BUILTIN)은 Flyway 불필요.
+JDBC 드라이버 없음 — 순수 리액티브.
-Spring WebFlux도 **옵셔널** — 리액티브 `ReactiveApiClientUtil` (`Mono` / `Mono` 반환)을 사용하려면 `spring-webflux` + `reactor-netty-http`를 의존성에 추가. 그러면 스타터가 리액티브 클라이언트를 블로킹과 함께 자동 등록. [리액티브 가이드](../guides/reactive.md) 참고.
+**`api-log-mybatis`** 추가:
+
+- `mybatis-spring-boot-starter:3.0.4`
+- `spring-jdbc`
+- `postgresql` JDBC 드라이버 (runtime)
## 직접 제공해야 하는 것
@@ -76,13 +129,25 @@ api:
## 자동 구성이 하는 일
-스타터가 클래스패스에 있고 `api.log.enabled`가 `true`(기본값)이면 `ApiLogAutoConfiguration`이 활성화되어 다음을 등록합니다:
+`api.log.enabled`가 `true`(기본값)이면 `api-log-core`에서 3개의 auto-config가
+활성화되고, 선택한 백엔드에서 1개가 추가로 활성화됩니다.
+
+**`api-log-core`에서** (`ApiLogCoreAutoConfiguration`,
+`RestApiClientAutoConfiguration`, `ReactiveApiClientAutoConfiguration`):
-- `ApiLogService` — 영속화 오케스트레이터 (`ObjectMapper` 빈이 있어야 활성화)
-- `ApiEventListener` — 이벤트를 서비스로 연결하는 `@EventListener` (async)
+- `ApiEventListener` — 이벤트를 등록된 `ApiLogWriter`로 연결하는 `@EventListener`
+- `PayloadJsonMapper` — 모든 writer가 공유하는 JSON 헬퍼
- `RetryConfig` — `@EnableRetry` 활성화 (리스너의 로그 쓰기 `@Retryable` 동작용)
-- `ApiLogSchemaInitializer` — 부팅 시 `CREATE TABLE IF NOT EXISTS` 실행 (`schema.management=builtin` 활성화 시, 즉 기본값)
-- JPA `@EntityScan` 및 `@EnableJpaRepositories` (`kr.devslab.apilog.model`, `kr.devslab.apilog.repository`)
+- `apiLogJacksonCustomizer` — Spring Boot 기본 `ObjectMapper`에 Blackbird 추가
+- `apiLogVirtualThreadExecutor` / `apiLogPlatformThreadExecutor` — 리스너용 async executor (Virtual Threads 활성화 시 virtual)
+- `RestApiClientUtil` (classpath에 `RestClient`가 있을 때)
+- `ReactiveApiClientUtil` (classpath에 `WebClient`가 있을 때)
+
+**선택한 백엔드 아티팩트에서**:
+
+- `ApiLogWriter` 구현체 — 추가한 아티팩트에 따라 `JpaApiLogWriter` / `R2dbcApiLogWriter` / `MybatisApiLogWriter`
+- 스키마 초기화 (BUILTIN 모드) — `:jpa` + `:mybatis`는 JDBC 기반, `:r2dbc`는 순수 리액티브
+- JPA `@EntityScan` + `@EnableJpaRepositories` (`:jpa`만) 또는 `@MapperScan` (`:mybatis`만)
모든 빈은 `@ConditionalOnMissingBean`. 직접 빈을 정의하면 오버라이드됩니다.
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index dbbeb10..564ae41 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -8,13 +8,38 @@
## Adding the dependency
+v0.6.0 splits the starter into a backend-agnostic core plus one persistence
+backend per artifact. **Pick one row from the table below — that's it; the
+backend artifact pulls in `api-log-core` transitively.**
+
+| You are using… | Add this artifact |
+| --- | --- |
+| Spring MVC + JPA (the v0.5.x default) | `kr.devslab:api-log-jpa` |
+| WebFlux + R2DBC (reactive end-to-end) | `kr.devslab:api-log-r2dbc` |
+| MyBatis (any web stack) | `kr.devslab:api-log-mybatis` |
+
=== "Maven"
```xml
+
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+
+
+
+ kr.devslab
+ api-log-r2dbc
+ 0.6.0
+
+
+
kr.devslab
- api-log-spring-boot-starter
- 0.3.0
+ api-log-mybatis
+ 0.6.0
```
@@ -22,7 +47,10 @@
```kotlin
dependencies {
- implementation("kr.devslab:api-log-spring-boot-starter:0.3.0")
+ // JPA — drop-in for v0.5.x setups
+ implementation("kr.devslab:api-log-jpa:0.6.0")
+ // or "kr.devslab:api-log-r2dbc:0.6.0"
+ // or "kr.devslab:api-log-mybatis:0.6.0"
}
```
@@ -30,26 +58,49 @@
```groovy
dependencies {
- implementation 'kr.devslab:api-log-spring-boot-starter:0.3.0'
+ implementation 'kr.devslab:api-log-jpa:0.6.0'
+ // or 'kr.devslab:api-log-r2dbc:0.6.0'
+ // or 'kr.devslab:api-log-mybatis:0.6.0'
}
```
!!! tip "Latest version"
- Replace `0.3.0` with the latest from [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-spring-boot-starter).
+ Replace `0.6.0` with the latest from [Maven Central](https://central.sonatype.com/artifact/kr.devslab/api-log-core).
-## What the starter pulls in
+!!! info "Upgrading from v0.5.x?"
+ Swap the old `api-log-spring-boot-starter` coordinate for `api-log-jpa`
+ (same JPA backend, same `api_log` rows). A few packages were renamed —
+ see the [v0.6.0 changelog](../changelog.md#060--multi-module-split-gradle-pluggable-jpa--r2dbc--mybatis-backends)
+ for the complete mapping.
-The starter brings these for you transitively:
+## What each artifact pulls in
+
+**`api-log-core`** (always — pulled transitively by every backend artifact):
+
+- `spring-boot-starter` (`@EventListener`, `@EnableAsync`, `ApplicationEventPublisher`)
+- `spring-retry` + `spring-boot-starter-aop` (listener `@Retryable` log-write retries)
+- `jackson-databind` + `jackson-module-blackbird` (the JSONB payload serializer)
+- `spring-web` / `spring-webflux` (compile-only — the HTTP utilities reference them but require the consumer's classpath to actually include one or the other)
+
+**`api-log-jpa`** adds:
- `spring-boot-starter-data-jpa` (the `ApiLogRepository`)
-- `spring-boot-starter-web` (the bundled `RestApiClientUtil`)
-- `spring-retry` (lets `ApiEventListener` retry log-write failures 3× before giving up)
-- `jackson-module-blackbird` (high-throughput JSON serialization)
- `postgresql` JDBC driver (runtime)
+- `flyway-core` (compile-only — only activated when `api.log.schema.management=flyway`)
+
+**`api-log-r2dbc`** adds:
+
+- `spring-r2dbc` (`DatabaseClient`)
+- `r2dbc-postgresql` (runtime)
+- `reactor-core`
-Flyway is **optional** — only needed if you set `api.log.schema.management=flyway` (see [Schema management](#schema-management) below). The default doesn't need it.
+No JDBC driver — pure reactive.
-Spring WebFlux is also **optional** — only needed if you want the reactive `ReactiveApiClientUtil` (returns `Mono` / `Mono`). Add `spring-webflux` + `reactor-netty-http` to your dependencies and the starter auto-registers the reactive client alongside the blocking one. See the [Reactive guide](../guides/reactive.md).
+**`api-log-mybatis`** adds:
+
+- `mybatis-spring-boot-starter:3.0.4`
+- `spring-jdbc`
+- `postgresql` JDBC driver (runtime)
## What you bring yourself
@@ -78,13 +129,25 @@ api:
## What auto-configuration does
-When the starter is on the classpath and `api.log.enabled` is `true` (the default), `ApiLogAutoConfiguration` activates and registers:
+When `api.log.enabled` is `true` (the default), three auto-configurations from
+`api-log-core` activate plus one from whichever backend you picked.
+
+From **`api-log-core`** (`ApiLogCoreAutoConfiguration`,
+`RestApiClientAutoConfiguration`, `ReactiveApiClientAutoConfiguration`):
-- `ApiLogService` — the persistence orchestrator (gated on an `ObjectMapper` bean)
-- `ApiEventListener` — the `@EventListener` (async) that bridges events to the service
+- `ApiEventListener` — the `@EventListener` that bridges events to the chosen `ApiLogWriter`
+- `PayloadJsonMapper` — shared JSON helper for every writer
- `RetryConfig` — enables `@EnableRetry` so the listener's own `@Retryable` log-write retries work
-- `ApiLogSchemaInitializer` — runs `CREATE TABLE IF NOT EXISTS` on startup (active for `schema.management=builtin`, the default)
-- JPA `@EntityScan` and `@EnableJpaRepositories` scoped to `kr.devslab.apilog.model` and `kr.devslab.apilog.repository`
+- `apiLogJacksonCustomizer` — adds Blackbird to Spring Boot's default `ObjectMapper`
+- `apiLogVirtualThreadExecutor` / `apiLogPlatformThreadExecutor` — async executor for the listener (virtual threads when enabled)
+- `RestApiClientUtil` (when `RestClient` is on the classpath)
+- `ReactiveApiClientUtil` (when `WebClient` is on the classpath)
+
+From the **backend artifact**:
+
+- `ApiLogWriter` implementation — `JpaApiLogWriter` / `R2dbcApiLogWriter` / `MybatisApiLogWriter`, depending on which artifact you added
+- Schema initializer (BUILTIN mode) — JDBC-based for `:jpa` + `:mybatis`, pure-reactive for `:r2dbc`
+- JPA `@EntityScan` + `@EnableJpaRepositories` (`:jpa` only) or `@MapperScan` (`:mybatis` only)
All beans use `@ConditionalOnMissingBean`. Define your own to override.
diff --git a/docs/getting-started/quickstart.ko.md b/docs/getting-started/quickstart.ko.md
index 8735c22..21ccadd 100644
--- a/docs/getting-started/quickstart.ko.md
+++ b/docs/getting-started/quickstart.ko.md
@@ -28,7 +28,7 @@ spring:
package com.example.demo;
import kr.devslab.apilog.util.RestApiClientUtil;
-import kr.devslab.apilog.model.dto.ApiResponse;
+import kr.devslab.apilog.dto.ApiResponse;
import org.springframework.stereotype.Service;
@Service
@@ -75,7 +75,7 @@ public class DemoApplication implements CommandLineRunner {
앱 실행:
```bash
-./mvnw spring-boot:run
+./gradlew bootRun
```
## 4. 로그 확인
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 6e9900e..a0db8b4 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -28,7 +28,7 @@ That's it — no `api.log.*` config needed for the happy path. The default `sche
package com.example.demo;
import kr.devslab.apilog.util.RestApiClientUtil;
-import kr.devslab.apilog.model.dto.ApiResponse;
+import kr.devslab.apilog.dto.ApiResponse;
import org.springframework.stereotype.Service;
@Service
@@ -75,7 +75,7 @@ public class DemoApplication implements CommandLineRunner {
Start the app:
```bash
-./mvnw spring-boot:run
+./gradlew bootRun
```
## 4. Inspect the logs
diff --git a/docs/guides/jpa-backend.ko.md b/docs/guides/jpa-backend.ko.md
new file mode 100644
index 0000000..79636ee
--- /dev/null
+++ b/docs/guides/jpa-backend.ko.md
@@ -0,0 +1,230 @@
+# JPA 백엔드
+
+JPA 백엔드 (`api-log-jpa`)가 기본 선택지입니다. v0.5.x에서 출시된 동작 그대로,
+이제 3개 백엔드 중 하나로 패키징됐을 뿐입니다. 이미 Spring Data JPA를 쓰고
+있다면, 또는 굳이 리액티브로 갈 이유가 없다면 이 백엔드를 고르세요.
+
+## 언제 선택하나
+
+- Spring MVC + JPA 스택을 쓰고 있을 때.
+- v0.5.x 동작을 그대로 원할 때 — `ApiLogEntity`, `ApiLogRepository`, JSONB
+ 컬럼용 Hibernate `@JdbcTypeCode(SqlTypes.JSON)` 매핑.
+- 다른 앱 코드처럼 `JpaRepository`로 감사 로그를 조회하고 싶을 때.
+
+WebFlux + R2DBC 환경이면 [`api-log-r2dbc`](r2dbc-backend.md), MyBatis 환경이면
+[`api-log-mybatis`](mybatis-backend.md)가 더 적합합니다. `api_log` 스키마 자체는
+세 백엔드 모두 동일합니다.
+
+## 설치
+
+=== "Maven"
+
+ ```xml
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+ ```
+
+=== "Gradle (Kotlin DSL)"
+
+ ```kotlin
+ implementation("kr.devslab:api-log-jpa:0.6.0")
+ ```
+
+`api-log-jpa`는 `api-log-core` (이벤트, 리스너, HTTP 유틸)와
+`spring-boot-starter-data-jpa`, PostgreSQL JDBC 드라이버를 transitive하게
+가져옵니다. 추가로 넣을 거 없음 — Flyway는 옵셔널이고
+`api.log.schema.management=flyway`일 때만 필요합니다.
+
+## 자동으로 등록되는 빈
+
+JPA 백엔드가 classpath에 있고 `api.log.enabled=true`(기본값)이면
+`ApiLogJpaAutoConfiguration`이 활성화되어 다음을 등록합니다:
+
+| 빈 | 역할 |
+| --- | --- |
+| `JpaApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 |
+| `ApiLogJpaSchemaInitializer` | `DataSource`에 `V1.0__create_api_log.sql` 실행 (BUILTIN 모드만) |
+| `ApiLogRepository` (`@EnableJpaRepositories` 경유) | `ApiLogEntity` 용 Spring Data 리포지토리 |
+| `ApiLogFlywayConfigurationCustomizer` | Flyway locations에 `classpath:db/api-log` 추가 (FLYWAY 모드만) |
+
+`@EntityScan(basePackageClasses = ApiLogEntity.class)`이 자동으로 적용되므로
+사용자 `@SpringBootApplication` 패키지 스캔에 `ApiLogEntity`를 별도로 추가할
+필요 없습니다.
+
+## 엔티티
+
+```java
+package kr.devslab.apilog.jpa.model;
+
+@Entity
+@Table(name = "api_log")
+public class ApiLogEntity {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String eventType;
+ private String requestId;
+ private String endpoint;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode payload;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode response;
+
+ private Integer statusCode;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode errorMessage;
+
+ private LocalDateTime timestamp;
+ private Integer retryCount;
+ private Boolean isRetry;
+}
+```
+
+JSONB 3개 컬럼 (`payload`, `response`, `error_message`)은
+`@JdbcTypeCode(SqlTypes.JSON)`으로 Jackson `JsonNode`에 매핑됩니다.
+Hibernate가 PostgreSQL 다이얼렉트의 JSONB 바인더로 위임해주기 때문에
+JSON 구조가 그대로 보존됩니다 (단순 텍스트가 아닌).
+
+## 코드에서 로그 조회
+
+번들된 `ApiLogRepository`가 기본 조회를 제공:
+
+```java
+@Service
+@RequiredArgsConstructor
+public class AuditQueryService {
+
+ private final ApiLogRepository repo;
+
+ public List timelineFor(String requestId) {
+ return repo.findByRequestId(requestId);
+ }
+
+ public List errorsAt(String endpoint) {
+ return repo.findByEndpoint(endpoint).stream()
+ .filter(e -> "ERROR".equals(e.getEventType()))
+ .toList();
+ }
+}
+```
+
+JSONB 연산자, GIN 인덱스 활용, 에러율 집계 같은 풍부한 질의는
+[로그 조회 가이드](querying-logs.md) 참고 — 테이블 스키마가 같아서 세 백엔드
+모두 동일하게 적용됩니다.
+
+## 트랜잭션 시맨틱
+
+`JpaApiLogWriter`의 모든 메서드는 `@Transactional(propagation = REQUIRES_NEW)`로
+실행됩니다:
+
+```java
+@Override
+@Transactional(propagation = Propagation.REQUIRES_NEW)
+public void writeInitiated(ApiCallInitiatedEvent event) { ... }
+```
+
+의도된 설계입니다. 감사 로그 쓰기는 호출자의 비즈니스 트랜잭션과 함께 롤백되면
+안 됩니다 — 호출자의 `@Transactional`이 나중에 실패해서 롤백되더라도
+`INITIATED` 행은 남아 있어야 합니다. 작업 단위의 운명과 무관하게 `api_log`를
+보면 그 호출이 실제로 나갔는지 확인할 수 있어야 합니다.
+
+## 스키마 관리
+
+기본값은 `api.log.schema.management=builtin` — 부팅 시 번들된
+`V1.0__create_api_log.sql`이 Spring Boot의 `DataSourceScriptDatabaseInitializer`를
+통해 `DataSource`에 실행됩니다. DDL이 `IF NOT EXISTS`를 사용해서 멱등합니다.
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: builtin # 기본값
+```
+
+Flyway 모드로 전환 (마이그레이션이 `flyway_schema_history`에 기록됨):
+
+```xml
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+ runtime
+
+```
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: flyway
+```
+
+`ApiLogFlywayConfigurationCustomizer`가 기존 `spring.flyway.locations`에
+`classpath:db/api-log`를 추가합니다 — 사용자 마이그레이션과 우리 마이그레이션이
+하나의 sweep에서 돌고, 하나의 history 테이블을 공유합니다.
+
+완전히 opt-out (DDL을 직접 적용):
+
+```yaml
+api:
+ log:
+ schema:
+ management: none
+```
+
+각 전략의 자세한 동작과 원본 DDL은 [스키마 레퍼런스](../reference/schema.md)에
+있습니다.
+
+## Writer 오버라이드
+
+로그 쓰기 방식을 커스터마이즈해야 할 때 (추가 컬럼, 페이로드 마스킹, 다른
+테이블 등)는 직접 `ApiLogWriter` 빈을 정의하면 됩니다 — 백엔드의
+`@ConditionalOnMissingBean(ApiLogWriter.class)`이 뒤로 빠집니다:
+
+```java
+@Bean
+public ApiLogWriter customWriter(ApiLogRepository repo, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ return new MaskingJpaApiLogWriter(repo, json, masker);
+}
+```
+
+번들된 writer를 감싸는 패턴이 일반적입니다:
+
+```java
+public class MaskingJpaApiLogWriter implements ApiLogWriter {
+
+ private final ApiLogWriter delegate;
+ private final PayloadMasker masker;
+
+ public MaskingJpaApiLogWriter(ApiLogRepository repo, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ this.delegate = new JpaApiLogWriter(repo, json);
+ this.masker = masker;
+ }
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ delegate.writeInitiated(masker.mask(event));
+ }
+ // ... writeSuccess / writeError도 동일
+}
+```
+
+## 더 읽어볼 거리
+
+- [로그 조회](querying-logs.md) — JSONB 연산자 레시피, 인덱스, 에러율.
+- [이벤트 직접 발행](publishing-events.md) — 자체 HTTP 클라이언트 사용 시
+ 이벤트만 사용.
+- [재시도 처리](retry-handling.md) — `RETRY_ERROR` 시맨틱, 리스너의 로그 쓰기
+ 재시도.
+- [스키마 레퍼런스](../reference/schema.md) — 컬럼 타입, 인덱스, 원본 DDL.
diff --git a/docs/guides/jpa-backend.md b/docs/guides/jpa-backend.md
new file mode 100644
index 0000000..872e084
--- /dev/null
+++ b/docs/guides/jpa-backend.md
@@ -0,0 +1,234 @@
+# JPA backend
+
+The JPA backend (`api-log-jpa`) is the default — it's what v0.5.x shipped, now
+packaged as one of three choices. Pick it when your application already uses
+Spring Data JPA, or when you don't have a strong reason to go reactive.
+
+## When to pick it
+
+- You're on a Spring MVC + JPA stack.
+- You want the v0.5.x behavior unchanged — `ApiLogEntity`,
+ `ApiLogRepository`, and Hibernate's `@JdbcTypeCode(SqlTypes.JSON)` mapping
+ for the JSONB columns.
+- You want to query the audit log via the same `JpaRepository` infrastructure
+ the rest of your app uses.
+
+If you're on WebFlux + R2DBC, prefer [`api-log-r2dbc`](r2dbc-backend.md). If
+you're on MyBatis, prefer [`api-log-mybatis`](mybatis-backend.md). The
+`api_log` schema is identical across all three.
+
+## Install
+
+=== "Maven"
+
+ ```xml
+
+ kr.devslab
+ api-log-jpa
+ 0.6.0
+
+ ```
+
+=== "Gradle (Kotlin DSL)"
+
+ ```kotlin
+ implementation("kr.devslab:api-log-jpa:0.6.0")
+ ```
+
+`api-log-jpa` transitively pulls in `api-log-core` (events, listener, HTTP
+utilities) plus `spring-boot-starter-data-jpa` and the PostgreSQL JDBC driver.
+Nothing else to add — Flyway is optional and only matters if you switch
+`api.log.schema.management` to `flyway`.
+
+## What gets registered
+
+When the JPA backend is on the classpath and `api.log.enabled=true` (the
+default), `ApiLogJpaAutoConfiguration` activates and registers:
+
+| Bean | Purpose |
+| --- | --- |
+| `JpaApiLogWriter` | The `ApiLogWriter` implementation the core listener routes events through |
+| `ApiLogJpaSchemaInitializer` | Runs `V1.0__create_api_log.sql` against your `DataSource` (BUILTIN mode only) |
+| `ApiLogRepository` (via `@EnableJpaRepositories`) | Spring Data repository for `ApiLogEntity` |
+| `ApiLogFlywayConfigurationCustomizer` | Appends `classpath:db/api-log` to Flyway's locations (FLYWAY mode only) |
+
+`@EntityScan(basePackageClasses = ApiLogEntity.class)` is wired automatically
+— you don't need to add `ApiLogEntity` to your own `@SpringBootApplication`'s
+package scan.
+
+## The entity
+
+```java
+package kr.devslab.apilog.jpa.model;
+
+@Entity
+@Table(name = "api_log")
+public class ApiLogEntity {
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private String eventType;
+ private String requestId;
+ private String endpoint;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode payload;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode response;
+
+ private Integer statusCode;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ private JsonNode errorMessage;
+
+ private LocalDateTime timestamp;
+ private Integer retryCount;
+ private Boolean isRetry;
+}
+```
+
+The three JSONB columns (`payload`, `response`, `error_message`) are mapped as
+Jackson `JsonNode` via `@JdbcTypeCode(SqlTypes.JSON)` — Hibernate delegates to
+the PostgreSQL dialect's JSONB binder, so the round-trip preserves JSON
+structure (not just text).
+
+## Querying logs from your code
+
+The bundled `ApiLogRepository` exposes simple lookups:
+
+```java
+@Service
+@RequiredArgsConstructor
+public class AuditQueryService {
+
+ private final ApiLogRepository repo;
+
+ public List timelineFor(String requestId) {
+ return repo.findByRequestId(requestId);
+ }
+
+ public List errorsAt(String endpoint) {
+ return repo.findByEndpoint(endpoint).stream()
+ .filter(e -> "ERROR".equals(e.getEventType()))
+ .toList();
+ }
+}
+```
+
+For richer queries — JSONB operators, GIN-indexed payload searches, error-rate
+aggregations — go to the [Querying logs guide](querying-logs.md), which
+applies to all three backends since the table schema is the same.
+
+## Transaction semantics
+
+Every `JpaApiLogWriter` method runs in `@Transactional(propagation = REQUIRES_NEW)`:
+
+```java
+@Override
+@Transactional(propagation = Propagation.REQUIRES_NEW)
+public void writeInitiated(ApiCallInitiatedEvent event) { ... }
+```
+
+This is deliberate. An audit log write must never roll back with the caller's
+business transaction — if the caller's `@Transactional` later fails and rolls
+back, the `INITIATED` row stays. You should be able to query `api_log` and see
+that the call actually went out, regardless of what happened to the rest of
+the unit of work.
+
+## Schema management
+
+The default is `api.log.schema.management=builtin` — on startup the bundled
+`V1.0__create_api_log.sql` runs against your `DataSource` via Spring Boot's
+`DataSourceScriptDatabaseInitializer`. The DDL uses `IF NOT EXISTS`, so it's
+idempotent.
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: builtin # default
+```
+
+To switch to Flyway (so the migration is recorded in `flyway_schema_history`):
+
+```xml
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+ runtime
+
+```
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: flyway
+```
+
+`ApiLogFlywayConfigurationCustomizer` appends `classpath:db/api-log` to your
+existing `spring.flyway.locations` — your own migrations and ours run from one
+sweep, share one history table.
+
+To opt out entirely (apply the DDL yourself):
+
+```yaml
+api:
+ log:
+ schema:
+ management: none
+```
+
+Full strategy details and the raw DDL are in the
+[Schema reference](../reference/schema.md).
+
+## Overriding the writer
+
+If you need to customize how rows are written (extra columns, masking
+payloads, a different table), provide your own `ApiLogWriter` bean — the
+backend's `@ConditionalOnMissingBean(ApiLogWriter.class)` backs off:
+
+```java
+@Bean
+public ApiLogWriter customWriter(ApiLogRepository repo, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ return new MaskingJpaApiLogWriter(repo, json, masker);
+}
+```
+
+A common pattern is to wrap the bundled writer:
+
+```java
+public class MaskingJpaApiLogWriter implements ApiLogWriter {
+
+ private final ApiLogWriter delegate;
+ private final PayloadMasker masker;
+
+ public MaskingJpaApiLogWriter(ApiLogRepository repo, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ this.delegate = new JpaApiLogWriter(repo, json);
+ this.masker = masker;
+ }
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ delegate.writeInitiated(masker.mask(event));
+ }
+ // ... same for writeSuccess / writeError
+}
+```
+
+## See also
+
+- [Querying logs](querying-logs.md) — JSONB operator recipes, indexes, error
+ rates.
+- [Publishing events manually](publishing-events.md) — bring your own HTTP
+ client, just use the events.
+- [Retry handling](retry-handling.md) — `RETRY_ERROR` semantics, listener
+ log-write retries.
+- [Schema reference](../reference/schema.md) — column types, indexes, raw DDL.
diff --git a/docs/guides/mybatis-backend.ko.md b/docs/guides/mybatis-backend.ko.md
new file mode 100644
index 0000000..0c8145c
--- /dev/null
+++ b/docs/guides/mybatis-backend.ko.md
@@ -0,0 +1,250 @@
+# MyBatis 백엔드
+
+MyBatis 백엔드 (`api-log-mybatis`)는 `@Mapper`로 어노테이션된 인터페이스를
+통해 감사 행을 씁니다. 이미 MyBatis를 쓰고 있는데 감사 로그 때문에 JPA /
+Hibernate를 끌고 들어오기 싫을 때 이걸 고르세요.
+
+## 언제 선택하나
+
+- 이미 MyBatis 사용 중 (웹 스택은 무관 — Servlet 또는 WebFlux+JDBC).
+- 프로젝트에 ORM을 하나만 두고 싶을 때. 감사 로깅 *만을 위해* JPA를 추가하면
+ 영속화 프레임워크 2개, 트랜잭션 매니저 2개, 컨벤션 2세트 — 보통은 그만한
+ 가치가 없습니다.
+
+JPA를 쓰고 있으면 [`api-log-jpa`](jpa-backend.md) 선택. WebFlux + R2DBC에서
+순수 리액티브 영속화를 원하면 [`api-log-r2dbc`](r2dbc-backend.md)가 맞습니다.
+`api_log` 스키마는 세 백엔드 모두 동일합니다.
+
+## 설치
+
+=== "Maven"
+
+ ```xml
+
+ kr.devslab
+ api-log-mybatis
+ 0.6.0
+
+ ```
+
+=== "Gradle (Kotlin DSL)"
+
+ ```kotlin
+ implementation("kr.devslab:api-log-mybatis:0.6.0")
+ ```
+
+`api-log-mybatis`는 `api-log-core`, `mybatis-spring-boot-starter:3.0.4`
+(Spring Boot 3.x 호환 라인), `spring-jdbc`, PostgreSQL JDBC 드라이버를
+transitive하게 가져옵니다. `DataSource`는 별도로 구성해야 합니다 — Spring
+Boot 기본 `spring.datasource.*`로 충분.
+
+## 자동으로 등록되는 빈
+
+MyBatis (`org.apache.ibatis.session.SqlSessionFactory`)가 classpath에 있고
+`api.log.enabled=true`이면 `ApiLogMybatisAutoConfiguration`이 활성화되어
+다음을 등록합니다:
+
+| 빈 | 역할 |
+| --- | --- |
+| `MybatisApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 |
+| `ApiLogMapper` (`@MapperScan` 경유) | INSERT SQL을 담은 MyBatis `@Mapper` |
+| `ApiLogMybatisSchemaInitializer` | `DataSource`에 `V1.0__create_api_log.sql` 실행 (BUILTIN 모드만) |
+
+`@MapperScan(basePackageClasses = ApiLogMapper.class)`가 자동으로 적용됩니다.
+사용자 애플리케이션에 이미 다른 패키지를 향한 `@MapperScan`이 있으면 우리 것이
+함께 합쳐 동작합니다 — 두 스캔 모두 실행.
+
+## 매퍼
+
+```java
+package kr.devslab.apilog.mybatis.mapper;
+
+@Mapper
+public interface ApiLogMapper {
+
+ @Insert("""
+ INSERT INTO api_log
+ (event_type, request_id, endpoint, payload, response,
+ status_code, error_message, timestamp, retry_count, is_retry)
+ VALUES
+ (#{eventType},
+ #{requestId},
+ #{endpoint},
+ CAST(#{payload,jdbcType=VARCHAR} AS jsonb),
+ CAST(#{response,jdbcType=VARCHAR} AS jsonb),
+ #{statusCode,jdbcType=INTEGER},
+ CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb),
+ #{timestamp},
+ #{retryCount},
+ #{isRetry})
+ """)
+ @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
+ void insert(ApiLogRow row);
+
+ @Select("""
+ SELECT id, event_type AS eventType, request_id AS requestId, endpoint,
+ payload::text AS payload, response::text AS response,
+ status_code AS statusCode, error_message::text AS errorMessage,
+ timestamp, retry_count AS retryCount, is_retry AS isRetry
+ FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC
+ """)
+ List findByRequestId(String requestId);
+}
+```
+
+### `CAST(...,jdbcType=VARCHAR) AS jsonb`를 쓰는 이유
+
+PostgreSQL은 Java `String` 파라미터를 `JSONB` 컬럼으로 묵시적 캐스트해주지
+않습니다. 두 가지 해결법:
+
+1. **커스텀 `TypeHandler`** — 보일러플레이트, JSONB 컬럼마다 핸들러 등록 필요.
+2. **SQL에서 명시적 캐스트** — 이 매퍼의 방식. 컬럼당 한 줄, 별도 와이어업
+ 없음.
+
+`jdbcType=VARCHAR` 어노테이션은 값이 `null`일 때도 VARCHAR 바인딩을 강제해서
+PostgreSQL의 "could not determine data type of parameter" 에러를 회피합니다.
+
+### 행 타입
+
+```java
+public class ApiLogRow {
+ private Long id;
+ private String eventType;
+ private String requestId;
+ private String endpoint;
+ private String payload; // JSON 문자열 — PayloadJsonMapper의 canonical form
+ private String response; // JSON 문자열
+ private Integer statusCode;
+ private String errorMessage; // JSON 문자열
+ private LocalDateTime timestamp;
+ private Integer retryCount;
+ private Boolean isRetry;
+}
+```
+
+"JSON" 필드 3개는 일반 `String`. `api-log-core`의
+`PayloadJsonMapper.toJsonString()`이 canonical JSON 형태로 만들어주고, 매퍼의
+캐스트가 insert 시점에 JSONB로 변환합니다.
+
+## 트랜잭션 시맨틱
+
+`MybatisApiLogWriter`의 메서드는 `@Transactional(propagation = REQUIRES_NEW)`로
+실행됩니다:
+
+```java
+@Override
+@Transactional(propagation = Propagation.REQUIRES_NEW)
+public void writeInitiated(ApiCallInitiatedEvent event) { ... }
+```
+
+JPA 백엔드와 동일한 계약 — 감사 쓰기는 호출자의 outer 트랜잭션과 함께 롤백되면
+안 됩니다. 감사 행은 작업 단위의 나머지 운명과 무관하게 자체적으로
+커밋됩니다.
+
+## 스키마 관리
+
+기본값은 `api.log.schema.management=builtin`. MyBatis 백엔드는 JDBC 기반
+`DataSourceScriptDatabaseInitializer`를 사용 (JPA 백엔드와 같은 방식),
+MyBatis 자체가 JDBC 위에서 동작하므로. DDL은 `IF NOT EXISTS`를 사용해서 매
+부팅마다 다시 돌려도 no-op.
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: builtin # 기본값
+```
+
+`api-log-mybatis`에는 **Flyway 통합이 번들되지 않습니다** — `FlywayConfigurationCustomizer`는
+`api-log-jpa`에만 들어 있습니다. MyBatis 앱이 Flyway로 테이블 관리를
+원한다면:
+
+- Flyway를 의존성에 추가 (`flyway-core` + `flyway-database-postgresql`).
+- `spring.flyway.locations`에 `classpath:db/api-log`를 사용자 위치와 함께
+ 추가:
+
+ ```yaml
+ spring:
+ flyway:
+ locations:
+ - classpath:db/migration # 사용자 자신의 것
+ - classpath:db/api-log # 우리 것
+ ```
+
+- `api.log.schema.management=none`으로 설정해서 BUILTIN 초기화가 Flyway 부트스트랩과
+ 충돌하지 않게 함.
+
+`none` (DDL을 Liquibase / `psql` 등으로 직접 적용)도 유효:
+
+```yaml
+api:
+ log:
+ schema:
+ management: none
+```
+
+## 행 다시 읽기
+
+번들된 `findByRequestId`가 "한 호출의 타임라인" 쿼리를 커버합니다. 그 외에는
+직접 `ApiLogMapper`에 질의를 추가하세요 (번들된 걸 확장하거나, 별도 매퍼에):
+
+```java
+@Mapper
+public interface MyApiLogQueries {
+
+ @Select("""
+ SELECT COUNT(*) FILTER (WHERE event_type = 'ERROR') * 100.0 / COUNT(*)
+ FROM api_log
+ WHERE endpoint = #{endpoint}
+ AND timestamp > NOW() - INTERVAL '1 hour'
+ """)
+ Double errorRateLastHour(String endpoint);
+}
+```
+
+JSONB 쿼리 플레이북 (연산자, GIN 인덱스, 에러율) 전체는 [로그 조회
+가이드](querying-logs.md)에 — 백엔드와 무관하게 동일합니다.
+
+## Writer 오버라이드
+
+행 쓰기 방식을 커스터마이즈할 때는 직접 `ApiLogWriter` 빈을 정의 — 백엔드의
+`@ConditionalOnMissingBean(ApiLogWriter.class)`가 뒤로 빠집니다:
+
+```java
+@Bean
+public ApiLogWriter customWriter(ApiLogMapper mapper, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ return new MaskingMybatisApiLogWriter(mapper, json, masker);
+}
+```
+
+번들된 writer를 감싸는 게 보통 충분합니다:
+
+```java
+public class MaskingMybatisApiLogWriter implements ApiLogWriter {
+
+ private final ApiLogWriter delegate;
+ private final PayloadMasker masker;
+
+ public MaskingMybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ this.delegate = new MybatisApiLogWriter(mapper, json);
+ this.masker = masker;
+ }
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ delegate.writeInitiated(masker.mask(event));
+ }
+ // ... writeSuccess / writeError도 동일
+}
+```
+
+## 더 읽어볼 거리
+
+- [로그 조회](querying-logs.md) — JSONB 연산자 레시피, GIN 인덱스, 에러율.
+- [이벤트 직접 발행](publishing-events.md) — 자체 HTTP 클라이언트 사용 시
+ 이벤트만 사용.
+- [재시도 처리](retry-handling.md) — `RETRY_ERROR` 시맨틱, 리스너의 로그 쓰기
+ 재시도.
+- [스키마 레퍼런스](../reference/schema.md) — 컬럼 타입, 인덱스, 원본 DDL.
diff --git a/docs/guides/mybatis-backend.md b/docs/guides/mybatis-backend.md
new file mode 100644
index 0000000..a159fb4
--- /dev/null
+++ b/docs/guides/mybatis-backend.md
@@ -0,0 +1,255 @@
+# MyBatis backend
+
+The MyBatis backend (`api-log-mybatis`) writes audit rows through a
+`@Mapper`-annotated interface. Pick it when your application is already on
+MyBatis and you don't want to drag JPA / Hibernate in just for the audit log.
+
+## When to pick it
+
+- You're already on MyBatis (any web stack — Servlet or WebFlux+JDBC).
+- You want one ORM in your project. Adding JPA *just* for audit logging means
+ two persistence frameworks, two transaction managers, two sets of conventions
+ — usually not worth it.
+
+If you're on JPA, prefer [`api-log-jpa`](jpa-backend.md). If you're on
+WebFlux + R2DBC and want pure-reactive persistence,
+[`api-log-r2dbc`](r2dbc-backend.md) is the right pick. The `api_log` schema
+is identical across all three.
+
+## Install
+
+=== "Maven"
+
+ ```xml
+
+ kr.devslab
+ api-log-mybatis
+ 0.6.0
+
+ ```
+
+=== "Gradle (Kotlin DSL)"
+
+ ```kotlin
+ implementation("kr.devslab:api-log-mybatis:0.6.0")
+ ```
+
+`api-log-mybatis` transitively pulls in `api-log-core`,
+`mybatis-spring-boot-starter:3.0.4` (the Spring Boot 3.x compatible line),
+`spring-jdbc`, and the PostgreSQL JDBC driver. You still need a configured
+`DataSource` — Spring Boot's defaults via `spring.datasource.*` work
+unchanged.
+
+## What gets registered
+
+When MyBatis (`org.apache.ibatis.session.SqlSessionFactory`) is on the
+classpath and `api.log.enabled=true`, `ApiLogMybatisAutoConfiguration`
+activates and registers:
+
+| Bean | Purpose |
+| --- | --- |
+| `MybatisApiLogWriter` | The `ApiLogWriter` implementation the core listener routes events through |
+| `ApiLogMapper` (via `@MapperScan`) | The MyBatis `@Mapper` carrying the INSERT SQL |
+| `ApiLogMybatisSchemaInitializer` | Runs `V1.0__create_api_log.sql` against the `DataSource` (BUILTIN mode only) |
+
+`@MapperScan(basePackageClasses = ApiLogMapper.class)` is applied
+automatically. If your application already has its own `@MapperScan` for
+different packages, ours composes additively — both scans run.
+
+## The mapper
+
+```java
+package kr.devslab.apilog.mybatis.mapper;
+
+@Mapper
+public interface ApiLogMapper {
+
+ @Insert("""
+ INSERT INTO api_log
+ (event_type, request_id, endpoint, payload, response,
+ status_code, error_message, timestamp, retry_count, is_retry)
+ VALUES
+ (#{eventType},
+ #{requestId},
+ #{endpoint},
+ CAST(#{payload,jdbcType=VARCHAR} AS jsonb),
+ CAST(#{response,jdbcType=VARCHAR} AS jsonb),
+ #{statusCode,jdbcType=INTEGER},
+ CAST(#{errorMessage,jdbcType=VARCHAR} AS jsonb),
+ #{timestamp},
+ #{retryCount},
+ #{isRetry})
+ """)
+ @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
+ void insert(ApiLogRow row);
+
+ @Select("""
+ SELECT id, event_type AS eventType, request_id AS requestId, endpoint,
+ payload::text AS payload, response::text AS response,
+ status_code AS statusCode, error_message::text AS errorMessage,
+ timestamp, retry_count AS retryCount, is_retry AS isRetry
+ FROM api_log WHERE request_id = #{requestId} ORDER BY id ASC
+ """)
+ List findByRequestId(String requestId);
+}
+```
+
+### Why `CAST(...,jdbcType=VARCHAR) AS jsonb`
+
+PostgreSQL won't implicitly cast a Java `String` parameter into a `JSONB`
+column. Two ways to bridge that:
+
+1. **Custom `TypeHandler`** — boilerplate, requires registering the handler
+ per JSONB column.
+2. **Explicit cast in SQL** — what this mapper does. One line per column,
+ nothing else to wire up.
+
+The `jdbcType=VARCHAR` annotation forces a VARCHAR binding even when the
+value is `null`, side-stepping PostgreSQL's "could not determine data type of
+parameter" error on null JSONB binds.
+
+### The row type
+
+```java
+public class ApiLogRow {
+ private Long id;
+ private String eventType;
+ private String requestId;
+ private String endpoint;
+ private String payload; // JSON string — canonical form from PayloadJsonMapper
+ private String response; // JSON string
+ private Integer statusCode;
+ private String errorMessage; // JSON string
+ private LocalDateTime timestamp;
+ private Integer retryCount;
+ private Boolean isRetry;
+}
+```
+
+The three "JSON" fields are plain `String`. `PayloadJsonMapper.toJsonString()`
+(from `api-log-core`) produces the canonical JSON form, and the mapper's cast
+turns it into JSONB on insert.
+
+## Transaction semantics
+
+`MybatisApiLogWriter` methods run in `@Transactional(propagation = REQUIRES_NEW)`:
+
+```java
+@Override
+@Transactional(propagation = Propagation.REQUIRES_NEW)
+public void writeInitiated(ApiCallInitiatedEvent event) { ... }
+```
+
+This matches the JPA backend's contract — audit writes must not roll back
+with the caller's outer transaction. The audit row is committed on its own,
+regardless of what the rest of the unit of work does.
+
+## Schema management
+
+The default is `api.log.schema.management=builtin`. The MyBatis backend uses
+the JDBC-based `DataSourceScriptDatabaseInitializer` (same approach as the
+JPA backend), since MyBatis itself runs on JDBC. The DDL uses `IF NOT EXISTS`,
+so re-running on every boot is a no-op.
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: builtin # default
+```
+
+Flyway integration **isn't bundled** in `api-log-mybatis` — only `api-log-jpa`
+ships the `FlywayConfigurationCustomizer`. For MyBatis apps that want Flyway
+to manage the table:
+
+- Add Flyway to your dependencies (`flyway-core` + `flyway-database-postgresql`).
+- Set `spring.flyway.locations` to include `classpath:db/api-log` alongside
+ your own:
+
+ ```yaml
+ spring:
+ flyway:
+ locations:
+ - classpath:db/migration # your own
+ - classpath:db/api-log # ours
+ ```
+
+- Set `api.log.schema.management=none` so the BUILTIN initializer doesn't
+ fight Flyway's own bootstrap.
+
+`none` (apply the DDL yourself, e.g. via Liquibase or `psql`) is also valid:
+
+```yaml
+api:
+ log:
+ schema:
+ management: none
+```
+
+## Reading rows back
+
+The bundled `findByRequestId` covers the common "timeline of one call"
+query. For everything else, write your own queries on `ApiLogMapper` (in your
+project's own mapper that extends the bundled one, or in a separate mapper):
+
+```java
+@Mapper
+public interface MyApiLogQueries {
+
+ @Select("""
+ SELECT COUNT(*) FILTER (WHERE event_type = 'ERROR') * 100.0 / COUNT(*)
+ FROM api_log
+ WHERE endpoint = #{endpoint}
+ AND timestamp > NOW() - INTERVAL '1 hour'
+ """)
+ Double errorRateLastHour(String endpoint);
+}
+```
+
+The full JSONB query playbook (operators, GIN indexes, error rates) is in the
+[Querying logs guide](querying-logs.md) — backend-independent.
+
+## Overriding the writer
+
+To customize how rows are written, provide your own `ApiLogWriter` bean —
+the backend's `@ConditionalOnMissingBean(ApiLogWriter.class)` backs off:
+
+```java
+@Bean
+public ApiLogWriter customWriter(ApiLogMapper mapper, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ return new MaskingMybatisApiLogWriter(mapper, json, masker);
+}
+```
+
+Wrapping the bundled writer is usually enough:
+
+```java
+public class MaskingMybatisApiLogWriter implements ApiLogWriter {
+
+ private final ApiLogWriter delegate;
+ private final PayloadMasker masker;
+
+ public MaskingMybatisApiLogWriter(ApiLogMapper mapper, PayloadJsonMapper json,
+ PayloadMasker masker) {
+ this.delegate = new MybatisApiLogWriter(mapper, json);
+ this.masker = masker;
+ }
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ delegate.writeInitiated(masker.mask(event));
+ }
+ // ... same for writeSuccess / writeError
+}
+```
+
+## See also
+
+- [Querying logs](querying-logs.md) — JSONB operator recipes, GIN indexes,
+ error rates.
+- [Publishing events manually](publishing-events.md) — bring your own HTTP
+ client, just use the events.
+- [Retry handling](retry-handling.md) — `RETRY_ERROR` semantics, listener
+ log-write retries.
+- [Schema reference](../reference/schema.md) — column types, indexes, raw DDL.
diff --git a/docs/guides/r2dbc-backend.ko.md b/docs/guides/r2dbc-backend.ko.md
new file mode 100644
index 0000000..95edcc3
--- /dev/null
+++ b/docs/guides/r2dbc-backend.ko.md
@@ -0,0 +1,218 @@
+# R2DBC 백엔드
+
+R2DBC 백엔드 (`api-log-r2dbc`)는 리액티브 `ConnectionFactory`로 감사 행을
+씁니다 — JDBC 드라이버도 없고, 블로킹 I/O도 없습니다. Spring WebFlux + R2DBC
+스택에서 감사 로그도 리액티브 파이프라인에 그대로 흐르게 하고 싶을 때 (JDBC
+브리지로 빠지지 않게) 이걸 고르세요.
+
+## 언제 선택하나
+
+- 애플리케이션 스택이 WebFlux + R2DBC.
+- 런타임 classpath에 JDBC 드라이버를 명시적으로 두기 싫을 때.
+- Spring Data R2DBC 리포지토리 대신 `DatabaseClient`를 직접 쓰는 게 괜찮을
+ 때 — 의존성 footprint를 최소로 유지하기 위한 의도된 트레이드오프입니다.
+
+Servlet 스택이라면 JPA가 더 자연스럽습니다 — [`api-log-jpa`](jpa-backend.md)
+선택. 두 백엔드는 `api_log`에 동일한 행을 만듭니다.
+
+## 설치
+
+=== "Maven"
+
+ ```xml
+
+ kr.devslab
+ api-log-r2dbc
+ 0.6.0
+
+ ```
+
+=== "Gradle (Kotlin DSL)"
+
+ ```kotlin
+ implementation("kr.devslab:api-log-r2dbc:0.6.0")
+ ```
+
+`api-log-r2dbc`는 `api-log-core`와 `spring-r2dbc` (`DatabaseClient`),
+`r2dbc-postgresql` (runtime), `reactor-core`를 transitive하게 가져옵니다.
+**JDBC 의존성 없음** — Hibernate, HikariCP, `spring-jdbc`가 사용자 앱에서
+다른 경로로 들어오지 않는 한 classpath에 안 올라옵니다.
+
+PostgreSQL용 `ConnectionFactory` 빈은 별도로 필요합니다 — 가장 쉬운 방법은
+Spring Boot 자동 구성:
+
+```yaml title="application.yml"
+spring:
+ r2dbc:
+ url: r2dbc:postgresql://localhost:5432/your_db
+ username: your_user
+ password: your_password
+```
+
+## 자동으로 등록되는 빈
+
+`ConnectionFactory`가 classpath에 있고 `api.log.enabled=true`이면
+`ApiLogR2dbcAutoConfiguration`이 활성화되어 다음을 등록합니다:
+
+| 빈 | 역할 |
+| --- | --- |
+| `DatabaseClient` (`@ConditionalOnMissingBean`) | 사용자의 `ConnectionFactory`에서 구성 — Spring Boot가 이미 제공했다면 스킵 |
+| `R2dbcApiLogWriter` | 코어 리스너가 이벤트를 라우팅하는 `ApiLogWriter` 구현체 |
+| `ApiLogR2dbcSchemaInitializer` | Spring Boot의 `R2dbcScriptDatabaseInitializer`로 `V1.0__create_api_log.sql`을 리액티브하게 실행 (BUILTIN 모드만) |
+
+스키마 초기화는 `ConnectionFactory`와만 통신합니다 — **JDBC DataSource가
+필요 없음**, 부팅 시에도. v0.6.0에서 이 백엔드가 약속하는 핵심:
+완전 리액티브 `api_log` 설치.
+
+## 행이 어떻게 써지는가
+
+`R2dbcApiLogWriter`는 Spring Data 리포지토리를 거치지 않습니다.
+`DatabaseClient.sql(...)`로 파라미터화된 INSERT를 호출하고, fire-and-forget
+의미로 인라인 subscribe 합니다:
+
+```java
+spec.fetch()
+ .rowsUpdated()
+ .subscribe(
+ rows -> { /* success — 리스너가 이미 DEBUG로 로그 */ },
+ ex -> log.error("R2DBC api_log insert failed: requestId={}, eventType={}",
+ requestId, eventType, ex)
+ );
+```
+
+리스너는 반환된 리액티브 타입을 소비하지 않습니다 — 이벤트는 설계상
+fire-and-forget이고, @Async 래핑이 `Mono`를 의미 있게 전달해주지도 않습니다.
+구독 에러는 로깅되지만 절대 재throw 되지 않습니다 — 감사 행 하나를 잃는다고
+사용자의 outbound HTTP 호출이 망가지면 안 되니까요.
+
+### `::jsonb` 캐스트 없이 JSONB 바인딩
+
+JSONB 3개 컬럼 (`payload`, `response`, `error_message`)은 `R2dbcType.CLOB`
+(text)으로 바인딩됩니다:
+
+```java
+private static Object asJsonbParam(String value) {
+ return value == null
+ ? Parameters.in(R2dbcType.CLOB)
+ : Parameters.in(R2dbcType.CLOB, value);
+}
+```
+
+PostgreSQL R2DBC 드라이버가 컬럼 레벨에서 `TEXT → JSONB` 묵시적 캐스트를
+처리해주므로 SQL에서 `::jsonb` 캐스트가 필요 없습니다 — 향후 다른 리액티브
+다이얼렉트가 등장해도 INSERT는 그대로 휴대 가능합니다.
+
+## 스키마 관리
+
+기본값은 `api.log.schema.management=builtin`. 리액티브 초기화는 Spring Boot의
+`R2dbcScriptDatabaseInitializer`를 사용해서 첫 연결 시 번들된 DDL을 실행합니다.
+`IF NOT EXISTS` 덕분에 부팅 간 멱등합니다.
+
+```yaml title="application.yml"
+api:
+ log:
+ schema:
+ management: builtin # 기본값
+```
+
+**R2DBC에서는 Flyway 모드가 지원되지 않습니다.** Flyway는 JDBC `DataSource`를
+요구합니다; Flyway 관리 스키마가 필요한 리액티브 앱은:
+
+- Spring Boot 기본 Flyway 자동 구성을 R2DBC와 함께 설치 (Flyway는 마이그레이션
+ 전용으로 자체 JDBC 연결을 열고 — R2DBC 풀과 별개로 — 부팅 후 앱은 순수
+ 리액티브로 유지됨), 그리고 `spring.flyway.locations`에 `classpath:db/api-log`를
+ 직접 추가; 또는
+- 순수 리액티브가 hard requirement가 아니면 JPA 백엔드로 전환.
+
+`api.log.schema.management=none` (DDL 직접 적용)도 유효합니다:
+
+```yaml
+api:
+ log:
+ schema:
+ management: none
+```
+
+## 리액티브 HTTP 클라이언트와의 조합
+
+리액티브 백엔드는 [`ReactiveApiClientUtil`](reactive.md)과 자연스럽게
+짝지어집니다 — `Mono`를 반환하면서 `R2dbcApiLogWriter`가 소비하는
+이벤트를 발행합니다:
+
+```java
+@Service
+@RequiredArgsConstructor
+public class ChargeService {
+
+ private final ReactiveApiClientUtil api;
+
+ public Mono charge(ChargeRequest input) {
+ return api.postTyped("/charges", input, ChargeResult.class);
+ }
+}
+```
+
+End-to-end 리액티브: WebClient 호출 → 발행된 이벤트 → R2DBC writer →
+PostgreSQL. 요청 경로 어디에도 블로킹 hop이 없습니다.
+
+## Writer 오버라이드
+
+행 쓰기 방식을 커스터마이즈해야 할 때 (마스킹, 추가 컬럼, 다른 테이블 등)는
+직접 `ApiLogWriter` 빈을 정의 — 백엔드의
+`@ConditionalOnMissingBean(ApiLogWriter.class)`가 뒤로 빠집니다:
+
+```java
+@Bean
+public ApiLogWriter customWriter(DatabaseClient client, PayloadJsonMapper json) {
+ return new TenantAwareR2dbcApiLogWriter(client, json, tenantContext);
+}
+```
+
+위임 패턴이 일반적:
+
+```java
+public class TenantAwareR2dbcApiLogWriter implements ApiLogWriter {
+
+ private final ApiLogWriter delegate;
+ private final TenantContext tenants;
+
+ public TenantAwareR2dbcApiLogWriter(DatabaseClient client, PayloadJsonMapper json,
+ TenantContext tenants) {
+ this.delegate = new R2dbcApiLogWriter(client, json);
+ this.tenants = tenants;
+ }
+
+ @Override
+ public void writeInitiated(ApiCallInitiatedEvent event) {
+ delegate.writeInitiated(annotateTenant(event));
+ }
+ // ... writeSuccess / writeError도 동일
+}
+```
+
+## 로그 조회
+
+이 백엔드에는 리포지토리 추상화가 없습니다 — 행을 다시 읽어야 할 때는
+`DatabaseClient`를 직접 사용:
+
+```java
+public Flux