# BackendV2Client

Synapse Backend **API v2** (`/v2/*`) 용 sync + async 클라이언트입니다.

v2 클라이언트는 기존 v1 클라이언트(`BackendClient`)와 나란히 존재하며 opt-in 방식입니다. 한 프로세스 안에서 둘을 함께 사용할 수 있습니다. v2 는 v1 과 세 가지 핵심 지점에서 다릅니다.

* **Cursor 페이지네이션** — `{next, previous, results}` (count 필드 없음).
* **Auth 헤더 포맷** — `SYNAPSE-ACCESS-TOKEN: syn_<token>` 와 raw `SYNAPSE-Tenant: <code>` (기본값에서 `Token` prefix 미사용).
* **리소스 속성 접근** — `client.list_projects(...)` 가 아닌 `client.projects.list(...)` 형식.

> Synapse Backend 릴리즈 **`v2026.1.6`** (API contract `2.0.0`) 을 타겟합니다. 백엔드가 새 호환 릴리즈를 배포하면 `make sync-v2-all` 로 스키마를 재동기화 하고 `synapse_sdk.clients.backend_v2.SYNAPSE_BACKEND_VERSION` 상수를 업데이트하세요.

## Overview[​](#overview "Overview에 대한 직접 링크")

v2 백엔드는 22 개 도메인에 걸쳐 102 paths / 149 operations 를 노출합니다. SDK 는 현재 **23 개 도메인** 을 구현합니다 — 주요 CLI 워크플로우에 필요한 P0 15 개 + Phase 3 에서 추가된 P1 8 개:

| Group          | Resources                                                                                             |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| Auth / infra   | `auth`, `tenants`, `tokens`, `schemas`                                                                |
| Data           | `data_collections` (+ nested `groups`), `data_units`, `data_files`                                    |
| ML             | `ground_truth_datasets` (+ nested `versions`), `ground_truths`, `models`, `experiments`               |
| Workflow       | `projects` (+ nested `tags`), `tasks`, `assignments`, `reviews`                                       |
| Workspace (P1) | `members`, `groups` *(top-level — data-collection groups 와 다름)*, `validation_scripts`, `workshops` |
| Execution (P1) | `jobs`, `job_logs`, `plugins`, `plugin_releases`                                                      |

