스토어 레퍼런스
다음은 @tanstack/ai-persistence의 공개 계약입니다. 필요한 스토어만 구현합니다.
미들웨어는 존재하는 스토어에 따라 동작을 활성화하므로 별도의 활성화 목록은 없습니다.
| 스토어 | 목적 | 사용처 |
|---|---|---|
messages | 스레드별 권위 있는 모델 메시지 기록입니다. | withPersistence, 필수 |
runs | 실행 상태, 시간 정보, 오류, 사용량입니다. | withPersistence |
interrupts | 대기 중이거나 해결 또는 취소된 사람의 대기입니다. runs가 필요합니다. | withPersistence |
metadata | 앱 및 통합의 키/값 상태입니다. | withPersistence |
generationRuns | 자체 runId로 식별되는 생성 실행 상태와 결과 메타데이터입니다. | withGenerationPersistence, 필수 |
artifacts | 파일 메타데이터입니다. blobs가 필요합니다. | withGenerationPersistence, 이식 가능한 스냅샷 |
blobs | 파일 바이트입니다. artifacts가 필요합니다. | withGenerationPersistence, 이식 가능한 스냅샷 |
채팅 스토어의 명명된 그룹(ChatTranscriptStores, ChatPersistenceStores,
ChatWithInterruptsStores)은 컨트롤에서 다룹니다.
runs는 이 패키지가 아니라 @tanstack/ai의 RunRecord 및 RunStore를 기준으로
타입이 지정됩니다. 하나의 정의를 사용하므로 채팅 영속성과 샌드박스 실행 드라이버가
하나의 실행에 대해 서로 다른 해석을 하지 않고 동일한 레코드를 키로 사용할 수 있습니다.
MessageStore
import type { ModelMessage } from '@tanstack/ai'
interface MessageStore {
loadThread(threadId: string): Promise<Array<ModelMessage>>
saveThread(threadId: string, messages: Array<ModelMessage>): Promise<void>
}
saveThread는 변경분이 아니라 전체 권위 모델 메시지 기록을 받습니다.
저장된 적 없는 스레드에 대해 loadThread는 []를 반환하며, null을 반환하지 않습니다.
RunStore
RunStore와 RunRecord는 @tanstack/ai에서 제공됩니다. @tanstack/ai-persistence가
둘을 다시 내보내므로 이 페이지의 샘플은 어느 패키지에서든 가져올 수 있습니다.
RunError는 @tanstack/ai에서만 내보냅니다.
import type { TokenUsage } from '@tanstack/ai'
type TerminalRunStatus = 'completed' | 'failed' | 'aborted'
type RunStatus = 'running' | 'interrupted' | TerminalRunStatus
// Why a run failed. `message` is the provider's prose, which changes between
// model versions and cannot be branched on; `code` is the stable
// classification a consumer switches over to retry, escalate, or show a
// specific UI. Providers do not always supply one, so `code` is optional.
interface RunError {
message: string
code?: string
}
interface RunRecord {
runId: string
threadId: string
status: RunStatus
startedAt: number // epoch ms
finishedAt?: number // epoch ms, set once the run reaches a terminal status
error?: RunError
usage?: TokenUsage // reported usage accumulated for this runId
// ---------------------------------------------------------------------------
// DURABLE SANDBOXED RUNS ONLY. A chat app never writes these four and nothing
// in `@tanstack/ai-persistence` reads them. Leave the columns out until you
// wire `withSandbox(sandbox, { runs, durability })`, and see
// [Build a Sandbox Adapter](./build-a-sandbox-adapter#the-four-run-fields)
// for what each one does and how to prove them.
// ---------------------------------------------------------------------------
sandboxKey?: string // which sandbox this run is bound to
detachedSince?: number // epoch ms since the last viewer left
cancelRequested?: boolean // an out-of-band cancel was recorded
driverEpoch?: number // fencing token, bumped by each host that claims the run
}
interface RunStore {
// Required: insert-if-absent. An existing runId returns the stored record
// unchanged, so resuming a run never resets its startedAt or status.
createOrResume(input: {
runId: string
threadId: string
status?: RunStatus
startedAt: number
}): Promise<RunRecord>
// Required: patching an unknown runId is a no-op, not an error.
update(
runId: string,
patch: Partial<
Pick<
RunRecord,
| 'status'
| 'finishedAt'
| 'error'
| 'usage'
| 'sandboxKey'
| 'detachedSince'
| 'cancelRequested'
| 'driverEpoch'
>
>,
): Promise<void>
// Required.
get(runId: string): Promise<RunRecord | null>
// Required. The most recent 'running' run for a thread (greatest
// `startedAt` wins), or null when the thread is idle. `reconstructChat`
// calls it to report `activeRun`, which is how a hydrating client tails a
// run that is still generating.
findActiveRun(threadId: string): Promise<RunRecord | null>
// Optional. Every run for a thread, ascending by startedAt. Only needed to
// render a thread's past agent activity.
listByThread?(threadId: string): Promise<Array<RunRecord>>
// Optional. Runs where status is 'running' and detachedSince <= now - ttlMs
// (inclusive). This is the query `reapDetachedRuns` (@tanstack/ai-sandbox)
// runs to find abandoned runs; scheduling that sweep is the app's job.
listReclaimable?(opts: {
now: number
ttlMs: number
}): Promise<Array<RunRecord>>
}
withPersistence는 동일한 runId에 대한 공급자 호출에서 보고된 숫자형 사용량 필드를
합산합니다. 불투명한 providerUsageDetails 필드에는 가장 최근에 보고된 값 묶음이
유지됩니다. 파악된 사용량은 실행이 인터럽트되거나 종료 상태에 도달할 때 영속화됩니다.
createOrResume, update, get, findActiveRun이 최소 요구사항입니다. 이 네 가지를
구현한 백엔드는 유효한 RunStore입니다. 지켜야 할 계약은 세 가지입니다.
createOrResume는 멱등적이어야 합니다. 기존runId에 대한 두 번째 호출은 변경 없이usage를 포함한 전체 저장 레코드를 반환합니다. 따라서 실행을 안전하게 재개할 수 있고 사용량을 계속 누적할 수 있습니다. 재시도에서는 같은 실행 ID를 반복할 수 있습니다.update에서 알 수 없는runId를 지정하면 아무 작업도 하지 않습니다.findActiveRun은 실제로 동작해야 합니다. 이를null을 반환하는 스텁으로 두면reconstructChat은 항상activeRun: null을 보고하므로, 다시 로드하거나 아직 생성 중인 스레드로 돌아온 클라이언트는 트랜스크립트는 복원하지만 실시간 응답을 재개하지 못합니다. 유휴 스레드에도null이 올바른 응답이므로 이를 감지할 수도 없습니다. 정확히 한 번의 릴리스 주기 동안만 선택 사항이었고 그 대가를 정확히 치렀기 때문에 이제는 필수입니다.
listByThread와 listReclaimable은 실제로 선택 사항이며 권장하면서 검사하는 항목이
아닙니다. 소비자는 각 메서드의 존재를 감지하고 없으면 기능을 축소하며, skipMethods에서
생략을 선언하면 적합성 테스트 모음도 해당 메서드의 테스트를 건너뜁니다. 앱에 필요한
메서드를 구현합니다.
listByThread를 생략하면 스레드의 이전 실행을 렌더링할 수 없습니다.listReclaimable을 생략하면 스토어를 회수할 수 없습니다.reapDetachedRuns는 이 메서드의 존재를 감지하고 한 줄을 기록한 뒤 아무것도 정리하지 않으므로, 분리된 실행은 완료 처리되지 않고 해당 샌드박스도 회수되지 않습니다.detachedSince는withSandbox의 분리 경로가 자동으로 채웁니다. 또한 회수기는 애플리케이션이 예약하는 함수이므로 이 메서드 구현만으로 충분하지는 않습니다. 옵션은 인계 및 분리된 실행을, 정리 자체와 이를 구동하는 일정은 회수 및 보존에서 확인합니다.
네 가지 내구성 실행 필드는 샌드박스 영역이므로 샌드박스 측에서 문서화하고 검증합니다.
샌드박스 어댑터 만들기.
runPersistenceConformance은 해당 필드를 검증하지 않으며, 채팅 전용 백엔드는 열을
완전히 생략할 수 있습니다. 내구성 있는 샌드박스 실행을 연결한다면 동일한
runs 스토어에 대해 @tanstack/ai-sandbox/testkit의
runDurableRunFieldsConformance를 실행합니다. 각 필드는 제거될 때 한 메커니즘을
조용히 깨뜨리므로, 경고 문단이 아니라 별도의 테스트 모음으로 검증합니다.
이 계약에는 두 가지 헬퍼도 함께 제공되며, 호출자가 캐스트를 사용할 필요가 없도록 존재합니다.
-
단순한
RunStatus문자열만으로 "이 실행이 계속 이벤트를 내보낼 수 있는가?"를 판단하면 이후에도 넓은 유니온을 처리해야 합니다.isTerminalRunStatus는 타입 술어이므로 가드 내부에서 상태가TerminalRunStatus가 되고, 해당 타입이 필요한 곳에 전달할 수 있습니다.import { isTerminalRunStatus } from '@tanstack/ai-persistence'
import type { RunRecord, TerminalRunStatus } from '@tanstack/ai-persistence'
function finalStatus(run: RunRecord): TerminalRunStatus | null {
return isTerminalRunStatus(run.status) ? run.status : null
} -
구현한 선택적 메서드를 자체 코드에서
?.로 호출할 필요는 없습니다.defineRunStore는 전달한 객체에 대해 제네릭이므로 결과가 해당 객체의 정확한 형태를 유지합니다.createRunStore(db).findActiveRun(threadId)는 채팅 워크스루에서 만든 스토어를 직접 호출하며, 일반RunStore를 가진 소비자는 여전히 같은 메서드의 존재를 감지합니다.
기능 수준은 메서드 수준이 아니라 스토어 수준에 속합니다. 실행 수명 주기가 실제로 없는
백엔드는 스텁 메서드가 있는 RunStore를 제공하는 대신 ChatTranscriptStores를 선언하고
runs를 완전히 생략해야 합니다. 없는 스토어는 타입 시스템이 잡지만, 불완전한 스토어는
런타임에서 조용히 실패합니다.
참조 구현인
packages/ai-persistence/src/memory.ts의 MemoryRunStore는 여섯 가지를 모두 구현합니다.
examples/ts-react-chat SQLite 어댑터(src/lib/sqlite-persistence.ts)는 네 가지 필수
메서드와 listReclaimable을 구현하고 runs.listByThread를 생략된 항목으로 선언합니다.
Interrupt스토어
interface InterruptRecord {
interruptId: string
runId: string
threadId: string
status: 'pending' | 'resolved' | 'cancelled'
requestedAt: number // epoch ms
resolvedAt?: number // epoch ms, set once resolved or cancelled
payload: Record<string, unknown>
response?: unknown
}
type InterruptCommitEntry =
| { interruptId: string; status: 'resolved'; response?: unknown }
| { interruptId: string; status: 'cancelled' }
interface InterruptStore {
create(record: Omit<InterruptRecord, 'status' | 'resolvedAt'>): Promise<void>
commitBatch?(entries: ReadonlyArray<InterruptCommitEntry>): Promise<void>
resolve(interruptId: string, response?: unknown): Promise<void>
cancel(interruptId: string): Promise<void>
get(interruptId: string): Promise<InterruptRecord | null>
list(threadId: string): Promise<Array<InterruptRecord>>
listPending(threadId: string): Promise<Array<InterruptRecord>>
listByRun(runId: string): Promise<Array<InterruptRecord>>
listPendingByRun(runId: string): Promise<Array<InterruptRecord>>
}
commitBatch는 선택 사항입니다. 이를 구현할 때는 모든 항목에 하나의 데이터베이스
트랜잭션을 사용합니다. 레거시 resolve 및 cancel 폴백은 순차적으로 실행되며
원자적이지 않습니다.
commitBatch를 구현한다면 어떤 항목에 중복된 interruptId가 있거나, 존재하지 않는
인터럽트를 지정하거나, 'pending'이 아닌 인터럽트를 지정할 때 전체 배치를 거부해야
합니다(예외를 발생시키고 아무것도 쓰지 않음). resolve와 cancel은 없는
interruptId에 대해 계속 아무 작업도 하지 않습니다.
create는 status/resolvedAt이 없는 레코드를 받으므로 모든 인터럽트는
'pending'으로 생성됩니다. 없는 경우에만 삽입하므로 중복 create가 이미 해결된
인터럽트를 덮어쓰지 않습니다. list* 메서드는 requestedAt 오름차순으로 정렬된
레코드를 반환합니다. 채팅 영속성과 함께 사용할 때 interrupts 스토어에는 runs
스토어가 필요합니다.
Metadata스토어
interface MetadataStore {
get(scope: string, key: string): Promise<unknown | null>
set(scope: string, key: string, value: unknown): Promise<void>
delete(scope: string, key: string): Promise<void>
}
네임스페이스와 값 스키마는 애플리케이션이 소유하며 (scope, key)가 복합 식별자입니다.
저장된 null은 타입 수준에서 부재와 구별할 수 없으므로, null로 영속화해야 하는 값은
{ value: null }처럼 감싸거나 위의 SQLite 스토어처럼 null 계열 값을 아예 거부합니다.
withPersistence는 핵심 MetadataCapability를 통해 이 스토어도 제공합니다.
미들웨어는 @tanstack/ai-persistence에 의존하지 않고 파생 상태에 이를 사용할 수
있습니다. 예를 들어 withCompaction은 검증된 컨텍스트 체크포인트를 여기에 저장합니다.
정본 트랜스크립트를 메타데이터에 저장하지 마십시오. 정본은 messages 스토어가
관리합니다.
생성Run스토어
RunStore에 대응하는 생성 스토어입니다. 자체 runId로 키를 지정하며, threadId는
findLatestForThread가 실행을 조회하는 슬롯입니다. withGenerationPersistence에는
runs가 아니라 이 스토어가 필요합니다.
이 스토어의 status는 채팅 실행의 RunStatus와 같은 용어를 사용하므로, 하나의 상태
열과 하나의 검사 집합으로 두 테이블을 모두 처리할 수 있습니다.
import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai'
// The same vocabulary as a chat run's `RunStatus`.
type GenerationRunStatus =
| 'running'
| 'interrupted'
| 'completed'
| 'failed'
| 'aborted'
interface GenerationRunRecord {
runId: string
threadId: string // the slot this run fills, hydrated by findLatestForThread
activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription'
provider: string
model: string
status: GenerationRunStatus
startedAt: number // epoch ms
finishedAt?: number // epoch ms, set once the run reaches a terminal status
error?: { message: string; code?: string }
result?: unknown // terminal result metadata (ids, urls), never media bytes
artifacts?: Array<PersistedArtifactRef> // present with an artifacts + blobs backend
usage?: TokenUsage
}
interface GenerationRunStore {
createOrResume(input: {
runId: string
activity: string
provider: string
model: string
startedAt: number
threadId: string
status?: GenerationRunStatus
}): Promise<GenerationRunRecord>
update(
runId: string,
patch: Partial<
Pick<
GenerationRunRecord,
'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage'
>
>,
): Promise<void>
get(runId: string): Promise<GenerationRunRecord | null>
// The most recent run filed under a thread (greatest `startedAt`), or null.
// Required: it is the only query that hydrates a generation, so an adapter
// without it would be indistinguishable from one whose thread has no runs:
// `persistence: true` would silently restore nothing, forever.
findLatestForThread(threadId: string): Promise<GenerationRunRecord | null>
}
createOrResume를 멱등적으로 구현합니다. 기존 runId에 대한 두 번째 호출은 저장된
레코드를 변경 없이 반환하며(startedAt / activity / provider / model /
threadId는 변경되지 않음), 이를 통해 실행을 안전하게 재개할 수 있습니다.
알 수 없는 runId에 대한 update는 아무 작업도 하지 않습니다.
Artifact스토어
영속화된 미디어의 메타데이터 행입니다. 바이트는 BlobStore에 저장되며, 이 레코드는
설명 메타데이터와 참조 전용 백엔드를 위한 선택적 sourceUrl을 보유합니다. 생성된
바이트를 보존하려면 BlobStore와 함께 제공합니다.
interface ArtifactRecord {
artifactId: string
runId: string
threadId: string
blobKey?: string // where the bytes live; absent on pre-blobKey records
name: string
mimeType: string
size: number
sourceUrl?: string // where the bytes were fetched FROM (provenance)
createdAt: number // epoch ms
}
interface ArtifactStore {
save: (record: ArtifactRecord) => Promise<void>
get: (artifactId: string) => Promise<ArtifactRecord | null>
list: (runId: string) => Promise<Array<ArtifactRecord>> // [] when the run has none
// Complete thread history, ordered by (createdAt, artifactId) ascending.
listForThread: (threadId: string) => Promise<Array<ArtifactRecord>>
delete: (artifactId: string) => Promise<void>
deleteForRun: (runId: string) => Promise<void>
}
list와 listForThread는 먼저 createdAt을 사용한 다음 artifactId의 서수 바이트
순서를 사용합니다. UTF-8 바이트를 왼쪽부터 비교하며 로케일 조합을 사용하지 않습니다.
Blob스토어
바이트를 위한 내구성 있는 객체/Blob 스토어입니다. withGenerationPersistence는
각 생성 파일을 artifacts/<runId>/<artifactId> 키 아래에 씁니다.
type BlobBody =
| ReadableStream<Uint8Array>
| ArrayBuffer
| ArrayBufferView
| string
| Blob
interface BlobRecord {
key: string
size?: number
etag?: string
contentType?: string
customMetadata?: Record<string, string>
createdAt?: number // epoch ms first written
updatedAt?: number // epoch ms last overwritten
}
interface BlobObject extends BlobRecord {
arrayBuffer(): Promise<ArrayBuffer>
text(): Promise<string>
body?: ReadableStream<Uint8Array>
// The slice served, when a range was requested. Absent on a whole read.
range?: { offset: number; length: number }
}
interface BlobListPage {
objects: Array<BlobRecord>
cursor?: string // present only when `truncated`
truncated?: boolean
}
interface BlobPutOptions {
contentType?: string
customMetadata?: Record<string, string>
// Exact byte length of `body`, when the producer knows it. Advisory: use it
// to pick an upload strategy (single-shot vs multipart), never as a
// substitute for counting the bytes you actually store.
expectedLength?: number
}
interface BlobRange {
offset: number // from the start of the object; must be inside it
length?: number // defaults to "to the end"; clamped when it overshoots
}
interface BlobGetOptions {
range?: BlobRange
}
interface BlobListOptions {
prefix?: string
cursor?: string
limit?: number
}
interface BlobStore {
put(key: string, body: BlobBody, options?: BlobPutOptions): Promise<BlobRecord>
get(key: string, options?: BlobGetOptions): Promise<BlobObject | null>
head(key: string): Promise<BlobRecord | null>
delete(key: string): Promise<void>
list(options?: BlobListOptions): Promise<BlobListPage>
}
list에서 지켜야 할 계약은 세 가지입니다.
prefix는 문자 그대로 대소문자를 구분하여 일치합니다. SQLLIKE메타문자는 이스케이프합니다.limit이 지정되고 일치하는 키가 더 많으면truncated: true와cursor를 반환합니다. 해당 커서를 다시 전달하면 바로 뒤에 오는 키를 반환하므로, 페이징으로 모든 키를 정확히 한 번씩 방문할 수 있습니다.limit: 0은 비어 있고 잘리지 않은 페이지를 반환합니다.
그리고 put에 관한 계약입니다. 본문은 선언된 길이가 없는 ReadableStream일 수
있습니다. 원본이 길이를 선언하지 않아 길이를 보장할 수 없는 경우 URL에서 가져온
아티팩트는 청크 응답 또는 압축된 바이트를 나타내는 content-length의 압축 응답으로
도착합니다. 원본이 사용 가능한 길이를 선언하면 본문은 fetch가 생성한 그대로 길이를
유지하며 도착하고 expectedLength에 같은 숫자가 담깁니다. 스토어는 길이 없는
스트림을 모두 읽어야 하며 처음부터 길이를 요구해서는 안 됩니다. 단일 업로드에 선언된
길이가 필요한 백엔드(예: workerd의 Cloudflare R2)는 expectedLength가 있으면 이를
다시 연결하고, 없으면 멀티파트 업로드로 스트리밍할 수 있습니다. 이 방법은
ai-persistence/build-cloudflare-artifact-store 스킬에 제공됩니다. 적합성 테스트 키트는
길이 없는 경우를 테스트하므로 바이트 본문만 처리하는 스토어는 테스트 모음에 실패합니다.
그리고 get에 관한 계약입니다. options.range를 준 경우 해당 슬라이스만 반환해야
합니다. size는 전체 객체의 크기를 계속 보고하고, 반환된 range는 실제로 제공한
범위를 보고합니다. 두 값이 함께 미디어 플레이어의 탐색에 필요한 206 응답을
구성합니다. resolveBlobRange(size, range)가 범위를 조정합니다(length가 끝을
넘어도 허용되며 조정되지만, offset이 끝을 넘으면 예외가 발생합니다. 제공 경로는
먼저 record.size로 416을 응답했어야 하기 때문입니다):
import { resolveBlobRange } from '@tanstack/ai-persistence'
async get(key: string, options?: BlobGetOptions) {
const row = await selectBlob(key)
if (!row) return null
if (!options?.range) return blobObject(row, row.body)
const served = resolveBlobRange(row.size, options.range)
// Slice at the storage layer, not after loading the whole object.
const bytes = await selectBlobSlice(key, served.offset, served.length)
return blobObject(row, bytes, served)
}
바이트를 보유하는 스토어에서는 이것이 선택 사항이 아니며 적합성 테스트 키트가 이를
검증합니다. range를 무시하고 전체 파일을 반환하면
<video> 탐색(및 Safari에서의 재생 자체)이 실패하며, 탐색할 때마다 전체 아티팩트를
조용히 전송합니다. 바이트를 저장하지 않는 참조 전용 백엔드는 대신 blobs를 완전히
생략합니다.
레코드의 관계
스레드는 자체 테이블이 아닙니다. 다른 레코드가 연결되는 thread_id로 존재합니다.
metadata는 (namespace, key)를 키로 하며 별도로 존재합니다. 생성 측에서는 실행이
먼저 자체 run_id로 키 지정되고 thread_id가 실행이 채우는 슬롯을 나타내는 비대칭에
유의해야 합니다. findLatestForThread는 이 슬롯을 기준으로 영속화합니다.
erDiagram
MESSAGES ||--o{ RUN : "thread_id, a thread has many runs"
RUN ||--o{ INTERRUPT : "run_id, a run may pause on interrupts"
MESSAGES ||..o{ GENERATION_RUN : "thread_id, the slot a run fills"
GENERATION_RUN ||--o{ ARTIFACT : "run_id, a run produces artifacts"
ARTIFACT ||--|| BLOB : "blob_key, the bytes"
MESSAGES {
string thread_id PK
json messages_json "full transcript, overwritten on save"
}
RUN {
string run_id PK
string thread_id
string status "running | completed | failed | interrupted"
int started_at
int finished_at
}
INTERRUPT {
string interrupt_id PK
string run_id
string thread_id
string status "pending | resolved | cancelled"
int requested_at
}
GENERATION_RUN {
string run_id PK
string thread_id "the slot this run fills"
string activity "image | audio | tts | video | transcription"
string status "running | completed | failed | interrupted"
}
ARTIFACT {
string artifact_id PK
string run_id
string blob_key "where the bytes live"
string mime_type
int size
}
BLOB {
string key PK
blob bytes
}
어댑터 타입 지정
각 스토어에는 define*Store 헬퍼(defineMessageStore, defineRunStore,
defineInterruptStore, defineMetadataStore, defineGenerationRunStore,
defineArtifactStore, defineBlobStore)가 있습니다. 이들을 defineAIPersistence로
조합하면 정확한 존재 여부를 추적합니다. 전달한 스토어는 persistence.stores에서
자동 완성되고, 전달하지 않은 스토어를 읽으면 컴파일 오류가 발생합니다.
이 일곱 키만 stores가 받습니다. 그 밖의 키는 생성 시
Unknown AIPersistence store key 오류를 발생시킵니다.
대신 값에 타입을 지정하려면 이름이 있는 형태를 사용합니다.
ChatPersistence: 네 가지 채팅 스토어 모두입니다.ChatTranscriptPersistence:messages기반 스토어입니다.AIPersistence: 모든 항목이 선택적인 묶음입니다.stores.messages가undefined일 수 있으므로withPersistence는 이를 거부합니다.
다음 단계
- 채팅 어댑터 만들기: SQLite를 대상으로 이 계약을 구현합니다.
- 생성 어댑터 만들기: 생성 부분을 다룹니다.
- 직접 어댑터 만들기: 적합성 테스트 모음으로 구현을 확인합니다.