전체 엔드포인트 카탈로그와 v1↔v2 diff 표 ([v1 ↔ v2 endpoint diff](#v1--v2-endpoint-diff) 참조) 는 [`INVENTORY.md`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/INVENTORY.md) 에 있습니다.

## Constructor[​](#constructor "Constructor에 대한 직접 링크")

```python
BackendV2Client(
    base_url: str,
    *,
    access_token: str | None = None,
    drf_token: str | None = None,
    tenant: str | None = None,
    tenant_token_prefix: bool = False,
    timeout: dict[str, int] | None = None,
    allow_insecure: bool = False,
)

```

### Parameters[​](#parameters "Parameters에 대한 직접 링크")

| Parameter             | Type                     | Required | Default                      | 설명                                                                               |
| --------------------- | ------------------------ | -------- | ---------------------------- | ---------------------------------------------------------------------------------- |
| `base_url`            | `str`                    | Yes      | –                            | 백엔드 호스트. `/v2` prefix 는 **포함하지 마세요** — 리소스가 자동으로 추가합니다. |
| `access_token`        | `str \| None`            | No       | `None`                       | `syn_*` access token. `SYNAPSE_BACKEND_V2_ACCESS_TOKEN` 환경변수로 폴백.           |
| `drf_token`           | `str \| None`            | No       | `None`                       | `/users/login/` 의 DRF token. `SYNAPSE_BACKEND_V2_DRF_TOKEN` 환경변수로 폴백.      |
| `tenant`              | `str \| None`            | No       | `None`                       | 테넌트 코드. `SYNAPSE_BACKEND_V2_TENANT` 환경변수로 폴백.                          |
| `tenant_token_prefix` | `bool`                   | No       | `False`                      | `True` 일 때 v1 호환을 위해 `SYNAPSE-Tenant: Token <code>` 형식으로 전송.          |
| `timeout`             | `dict[str, int] \| None` | No       | `{'connect': 5, 'read': 15}` | Connect / read timeout (초).                                                       |
| `allow_insecure`      | `bool`                   | No       | `False`                      | `base_url` 이 평문 HTTP 일 때 경고를 억제.                                         |

`AsyncBackendV2Client` 는 동일한 인자를 받습니다. 단, `timeout` 은 dict 대신 `httpx.Timeout` 인스턴스를 받습니다.

## Quick start[​](#quick-start "Quick start에 대한 직접 링크")

### Synchronous[​](#synchronous "Synchronous에 대한 직접 링크")

```python
from synapse_sdk.clients.backend_v2 import BackendV2Client

client = BackendV2Client(
    'https://api.test.synapse.sh',
    access_token='syn_xxxxx',
    tenant='acme',
)

# Cursor 페이지네이션 — 단일 페이지
page = client.projects.list(category='image', per_page=50)
for project in page.results:
    print(project.id, project.title)
if page.next:
    next_page = client.projects.list(cursor=page.next.split('cursor=')[-1])

# 모든 페이지 lazy 스트리밍
for project in client.projects.list(category='image', per_page=50, list_all=True):
    print(project.id, project.title)

```

### Asynchronous[​](#asynchronous "Asynchronous에 대한 직접 링크")

```python
import asyncio
from synapse_sdk.clients.backend_v2 import AsyncBackendV2Client

async def main() -> None:
    async with AsyncBackendV2Client(
        'https://api.test.synapse.sh',
        access_token='syn_xxxxx',
        tenant='acme',
    ) as client:
        page = await client.projects.list(per_page=50).first_page()
        async for project in client.projects.list(per_page=50):
            print(project.id, project.title)

asyncio.run(main())

```

## Authentication[​](#authentication "Authentication에 대한 직접 링크")

v2 백엔드는 네 가지 인증 스킴을 지원합니다. 클라이언트는 전달된 인자/환경 변수에 따라 헤더를 조합합니다.

| Header                 | Value                                        | Source                                                                                                |
| ---------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `SYNAPSE-ACCESS-TOKEN` | `syn_<token>`                                | `access_token=` 인자 또는 `SYNAPSE_BACKEND_V2_ACCESS_TOKEN` env                                       |
| `Authorization`        | `Token <drf>`                                | `drf_token=` 인자, `SYNAPSE_BACKEND_V2_DRF_TOKEN` env, 또는 `client.auth.login()` 반환값              |
| `SYNAPSE-Tenant`       | `<code>` (기본) 또는 `Token <code>` (legacy) | `tenant=` 인자 또는 `SYNAPSE_BACKEND_V2_TENANT` env. `tenant_token_prefix=True` 시 legacy prefix 적용 |

`tenant_token_prefix` 기본값 — v2-native

`api.test.synapse.sh` 에서 OQ-1 라이브 검증을 마친 이후 ([Open items pending live verification](#open-items-pending-live-verification) 참조), `tenant_token_prefix` 의 기본값은 `False` 입니다 — SDK 가 raw 테넌트 코드(`SYNAPSE-Tenant: <code>`) 를 전송합니다. dev 백엔드는 양쪽 포맷을 모두 수용하므로, 아직 `Token <code>` prefix 를 기대하는 v1 스타일 배포는 `tenant_token_prefix=True` 로 명시적으로 opt-in 할 수 있습니다.

```python
client = BackendV2Client(
    'https://api.example.com',
    access_token='syn_...',
    tenant='acme',
    tenant_token_prefix=True,   # ⇒ "SYNAPSE-Tenant: Token acme"
)

```

`mask_token()` 헬퍼는 시크릿을 로그-안전 형식으로 변환합니다.

```python
from synapse_sdk.clients.backend_v2 import mask_token

mask_token('syn_12345678901234')   # 'syn_***1234'
mask_token(None)                   # '<unset>'

```

### Login → access-token bootstrap[​](#login--access-token-bootstrap "Login → access-token bootstrap에 대한 직접 링크")

```python
client = BackendV2Client('https://api.test.synapse.sh', tenant='acme')

# 1. 자격증명을 DRF token 으로 교환 (클라이언트에 자동 저장).
client.auth.login(email='you@example.com', password='...')

# 2. 장기간 유효한 access token 발급.
created = client.tokens.create({'description': 'sdk-cli'})
print(created.token)   # 'syn_xxxxx' — 생성 시점에만 평문 반환

# 3. 클라이언트를 새 토큰으로 전환.
client.set_access_token(created.token)
client.set_drf_token(None)

```

## Pagination[​](#pagination "Pagination에 대한 직접 링크")

v2 백엔드는 DRF `CursorPagination` 을 사용합니다. transport 가 `{data, meta}` envelope 을 unwrap 한 뒤 ([Response envelope handling](#response-envelope-handling) 참조), 리소스 모듈은 다음 CursorPage 형태를 받습니다.

```json
{
  "next": "<cursor-or-null>",
  "previous": "<cursor-or-null>",
  "results": [...]
}

```

**`count` 필드는 없습니다**. 클라이언트는 페이지네이션을 다음과 같이 노출합니다.

| 호출 형태                                                        | 반환                                             |
| ---------------------------------------------------------------- | ------------------------------------------------ |
| `client.projects.list(per_page=50)`                              | `CursorPage[ProjectV2List]` (단일 페이지)        |
| `client.projects.list(per_page=50, list_all=True)`               | `Iterator[ProjectV2List]` (모든 페이지 스트리밍) |
| `await client.projects.list(per_page=50).first_page()` (async)   | `CursorPage[ProjectV2List]`                      |
| `async for project in client.projects.list(per_page=50)` (async) | 모든 페이지 스트리밍                             |

총 개수가 필요한 경우 사용자 코드에서 카운터를 직접 구현해야 합니다.

### Response envelope handling[​](#response-envelope-handling "Response envelope handling에 대한 직접 링크")

v2 백엔드는 모든 성공 응답을 `{"data": ..., "meta": ...}` envelope 으로 래핑합니다. transport (`BackendV2Client._request` / `AsyncBackendV2Client._request`) 는 모든 성공 페이로드에 대해 [`unwrap_envelope`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/envelope.py) 를 호출하여 리소스 모듈이 legacy 형태를 그대로 소비할 수 있게 합니다.

| Backend payload                                                                                            | Unwrap 후                                             |
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `{"data": [...], "meta": {"pagination": {"next_cursor": "...", "previous_cursor": null, "per_page": 50}}}` | `{"results": [...], "next": "...", "previous": null}` |
| `{"data": {"id": 1, ...}, "meta": {"request_id": "..."}}`                                                  | `{"id": 1, ...}`                                      |
| `{"data": null, "meta": {...}}`                                                                            | `null`                                                |
| 루트에 `data` 또는 `meta` 중 **하나라도** 누락된 페이로드                                                  | 변경 없이 통과 (forward-compat passthrough)           |

unwrap 은 **엄격하게 조건부** 입니다 — 루트에 `data` 와 `meta` 가 모두 존재할 때만 트리거됩니다. v1 스타일 페이로드, 픽스처, envelope 미적용 프록시는 그대로 통과합니다.

<!-- -->

`meta.request_id` 는 unwrap 이 `meta` 를 폐기하기 **전에** per-context 슬롯에 캡처되며, [`client.last_request_id`](#observability--last_request_id-trace) 와 [`client.capture_request_ids()`](#observability--last_request_id-trace) 로 로그 상관관계 추적이 가능합니다.

## Validation & Models[​](#validation--models "Validation & Models에 대한 직접 링크")

각 리소스는 OpenAPI 스키마 이름을 따른 Pydantic 모델을 반환합니다.

| Suffix                    | 용도                            |
| ------------------------- | ------------------------------- |
| `*V2List`                 | List 응답 (slim payload)        |
| `*V2Detail`               | Retrieve / create / update 응답 |
| `*V2CreateRequest`        | POST body                       |
| `Patched*V2CreateRequest` | PATCH body                      |

### `response_model=` / `request_model=` kwargs[​](#response_model--request_model-kwargs "response_model--request_model-kwargs에 대한 직접 링크")

transport 헬퍼 (`client._get`, `client._post`, `client._put`, `client._patch`, `client._delete` 와 async 대응 메서드) 는 두 개의 선택적 Pydantic kwarg 를 받습니다. 리소스 모듈이 `Model.model_validate(...)` 를 직접 호출하는 대신 이 kwarg 에 의존할 수 있습니다.

```python
# Before — 메서드마다 수동 검증
return ProjectV2Detail.model_validate(self._get(f'/v2/projects/{pid}/'))

# After — kwarg 기반 (리소스 메서드가 한 줄로 축약됨)
return self._get(f'/v2/projects/{pid}/', response_model=ProjectV2Detail)

```

```python
# POST 도 Pydantic 인스턴스를 직접 받을 수 있음
return self._post(
    '/v2/projects/',
    data=ProjectV2CreateRequest(title='demo'),     # sync 는 `data=`
    request_model=ProjectV2CreateRequest,
    response_model=ProjectV2Detail,
)
# Async 는 `data=` 대신 `json=` 을 사용합니다.

```

동작:

* `response_model=Model` — raw dict 가 아닌 완성된 `Model` 인스턴스를 반환.
* `request_model=Model` — Pydantic 인스턴스 **또는** dict 를 받으며, 둘 다 `model_dump(by_alias=True, exclude_none=True, mode='json')` 으로 직렬화됩니다. 이는 기존 [`resources/_helpers.model_dump`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/resources/_helpers.py) 헬퍼와 동일한 wire 규약입니다. caller 가 Pydantic 인스턴스를 보내든 dict 를 보내든 wire shape 은 동일합니다.
* **응답** 디코딩 중 발생한 `pydantic.ValidationError` 는 [`synapse_sdk.exceptions.ValidationError`](/ko/api/clients/base.md) 로 wrap 됩니다. 원본 예외는 `__cause__` 로 보존됩니다.
* **요청** 검증 중 발생한 `pydantic.ValidationError` 는 그대로 전파됩니다 — caller 가 제공한 데이터는 디버깅을 위해 pydantic 의 풍부한 에러 정보가 유용하기 때문입니다 (의도적 비대칭).
* 두 kwarg 모두 생략하면 transport 동작은 변경되지 않습니다.

Phase 2 마이그레이션 완료

SYN-6854 번들에서 read 와 write 도메인의 60+ 리소스 메서드 전부가 `response_model=` / `request_model=` kwarg 패턴으로 전환되었습니다. 호출자 시그니처와 wire shape 은 변하지 않았습니다 — 순수 내부 변경이지만, 이제 `pydantic.ValidationError` 가 SDK 호출 경로에서 누설되지 않습니다. [`synapse_sdk.exceptions.ValidationError`](/ko/api/clients/base.md) 를 catch 하고 raw pydantic detail 이 필요하면 `__cause__` 를 확인하세요.

보안 — 응답 페이로드는 `ValidationError.detail` 로 누설되지 않습니다

mixin 은 wrap 전에 `pydantic.ValidationError.errors(include_input=False)` 를 호출하여 각 에러 엔트리에서 raw 응답 본문을 제거합니다. 응답에는 시크릿 (예: `tokens.create()` 가 반환하는 평문 `syn_*` token) 이 포함될 수 있고, SDK 사용자는 종종 `ValidationError.detail` 을 외부 로깅 sink 로 전달합니다. 완전한 진단 정보는 `exc.__cause__` (원본 `pydantic.ValidationError`) 를 통해 그대로 접근할 수 있습니다.

```python
from synapse_sdk.exceptions import ValidationError as SdkValidationError
import pydantic

try:
    project = client.projects.retrieve(42)
except SdkValidationError as exc:
    # 단일 SDK 고유 예외 타입 — bare pydantic 은 절대 노출되지 않음
    print(exc.detail['model'])      # 'ProjectV2Detail'
    print(exc.detail['detail'])     # 에러 dict 리스트 ('input' 키 없음)
    if isinstance(exc.__cause__, pydantic.ValidationError):
        # 진단용 전체 pydantic detail (raw input 포함).
        for err in exc.__cause__.errors():
            print(err['loc'], err['msg'])

```

kwarg 경로는 [`BackendV2ValidationMixin`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/validation.py) 에 구현되어 있고 sync 와 async 클라이언트가 MRO 를 통해 공유합니다.

<!-- -->

## Error handling[​](#error-handling "Error handling에 대한 직접 링크")

`BackendV2Client` 는 v1 클라이언트와 동일한 표준 SDK 예외 계층 (`synapse_sdk.exceptions`) 을 raise 합니다.

```python
from synapse_sdk.exceptions import (
    AuthenticationError,
    AuthorizationError,
    NotFoundError,
    RateLimitError,
    ServerError,
    ValidationError,
)

try:
    project = client.projects.retrieve(42)
except NotFoundError:
    ...
except AuthenticationError:
    # 재로그인 또는 access token 갱신.
    ...
except ValidationError as e:
    # 400 / 422 — 백엔드 `detail` 페이로드가 예외에 보존됨.
    # `response_model=` 디코딩 실패 시에도 raise — Validation & Models 참조.
    ...

```

## Resource map[​](#resource-map "Resource map에 대한 직접 링크")

동일한 표면적이 `BackendV2Client` (sync) 와 `AsyncBackendV2Client` (async) 양쪽에서 제공됩니다.

### Top-level resources[​](#top-level-resources "Top-level resources에 대한 직접 링크")

| Attribute                      | Endpoints                          | Notable methods                                        |
| ------------------------------ | ---------------------------------- | ------------------------------------------------------ |
| `client.auth`                  | `POST /users/login/`               | `login(email, password, set_drf_token=True)`           |
| `client.tenants`               | `GET /v2/tenants/(...)`            | `list`, `retrieve(code)`                               |
| `client.tokens`                | `*/v2/tokens/(...)`                | `list`, `create`, `retrieve(id)`, `destroy(id)`        |
| `client.schemas`               | `GET /v2/schemas/...`              | `annotation_configurations()`, `file_specifications()` |
| `client.data_collections`      | `*/v2/data-collections/(...)`      | full CRUD + membership + nested `groups(dc_id)`        |
| `client.data_units`            | `*/v2/data-units/(...)`            | full CRUD + permission trio                            |
| `client.data_files`            | `GET /v2/data-files/(...)`         | `list`, `retrieve(id)`                                 |
| `client.ground_truth_datasets` | `*/v2/ground-truth-datasets/(...)` | full CRUD + nested `versions(dataset_id)`              |
| `client.ground_truths`         | `GET /v2/ground-truths/(...)`      | `list`, `retrieve(id)`                                 |
| `client.models`                | `GET /v2/models/(...)`             | `list`, `retrieve(id)`                                 |
| `client.experiments`           | `*/v2/experiments/(...)`           | full CRUD + membership family                          |
| `client.projects`              | `*/v2/projects/(...)`              | full CRUD + membership + nested `tags(project_id)`     |
| `client.tasks`                 | `*/v2/tasks/(...)`                 | full CRUD + permission trio                            |
| `client.assignments`           | `GET /v2/assignments/(...)`        | `list`, `retrieve(id)` + permission trio               |
| `client.reviews`               | `*/v2/reviews/(...)`               | `list`, `create`, `retrieve(id)` + permission trio     |
| `client.members`               | `*/v2/members/(...)`               | `list`, `retrieve(id)`, `create`                       |
| `client.groups`                | `*/v2/groups/(...)`                | full CRUD (top-level workspace groups)                 |
| `client.validation_scripts`    | `*/v2/validation-scripts/(...)`    | full CRUD                                              |
| `client.workshops`             | `*/v2/workshops/(...)`             | `list`, `retrieve(id)` + permission trio               |
| `client.jobs`                  | `GET /v2/jobs/(...)`               | `list`, `retrieve(id)` (UUID-keyed)                    |
| `client.job_logs`              | `GET /v2/job-logs/(...)`           | `list`, `retrieve(id)`                                 |
| `client.plugins`               | `GET /v2/plugins/(...)`            | `list`, `retrieve(id)`                                 |
| `client.plugin_releases`       | `GET /v2/plugin-releases/(...)`    | `list`, `retrieve(id)`                                 |

### Membership family[​](#membership-family "Membership family에 대한 직접 링크")

Top-level 리소스 (`projects`, `experiments`, `data_collections`) 는 다음을 노출합니다.

```python
.check_permission(id, permission='view')
.permissions(id)
.roles(id)
.grantable_roles(id)
.default_roles()
.invite(id, payload)
.get_join_requests(id)
.request_join(id, payload)
.confirm_join_request(id, payload)
.my_join_requests()
.my_join_request_detail(join_request_id)

```

### Permission trio[​](#permission-trio "Permission trio에 대한 직접 링크")

자식 / leaf 리소스 (`tasks`, `data_units`, `assignments`, `reviews`, `workshops`, nested `groups` *(data-collections 하위)*, nested `versions`, nested `tags`) 는 다음만 노출합니다.

```python
.check_permission(id, permission='view')
.permissions(id)
.roles(id)

```

### Membership helpers — typed payloads[​](#membership-helpers--typed-payloads "Membership helpers — typed payloads에 대한 직접 링크")

Top-level 리소스 (`projects`, `experiments`, `data_collections`) 의 membership 메서드는 mutating verb 에 대해 **타입이 지정된 payload 모델** 을 받습니다.

```python
from synapse_sdk.clients.backend_v2.models import (
    MemberRoleJoinRequestCreateRequest,
    PatchedMemberRoleJoinRequestConfirmRequest,
    TargetRoleCreateRequest,
)

# 1) 대상 사용자/그룹에 role 을 부여하면서 invite.
client.projects.invite(
    project_id,
    TargetRoleCreateRequest(target=42, role=7),
)

# 2) 기존 프로젝트 가입 요청.
client.projects.request_join(
    project_id,
    MemberRoleJoinRequestCreateRequest(role=7, message='please add me'),
)

# 3) 가입 요청 confirm / reject.
client.projects.confirm_join_request(
    project_id,
    PatchedMemberRoleJoinRequestConfirmRequest(confirm=True),
)

```

dict 를 그대로 전달해도 됩니다 — wire shape 은 동일합니다.

helpers 자체는 [`resources/_membership.py`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/resources/_membership.py) 에 두 `@runtime_checkable` Protocol (`SyncMembershipResource` / `AsyncMembershipResource`) 뒤에 위치합니다. 정적 타입 체커 (mypy, pyright) 와 런타임 가드가 sync↔async cross-wiring 을 거부하므로 `coroutine was never awaited` 가 코드 리뷰를 슬쩍 통과하지 못합니다.

### P1 도메인 quick examples[​](#p1-도메인-quick-examples "P1 도메인 quick examples에 대한 직접 링크")

```python
# Workspace members (top-level /v2/members/)
page = client.members.list(per_page=20)
member = client.members.retrieve(123)

# Workspace groups (top-level — data-collection groups 와 다름)
group = client.groups.create({'name': 'reviewers'})
client.groups.partial_update(group.id, {'name': 'qa-reviewers'})

# Validation scripts
script = client.validation_scripts.retrieve(7)

# Workshops (read-only + permission trio)
ws = client.workshops.retrieve(3)
client.workshops.check_permission(3, permission='view')

# Async jobs (UUID-keyed) 와 console logs
job = client.jobs.retrieve('00000000-0000-0000-0000-000000000001')
for log in client.job_logs.list(job=job.id, list_all=True):
    print(log.message)

# Plugins + releases (read-only)
client.plugins.list(per_page=10)
client.plugin_releases.retrieve(42)

```

## Common patterns[​](#common-patterns "Common patterns에 대한 직접 링크")

### 자동 생성 모델로 요청 본문 만들기[​](#자동-생성-모델로-요청-본문-만들기 "자동 생성 모델로 요청 본문 만들기에 대한 직접 링크")

Pydantic 모델은 [Validation & Models](#validation--models) 에 정리되어 있습니다. 빠른 예시:

```python
from synapse_sdk.clients.backend_v2.models import ProjectV2CreateRequest

payload = ProjectV2CreateRequest(
    title='New project',
    category='image',
    data_collection=10,
)
project = client.projects.create(payload)

```

dict 를 그대로 전달해도 됩니다 — 백엔드가 검증합니다.

### 환경변수로 부트스트랩[​](#환경변수로-부트스트랩 "환경변수로 부트스트랩에 대한 직접 링크")

```python
import os

os.environ['SYNAPSE_BACKEND_V2_ACCESS_TOKEN'] = 'syn_...'
os.environ['SYNAPSE_BACKEND_V2_TENANT']        = 'acme'

# 세 값 모두 생성자에서 생략 가능.
client = BackendV2Client('https://api.test.synapse.sh')

```

### 중첩 리소스 사용[​](#중첩-리소스-사용 "중첩 리소스 사용에 대한 직접 링크")

```python
# Data collection groups
client.data_collections.groups(dc_id=7).list(per_page=20)
client.data_collections.groups(dc_id=7).create({'name': 'g1'})

# Ground truth dataset versions
client.ground_truth_datasets.versions(dataset_id=3).list()

# Project tags
client.projects.tags(project_id=42).list(per_page=10)
client.projects.tags(project_id=42).create({'name': 'urgent', 'category': 'project'})

```

## Observability — `last_request_id` trace[​](#observability--last_request_id-trace "observability--last_request_id-trace에 대한 직접 링크")

성공한 모든 v2 응답에는 백엔드가 해당 요청에 대해 로깅하는 `meta.request_id` 가 포함됩니다. transport 는 [`unwrap_envelope`](#response-envelope-handling) 가 `meta` 를 폐기하기 **전에**, 이를 [`contextvars.ContextVar`](https://docs.python.org/3/library/contextvars.html) (설계상 async-safe / thread-safe) 에 캡처합니다. 두 가지 표면이 노출됩니다.

| Surface                                          | 반환          | 사용처                                                                                   |
| ------------------------------------------------ | ------------- | ---------------------------------------------------------------------------------------- |
| `client.last_request_id` (property)              | `str \| None` | 현재 컨텍스트에서 관측된 가장 최근 request\_id 를 읽기.                                  |
| `client.capture_request_ids()` (context manager) | `None`        | 진입 시 슬롯을 reset, 종료 시 외부 값을 복원 — 작업 블록을 자체 request\_id 범위로 한정. |

```python
project = client.projects.retrieve(42)
print(client.last_request_id)   # 예: 'req_01H8...'

with client.capture_request_ids():
    page = client.projects.list(per_page=20)
    log.info('listed', request_id=client.last_request_id)
# 외부 컨텍스트의 request_id 가 여기서 복원됩니다.

```

async 클라이언트도 같은 표면을 제공합니다 — `last_request_id` 와 `capture_request_ids()` 모두 `AsyncBackendV2Client` 에서 사용 가능합니다. `ContextVar` 가 `asyncio.Task` 컨텍스트 캡처에 참여하므로 `asyncio.gather(call_a(), call_b())` 는 race 가 발생하지 않습니다 — 각 태스크가 자체 `last_request_id` 를 봅니다.

<!-- -->

구현체: [`observability.py`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/observability.py). `record_request_id` 는 `unwrap_envelope` 와 동일한 게이트를 사용합니다 (루트 에 `data` 와 `meta` 가 모두 있을 때만 트리거) — envelope 미적용 응답은 슬롯 을 건드리지 않으므로 이전 `request_id` 값이 보존됩니다.

## CLI — `synapse v2 ...`[​](#cli--synapse-v2- "cli--synapse-v2-에 대한 직접 링크")

SDK 는 `BackendV2Client` 를 감싸는 read-only CLI sub-command 를 제공합니다 — 즉석 작업과 테넌트 smoke 테스트용입니다. 인증은 기존 `synapse login` 플로우 (`~/.synapse/config.json`) 와 공유됩니다.

```bash
synapse v2 projects list --tenant acme --per-page 20
synapse v2 projects retrieve 42 --tenant acme
synapse v2 tokens list --tenant acme
synapse v2 tokens retrieve 7 --tenant acme
synapse v2 tenants list
synapse v2 tenants retrieve 1

```

| Flag / env       | 용도                                                      |
| ---------------- | --------------------------------------------------------- |
| `--tenant`       | 테넌트 코드. `SYNAPSE_BACKEND_V2_TENANT` 환경변수로 폴백. |
| `--host`         | API 호스트. `synapse login` 의 호스트가 기본값.           |
| `--token` / `-t` | Access token. `synapse login` 의 토큰이 기본값.           |
| `--drf-token`    | Legacy DRF token. 보통 생략.                              |
| `--per-page`     | List 페이지 크기 (기본 `20`).                             |

list 명령은 Rich 테이블을 출력하고, `projects list` 는 추가로 `client.last_request_id` 를 echo 하므로 백엔드 로그 상관 추적이 셸 명령 한 번에 끝납니다. mutating verb (`create` / `update` / `destroy`) 는 follow-up PR 로 보류됐습니다 — 더 풍부한 입력 (JSON body, confirm prompt, 이중 인증) 이 필요해 smoke MVP 의 표면을 부풀립니다.

<!-- -->

CLI 는 [`synapse_sdk/cli/v2.py`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/cli/v2.py) 에 위치하며 `synapse_sdk.cli.main` 에서 `cli.add_typer(v2_app, name='v2')` 로 등록됩니다.

## Performance — lazy imports[​](#performance--lazy-imports "Performance — lazy imports에 대한 직접 링크")

`BackendV2Client` 는 import 비용이 가볍게 설계됐습니다. 두 레이어가 참여합니다.

1. **리소스 속성** — lazy `@property` 접근자입니다. `client.projects` 는 첫 사용 시점에만 리소스 객체를 생성합니다.
2. **Pydantic 모델** — [`synapse_sdk.clients.backend_v2.models`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/models/__init__.py) 는 자동 생성된 `_generated` 모듈을 [PEP 562](https://peps.python.org/pep-0562/) `__getattr__` 로 re-export 합니다. 1900 줄짜리 `_generated` 모듈 (137 개 pydantic 클래스) 은 import 시점이 아닌 **첫 attribute 접근 시점에** 로드됩니다.

```python
# Import-time 작업: 최소 — _generated load 없음.
from synapse_sdk.clients.backend_v2 import BackendV2Client

# _generated import 트리거 (1회, cold ~140ms).
from synapse_sdk.clients.backend_v2.models import ProjectV2List

```

`BackendV2Client` 만 사용하고 모델 클래스를 직접 import 하지 않는 워크플로 우는 \~140ms cold-import 비용을 절감합니다. 호출자 관점에서 lazy export 는 투명합니다 — `from synapse_sdk.clients.backend_v2.models import ProjectV2List` 는 이전과 정확히 동일하게 동작합니다.

## Optional dependencies[​](#optional-dependencies "Optional dependencies에 대한 직접 링크")

`email-validator` 는 Phase 4 에서 런타임 의존성에서 **제거** 되었습니다.

유일한 `EmailStr` 사용처 (`UserThumbnailInfo.email`) 는 read-only 응답 필드입니다 — 백엔드 검증이 권위적이므로 클라이언트 측 RFC 5322 강제는 가치가 없습니다. codegen 후 패치 (`scripts/sync_v2_schema.py` 의 `_patch_email_str`) 가 생성된 모든 `EmailStr` 어노테이션을 `str` 로 재작성 하면서 `max_length=254` 제약은 보존하므로, 모델은 `email-validator` 런타임 의존성 없이도 문서화된 길이를 계속 검증합니다.

`UserThumbnailInfo` 를 적대적 입력으로 직접 구성하면서 RFC 5322 검증이 필요한 워크플로우라면, `email-validator` 를 직접 설치해 값을 사전 검증 하세요. 대부분의 호출자는 이 작업이 필요하지 않습니다.

## v1 ↔ v2 endpoint diff[​](#v1--v2-endpoint-diff "v1 ↔ v2 endpoint diff에 대한 직접 링크")

`INVENTORY.md` 의 "Diff vs v1" 섹션은 `make sync-v2-all` (또는 단독 타겟 `make gen-v2-diff`) 가 자동 생성합니다. v1 경로는 `synapse_sdk/clients/backend/*.py` 에서 introspect 하고 v2 스키마와 비교 합니다 (매칭 전에 `/v2/` prefix 제거).

| Bucket      | Count | 의미                                                                                                                                          |
| ----------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **v1 only** | 31    | `BackendClient` 만 노출하는 엔드포인트 — v2 대응이 없음. v1 클라이언트를 계속 사용하세요 (agents, storages, legacy `serve_applications/` 등). |
| **v2 only** | 101   | v2 고유의 신규 표면 — membership / permission trio 라우트, workshops 등.                                                                      |
| **Shared**  | 5     | 양쪽에서 동일한 method+path (예: `GET /jobs/`, `POST /tasks/`). 편한 시점에 마이그레이션.                                                     |

전체 표는 [`INVENTORY.md`](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/INVENTORY.md#diff-vs-v1) 에 있습니다. 백엔드가 새 릴리즈를 배포할 때마다 `make sync-v2-all` 을 재실행하면 표가 in-place 로 업데이트됩니다.

## Migration from v1 (`BackendClient`)[​](#migration-from-v1-backendclient "migration-from-v1-backendclient에 대한 직접 링크")

| 항목                 | v1                                     | v2                                                                      |
| -------------------- | -------------------------------------- | ----------------------------------------------------------------------- |
| Pagination           | `count/next/previous/results` (offset) | `next/previous/results` (cursor; **count 없음**)                        |
| 페이지 크기 파라미터 | `page_size=`                           | `per_page=`                                                             |
| Tenant 헤더          | `Synapse-Tenant: Token <code>`         | `SYNAPSE-Tenant: <code>` (기본; `tenant_token_prefix=True` 로 opt-back) |
| Access token 헤더    | `Synapse-Access-Token: Token <token>`  | `SYNAPSE-ACCESS-TOKEN: syn_<token>`                                     |
| 메서드 형태          | flat (`client.list_projects(...)`)     | namespaced (`client.projects.list(...)`)                                |
| 응답                 | raw dict                               | typed Pydantic model (`*V2List` / `*V2Detail`)                          |

두 클라이언트는 공존 가능합니다. v2 엔드포인트가 사용 워크로드에 가용해질 때마다 호출 지점을 점진적으로 전환하세요.

## Schema sync & drift handling[​](#schema-sync--drift-handling "Schema sync & drift handling에 대한 직접 링크")

Pydantic 모델과 `INVENTORY.md` 는 캐시된 OpenAPI 스키마 (`schema/openapi.yaml`) 에서 파생됩니다. 백엔드가 새 릴리즈를 배포하면 다음 Make 타겟으로 갱신하세요.

```bash
make sync-v2-schema       # 스키마만 다운로드 + 캐시
make gen-v2-models        # Pydantic 모델 재생성
make gen-v2-inventory     # INVENTORY.md 재생성
make sync-v2-all          # 전부 실행

```

스크립트 위치: [`scripts/sync_v2_schema.py`](https://github.com/datamaker/synapse-sdk/blob/main/scripts/sync_v2_schema.py). 기본적으로 `api.test.synapse.sh` 에서 다운로드합니다 — `--host` 로 override 가능.

### 라이브 백엔드와 OpenAPI 의 drift (Path γ)[​](#라이브-백엔드와-openapi-의-drift-path-γ "라이브 백엔드와 OpenAPI 의 drift (Path γ)에 대한 직접 링크")

dev 백엔드가 OpenAPI 스키마에 인코딩되어 있지 않거나 잘못 인코딩된 형태를 때때로 반환하는 경우가 있습니다. 이 때 생성된 모델을 직접 손대는 대신, SDK 는 **codegen + override 하이브리드** ("Path γ") 를 사용합니다.

1. `make gen-v2-models` 가 캐시된 스키마를 단일 출처로 삼아 `datamodel-codegen` 을 실행합니다.
2. `scripts/sync_v2_schema.py` 가 생성된 트리에 선언적 build-time override 를 적용합니다.
3. 각 override 는 자가 진단됩니다 — 상위 스키마가 보정되면 override 가 `SystemExit` 를 raise 하여 contributor 가 drift 가 해소되었음을 인지하고 override 를 제거할 수 있도록 합니다.

현재 트래킹 중인 override:

| Drift                                                                                                     | 위치                                                            | Override                                                            |
| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
| `Category10` enum 의 `data` 멤버 누락 (라이브 백엔드는 `category=data` 를 반환하지만 스키마가 누락)       | `_patch_enum_drift` 의 `_ENUM_DRIFT = {'Category10': ['data']}` | codegen 후 누락된 enum 멤버 추가                                    |
| `JobV2Detail.completed` / `JobV2List.completed` 가 `nullable+required` 로 선언되었으나 스키마 본문에 누락 | 동일 스크립트의 모델 patcher                                    | 속성을 `Optional[...]` 로 격상하여 라이브 `null` 페이로드 검증 통과 |

새 override 추가 시

스크립트 헤더에 상위 이슈를 문서화하고, 자가 진단 가드 (`SystemExit` 스타일 assertion) 를 포함하여 스키마가 수정되는 즉시 override 가 코드베이스에서 스스로 제거되도록 하세요. 문서화되지 않은 장기 override 는 안티패턴입니다.

## Open items pending live verification[​](#open-items-pending-live-verification "Open items pending live verification에 대한 직접 링크")

다음 항목들은 자격증명이 필요한 dev 백엔드 액세스를 요구합니다. 클라이언트는 안전한 기본값으로 출하되지만, 테넌트 환경이 다르다면 연결된 노브를 조정하세요.

* **OQ-1** *(해결됨 — SYN-6854)* — `SYNAPSE-Tenant` 값 형식. dev 백엔드는 raw 테넌트 코드와 `Token <code>` 를 모두 수용합니다. SDK 기본값은 raw 값 (`tenant_token_prefix=False`) 입니다. v1 스타일 배포는 `tenant_token_prefix=True` 로 opt-back 가능합니다.
* **OQ-2** *(보류 — 라이브 데이터 필요)* — `per_page` 상한. dev 백엔드가 1 \~ 10 000 모든 페이지 크기에 대해 빈 결과 셋과 함께 200 을 반환하여 실제 cap 을 아직 관측할 수 없습니다. SDK 는 cap 을 강제하지 않습니다 — `specs/backend-v2-followup-roadmap/` 에서 트래킹.
* **OQ-3** *(해결됨 — SYN-6854)* — `tokens.create()` 평문 token 노출. dev 백엔드가 create 응답에 평문 `syn_*` token 을 포함하여 반환합니다 (`AccessTokenCreateResponse.token: SecretStr | None`).

## See also[​](#see-also "See also에 대한 직접 링크")

* [BackendClient (v1)](/ko/api/clients/backend.md)
* [BaseClient / AsyncBaseClient](/ko/api/clients/base.md)
* [Module README](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/README.md)
* [Endpoint INVENTORY](https://github.com/datamaker/synapse-sdk/blob/main/synapse_sdk/clients/backend_v2/INVENTORY.md)
