snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
---
title: API
icon: plug
description: 워크스페이스 스키마에서 생성된 REST 및 GraphQL API.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
## 테넌트별 스키마 API
Twenty에는 정적 API 참조 문서가 없습니다. 각 워크스페이스는 고유한 스키마를 가집니다 — 사용자 지정 객체(예: `Invoice`)를 추가하면, `Company` 또는 `Person`과 같은 기본 제공 객체와 동일한 REST 및 GraphQL 엔드포인트가 즉시 생성됩니다. API는 스키마에서 생성되므로 엔드포인트에서 객체와 필드 이름을 직접 사용합니다 — 의미를 알 수 없는 ID는 없습니다.
워크스페이스별 API 문서는 API 키 생성 후 **설정 → API 및 웹훅**에서 확인할 수 있습니다. 데이터에 대해 실제 호출을 실행할 수 있는 대화형 플레이그라운드가 포함되어 있습니다.
## 두 가지 API
**핵심 API** — `/rest/` 및 `/graphql/`
레코드에 대한 CRUD: People, Companies, Opportunities, 사용자 지정 객체. 쿼리, 필터링, 관계 탐색.
**메타데이터 API** — `/rest/metadata/` 및 `/metadata/`
스키마 관리: 객체, 필드, 관계 생성/수정/삭제. 이것이 데이터 모델을 프로그래밍 방식으로 변경하는 방법입니다.
둘 다 REST와 GraphQL로 사용할 수 있습니다. GraphQL은 배치 업서트와 단일 쿼리에서 관계를 탐색하는 기능을 제공합니다. 어떤 방식을 사용하든 동일한 기본 데이터입니다.
## 기본 URL
| 환경 | 기본 URL |
| ------ | ------------------------- |
| 클라우드 | `https://api.twenty.com/` |
| 셀프 호스팅 | `https://{your-domain}/` |
## 인증
```
Authorization: Bearer YOUR_API_KEY
```
**Settings → API & Webhooks → + Create key**에서 API 키를 생성하세요. 즉시 복사하세요 — 한 번만 표시됩니다. 키는 **Settings → Members → Roles → Assignment tab**에서 특정 역할에 범위를 지정하여 액세스 가능한 범위를 제한할 수 있습니다.
<VimeoEmbed videoId="928786722" title="API 키 생성" />
사용자 대신 동작하는 외부 앱을 위한 OAuth 기반 액세스에 대해서는 [OAuth](/l/ko/developers/extend/oauth)를 참고하세요.
## 배치 작업
REST와 GraphQL 모두 요청당 최대 60개의 레코드에 대한 배치 처리를 지원합니다 — 생성, 업데이트 또는 삭제. GraphQL은 `CreateCompanies`와 같은 복수형 이름을 사용하여 배치 업서트(한 번의 호출로 생성 또는 업데이트)도 지원합니다.
## 속도 제한
| 제한 | 값 |
| ----- | ------------ |
| 요청 | 분당 100회 호출 |
| 배치 크기 | 호출당 60개의 레코드 |

View File

@@ -0,0 +1,124 @@
---
title: 애플리케이션 구성
description: "`defineApplication`을 사용하여 앱의 식별 정보, 기본 역할, 변수 및 마켓플레이스 메타데이터를 선언합니다."
icon: rocket
---
모든 앱에는 `defineApplication` 호출이 정확히 하나 있어야 합니다. 다음 항목을 선언합니다:
* **식별 정보** — 범용 식별자, 표시 이름, 설명.
* **권한** — 로직 함수와 프런트 컴포넌트가 어떤 역할로 실행되는지.
* **변수** *(선택 사항)* — 코드에 환경 변수로 노출되는 키–값 쌍.
* **설치 전/설치 후/제거 훅** *(선택 사항)* — [Logic Functions](/l/ko/developers/extend/apps/logic/logic-functions)를 참조하세요.
```ts src/application-config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
displayName: 'My Twenty App',
description: 'My first Twenty app',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Jane Doe',
isSecret: false,
},
},
});
```
노트:
* `universalIdentifier` 필드는 여러분이 소유하는 변하지 않는 고유 ID입니다. 한 번만 생성하고 이후 동기화 동안에도 변하지 않도록 유지하세요.
* `applicationVariables`는 함수와 프런트 컴포넌트의 환경 변수가 됩니다. 로직 함수(서버 사이드)에서는 `process.env.VARIABLE_NAME`으로 사용할 수 있습니다. 프런트 컴포넌트에서는 `twenty-sdk/front-component`의 `getApplicationVariable('VARIABLE_NAME')`을 사용하세요. `isSecret: true`로 표시된 변수는 로직 함수에만 주입됩니다. 프런트 컴포넌트는 비밀이 아닌 변수만 받습니다.
* 기본 역할은 [`defineApplicationRole()`](/l/ko/developers/extend/apps/config/roles)로 표시된 역할 파일에서 자동으로 감지되므로, `defineApplication()`에서 이를 참조할 필요가 없습니다.
* 설치 전, 설치 후, 제거 함수는 매니페스트 빌드 중 자동으로 감지됩니다 — `defineApplication()`에서 별도로 참조할 필요가 없습니다.
* 하위 호환성을 위해 `defaultRoleUniversalIdentifier`를 명시적으로 전달하는 방식도 계속 지원되지만, 이제는 `defineApplicationRole()` 사용을 권장하며 이전 방식은 더 이상 권장되지 않습니다.
* `serverVariables`는 인스턴스 범위의 구성 및 비밀(예: API 키)입니다. `applicationVariables`와 달리, 매니페스트에는 값을 선언하지 않으며, 워크스페이스 운영자가 앱 설정에서 값을 채워 넣으면 설정된 이후에만 로직 함수에 주입됩니다.
* 두 종류의 변수 모두 `isDeprecated: true`를 허용합니다. 변수를 삭제하는 대신 사용 중지할 때 사용하십시오. 키 선언을 유지하면 저장된 값이 보존되고(삭제하면 운영자가 입력한 값이 사라집니다), 변수는 여전히 주입되므로 코드에서 `process.env.NEW_API_KEY ?? process.env.API_KEY`로 폴백할 수 있습니다. 더 이상 사용되지 않는 변수는 값이 없어지면 앱 설정에서 사라지며, 앱의 구성 검사에 절대 포함되지 않습니다. 따라서 `isDeprecated`가 `isRequired`보다 우선합니다.
* 앱의 **Settings** 탭에서 기본 변수 구성 섹션 대신 사용자 지정 구성 UI를 렌더링하려면, 별도의 파일에서 [`defineSettingsFrontComponent()`](/l/ko/developers/extend/apps/layout/front-components#custom-settings-component)를 사용하여 프론트 컴포넌트를 선언하세요. 앱당 하나만 허용됩니다. 시스템에서 관리하는 섹션(auto-upgrade, App URL, connections)은 항상 표시된 상태로 유지됩니다.
## 변수 유형
`applicationVariables`와 `serverVariables`는 모두 선택적인 `type`을 허용하며, `SELECT` / `MULTI_SELECT`의 경우 `options` 목록을 허용합니다. 지원되는 타입: `TEXT`(기본값), `BOOLEAN`, `NUMBER`, `NUMERIC`, `DATE`, `DATE_TIME`, `SELECT`, `MULTI_SELECT`, `ARRAY`, `RAW_JSON`, `RICH_TEXT`.
```ts src/application-config.ts
import { defineApplication, FieldType } from 'twenty-sdk/define';
export default defineApplication({
// ...identity, role...
applicationVariables: {
MAX_POSTCARDS: {
universalIdentifier: '5f4497e4-9030-4085-85eb-2c48b8d53713',
description: 'Maximum postcards per batch',
type: FieldType.NUMBER,
value: 10,
},
DEFAULT_REGION: {
universalIdentifier: '76c5c321-b6b6-46eb-b4fc-f9f04bb04227',
description: 'Default shipping region',
type: FieldType.SELECT,
options: [
{ label: 'Europe', value: 'eu' },
{ label: 'United States', value: 'us' },
],
value: 'eu',
},
},
});
```
`type`은 **표현과 검증**에만 영향을 줍니다. 즉, 워크스페이스 설정 UI에서 해당하는 입력 요소(토글, 숫자 필드, 드롭다운, 날짜 선택기, JSON 편집기 등)를 선택합니다. 또한 빌드가 구성(config)을 검증하도록 합니다(예를 들어 `SELECT` / `MULTI_SELECT`는 비어 있지 않은 `options`를 선언해야 합니다). 값이 코드로 전달되는 방식은 **변경되지 않습니다**.
값은 **항상 문자열로 주입**됩니다. 이는 환경 변수의 특성 때문입니다(`process.env.*`는 문자열만 허용). 로직 함수가 실행될 때, 실행기는 선언된 `type`에 따라 각 값을 직렬화하여 `process.env`를 구성하므로, 값이 어떻게 설정되었는지(매니페스트 기본값, 설정 UI, 또는 이전 버전)와 관계없이 문자열 형식이 일관되게 유지됩니다:
| 유형 | `process.env` 문자열 |
| ------------------------------------- | --------------------------------- |
| `TEXT`, `SELECT`, `DATE`, `DATE_TIME` | 원시 값(`"eu"`, `"2026-01-01"`) |
| `BOOLEAN` | `"true"` / `"false"` |
| `NUMBER`, `NUMERIC` | 10진수 문자열(`"10"`, `"2.5"`) |
| `MULTI_SELECT`, `ARRAY` | JSON 배열(`'["email","postcard"]'`) |
| `RAW_JSON`, `RICH_TEXT` | JSON 객체(`'{"retries":3}'`) |
문자열을 다시 기대하는 타입으로 파싱하세요:
```ts
const maxCards = Number(process.env.MAX_POSTCARDS); // "10" -> 10
const enabled = process.env.ENABLE_TRACKING === 'true'; // "true" -> true
const channels = JSON.parse(process.env.ENABLED_CHANNELS ?? '[]'); // '["email"]' -> ["email"]
const config = JSON.parse(process.env.PROVIDER_CONFIG ?? '{}'); // '{"retries":3}' -> { retries: 3 }
```
`getApplicationVariable('VARIABLE_NAME')`을 통해 값을 읽는 프런트 컴포넌트에도 동일하게 적용됩니다. 반환되는 값은 문자열이므로, 필요에 따라 파싱해야 합니다.
## 기본 함수 역할
[`defineApplicationRole()`](/l/ko/developers/extend/apps/config/roles)로 선언된 역할은 앱의 로직 함수와 프런트엔드 컴포넌트가 무엇에 접근할 수 있는지를 제어합니다:
* 로직 함수에 주입되는 런타임 토큰은 이 역할에서 파생됩니다. 사용자 권한으로 작동하는 호출은 해당 사용자가 할 수 있는 작업으로 더욱 제한되므로, 어느 쪽의 권한도 초과할 수 없습니다. [호출이 사용하는 액세스 권한](/l/ko/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses)을 참조하세요.
* 타입드 API 클라이언트는 해당 역할에 부여된 권한으로 제한됩니다.
* 최소 권한 원칙을 따르세요: 함수에 필요한 권한만 선언하세요.
새 앱을 스캐폴딩하면 CLI가 `src/roles/default-role.ts`에 시작용 역할 파일을 생성합니다. 전체 내용은 [Roles & Permissions](/l/ko/developers/extend/apps/config/roles)를 참조하세요.
## 마켓플레이스 메타데이터
앱을 [게시](/l/ko/developers/extend/apps/operations/publishing)할 계획이라면, 다음 선택적 필드로 마켓플레이스에서 표시되는 방식을 제어합니다:
| 필드 | 설명 |
| ------------------ | ------------------------------------------------------------------- |
| `author` | 작성자 또는 회사 이름 |
| `category` | 마켓플레이스 필터링을 위한 앱 카테고리 |
| `logo` | `public/`에 번들된 앱 로고의 경로(예: `public/logo.png`) |
| `galleryImages` | `public/`에 번들된 갤러리 이미지 경로의 배열(예: `public/screenshot-1.png`) |
| `aboutDescription` | "About" 탭에 대한 더 긴 마크다운 설명. 생략하면 마켓플레이스는 npm의 패키지 `README.md`를 사용합니다 |
| `websiteUrl` | 웹사이트 링크 |
| `termsUrl` | 서비스 약관 링크 |
| `emailSupport` | 지원 이메일 주소 |
| `issueReportUrl` | 이슈 트래커 링크 |
<Note>
`logoUrl` 및 `screenshots`는 `logo` 및 `galleryImages`의 더 이상 사용되지 않는 별칭입니다. 다음 필드에는 외부 절대 URL(`http://` 또는 `https://`)이 지원되지 않습니다. 빌드 시 경고와 함께 제거됩니다. 대신 앱의 `public/` 폴더에 이미지를 번들하세요.
</Note>

View File

@@ -0,0 +1,188 @@
---
title: 설치 훅
description: 설치, 업그레이드 또는 제거 라이프사이클 동안 로직을 실행하여 데이터를 시드하고, 레코드를 백업하고, 업그레이드를 검증하고, 외부 리소스를 정리합니다.
icon: wrench
---
설치 훅은 설치, 업그레이드 또는 제거 라이프사이클 동안 실행되는 특수한 로직 함수입니다. 이들은 일반 [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions)와 동일한 핸들러 런타임을 공유하지만, 자체 define 함수로 선언되며 일반 트리거 모델(HTTP, cron, 데이터베이스 이벤트) 외부에서 동작합니다. 설치 훅은 `InstallPayload` (`{ previousVersion?: string; newVersion: string }` — 새로 설치하는 경우 `previousVersion`은 `undefined`임)을 받고, 제거 훅은 제거되는 버전을 나타내는 `UninstallPayload` (`{ version?: string }`)을 받습니다.
각 앱은 각 훅(pre-install, post-install, uninstall)을 **최대 하나씩만** 정의할 수 있습니다. 어떤 종류든 둘 이상 감지되면 매니페스트 빌드에서 오류가 발생합니다.
```
┌─────────────────────────────────────────────────────────────┐
│ install flow │
│ │
│ upload package → [pre-install] → metadata migration → │
│ generate SDK → [post-install] │
│ │
│ old schema visible new schema visible │
└─────────────────────────────────────────────────────────────┘
```
## 한눈에 보기
| | `definePreInstallLogicFunction` | `definePostInstallLogicFunction` |
| ------- | ------------------------------------------------- | ------------------------------------------------------------------------------ |
| 실행 | 메타데이터 마이그레이션 이전 — **이전** 스키마와 데이터는 그대로 유지됨 | 마이그레이션 및 SDK 생성 이후 — **새로운** 스키마가 적용됨 |
| 실행 | 항상 동기식; 설치를 차단함 | 기본적으로 비동기(대기열에 등록, 최대 3회 재시도); `shouldRunSynchronously: true`로 동기 실행을 선택할 수 있음 |
| 실패 시 | 스키마 변경 이전에 설치가 **중단**됨 | 비동기: 최대 3회까지 재시도됩니다. 동기: 호출자는 `POST_INSTALL_ERROR`를 받음(스키마 변경은 **롤백되지 않습니다**) |
| 일반적인 사용 | 마이그레이션으로 손실될 데이터를 백업하거나 수정함; 예외를 던져 위험한 업그레이드를 거부 | 기본 데이터 시딩, 워크스페이스 구성, 외부 리소스 등록 |
**기본 원칙:** 기본적으로 post-install을 사용하세요. 마이그레이션 자체가 파괴적이며 이전 상태가 사라지기 전에 이를 가로채야 할 때에만 pre-install을 사용하세요.
| 원하는 작업... | 사용 |
| -------------------------------- | --------------------------------------------------- |
| 데이터 시딩, 워크스페이스 구성, 외부 리소스 등록 | `post-install` |
| 설치 응답을 차단해서는 안 되는 장시간 작업 | `post-install` (기본 비동기 모드, 워커 재시도 포함) |
| 설치 호출이 반환된 직후 호출자가 즉시 의존하는 빠른 설정 | `shouldRunSynchronously: true`를 사용하는 `post-install` |
| 곧 진행될 마이그레이션으로 손실될 데이터를 읽거나 백업 | `pre-install` |
| 기존 데이터를 손상시킬 업그레이드를 거부 | `pre-install` (핸들러에서 예외를 던짐) |
| 모든 업그레이드 시 상태 조정 수행 | `shouldRunOnVersionUpgrade: true`가 설정된 어느 훅이든 사용 |
## 두 훅에 공통으로 적용되는 동작
* 구성은 트리거 설정을 제외한 `defineLogicFunction` 구성에 `shouldRunOnVersionUpgrade`가 추가된 형태입니다.
* **실행 시점**: 기본적으로 신규 설치에서만 실행됩니다. 업그레이드 시에도 실행하려면 `shouldRunOnVersionUpgrade: true`를 설정합니다. 업그레이드 경로에 따라 분기하기 위해 `previousVersion` / `newVersion`을 사용합니다.
* **멱등성이 중요합니다**: 비동기 post-install은 재시도될 수 있고, `shouldRunOnVersionUpgrade`가 켜져 있으면 두 훅 모두 업그레이드 시 다시 실행됩니다.
* 일반적인 로직 함수 환경(`APPLICATION_ID`, `APP_ACCESS_TOKEN`, `API_URL`)이 주입되므로, 앱의 토큰으로 Twenty API를 호출할 수 있습니다.
* 훅은 빌드 시 애플리케이션 매니페스트에 자동으로 연결됩니다(`preInstallLogicFunction` / `postInstallLogicFunction`) — [`defineApplication()`](/l/ko/developers/extend/apps/config/application)에서 참조할 것은 없습니다.
* 기본 `timeoutSeconds`는 데이터 시딩과 같은 더 긴 설정 작업을 허용하기 위해 300으로 설정되어 있습니다.
* **dev 모드에서는 실행되지 않음**: `yarn twenty dev`는 설치 플로우를 건너뛰고 파일을 직접 동기화하므로, 해당 환경에서는 훅이 전혀 실행되지 않습니다. 대신 수동으로 트리거하세요:
```bash filename="Terminal"
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
```
<AccordionGroup>
<Accordion title="definePostInstallLogicFunction" description="워크스페이스 메타데이터 마이그레이션이 적용된 후에 실행됩니다.">
앱 설치가 완료된 후 한 번 실행됩니다: 메타데이터 동기화 완료, SDK 클라이언트 생성, 새로운 스키마 쿼리 가능 상태. 예시 — 신규 설치에서 기본 레코드를 시딩하기:
```ts src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion }: InstallPayload): Promise<void> => {
if (previousVersion) return; // fresh installs only
const client = new CoreApiClient();
await client.mutation({
createPostCard: {
__args: { data: { name: 'Welcome to Postcard', content: 'Your first card!' } },
id: true,
},
});
};
export default definePostInstallLogicFunction({
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
name: 'post-install',
description: 'Seeds a welcome post card after install.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: false,
shouldRunSynchronously: false,
handler,
});
```
`shouldRunSynchronously` 플래그가 실행 모델을 제어합니다:
* `false` *(기본값)* — 메시지 큐에 등록되고(`retryLimit: 3`), 워커에 의해 실행됩니다. 작업이 큐에 등록되면 설치 응답이 즉시 반환됩니다. **장시간 작업에 사용** — 대용량 데이터셋 시딩, 지연이 긴 서드파티 API 호출 등.
* `true` — 설치 플로우 중에 인라인으로 실행됩니다. 설치 요청은 핸들러가 종료될 때까지 블로킹되며, 예외가 발생하면 호출자에게 `POST_INSTALL_ERROR`로 전달됩니다(재시도 없음). **빠르고, 응답 전에 반드시 완료되어야 하는 작업에 사용하세요.** 이 시점에는 이미 마이그레이션이 적용되었으므로, 실패하더라도 스키마 변경은 롤백되지 않고 오류만 노출됩니다.
</Accordion>
<Accordion title="definePreInstallLogicFunction" description="워크스페이스 메타데이터 마이그레이션이 적용되기 전에 실행됩니다.">
메타데이터 마이그레이션 이전, **이전** 스키마를 대상으로 실행됩니다 — 마이그레이션으로 손실될 데이터를 백업하거나, 위험한 업그레이드를 거부하기에 적절한 위치입니다. 실행에 앞서, 서버는 순수 추가식의 "간소화된 동기화"를 수행하여 새 버전의 pre-install 함수만 등록하고, 나머지 — 이전 버전의 오브젝트, 필드, 데이터 — 는 핸들러가 실행될 때까지 변경하지 않습니다.
pre-install은 항상 **동기식**이며 설치를 차단합니다. 핸들러에서 예외를 던지면, 어떤 스키마 변경도 적용되기 전에 설치가 중단되며 — 워크스페이스는 일관된 상태로 이전 버전에 머무릅니다. 이는 의도된 동작입니다: pre-install은 위험한 업그레이드를 거부할 수 있는 마지막 기회입니다.
예시 — 마이그레이션이 기존 필드를 삭제하기 전에 해당 필드 값을 복사하기:
```ts src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async ({ previousVersion, newVersion }: InstallPayload): Promise<void> => {
// Only the 1.x → 2.x upgrade drops the legacy `notes` field.
if (!previousVersion?.startsWith('1.') || !newVersion.startsWith('2.')) {
return;
}
const client = new CoreApiClient();
const { postCards } = await client.query({
postCards: {
__args: { filter: { notes: { isNot: null } } },
edges: { node: { id: true, notes: true } },
},
});
// Copy legacy `notes` into `description` before the migration drops the
// column. If this fails, the upgrade aborts and the workspace stays on v1.
for (const { node } of postCards.edges) {
await client.mutation({
updatePostCard: {
__args: { id: node.id, data: { description: node.notes } },
id: true,
},
});
}
};
export default definePreInstallLogicFunction({
universalIdentifier: 'a1b2c3d4-5678-90ab-cdef-1234567890ab',
name: 'pre-install',
description: 'Backs up legacy notes into description before the v2 migration.',
timeoutSeconds: 300,
shouldRunOnVersionUpgrade: true,
handler,
});
```
</Accordion>
</AccordionGroup>
## 제거 훅
`defineUninstallLogicFunction`은 사용자가 앱을 제거할 때 실행되는 훅을 선언합니다. 이 훅은 앱의 메타데이터, 데이터, 코드가 제거되기 **이전**에 실행됩니다. 삭제 마이그레이션이 실행된 후에는 더 이상 실행할 것이 남지 않으므로, 핸들러는 여전히 앱의 오브젝트와 레코드를 쿼리할 수 있습니다. 이를 외부 리소스 정리에 사용하십시오. 예를 들어 API 리소스 프로비저닝 해제, 남은 봇 삭제, 웹훅 해지에 사용할 수 있습니다.
노트:
* 이 훅은 최대한 시도(best-effort) 방식으로 동작합니다. 동기적으로 실행되지만, 실패해도 로그에만 기록되고 **제거를 차단하지 않습니다**. 정리 작업 때문에 앱을 제거할 수 없게 만들어서는 안 됩니다.
* 이 훅은 제거되는 버전을 나타내는 `UninstallPayload` (`{ version?: string }`)을 받습니다.
* 새 설치가 실패하여 롤백될 때는 이 훅이 **실행되지 않습니다**. 앱 설치가 끝까지 완료되지 않았기 때문입니다.
* 앱이 제거된 후에는 이 훅을 실행할 수 없으므로, 앱 데이터(예: 레코드에 저장된 봇 ID)에 의존하는 외부 정리 작업은 외부 예약 작업이 아니라 여기에서 수행해야 합니다.
* 설치 훅과 마찬가지로, 이 훅은 **개발 모드에서는 실행되지 않습니다**. 대신 수동으로 트리거해야 합니다:
```bash filename="Terminal"
yarn twenty dev:function:exec --uninstall
```
```ts src/logic-functions/uninstall.ts
import { defineUninstallLogicFunction, type UninstallPayload } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (_payload: UninstallPayload): Promise<void> => {
const client = new CoreApiClient();
const { meetingBots } = await client.query({
meetingBots: { edges: { node: { id: true, externalBotId: true } } },
});
// Delete the provider-side bots so nothing keeps recording after uninstall.
for (const { node } of meetingBots.edges) {
await fetch(`https://api.recorder.example/bots/${node.externalBotId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.RECORDER_API_KEY}` },
});
}
};
export default defineUninstallLogicFunction({
universalIdentifier: 'b2c3d4e5-6789-01bc-def0-234567890abc',
name: 'uninstall',
description: 'Deletes remaining recorder bots when the app is uninstalled.',
timeoutSeconds: 300,
handler,
});
```

View File

@@ -0,0 +1,56 @@
---
title: 개요
description: 앱 자체를 구성합니다. 앱의 ID, 기본 권한, 그리고 설치 시점에 무엇이 실행되는지를 설정합니다.
icon: screwdriver-wrench
---
Twenty 앱의 **구성 레이어(config layer)** 는 앱의 ID, 보유한 권한, 설치나 업그레이드 시 실행되는 코드를 포함해, 앱을 *플랫폼에 어떻게 설명할지* 정의합니다. 이 선언들은 새로운 데이터 구조나 런타임 동작을 추가하지 않습니다. 대신 Twenty에게 앱이 무엇인지와 어떻게 설정해야 하는지를 알려줍니다.
```text
┌────────────────────────────────────────────────────────┐
│ Application — identity, default role, variables, │
│ marketplace metadata │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Role — what the app's logic functions can read │ │
│ │ and write (referenced by Application) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
│
▼ (at install / upgrade time)
┌──────────────────────────────────┐
│ Pre-install hook │ before metadata migration
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Post-install hook │ after metadata migration
└──────────────────────────────────┘
│
▼ (at uninstall time)
┌──────────────────────────────────┐
│ Uninstall hook │ before app removal
└──────────────────────────────────┘
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="애플리케이션 구성" icon="rocket" href="/l/ko/developers/extend/apps/config/application">
`defineApplication` — ID, 기본 역할, 변수, 마켓플레이스 메타데이터를 정의합니다.
</Card>
<Card title="역할 및 권한" icon="shield-halved" href="/l/ko/developers/extend/apps/config/roles">
`defineRole` — 앱의 로직 함수가 무엇을 읽고 쓸 수 있는지 선언합니다.
</Card>
<Card title="설치 훅" icon="wrench" href="/l/ko/developers/extend/apps/config/install-hooks">
`definePreInstallLogicFunction`, `definePostInstallLogicFunction`, 그리고 `defineUninstallLogicFunction` — 데이터를 백업하고, 기본값을 시드하며, 업그레이드를 검증하고, 제거 시 정리를 수행합니다.
</Card>
</CardGroup>
## 구성 요소 간의 관계
* **애플리케이션**이 진입점입니다. 모든 앱에는 정확히 한 번의 `defineApplication()` 호출이 있으며, 이 호출은 하나의 \*\*역할(Role)\*\*을 기본값으로 가리킵니다.
* \*\*역할(Role)\*\*은 앱의 로직 함수와 프런트 컴포넌트가 무엇을 읽고 쓸 수 있는지를 제어합니다. 최소 권한 원칙을 따르세요. 코드에 실제로 필요한 권한만 부여하세요.
* \*\*설치 훅(Install Hooks)\*\*은 설치나 업그레이드 중에 실행됩니다. 메타데이터 마이그레이션 이전에 실행되는 사전 설치 훅은 위험한 업그레이드를 거부할 수 있고, 마이그레이션 이후에 실행되는 사후 설치 훅은 새 스키마에 맞춰 기본 데이터를 시드할 수 있습니다. 제거 훅은 앱이 제거되기 직전에 실행되므로, 앱의 데이터를 아직 읽을 수 있을 때 외부 리소스를 정리할 수 있습니다.
<Note>
설치 훅은 [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) 런타임을 공유합니다. 동일한 핸들러 시그니처, 동일한 환경 변수, 동일한 타입이 지정된 API 클라이언트를 사용하지만, 자체 define 함수로 선언되고 일반 트리거 모델(HTTP, cron, 데이터베이스 이벤트) 외부에서 동작합니다.
</Note>

View File

@@ -0,0 +1,67 @@
---
title: 퍼블릭 에셋
description: 정적 파일(이미지, 아이콘, 폰트 등)을 public/ 폴더를 통해 앱과 함께 제공합니다.
icon: folder-open
---
앱 루트의 `public/` 폴더에는 정적 파일이 있습니다 — 이미지, 아이콘, 폰트 또는 런타임에 필요한 기타 에셋. 이 파일들은 빌드에 자동 포함되고, 개발 모드에서 동기화되며, 서버로 업로드됩니다.
`public/`에 있는 파일은 다음과 같은 특성을 가집니다:
* **공개적으로 접근 가능** — 서버로 동기화되면 에셋은 공개 URL로 제공됩니다. 접근하려면 인증이 필요하지 않습니다.
* **프런트 컴포넌트에서 사용 가능** — React 컴포넌트 내부에서 이미지, 아이콘 또는 미디어를 표시하기 위해 에셋 URL을 사용하세요.
* **로직 함수에서 사용 가능** — 이메일, API 응답 또는 서버 측 로직에서 에셋 URL을 참조하세요.
* **마켓플레이스 메타데이터에 사용** — `defineApplication()`의 `logo` 및 `galleryImages` 필드는 이 폴더의 파일을 참조합니다(예: `public/logo.png`). 앱이 게시되면 이러한 항목이 마켓플레이스에 표시됩니다. 이 필드에서 외부 절대 URL은 무시되므로, 대신 이미지를 `public/` 폴더에 번들링하세요. 각 이미지는 10 MB를 초과해서는 안 됩니다.
* **개발 모드에서 자동 동기화** — `public/`에 파일을 추가, 업데이트 또는 삭제하면 자동으로 서버와 동기화됩니다. 재시작이 필요 없습니다.
* **빌드에 포함** — `yarn twenty dev:build`는 모든 퍼블릭 에셋을 배포 출력물에 번들합니다.
## `getPublicAssetUrl`로 퍼블릭 에셋에 접근하기
`twenty-sdk`의 `getPublicAssetUrl` 헬퍼를 사용해 `public/` 디렉터리의 파일 전체 URL을 가져오세요. 이는 로직 함수와 프런트 컴포넌트 모두에서 동작합니다.
**로직 함수에서:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
// Fetch the file content (no auth required — public endpoint)
const response = await fetch(invoiceUrl);
const buffer = await response.arrayBuffer();
return { logoUrl, size: buffer.byteLength };
};
export default defineLogicFunction({
universalIdentifier: 'a1b2c3d4-...',
name: 'send-invoice',
description: 'Sends an invoice with the app logo',
timeoutSeconds: 10,
handler,
});
```
**프런트 컴포넌트에서:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
`path` 인수는 앱의 `public/` 폴더를 기준으로 합니다. `getPublicAssetUrl('logo.png')`과 `getPublicAssetUrl('public/logo.png')`는 동일한 URL로 해석됩니다 — `public/` 접두사가 있으면 자동으로 제거됩니다.

View File

@@ -0,0 +1,204 @@
---
title: 역할 및 권한
description: 앱의 로직 함수와 프런트 컴포넌트가 어떤 객체와 필드를 읽고 쓸 수 있는지 선언합니다.
icon: shield-halved
---
\*\*역할(role)\*\*은 권한 집합입니다. 즉, 앱이 어떤 객체를 읽거나 쓸 수 있는지, 어떤 필드를 볼 수 있는지, 그리고 어떤 플랫폼 수준 기능을 사용할 수 있는지를 정의합니다. 모든 앱의 로직 함수와 프런트 컴포넌트는 `defineApplicationRole()`로 표시된 역할의 권한을 상속받습니다(아래 [기본 함수 역할](#the-default-function-role)을 참조하세요).
```ts src/roles/restricted-company-role.ts
import {
defineRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
} from 'twenty-sdk/define';
export default defineRole({
universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
label: 'My new role',
description: 'A role that can be used in your workspace',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
```
## 행 수준 보안
오브젝트 및 필드 권한은 역할이 *어떤 오브젝트와 필드* 를 다룰 수 있는지를 결정합니다. **행 수준
권한 프레디킷** 은 더 나아가 역할이 *어떤 레코드* 를 보고 작업할 수 있는지를 결정합니다. 예를 들어, 각 외부 사용자가 자신의 레코드만 볼 수 있는 셀프 서비스 역할 같은 경우입니다.
역할에 `rowLevelPermissionPredicates` 로 프레디킷을 선언합니다. 매니페스트의 나머지와 마찬가지로,
각 프레디킷은 자체 `universalIdentifier` 를 가지고, 오브젝트와 필드를 각각의
`universalIdentifier`, `operand`, 그리고 (선택적으로) 쿼리 시점에 값이 주입되는 workspaceMember 필드를 통해 참조합니다. 이렇게 하면 "레코드의 owner relation **is** 현재 workspace member이다"를 표현할 수 있습니다:
```ts src/roles/partner-role.ts
import {
defineRole,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field';
export default defineRole({
universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343',
label: 'Partner',
description: 'External partner — sees only its own records',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier,
},
],
});
```
프레디킷은 매니페스트에 함께 포함되어 배포되므로, 모든 설치와 업그레이드 시 역할과 함께 생성·업데이트·제거됩니다. 동기화를 유지하기 위한 별도의 설치 후 단계는 필요하지 않습니다.
### 프레디킷을 그룹과 결합하기
기본적으로 역할의 프레디킷은 `AND`로 결합됩니다. 일부를 `OR`로 결합하거나(또는 논리를 중첩하려면) `rowLevelPermissionPredicateGroups` 항목을 선언하고 각 프레디킷이 `predicateGroupUniversalIdentifier`를 통해 해당 그룹을 가리키도록 설정합니다. 이 역할은 파트너가 자신이 **소유한** 또는 **담당자**인 기회(Opportunity)를 볼 수 있도록 허용합니다:
```ts src/roles/partner-opportunities-role.ts
import {
defineRole,
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity;
const CURRENT_MEMBER =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier;
export default defineRole({
universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222',
label: 'Partner (opportunities)',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
{
universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier:
OPPORTUNITY.fields.pointOfContact.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
],
});
```
노트:
* 모든 프레디킷과 그룹에 안정적인 `universalIdentifier`(임의의 uuid)를 부여하세요. 이는 업그레이드 전반에 걸쳐 엔티티의 키로 사용되며, 프레디킷은 이를 통해 그룹을 참조합니다.
* 프레디킷은 앱에서 소유한 오브젝트와 필드뿐만 아니라 Twenty의 표준 오브젝트가 소유한 오브젝트와 필드를 참조할 수 있습니다.
* 행 수준 보안은 이를 포함한 플랜의 워크스페이스에 대해 적용됩니다. 다른 플랜에서도 프레디킷은 계속 동기화되지만, 적용되지는 않습니다.
## 기본 함수 역할
새 앱을 스캐폴딩하면, CLI가 `defineApplicationRole()`로 선언된 기본 역할 파일을 생성합니다:
```ts src/roles/default-role.ts
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [],
});
```
`defineApplicationRole()`는 설치 시 애플리케이션의 기본 역할로 사용할 역할을 표시하는, `defineRole()`에 대한 간단한 래퍼입니다. 검증 방식은 `defineRole`과 동일하지만, 빌드 파이프라인이 애플리케이션 매니페스트의 `defaultRoleUniversalIdentifier`에 해당 역할의 `universalIdentifier`를 자동으로 연결하므로, [`defineApplication`](/l/ko/developers/extend/apps/config/application)에서 직접 참조할 필요가 없습니다.
노트:
* 앱당 정확히 **하나의** `defineApplicationRole(...)`만 허용되며, 둘 이상 발견되면 매니페스트 빌드가 실패합니다.
* 앱에서 제공하는 **추가** 역할에는 `defineApplicationRole()`가 아니라 `defineRole()`을 사용하세요.
* 호환성을 위해 `defineApplication()`에서 `defaultRoleUniversalIdentifier`를 명시적으로 설정하는 방식도 여전히 지원되지만, 이제는 `defineApplicationRole()` 방식이 권장되며 이전 방식은 더 이상 권장되지 않습니다.
## 모범 사례
* 스캐폴딩된 역할에서 시작한 다음 점진적으로 권한을 제한하세요. 기본 설정은 광범위한 읽기 액세스를 부여하는데, 이는 프로덕션 환경에서 원하는 경우가 거의 없습니다.
* `objectPermissions`와 `fieldPermissions`를 함수에 실제로 필요한 정확한 객체와 필드로 교체하세요.
* `permissionFlagUniversalIdentifiers`는 플랫폼 수준 기능에 대한 액세스를 제어합니다. 최소한으로 유지하세요.
* 동작 예제를 참조하세요: [`examples/hello-world/src/roles/default-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/hello-world/src/roles/default-role.ts).

View File

@@ -0,0 +1,52 @@
---
title: 객체 확장하기
description: 표준 Twenty 객체(Person, Company 등)에 필드를 추가합니다. 또는 `defineField`를 사용해 다른 앱의 객체에 필드를 추가합니다.
icon: wand-magic-sparkles
---
`defineField()`를 사용해 소유하지 않은 객체(예: Person이나 Company 같은 표준 Twenty 객체 또는 다른 설치된 앱에서 제공하는 객체)에 필드를 추가합니다. [`defineObject`](/l/ko/developers/extend/apps/data/objects) 안에 선언된 인라인 필드와 달리, 독립형 필드는 어느 객체를 확장할지 지정하기 위해 `objectUniversalIdentifier`가 필요합니다.
```ts src/fields/company-loyalty-tier.field.ts
import { defineField, FieldType } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
name: 'loyaltyTier',
type: FieldType.SELECT,
label: 'Loyalty Tier',
icon: 'IconStar',
options: [
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
],
});
```
## 핵심 요점
* `objectUniversalIdentifier`는 대상 객체를 식별합니다. 표준 Twenty 객체의 경우 `twenty-sdk`에서 상수를 가져옵니다:
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier
// …
```
* `defineObject()`에서 필드를 **인라인으로 정의할 때는**, `objectUniversalIdentifier`가 **필요하지 않습니다** — 상위 객체에서 상속됩니다.
* `defineField()`는 `defineObject()`로 생성하지 않은 객체에 필드를 추가하는 유일한 방법입니다.
* 파일 위치는 자유롭게 정할 수 있습니다. 규칙으로는 `src/fields/\<name>.field.ts`를 사용하지만, SDK는 `src/` 어디에서든 필드를 감지합니다.
* `writability`는 필드별로도 설정할 수 있습니다. `MetadataWritability.APPLICATION`은 앱 자체의 로직 함수에서만 쓰기가 가능하도록 제한하고, `MetadataWritability.SYSTEM`은 플랫폼에서 관리하는 데이터에 예약되어 있습니다. 필드의 쓰기 가능 여부는 해당 객체의 수준보다 더 엄격하게만 설정할 수 있으며, 읽기 동작에는 영향을 주지 않습니다. 전체 의미는 [Objects](/l/ko/developers/extend/apps/data/objects)를 참조하세요.
* 표준 페이지 레이아웃(예: Task 또는 Company 상세 페이지)에 탭을 추가하려면, `twenty-sdk/define`의 `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS`와 함께 [`definePageLayoutTab`](/l/ko/developers/extend/apps/layout/page-layouts#definepagelayouttab)을(를) 사용하십시오.
## 기존 객체에 관계 추가하기
관계 필드(예: 사용자 정의 객체를 표준 `Person`에 연결)를 추가하려면, `FieldType.RELATION`과 함께 `defineField()`를 사용하세요. 패턴은 인라인 관계와 동일하지만, `objectUniversalIdentifier`를 명시적으로 설정합니다. 양방향 패턴은 [Relations](/l/ko/developers/extend/apps/data/relations)를 참조하세요.

View File

@@ -0,0 +1,143 @@
---
title: 개체
description: defineObject를 사용하여 고유한 필드를 가진 사용자 정의 테이블인 새 레코드 유형을 선언합니다.
icon: table
---
사용자 정의 **개체**는 워크스페이스에 앱이 추가하는 새로운 레코드 유형입니다. 엽서(Post Card), 송장(Invoice), 구독(Subscription) 등 도메인에 특화된 어떤 것이라도 될 수 있습니다. 각 개체는 스키마(필드, 관계, 기본값)와 동기화 및 배포 간에도 유지되는 안정적인 범용 식별자를 선언합니다.
```ts src/objects/post-card.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
name: 'content',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
name: 'recipientName',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
name: 'recipientAddress',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
name: 'deliveredAt',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
```
## 핵심 요점
* `universalIdentifier`는 배포 전반에서 고유하고 안정적이어야 합니다.
* 각 필드는 `name`, `type`, `label` 및 고유하고 안정적인 `universalIdentifier`가 필요합니다.
* `fields` 배열은 선택 사항입니다. 사용자 정의 필드 없이도 개체를 정의할 수 있습니다.
* `openRecordIn`은 이 오브젝트의 레코드를 클릭했을 때 어디에서 열릴지 설정합니다: `ObjectOpenRecordIn.USER_CHOICE`(기본값이며, 각 워크스페이스 구성원이 Settings → Experience에서 지정한 개인 설정을 따릅니다), `ObjectOpenRecordIn.SIDE_PANEL`, 또는 `ObjectOpenRecordIn.RECORD_PAGE`. 워크플로와 대시보드처럼 전체 페이지에서 사용해야 하는 레코드의 경우 `RECORD_PAGE`에 고정하고, 캘린더 이벤트처럼 빠른 패널로 볼 때만 의미가 있는 레코드의 경우 `SIDE_PANEL`에 고정하세요.
* `writability`는 역할 권한이 적용되기 전에, 해당 객체의 레코드를 누가 전반적으로 쓸 수 있는지를 제어합니다. `MetadataWritability.OPEN`(기본값 — 워크스페이스 역할이 결정함), `MetadataWritability.APPLICATION`(앱 자체의 로직 함수만 레코드를 생성, 업데이트 또는 삭제할 수 있음; 레코드가 동작을 부여하는 구성 항목과 유사한 객체에 사용하여, 광범위한 레코드 액세스를 가진 워크스페이스 구성원이 API를 통해 이를 편집하지 못하도록 함), 또는 `MetadataWritability.SYSTEM`(플랫폼이 관리하는 데이터용으로 예약됨) 중 하나입니다. 읽기 작업에는 영향을 주지 않습니다. 이는 서버 측에서 강제되며, UI 제공 요소만 숨기는 `isUIEditable`과는 다릅니다. 이는 필드 단위로도 존재하며, 이 경우 객체 수준보다 더 엄격하게만 설정할 수 있습니다.
* 여기에서 정의된 인라인 필드는 `objectUniversalIdentifier`가 **필요하지 않습니다** — 상위 개체에서 상속됩니다. 소유하지 않은 개체에 필드를 추가하려면 [`defineField()`](/l/ko/developers/extend/apps/data/extending-objects)를 사용하세요.
* `yarn twenty dev:add object`를 사용하여 새 개체를 스캐폴딩할 수 있으며, 이름, 필드, 관계 설정 과정을 안내합니다. [Architecture → Scaffolding entities](/l/ko/developers/extend/apps/getting-started/scaffolding)를 참조하세요.
<Note>
**기본 필드는 자동으로 추가됩니다.** 사용자 정의 개체를 정의하면 Twenty가 `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, `deletedAt`와 같은 표준 필드를 자동으로 생성합니다. 이 필드들은 `fields` 배열에 선언할 필요가 없습니다 — 사용자 정의 필드만 선언하면 됩니다. 동일한 이름으로 필드를 선언하여 기본 필드를 재정의할 수 있지만, 이는 거의 바람직하지 않습니다.
</Note>
## 필드 유형
`twenty-sdk/define`에서 내보낸 `FieldType` 값의 전체 집합:
| 카테고리 | 유형 |
| -------- | ------------------------------------------------------------------------------------------------------------------- |
| 텍스트 | `TEXT`, `RICH_TEXT`, `ARRAY` (문자열 배열), `RAW_JSON` |
| 숫자형 | `NUMBER` (`universalSettings.dataType`: `'float'` / `'int'` / `'bigint'`), `NUMERIC` (임의 정밀도), `RATING`, `POSITION` |
| 날짜 | `DATE`, `DATE_TIME` |
| 선택형 | `BOOLEAN`, `SELECT`, `MULTI_SELECT` |
| 복합 | `FULL_NAME`, `ADDRESS`, `EMAILS`, `PHONES`, `LINKS`, `CURRENCY`, `ACTOR`, `FILES` |
| 식별자 및 관계 | `UUID`, `RELATION`, `MORPH_RELATION` (자세한 내용은 [Relations](/l/ko/developers/extend/apps/data/relations)을 참조) |
| 시스템 | `TS_VECTOR` (서버에서 관리되는 전체 텍스트 검색 벡터) |
복합 타입은 여러 하위 필드를 저장합니다(예: `FULL_NAME` = 이름 + 성; `CURRENCY` = `amountMicros` + `currencyCode`). `SELECT` 및 `MULTI_SELECT`는 위 예시와 같이 `options` 배열이 필요합니다.
## 기본값
리터럴 문자열 기본값은 문자열 **내부에서** 작은따옴표로 감싸야 합니다. 즉, `defaultValue: "'Draft'"`처럼 작성해야 하며, `defaultValue: "Draft"`처럼 작성하면 안 됩니다. 그래서 위의 `status` 필드는 `` `'${PostCardStatus.DRAFT}'` ``를 사용합니다.
따옴표로 감싸지 않은 문자열은 레코드가 생성될 때 평가되는 계산형 기본값으로 예약되어 있습니다.
* `'uuid'` — UUID를 생성합니다 (`UUID` 필드용).
* `'now'` — 현재 타임스탬프입니다 (`DATE_TIME` 필드용).
동일한 규칙이 복합 기본값의 문자열 하위 필드(예: `ACTOR` 필드의 `{ source: "'MANUAL'" }`)와 `SELECT`/`MULTI_SELECT` 값에도 적용됩니다. 따옴표로 감싸지 않은 리터럴 문자열 기본값은 앱을 빌드할 때 경고를 발생시킵니다.
## Null 허용 여부
`isNullable`는 필드가 `NULL`을 허용하는지 여부를 제어합니다. 기본값은 `true`이며, 선택적 필드의 경우 생략하면 됩니다. 데이터베이스 수준에서 필드를 필수로 만들려면 `isNullable: false`로 설정하세요.
`isNullable`에 대한 변경 사항은 기존 필드를 업데이트하는 동기화를 포함해 모든 동기화 시 적용되므로, 매니페스트를 수정하고 다시 동기화하여 필드의 Null 허용 여부를 전환할 수 있습니다.
<Note>
**기존 필드를 널 불가(non-nullable)로 변경하려면 기본값이 필요합니다.** 필드를 `isNullable: false`로 변경할 때는 널이 아닌 `defaultValue`도 함께 제공해야 합니다. 기본값은 `NOT NULL` 제약 조건이 적용되기 전에 기존의 `NULL` 행들을 모두 채웁니다. 기본값이 없으면 동기화가 실패하며 `Default value cannot be null for non-nullable fields` 오류가 발생합니다. 릴레이션 필드와 `TS_VECTOR` 필드는 항상 널 허용이므로 `isNullable` 설정이 이들 필드에는 영향을 주지 않습니다.
</Note>
```ts
{
universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000',
name: 'reference',
type: FieldType.TEXT,
label: 'Reference',
isNullable: false,
defaultValue: "'N/A'",
}
```
## 다음 단계
* **이 개체를 다른 개체와 연결** — 양방향 관계 패턴은 [Relations](/l/ko/developers/extend/apps/data/relations)를 참조하세요.
* **다른 앱의 개체에 필드 추가** — `defineField()`에 대해서는 [Extending Objects](/l/ko/developers/extend/apps/data/extending-objects)를 참조하세요.
* **이 오브젝트를 UI에 표시합니다** — 사이드바 항목을 추가하려면 [Navigation Menu Items](/l/ko/developers/extend/apps/layout/navigation-menu-items)를, 사용자 지정 목록 구성을 추가하려면 [Views](/l/ko/developers/extend/apps/layout/views)를 참조하세요.

View File

@@ -0,0 +1,101 @@
---
title: 개요
description: 워크스페이스에 앱이 추가하는 데이터(개체, 필드, 관계)의 구성을 정의하세요.
icon: database
---
Twenty 앱의 **데이터 레이어**는 워크스페이스에 앱이 *추가하는* 데이터입니다. 앱이 선언하는 새로운 레코드 타입, 기존 개체에 추가하는 열, 그리고 이러한 레코드들이 서로 연결되는 방식이 여기에 포함됩니다.
```text
┌──────────────────────────────────────────────────┐
│ Object — a record type, e.g. PostCard │
│ ├─ Field (name, type, label) │
│ ├─ Field │
│ └─ Relation (link to another object) │
└──────────────────────────────────────────────────┘
│
├── lives in your app, OR
│
▼
┌──────────────────────────────────────────────────┐
│ Standard / other apps' objects │
│ └─ Field added by your app via defineField │
└──────────────────────────────────────────────────┘
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="개체" icon="table" href="/l/ko/developers/extend/apps/data/objects">
`defineObject` — 자체 필드를 가진 새로운 레코드 타입을 선언합니다.
</Card>
<Card title="객체 확장하기" icon="wand-magic-sparkles" href="/l/ko/developers/extend/apps/data/extending-objects">
`defineField` — 표준 개체 또는 다른 앱의 개체에 필드를 추가합니다.
</Card>
<Card title="관계" icon="diagram-project" href="/l/ko/developers/extend/apps/data/relations">
개체 간의 양방향 `MANY_TO_ONE` / `ONE_TO_MANY` 연결입니다.
</Card>
<Card title="타임라인 활동 유형" icon="clock-rotate-left" href="/l/ko/developers/extend/apps/data/timeline-activity-types">
레코드 타임라인의 자동 감사 이벤트 및 명시적 앱 이벤트를 정의합니다.
</Card>
</CardGroup>
## 엔터티 한눈에 보기
| 엔터티 | 목적 | 정의 방식 |
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------ |
| **객체** | 고유한 필드를 가진 새로운 사용자 정의 레코드 타입(예: PostCard, Invoice) | `defineObject()` |
| **필드** | 객체의 열 독립형 필드를 사용하면 직접 생성하지 않은 객체도 확장할 수 있습니다(예: Company에 `loyaltyTier` 추가). | `defineField()` |
| **관계** | 두 객체 사이의 양방향 연결 — 양쪽 모두 필드로 선언됩니다. | `FieldType.RELATION`을 사용하는 `defineField()` |
| **색인** | 객체 중 하나에 대해 반복적으로 실행되는 쿼리를 빠르게 하기 위한 데이터베이스 색인 | `defineIndex()` |
| **타임라인 활동 유형** | 레코드 타임라인에 표시되는 자동 감사 이벤트 또는 명시적 도메인 이벤트 | `defineTimelineActivityType()` |
SDK는 빌드 시 AST 분석을 통해 이를 감지하므로 파일 구성은 자유롭게 정할 수 있습니다. 규칙은 `src/objects/`, `src/fields/`, `src/indexes/`, `src/timeline-activity-types/`입니다. 안정적인 `universalIdentifier` UUID가 배포 간 모든 것을 하나로 연결합니다.
## 색인(선택 사항)
앱은 반복적인 쿼리를 빠르게 유지하기 위해 객체와 함께 색인을 포함하여 배포할 수 있습니다. 가장 일반적인 사례는 자주 읽는 상태 열 또는 외래 키 열입니다.
```ts src/indexes/post-card-status.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
```
### 고유 색인
`defineIndex`는 단일 열 및 다중 열 고유성 모두에 대해 `isUnique: true`를 허용합니다. 이것이 권장 기본 요소이며, `defineField({ isUnique: true })`는 사용이 중단(deprecated)되었고 향후 릴리스에서 제거될 예정입니다.
```ts
defineIndex({
universalIdentifier: '…',
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
});
```
### 기타 제약 조건
* 부분 `WHERE` 절은 관리자 제어 하에 유지되며, 앱에서 이를 선언할 수 없습니다.
* 각 객체에는 사용자 정의 색인이 최대 10개까지 허용됩니다(프레임워크 자체 색인은 포함되지 않음).
`fields` 배열의 순서는 Postgres가 사용하는 순서대로 지정하세요. 전화번호부처럼 가장 왼쪽 열이 먼저 오도록 합니다. 색인은 공짜가 아닙니다. 테이블에 대한 모든 쓰기 작업은 색인을 갱신합니다. 해당 색인을 필요로 하는 쿼리가 있을 때에만 색인을 추가하세요.
<Note>
**Application Config** 또는 **Roles & Permissions**를 찾고 계신가요? 이러한 항목은 앱이 추가하는 데이터가 아니라 앱 자체를 설명하며, [Config](/l/ko/developers/extend/apps/config/overview) 아래에 있습니다. **Connections**(Linear, GitHub, Slack OAuth)를 찾고 계신가요? 이들은 로직 함수 *내부에서* 호출되기 위한 것이며, [Logic](/l/ko/developers/extend/apps/logic/connections) 아래에 있습니다.
</Note>

View File

@@ -0,0 +1,184 @@
---
title: 관계
description: 양방향 MANY_TO_ONE / ONE_TO_MANY 관계로 객체들을 서로 연결합니다.
icon: diagram-project
---
관계는 두 객체를 서로 연결합니다. Twenty에서는 관계가 항상 **양방향**입니다 — 모든 관계에는 두 개의 측면이 있으며, 각 측면은 서로를 참조하는 필드로 선언됩니다.
| 관계 유형 | 설명 | 외래 키가 있나요? |
| ------------- | ------------------------------- | -------------------- |
| `MANY_TO_ONE` | 이 객체의 여러 레코드가 대상의 한 레코드를 가리킵니다 | 예 (`joinColumnName`) |
| `ONE_TO_MANY` | 이 객체의 하나의 레코드가 대상의 여러 레코드를 가집니다 | 아니오 (역방향) |
## 관계 작동 방식
모든 관계에는 서로를 참조하는 **두 개의 필드**가 필요합니다:
1. **MANY_TO_ONE** 측 — 외래 키를 보유한 객체에 존재합니다.
2. **ONE_TO_MANY** 측 — 컬렉션을 소유한 객체에 존재합니다.
두 필드 모두 `FieldType.RELATION`을 사용하며 `relationTargetFieldMetadataUniversalIdentifier`를 통해 서로를 교차 참조합니다.
## 예시: Post Card에는 여러 수신자가 있습니다
`PostCard`는 여러 `PostCardRecipient` 레코드에 전송될 수 있습니다. 각 수신자는 정확히 하나의 Post Card에 속합니다.
**1단계: PostCard에서 ONE_TO_MANY 측을 정의합니다** ("one" 측):
```ts src/fields/post-card-recipients-on-post-card.field.ts
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
// Import from the other side
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
icon: 'IconUsers',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
```
**2단계: PostCardRecipient에서 MANY_TO_ONE 측을 정의합니다** ("many" 측 — 외래 키를 보유함):
```ts src/fields/post-card-on-post-card-recipient.field.ts
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
// Export so the other side can reference it
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
// Import from the other side
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
export default defineField({
universalIdentifier: POST_CARD_FIELD_ID,
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
icon: 'IconMail',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
});
```
<Note>
**순환 임포트:** 두 관계 필드는 서로의 `universalIdentifier`를 참조합니다. 순환 임포트 문제를 피하려면, 각 파일에서 필드 ID를 명명된 상수로 export하고 다른 파일에서 import하세요. 빌드 시스템은 컴파일 시점에 이를 해결합니다.
</Note>
## 중간 관계들
중간 객체는 하나의 소스 레코드를 하나 이상의 대상 레코드에 연결합니다. 예를 들어, `PostCardRecipient`는 `postCard` 관계와 `recipient` 관계를 모두 포함할 수 있습니다. 소스 객체의 `ONE_TO_MANY` 필드에서 `junctionTargetFieldUniversalIdentifier`를 대상 객체를 가리키는 중간 객체의 관계 필드로 설정합니다:
```ts
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldUniversalIdentifier: RECIPIENT_FIELD_ID,
},
});
```
`RECIPIENT_FIELD_ID`는 중간 객체의 `MANY_TO_ONE` 또는 morph 관계를 식별해야 합니다. [타임라인 활동 팬아웃](/l/ko/developers/extend/apps/data/timeline-activity-types#automatic-and-explicit-events)과 같은 기능이 중간 객체를 순회할 때 어떤 관련 레코드가 대상인지 Twenty에 알려 줍니다.
## 표준 객체와의 관계 설정
내장 Twenty 객체(Person, Company 등)와 관계를 만들려면 `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`를 사용하세요:
```ts src/fields/person-on-self-hosting-user.field.ts
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
export default defineField({
universalIdentifier: PERSON_FIELD_ID,
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
```
## 관계 필드 속성
| 속성 | 필수 | 설명 |
| ---------------------------------------------------------- | ------------------- | --------------------------------------------------------------------- |
| `type` | 예 | `FieldType.RELATION`이어야 함 |
| `relationTargetObjectMetadataUniversalIdentifier` | 예 | 대상 객체의 `universalIdentifier` |
| `relationTargetFieldMetadataUniversalIdentifier` | 예 | 대상 객체의 일치하는 필드의 `universalIdentifier` |
| `universalSettings.relationType` | 예 | `RelationType.MANY_TO_ONE` 또는 `RelationType.ONE_TO_MANY` |
| `universalSettings.onDelete` | MANY_TO_ONE에만 해당 | 참조된 레코드가 삭제될 때의 동작: `CASCADE`, `SET_NULL`, `RESTRICT`, 또는 `NO_ACTION` |
| `universalSettings.joinColumnName` | MANY_TO_ONE에만 해당 | 외래 키에 대한 데이터베이스 컬럼 이름(예: `postCardId`) |
| `universalSettings.junctionTargetFieldUniversalIdentifier` | 중간 `ONE_TO_MANY` 전용 | 팬아웃 대상을 가리키는 중간 객체의 관계 필드 |
## 인라인 관계 필드
[`defineObject`](/l/ko/developers/extend/apps/data/objects) 안에서 관계를 직접 선언할 수도 있습니다. 인라인인 경우에는 `objectUniversalIdentifier`를 생략하세요 — 상위 객체에서 상속됩니다:
```ts
export default defineObject({
universalIdentifier: '...',
nameSingular: 'postCardRecipient',
// ...
fields: [
{
universalIdentifier: POST_CARD_FIELD_ID,
type: FieldType.RELATION,
name: 'postCard',
label: 'Post Card',
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'postCardId',
},
},
// … other fields
],
});
```

View File

@@ -0,0 +1,203 @@
---
title: 시스템 메타데이터 대상 지정
description: Twenty가 모든 객체에 자동으로 프로비저닝하는 메타데이터의 결정론적 범용 식별자를 해석하여, 앱이 하드코딩 없이 이를 참조할 수 있도록 합니다.
icon: gears
---
Twenty의 모든 객체에는 필드 집합과 열이 포함된 기본 목록 보기처럼, 직접 선언하지 않아도 되는 **시스템 메타데이터**가 함께 제공됩니다. 객체가 프로비저닝될 때 서버가 이러한 모든 요소를 생성하며, Twenty가 확장됨에 따라 해당 집합도 함께 증가합니다.
직접 이를 선언하지 않기 때문에, 가져올 수 있는 `universalIdentifier` 상수가 없습니다. 대신 서버는 각 식별자를 결정론적으로 **도출**하며, `twenty-sdk`는 동일한 도출 방식을 제공하여 매니페스트가 서버가 사용하는 정확한 값을 해석할 수 있게 합니다.
## 시스템 필드
모든 객체에 존재하는 스칼라 필드로, [`defineField()`](/l/ko/developers/extend/apps/data/extending-objects)로 선언하지 않는 필드들입니다:
`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`
그렇다면 [view](/l/ko/developers/extend/apps/layout/views)에서 `createdAt`을 컬럼으로 어떻게 참조할 수 있을까요?
### 문제
Twenty 2.19부터 시스템 필드의 유니버설 식별자는 애플리케이션 유니버설 식별자, 객체 유니버설 식별자, 필드 이름이라는 세 가지 입력값으로부터 서버에서 결정론적으로 도출됩니다. id를 임의로 만들고 하드코딩해도 동작하지 않습니다. 서버에서 어떤 것과도 일치하지 않으며, 동기화에서 이러한 끊어진 참조를 거부합니다:
```
Dev sync failed: viewField: INVALID_VIEW_DATA: Field metadata not found
```
### 해결책
<Note>
`getFieldUniversalIdentifier`는 `twenty-sdk` 2.21부터 사용할 수 있습니다.
</Note>
`getFieldUniversalIdentifier`를 사용해 서버가 사용하는 것과 정확히 동일한 값을 해석하세요. 이 함수는 세 가지 입력값을 받아 필드의 유니버설 식별자를 반환합니다:
```ts
import { getFieldUniversalIdentifier } from 'twenty-sdk/define';
const createdAtFieldId = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
});
```
* `applicationUniversalIdentifier`는 [`defineApplication()`](/l/ko/developers/extend/apps/config/application)에 전달하는, 앱의 식별자입니다.
* `objectUniversalIdentifier`는 필드가 속한 객체의 식별자입니다.
* `name`은 시스템 필드 이름이며, 위에 나열된 값들 중 하나입니다.
### 예시: 뷰의 createdAt 컬럼
일반적인 사용 사례는 커스텀 객체 중 하나의 뷰에 `createdAt` 컬럼을 추가하는 것입니다. 필드 id를 해석한 다음, 다른 `fieldMetadataUniversalIdentifier`와 동일한 방식으로 참조하세요:
```ts src/views/example-view.ts
import {
defineView,
getFieldUniversalIdentifier,
} from 'twenty-sdk/define';
const APPLICATION_UNIVERSAL_IDENTIFIER =
'0b04e15c-27b2-4741-9046-b32e07469072';
const MY_OBJECT_UNIVERSAL_IDENTIFIER =
'c782b61c-70fd-4c88-9cd6-4e61ab8d7591';
export default defineView({
universalIdentifier: '70f10d44-144a-4da8-8c6f-3ec2422138c0',
name: 'All records',
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '75a90bc4-d901-4df4-85e0-af29db5e0104',
fieldMetadataUniversalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: MY_OBJECT_UNIVERSAL_IDENTIFIER,
name: 'createdAt',
}),
position: 0,
isVisible: true,
size: 200,
},
],
});
```
이렇게 해석된 동일한 id는 `fieldMetadataUniversalIdentifier`가 필요한 모든 곳에서 사용할 수 있습니다. 예를 들어 view 필드, 필터, 정렬, 그룹, 페이지 레이아웃 위젯 등입니다.
<Note>
id를 해석해서 사용하고, 하드코딩하지 마세요. 서버는 애플리케이션 id, 객체 id, 필드 이름으로부터 값을 도출하기 때문에,
`getFieldUniversalIdentifier`를 호출하면 이 입력값들이 변경되더라도 참조를 올바르게 유지할 수 있고, 도출 방식이 바뀌더라도 드리프트를 방지할 수 있습니다.
</Note>
### 시스템 관계 필드
<Note>
`getSystemRelationFieldUniversalIdentifier`는 `twenty-sdk`
2.23부터 사용 가능하며, Twenty 서버 버전 2.23 이상이 필요합니다.
</Note>
위에 언급된 스칼라 시스템 필드 외에도 서버는 모든 객체에 `timelineActivities`, `attachments`, `noteTargets`, `taskTargets`라는 네 개의 **시스템 관계 필드**를 제공하며, 각각은 해당하는 표준 관계 객체를 가리킵니다.
이 필드는 `getFieldUniversalIdentifier`로는 해결되지 않습니다. 이들의 식별자는 필드를 호스팅하는 객체와 필드가 가리키는 객체에서 **이름과 무관하게** 도출됩니다. 이 방식이면 객체의 이름을 변경해도 해당 객체의 관계 필드 식별자는 변경되지 않습니다.
이들을 해결하려면 `getSystemRelationFieldUniversalIdentifier`를 사용하세요:
```ts
import {
getSystemRelationFieldUniversalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
// rocket.attachments — the relation field hosted on your custom object
const rocketAttachmentsFieldId = getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
});
```
* `objectUniversalIdentifier`는 필드를 **호스팅하는** 객체입니다.
* `relationTargetObjectUniversalIdentifier`는 필드가 **가리키는** 객체입니다.
방향은 인수의 순서에 의해 인코딩됩니다. 반대쪽(예: `attachment.targetRocket`, 서버가 표준 관계 객체에 생성하는 morph 필드)을 해결하려면 두 인수의 순서를 바꾸면 됩니다:
```ts
// attachment.targetRocket — the reverse morph field on Attachment
const attachmentTargetRocketFieldId =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment.universalIdentifier,
relationTargetObjectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
});
```
스칼라 시스템 필드와 마찬가지로, 해결된 ID는 `fieldMetadataUniversalIdentifier`가 필요한 곳이면 어디서든 사용할 수 있습니다.
## 시스템 뷰
<Note>
`getSystemViewUniversalIdentifier`와 `getSystemViewFieldUniversalIdentifier`는 `twenty-sdk` 2.26부터 사용 가능하며, Twenty 서버 버전 2.26 이상이 필요합니다.
</Note>
서버는 또한 모든 객체에 대해 **시스템 뷰**를 프로비저닝합니다. 즉, 기본 목록 보기(`All {objectLabelPlural}`; 키는 `SYSTEM_VIEW_KEYS.INDEX`에서 파생된 `ViewKey.INDEX`)로, 표시 가능한 각 필드마다 하나의 열이 있습니다. 시스템 관계 필드와 마찬가지로, 이들의 식별자는 **이름에 의존하지 않고** 도출되므로, 객체나 필드의 이름을 변경해도 식별자는 절대 바뀌지 않습니다.
뷰를 해석하려면 `getSystemViewUniversalIdentifier`를 사용하세요:
```ts
import {
getSystemViewUniversalIdentifier,
SYSTEM_VIEW_KEYS,
} from 'twenty-sdk/define';
const rocketIndexViewId = getSystemViewUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ROCKET_OBJECT_UNIVERSAL_IDENTIFIER,
viewKey: SYSTEM_VIEW_KEYS.INDEX,
});
```
* `objectMetadataApplicationUniversalIdentifier`는 **객체**를 소유하는 애플리케이션으로, 뷰의 네임스페이스 기준이 됩니다.
* `objectUniversalIdentifier`는 뷰가 목록으로 표시하는 객체입니다.
* `viewKey`는 시스템 뷰를 구분하는 값입니다. 기본 목록 보기를 위한 `SYSTEM_VIEW_KEYS.INDEX`, 레코드 페이지 필드 위젯 뷰를 위한 `SYSTEM_VIEW_KEYS.FIELDS_WIDGET`이 있습니다. 이는 파생을 위한 키이며, 이 중 `INDEX`만 뷰 행에 함께 저장됩니다.
해석된 ID는 [`NavigationMenuItemType.VIEW`](/l/ko/developers/extend/apps/layout/navigation-menu-items) 사이드바 항목처럼, `viewUniversalIdentifier`가 필요한 모든 곳에서 사용할 수 있습니다. 객체의 기본 목록만 단순히 열고 싶다면, 파생 과정이 필요 없는 `targetObjectUniversalIdentifier`와 함께 `NavigationMenuItemType.OBJECT`를 사용하는 것이 좋습니다.
`getSystemViewFieldUniversalIdentifier`는 뷰와 해당 뷰에 표시되는 필드 정보를 기반으로 시스템 뷰의 개별 **열**을 해석합니다:
```ts
import { getSystemViewFieldUniversalIdentifier } from 'twenty-sdk/define';
const rocketNameColumnId = getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: rocketIndexViewId,
fieldMetadataUniversalIdentifier: ROCKET_NAME_FIELD_UNIVERSAL_IDENTIFIER,
});
```
첫 번째 인자에 주목하세요. 열은 뷰를 소유한 애플리케이션이 아니라, **표시되는 필드를 소유한** 애플리케이션을 기준으로 네임스페이스가 구분됩니다. 앱이 표준 객체에 추가한 필드는, Twenty가 소유한 뷰 위에 있지만 해당 열은 앱이 소유한 애플리케이션 네임스페이스 하에서 도출됩니다.
<Warning>
시스템 뷰와 그 열은 **서버 소유**입니다. 이들을 참조하기 위해서만 식별자를 해석해야 하며, 직접 선언하는 용도로는 사용해서는 안 됩니다. [`defineView()`](/l/ko/developers/extend/apps/layout/views)의 `key`는 더 이상 사용되지 않으며 무시되므로, 매니페스트 뷰가 `INDEX` 키를 차지할 수 없습니다. 또한 서버는 앱이 추가하는 모든 필드에 대해 이미 열을 프로비저닝하므로, 시스템 뷰에서 동일한 필드에 대해 별도의 `defineViewField()`를 선언하면 서버가 생성한 열과 충돌합니다.
</Warning>
## 표준 Twenty 객체
**표준** Twenty 객체(Person, Company, Opportunity 등)의 경우 어떤 것도 도출할 필요가 없습니다. 필드와 뷰 모두에 대해, 미리 계산된 상수 식별자를 바로 import하여 사용할 수 있습니다.
```ts
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.createdAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.fields.updatedAt.universalIdentifier
// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.views.allPeople.universalIdentifier
```
[`defineObject()`](/l/ko/developers/extend/apps/data/objects)로 **앱에서 직접 정의한** 객체처럼, 그러한 상수가 존재하지 않는 경우에는 위에 소개한 헬퍼들을 사용하세요.
<Note>
`name`은 시스템 필드가 아니라 **기본(default)** 필드입니다. 이 필드는 자체 하드코딩된 유니버설 식별자를 유지하며,
`getFieldUniversalIdentifier`를 통해 해석되지 않습니다. 여러분이 정의한 객체에서는 `defineObject()`에서 부여한 식별자를 사용해 `name` 필드를 참조하세요.
</Note>

View File

@@ -0,0 +1,184 @@
---
title: 타임라인 활동 유형
description: 레코드 타임라인에 표시되는 자동 감사 이벤트와 명시적 앱 이벤트를 정의합니다.
icon: clock-rotate-left
---
타임라인 활동 유형은 레코드의 타임라인에 표시되는 이벤트를 위한 안정적인 어휘를 정의합니다. 표준 객체와 앱 객체는 동일한 계약을 사용합니다. 유형에는 레이블과 아이콘이 있으며, 선택적으로 발생 시점을 선언하고 앱의 [프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components) 중 하나를 통해 렌더링할 수 있습니다.
<Note>
타임라인 활동 유형은 베타 상태이며 Twenty 2.34에서 곧 제공됩니다. 앱 개발자의 사용 사례를 파악하는 동안
API는 변경될 수 있습니다.
</Note>
스캐폴더로 하나를 생성합니다:
```bash filename="Terminal"
yarn twenty dev:add timelineActivityType
```
또는 직접 정의합니다:
```ts filename="src/timeline-activity-types/post-card-created.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7500',
name: 'postCardCreated',
label: 'created a post card',
icon: 'IconMail',
emit: {
on: 'created',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
},
});
```
## 자동 및 명시적 이벤트
Twenty가 이 유형을 자동으로 생성해야 하는 경우 `emit`을 추가합니다. `emit.on`은 `created`, `updated`, `deleted`, `restored`, `linked`, `unlinked`를 지원하며, `emit.objectUniversalIdentifier`는 소스 객체를 식별합니다. 워크스페이스에서는 하나의 유효 유형만 동일한 emit 키, 즉 `on`, 객체 및 선택적 through 관계를 처리할 수 있습니다.
`emit.through`가 없으면 이벤트는 소스 레코드 자체의 타임라인에 기록됩니다. 연결된 레코드로 확산하려면 `emit.through.relationFieldUniversalIdentifier`를 소스 객체의 직접 다대일 관계 또는 [일대다 정션 관계](/l/ko/developers/extend/apps/data/relations#junction-relations)로 설정하세요. 모프 관계는 모프 그룹의 모든 멤버로 확산되므로 단일 선언으로 여러 객체 유형을 대상으로 지정할 수 있습니다.
직접 관계의 경우 소스 레코드를 생성하거나 복원하면 `linked`가 생성되고, 삭제하면 `unlinked`가 생성되며, 관계의 대상을 변경하면 이전 대상에 `unlinked`와 새 대상에 `linked`가 생성됩니다. 다른 소스 업데이트는 링크 이벤트를 생성하지 않습니다. 이는 대상이 직접 모프 관계인 첨부 파일에서 사용하는 계약입니다.
정션 관계의 경우 `universalSettings.junctionTargetFieldUniversalIdentifier`는 정션 객체에서 대상으로의 관계를 식별합니다. 정션 행을 생성하거나 삭제하면 `linked` 또는 `unlinked`가 생성됩니다. 정션 행의 어느 한쪽 대상이 변경되어도 링크 이벤트가 생성되지만, 관련 없는 정션 필드의 업데이트는 생성되지 않습니다.
`linked` 및 `unlinked` 이벤트는 구성된 직접 또는 정션 관계의 변경이 트리거이므로 `emit.through`가 필요합니다.
예를 들어, 이는 노트, 작업, 메시지, 캘린더 이벤트에서 사용되는 것과 동일한 일반 계약입니다:
```ts filename="src/timeline-activity-types/post-card-linked.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
import { POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/post-card-recipients-on-post-card.field';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7502',
name: 'postCardLinked',
label: 'received a post card',
icon: 'IconMail',
emit: {
on: 'linked',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
through: {
relationFieldUniversalIdentifier:
POST_CARD_RECIPIENTS_FIELD_UNIVERSAL_IDENTIFIER,
},
},
});
```
기본적으로 모든 소스 업데이트에 대해 `updated` through 이벤트가 발생합니다. 선택한 소스 필드의 변경 사항만 대상 타임라인에 표시되어야 하는 경우 `emit.through.triggerFieldUniversalIdentifiers`를 설정합니다.
로직 함수가 직접 생성하는 명시적 도메인 이벤트에는 `emit`을 생략합니다. 이렇게 하면 동일한 작업에 대해 자동 감사 행과 명시적 행이 모두 생성되는 것을 방지할 수 있습니다.
```ts filename="src/timeline-activity-types/post-card-sent.ts"
import { defineTimelineActivityType } from 'twenty-sdk/define';
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
});
```
[`createTimelineActivity()`](/l/ko/developers/extend/apps/logic/logic-functions#create-a-timeline-activity)로 명시적 이벤트를 생성합니다. 앱 코드는 안정적인 범용 식별자를 사용하며, Twenty는 설치별 메타데이터 ID를 확인합니다.
## 사용자 지정 렌더링
프런트 컴포넌트가 없으면 Twenty는 유형 레이블, 아이콘 및 연결된 객체 메타데이터를 기반으로 네이티브 일반 행을 렌더링합니다. 이 방식은 객체별 렌더러 없이 표준 및 사용자 지정 객체에 사용할 수 있습니다.
사용자 지정 세부 정보를 위해 `frontComponentUniversalIdentifier`를 동일한 앱이 소유한 프런트 컴포넌트로 설정합니다:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
name: 'postCardSent',
label: 'sent a post card',
icon: 'IconSend',
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
네이티브 행은 접힌 상태의 표시로 유지됩니다. Twenty는 사용자가 해당 행을 확장한 후에만 프런트 컴포넌트를 마운트하므로, 표시되는 모든 이벤트에 대해 샌드박스와 워커가 생성되는 것을 방지합니다. 컴포넌트 내부에서 `twenty-sdk/front-component`의 `useTimelineActivityId()`를 호출하여 행 ID를 읽고 표시에 필요한 데이터를 가져옵니다. 컴포넌트가 타임라인 행 외부에서 렌더링되면 `null`을 반환합니다.
## 다른 애플리케이션의 표시 재정의
앱은 자체 객체에 대한 자동 타임라인 계약을 소유합니다. 다른 앱이 소유한 객체의 이벤트를 사용자 지정하려면 `replacesTimelineActivityTypeUniversalIdentifier`로 기존 유형을 명시적으로 선언합니다:
```ts
export default defineTimelineActivityType({
universalIdentifier: 'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7503',
name: 'companyCreatedWithDeploymentContext',
label: 'was created from a deployment',
emit: {
on: 'created',
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
},
replacesTimelineActivityTypeUniversalIdentifier:
RECORD_CREATED_TIMELINE_ACTIVITY_TYPE_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
});
```
재정의는 명시적 전용 유형이 아닌 기존 자동 emit 슬롯을 대체하므로 `emit`을 포함해야 합니다. 참조된 유형은 대상 객체의 애플리케이션에 속해야 하며 동일한 작업과 경로를 설명해야 합니다. 재정의 앱을 제거하면 기본 유형이 복원되고, 기본 유형을 제거하면 재정의가 비활성화됩니다.
## 워크스페이스 재정의 및 음소거
워크스페이스 관리자는 애플리케이션을 포크하지 않고도 유형의 표시를 변경하거나 자동 및 명시적 이벤트를 음소거할 수 있습니다. 설치별 유형 ID와 함께 메타데이터 API의 `updateTimelineActivityType` 뮤테이션을 사용합니다:
```graphql
mutation CustomizeTimelineActivityType(
$id: UUID!
$label: String
$icon: String
$isActive: Boolean
) {
updateTimelineActivityType(
input: { id: $id, label: $label, icon: $icon, isActive: $isActive }
) {
id
label
icon
isActive
}
}
```
`label`과 `icon`은 워크스페이스 재정의로 저장되므로 이후 애플리케이션 업데이트가 관리자의 선택을 덮어쓰지 않습니다. 자동 발생을 중지하고 해당 유형의 새 명시적 이벤트를 거부하려면 `isActive`를 `false`로 설정합니다. 기존 행은 계속 표시됩니다.
활성 상태를 포함한 애플리케이션 기본값을 다음으로 복원합니다:
```graphql
mutation ResetTimelineActivityType($id: UUID!) {
resetTimelineActivityType(id: $id) {
id
label
icon
isActive
}
}
```
## 구성 필드
| 필드 | 필수 | 설명 |
| ------------------------------------------------- | ------- | -------------------------------------- |
| `universalIdentifier` | 예 | 설치 및 업그레이드 전반에서 유형에 대한 안정적인 UUID |
| `name` | 예 | 앱 내 프로그래밍 방식 이름이며, 앱 내에서 고유해야 합니다 |
| `label` | 예 | 네이티브 렌더러에서 사용하는 사용자 대상 작업 텍스트 |
| `icon` | 아니요 | 이벤트 옆에 표시되는 Twenty 아이콘 이름 |
| `emit` | 아니요 | 자동 발생 선언; 명시적 전용 유형에서는 생략 |
| `emit.on` | emit 포함 | 이 유형이 기록되도록 하는 감사 작업 |
| `emit.objectUniversalIdentifier` | emit 포함 | 레코드가 이 유형을 발생시키는 객체 |
| `emit.through.relationFieldUniversalIdentifier` | 아니요 | 연결된 타임라인으로 확산하는 데 사용되는 직접 또는 정션 관계 |
| `emit.through.triggerFieldUniversalIdentifiers` | 아니요 | `updated` through 이벤트를 트리거할 수 있는 소스 필드 |
| `frontComponentUniversalIdentifier` | 아니요 | 행이 확장될 때 마운트되는 앱 소유 프런트 컴포넌트. |
| `replacesTimelineActivityTypeUniversalIdentifier` | 아니요 | 기존 emit 유형이 대체됨, `emit` 필요 |
Twenty는 이벤트가 생성될 때 의미론적 식별 정보와 지속적인 표시 대체 정보를 스냅샷으로 저장합니다. 기존 행은 원래의 작업 및 객체 의미를 유지합니다. 유형이 설치되어 있는 동안에는 현재 번역된 레이블, 아이콘 및 프런트 컴포넌트가 실시간으로 렌더링되며, 제거 후에는 스냅샷 대체 정보가 행의 가독성을 유지합니다. 확인은 범용 식별자를 사용하므로 앱을 다시 설치한 후에도 표시가 유지됩니다.

View File

@@ -0,0 +1,102 @@
---
title: 개념
description: Twenty 앱이 작동하는 방식 — 엔티티 모델, 샌드박싱, 설치 라이프사이클.
icon: sitemap
---
Twenty 앱은 사용자 정의 객체, 로직, UI 컴포넌트 및 AI 기능으로 워크스페이스를 확장하는 TypeScript 패키지입니다. 앱은 Twenty 플랫폼에서 완전한 샌드박싱과 권한 제어 하에 실행됩니다.
## 앱 작동 방식
앱은 `twenty-sdk` 패키지의 `defineEntity()` 함수를 사용해 선언된 **엔티티**의 모음입니다. SDK는 빌드 시점에 AST 분석을 통해 이러한 선언을 감지하고, 앱이 워크스페이스에 추가하는 내용을 완전하게 설명한 **매니페스트**를 생성합니다. 이 함수들은 빌드 시점에 구성을 검증하고 IDE 자동 완성과 타입 안정성을 제공합니다.
```
your-app/
├── src/
│ ├── application-config.ts ← defineApplication (required, one per app)
│ ├── roles/ ← defineRole
│ ├── objects/ ← defineObject
│ ├── fields/ ← defineField
│ ├── logic-functions/ ← defineLogicFunction
│ ├── front-components/ ← defineFrontComponent
│ ├── skills/ ← defineSkill
│ ├── agents/ ← defineAgent
│ ├── views/ ← defineView
│ ├── navigation-menu-items/ ← defineNavigationMenuItem
│ └── page-layouts/ ← definePageLayout
├── public/ ← Static assets (images, icons)
└── package.json
```
<Note>
**파일 구성은 사용자의 선택입니다.** 엔티티 감지는 AST 기반이며 — 파일 위치와 관계없이 SDK가 `export default defineEntity(...)` 호출을 찾습니다. 위의 폴더 구조는 관례이며 필수 사항은 아닙니다.
</Note>
## 엔티티 유형
| 엔티티 | 목적 | 문서 |
| --------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **애플리케이션** | 앱 식별, 기본 역할, 변수 | [Application Config](/l/ko/developers/extend/apps/config/application) |
| **역할** | 객체와 필드에 대한 권한 세트 | [Roles & Permissions](/l/ko/developers/extend/apps/config/roles) |
| **객체** | 필드가 있는 사용자 정의 레코드 타입 | [Objects](/l/ko/developers/extend/apps/data/objects) |
| **필드** | 다른 앱의 객체에 필드를 추가 | [Extending Objects](/l/ko/developers/extend/apps/data/extending-objects) |
| **관계** | 객체 간 양방향 링크 | [Relations](/l/ko/developers/extend/apps/data/relations) |
| **로직 함수** | 트리거가 있는 서버 측 TypeScript | [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) |
| **스킬** | 재사용 가능한 AI 에이전트 지침 | [스킬 및 에이전트](/l/ko/developers/extend/apps/logic/skills-and-agents) |
| **에이전트** | 사용자 지정 프롬프트를 사용하는 AI 에이전트 | [스킬 및 에이전트](/l/ko/developers/extend/apps/logic/skills-and-agents) |
| **연결 제공자** | 서드파티 API용 OAuth 자격 증명 | [Connections](/l/ko/developers/extend/apps/logic/connections) |
| **뷰** | 사전 구성된 레코드 목록 뷰 | [Views](/l/ko/developers/extend/apps/layout/views) |
| **내비게이션 메뉴 항목** | 사용자 정의 사이드바 항목 | [Navigation Menu Items](/l/ko/developers/extend/apps/layout/navigation-menu-items) |
| **페이지 레이아웃** | 레코드 상세 페이지의 탭과 위젯 | [Page Layouts](/l/ko/developers/extend/apps/layout/page-layouts) |
| **프런트 컴포넌트** | Twenty 내부의 샌드박스 React UI | [프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components) |
| **공유 의존성** | 앱의 프런트 컴포넌트에서 공유되고 package.json에 선언된 의존성 | [공유 의존성](/l/ko/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components) |
| **명령 메뉴 항목** | 빠른 작업 및 Cmd+K 항목 | [Command Menu Items](/l/ko/developers/extend/apps/layout/command-menu-items) |
## 샌드박싱
* **로직 함수**는 서버의 격리된 Node.js 프로세스에서 실행됩니다. 이들은 타입이 지정된 API 클라이언트를 통해서만 데이터에 액세스하며, 이 클라이언트는 실행을 트리거한 사람 또는 앱 자체로 작동합니다. [호출에 사용되는 액세스 권한](/l/ko/developers/extend/apps/logic/logic-functions#whose-access-a-call-uses)을 참조하세요.
* **프런트 컴포넌트**는 Remote DOM을 사용하는 Web Worker에서 실행됩니다 — 메인 페이지와는 샌드박스로 격리되어 있지만 네이티브 DOM 요소를 렌더링합니다(iframe이 아님). 이들은 메시지 전달 호스트 API를 통해 Twenty와 통신합니다.
* **권한**은 API 수준에서 강제 적용됩니다. `defineApplicationRole()`로 선언된 역할에서 파생된 두 개의 런타임 토큰이 주입됩니다. 기본 토큰은 실행 뒤에 사람이 있는 경우마다 실행을 트리거한 사람이 할 수 있는 작업으로 더 제한됩니다.
## 앱 라이프사이클
```
┌─────────────────────────────────────────────────────────┐
│ Development │
│ npx create-twenty-app → yarn twenty dev (live sync) │
├─────────────────────────────────────────────────────────┤
│ Build & Deploy │
│ yarn twenty dev:build → yarn twenty app:publish │
├─────────────────────────────────────────────────────────┤
│ Install flow │
│ upload → [pre-install] → metadata migration → │
│ generate SDK → [post-install] → … → [uninstall] │
├─────────────────────────────────────────────────────────┤
│ Publish │
│ npm publish → appears in Twenty marketplace │
└─────────────────────────────────────────────────────────┘
```
* **`yarn twenty dev`** — 소스 파일을 감시하고 연결된 Twenty 서버로 변경 사항을 실시간 동기화합니다. 스키마가 변경되면 타입이 지정된 API 클라이언트가 자동으로 재생성됩니다.
* **`yarn twenty dev:build`** — TypeScript를 컴파일하고, 로직 함수와 프런트 컴포넌트를 esbuild로 번들링하며, 매니페스트를 생성합니다.
* **사전/사후 설치 및 제거 훅** — 설치 중이거나 제거 직전에 실행되는 선택적 함수입니다. 자세한 내용은 [Install Hooks](/l/ko/developers/extend/apps/config/install-hooks)를 참조하세요.
## 다음 단계
<CardGroup cols={2}>
<Card title="설정" icon="screwdriver-wrench" href="/l/ko/developers/extend/apps/config/overview">
애플리케이션 식별, 기본 역할, 설치 및 제거 훅.
</Card>
<Card title="데이터" icon="database" href="/l/ko/developers/extend/apps/data/overview">
객체, 필드, 양방향 관계.
</Card>
<Card title="로직" icon="bolt" href="/l/ko/developers/extend/apps/logic/overview">
로직 함수, 스킬, 에이전트, OAuth 연결.
</Card>
<Card title="레이아웃" icon="table-columns" href="/l/ko/developers/extend/apps/layout/overview">
뷰, 내비게이션, 페이지 레이아웃, 프런트 컴포넌트.
</Card>
<Card title="작업" icon="rocket" href="/l/ko/developers/extend/apps/operations/overview">
CLI, 테스트, 리모트, CI, 앱 게시.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: 로컬 서버
description: 로컬 Twenty Docker 서버를 관리합니다. 시작, 중지, 업그레이드, 병렬 테스트 인스턴스 및 수동 SDK 설정을 수행할 수 있습니다.
icon: server
---
## 로컬 서버 관리
로컬 Twenty 컨테이너를 제어하려면 `yarn twenty docker:*`를 사용하세요:
| 명령 | 하는 일 |
| -------------------------------------- | ----------------------------- |
| `yarn twenty docker:start` | 서버 시작(필요하면 이미지를 가져옴) |
| `yarn twenty docker:start 2.2.0` | 특정 서버 버전을 시작합니다 |
| `yarn twenty docker:start --port 3030` | 사용자 지정 포트에서 시작 |
| `yarn twenty docker:stop` | 서버 중지(데이터 보존) |
| `yarn twenty docker:status` | URL, 버전 및 로그인 자격 증명 표시 |
| `yarn twenty docker:logs` | 서버 로그 스트리밍 |
| `yarn twenty docker:reset` | 데이터를 모두 삭제하고 새로 시작 |
| `yarn twenty docker:upgrade` | `twenty-app-dev` 최신 이미지를 가져오기 |
| `yarn twenty docker:upgrade 2.2.0` | 특정 버전으로 업그레이드합니다 |
데이터는 두 개의 Docker 볼륨에 저장되어 재시작 후에도 유지됩니다(PostgreSQL은 `twenty-app-dev-data`, 파일은 `twenty-app-dev-storage`). `reset`을 사용하여 모든 것을 삭제하세요.
## 서버 버전 고정하기
버전을 전달하지 않으면, `docker:start`는 `package.json`의 앱 `engines.twenty` 범위에서 버전을 결정합니다. 이 범위는 앱이 설치될 때 서버가 검증에 사용하는 범위와 같습니다. 이 명령은 해당 범위를 만족하는 가장 최근에 게시된 `twenty-app-dev` 이미지를 시작하며, 해당 필드가 없거나 게시된 버전이 범위와 일치하지 않으면 `latest`로 대체합니다:
```json filename="package.json"
{
"engines": {
"twenty": ">=2.2.0"
}
}
```
단일 실행에서만 범위를 재정의하려면 버전을 명시적으로 전달하세요: `yarn twenty docker:start 2.3.0`. 이미 다른 버전에서 컨테이너가 존재하는 경우, `docker:start`는 해당 컨테이너를 제자리에서 업그레이드합니다(데이터 볼륨을 유지하면서 컨테이너를 재생성).
## 서버 이미지 업그레이드
`yarn twenty docker:upgrade`는 최신 이미지를 가져와 다이제스트를 비교하고, 실제로 변경 사항이 있을 때만 컨테이너를 다시 생성합니다. 데이터 볼륨은 유지되며 — 컨테이너만 교체됩니다. 새 이미지를 가져왔고 컨테이너가 실행 중이었다면, 업그레이드는 자동으로 새 컨테이너를 시작합니다; 이후 `yarn twenty docker:start`를 실행하여 상태가 정상(healthy)이 될 때까지 기다리세요.
```bash filename="Terminal"
yarn twenty docker:upgrade # Latest
yarn twenty docker:upgrade 2.2.0 # Specific version
```
`yarn twenty docker:status`로 실행 중인 버전을 확인하세요(컨테이너에 포함된 `APP_VERSION`을 표시합니다).
## 병렬 테스트 인스턴스 실행
완전히 분리된 두 번째 인스턴스를 관리하려면 모든 `docker:*` 명령에 `--test`를 전달하세요 — 메인 개발 데이터를 건드리지 않고 통합 테스트를 실행하거나 실험할 때 유용합니다:
| 명령 | 하는 일 |
| ----------------------------------- | ---------------------------- |
| `yarn twenty docker:start --test` | 테스트 인스턴스를 시작합니다(기본 포트는 2021) |
| `yarn twenty docker:stop --test` | 중지 |
| `yarn twenty docker:status --test` | 상태 표시 |
| `yarn twenty docker:logs --test` | 로그 스트리밍 |
| `yarn twenty docker:reset --test` | 데이터 삭제 |
| `yarn twenty docker:upgrade --test` | 이미지 업그레이드 |
테스트 인스턴스는 자체 컨테이너(`twenty-app-dev-test`), 전용 볼륨(`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`), 구성으로 실행되며, 메인 인스턴스와 충돌 없이 나란히 실행됩니다. `--test`를 `--port`와 함께 사용하여 2021을 재정의하세요.
## 수동 설정(스캐폴더 없이)
기존 프로젝트에 SDK를 추가하는 경우 스캐폴더 단계를 건너뜁니다:
```bash filename="Terminal"
yarn add twenty-sdk twenty-client-sdk
```
`package.json`에 스크립트를 추가하세요:
```json filename="package.json"
{
"scripts": {
"twenty": "twenty"
}
}
```
이제 `yarn twenty dev`, `yarn twenty docker:start` 등을 실행할 수 있습니다.
<Note>
`twenty-sdk`를 전역으로 설치하지 마세요 — 각 프로젝트에 고정하여 각 앱이 자체 버전을 사용하도록 하세요.
</Note>

View File

@@ -0,0 +1,79 @@
---
title: 프로젝트 구조
description: 스캐폴딩된 Twenty 앱 안에 무엇이 들어 있는지 — 파일, 폴더, 그리고 각 요소의 역할을 설명합니다.
icon: folder-tree
---
`npx create-twenty-app`으로 생성한 새 앱은 다음과 같은 구조입니다:
```text filename="my-twenty-app/"
my-twenty-app/
package.json
src/
application-config.ts # Required — your app's entry point
default-role.ts # Permissions for logic functions
constants/
universal-identifiers.ts # Auto-generated UUIDs and metadata
front-components/
main-page.tsx # Welcome page component
navigation-menu-items/
main-page.navigation-menu-item.ts # Sidebar entry for the welcome page
page-layouts/
main-page.page-layout.ts # Standalone page hosting the component
__tests__/
application-config.test.ts # Unit test
global-setup.ts # Integration test setup (sync + uninstall)
schema.integration-test.ts # Integration test against a live server
.github/workflows/
ci.yml # Lint, typecheck, unit + integration tests
cd.yml # Deploy + install on push to main
publish.yml # Publish to npm on version tags (with provenance)
public/
logo.svg # Static assets
vitest.config.ts # Integration test runner config
vitest.unit.config.ts # Unit test runner config
tsconfig.json, tsconfig.spec.json
.nvmrc, .yarnrc.yml, .oxlintrc.json
README.md, AGENTS.md, CLAUDE.md, CHANGELOG.md, SETUP.md
```
## 주요 파일
| 파일 / 폴더 | 목적 |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `src/application-config.ts` | **필수.** 앱의 기본 구성 파일입니다. |
| `src/default-role.ts` | 로직 함수가 접근할 수 있는 범위를 제어하는 기본 역할 |
| `src/constants/universal-identifiers.ts` | 자동 생성된 UUID와 앱 메타데이터(표시 이름, 설명). |
| `src/front-components/`, `src/navigation-menu-items/`, `src/page-layouts/` | 시작용 환영 페이지: 사이드바에서 접근할 수 있는, 독립적인 페이지 레이아웃에 의해 렌더링되는 프런트 컴포넌트입니다. |
| `src/__tests__/` | 실제 서버와 앱을 동기화하는 통합 테스트(글로벌 설정 포함)와 단위 테스트입니다. |
| `public/` | 앱과 함께 제공되는 정적 에셋(이미지, 폰트). |
| `AGENTS.md` / `CLAUDE.md` | 앱에서 작업하는 AI 코딩 에이전트를 위한 안내서입니다. |
| `CHANGELOG.md` / `SETUP.md` | 주요 변경 로그와 로컬 개발을 위한 설정 지침입니다. |
<Note>
**파일 구성은 사용자의 선택입니다.** 위 폴더들은 관례일 뿐이며, SDK는 파일 위치와 관계없이 `export default defineEntity(...)` 호출에 대한 AST 분석을 통해 엔티티를 감지합니다.
</Note>
## 의존성
두 Twenty SDK 패키지는 `dependencies`가 아니라 `devDependencies` 아래에 속해야 합니다.
```json filename="package.json"
{
"dependencies": {},
"devDependencies": {
"twenty-client-sdk": "2.20.0",
"twenty-sdk": "2.20.0",
"twenty-ui": "1.0.0-alpha.1"
}
}
```
스캐폴더는 `twenty-sdk`와 `twenty-client-sdk`를 자체 버전에 고정합니다. 업그레이드할 때 두 패키지의 버전을 동기화된 상태로 유지하세요.
* \*\*`twenty-sdk`\*\*는 `twenty` CLI와 빌드/스캐폴딩 도구를 제공합니다. 이 패키지는 개발 및 빌드 시점에만 실행되며, 배포된 앱의 런타임에서는 전혀 임포트되지 않습니다.
* \*\*`twenty-client-sdk`\*\*는 앱 코드(`CoreApiClient`, `MetadataApiClient`, `RestApiClient`)에서 임포트되지만, 런타임에는 Twenty가 이를 제공합니다. 로직 함수는 생성된 SDK 레이어에서 이를 가져오고, 프런트엔드 컴포넌트는 서버에서 제공되는 모듈에서 이를 해석하여 가져옵니다. 설치된 사본은 타입 검사와 배포 시점 빌드에만 사용되므로, 배포된 번들에 포함되어 함께 제공될 필요가 없습니다.
어느 한 패키지라도 `dependencies` 아래에 두면, 설치된 앱의 런타임 번들에 포함되어 쓸모없는 부하가 됩니다. `twenty dev:build`는 둘 중 하나라도 여전히 `dependencies` 아래에 나열되어 있으면 경고를 출력합니다.
앱의 실제 런타임 의존성(로직 함수가 런타임에 실제로 임포트하는 라이브러리)은 평소와 같이 `dependencies` 아래에 추가하세요. 의존성 트리를 슬림하게 유지하세요: `dependencies`는 압축 해제 기준 250MB로 제한된 런타임 레이어에 설치되며, 이 용량을 초과하면 동기화 또는 설치가 의존성 크기 오류와 함께 실패합니다. UI 라이브러리와 개발 도구는 `devDependencies`에 포함되어야 합니다.

View File

@@ -0,0 +1,166 @@
---
title: 빠른 시작
icon: rocket
description: 몇 분 만에 첫 번째 Twenty 앱을 만들어 보세요.
---
## 사전 준비
* **Node.js 24.5+** — [여기에서 다운로드](https://nodejs.org/)
* **Yarn 4** — Corepack을 통해 Node.js와 함께 제공됩니다. 활성화하려면: `corepack enable`
* **Docker** — [여기에서 다운로드](https://www.docker.com/products/docker-desktop/). 로컬 Twenty 서버를 실행하려면 필요합니다. 이미 다른 곳에서 Twenty가 실행 중이라면 건너뛰세요.
Twenty 앱을 빌드하는 과정은 세 단계로 이루어집니다. 스캐폴더는 이를 단일 해피 패스 명령으로 합쳐 주지만, 각 단계는 별개의 개념입니다 — 문제가 발생했을 때 현재 단계가 어디인지 알면 무엇을 고쳐야 하는지 파악할 수 있습니다.
| 단계 | 하는 일 | 도구 | 결과 |
| ------------ | ------------------------- | ----------------------------------- | -------------------- |
| **1. 스캐폴딩** | 앱의 소스 코드를 생성 | `npx create-twenty-app` | 디스크에 TypeScript 프로젝트 |
| **2. 서버 실행** | 동기화 대상으로 사용할 Twenty 서버 시작 | Docker + `yarn twenty docker:start` | 실행 중인 Twenty 인스턴스 |
| **3. 동기화** | 코드를 서버와 실시간 동기화 | `yarn twenty dev` | 변경 사항이 UI에 표시됨 |
---
## 1단계 — 프로젝트 스캐폴딩
템플릿에서 새 앱을 생성합니다:
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app
```
스캐폴더는 비대화식입니다. 디렉터리 이름이 앱 이름이 됩니다. 생성되는 메타데이터를 사용자 지정하려면 `--display-name` 및 `--description`을(를) 전달합니다(나중에 `src/constants/universal-identifiers.ts`에서 수정할 수도 있습니다). 이 명령은 `my-twenty-app/`에 시작용 `application-config.ts`, 기본 역할, CI/CD 워크플로, 통합 테스트가 포함된 TypeScript 프로젝트를 생성합니다.
**이 단계를 마치면:** 로컬 머신에 앱의 소스 코드가 준비됩니다. 아직 실행되지는 않았습니다 — 그건 2단계에서 진행합니다.
---
## 2단계 — 로컬 Twenty 서버 실행
앱은 동기화할 Twenty 서버가 필요합니다. 이 서버는 Docker에서 로컬로 실행되는 완전한 Twenty 인스턴스입니다 — UI, GraphQL API, PostgreSQL을 포함합니다. 로컬 코드가 해당 서버로 정의를 업로드하면 UI에 표시됩니다.
Scaffolder가 이를 대신 시작합니다. Docker가 실행 중이면 `twentycrm/twenty-app-dev` 이미지를 pull 하고, 포트 `2020`에서 시작한 다음, 미리 시드된 데모 워크스페이스(`tim@apple.dev`)에 대해 CLI를 인증합니다. 별도의 로그인은 필요하지 않습니다.
대신 기존 Twenty 서버에 연결하려면 `--url \<your-server-url>`을 전달하세요. 원격 서버는 OAuth로 인증합니다. 브라우저가 열리면 로그인한 뒤 **Authorize**를 클릭해 워크스페이스에 대한 CLI 액세스를 허용하면 됩니다. (로컬에서도 `--authentication-method oauth`로 OAuth를 선택할 수 있습니다. `tim@apple.dev` / `tim@apple.dev`로 로그인하세요.)
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/login.png" alt="Twenty 로그인 화면" />
</div>
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Twenty CLI 권한 부여 화면" />
</div>
권한 부여가 완료되면 터미널에 설정이 완료되었다는 메시지가 표시됩니다.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="앱 스캐폴딩 성공" />
</div>
**이 단계를 마치면:** [http://localhost:2020](http://localhost:2020)에서 Twenty 서버가 실행 중이며, CLI가 해당 서버로 동기화하도록 권한이 부여됩니다.
<Note>
Docker가 설치되어 있지 않거나 실행 중이 아니면, 스캐폴더가 OS에 맞는 올바른 시작 명령을 알려줍니다. Docker가 올라오면 `yarn twenty docker:start`로 이어서 진행할 수 있습니다 — 다시 스캐폴딩할 필요는 없습니다.
</Note>
---
## 3단계 — 변경 사항 동기화
이 단계는 대부분의 시간을 보내게 될 내부 루프입니다.
```bash filename="Terminal"
cd my-twenty-app
yarn twenty dev
```
`src/`를 감시하며 변경될 때마다 다시 빌드하고, 결과를 서버에 동기화합니다. 파일을 수정해 저장하면 몇 초 안에 서버에 변경 사항이 반영됩니다. 터미널에서 실시간 상태 패널을 확인할 수 있습니다.
더 자세한 출력(빌드 로그, 동기화 요청, 오류 트레이스)을 보려면 `--verbose`를 추가하세요.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/dev.png" alt="개발 모드 터미널 출력" />
</div>
[http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer)를 엽니다. **Your Apps** 아래에 앱이 표시됩니다.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Your Apps 목록에 My twenty app이 표시됨" />
</div>
**My twenty app**을 클릭하여 **애플리케이션 등록**을 확인하세요 — 앱을 설명하는 서버 수준의 레코드입니다(이름, 식별자, OAuth 자격 증명, 소스). 하나의 등록은 동일한 서버의 여러 워크스페이스에 설치할 수 있습니다.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="애플리케이션 등록 세부정보" />
</div>
워크스페이스 설치를 확인하려면 **View installed app**을 클릭하세요. **About** 탭에는 버전과 관리 옵션이 표시됩니다.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="설치된 앱" />
</div>
**이 단계를 마치면:** 라이브 개발 루프가 준비됩니다. `src/`의 파일을 수정하면 UI에 반영됩니다.
### CI 및 스크립트를 위한 1회성 동기화
워처 없이 동일한 파이프라인을 한 번만 실행하려면 `plan`과 `apply`를 사용하세요:
```bash filename="Terminal"
yarn twenty plan # preview the metadata changes without applying them
yarn twenty apply # show the plan, then apply it
```
| 명령 | 동작 | 사용 시점 |
| ------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `yarn twenty dev` | 소스 파일을 감시하고 변경될 때마다 다시 동기화합니다. 중지할 때까지 계속 실행됩니다. | 대화형 로컬 개발. |
| `yarn twenty apply` | 한 번만 빌드 + 동기화를 수행하고, 성공 시 `0`, 실패 시 `1`로 종료합니다. 파괴적인 변경 사항에 대해 확인을 요청합니다(건너뛰려면 `--force`를 전달하세요). | CI, pre-commit 훅, AI 에이전트, 스크립트형 워크플로. |
| `yarn twenty plan` | 메타데이터 변경 사항을 **실제로 적용하지 않고** 빌드하고 출력합니다. | 커밋하기 전에 동기화가 어떤 변경을 수행할지 살펴봅니다. |
모든 모드에는 인증된 리모트가 필요합니다. `plan`에 대한 자세한 내용은 [동기화 및 복구](/l/ko/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan)를 참고하세요.
<Note>
`yarn twenty dev --once` 및 `yarn twenty dev --once --dry-run`은 `yarn twenty apply` 및 `yarn twenty plan`의 사용 중단된 별칭입니다.
</Note>
### 개발 모드 옵션
| 플래그 | 설명 |
| ------------------------------------- | --------------------------------------------- |
| `--force` | 확인 없이 파괴적인 변경 사항(삭제)을 적용합니다. |
| `--debounceMs \<ms>` | 파일 변경 디바운스 지연 시간을 밀리초 단위로 설정합니다(기본값: `1000`). |
| `--verbose` / `--debug` | 자세한 빌드 로그, 동기화 요청, 오류 추적을 표시합니다. |
## 만들 수 있는 것
앱은 **엔터티**로 구성됩니다 — 각 엔터티는 하나의 `export default`만 포함하는 TypeScript 파일로 정의됩니다:
| 엔터티 | 하는 일 |
| ------------- | ---------------------------------------------------------- |
| **객체 & 필드** | 사용자 정의 데이터 모델(Post Card, Invoice 등) 타입이 지정된 필드 포함 |
| **로직 함수** | HTTP 라우트, cron 스케줄 또는 데이터베이스 이벤트로 트리거되는 서버 측 TypeScript 코드 |
| **프론트 컴포넌트** | Twenty의 UI(사이드 패널, 위젯, 명령 메뉴) 안에 렌더링되는 React 컴포넌트 |
| **스킬 & 에이전트** | AI 기능 — 재사용 가능한 지침과 자율형 어시스턴트 |
| **뷰 & 내비게이션** | 사전 구성된 목록 뷰와 사이드바 메뉴 항목 |
| **페이지 레이아웃** | 탭과 위젯이 있는 사용자 정의 레코드 상세 페이지 |
전체 참고 문서: [개념](/l/ko/developers/extend/apps/getting-started/concepts).
## 다음 단계
<CardGroup cols={2}>
<Card title="설정" icon="screwdriver-wrench" href="/l/ko/developers/extend/apps/config/overview">
애플리케이션 ID, 기본 역할, 설치 및 제거 훅, 공개 자산.
</Card>
<Card title="데이터" icon="database" href="/l/ko/developers/extend/apps/data/overview">
객체, 필드, 그리고 양방향 관계.
</Card>
<Card title="로직" icon="bolt" href="/l/ko/developers/extend/apps/logic/overview">
로직 함수, 스킬, 에이전트, OAuth 연결.
</Card>
<Card title="레이아웃" icon="table-columns" href="/l/ko/developers/extend/apps/layout/overview">
뷰, 내비게이션, 페이지 레이아웃, 프론트 컴포넌트.
</Card>
<Card title="작업" icon="rocket" href="/l/ko/developers/extend/apps/operations/overview">
CLI, 테스트, 리모트, CI, 그리고 앱 게시.
</Card>
</CardGroup>

View File

@@ -0,0 +1,63 @@
---
title: 스캐폴딩
description: "`yarn twenty dev:add`를 사용해 엔티티 파일을 대화형으로 생성하여 객체, 필드, 뷰, 로직 함수 등을 추가하세요."
icon: wand-magic-sparkles
---
엔티티 파일을 수동으로 만드는 대신, 대화형 스캐폴더를 사용하세요:
```bash filename="Terminal"
yarn twenty dev:add
```
이 도구는 엔티티 유형을 선택하도록 안내하고, 필요한 필드를 하나씩 진행한 다음, 안정적인 `universalIdentifier`와 올바른 `defineEntity()` 호출이 포함된 바로 사용할 수 있는 파일을 생성합니다.
첫 번째 프롬프트를 건너뛰려면 엔티티 타입을 직접 전달할 수도 있습니다:
```bash filename="Terminal"
yarn twenty dev:add object
yarn twenty dev:add logicFunction
yarn twenty dev:add frontComponent
```
## 사용 가능한 엔티티 타입
| 엔티티 유형 | 명령 | 생성된 파일 |
| ----------- | ------------------------------------------ | --------------------------------------------------------- |
| 객체 | `yarn twenty dev:add object` | `src/objects/\<name>.ts` |
| 필드 | `yarn twenty dev:add field` | `src/fields/\<name>.ts` |
| 로직 함수 | `yarn twenty dev:add logicFunction` | `src/logic-functions/\<name>.ts` |
| 프런트 컴포넌트 | `yarn twenty dev:add frontComponent` | `src/front-components/\<name>.tsx` |
| 역할 | `yarn twenty dev:add role` | `src/roles/\<name>.ts` |
| 스킬 | `yarn twenty dev:add skill` | `src/skills/\<name>.ts` |
| 에이전트 | `yarn twenty dev:add agent` | `src/agents/\<name>.ts` |
| 뷰 | `yarn twenty dev:add view` | `src/views/\<name>.ts` |
| 내비게이션 메뉴 항목 | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/\<name>.ts` |
| 페이지 레이아웃 | `yarn twenty dev:add pageLayout` | `src/page-layouts/\<name>.ts` |
| 페이지 레이아웃 탭 | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/\<name>.ts` |
| 명령 메뉴 항목 | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/\<name>.ts` |
| 보기 필드 | `yarn twenty dev:add viewField` | `src/view-fields/\<name>.ts` |
| 연결 제공자 | `yarn twenty dev:add connectionProvider` | `src/connection-providers/\<name>.ts` |
| 타임라인 활동 유형 | `yarn twenty dev:add timelineActivityType` | `src/timeline-activity-types/\<name>.ts` |
## 스캐폴더가 생성하는 것
각 엔티티 타입은 자체 템플릿을 가집니다. 예를 들어, `yarn twenty dev:add object`는 다음을 묻습니다:
1. **이름(단수)** — 예: `invoice`
2. **이름(복수)** — 예: `invoices`
3. **레이블(단수)** — 이름에서 자동으로 채워짐(예: `Invoice`)
4. **레이블(복수)** — 자동으로 채워짐(예: `Invoices`)
5. **뷰와 내비게이션 항목을 생성할까요?** — 예라고 답하면, 스캐폴더가 새 객체에 대한 일치하는 뷰와 사이드바 링크도 생성합니다.
다른 엔티티 타입은 더 단순한 프롬프트를 사용하며 — 대부분 이름만 묻습니다.
`field` 엔티티 타입은 더 자세합니다: 필드 이름, 레이블, 타입( `TEXT`, `NUMBER`, `SELECT`, `RELATION` 등 사용 가능한 모든 필드 타입 목록에서 선택), 그리고 대상 객체의 `universalIdentifier`를 묻습니다.
## 사용자 지정 출력 경로
`--path` 플래그를 사용해 생성된 파일을 사용자 지정 위치에 배치하세요:
```bash filename="Terminal"
yarn twenty dev:add logicFunction --path src/custom-folder
```

View File

@@ -0,0 +1,14 @@
---
title: 문제 해결
description: 일반적인 첫 실행 문제 — Docker, Node 버전, Yarn, 의존성.
icon: wrench
---
* **Docker 오류** — `yarn twenty docker:start`를 실행하기 전에 Docker Desktop(또는 데몬)이 실행 중인지 확인하세요. 오류 메시지에 OS에 맞는 올바른 시작 명령이 표시됩니다.
* **잘못된 Node 버전** — 24.5+가 필요합니다 (`engines.node: ^24.5.0`). `node -v`로 확인하세요.
* **Yarn 4 누락** — `corepack enable`을 실행하세요.
* **의존성 문제** — `rm -rf node_modules && yarn install`.
* **`twenty-sdk` v2.8.0으로 업그레이드한 후 오류 발생** — v2.8.0에서 `dependencies`에서 `devDependencies`로 이동했습니다. [프로젝트 구조 → Dependencies](/l/ko/developers/extend/apps/getting-started/project-structure#dependencies)를 참조하세요.
* **`twenty dev:build`는 `dependencies` 아래의 `twenty-client-sdk`에 대해 경고합니다** — 이는 실행 시 Twenty에서 제공되므로, `twenty-sdk`와 함께 `devDependencies`로 옮겨야 합니다. [프로젝트 구조 → Dependencies](/l/ko/developers/extend/apps/getting-started/project-structure#dependencies)를 참조하세요.
막히셨나요? [Twenty Discord](https://discord.com/channels/1130383047699738754/1130386664812982322)에서 문의하세요.

View File

@@ -0,0 +1,118 @@
---
title: 명령 메뉴 항목
description: 프런트 컴포넌트를 빠른 작업 및 명령 메뉴(Cmd+K) 항목으로 노출하려면 `defineCommandMenuItem`을 사용합니다.
icon: terminal
---
**명령 메뉴 항목**은 사용자와 [프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components) 사이를 연결하는 다리입니다. 이 항목은 Twenty의 명령 메뉴(Cmd+K)에 구성 요소를 등록하고, 선택적으로 페이지 오른쪽 상단 모서리에 고정된 빠른 작업 버튼으로도 등록합니다.
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
label: 'Open Dashboard',
shortLabel: 'Dashboard',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
## 구성 필드
| 필드 | 필수 | 설명 |
| --------------------------------------- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `universalIdentifier` | 예 | 명령의 안정적인 고유 ID |
| `label` | 예 | 명령 메뉴(Cmd+K)에 표시되는 전체 레이블 |
| `frontComponentUniversalIdentifier` | 예 | 이 명령으로 열리는 프런트 컴포넌트의 `universalIdentifier` |
| `shortLabel` | 아니요 | 고정된 빠른 작업 버튼에 표시되는 더 짧은 레이블 |
| `icon` | 아니요 | **사용 중단됨** — 애플리케이션 아이콘이 대신 사용되며, 설정된 경우 빌드 시 경고가 발생합니다. |
| `isPinned` | 아니요 | `true`이면 페이지 우측 상단에 빠른 작업 버튼으로 명령을 표시합니다 |
| `availabilityType` | 아니요 | 명령이 표시되는 위치를 제어합니다: `'GLOBAL'`(항상 사용 가능), `'GLOBAL_OBJECT_CONTEXT'`(객체 컨텍스트가 있는 페이지에서만 — 인덱스 및 레코드 페이지), `'RECORD_SELECTION'`(레코드가 선택된 경우에만), `'FALLBACK'`(다른 명령이 일치하지 않을 때 표시). |
| `availabilityObjectUniversalIdentifier` | 아니요 | 명령을 특정 객체 타입의 페이지로 제한합니다(예: Company 레코드에서만) |
| `conditionalAvailabilityExpression` | 아니요 | 표시 여부를 동적으로 제어하는 불리언 표현식(아래 참조) |
## 헤드리스 명령
[헤드리스 프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components#headless-vs-non-headless)와 짝을 이룬 명령 메뉴 항목은 원클릭 작업—코드 실행, 이동, 확인 후 실행—을 제공하는 가장 전형적인 방식입니다. 프런트 컴포넌트 페이지에서는 동작 후 언마운트 패턴을 처리하는 [SDK Command 구성 요소](/l/ko/developers/extend/apps/layout/front-components#sdk-command-components) (`Command`, `CommandLink`, `CommandModal`, `CommandOpenSidePanelPage`)를 다룹니다.
일반적인 흐름: 헤드리스 컴포넌트가 `<Command execute={...} />`( [전체 예제](/l/ko/developers/extend/apps/layout/front-components#sdk-command-components)를 참고)와 같이 렌더링하고, 명령 메뉴 항목이 이를 가리킵니다.
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
## 조건부 가용성 표현식
`conditionalAvailabilityExpression` 필드를 사용하면 현재 페이지 컨텍스트에 따라 명령의 표시 시점을 제어할 수 있습니다. 표현식을 만들기 위해 `twenty-sdk`에서 타입이 지정된 변수와 연산자를 임포트하세요:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '...',
label: 'Bulk Update',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: '...',
conditionalAvailabilityExpression: everyEquals(
objectPermissions,
'canUpdateObjectRecords',
true,
),
});
```
<Note>
`RECORD_SELECTION`은 이미 비어 있지 않은 선택을 의미하므로, 특정 개수를 나타낼 때만(예: `>= 2`) `numberOfSelectedRecords`를 사용하세요.
</Note>
### 컨텍스트 변수
이는 현재 페이지의 상태를 나타냅니다:
| 변수 | 유형 | 설명 |
| ------------------------------ | --------- | ------------------------------------------------- |
| `pageType` | `string` | 현재 페이지 타입(예: 'RecordIndexPage', 'RecordShowPage') |
| `isInSidePanel` | `boolean` | 컴포넌트가 사이드 패널에서 렌더링되는지 여부 |
| `numberOfSelectedRecords` | `number` | 현재 선택된 레코드 수 |
| `isSelectAll` | `boolean` | "전체 선택"이 활성화되었는지 여부 |
| `selectedRecords` | `array` | 선택된 레코드 객체 |
| `favoriteRecordIds` | `array` | 즐겨찾기된 레코드의 ID들 |
| `objectPermissions` | `object` | 현재 객체 타입에 대한 권한 |
| `targetObjectReadPermissions` | `object` | 대상 객체에 대한 읽기 권한 |
| `targetObjectWritePermissions` | `object` | 대상 객체에 대한 쓰기 권한 |
| `featureFlags` | `object` | 활성화된 기능 플래그 |
| `objectMetadataItem` | `object` | 현재 객체 타입의 메타데이터 |
| `hasAnySoftDeleteFilterOnView` | `boolean` | 현재 뷰에 소프트 삭제 필터가 있는지 여부 |
### 연산자
변수를 결합해 불리언 표현식을 만듭니다:
| 연산자 | 설명 |
| ----------------------------------- | --------------------------------------- |
| `isDefined(value)` | 값이 null/undefined가 아니면 `true` |
| `isNonEmptyString(value)` | 값이 비어 있지 않은 문자열이면 `true` |
| `includes(array, value)` | 배열에 해당 값이 포함되어 있으면 `true` |
| `includesEvery(array, prop, value)` | 모든 항목의 속성에 해당 값이 포함되어 있으면 `true` |
| `every(array, prop)` | 모든 항목에서 해당 속성이 truthy이면 `true` |
| `everyDefined(array, prop)` | 모든 항목에서 해당 속성이 정의되어 있으면 `true` |
| `everyEquals(array, prop, value)` | 모든 항목에서 해당 속성이 그 값과 같으면 `true` |
| `some(array, prop)` | 적어도 한 항목에서 해당 속성이 truthy이면 `true` |
| `someDefined(array, prop)` | 적어도 한 항목에서 해당 속성이 정의되어 있으면 `true` |
| `someEquals(array, prop, value)` | 적어도 한 항목에서 해당 속성이 그 값과 같으면 `true` |
| `someNonEmptyString(array, prop)` | 적어도 한 항목에서 해당 속성이 비어 있지 않은 문자열이면 `true` |
| `none(array, prop)` | 모든 항목에서 해당 속성이 falsy이면 `true` |
| `noneDefined(array, prop)` | 모든 항목에서 해당 속성이 정의되지 않았으면 `true` |
| `noneEquals(array, prop, value)` | 어떤 항목에서도 해당 속성이 그 값과 같지 않으면 `true` |

View File

@@ -0,0 +1,849 @@
---
title: 프런트 컴포넌트
description: Twenty의 UI 내부에서 샌드박스 환경으로 격리된 채 렌더링되는 React 컴포넌트를 빌드하세요.
icon: window-maximize
---
프런트 컴포넌트는 Twenty의 UI 내부에서 직접 렌더링되는 React 컴포넌트입니다. 이들은 Remote DOM을 사용하는 **격리된 Web Worker**에서 실행됩니다 — 코드는 샌드박스 처리된 opaque-origin iframe 내부에서 실행되지만, UI는 해당 iframe 안에 갇히지 않고 페이지 내에서 네이티브로 렌더링됩니다.
<Warning>
Front 컴포넌트는 여전히 활발히 개발 중입니다. 실제 브라우저 페이지가 아닌 부분적인 DOM을 대상으로 코드를 실행하므로 고급 사용 시에는 종종 아무런 표시 없이 실패할 수 있습니다. [현재 제한 사항](#current-limitations)을 참고하세요.
</Warning>
## 프런트 컴포넌트를 사용할 수 있는 위치
프런트 컴포넌트는 Twenty 내에서 세 위치에 렌더링될 수 있습니다:
* **사이드 패널** — 비헤드리스 프런트 컴포넌트는 오른쪽 사이드 패널에서 열립니다. 이는 명령 메뉴에서 프런트 컴포넌트를 트리거할 때의 기본 동작입니다.
* **위젯(대시보드 및 레코드 페이지)** — 프런트 컴포넌트를 [페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts) 내 위젯으로 삽입할 수 있습니다. 대시보드 또는 레코드 페이지 레이아웃을 구성할 때 사용자는 프런트 컴포넌트 위젯을 추가할 수 있습니다.
* **App settings** — [`defineSettingsFrontComponent()`](#custom-settings-component)으로 정의되며, 이 프런트 컴포넌트는 기본 변수 구성 UI 대신 앱의 **Settings** 탭 내부 섹션으로 렌더링됩니다.
프런트 컴포넌트만으로는 UI에서 직접 접근할 수 없으므로 *표시*해야 합니다. 이를 수행하는 세 가지 방법은 다음과 같습니다.
* **[명령 메뉴 항목](/l/ko/developers/extend/apps/layout/command-menu-items)과 연결** — 명령 메뉴(Cmd+K)에 등록하고, 선택적으로 고정된 빠른 작업으로 등록합니다.
* **[페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts)에 위젯으로 포함** — 레코드 상세 페이지 또는 대시보드에 배치합니다.
* **[`defineSettingsFrontComponent()`](#custom-settings-component)로 정의** — 기본 변수 구성 UI 대신 앱의 **Settings** 탭 내부 섹션으로 렌더링합니다.
## 기본 예제
프런트 컴포넌트가 실제로 동작하는 모습을 가장 빨리 확인하는 방법은 [`defineCommandMenuItem`](/l/ko/developers/extend/apps/layout/command-menu-items)과 연결하여 페이지 오른쪽 상단에 빠른 작업 버튼으로 표시되게 하는 것입니다.
```tsx src/front-components/hello-world.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello from my app!</h1>
<p>This component renders inside Twenty.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
name: 'hello-world',
description: 'A simple front component',
component: HelloWorld,
});
```
```ts src/command-menu-items/hello-world.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
shortLabel: 'Hello',
label: 'Hello World',
isPinned: true,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```
`yarn twenty dev`로 동기화한 후(또는 일회성으로 `yarn twenty apply`를 실행한 경우), 페이지 우측 상단에 빠른 작업이 표시됩니다:
<div style={{textAlign: 'center'}}>
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="우측 상단의 빠른 작업 버튼" />
</div>
클릭하면 컴포넌트를 인라인으로 렌더링합니다.
## 구성 필드
| 필드 | 필수 | 설명 |
| --------------------- | --- | -------------------------------------- |
| `universalIdentifier` | 예 | 이 컴포넌트의 안정적인 고유 ID |
| `component` | 예 | React 컴포넌트 함수 |
| `name` | 아니요 | 표시 이름 |
| `description` | 아니요 | 컴포넌트가 수행하는 작업에 대한 설명 |
| `isHeadless` | 아니요 | 컴포넌트에 보이는 UI가 없다면 `true`로 설정하세요(아래 참조) |
## 프런트 컴포넌트를 페이지에 배치하기
명령 외에도, 페이지 레이아웃에 위젯으로 추가하여 레코드 페이지에 프런트 컴포넌트를 직접 임베드할 수 있습니다. 자세한 내용은 [페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts)을 참조하세요.
## 사용자 정의 설정 컴포넌트
앱의 **Settings** 탭에서 자동 생성되는 변수 구성 UI를 사용자 정의 컴포넌트로 대체하려면, `defineFrontComponent` 대신 `defineSettingsFrontComponent`로 정의하세요. 이 설정 컴포넌트는 항상 눈에 보이는 UI를 렌더링하므로 허용되지 않는 `isHeadless`를 제외하고, 동일한 [구성 필드](#configuration-fields)를 사용하며, 추가로 이 컴포넌트를 앱의 설정 UI로 표시합니다.
이 컴포넌트는 전체 탭을 대체하는 것이 아니라, Settings 탭 내부의 섹션으로 렌더링됩니다. Twenty의 시스템 관리 섹션(자동 업그레이드, App URL, 연결)은 항상 이 컴포넌트 위에 렌더링되며, 앱에서 이를 재정의할 수 없습니다.
```tsx src/front-components/app-settings.tsx
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
const AppSettings = () => {
return (
<div style={{ padding: '20px' }}>
<h2>My app settings</h2>
{/* render your own configuration UI here */}
</div>
);
};
export default defineSettingsFrontComponent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'app-settings',
description: "Custom UI for the app's Settings tab",
component: AppSettings,
});
```
앱당 하나의 설정 프런트 컴포넌트만 허용되며, 둘 이상 선언하면 빌드가 실패합니다. 설정 프런트 컴포넌트가 존재하는 경우, 앱의 **Settings** 탭은 기본 변수 구성 UI 대신 이 컴포넌트를 렌더링합니다.
## 헤드리스 vs 비헤드리스
프런트 컴포넌트는 `isHeadless` 옵션으로 제어되는 두 가지 렌더링 모드를 제공합니다:
**비헤드리스(기본값)** — 컴포넌트가 가시적인 UI를 렌더링합니다. 명령 메뉴에서 트리거되면 사이드 패널에서 열립니다. `isHeadless`가 `false`이거나 생략된 경우의 기본 동작입니다.
**헤드리스 (`isHeadless: true`)** — 컴포넌트가 백그라운드에서 보이지 않게 마운트됩니다. 사이드 패널을 열지 않습니다. 헤드리스 컴포넌트는 로직을 실행한 뒤 스스로 언마운트하는 작업에 맞게 설계되었습니다 — 예: 비동기 작업 실행, 페이지로 이동, 확인 모달 표시. 아래에 설명된 SDK Command 컴포넌트와 자연스럽게 짝을 이룹니다.
```tsx src/front-components/sync-tracker.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds, enqueueSnackbar } from 'twenty-sdk/front-component';
import { useEffect } from 'react';
const SyncTracker = () => {
const [recordId] = useSelectedRecordIds();
useEffect(() => {
enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
}, [recordId]);
return null;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-tracker',
description: 'Tracks record views silently',
isHeadless: true,
component: SyncTracker,
});
```
컴포넌트가 `null`을 반환하기 때문에, Twenty는 해당 컴포넌트에 대한 컨테이너 렌더링을 생략합니다 — 레이아웃에 빈 공간이 생기지 않습니다. 컴포넌트는 여전히 모든 훅과 호스트 통신 API에 접근할 수 있습니다.
## SDK Command 컴포넌트
`twenty-sdk` 패키지는 헤드리스 프런트 컴포넌트를 위해 설계된 네 가지 Command 헬퍼 컴포넌트를 제공합니다. 각 컴포넌트는 마운트 시 동작을 실행하고, 스낵바 알림을 표시하여 오류를 처리하며, 완료되면 프런트 컴포넌트를 자동으로 언마운트합니다.
`twenty-sdk/front-component`에서 임포트하세요:
* **`Command`** — `execute` prop을 통해 비동기 콜백을 실행합니다.
* **`CommandLink`** — 앱 경로로 이동합니다. Props: `to`, `params`, `queryParams`, `options`.
* **`CommandModal`** — 확인 모달을 엽니다. 사용자가 확인하면 `execute` 콜백을 실행합니다. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
* **`CommandOpenSidePanelPage`** — 사이드 패널 페이지를 엽니다. Props는 `page`에 따라 달라집니다. 예를 들어 `ViewRecord`는 `recordId`와 `objectNameSingular`를 받고, 선택적으로 특정 탭에서 레코드를 열기 위한 `tab` id도 받을 수 있습니다. 다른 페이지는 `pageTitle`과 `pageIcon`을 받습니다.
`Command`를 사용해 명령 메뉴에서 동작을 실행하는 헤드리스 프런트 컴포넌트의 전체 예시는 다음과 같습니다:
```tsx src/front-components/run-action.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const RunAction = () => {
const execute = async () => {
const client = new CoreApiClient();
await client.mutation({
createTask: {
__args: { data: { title: 'Created by my app' } },
id: true,
},
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
name: 'run-action',
description: 'Creates a task from the command menu',
component: RunAction,
isHeadless: true,
});
```
```ts src/command-menu-items/run-action.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
label: 'Run my action',
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```
그리고 실행 전에 확인을 요청하기 위해 `CommandModal`을 사용하는 예시는 다음과 같습니다:
```tsx src/front-components/delete-draft.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { CommandModal } from 'twenty-sdk/front-component';
const DeleteDraft = () => {
const execute = async () => {
// perform the deletion
};
return (
<CommandModal
title="Delete draft?"
subtitle="This action cannot be undone."
execute={execute}
confirmButtonText="Delete"
confirmButtonAccent="danger"
/>
);
};
export default defineFrontComponent({
universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
name: 'delete-draft',
description: 'Deletes a draft with confirmation',
component: DeleteDraft,
isHeadless: true,
});
```
그리고 `CommandOpenSidePanelPage`를 사용하여 현재 레코드를 사이드 패널의 특정 탭에서 여는 예시는 다음과 같습니다. `tab`은 페이지 레이아웃 탭 id입니다(기본 레이아웃은 `company-tab-emails` 또는 `company-tab-timeline`과 같은 id를 사용하고, 사용자 지정 레이아웃은 탭 고유의 id를 사용합니다). 해당 id가 레코드의 레이아웃에 존재하지 않으면, 대신 기본 탭이 열립니다:
```tsx src/front-components/open-company-emails.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
CommandOpenSidePanelPage,
SidePanelPages,
useSelectedRecordIds,
} from 'twenty-sdk/front-component';
const OpenCompanyEmails = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
if (!recordId) {
return null;
}
return (
<CommandOpenSidePanelPage
page={SidePanelPages.ViewRecord}
recordId={recordId}
objectNameSingular="company"
tab="company-tab-emails"
resetNavigationStack={false}
/>
);
};
export default defineFrontComponent({
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
name: 'open-company-emails',
description: 'Opens the current company on its Emails tab',
component: OpenCompanyEmails,
isHeadless: true,
});
```
## 로직 함수 호출하기
Front 컴포넌트는 opaque-origin iframe 내부에 샌드박스된 Web Worker 안에서 브라우저 측에서 실행되고, [logic functions](/l/ko/developers/extend/apps/logic/logic-functions)는 서버 측에서 실행됩니다. 두 요소 사이에는 프로세스 내에서의 직접 호출이 없습니다. 대신, Front 컴포넌트는 HTTP를 통해 로직 함수에 접근합니다.
`httpRouteTriggerSettings`로 선언된 로직 함수는 HTTP를 통해 해당 라우트 경로에서 액세스할 수 있습니다. `RestApiClient`는 `/s/`로 시작하는 경로를 앱 라우트로 처리하고, 해당 경로를 함수가 제공되는 URL로 해석한 뒤 `TWENTY_APP_ACCESS_TOKEN`으로 인증합니다.
> **Twenty Cloud에서 HTTP로 트리거되는 로직 함수는 작업공간별 전용 도메인에서 제공됩니다**: `https://\<your-workspace-subdomain>.withtwenty.com\<path>`. 외부 호출자의 경우, 함수의 **HTTP trigger** 설정 또는 애플리케이션의 **Settings** 탭에서 정확한 URL을 복사하세요.
헤드리스 Front 컴포넌트는 `Command` 컴포넌트를 통해 마운트 시점에 호출을 실행한 뒤, 자동으로 언마운트될 수 있습니다:
```tsx src/front-components/sync-prs.tsx
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
const SyncPrs = () => {
const execute = async () => {
await new RestApiClient().post('/s/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
});
};
return <Command execute={execute} />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'sync-prs',
description: 'Triggers the fetch-prs logic function',
isHeadless: true,
component: SyncPrs,
});
```
`RestApiClient`에 전달되는 경로는 로직 함수의 `httpRouteTriggerSettings.path` 앞에 `/s`가 접두사로 붙은 값입니다. `isAuthRequired: true`로 유지하세요. 컴포넌트를 위해 Twenty가 발급하는 `TWENTY_APP_ACCESS_TOKEN`이 요청을 인증합니다:
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
// ...fetch from GitHub and persist records...
return { ok: true };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-prs',
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-prs',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
<Note>
`TWENTY_APP_ACCESS_TOKEN`은 자동으로 주입됩니다. 자세한 내용은 [Application variables](#application-variables)을 참고하세요. 비밀 애플리케이션 변수는 Front 컴포넌트에 절대 노출되지 않으므로, API 키 및 기타 민감한 로직은 Front 컴포넌트가 아니라 로직 함수 안에 유지해야 합니다.
</Note>
### Twenty REST API 호출하기
앱 HTTP 라우트를 호출하거나 프론트 컴포넌트에서 Twenty 레코드를 읽고 쓰려면 `twenty-client-sdk/rest`의 `RestApiClient`를 사용하세요. 이 클라이언트는 `/s/...` 경로를 작업공간의 함수 base URL로 전송하고, `/rest/...`를 포함한 그 외 모든 경로는 `TWENTY_API_URL`로 전송합니다.
항상 페이지를 보는 사람으로 작동합니다. `runAs: 'application'`은 로직 함수 전용 옵션입니다. 구성 요소는 애플리케이션 자체 토큰을 절대 받지 않으므로, 여기에서 이를 요청하면 오류가 발생합니다. 앱 자체 액세스가 필요한 작업은 로직 함수 뒤에 두고 대신 이를 호출하세요.
| 방법 | 설명 |
| --------------------------------- | ------------------------------------- |
| `get(path, options?)` | `GET` 요청을 전송합니다. |
| `post(path, body?, options?)` | `POST` 요청을 전송합니다. |
| `put(path, body?, options?)` | `PUT` 요청을 전송합니다. |
| `patch(path, body?, options?)` | `PATCH` 요청을 전송합니다. |
| `delete(path, options?)` | `DELETE` 요청을 전송합니다. |
| `request(method, path, options?)` | 임의의 HTTP 메서드를 사용하는 일반적인 요청입니다. |
| `resolveUrl(path, options?)` | 요청을 보내지 않고(예: 링크용) 경로를 전체 URL로 해석합니다. |
`options`는 `headers`, `query`(쿼리 문자열 파라미터의 레코드이며, nullish 값은 건너뜁니다), 그리고 `signal`을 통한 `AbortSignal`을 받습니다. `FormData`가 아닌 객체 `body`는 자동으로 JSON 직렬화됩니다. `401`이 발생하면 클라이언트는 호스트를 통해 한 번 액세스 토큰을 갱신한 뒤 요청을 재시도합니다.
기본 URL과 토큰은 기본적으로 환경에서 자동으로 결정됩니다. 테스트 등에서 필요할 때는 생성자에 override를 전달하세요. 예를 들면 다음과 같습니다:
```ts
const client = new RestApiClient({
baseUrl: 'https://myworkspace.twenty.com',
token: 'my-token',
});
```
실패한 요청은 `status`, `statusText`, `url`, 파싱된 `body`를 노출하는 `RestApiClientError`를 throw합니다:
```tsx
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
const client = new RestApiClient();
try {
const people = await client.get('/rest/people', {
query: { limit: 10 },
});
} catch (error) {
if (error instanceof RestApiClientError) {
console.error(error.status, error.body);
}
}
```
## 런타임 컨텍스트에 접근하기
컴포넌트 내부에서, 현재 사용자, 레코드, 컴포넌트 인스턴스에 접근하려면 SDK 훅을 사용하세요:
```tsx src/front-components/record-info.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
useUserId,
useSelectedRecordIds,
useFrontComponentId,
} from 'twenty-sdk/front-component';
const RecordInfo = () => {
const userId = useUserId();
const [recordId] = useSelectedRecordIds();
const componentId = useFrontComponentId();
return (
<div>
<p>User: {userId}</p>
<p>Record: {recordId ?? 'No record context'}</p>
<p>Component: {componentId}</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
name: 'record-info',
component: RecordInfo,
});
```
사용 가능한 훅:
| 훅 | 반환값 | 설명 |
| --------------------------------------------- | --------------------- | ---------------------------------------------- |
| `useUserId()` | `string` 또는 `null` | 현재 사용자의 ID |
| `useSelectedRecordIds()` | `string[]` | 선택된 모든 기록 ID(선택된 기록이 없으면 빈 배열) |
| `useRecordId()` | `string` 또는 `null` | **사용 중단됨.** 대신 `useSelectedRecordIds()`를 사용하세요 |
| `useFrontComponentId()` | `string` | 이 컴포넌트 인스턴스의 ID |
| `useTimelineActivityId()` | `string` 또는 `null` | 사용자 지정 타임라인 행을 렌더링할 때의 현재 타임라인 활동 ID |
| `useColorScheme()` | `'light'` 또는 `'dark'` | 호스트 UI의 활성 색 구성표 (`System`은 이미 결정됨) |
| `useFrontComponentExecutionContext(selector)` | 항목에 따라 다름 | 셀렉터 함수를 사용해 전체 실행 컨텍스트에 접근 |
## 애플리케이션 변수
[`defineApplication()`](/l/ko/developers/extend/apps/config/application)에서 `isSecret: false`로 정의된 애플리케이션 변수는 `getApplicationVariable` 유틸리티를 통해 프론트 컴포넌트 안에서 사용할 수 있습니다:
```tsx src/front-components/greeting.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const Greeting = () => {
const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';
return <p>Hello, {recipientName}!</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'greeting',
component: Greeting,
});
```
<Warning>
비밀 변수(`isSecret: true`)는 프론트 컴포넌트에 **노출되지 않습니다**. 이 변수들은 서버 측에서 실행되는 [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions)에서만 사용할 수 있습니다. 이는 API 키와 같은 민감한 값이 브라우저로 전송되는 것을 방지합니다.
</Warning>
`getApplicationVariable`은(는) 변수에 선언된 `type`과 관계없이 항상 **문자열**(또는 `undefined`)을 반환합니다. 문자열은 타입에 따라 일관되게 직렬화됩니다(불리언은 `"true"` / `"false"`, 숫자는 10진수 문자열, 배열/객체는 JSON). 이는 로직 함수 `process.env`에 사용되는 것과 동일한 형식이므로, 직접 파싱해야 합니다(`Number(...)`, `JSON.parse(...)`, `=== 'true'`). [변수 타입](/l/ko/developers/extend/apps/config/application#variable-types)을(를) 참조하세요.
다음 시스템 변수는 항상 `process.env`를 통해 사용할 수 있습니다:
| 변수 | 설명 |
| ------------------------- | ------------------------------------------------------------------------------- |
| `TWENTY_API_URL` | Twenty 코어 API의 기본 URL |
| `TWENTY_APP_ACCESS_TOKEN` | 로그인한 사람의 역할과 앱의 역할이 교차하는 범위로 지정된 단기 토큰이므로, 구성 요소는 이를 보는 사람보다 더 많은 작업을 할 수 없습니다. |
### `TWENTY_FUNCTIONS_URL`
Twenty는 또한 프론트 컴포넌트와 로직 함수에 `TWENTY_FUNCTIONS_URL`을 주입합니다. 이는 앱의 HTTP로 트리거되는 로직 함수가 제공되는 base URL입니다.
이 URL이 항상 Twenty 서버 자체를 가리키는 것은 아니기 때문에 이 변수가 존재합니다. Twenty Cloud에서는 앱 라우트가 작업공간별 전용 도메인(`https://\<your-workspace-subdomain>.withtwenty.com`, 또는 구성된 경우 애플리케이션의 기본 public 도메인)에서 제공되므로, 앱이 작성한 응답이 Twenty 앱 origin이 아니라 분리된 origin에서 실행됩니다. 셀프 호스팅 및 로컬 인스턴스에서는 앱 라우트가 서버 자체의 `/s` 접두사 아래에서 제공되며, 이 변수가 전혀 설정되지 않을 수 있습니다. base URL은 작업공간과 인스턴스마다 다르므로 코드에서 이를 하드코딩할 수 없으며, 서버가 런타임에 올바른 값을 주입합니다.
이 변수를 직접 읽어야 하는 경우는 드뭅니다. `RestApiClient`를 통해 `/s/` 접두사가 붙은 경로로 라우트를 호출하면, 클라이언트가 URL을 대신 해석합니다. `/s` 접두사를 제거하고 `TWENTY_FUNCTIONS_URL`을 대상으로 하며, 변수가 설정되어 있지 않은 경우 `\<TWENTY_API_URL>/s`를 사용합니다. 요청을 보내지 않고 절대 URL이 필요할 때(예: 링크용) `resolveUrl('/s/\<path>')`를 사용하세요. 직접 URL을 구성해야 할 때만 이 변수를 직접 읽으세요:
```ts
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
```
## 호스트 통신 API
프런트 컴포넌트는 `twenty-sdk`의 함수를 사용해 내비게이션, 모달, 알림을 트리거할 수 있습니다:
| 함수 | 설명 |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `navigate(to, params?, queryParams?, options?)` | 앱 내 페이지로 이동 |
| `openSidePanelPage(params)` | 사이드 패널 열기 |
| `closeSidePanel()` | 사이드 패널을 닫습니다 |
| `openCommandConfirmationModal(params)` | 확인 대화상자 표시 |
| `enqueueSnackbar(params)` | 토스트 알림 표시 |
| `unmountFrontComponent()` | 컴포넌트를 언마운트합니다 |
| `updateProgress(progress)` | 진행률 표시기 업데이트 |
| `uploadFile(file, { fieldMetadataId, fileName? })` | `Blob`을 FILES 필드에 업로드합니다. 이 함수는 `{ status: 'uploaded', file: { fileId, path, url, size, mimeType } }` 또는 `{ status: 'failed', reason: 'invalid-params' \| 'upload-failed' }`로 resolve됩니다. |
호스트 API를 사용하여 동작이 완료된 후 스낵바를 표시하고 사이드 패널을 닫는 예시는 다음과 같습니다:
```tsx src/front-components/archive-record.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, closeSidePanel, useSelectedRecordIds } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const ArchiveRecord = () => {
const [recordId] = useSelectedRecordIds();
const handleArchive = async () => {
const client = new CoreApiClient();
await client.mutation({
updateTask: {
__args: { id: recordId, data: { status: 'ARCHIVED' } },
id: true,
},
});
await enqueueSnackbar({
message: 'Record archived',
variant: 'success',
});
await closeSidePanel();
};
return (
<div style={{ padding: '20px' }}>
<p>Archive this record?</p>
<button onClick={handleArchive}>Archive</button>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
name: 'archive-record',
description: 'Archives the current record',
component: ArchiveRecord,
});
```
### 저장소
`localStorage`와 `sessionStorage`는 일반 페이지에서와 동일하게, 표준 동기식 API로 동작합니다. 키는 앱 설치와 로그인한 사용자 범위로만 한정됩니다. 다른 어떤 앱도 이 키를 읽을 수 없으며, 같은 브라우저에 다른 사용자가 로그인하면 비어 있는 저장소에서 시작합니다. `localStorage`에 기록된 값은 새로고침 후에도 기기에 그대로 남아 있고, `sessionStorage`는 브라우저 세션 동안만 유지됩니다.
```tsx src/front-components/note-draft.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const NoteDraft = () => {
const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');
const handleChange = (nextDraft: string) => {
setDraft(nextDraft);
localStorage.setItem('draft', nextDraft);
};
return (
<textarea value={draft} onChange={(event) => handleChange(event.target.value)} />
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-def0-890123456789',
name: 'note-draft',
description: 'Keeps an unsaved note on this device',
component: NoteDraft,
});
```
Twenty가 앱을 대신해 값을 저장하므로, 쓰기 작업은 로컬에 즉시 반영되고 백그라운드에서 저장됩니다. 읽기 작업은 호스트를 기다리지 않습니다. 아무것도 동기화되지 않습니다. 값이 사용자를 다른 브라우저나 머신으로 따라가지 않으므로, 디바이스 변경 이후에도 유지되어야 하는 값에는 로직 함수의 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)를 사용하세요.
쓰기에는 한도가 있으며, 모든 한도는 바이트가 아니라 문자 수를 기준으로 합니다. 키는 최대 512자, 단일 값은 최대 262,144자, 각 스토리지는 앱과 사용자당 최대 1,048,576자까지 저장할 수 있습니다. 한도를 초과하는 쓰기는 브라우저 API와 마찬가지로 `QuotaExceededError`를 발생시킵니다.
### 여러 기록과의 작업
여러 개의 선택된 기록을 처리하려면 `useSelectedRecordIds()`를 사용하세요. 이는 일괄 작업에 유용합니다:
```tsx src/front-components/bulk-export.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const BulkExport = () => {
const selectedRecordIds = useSelectedRecordIds();
const handleExport = async () => {
const client = new CoreApiClient();
for (const recordId of selectedRecordIds) {
await client.mutation({
updateTask: {
__args: { id: recordId, data: { exported: true } },
id: true,
},
});
}
await enqueueSnackbar({
message: `Exported ${selectedRecordIds.length} records`,
variant: 'success',
});
await closeSidePanel();
};
return (
<div style={{ padding: '20px' }}>
<p>Export {selectedRecordIds.length} selected record(s)?</p>
<button onClick={handleExport}>Export</button>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
name: 'bulk-export',
description: 'Export selected records',
component: BulkExport,
});
```
레코드 선택으로 제한된 [명령 메뉴 항목](/l/ko/developers/extend/apps/layout/command-menu-items)으로 노출하세요:
```ts src/command-menu-items/bulk-export.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
label: 'Bulk Export',
availabilityType: 'RECORD_SELECTION',
frontComponentUniversalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
});
```
## 퍼블릭 에셋
프런트 컴포넌트는 `getPublicAssetUrl`을 사용해 앱의 `public/` 디렉터리의 파일에 접근할 수 있습니다:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
export default defineFrontComponent({
universalIdentifier: '...',
name: 'logo',
component: Logo,
});
```
자세한 내용은 [퍼블릭 에셋 섹션](/l/ko/developers/extend/apps/config/public-assets)을 참조하세요.
## 프론트 컴포넌트 간의 의존성 공유하기
기본적으로 각 프론트 컴포넌트는 가져온 라이브러리의 사본을 자체적으로 번들링하므로, 컴포넌트가 다섯 개인 앱은 React를 다섯 번 제공하게 됩니다. 앱의 `package.json`에서 공유 의존성을 선언해 해당 라이브러리들을 한 번만 빌드하고, 앱의 모든 컴포넌트가 단일 캐시 파일에서 이를 불러오도록 할 수 있습니다:
```json package.json
{
"frontComponentSharedDependencies": ["react", "react-dom/client", "twenty-ui/input"]
}
```
그다음 각 컴포넌트는 이전과 완전히 동일한 방식으로 의존성을 import하며, 컴포넌트 코드에는 아무런 변경이 필요하지 않습니다:
```tsx src/front-components/counter.tsx
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'counter',
component: Counter,
});
```
알아두면 좋은 몇 가지 사항:
* **앱당 하나의 공유 의존성 번들.** 번들은 앱 자체의 의존성으로부터 빌드되므로, 배포하는 버전에 대한 완전한 통제권을 유지할 수 있습니다.
* **가져오는 정확한 specifier를 나열하세요.** `twenty-ui/input`과 `twenty-ui/display`는 두 개의 항목이며, 패키지 이름만으로는 그 하위 경로가 포함되지 않습니다. `react`를 나열하면 `react/jsx-runtime`이 자동으로 포함됩니다.
* **`react`와 함께 `react-dom/client`도 공유하세요.** 모든 컴포넌트는 `createRoot`를 통해 렌더링되므로, 이를 제외하면 각 컴포넌트가 여전히 React DOM을 번들링하게 됩니다.
* **번들은 캐시됩니다.** 이 번들은 장기 불변 캐시가 적용된 content-hash URL로 제공되므로, 한 번만 다운로드된 뒤 의존성 중 하나가 변경될 때까지 앱의 모든 컴포넌트에서 재사용됩니다.
* **공유 패키지를 전혀 import하지 않는 컴포넌트는 이 번들을 다운로드하지 않습니다.**
## 스타일링
프런트 컴포넌트는 여러 스타일링 방식을 지원합니다. 다음과 같은 방식을 사용할 수 있습니다:
* **인라인 스타일** — `style={{ color: 'red' }}`
* **Twenty UI 컴포넌트** — Twenty의 자체 컴포넌트 라이브러리입니다. 아래의 [Twenty UI 컴포넌트 사용하기](#using-twenty-ui-components)를 참조하세요.
* **Emotion** — `@emotion/react`를 사용하는 CSS-in-JS
* **Styled-components** — `styled.div` 패턴
* **Tailwind CSS** — 유틸리티 클래스
* React와 호환되는 **모든 CSS-in-JS 라이브러리**
## Twenty UI 컴포넌트 사용하기
Twenty는 컴포넌트 라이브러리를 [`twenty-ui`](https://www.npmjs.com/package/twenty-ui/v/1.0.0-alpha.1) 패키지로 제공합니다. 프론트 컴포넌트에서는 버튼, 태그, 상태 배지, 칩, 아바타, 아이콘, 타이포그래피, 그리고 워크스페이스의 라이트 및 다크 테마에 자동으로 맞춰지는 테마 토큰 등에 사용할 수 있습니다.
### 설치
앱에 패키지를 추가하되, Twenty 인스턴스에서 제공되는 버전에 고정(pinned)하여 사용하세요:
```bash
yarn add twenty-ui@1.0.0-alpha.1
```
`twenty-ui` 는 빌드 시점에 프론트 컴포넌트에 번들되므로, 앱의 의존성으로만 추가하면 됩니다. 런타임에 따로 설정할 내용은 없습니다.
### 컴포넌트 가져오기
패키지 루트가 아니라 일치하는 서브패스에서 import 해서, 사용하는 컴포넌트만 번들에 포함되도록 하세요:
| 서브패스 | 내보내는 항목 |
| --------------------------- | ------------------------------------------ |
| `twenty-ui/input` | `Button` 및 폼 입력 컴포넌트 |
| `twenty-ui/data-display` | `Tag`, `Status`, `Chip`, `Avatar` 등 |
| `twenty-ui/feedback` | `Callout`, `Banner`, `Info` 등 |
| `twenty-ui/typography` | `H1Title`, `H2Title`, `H3Title`, `Label` 등 |
| `twenty-ui/icon` | `Icon*` 컴포넌트(예: `IconCheck`) |
| `twenty-ui/theme-constants` | `ThemeProvider`, `themeCssVariables` |
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Status, Tag } from 'twenty-ui/data-display';
import { Button } from 'twenty-ui/input';
const StyledWidget = () => {
return (
<div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
<Button title="Click me" onClick={() => alert('Clicked!')} />
<Tag text="Active" color="green" />
<Status color="green" text="Online" />
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
name: 'styled-widget',
component: StyledWidget,
});
```
### 아이콘
`twenty-ui/icon` 에서 개별 아이콘을 import 하세요:
```tsx
import { IconBox, IconCheck } from 'twenty-ui/icon';
```
각 이름이 지정된 아이콘은 트리 셰이킹되므로, 소수만 import 해도 번들 크기에 거의 영향을 주지 않습니다. `IconsProvider`, `useIcons`, `iconsState` 는 사용을 피하세요. 전체 Tabler 아이콘 세트(수 MB)를 모두 가져오기 때문입니다.
### 테마 및 테마 토큰
Twenty UI 컴포넌트는 워크스페이스의 라이트 및 다크 테마와 자동으로 일치합니다. 렌더러가 호스트에서 활성 색 구성표를 적용하고, 컴포넌트는 그에 맞춰 자신의 색상을 결정합니다.
자신의 인라인 스타일에서 동일한 디자인 토큰을 사용하려면 `useTheme()` 훅을 호출하세요. 이 훅은 활성 테마에 연결된 Twenty의 테마 토큰(여백, 색상, 모서리 반경, 폰트)을 반환하며, 컴포넌트에서 별도의 `ThemeProvider` 설정이 필요하지 않습니다:
```tsx
import { useTheme } from 'twenty-ui/theme-constants';
const Card = () => {
const theme = useTheme();
return (
<div
style={{
padding: theme.spacing[4],
background: theme.background.secondary,
color: theme.font.color.primary,
}}
>
Themed card
</div>
);
};
```
`useTheme()` 는 훅이므로, 컴포넌트 본문 안에서 토큰을 읽게 되어 값이 항상 실제 활성 테마를 반영합니다. 동일한 토큰 맵은 `themeCssVariables` 상수로도 export 되지만, 프론트 컴포넌트에서는 `useTheme()` 를 우선적으로 사용하세요. `themeCssVariables` 를 역참조하는 모듈 레벨 상수는 앱 매니페스트가 추출되는 동안에는 정의되지 않았을 수 있습니다.
활성 스킴에 따라 분기해야 할 경우, `twenty-sdk/front-component` 의 `useColorScheme()` 으로 값을 읽으세요. 이 훅은 `'light'` 또는 `'dark'` 를 반환합니다.
## 현재 제한 사항
Front 컴포넌트는 활발히 개발 중입니다. 렌더링, 스타일링, 이벤트 처리, 요소 측정 및 브라우저 스토리지는 잘 동작합니다. 그 범위를 *넘어서는* 모든 작업(ref에 대해 DOM 메서드를 호출하거나, 요소 크기 변경을 관찰하거나, 트리 바깥으로 포털링하는 등)은 현재 누락되었거나 불완전하며, 대부분 조용히 실패합니다. 예외도 발생하지 않고, 스캐폴드가 전체 브라우저 DOM을 기준으로 타입이 지정되어 있기 때문에 TypeScript 오류도 발생하지 않습니다.
이 중 하나가 개발을 가로막는다면, 우선순위를 높일 수 있도록 [issue를 열어](https://github.com/twentyhq/twenty/issues/new/choose) 주세요.
### 레이아웃과 측정
요소는 스스로를 측정할 수 있습니다. 호스트는 기하 정보를 샌드박스로 미러링하므로, 읽기는 로컬에서 처리되지만 최대 한 프레임까지 오래된 값일 수 있으며, 한 번도 측정되지 않은 요소를 처음 읽으면 0을 반환합니다. 값을 쓴 다음에는 `requestAnimationFrame` 콜백이나 effect에서 다시 읽으세요.
| API | 무슨 일이 일어나는지 |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `getBoundingClientRect()`, `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | 미러에서 동작합니다. `scrollTop` / `scrollLeft`를 할당해도 아무 일도 일어나지 않습니다(no-op). |
| `getClientRects()`, `window.matchMedia()` | 예외 발생 |
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | **브라우저 뷰포트**를 기준으로 동작합니다. 위젯 자체의 크기는 `document.body.clientWidth` / `clientHeight`입니다. |
| `window.getComputedStyle()` | 요소의 인라인 스타일만 반환하며, 호스트에서 계산된 전체 스타일(cascade)은 반환하지 않습니다. |
| `ResizeObserver`, `IntersectionObserver` | `ReferenceError` 발생(단, `typeof` 가드는 동작함) |
| `MutationObserver` | `subtree`, `attributeFilter`, 이전 값(old values) 및 `takeRecords()`를 포함해 정상적으로 동작합니다. |
`getBoundingClientRect`를 통한 포지셔닝은 이제 동작하지만, `ResizeObserver`를 통해 크기 변화를 감시하는(recharts의 `ResponsiveContainer`, Floating UI의 `autoUpdate` 등) 기능은 여전히 동작하지 않습니다. 레이아웃은 가능하면 CSS로 작성하는 편이 좋습니다. 스타일시트가 실제 페이지에 적용되므로 flexbox, grid, `aspect-ratio`, `clamp()` 및 `@container`는 지연 없이 정상적으로 동작합니다.
<Note>
`requestAnimationFrame`, `fetch`, `setTimeout` 및 `queueMicrotask`는 `window.` 접두사 없이 동작합니다. `window.requestAnimationFrame(...)` 등만 예외를 발생시킵니다.
</Note>
### DOM 액세스
`ref`는 `HTMLElement`가 아니라 샌드박스 요소를 반환합니다.
| 작성한 코드 | 무슨 일이 일어나는지 | 대신 사용할 것 |
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ref.current.focus()`, `.click()`, `.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | 예외 발생 | 제어 컴포넌트를 사용하고 값을 `event.target`에서 읽습니다 |
| `element.classList.add(...)` | 예외 발생(`classList`는 `undefined`임) | `className` 문자열을 직접 구성하세요. |
| `document.createTreeWalker()` | 예외 발생 | `querySelector()` / `querySelectorAll()` 및 `getElementById()`는 동작합니다. `getElementsByClassName()`도 동작하지만, live `HTMLCollection`이 아니라 정적인 컬렉션을 반환합니다. |
| `document.activeElement` | 항상 `undefined` | `onFocus` / `onBlur`로 포커스를 추적하세요. |
| `canvas` | 아무것도 렌더링되지 않으며, 오류도 없습니다. | SVG 또는 오프스크린에서 그린 뒤 결과를 `img` 요소에 표시 |
| `createPortal(node, document.body)` | `isConnected`는 성공으로 보고하지만 아무것도 렌더링되지 않습니다 | `position: absolute`로 인라인 오버레이를 사용하거나 라이브러리에 직접 렌더링한 컨테이너 요소를 전달하세요 |
이 포털 간극 때문에 Radix, Headless UI, MUI, react-select의 팝오버는 기본적으로 아무것도 렌더링하지 않습니다. 대부분 컨테이너 prop을 받으므로, 직접 렌더링한 요소를 가리키도록 설정하세요.
### 이벤트
마우스, 포인터, 터치, 드래그, 키보드, 포커스, `input`/`change`/`submit`, `scroll`/`wheel`/`contextmenu` 그리고 `animationend`/`transitionend` 이벤트는 호스트로 전달되며, 여기에 요소별로 몇 가지가 더해집니다: `img`의 `load`/`error`, `input`/`textarea`의 클립보드 및 조합(composition), `video`/`audio`의 미디어, `details`/`dialog`의 `toggle` 등입니다. 그 밖의 것들(`onAuxClick`, `onSelect`, `onInvalid`, `onReset`, `onAnimationStart`, 포인터 캡처, `img`의 `onLoad` 등)은 경고 없이 모두 무시됩니다.
`document.addEventListener()`와 `window.addEventListener()`는 오류 없이 등록되지만 절대 실행되지 않으며, 이 때문에 드래그는 시작한 요소를 벗어나는 순간 중지됩니다. `event.preventDefault()` 역시 전달되지 않습니다. 폼 제출, `dragover`/`drop` 및 링크 클릭은 이미 보호되어 있습니다.
### 속성과 스타일링
각 요소는 자신의 속성을 호스트 DOM으로 전달합니다(`a`의 `href`, `img`의 `src`/`alt`, `input`의 `value`/`placeholder`/`disabled` 등). 또한 모든 요소에 공통으로 `id`, `className`, `style`, `title`, `tabIndex`, `role`, `draggable`, 그리고 하이픈이 포함된 모든 `aria-*` / `data-*` 속성(따라서 `ariaLabel`은 무시됨)을 전달합니다. 그 외의 속성은 조용히 폐기되므로, 사용자 정의 상태는 `data-*`로 표현하세요.
컴포넌트 CSS는 `import './styles.css'`, CSS-in-JS 또는 `style` 요소에서 오든, 호스트 페이지의 `head`에 **범위 없이(unscoped)** 주입됩니다. 따라서 클래스 이름이 Twenty 자체 것과 충돌합니다(접두사를 붙이고, `div { ... }` 선택자를 직접 작성하지 마세요). 그리고 `@media`는 위젯이 아니라 브라우저 창을 기준으로 매칭됩니다(직접 지정한 `container-type`과 함께 `@container`를 사용하세요). 인라인 `style` prop은 영향을 받지 않습니다.
### 스토리지와 네트워크
`localStorage`와 `sessionStorage`는 브라우저가 아니라 Twenty에서 제공합니다. 컴포넌트는 불투명 출처의 워커에서 실행되므로, 호스트가 앱을 대신해 값을 저장합니다. 범위와 한도에 대해서는 [storage](#storage)를 참조하세요. IndexedDB, 쿠키, Cache API 및 `BroadcastChannel`은 여전히 사용할 수 없습니다. 디바이스 간에 상태를 영속화하려면 [logic function](/l/ko/developers/extend/apps/logic/logic-functions)을 호출하고 해당 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)를 사용하세요.
`fetch`는 동작하지만, 몇 가지 주의사항이 있습니다:
* Twenty API와 앱의 라우트로 가는 호출은 호스트에서 프록시되므로, [`RestApiClient`](#calling-the-twenty-rest-api)를 사용하는 것이 좋습니다. 프록시되는 호출에서는 `AbortSignal` 및 기타 `RequestInit` 옵션이 제거되며, `string`과 `URLSearchParams` 타입의 body만 지원됩니다.
* 다른 오리진으로 나가는 요청은 `Origin: null`을 가진 채 샌드박스를 벗어나므로, 서드파티 API는 `Access-Control-Allow-Origin: *`를 보내는 경우에만 응답합니다. 대신 logic function에서 호출하세요.
* 샌드박스에는 상대 경로를 해석할 페이지 URL이 없으므로, `fetch('/rest/people')`은 Twenty API와 절대 매칭되지 않습니다.
### 미디어 캡처
`navigator.mediaDevices.getUserMedia()`와 `MediaRecorder`는 샌드박스 폴리필을 통해 front 컴포넌트 내부에서 동작하므로, 표준 녹화 코드는 변경 없이 실행되며 `MediaRecorder.isTypeSupported`는 일반적인 컨테이너/코덱 조합에 대해 응답합니다. 상세한 `getUserMedia` 제약 조건 객체는 허용되지만 전달되지는 않습니다. 호스트가 요청된 종류에 대해 기본값으로 캡처하며, 애플리케이션 전체에서 한 번에 하나의 캡처만 활성 상태일 수 있습니다. 기록된 `Blob`을 `uploadFile` 호스트 함수로 저장합니다.
### 기타 제약 사항
* **파일 내용.** `file` 타입의 `input`은 핸들러에 바이트가 아닌 파일 메타데이터만 제공하므로 `FileReader`는 사용할 수 없습니다. `MediaRecorder`가 생성한 것처럼 코드에서 이미 가지고 있는 `Blob`을 업로드하려면 `uploadFile` 호스트 함수를 사용하세요.
* **드래그 앤 드롭 페이로드.** 드래그 이벤트는 발생하지만, `event.dataTransfer`는 `undefined`입니다.
* **Node 내장 모듈.** `fs`, `path` 및 `node:crypto`는 빌드에 실패하므로, 해당 작업은 [logic function](/l/ko/developers/extend/apps/logic/logic-functions)으로 옮기세요. Web Crypto, `fetch`, `TextEncoder` 및 `URL`은 사용할 수 있습니다.
* \*\*`iframe`\*\*은 항상 `allow-same-origin` 없이 다시 샌드박싱되므로, 자체 세션에 의존하는 임베드는 로그아웃된 상태로 렌더링됩니다. 또한 `onLoad`도 지원되지 않습니다.

View File

@@ -0,0 +1,46 @@
---
title: 내비게이션 메뉴 항목
description: 워크스페이스 사이드바에 사용자 지정 항목을 추가하여 저장된 보기나 외부 URL에 연결하세요.
icon: bars
---
**내비게이션 메뉴 항목**은 왼쪽 사이드바의 항목입니다. `defineNavigationMenuItem()`을 사용해 사용자 지정 사이드바 링크를 추가하세요 — 일반적으로 배포하는 각 [보기](/l/ko/developers/extend/apps/layout/views)마다 하나씩 추가하거나 외부 URL을 가리키도록 설정합니다.
```ts src/navigation-menu-items/example-navigation-menu-item.ts
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
```
## 핵심 요점
* `type`은 메뉴 항목이 어디로 연결되는지를 결정합니다. 각 유형은 특정 식별자 필드와 짝을 이룹니다:
| 유형 | 하는 일 | 필수 필드 |
| ------------------------------------ | --------------------------- | -------------------------------------------------------------- |
| `NavigationMenuItemType.VIEW` | 저장된 보기를 엽니다 | `viewUniversalIdentifier` |
| `NavigationMenuItemType.LINK` | 외부 URL을 엽니다 | `link` |
| `NavigationMenuItemType.FOLDER` | 중첩된 항목들을 하나의 레이블 아래로 그룹화합니다 | `name` (그리고 하위 항목들은 `folderUniversalIdentifier`를 통해 폴더를 참조합니다) |
| `NavigationMenuItemType.OBJECT` | 오브젝트의 기본 인덱스 페이지를 엽니다 | `targetObjectUniversalIdentifier` |
| `NavigationMenuItemType.PAGE_LAYOUT` | 독립형 페이지 레이아웃을 엽니다 | `pageLayoutUniversalIdentifier` |
* `position`은 사이드바에서의 정렬 순서를 제어합니다.
* 열거형에는 사용자가 만든 레코드 즐겨찾기에 내부적으로 사용되는 `NavigationMenuItemType.RECORD`도 포함되어 있습니다. 이는 앱 매니페스트에서는 사용할 수 없습니다(레코드를 참조할 필드가 없기 때문입니다).
* `icon`과 `color`는 선택 사항이며 항목의 표시 방식을 사용자 지정합니다.
* `folderUniversalIdentifier`는 모든 항목에서 사용할 수 있으며, 이를 통해 해당 항목을 `FOLDER` 유형의 상위 항목 안에 중첩할 수 있습니다.
<Note>
**흔한 오류:** 연결된 내비게이션 메뉴 항목 없이 오브젝트를 생성하면 해당 오브젝트는 사이드바에서 접근할 수 없습니다. 기술적/내부용 오브젝트가 아닌 한, 모든 사용자 지정 오브젝트에는 이를 가리키는 사이드바 항목이 있어야 합니다.
</Note>

View File

@@ -0,0 +1,59 @@
---
title: 개요
description: Twenty의 UI에 앱을 배치하세요 — 사이드바 항목, 저장된 보기, 레코드 페이지 탭, 샌드박스된 React 컴포넌트.
icon: table-columns
---
Twenty 앱의 **레이아웃 레이어**는 사용자가 보는 모든 것을 의미합니다. 앱이 사이드바 어디에 노출되는지, 어떤 목록 보기를 제공하는지, 레코드 상세 페이지가 어떻게 구성되는지, 그리고 어떤 커스텀 React 컴포넌트가 해당 페이지 내부에 렌더링되는지가 여기에 포함됩니다.
```text
Sidebar Record list Record detail page
─────── ─────────── ──────────────────
[📋 My View] ────▶ ┌──────────┐ ┌─────────────────────┐
[📋 Drafts ] │ Companies│ │ Tabs: [Overview ] │
[📋 Inbox ] │ ──────── │ │ [Notes ] │
▲ │ Apple │ │ [Hello ]◀──── definePageLayoutTab
│ │ Acme │ │ │ adds a tab...
└ defineNavi- │ … │ │ ┌────────────────┐ │
gationMenu- └────▲─────┘ │ │ │ │
Item points │ │ │ React UI │◀── …with a
to a defineView │ │ │ (sandboxed in │ │ defineFrontComponent
└ defineView │ │ a Worker) │ │ widget inside
picks columns │ └────────────────┘ │
and filters └─────────────────────┘
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="보기" icon="list" href="/l/ko/developers/extend/apps/layout/views">
`defineView` — 표시 열, 필터, 그룹을 포함한 저장된 목록 구성입니다.
</Card>
<Card title="내비게이션 메뉴 항목" icon="bars" href="/l/ko/developers/extend/apps/layout/navigation-menu-items">
`defineNavigationMenuItem` — 보기 또는 외부 URL을 가리키는 사이드바 항목입니다.
</Card>
<Card title="페이지 레이아웃" icon="table-columns" href="/l/ko/developers/extend/apps/layout/page-layouts">
`definePageLayout` 및 `definePageLayoutTab` — 레코드 상세 페이지의 탭과 위젯을 정의합니다.
</Card>
<Card title="프런트 컴포넌트" icon="window-maximize" href="/l/ko/developers/extend/apps/layout/front-components">
`defineFrontComponent` — Twenty 내부에서 렌더링되는 샌드박스된 React 컴포넌트입니다.
</Card>
<Card title="명령 메뉴 항목" icon="terminal" href="/l/ko/developers/extend/apps/layout/command-menu-items">
`defineCommandMenuItem` — 프런트 컴포넌트를 Cmd+K 항목과 빠른 작업으로 등록합니다.
</Card>
</CardGroup>
## 앱이 노출되는 위치
| 표시 위치 | 제어하는 항목 | 엔터티 |
| ----------------- | ---------------------------------------- | ----------------------------------------- |
| **사이드바** | 저장된 보기 또는 외부 URL에 연결되는 커스텀 항목 | `defineNavigationMenuItem` |
| **레코드 목록** | 오브젝트에 대한 저장된 구성 — 표시되는 열, 정렬 순서, 필터, 그룹 | `defineView` |
| **레코드 상세 페이지** | 레코드 페이지(사용자 정의 오브젝트 또는 표준 오브젝트)의 탭과 위젯 | `definePageLayout`, `definePageLayoutTab` |
| **위의 어느 위치 내부** | 커스텀 React 위젯 — 버튼, 폼, 대시보드, 통합 기능 | `defineFrontComponent` |
| **앱 설정** | 기본 변수 UI 대신 앱의 설정 탭 안에 포함되는 사용자 지정 구성 섹션 | `defineSettingsFrontComponent` |
| **명령 메뉴 (Cmd+K)** | 고정된 빠른 작업 또는 숨겨진 명령 | `defineCommandMenuItem` |
프런트 컴포넌트는 Remote DOM을 사용하는 격리된 Web Worker 내부에서 실행됩니다. 이들은 페이지 안에서 네이티브하게 렌더링되지만(iframe 내부가 아님), 호스트 페이지나 DOM에 직접 접근할 수는 없습니다. Twenty와의 통신은 메시지 전달 호스트 API를 통해 이루어집니다.
여전히 활발히 개발 중입니다. 샌드박스는 DOM을 부분적으로만 구현하고 있어 고급 사용 사례에서는 실패할 수 있습니다. [현재 제한 사항](/l/ko/developers/extend/apps/layout/front-components#current-limitations)을 참고하세요.

View File

@@ -0,0 +1,158 @@
---
title: 페이지 레이아웃
description: "`definePageLayout` 및 `definePageLayoutTab`을 사용하여 레코드 상세 페이지의 탭, 위젯, 그리고 프런트 컴포넌트가 렌더링되는 위치를 사용자 지정합니다."
icon: table-columns
---
**페이지 레이아웃**은 레코드의 상세 페이지가 어떻게 구성되는지를 제어합니다. 어떤 탭이 표시되고, 그 탭 안에 어떤 위젯이 포함되는지를 결정합니다. 소유한 객체에 대한 레이아웃을 선언하려면 `definePageLayout()`을 사용하고, 이미 존재하는 레이아웃(사용자 소유 또는 표준 Twenty 레이아웃)에 단일 탭을 추가하려면 `definePageLayoutTab()`을 사용합니다.
| 사용 사례 | 엔터티 |
| ---------------------------------------------------- | --------------------- |
| 소유한 객체의 레코드 페이지에 대한 전체 레이아웃을 정의합니다. | `definePageLayout` |
| 기존 레이아웃(자신이 소유한 객체의 레이아웃이든, 표준 레이아웃이든)에 탭 하나를 추가합니다. | `definePageLayoutTab` |
## definePageLayout
전체 상세 페이지를 소유할 때 사용합니다. 일반적으로 직접 정의한 사용자 지정 객체에 해당합니다.
```ts src/page-layouts/example-record-page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
### 핵심 요점
* `type`은 `'RECORD_INDEX'`, `'RECORD_PAGE'`, `'DASHBOARD'`, `'STANDALONE_PAGE'` 중 하나입니다. 'RECORD_PAGE'를 사용하면 특정 객체의 상세 보기를 사용자 지정할 수 있습니다.
* `objectUniversalIdentifier`는 이 레이아웃이 적용되는 객체를 지정합니다.
* 각 `tab`은 `title`, `position`, `layoutMode`를 사용해 페이지의 섹션을 정의합니다. 레코드 페이지와 독립형 페이지에는 `VERTICAL_LIST`를, 대시보드에는 `GRID`를, 탭 뷰포트를 채워야 하는 단일 위젯에는 `CANVAS`를 사용합니다. `VERTICAL_LIST` 탭은 위젯을 세로로 쌓습니다. 타임라인, 파일, 메모, 작업, 워크플로와 같이 자체 스크롤을 관리하는 기본 제공 위젯은 하나의 뷰포트를 채우며, 필드, 프론트 컴포넌트, 그래프 및 기타 콘텐츠 크기에 맞는 위젯은 콘텐츠 높이 또는 구성된 높이로 렌더링됩니다. `GRID` 탭은 항상 12열 그리드에서 위젯을 카드로 배치합니다. `CANVAS` 위젯에는 명시적 위치가 없습니다. 캔버스 탭에 여러 위젯이 포함된 경우, 뷰포트를 채우는 대신 콘텐츠 높이로 렌더링됩니다.
* `layoutMode`를 명시적으로 설정하세요. 이를 생략하면 `STANDALONE_PAGE`에서는 `VERTICAL_LIST`가, 그 외의 경우에는 `GRID`가 기본값으로 설정되는데, 이는 레코드 페이지에서는 거의 원하지 않는 동작입니다.
* 탭 내부의 각 `widget`은 [front component](/l/ko/developers/extend/apps/layout/front-components), 관계 목록 또는 기타 기본 제공 위젯 타입을 렌더링할 수 있습니다.
* `FRONT_COMPONENT` 위젯은 동일한 앱의 명령 메뉴 항목 범용 식별자로 이루어진 순서 있는 배열로 `headerCommandMenuItemUniversalIdentifiers`를 설정할 수 있습니다. 이러한 작업은 위젯 카드 헤더에 아이콘 버튼으로 표시되며, 명령 수준의 가용성 및 권한 검사를 유지합니다. 식별자는 고유해야 하며 앱이 설치될 때 확인 가능해야 합니다.
* 탭의 `position`은 탭의 순서를 제어합니다. 값을 더 높게 설정하면(예: 50) 기본 탭 뒤에 사용자 지정 탭을 배치할 수 있습니다.
### 필드 위젯
`FIELD` 위젯은 레코드의 하나의 필드를 렌더링합니다. 관계 필드의 경우 관련 레코드 목록을 포함할 수도 있습니다:
```ts
{
universalIdentifier: 'c1c2c3c4-c5c6-4000-8000-000000000003',
title: 'People → Opportunities',
type: 'FIELD',
configuration: {
configurationType: 'FIELD',
fieldMetadataId: PEOPLE_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'TABLE',
nestedRelationFieldMetadataId: OPPORTUNITIES_FIELD_UNIVERSAL_IDENTIFIER,
},
}
```
* `fieldMetadataId`는 레이아웃의 오브젝트에 있는 필드의 범용 식별자를 받습니다.
* `fieldDisplayMode`는 `'FIELD'`, `'CARD'`, `'EDITOR'`, `'VIEW'`, `'TABLE'` 중 하나입니다. `TABLE`은 일대다 관계 필드의 레코드를 나열하는 뷰를 포함합니다.
* `nestedRelationFieldMetadataId`는 선택 사항이며, 관계 대상 객체에 있는 일대다 관계 필드의 범용 식별자를 받습니다. 이를 통해 두 번의 관계 홉을 거친 레코드를 나열할 수 있습니다(예: Company 페이지에서 해당 회사 사람들의 Opportunity 레코드를 나열하거나, Person 페이지에서 해당 사람의 회사의 Opportunity 레코드를 나열하는 경우). 첫 번째 홉은 일대다 또는 다대일 관계 필드일 수 있지만, 두 번째 홉은 반드시 일대다 관계여야 합니다(junction 관계는 지원되지 않습니다). 또한 `fieldDisplayMode: 'TABLE'`이 필요하며, 다른 어떤 display mode와 결합하면 유효성 검사 오류가 발생합니다. 중첩 위젯은 항상 임베디드 뷰로 렌더링되기 때문입니다.
## definePageLayoutTab
기존 레이아웃에 탭만 추가하려는 경우에 사용합니다. 예를 들어, 표준 Company 페이지에 분석 탭을 추가하거나, 자신이 소유한 객체의 레이아웃에 AI 요약 탭을 연결하는 경우입니다.
```ts src/page-layouts/example-extra-tab.ts
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
});
```
### 핵심 요점
* `pageLayoutUniversalIdentifier`는 **필수**이며, 설치 시점에 이미 존재하는 페이지 레이아웃을 가리켜야 합니다. 이는 표준 Twenty 레이아웃이거나, 사용자의 앱에서 정의한 레이아웃일 수 있습니다. 현재는 설치된 다른 앱이 소유한 레이아웃에 대한 크로스 앱 참조는 지원되지 않습니다. 상위 레이아웃이 없으면, 명확한 유효성 검사 오류와 함께 설치가 실패합니다.
* 표준 Twenty 레이아웃의 경우 `twenty-sdk/define`에서 식별자를 가져옵니다:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
각 레이아웃 항목은 `tabs`와 해당 `widgets`도 노출하므로, 모든 수준을 참조할 수 있습니다:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home
.universalIdentifier;
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets
.fields.universalIdentifier;
```
짧은 별칭인 `STANDARD_PAGE_LAYOUT`도 사용할 수 있습니다:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
* `widgets`는 이 탭에만 범위가 국한됩니다. `definePageLayout`에 인라인으로 정의된 위젯과 완전히 동일한 방식으로 [front components](/l/ko/developers/extend/apps/layout/front-components), 뷰 등을 참조합니다.
* `position`은 대상 레이아웃의 기존 탭과의 순서를 제어합니다. 기본 제공 탭을 기준으로 원하는 위치에 탭이 놓이도록 값을 선택하세요.
* 기존 레이아웃에만 추가하려는 경우에는 `definePageLayout` 대신 이것을 사용하세요. 전체 레이아웃을 소유하는 경우에는 `definePageLayout`을 사용하세요.

View File

@@ -0,0 +1,113 @@
---
title: 보기
description: 앱의 객체에 대해 열 순서, 필터, 그룹이 미리 구성된 저장된 보기를 제공하세요.
icon: list
---
**보기**는 객체 레코드가 어떻게 표시될지에 대한 저장된 구성입니다. 어떤 필드가 표시되는지, 그 순서, 표시 여부, 적용된 필터나 그룹 등을 정의합니다. 앱과 함께 미리 구성된 뷰를 제공하려면 `defineView()`를 사용하세요.
모든 개체는 서버에서 기본 목록 뷰(`OBJECT` 탐색 항목이 여는 "All ..." 테이블)를 자동으로 프로비저닝됩니다. 여기에는 앱이 생성하는 개체도 포함되며, 앱이 선언한 각 필드당 하나의 열이 생성됩니다. `defineView()`로 정의한 뷰는 항상 해당 기본 뷰 위에 추가되는 **추가** 뷰입니다.
```ts src/views/example-view.ts
import { defineView } from 'twenty-sdk/define';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
export default defineView({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'All example items',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
```
## 핵심 요점
* `objectUniversalIdentifier`는 이 뷰가 적용되는 객체를 지정합니다. 이 객체는 사용자가 정의한 커스텀 객체일 수도 있고, 표준 Twenty 객체일 수도 있습니다.
* 개체의 기본 목록 뷰는 서버 소유입니다. `key` 는 더 이상 사용되지 않으며 무시되므로 매니페스트 뷰가 이를 소유할 수 없습니다. 사이드바에서 한 번의 클릭으로 뷰에 접근할 수 있게 하려면 `VIEW` 탐색 항목을 제공하십시오.
* `fields`는 어떤 열을 어떤 순서로 표시할지를 제어합니다. 각 필드는 `fieldMetadataUniversalIdentifier`를 참조합니다. 자동 생성된 시스템 필드(예: `createdAt`)를 참조하는 방법은 [시스템 메타데이터 대상으로 지정하기](/l/ko/developers/extend/apps/data/system-fields)를 참조하세요.
* 또한 더 고급 구성을 위해 `filters`, `filterGroups`, `sorts`, `groups`, `fieldGroups`를 선언할 수 있습니다.
* `position`은 동일한 객체에 여러 뷰가 있을 때의 정렬 순서를 제어합니다.
## 선택적 속성
| 속성 | 값 | 설명 |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `ViewType.TABLE`(기본값), `ViewType.LIST`, `ViewType.KANBAN`, `ViewType.CALENDAR` | 레코드가 어떻게 배치되는지에 대한 설정입니다. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, `LIST_WIDGET`, 및 `CALENDAR_WIDGET`도 존재하지만, 페이지 레이아웃 위젯에서 내부적으로 사용됩니다.) |
| `visibility` | `ViewVisibility.WORKSPACE`(기본값), `ViewVisibility.UNLISTED` | 워크스페이스 전체에 대해 뷰가 목록에 표시되는지, 선택기에서 숨겨지는지 여부입니다. |
| `openRecordIn` | 사용 중단됨 | 더 이상 읽지 않음: 레코드가 어디에서 열리는지는 이제 [object](/l/ko/developers/extend/apps/data/objects)의 속성(`defineObject()`의 `openRecordIn`)으로 정의되며, 설정되지 않은 경우 각 멤버의 개별 기본 설정을 따릅니다. |
| `정렬` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | 기본 정렬 순서입니다. |
| `isCompact` | `boolean` | 행을 압축 형태로 표시합니다. |
| `mainGroupByFieldMetadataUniversalIdentifier` + `shouldHideEmptyGroups` | — | 레코드를 필드별로 그룹화합니다(예: 칸반 열). |
| `kanbanAggregateOperation`, `kanbanAggregateOperationFieldMetadataUniversalIdentifier`, `kanbanColumnWidth` | `AggregateOperations.*` | 칸반 열의 집계 및 크기 설정입니다. |
| `calendarLayout`, `calendarFieldMetadataUniversalIdentifier`, `calendarEndFieldMetadataUniversalIdentifier` | `ViewCalendarLayout.DAY` / `WEEK` / `MONTH` | 캘린더 뷰: 레이아웃, 레코드의 위치를 결정하는 날짜 필드, 그리고 선택적인 종료 날짜 필드입니다. |
위의 모든 enum은 `twenty-sdk/define`에서 내보내집니다.
## 필터
뷰는 미리 적용된 필터와 함께 제공될 수 있습니다. 각 필터에는 세 가지 요소가 있습니다: 필터링할 **필드**, **연산자**(어떻게 비교할지), 그리고 **값**(무엇과 비교할지). 세 가지가 모두 맞아야 하며, 필드 유형에 적용되지 않는 연산자를 사용하면 동기화 시 거부됩니다.
```ts
import { ViewFilterOperand } from 'twenty-sdk/define';
filters: [
{
universalIdentifier: '...',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
operand: ViewFilterOperand.IS,
value: ['ACTIVE'],
},
],
```
### 필드 유형별 지원되는 연산자
| 필드 유형 | 지원되는 피연산자 |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> 유사한 이름을 가진 필드 타입이라도 전혀 다른 피연산자를 사용할 수 있습니다. 대표적인 예로 `SELECT`와 `MULTI_SELECT`가 있습니다.
### 피연산자별 값 구조
`value` 필드는 항상 JSON으로 직렬화할 수 있는 값이지만, 기대되는 구조는 피연산자에 따라 달라집니다:
| 피연산자 그룹 | 값 구조 | 예시 |
| ----------------------------------------------------- | --------------------- | ------------------------ |
| `SELECT`에서의 `IS`, `IS_NOT` | 옵션 키(문자열) 배열 | `['ACTIVE', 'PENDING']` |
| `MULTI_SELECT`에서의 `CONTAINS`, `DOES_NOT_CONTAIN` | 옵션 키(문자열) 배열 | `['TAG_A']` |
| `RELATION`에서의 `IS`, `IS_NOT` | 레코드 ID(uuid) 배열 | `['c5a1...']` |
| 텍스트 계열 필드에서의 `CONTAINS`, `DOES_NOT_CONTAIN` | 문자열 | `'acme'` |
| `NUMBER`에서의 `IS`, `IS_NOT` | 문자열(값) | `'5'` |
| `RATING` / `UUID`에서의 `IS` | 문자열(값) | `'5'` |
| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | 문자열(경계값) | `'10'` |
| `DATE` / `DATE_TIME`에서의 `IS`, `IS_BEFORE`, `IS_AFTER` | ISO 8601 문자열 | `'2025-01-01T00:00:00Z'` |
| `IS_EMPTY`, `IS_NOT_EMPTY` | 빈 문자열 | `''` |
| `BOOLEAN`에서의 `IS` | `'true'` 또는 `'false'` | `'true'` |
## 보기가 UI에 표시되는 방식
보기만으로는 사이드바에서 직접 접근할 수 없습니다. 사이드바에 표시하려면, 해당 보기의 `universalIdentifier`를 가리키는 `VIEW` 타입의 [탐색 메뉴 항목](/l/ko/developers/extend/apps/layout/navigation-menu-items)과 연결해야 합니다. 이것이 정형화된 패턴입니다. 모든 사용자 정의 객체는 생성 시 기본 인덱스 뷰가 자동으로 생성되므로, 이를 여는 사이드바 항목만 제공하면 됩니다.

View File

@@ -0,0 +1,197 @@
---
title: 백그라운드 작업
description: 긴 시간이 걸리거나 속도 제한이 걸린 작업은 모두 인라인으로 처리하지 말고, 또 다른 로직 함수 실행을 큐에 넣어 Twenty 워커에 위임하세요.
icon: layer-group
---
로직 함수 실행은 `timeoutSeconds`(최대 900초)로 제한됩니다. 해당 시간 안에 끝나지 않는 모든 작업(전체 재동기화, 레코드별 팬아웃, 속도 제한이 걸린 서드파티 API 등)은 더 작은 실행 단위로 나눠야 합니다.
`enqueueJobs`는 바로 그 일을 합니다. Twenty 워커에게 나중에 앱의 로직 함수 중 하나를 실행해 달라고 요청하며, 각 페이로드당 한 번씩 실행되고 각 실행은 자체 프로세스에서 자체 타임아웃 한도로 처리됩니다. 호출은 즉시 반환됩니다.
```text
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
└─────────────────┘ └──────────────┘ └────────────────────┘
```
## 실행 대기열에 추가하기
`twenty-sdk/logic-function`에서 `enqueueJobs`를 가져와 실행할 로직 함수의 `universalIdentifier`를 지정하고, 대기열에 추가할 각 실행마다 하나의 페이로드를 전달하세요.
```ts src/logic-functions/sync-all-contacts.ts
import { enqueueJobs } from 'twenty-sdk/logic-function';
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
});
```
각 실행은 다른 모든 트리거와 정확히 동일하게 핸들러 인수로 해당 페이로드를 받습니다. 대상은 호출자와 **동일한 애플리케이션**에 속해야 합니다. 다른 앱의 함수를 대기열에 추가하려고 하면 `Logic function not found` 오류와 함께 거부되며 아무것도 대기열에 추가되지 않습니다. 한 번의 호출로 최대 `200`개의 페이로드를 허용합니다.
<Note>
`enqueueJobs`는 작업이 실행되었을 때가 아니라 작업이 수락되는 즉시 반환됩니다. 대상들의 결과를 반환하지는 않습니다. 결과를 다시 읽어와야 한다면, 각 대상이 생성한 내용을 [키-값 스토어](/l/ko/developers/extend/apps/logic/key-value-store) 또는 워크스페이스 레코드에 기록하도록 하세요.
</Note>
<Note>
호출당 하나의 작업을 대기열에 추가하는 이전 `enqueueJob` 도우미는 더 이상 사용되지 않습니다. 대신 요소가 하나인 `payloads` 목록과 함께 `enqueueJobs`를 사용하세요.
</Note>
## 작업 옵션
옵션은 배치의 모든 실행에 적용됩니다.
| 옵션 | 기본값 | 범위 | 하는 일 |
| ------------ | --- | ------------------------ | ------------------------------------------------------------------------------- |
| `retryLimit` | `0` | `0`–`10` | 전체 추가 큐 시도 횟수. 애플리케이션에서 요청한 재시도는 `3`회로 제한됩니다. 두 번 실행해도 안전한 핸들러에 대해서만 이 값을 올리세요. |
| `delayMs` | `0` | `0`–`604800000` (7 days) | 실행이 가능해지기 전까지 이만큼 대기합니다. |
```ts
await enqueueJobs({
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
payloads: [{ page: 1 }],
retryLimit: 3,
delayMs: 60_000,
});
```
<Note>
**우선순위는 아직 설정할 수 없습니다.** 대기열에 추가된 작업은 항상 가장 낮은 우선순위로 실행되므로, 플랫폼 작업이 애플리케이션 작업보다 뒤로 밀려 지연되는 일은 없습니다. 우선순위를 제어하는 기능은 곧 제공될 예정입니다.
</Note>
대기열에 추가된 실행은 이를 대기열에 추가한 함수의 실행 사용자를 상속하므로, 동일한 권한으로 동작합니다.
## 일시적 실패 재시도하기
Twenty는 애플리케이션 코드의 모든 예외를 재시도하지 않습니다. 일반 오류가 throw되면 영구적 실패로 처리됩니다. 일시적 실패의 경우, 큐에 대기 중인 로직 함수는 `RetryableLogicFunctionError`를 throw하여 최대 세 번의 재시도를 요청할 수 있습니다.
```ts
import {
type LogicFunctionExecutionContext,
RetryableLogicFunctionError,
} from 'twenty-sdk/logic-function';
export const handler = async (
_payload: unknown,
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
) => {
const response = await fetch('https://api.example.com/contacts');
if (response.status === 429 || response.status >= 500) {
throw new RetryableLogicFunctionError(
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
);
}
};
```
가능한 경우 `RetryableLogicFunctionError`를 직접 throw하세요. 이를 확장하는 경우 `name`을 바꾸지 마세요. Twenty는 실행 런타임 전반에서 직렬화된 이름 `RetryableLogicFunctionError`를 인식합니다.
`retryCount`는 최초 실행 시 `0`이며, 애플리케이션 코드가 재시도를 요청할 때만 증가합니다. `maxRetries`는 최대 `3`이며, 큐에 대기 중인 작업의 전체 재시도 한도가 더 작으면 더 낮을 수 있습니다. 플랫폼 실패는 큐의 전체 안전 예산을 계속 소모하지만 `retryCount`를 증가시키지는 않습니다.
큐는 지수 백오프와 지터를 사용하여 재시도 시도를 지연합니다. 정확한 지연 시간은 의도적으로 보장되지 않으므로, 애플리케이션 코드는 정확한 시간에 재시도가 발생하는 것에 의존해서는 안 됩니다. `maxRetries`에 도달하면, 또 다른 `RetryableLogicFunctionError`는 추가 실행 없이 최종 애플리케이션 실패로 기록됩니다.
<Warning>
재시도는 전체 핸들러를 다시 실행하며, 일부 부수 효과가 성공한 후 발생할 수 있습니다. 재시도를 요청하기 전에 핸들러를 멱등적으로 만드세요.
</Warning>
## 사용 예: 긴 동기화를 페이지 단위로 처리하기
전형적인 형태는, 다음 커서를 사용해 *자기 자신*을 다시 대기열에 추가하는 함수입니다. 각 실행은 자신의 타임아웃 안에서 한 페이지 분량의 작업만 처리하고, 더 이상 처리할 것이 없으면 이 체인은 멈춥니다.
```ts src/logic-functions/sync-contacts-page.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs } from 'twenty-sdk/logic-function';
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
const handler = async (params: { cursor?: string }) => {
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
await importContacts(contacts);
if (nextCursor) {
await enqueueJobs({
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
payloads: [{ cursor: nextCursor }],
delayMs: 2_000,
});
}
return { imported: contacts.length, done: !nextCursor };
};
export default defineLogicFunction({
universalIdentifier: SYNC_CONTACTS_PAGE,
name: 'sync-contacts-page',
timeoutSeconds: 120,
handler,
});
```
## 레코드별로 팬아웃하기
작업이 본질적으로 항목 단위일 때는, 단일 호출에서 항목마다 하나의 작업을 대기열에 추가하고 워커들이 인라인 루프를 도는 대신 병렬로 처리하도록 하세요.
```ts
const companies = await listCompaniesToEnrich();
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
payloads: companies.map((company) => ({ companyId: company.id })),
retryLimit: 2,
});
```
## 장시간 실행 작업을 위한 모범 사례
대부분의 장시간 작업은 두 가지 규칙으로 다룰 수 있습니다: **루프 대신 재귀를 사용**하고, **실행마다 한정된 크기의 청크를 처리**하세요.
한 번에 모든 것을 처리하려는 실행은 실패 패턴입니다. 타임아웃에 걸리고, 재시도가 발생하면 전체 작업을 다시 처음부터 시작합니다. 대신, 하나의 청크가 `timeoutSeconds` 안에 여유 있게 끝나도록 크기를 잡고, 현재 위치를 저장한 뒤 다음 실행을 대기열에 추가하세요.
```ts src/logic-functions/enrich-companies-batch.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
const CHUNK_SIZE = 50;
const handler = async (params: { offset?: number }) => {
const offset = params.offset ?? 0;
const companies = await listCompaniesToEnrich({
offset,
limit: CHUNK_SIZE,
});
for (const company of companies) {
await enrichCompany(company);
}
await kv.set('enrich:progress', { offset: offset + companies.length });
if (companies.length === CHUNK_SIZE) {
await enqueueJobs({
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
payloads: [{ offset: offset + CHUNK_SIZE }],
});
}
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
};
export default defineLogicFunction({
universalIdentifier: ENRICH_COMPANIES_BATCH,
name: 'enrich-companies-batch',
timeoutSeconds: 300,
handler,
});
```
이 접근이 유효한 이유:
* **청크 크기는 평균이 아니라 가장 느린 항목을 기준으로 정하세요.** `CHUNK_SIZE × 최악의 항목 처리 시간`은 여유를 두고 `timeoutSeconds` 안에 들어와야 합니다. 그렇지 않으면 실행이 중단될 때 청크의 끝부분이 유실됩니다.
* **종료 조건을 명시적으로 만드세요.** 가득 찬 청크가 반환되었을 때만 재귀적으로 호출하세요. "결과 없음"만을 기준으로 멈추는 체인은, 소스가 중간에 짧은 페이지를 한 번이라도 반환하면 영원히 계속될 수 있습니다.
* **다음 실행을 대기열에 추가하기 전에 진행 상태를 저장**해서, 실패한 링크가 처음부터가 아니라 마지막으로 완료된 청크부터 다시 시작하도록 하세요.
* **각 청크는 멱등성을 유지하세요.** 재시도 후에 하나의 청크를 다시 처리하더라도 중복 기록이 발생하지 않도록, 처리 중인 레코드나 외부 ID를 기준으로 쓰기를 수행해야 합니다.
* **거대한 한 번의 팬아웃보다 청크 단위 체인을 우선적으로 사용**하세요. 작업이 호출 한도(rate limit)가 있는 서드파티에 도달하는 경우, `delayMs`가 있는 체인은 스스로 속도를 조절하지만, 수천 개의 작업을 한 번에 대기열에 추가하면 모두 즉시 실행 가능 상태가 됩니다.

View File

@@ -0,0 +1,269 @@
---
title: 연결
description: 앱이 OAuth를 통해 타사 서비스에서 사용자를 대신해 동작하도록 하세요.
icon: plug
---
연결은 사용자가 외부 서비스(Linear, GitHub, Slack 등)에 대한 자격 증명을 보유하는 것을 말합니다. 앱은 해당 자격 증명을 **어떻게** 얻는지(즉, **연결 제공자**)를 선언하고, 런타임에 이를 사용해 서드파티 API에 인증된 호출을 수행합니다.
현재는 OAuth 2.0만 지원됩니다. 향후 자격 증명 유형(개인 액세스 토큰, API 키, 기본 인증)은 동일한 인터페이스에 연동되며 — 이미 `defineConnectionProvider({ type: 'oauth', ... })`를 사용하는 앱은 마이그레이션이 필요하지 않습니다.
<AccordionGroup>
<Accordion title="defineConnectionProvider" description="앱의 연결을 얻는 방법을 선언합니다">
연결 제공자는 앱에 필요한 OAuth 핸드셰이크를 설명합니다. 사용자가 앱 설정에서 "연결 추가"를 클릭하고 제공자의 동의 화면을 완료하면, 워크스페이스에 `ConnectedAccount` 행이 생성됩니다.
정상 동작하려면 **두 개의 파일**이 필요합니다 — 연결 제공자, 그리고 OAuth 클라이언트 자격 증명을 보유하는 `defineApplication`의 해당 `serverVariables` 선언.
```ts src/connection-providers/linear-connection.ts
import { defineConnectionProvider } from 'twenty-sdk/define';
export default defineConnectionProvider({
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
name: 'linear',
displayName: 'Linear',
icon: 'IconBrandLinear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
// These must match keys in `defineApplication.serverVariables` below.
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
// 'form-urlencoded' for the token request.
tokenRequestContentType: 'form-urlencoded',
// Optional: defaults to true. Disable only if the provider rejects PKCE.
usePkce: false,
// Optional: extra query params on the authorize URL.
// authorizationParams: { prompt: 'consent' },
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
```ts src/application.config.ts
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: '...',
displayName: 'Linear',
description: 'Connect Linear to Twenty.',
// OAuth client credentials live on the app registration (one OAuth app per
// Twenty server, configured by the admin) — not per-workspace. Declare them
// as serverVariables so the admin can fill them in once for all installs.
serverVariables: {
LINEAR_CLIENT_ID: {
description: 'OAuth client ID from your Linear OAuth application.',
isSecret: false,
isRequired: true,
},
LINEAR_CLIENT_SECRET: {
description: 'OAuth client secret from your Linear OAuth application.',
isSecret: true,
isRequired: true,
},
},
});
```
핵심 요점:
* `name`은 `listConnections({ providerName })`에서 사용하는 고유 식별자 문자열입니다(kebab-case, `^[a-z][a-z0-9-]*$`와 일치해야 함).
* `displayName`은 앱별 설정 탭과 AI 도구 목록에 표시됩니다.
* `clientIdVariable` / `clientSecretVariable`은 값이 아닌 **이름**이며, `defineApplication.serverVariables`에 선언된 키와 일치해야 합니다. 실제 `client_id`와 `client_secret`은 서버 관리자가 앱 등록 UI를 통해 입력하며, 저장소에 커밋되지 않습니다.
* `serverVariables`(`applicationVariables` 아님)을 사용하세요 — OAuth 자격 증명은 서버 전체에 적용되며 Twenty 서버마다 하나의 OAuth 앱만 사용합니다.
* 두 `serverVariables`가 모두 채워질 때까지, 앱별 설정 탭에는 "서버 관리자 필요" 힌트가 표시되고 "연결 추가" 버튼이 비활성화됩니다.
* `type: 'oauth'`는 현재 지원되는 유일한 값입니다. 구분자는 전방 호환됩니다: 향후 유형(`'pat'`, `'api-key'`, ...) `oauth`와 함께 새로운 하위 구성 블록이 추가됩니다.
제공자가 허용 목록에 추가해야 할 OAuth 콜백 URL은 다음과 같습니다:
```
https://<your-twenty-server>/auth/apps/callback
```
</Accordion>
<Accordion title="연결 시 로직 함수 실행하기" description="연결이 성립되는 즉시 반응하기">
일부 공급자는 연결 시점에, 연결을 실제로 사용할 수 있게 되기 전에 유지해야 하는 데이터를 제공하기도 합니다. 대표적인 예로 Slack의 경우 OAuth 응답에, 수신 이벤트를 키로 사용할 워크스페이스의 `team_id`가 포함됩니다. `onConnectLogicFunction`을 동일한 앱의 로직 함수(그 로직 함수의 `universalIdentifier`로 식별)에 연결하면, `ConnectedAccount`가 생성된 직후 해당 로직 함수가 실행됩니다.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
이 훅은 **연결 중인 워크스페이스에서 비동기적으로 실행**됩니다(대기하지 않고 큐에 넣어 처리하므로), 훅이 느리게 동작하거나 실패하더라도 OAuth 콜백을 절대 차단하거나 중단하지 않습니다. 따라서 멱등적으로 구현하고, 재시도를 스스로 처리하도록 하십시오. 핸들러는 다음을 수신합니다:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
이후에는 `getConnection(connectedAccountId)`를 사용하여 최신 액세스 토큰을 읽고, 공급자의 API(예: Slack `auth.test`)를 호출하거나 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)에 매핑을 저장합니다.
</Accordion>
<Accordion title="연결 해제 시 로직 함수 실행하기" description="연결이 제거될 때 정리 작업 수행하기">
앱이 연결 시점에 확보(클레임)한 모든 항목은 연결이 해제될 때 반드시 해제되어야 합니다. 예를 들어, 연결 시점에 `team_id`를 확보(클레임)하는 Slack 연동은, 동일한 Slack 팀에 다른 워크스페이스가 연결될 수 있도록 그 클레임을 해제해야 합니다. `onDisconnectLogicFunction`을 동일한 앱의 로직 함수를 참조하도록 설정하면, `ConnectedAccount`가 삭제된 직후 해당 로직 함수가 실행됩니다.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
on-connect 훅과 마찬가지로 **연결이 끊기는 워크스페이스에서 비동기적으로 실행**되며, 연결 해제를 차단(block)하지 않습니다. 핸들러는 동일한 형태의 페이로드를 전달받습니다:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
훅이 실행될 때는 `ConnectedAccount`가 이미 제거된 상태이므로 `getConnection(connectedAccountId)`는 더 이상 해결(resolve)되지 않습니다. 정리(cleanup)에 필요한 모든 것(예: `team_id`, 외부 구독 ID)은 연결 시점에 `connectedAccountId`를 키로 사용해 [key-value store](/l/ko/developers/extend/apps/logic/key-value-store)에 미리 기록되어 있어야 합니다.
이 훅은 연결이 자체적으로 제거될 때 실행됩니다. 앱을 제거(uninstall)하면, 데이터베이스의 CASCADE를 통해 연결이 삭제되므로 해당 경우에는 이 훅이 실행되지 않습니다. 그 경로에 대해 `defineApplication`에 `uninstallLogicFunction`을 선언하세요. 이 함수는 앱의 메타데이터가 삭제되기 전에 실행되므로, 아직 `listConnections`를 호출해 남은 항목을 정리(clean up)할 수 있습니다.
</Accordion>
<Accordion title="listConnections / getConnection" description="로직 함수에서 연결 사용">
로직 함수 핸들러 내부에서 `listConnections({ providerName })`는 지정된 제공자에 대한 이 앱의 `ConnectedAccount` 행을 갱신된 액세스 토큰과 함께 반환합니다.
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
import { listConnections } from 'twenty-sdk/logic-function';
export const createLinearIssueHandler = async (input: {
teamId?: string;
title?: string;
}) => {
if (!input.teamId || !input.title) {
return { success: false, error: 'teamId and title are required' };
}
const connections = await listConnections({ providerName: 'linear' });
// Workspace-shared credentials win when present; fall back to the first
// user-visibility one. For HTTP-route triggers you typically pick the
// request user's connection via event.userWorkspaceId instead.
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection".',
};
}
// Use connection.accessToken to call the third-party API.
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
}),
});
return { success: response.ok };
};
```
각 연결에는 다음이 포함됩니다:
| 필드 | 설명 |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `id` | 고유한 행 ID; 단일 항목을 다시 가져오려면 `getConnection(id)`에 전달하세요. |
| `visibility` | `'user'`(한 워크스페이스 구성원에게만 비공개) 또는 `'workspace'`(모든 구성원과 공유) |
| `scopes` | 상위 제공자가 부여한 OAuth 권한(`visibility`와는 구별됨 — 서로 관련 없음) |
| `userWorkspaceId` | 소유자의 userWorkspace ID — HTTP 경로 트리거에서 "요청 사용자의 연결"을 선택할 때 유용합니다 |
| `accessToken` | 최신 OAuth 액세스 토큰(만료 시 자동으로 갱신됨) |
| `name` | 연결의 표시 이름(OAuth 콜백 시 자동으로 결정되며, 사용자가 이름을 변경할 수 있음) |
| `핸들` | 연결된 업스트림 계정의 이메일로, 매번 재연결할 때마다 갱신되며 토큰 교환 시 반환되는 OIDC `id_token`에서 결정됩니다. 공급자가 이메일을 반환하지 않으면 연결 중인 Twenty 사용자의 이메일로 대체됩니다 |
| `authFailedAt` | 가장 최근 갱신이 실패했을 때 설정됩니다; 사용자가 다시 연결해야 합니다 |
핵심 요점:
* 제공자별로 필터링하려면 `{ providerName }`를 전달하세요; 생략하면 이 앱이 모든 제공자에서 보유한 모든 연결을 가져옵니다.
* 서버는 반환하기 전에 액세스 토큰을 투명하게 갱신합니다. 핸들러는 항상 사용 가능한 토큰(또는 `authFailedAt`이 설정된 상태)을 보게 됩니다.
* `getConnection(id)`는 단일 행 버전입니다.
</Accordion>
<Accordion title="사용자별 대 워크스페이스 공유 가시성" description="사용자가 비공개 자격 증명과 공유 자격 증명 사이를 선택하는 방법">
사용자가 "연결 추가"를 클릭하면, 가시성을 선택하라는 프롬프트가 표시됩니다:
* **나만 사용** — 해당 자격 증명은 연결한 사용자에게만 비공개입니다. 그 사용자를 대신해 호출되는 모든 로직 함수(`isAuthRequired: true`가 설정된 HTTP 경로 트리거)는 이를 볼 수 있습니다; cron 트리거와 데이터베이스 이벤트는 볼 수 없습니다.
* **워크스페이스 공유** — 모든 워크스페이스 구성원이 해당 자격 증명을 사용할 수 있습니다. 요청 사용자가 없으므로 Cron/데이터베이스 트리거도 이를 볼 수 있습니다.
각 핸들러에 맞는 유형을 사용하세요:
```ts
// HTTP-route trigger — prefer the request user's own connection.
const conn =
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
connections.find((c) => c.visibility === 'workspace');
// Cron trigger — no request user; only shared credentials are sensible.
const conn = connections.find((c) => c.visibility === 'workspace');
```
각 (사용자, 제공자)당 여러 연결이 허용되므로, 동일한 사용자가 "Personal Linear"와 "Work Linear"를 나란히 보유할 수 있습니다.
</Accordion>
<Accordion title="일회성 제공자 설정" description="서드파티 서비스에 OAuth 앱을 등록하세요">
각 연결 제공자마다 서버 관리자가 먼저 서드파티에 OAuth 앱을 등록해야 합니다.
1. 제공자의 개발자 설정으로 이동하세요(예: https://linear.app/settings/api/applications/new).
2. **Redirect URI**를 `\<SERVER_URL>/auth/apps/callback`으로 설정하세요.
3. 생성된 **Client ID**와 **Client Secret**을 복사하세요.
4. 서버 관리자 권한으로 Twenty에서 설치된 앱을 열고 → 해당 `serverVariables`에 값을 설정하세요.
5. 그런 다음 워크스페이스 구성원은 앱별 **연결** 섹션에서 연결을 추가할 수 있습니다.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,110 @@
---
title: 키-값 저장소
description: 기본 제공 애플리케이션 키-값 스토어를 사용해 중간 결과를 유지하고, 데이터를 캐시하며, 논리 함수 실행 간 상태를 공유하세요.
icon: database
---
로직 함수는 단기간 실행되는 샌드박스된 Node.js 프로세스에서 동작하며, 한 번 실행이 끝나면 메모리에 남아 있는 것은 아무것도 유지되지 않습니다. 실행 사이에서 **어떤 값을 기억해야 할 때**(비용이 큰 API 응답을 캐시하거나, 증분 동기화를 위한 커서를 저장하거나, 작업을 디바운스하거나, 한 함수에서 다른 함수로 상태를 넘길 때)는 기본 제공 키-값 스토어에 상태를 저장하세요.
각 애플리케이션은 자체적으로 격리된 네임스페이스를 갖습니다. 항목은 인증된 앱을 기준으로 키가 지정되므로, 키가 다른 애플리케이션과 충돌하거나 다른 애플리케이션에 의해 읽히는 일은 없습니다.
```text
┌─────────────────┐ kv.set(key, value) ┌──────────────────────────┐
│ Logic function │ ─────────────────────▶ │ Application KV store │
│ (your handler) │ ◀───────────────────── │ key (unique) │ value │
└─────────────────┘ kv.get(key) └──────────────────────────┘
```
## 가져오기, 설정, 삭제
`twenty-sdk/logic-function`에서 `kv`를 임포트하세요. 값은 JSON으로 직렬화할 수 있는 모든 페이로드가 될 수 있습니다.
```ts src/logic-functions/sync-linear-issues.ts
import { kv } from 'twenty-sdk/logic-function';
// Read a value. Returns null when the key is missing.
const cursor = await kv.get<string>('sync-cursor:linear');
// Write a value. Creates the entry on first write, updates it afterwards.
await kv.set('sync-cursor:linear', newCursor);
// Delete an entry. Returns true when an entry was removed.
await kv.delete('sync-cursor:linear');
```
## 범위
각 항목에는 스코프가 있으며, 모든 호출에서 옵션으로 전달됩니다. 기본값은 `WORKSPACE`입니다.
* **`WORKSPACE`** (기본값) — 항목은 현재 워크스페이스에 설치된 앱 인스턴스에만 비공개입니다. 앱을 설치한 각 워크스페이스는 자체적인 독립 키 집합을 가집니다. 캐시, 커서, 워크스페이스별 상태에는 이 스코프를 사용하는 것이 적합합니다.
* **`SERVER`** — 항목은 서버에서 실행 중인 앱의 모든 설치 간에 공유됩니다. 서버 항목은 \*\*클레임(claim)\*\*처럼 동작합니다. 저장된 값은 항상 해당 키를 클레임한 workspaceId이며(`set`에서 `value`를 생략하면 현재 워크스페이스에 대해 키를 클레임합니다), 그 워크스페이스만 해당 값을 덮어쓰거나 삭제할 수 있습니다. 어떤 설치에서든 해당 항목을 읽을 수 있습니다.
서버 클레임은 워크스페이스 간 라우팅을 위해 존재합니다. [server-route resolver](/l/ko/developers/extend/apps/logic/logic-functions#server-route-trigger)는 애플리케이션 등록 소유자 워크스페이스에서 실행되지만, 인바운드 웹후크는 일반적으로 Twenty workspaceId가 아닌 외부 계정 ID만을 포함합니다. 각 워크스페이스가 연결 시점에 자신의 외부 ID를 클레임하게 한 다음, 라우트에서 이를 해결하세요:
```ts
// In the connected workspace, when the external account is linked:
await kv.set(`slack:team:${teamId}`, undefined, { scope: 'SERVER' });
// In the server-route resolver (owner workspace), on each webhook:
const workspaceId = await kv.get<string>(`slack:team:${teamId}`, {
scope: 'SERVER',
});
```
서버 키는 호출자의 워크스페이스에 대해서만 클레임할 수 있고 다른 워크스페이스가 덮어쓸 수 없기 때문에, 한 워크스페이스가 다른 워크스페이스에 속한 매핑을 가로채는 일은 일어날 수 없습니다. 키가 이미 다른 워크스페이스에 의해 클레임된 경우 `kv.set`은 예외를 발생시킵니다.
## 사용 예: 비용이 큰 호출 캐시하기
일반적인 사용 예는 느리거나 rate limit이 걸린 서드파티 응답을 캐시해서, 반복 실행 시 매번 비용을 지불하지 않고 재사용하도록 하는 것입니다.
```ts src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `cache:exchange-rate:${params.from}:${params.to}`;
const cached = await kv.get<CachedRate>(cacheKey);
if (cached && Date.now() - cached.fetchedAt < ONE_HOUR_MS) {
return { rate: cached.rate, cached: true };
}
const response = await fetch(
`https://api.example.com/rate?from=${params.from}&to=${params.to}`,
);
const { rate } = (await response.json()) as { rate: number };
await kv.set(cacheKey, { rate, fetchedAt: Date.now() });
return { rate, cached: false };
};
export default defineLogicFunction({
universalIdentifier: 'd9b2f4e6-1c83-4a07-9e52-6b1d3c8a0f47',
name: 'get-exchange-rate',
timeoutSeconds: 10,
handler,
});
```
## 패턴 및 팁
* **네임스페이스 구성.** 서로 다른 관심사를 분리하기 위해 키에 접두사를 붙이세요 — `sync-cursor:linear`, `cache:exchange-rate:USD:EUR`, `lock:nightly-report`.
* **만료(TTL).** 이 스토어에는 내장된 만료 기능이 없습니다. 값 안에 타임스탬프를 저장해(캐시 예제처럼) 읽을 때 확인하거나, [cron-triggered function](/l/ko/developers/extend/apps/logic/logic-functions)을 통해 오래된 키를 주기적으로 정리하세요.
* **무엇을 저장할지.** 숫자, 문자열, 배열, 객체 등 JSON으로 직렬화 가능한 어떤 값이든 저장할 수 있습니다. 엔트리는 작게 유지하세요. 이 스토어는 대용량 blob이나 파일이 아니라, 조정 및 캐싱용입니다. 파일의 경우 `FILES` 필드와 [`uploadFile`](/l/ko/developers/extend/apps/logic/logic-functions#uploading-files)을 사용하세요.
* **가시성.** 항목은 워크스페이스 레코드가 아니라 인스턴스 데이터베이스에 저장됩니다. 따라서 워크스페이스 UI에 나타나지 않고, 앱의 데이터 모델의 일부도 아니며, 역할이나 객체 권한도 필요하지 않습니다.
## 대안: 조회 가능한 스토어 객체
기본 제공 스토어는 의도적으로 불투명합니다. 항목이 레코드가 아니기 때문에 UI에서 탐색하거나, 다른 객체와 관계를 맺거나, 레코드 쿼리로 필터링할 수 없습니다. 이러한 기능이 필요할 때(예: 눈에 보이는 동기화 로그나 레코드별 상태 등)에는 고유한 `key` 필드와 `RAW_JSON` `value` 필드를 가진 작은 **technical object**를 정의하고, [typed API client](/l/ko/developers/extend/apps/logic/logic-functions#typed-api-clients-twenty-client-sdk)를 통해 이를 쿼리하세요. `defineObject` 레퍼런스는 [Objects](/l/ko/developers/extend/apps/data/objects)를, 키의 고유성을 강제하려면 [Data → Unique indexes](/l/ko/developers/extend/apps/data/overview#unique-indexes)를 참고하세요.
* **레코드에 스코프 지정하기.** ID를 키에 인코딩하지 말고, 스토어 객체에서 대상 객체로 [relation](/l/ko/developers/extend/apps/data/relations)을 추가하세요.
* **가시성 및 권한.** 행은 다른 레코드와 마찬가지로 워크스페이스 데이터베이스에 저장되므로, API를 통해 조회할 수 있고 앱의 [role](/l/ko/developers/extend/apps/config/roles)을 그대로 따릅니다. 스토어를 기본 UI에서 숨기고 싶다면, [navigation menu](/l/ko/developers/extend/apps/layout/navigation-menu-items)에 추가하지 마세요.
<Note>
기본 제공 스토어와 달리 커스텀 객체는 항상 하나의 워크스페이스에만 스코프가 지정되며, `SERVER` 키처럼 설치 간에 항목을 공유할 수 없습니다.
</Note>

View File

@@ -0,0 +1,908 @@
---
title: 로직 함수
description: HTTP, cron 및 데이터베이스 이벤트 트리거를 사용하여 서버 측 TypeScript 함수를 정의합니다.
icon: bolt
---
로직 함수는 Twenty 플랫폼에서 실행되는 서버 측 TypeScript 함수입니다. 이 함수들은 HTTP 요청, cron 스케줄 또는 데이터베이스 이벤트에 의해 트리거될 수 있으며 — AI 에이전트를 위한 도구로도 제공할 수 있습니다.
<AccordionGroup>
<Accordion title="defineLogicFunction" description="로직 함수와 해당 트리거 정의">
각 함수 파일은 `defineLogicFunction()`을 사용해 핸들러와 선택적 트리거가 포함된 구성을 내보냅니다.
```ts src/logic-functions/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const body = (params.body ?? {}) as { name?: string };
const name = body.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world';
const result = await client.mutation({
createPostCard: {
__args: { data: { name } },
id: true,
name: true,
},
});
return result;
};
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'POST',
isAuthRequired: true,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
},*/
/*cronTriggerSettings: {
pattern: '0 0 1 1 *',
},*/
});
```
사용 가능한 트리거 유형:
* **httpRoute**: HTTP 경로와 메서드로 함수를 노출합니다. 앱 코드에서 `RestApiClient`를 사용할 때는 라우트 경로 앞에 `/s/`를 접두사로 붙이십시오. 배포된 URL은 주입된 `TWENTY_FUNCTIONS_URL` 기본 URL(설정되지 않은 경우 `\<server-url>/s`)을 사용합니다.
<Note>
(헤드리스) 프런트 컴포넌트에서 라우트로 트리거되는 로직 함수를 호출하려면 [로직 함수 호출하기](/l/ko/developers/extend/apps/layout/front-components#calling-a-logic-function)를 참고하세요.
</Note>
* **cron**: CRON 식을 사용하여 예약된 일정으로 함수를 실행합니다.
* **databaseEvent**: 워크스페이스 객체 라이프사이클 이벤트에서 실행됩니다. 이벤트 작업이 `updated`인 경우, 수신할 특정 필드를 `updatedFields` 배열에 지정할 수 있습니다. 정의하지 않거나 비워두면, 어떤 업데이트든 함수가 트리거됩니다.
> 예: `person.updated`, `*.created`, `company.*`
* **serverRoute**: 단일 등록 범위 HTTP 라우트를 노출합니다. `serverRouteTriggerSettings`로 선언된 **resolver** 함수는 소유자 워크스페이스에서 실행되며, 동기식 `Response`를 반환하거나, 대상 워크스페이스와 큐에 넣을 대상 로직 함수를 반환합니다. 큐에 넣는 경로의 경우 플랫폼은 `202`로 응답을 확인(ack)하고 워커 큐에서 해당 **target**을 실행합니다. [서버 라우트 트리거](#server-route-trigger)를 참고하세요.
<Note>
CLI를 사용해 함수를 수동으로 실행할 수도 있습니다:
```bash filename="Terminal"
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
```
```bash filename="Terminal"
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
다음으로 로그를 확인할 수 있습니다:
```bash filename="Terminal"
yarn twenty dev:function:logs
```
</Note>
#### 라우트 트리거 페이로드
라우트 트리거가 로직 함수를 호출하면, 함수는 [AWS HTTP API v2 형식](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)을 따르는 `RoutePayload` 객체를 받습니다.
`twenty-sdk/logic-function`에서 `RoutePayload` 타입을 임포트하세요:
```ts
import type { RoutePayload } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
const { headers, queryStringParameters, pathParameters, body } = event;
const { method, path } = event.requestContext.http;
return { message: 'Success' };
};
```
`RoutePayload` 타입은 다음과 같은 구조입니다:
| 속성 | 유형 | 설명 | 예시 |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP 헤더(`forwardedRequestHeaders`에 나열된 항목만) | 아래 섹션 참조 |
| `queryStringParameters` | `Record\<string, string \| undefined>` | 쿼리 문자열 매개변수(여러 값은 쉼표로 연결됨) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | 라우트 패턴에서 추출된 경로 매개변수 | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | 파싱된 요청 본문(JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | JSON 파싱 이전의 원본 UTF-8 요청 본문입니다. HMAC 스타일 웹훅 서명을 검증하는 데 유용합니다(예: GitHub의 `X-Hub-Signature-256`, Stripe). 런타임이 이를 보존하지 않은 경우에는 `undefined`입니다. | |
| `isBase64Encoded` | `boolean` | 본문이 base64로 인코딩되었는지 여부 | |
| `requestContext.http.method` | `string` | HTTP 메서드(GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | 원시 요청 경로 | |
#### forwardedRequestHeaders
기본적으로 보안상의 이유로 들어오는 요청의 HTTP 헤더는 로직 함수로 **전달되지 않습니다**.
특정 헤더에 접근하려면 `forwardedRequestHeaders` 배열에 나열하세요:
```ts
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'webhook-handler',
handler,
httpRouteTriggerSettings: {
path: '/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
},
});
```
핸들러에서, 전달된 헤더에 다음과 같이 접근합니다:
```ts
const handler = async (event: RoutePayload) => {
const signature = event.headers['x-webhook-signature'];
const contentType = event.headers['content-type'];
// Validate webhook signature...
return { received: true };
};
```
<Note>
헤더 이름은 소문자로 정규화됩니다. 소문자 키를 사용해 접근하세요(예: `event.headers['content-type']`).
</Note>
#### 사용자 정의 HTTP 응답
기본적으로, 핸들러에서 일반 값을 반환하면 해당 값이 `200` 응답으로 전송됩니다(객체는 JSON, 문자열은 `text/plain`). 상태 코드와 응답 헤더를 제어하려면 `twenty-sdk/logic-function`에서 `Response`를 반환하세요:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
보안상의 이유로, 응답 헤더는 허용 목록으로 제한됩니다. 목록에 없는 헤더(예: `Set-Cookie`, `Access-Control-Allow-Origin`과 같은 CORS 헤더, 또는 사용자 정의 `X-*` 헤더)는 응답이 전송되기 전에 조용히 제거됩니다. 허용되는 응답 헤더는 다음과 같습니다:
* `content-type`
* `content-language`
* `content-disposition`
* `cache-control`
* `retry-after`
<Note>
상태 코드는 유효한 HTTP 상태 코드(100에서 599 사이)여야 합니다. 응답 헤더 이름은 대소문자를 구분하지 않습니다.
</Note>
#### 플랫폼 오류 응답
핸들러 자체의 응답 외에도, 플랫폼은 일부 상황에서 라우트 호출에 직접 응답합니다. 라우트나 함수가 존재하지 않을 때는 `404`, 애플리케이션이 중지된 상태일 때는 `403`, 실행 속도 제한에 도달했을 때는 `429`, 애플리케이션의 프로덕션 `dependencies`가 너무 커서 설치할 수 없을 때는 `422`를 반환합니다. 자세한 내용은 [dependencies size limits](/l/ko/developers/extend/apps/getting-started/project-structure#dependencies)를 참조하세요.
#### 서버 라우트 트리거
`httpRouteTriggerSettings`는 `/s/` 아래에 함수를 노출하고 요청 호스트에서 워크스페이스를 해석합니다. 이는 각 워크스페이스가 자체 도메인을 가질 때 동작합니다. 그러나 타사 공급자는 모든 테넌트의 이벤트를 **하나의** URL로 전달합니다. 그 경우에는 `serverRouteTriggerSettings`를 사용하세요.
트리거는 두 부분으로 구성됩니다:
1. **resolver** 로직 함수 — `serverRouteTriggerSettings`로 선언되는 — 는 **소유자 워크스페이스**(애플리케이션 등록을 소유한 워크스페이스)에서 실행됩니다. 이 함수는 들어오는 요청을 검사한 뒤 다음 중 하나를 반환합니다:
* `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` — 플랫폼은 지정된 워크스페이스에서 해당 대상 함수를 큐에 넣고 `202 { queued: true }`로 응답을 확인(ack)합니다, 또는
* `twenty-sdk/logic-function`의 `Response` — 플랫폼은 해당 HTTP 응답을 **동기적**으로 그대로 반환하고, target을 큐에 넣지는 **않습니다**(Slack `url_verification`과 같은 챌린지 핸드셰이크에 사용하십시오).
resolver는 단일 인가 지점입니다 — URL에는 resolver의 식별자만 포함됩니다. **요청 서명을 검증하기에 가장 적합한 위치입니다**. resolver는 어떤 부수 효과가 발생하기 전에 실행되며, 원본 `rawBody`와 전달된 헤더에 접근할 수 있고, 대상에 전혀 접근하지 않고도 요청을 거부할 수 있습니다.
2. 그 다음 **target** 로직 함수 — 일반적인 워크스페이스별 로직 함수 — 가 resolver가 반환한 payload(또는 resolver가 변환하지 않았다면 원본 요청 payload)를 가지고 결정된 워크스페이스에서 실행됩니다. resolver가 enqueue 경로를 선택한 경우, 해당 반환 값은 HTTP 호출자가 **전혀** 관찰하지 못합니다.
```ts src/logic-functions/resolve-server-route.logic-function.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { defineLogicFunction } from 'twenty-sdk/define';
import { Response, type RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the owner workspace. Verifies the request signature, picks
// which target function should handle the event, and returns the
// workspace + target the platform should dispatch to.
const handler = async (event: RoutePayload) => {
// Fail closed if the secret isn't configured — never fall back to an
// empty key, which would let any caller forge a matching signature.
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
}
const signature = event.headers['x-hub-signature-256'] ?? '';
const expected =
'sha256=' +
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('invalid signature');
}
const body = (event.body ?? {}) as {
challenge?: string;
metadata?: { twentyWorkspaceId?: string };
type?: string;
};
// Handshakes must be answered on this same response, so reply from the
// resolver instead of returning a dispatch target.
if (body.type === 'url_verification') {
return new Response({ challenge: body.challenge });
}
const workspaceId = body.metadata?.twentyWorkspaceId;
if (!workspaceId) {
throw new Error('event is not linked to a workspace');
}
return {
workspaceId,
// Route different event types to different target functions.
targetLogicFunctionUniversalIdentifier:
body.type === 'invoice.paid'
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
};
};
export default defineLogicFunction({
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'resolve-server-route',
handler,
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
},
});
```
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';
// Runs in the resolved workspace. The resolver has already authenticated
// the request, so this handler can focus on the actual work.
const handler = async (event: RoutePayload) => {
// ...handle the verified event
return { received: true };
};
export default defineLogicFunction({
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
name: 'handle-invoice-paid',
handler,
});
```
엔드포인트는 다음 위치에서 접근할 수 있습니다:
```
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
```
이 식별자는 manifest에 있는 resolver의 `universalIdentifier`입니다. 해당 URL을 공급자에 등록하세요.
**검증 `GET`에 응답하기.** 일부 공급자는 이벤트를 나중에 `POST`할 동일한 URL로 챌린지가 포함된 `GET`을 전송하여 엔드포인트에 전달하기 전에 이를 검증합니다. Meta의 WhatsApp Cloud API가 그중 하나입니다. 서버 경로는 별도로 지정하지 않는 한 `POST`에만 응답하므로, 두 메서드를 모두 선언하세요:
```ts
serverRouteTriggerSettings: {
forwardedRequestHeaders: ['x-hub-signature-256'],
httpMethods: ['GET', 'POST'],
}
```
챌린지는 `event.queryStringParameters`에 도착하며, `Response`를 반환하면 동일한 요청에서 공급자에게 이를 다시 에코합니다. 문자열 본문은 `text/plain`으로 전송되며, 이는 이러한 공급자가 예상하는 형식입니다:
```ts
const handler = async (event: RoutePayload) => {
if (event.requestContext.http.method === 'GET') {
return new Response(event.queryStringParameters['hub.challenge']);
}
// …handle the delivered event
};
```
`httpMethods`는 기본값에 추가하는 대신 이를 대체하므로, `['GET']`만 지정하면 경로는 `POST`를 거부합니다. `GET` 및 `POST`만 지원됩니다. 공급자에게 두 번째 메서드가 필요하지 않은 한 설정하지 않은 상태로 두세요. `GET`을 선언하는 경로는 별도 요청 없이 `GET`을 보내는 크롤러와 링크 미리보기 생성기를 포함한 모든 인증되지 않은 호출자에 의해 해당 리졸버가 실행됩니다. 플랫폼에 메서드가 없는 모든 요청에는 리졸버가 실행되지 않은 채 `405`가 응답됩니다.
<Note>
**애플리케이션은 소유자 워크스페이스가 지정되어 해당 워크스페이스에 설치되어 있어야 합니다.** 리졸버는 **소유자 워크스페이스**(애플리케이션 등록을 소유한 워크스페이스)에서 실행되므로, 서버 라우트 트리거는 애플리케이션에 소유자 워크스페이스가 *지정되고* — 즉, 소유자 워크스페이스가 있고 — **그리고** 그 애플리케이션이 **소유자 워크스페이스에 설치된** 때에만 동작합니다. 이 두 조건이 모두 충족되기 전에는 리졸버가 실행될 위치가 없으므로, 라우트를 디스패치할 수 없습니다. 따라서 `serverRouteTriggerSettings` 로직 함수를 노출하는 애플리케이션은 소유자 워크스페이스가 지정되어 그 워크스페이스에 설치되기 전까지는 마켓플레이스에 등록될 수 없습니다.
</Note>
**Resolver 계약.** SDK의 `LogicFunctionConfig` 타입은 컴파일 타임에 이를 강제합니다. `serverRouteTriggerSettings`를 설정하는 즉시, 핸들러는 `Response` 또는 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(또는 이 둘 중 하나를 반환하는 `Promise`)을 반환하도록 제한됩니다. dispatch 경로에서 `workspaceId`는 대상 함수가 설치된 워크스페이스여야 하며, 그렇지 않으면 요청은 `404`로 거부됩니다. 두 가지 형태 중 어느 것에도 일치하지 않는 결과 — 식별자가 UUID가 아닌 경우를 포함해 —는 `502`로 거부됩니다.
| 필드 | 유형 | 노트 |
| ---------------------------------------- | ---------------- | ------------------------------------------------ |
| `workspaceId` | `string` | 대상 함수가 실행될 워크스페이스의 UUID입니다. |
| `targetLogicFunctionUniversalIdentifier` | `string` | 해당 워크스페이스에서 호출할 로직 함수의 `universalIdentifier`입니다. |
| `payload` | `object` (선택 사항) | 설정된 경우, 대상 함수에 전송되는 요청 본문을 대체합니다. |
<Warning>
**서명 검증은 사용자 책임입니다 — resolver에서 검증하세요.** 플랫폼은 요청 서명을 검증하지 않습니다. resolver는 이를 수행하기에 권장되는 위치입니다. 가장 먼저 실행되며, `event.rawBody`와 `forwardedRequestHeaders`에 나열한 헤더에 접근할 수 있고, 오류를 발생시키거나 일치하지 않는 `workspaceId`를 반환하면 대상이 호출되기 전에 디스패치를 중단합니다. 검증을 대신 대상 쪽으로 미루는 경우, 대상은 `rawBody`와 헤더를 잃지 않도록 주의해야 합니다. 즉, resolver가 `payload`를 반환해서는 안 됩니다. 항상 어떤 부수 효과가 발생하기 **이전**에 검증을 수행하고, 상수 시간 비교를 사용하세요.
</Warning>
요청 서명의 경우 대부분의 공급자는 HMAC-SHA256으로 서명합니다. 서로 다른 부분은 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열입니다. 몇 가지 예시는 다음과 같습니다:
| 공급자 | 포워딩할 헤더 | 서명 문자열 | 다이제스트 |
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (`whsec_` 제거 후 시크릿이 base64) |
| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex |
| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (`sha256=` prefix 포함) |
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (`v0=` prefix 포함) |
위의 resolver 예제는 이미 GitHub HMAC-SHA256 플로우를 보여 줍니다. 통합하려는 공급자에 따라 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열을 조정하세요.
<Note>
resolver가 dispatch 객체를 반환하면, 라우트는 `202 { queued: true }`로 응답하고 대상 함수는 워커 큐에서 실행됩니다. 호출자는 대상 함수의 지연 시간, 결과, 실패를 전혀 관찰하지 못하며(이러한 정보는 실행 로그에 기록됩니다). 이렇게 하면 발신자 재전송으로 인해 처리 지연이 더 커지는 것을 막을 수 있으며, 이는 웹훅 수신에서 바람직한 동작입니다.
호출자가 동일한 요청에서 응답 본문을 반드시 읽어야 하는 경우(챌린지 핸드셰이크, 대화형 확인 등)에는, 대신 **resolver**에서 `Response`를 반환하십시오. 플랫폼은 이를 동기적으로 그대로 반환하고 큐는 건너뜁니다. 이때 헤더는 HTTP 라우트 응답과 동일한 allow-list를 통과합니다. resolver는 빠르게 유지하세요. 일부 공급자(예: Slack)는 몇 초 안에 타임아웃됩니다. resolver는 public endpoint로 접근 가능하므로, 엣지에서 rate limiting으로 보호하세요.
</Note>
#### 데이터베이스 이벤트 트리거 페이로드
데이터베이스 이벤트 트리거가 로직 함수를 호출하면, 변경된 각 레코드마다 하나의 `DatabaseEventPayload`를 받습니다. 이 페이로드는 소스 워크스페이스와 오브젝트에 대한 메타데이터를 레코드 수준 이벤트와 결합합니다.
```ts
import type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-sdk/logic-function';
type Person = {
id: string;
emails?: { primaryEmail?: string };
};
```
페이로드에는 다음이 포함됩니다:
| 속성 | 설명 |
| ------------------------------------------------ | ------------------------------------------------------------------------------ |
| `name` | `person.updated`와 같은 이벤트 이름입니다. |
| `workspaceId` | 이벤트가 발생한 워크스페이스입니다. |
| `objectMetadata` | 변경된 오브젝트에 대한 메타데이터입니다. |
| `recordId` | 변경된 레코드의 ID입니다. |
| `userId`, `userWorkspaceId`, `workspaceMemberId` | 워크스페이스 사용자가 이벤트를 발생시켰을 때의 액터 필드입니다. |
| `properties` | 이벤트에 대한 레코드 데이터로, 작업 유형에 따라 `before`, `after`, `diff`, `updatedFields`를 포함합니다. |
| 이벤트 | 레코드 데이터 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `person.created` | `event.properties.after` |
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
| `person.destroyed` | `event.properties.before` |
소프트 삭제의 경우, 레코드의 `deletedAt` 필드가 변경되므로 `.deleted`는 업데이트 스타일 구조를 따릅니다.
영구 삭제의 경우 `.destroyed`를 사용하세요.
<Note>
`databaseEventTriggerSettings.updatedFields`는 어떤 업데이트 이벤트가 함수를 트리거할지 필터링합니다.
`event.properties.updatedFields`는 현재 이벤트에서 실제로 어떤 필드가 변경되었는지 알려줍니다.
</Note>
생성 이벤트 예시:
```ts
type PersonCreatedEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<Person>
>;
const handler = async (event: PersonCreatedEvent) => {
const person = event.properties.after;
return {
personId: event.recordId,
email: person.emails?.primaryEmail,
};
};
```
업데이트 이벤트 예시:
```ts
type PersonUpdatedEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<Person>
>;
const handler = async (event: PersonUpdatedEvent) => {
const { before, after, diff, updatedFields } = event.properties;
return {
personId: event.recordId,
updatedFields,
previousEmail: before.emails?.primaryEmail,
currentEmail: after.emails?.primaryEmail,
emailDiff: diff.emails,
};
};
```
이메일 업데이트에만 트리거되도록:
```ts
export default defineLogicFunction({
...,
databaseEventTriggerSettings: {
eventName: 'person.updated',
updatedFields: ['emails'],
},
});
```
삭제 이벤트 예시:
```ts
type PersonDestroyedEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<Person>
>;
const handler = async (event: PersonDestroyedEvent) => {
const personBeforeDestroy = event.properties.before;
return {
personId: event.recordId,
email: personBeforeDestroy.emails?.primaryEmail,
};
};
```
#### 실행 컨텍스트
모든 핸들러는 무엇이 이를 트리거했는지와 관계없이 실행 자체를 설명하는 두 번째 인수를 받습니다. 첫 번째 인수는 트리거에 따라 형태가 달라지지만, 이 인수는 달라지지 않습니다:
```ts
import type { LogicFunctionExecutionContext } from 'twenty-sdk/logic-function';
const handler = async (
event: DatabaseEventPayload,
{ workspaceId, workspaceMemberId }: LogicFunctionExecutionContext,
) => {
if (workspaceMemberId === null) {
throw new Error('This function must be triggered by a person');
}
return { reviewedBy: workspaceMemberId, workspaceId };
};
```
| 필드 | 유형 | 설명 |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------ |
| `workspaceId` | `string` | 실행이 속한 워크스페이스입니다. |
| `userWorkspaceId` | `string \| null` | 실행을 트리거한 사람을 이 워크스페이스의 멤버십으로 나타낸 것입니다. |
| `workspaceMemberId` | `string \| null` | 레코드를 연결하는 대상인 워크스페이스 멤버 레코드로서의 동일한 사람입니다. |
| `retryCount` | `number` | 첫 번째 시도에서는 `0`입니다. [백그라운드 작업](/l/ko/developers/extend/apps/logic/background-jobs)을 참조하세요. |
| `maxRetries` | `number` | 애플리케이션이 요청한 재시도의 상한입니다. |
아무도 실행을 트리거하지 않은 경우 `userWorkspaceId` 및 `workspaceMemberId`는 `null`입니다. cron 일정, 설치 훅 및 인증되지 않은 웹훅에는 그 뒤에 사람이 없습니다. 해당 사람에게 워크스페이스 멤버 레코드가 없거나 그 레코드가 삭제된 경우에도 `null`입니다.
<Note>
**컨텍스트는 실행을 트리거한 사람이 누구인지 알려 줍니다.** 실행이 *수행할 수 있는* 작업은 아래의 별도 선언입니다.
</Note>
#### 호출에서 사용하는 액세스 주체
모든 클라이언트, 즉 `CoreApiClient`, `MetadataApiClient`, `RestApiClient`는 실행을 트리거한 사람으로 동작합니다. 해당 사람의 역할과 애플리케이션의 역할이 교차되므로, 호출은 둘 중 어느 쪽보다도 더 많은 작업을 수행할 수 없습니다. 이것이 기본값이며, 사용자는 앱을 사용해 자신의 권한을 초과할 수 없음을 의미합니다.
아무도 실행을 트리거하지 않은 경우 동작할 사람이 없으므로, 동일한 클라이언트는 애플리케이션 자체의 액세스로 대체됩니다. cron 일정, 설치 훅 및 인증되지 않은 웹훅은 특별한 처리가 필요하지 않습니다.
실행 뒤에 사람이 *있는* 경우에도 일부 호출에는 정당하게 애플리케이션 자체의 액세스가 필요합니다. 예를 들어 앱의 구성 레코드를 읽거나, 해당 사람이 직접 수행할 수 없는 작업을 하는 경우입니다. 이러한 경우를 위한 두 번째 클라이언트를 만드세요:
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const asPerson = new CoreApiClient();
const asApplication = new CoreApiClient({ runAs: 'application' });
const handler = async (event: DatabaseEventPayload) => {
// config records only your app may read
const { slackUserLinks } = await asApplication.query({ ... });
// created as the person, so it is attributed and permission-checked
await asPerson.mutation({ createNote: { ... } });
};
```
둘 다 모듈 범위에서 한 번만 만들면, 각 호출 위치는 호출하는 클라이언트에 따라 사용하는 액세스를 지정합니다. `RestApiClient`도 동일한 옵션을 사용합니다:
```ts
import { RestApiClient } from 'twenty-client-sdk/rest';
const asApplication = new RestApiClient({ runAs: 'application' });
```
<Note>
**아무도 트리거하지 않은 실행은 애플리케이션으로 동작합니다.** cron 일정, 설치 훅 및 인증되지 않은 웹훅에는 그 뒤에 사람이 없으므로, 기본 클라이언트는 애플리케이션 자체의 액세스로 대체되어 계속 작동합니다. `runAs: 'application'`은 사람이 *실제로* 트리거한 실행에서 해당 액세스를 원하는 경우에만 필요합니다.
예를 들어 레코드의 귀속을 지정하는 등, 누군가가 그 뒤에 있는지 여부에 따라 함수가 다르게 동작하는 경우 `context.workspaceMemberId`를 확인하세요.
</Note>
애플리케이션 자체의 리소스에 접근하는 SDK 헬퍼는 항상 해당 액세스를 사용하며 `runAs`를 무시합니다. [키-값 저장소](/l/ko/developers/extend/apps/logic/key-value-store), [연결](/l/ko/developers/extend/apps/logic/connections), `runAgent`, `getPublicAssetUrl`, 크레딧 청구가 이에 해당합니다.
#### 함수를 AI 도구 또는 워크플로 작업으로 노출하기
로직 함수는 두 가지 영역에서 노출될 수 있으며, 각 영역마다 자체 트리거가 있습니다:
* **`toolTriggerSettings`** — 이 설정을 사용하면 함수가 Twenty의 AI 기능(채팅, MCP, 함수 호출)에서 검색 가능해집니다. 표준 JSON 스키마(LLM이 기본적으로 이해하는 형식)를 사용합니다.
* **`workflowActionTriggerSettings`** — 시각적 워크플로 빌더에서 함수를 단계로 나타나게 합니다. 빌더가 적절한 필드 에디터, 변수 선택기, 레이블을 렌더링할 수 있도록 Twenty의 풍부한 `InputSchema`를 사용합니다.
함수는 둘 중 하나만 또는 둘 다 선택할 수 있습니다. 이들 설정은 `cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`와 나란히 위치하며 — 패턴도 형태도 동일합니다.
<Note>
**워크플로 Code 액션과의 관계.** 워크플로 빌더에 내장된 **Code** 액션 자체가 하나의 로직 함수입니다. Twenty는 Code 단계마다 하나씩 생성하고, 해당 편집기를 인라인으로 노출합니다. `workflowActionTriggerSettings`는 그러한 일회성 인라인 코드를 **재사용 가능한** 액션으로 전환하는 방법입니다. 앱에서 함수를 한 번만 정의하면, 각 Code 단계에 코드를 복사·붙여넣기 하는 대신 모든 워크플로에서 선택할 수 있게 됩니다. 엔드유저 관점에 대해서는 사용자 가이드의 [Code 액션](/l/ko/user-guide/workflows/capabilities/workflow-actions#code)을 참고하세요.
</Note>
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
return { taskId: result.createTask.id };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
toolTriggerSettings: {},
});
```
핵심 요점:
* 함수는 노출 방식을 혼합할 수 있습니다 — `toolTriggerSettings`와 `workflowActionTriggerSettings`를 모두 선언하여 채팅과 워크플로 빌더 모두에 노출할 수 있습니다.
* `toolTriggerSettings.inputSchema`와 `workflowActionTriggerSettings.inputSchema`는 모두 선택 사항입니다. 생략되면 매니페스트 빌더가 핸들러 소스 코드에서 이를 추론합니다(AI 도구의 경우 JSON 스키마, 워크플로 작업의 경우 Twenty의 `InputSchema`). 더 풍부한 타입 지정을 원한다면 명시적으로 제공하세요 — 예를 들어 워크플로 빌더를 위해 `CURRENCY` 또는 `RELATION`처럼 `FieldMetadataType`을 인지하는 필드를 사용하거나, AI 에이전트가 읽을 수 있는 `description` 필드를 포함하려는 경우:
```ts
export default defineLogicFunction({
...,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
},
});
```
매개 변수를 **한 번만** 선언하고 두 환경 모두에서 사용하려면, 단일 JSON Schema(`InputJsonSchema`)를 정의한 후 `twenty-sdk/logic-function`의 `jsonSchemaToInputSchema`를 사용해 워크플로 작업용으로 변환하세요. `toolTriggerSettings.inputSchema`는 JSON Schema를 그대로 사용하지만, `workflowActionTriggerSettings.inputSchema`는 Twenty의 `InputSchema`를 요구합니다:
```ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
export default defineLogicFunction({
...,
toolTriggerSettings: { inputSchema },
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
},
});
```
##### 완전한 워크플로 액션 예제
`workflowActionTriggerSettings`는 네 가지 필드를 받습니다:
| 필드 | 목적 |
| -------------- | --------------------------------------------------------------------------------------------------- |
| `label` | 워크플로 빌더의 단계 선택기에서 액션에 표시되는 이름입니다. 기본값은 함수의 `name`입니다. |
| `icon` | 액션 옆에 표시되는 아이콘입니다 (`tabler-icons` 이름, 예: `IconBuilding`). |
| `inputSchema` | 빌더에서 구성 가능한 필드(변수 선택기 포함)로 렌더링되는 Twenty의 강력한 `InputSchema`입니다. 선택 사항입니다. 생략하면 핸들러에서 자동으로 유추됩니다. |
| `outputSchema` | 핸들러가 반환하는 데이터 구조를 선언하여, **이후 단계가 해당 출력 필드에 매핑할 수 있도록 합니다**. 선택 사항입니다. 이것이 없으면 출력은 단일 불투명 값으로 노출됩니다. |
이 모든 것을 하나로 — 선언된 출력을 통해 이후 단계에서 `taskId`를 참조할 수 있는, 워크플로 액션으로 노출된 함수 예시는 다음과 같습니다:
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
const inputSchema: InputJsonSchema = {
type: 'object',
properties: {
companyName: { type: 'string', label: 'Company name' },
domain: { type: 'string', label: 'Domain' },
},
required: ['companyName'],
};
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const result = await client.mutation({
createTask: {
__args: {
data: {
title: `Enrich data for ${params.companyName}`,
body: `Domain: ${params.domain ?? 'unknown'}`,
},
},
id: true,
},
});
// The keys returned here should match the `outputSchema` properties below.
return { taskId: result.createTask.id, enriched: true };
};
export default defineLogicFunction({
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: 'enrich-company',
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
workflowActionTriggerSettings: {
label: 'Enrich Company',
icon: 'IconBuilding',
inputSchema: jsonSchemaToInputSchema(inputSchema),
outputSchema: [
{
type: 'object',
properties: {
taskId: { type: 'string' },
enriched: { type: 'boolean' },
},
},
],
},
});
```
앱이 설치되면 **Enrich Company**가 워크플로 빌더의 액션 선택기에 나타납니다. 빌더는 `companyName`과 `domain`을 입력 필드로 렌더링하며(각각 이전 단계에서 값을 가져올 수 있음), 이후 단계에서는 해당 단계의 `taskId` 및 `enriched` 출력 값을 참조할 수 있습니다.
<Note>
**좋은 `description`을 작성하세요.** AI 에이전트는 도구를 언제 사용할지 결정하기 위해 함수의 `description` 필드에 의존합니다. 도구가 무엇을 하는지와 언제 호출해야 하는지 구체적으로 작성하세요.
</Note>
</Accordion>
</AccordionGroup>
<Note>
**런타임 헬퍼.** `twenty-sdk/utils`는 작은 런타임 헬퍼를 다시 export하여 핸들러가 `twenty-shared`를 직접 import하지 않도록 합니다. 예를 들어 `isDefined(value)`는 `null`과 `undefined` 모두에 대해 `false`를 반환합니다. 이를 사용하면 선택적 핸들러 입력을 안전하게 좁힐 수 있습니다. 이러한 입력은 타입이 `T | undefined`로 지정되어 있더라도 런타임에는 `null`로 전달될 수 있습니다:
```ts
import { isDefined } from 'twenty-sdk/utils';
const handler = async (params: { parentMessageId?: string }) => {
if (isDefined(params.parentMessageId)) {
// params.parentMessageId is narrowed to string here
}
};
```
</Note>
<Note>
**설치 훅** — 사전 설치, 사후 설치 및 제거 핸들러 — 는 이 런타임을 공유하지만, 각각의 `define` 함수로 선언되며 트리거 설정을 받지 않습니다. `definePreInstallLogicFunction`, `definePostInstallLogicFunction`, 및 `defineUninstallLogicFunction` 에 대해서는 [설치 훅](/l/ko/developers/extend/apps/config/install-hooks)을 참고하세요.
</Note>
## 타임라인 활동 만들기
`createTimelineActivity()`를 사용하여 로직 함수에서 명시적 도메인 이벤트를 게시합니다. 먼저 이벤트를 [타임라인 활동 유형](/l/ko/developers/extend/apps/data/timeline-activity-types)으로 정의한 다음, 안정적인 범용 식별자를 사용하여 유형과 객체를 지정합니다:
```ts
import { createTimelineActivity } from 'twenty-sdk/logic-function';
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
happensAt: new Date().toISOString(),
properties: {
deliveryProvider: 'postal-service',
},
});
```
Twenty는 범용 식별자를 설치별 메타데이터 ID로 확인하고, 타임라인 활동 유형이 호출 앱에 속하는지 검증하며, 새 활동에 표시 메타데이터의 스냅샷을 저장합니다.
필수 입력값은 `timelineActivityTypeUniversalIdentifier`, `targetObjectUniversalIdentifier`, `targetRecordId`입니다. `happensAt`, `properties`, `workspaceMemberId`도 제공할 수 있습니다. `happensAt`는 타임라인에서 이벤트의 위치와 표시 시간을 제어하며, 기본값은 생성 시간입니다.
다른 레코드를 이벤트에 연결하려면 `linkedRecordId`와 `linkedObjectMetadataUniversalIdentifier`를 함께 제공합니다. 추가로 기록용 표시 대체값으로 `linkedRecordCachedName`을 제공할 수 있습니다:
```ts
await createTimelineActivity({
timelineActivityTypeUniversalIdentifier:
'f4fa646c-6e11-4d8f-a6be-c3b7a2fc7501',
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
targetRecordId: postCardId,
linkedObjectMetadataUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
linkedRecordId: recipientId,
linkedRecordCachedName: recipientName,
});
```
로직 함수의 역할에는 표준 `timelineActivity` 객체에 대한 쓰기 권한이 필요합니다. 명시적 이벤트 유형은 `action`에 바인딩하지 마세요. 작업에 바인딩된 유형은 이미 자동 감사 이벤트를 수신하며, 그렇지 않으면 중복 행이 생성됩니다.
## 타입이 지정된 API 클라이언트(twenty-client-sdk)
`twenty-client-sdk` 패키지는 로직 함수와 프런트 컴포넌트에서 Twenty API와 상호작용하기 위한 타입이 지정된 두 가지 GraphQL 클라이언트를 제공합니다.
| 클라이언트 | 가져오기 | 엔드포인트 | 생성 여부? |
| ------------------- | ---------------------------- | -------------------------------- | -------------- |
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — 워크스페이스 데이터(레코드, 객체) | 예, 개발/빌드 시점 |
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — 워크스페이스 구성, 파일 업로드 | 아니오, 사전 빌드로 제공 |
<AccordionGroup>
<Accordion title="CoreApiClient" description="워크스페이스 데이터(레코드, 객체) 조회 및 변경">
`CoreApiClient`는 워크스페이스 데이터를 조회하고 변경하는 데 사용하는 기본 클라이언트입니다. `yarn twenty dev` 또는 `yarn twenty dev:build` 중에 **워크스페이스 스키마로부터 생성되므로**, 객체와 필드에 정확히 맞는 완전한 타입 정보를 제공합니다.
```ts
import { CoreApiClient } from 'twenty-client-sdk/core';
const client = new CoreApiClient();
// Query records
const { companies } = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
domainName: {
primaryLinkLabel: true,
primaryLinkUrl: true,
},
},
},
},
});
// Create a record
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Acme Corp',
},
},
id: true,
name: true,
},
});
```
이 클라이언트는 선택 집합 구문을 사용합니다: 필드를 포함하려면 `true`를 전달하고, 인수에는 `__args`를 사용하며, 관계는 객체를 중첩합니다. 워크스페이스 스키마를 기반으로 완전한 자동 완성과 타입 검사를 제공합니다.
<Note>
**CoreApiClient는 개발/빌드 시점에 생성됩니다.** 먼저 `yarn twenty dev` 또는 `yarn twenty dev:build`를 실행하지 않고 사용하면 오류가 발생합니다. 생성은 자동으로 이루어지며 — CLI가 워크스페이스의 GraphQL 스키마를 분석하고 `@genql/cli`를 사용해 타입이 지정된 클라이언트를 생성합니다.
</Note>
#### 타입 주석을 위한 CoreSchema 사용
`CoreSchema`는 워크스페이스 객체에 맞는 TypeScript 타입을 제공하며, 컴포넌트 상태나 함수 매개변수에 타입을 지정할 때 유용합니다:
```ts
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { useState } from 'react';
const [company, setCompany] = useState<
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
>(undefined);
const client = new CoreApiClient();
const result = await client.query({
company: {
__args: { filter: { position: { eq: 1 } } },
id: true,
name: true,
},
});
setCompany(result.company);
```
</Accordion>
<Accordion title="MetadataApiClient" description="워크스페이스 구성, 애플리케이션, 파일 업로드">
`MetadataApiClient`는 SDK와 함께 사전 빌드된 상태로 제공됩니다(생성 필요 없음). 워크스페이스 구성, 애플리케이션 및 파일 업로드를 위해 `/metadata` 엔드포인트를 조회합니다. 이는 `CoreApiClient`와 동일한 `runAs` 옵션을 사용합니다. [호출에서 사용하는 액세스 주체](#whose-access-a-call-uses)를 참조하세요.
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadataClient = new MetadataApiClient();
// List first 10 objects in the workspace
const { objects } = await metadataClient.query({
objects: {
edges: {
node: {
id: true,
nameSingular: true,
namePlural: true,
labelSingular: true,
isCustom: true,
},
},
__args: {
filter: {},
paging: { first: 10 },
},
},
});
```
#### 파일 업로드
`MetadataApiClient`에는 파일 유형 필드에 파일을 첨부하기 위한 `uploadFile` 메서드가 포함되어 있습니다:
```ts
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
| 매개변수 | 유형 | 설명 |
| ---------------------------------- | -------- | --------------------------------------------- |
| `fileBuffer` | `Buffer` | 원시 파일 내용 |
| `filename` | `string` | 파일의 이름(저장 및 표시용) |
| `contentType` | `string` | MIME 유형(생략하면 기본값은 `application/octet-stream`) |
| `fieldMetadataUniversalIdentifier` | `string` | 객체의 파일 유형 필드의 `universalIdentifier` |
핵심 요점:
* 필드의 `universalIdentifier`(워크스페이스별 ID가 아님)를 사용하므로, 앱이 설치된 모든 워크스페이스에서 업로드 코드가 동작합니다.
* 반환된 `url`은 업로드된 파일에 액세스하는 데 사용할 수 있는 서명된 URL입니다.
</Accordion>
</AccordionGroup>
<Note>
코드가 Twenty에서 실행될 때(로직 함수 또는 프런트 컴포넌트), 플랫폼이 자격 증명을 환경 변수로 주입합니다:
* `TWENTY_API_URL` — Twenty API의 기본 URL
* `TWENTY_APP_ACCESS_TOKEN` — 기본 액세스를 위한 단기 키입니다. 실행 뒤에 사람이 있는 경우 해당 사람의 역할과 애플리케이션의 역할이 교차된 역할이며, 아무도 없는 경우 애플리케이션 자체의 역할입니다. 로직 함수에서는 실행을 트리거한 사람이, 프런트 컴포넌트에서는 페이지를 보고 있는 사람이 해당 사람입니다.
* `TWENTY_APP_APPLICATION_ACCESS_TOKEN` — 애플리케이션 자체의 역할로만 범위가 제한된 단기 키입니다. 로직 함수에서만 사용되며, այնտեղ 항상 주입되고, `runAs: 'application'`이 사용하는 키입니다.
이 키들을 클라이언트에 전달할 필요는 **없습니다**. 클라이언트가 `process.env`에서 자동으로 읽으며, [호출에서 사용하는 액세스 주체](#whose-access-a-call-uses)에서 둘 중 무엇을 선택하는지 다룹니다. 애플리케이션 자체의 권한은 `defineApplicationRole()`로 선언된 역할(또는 `application-config.ts`의 `defaultRoleUniversalIdentifier`를 통해 참조된 역할)로 결정됩니다. 사람으로 동작하는 실행은 해당 역할이나 그 사람의 역할을 절대 초과할 수 없습니다.
</Note>

View File

@@ -0,0 +1,61 @@
---
title: 개요
description: HTTP 경로, 크론 스케줄, 데이터베이스 이벤트, AI 도구 또는 워크플로우 액션에 의해 트리거되어 Twenty 내부에서 실행되는 서버 사이드 TypeScript입니다.
icon: bolt
---
Twenty 앱의 **로직 계층**은 *실행되는* 코드로, HTTP 요청, 크론 스케줄, 레코드 변경에 반응하는 서버 사이드 TypeScript 핸들러, 워크스페이스 내부에 존재하는 AI 스킬과 에이전트, 그리고 서드파티 서비스에서 사용자를 대신해 함수가 동작할 수 있도록 하는 OAuth 연결로 구성됩니다.
```text
┌─ HTTP route ──┐
│ Cron schedule │
│ Database event │ ┌────────────────────┐
triggers ─┤ AI tool call ├─────▶│ Logic function │
│ Workflow action │ │ (your handler) │
│ Manual exec │ └────────────────────┘
└────────────────────┘ │
▼
┌────────────────────────────┐
│ Twenty API (records) │
│ Third-party API │
│ (via Connection token) │
└────────────────────────────┘
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="로직 함수" icon="bolt" href="/l/ko/developers/extend/apps/logic/logic-functions">
핵심 빌딩 블록 — 트리거 유형, 페이로드, 그리고 타입이 지정된 API 클라이언트입니다.
</Card>
<Card title="스킬 & 에이전트" icon="robot" href="/l/ko/developers/extend/apps/logic/skills-and-agents">
재사용 가능한 AI 에이전트 지시문과 맞춤형 시스템 프롬프트를 사용하는 어시스턴트입니다.
</Card>
<Card title="연결" icon="plug" href="/l/ko/developers/extend/apps/logic/connections">
Linear, GitHub, Slack 등 서드파티 서비스를 위해 앱이 보유한 OAuth 자격 증명입니다.
</Card>
<Card title="키-값 저장소" icon="database" href="/l/ko/developers/extend/apps/logic/key-value-store">
로직 함수 실행 사이에 상태를 유지합니다 — 캐시, 커서, 그리고 워크스페이스 간 클레임.
</Card>
<Card title="백그라운드 작업" icon="layer-group" href="/l/ko/developers/extend/apps/logic/background-jobs">
워커에서 로직 함수를 실행하도록 대기열에 추가하여 실행당 타임아웃을 우회하세요.
</Card>
</CardGroup>
## 한눈에 보는 트리거 유형
로직 함수는 하나 이상의 트리거를 선택합니다. 아래의 각 항목은 `defineLogicFunction()`의 개별 필드입니다:
| 트리거 | 실행 시점 | 설정 |
| -------------- | ---------------------------------------------- | ------------------------------- |
| **HTTP 경로** | 요청이 `/s/\<path>` 엔드포인트에 도달할 때 | `httpRouteTriggerSettings` |
| **크론** | CRON 표현식이 일치할 때 | `cronTriggerSettings` |
| **데이터베이스 이벤트** | 워크스페이스 레코드가 생성, 업데이트 또는 삭제될 때 | `databaseEventTriggerSettings` |
| **AI 도구** | Twenty AI 기능이 사용자의 함수를 호출하기로 결정할 때 | `toolTriggerSettings` |
| **워크플로우 액션** | 워크플로우 단계가 사용자의 함수를 호출할 때 | `workflowActionTriggerSettings` |
함수는 격리된 Node.js 프로세스의 샌드박스 환경에서 실행되며, [`defineApplication()`](/l/ko/developers/extend/apps/config/application)에 선언된 역할 범위에 맞춰 지정된 타입의 API 클라이언트를 통해 워크스페이스에 접근합니다.
<Note>
**설치 시점 후크** — 설치 전후에 실행되는 코드 — 는 동일한 런타임을 공유하지만, 별도의 define 함수들을 사용하며 [Config → Install Hooks](/l/ko/developers/extend/apps/config/install-hooks) 아래에 위치합니다.
</Note>

View File

@@ -0,0 +1,182 @@
---
title: 스킬 & 에이전트
description: 앱용 AI 스킬 및 에이전트 정의.
icon: robot
---
<Warning>
스킬과 에이전트는 현재 알파 단계입니다. 해당 기능은 작동하지만 아직 발전 중입니다.
</Warning>
앱은 워크스페이스 내에서 동작하는 AI 기능을 정의할 수 있습니다 — 재사용 가능한 스킬 지침과 사용자 지정 시스템 프롬프트를 갖춘 에이전트.
<AccordionGroup>
<Accordion title="defineSkill" description="AI 에이전트 스킬 정의">
스킬은 워크스페이스 내에서 AI 에이전트가 사용할 수 있는 재사용 가능한 지침과 기능을 정의합니다. `defineSkill()`을 사용해 내장 검증과 함께 스킬을 정의하세요:
```ts src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
핵심 요점:
* `name`은 스킬의 고유 식별자 문자열입니다(케밥 케이스 권장).
* `label`은 UI에 표시되는 사람이 읽기 쉬운 표시 이름입니다.
* `content`에는 스킬 지침이 포함됩니다 — 이는 AI 에이전트가 사용하는 텍스트입니다.
* `icon` (선택 사항)은 UI에 표시되는 아이콘을 설정합니다.
* `description` (선택 사항)은 스킬의 목적에 대한 추가 컨텍스트를 제공합니다.
</Accordion>
<Accordion title="defineAgent" description="사용자 지정 프롬프트로 AI 에이전트를 정의하세요">
에이전트는 워크스페이스 내에서 동작하는 AI 비서입니다. `defineAgent()`를 사용하여 사용자 지정 시스템 프롬프트로 에이전트를 생성하세요:
```ts src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'Helps the sales team draft outreach emails and research prospects',
icon: 'IconRobot',
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
});
```
핵심 요점:
* `name`은 에이전트의 고유 식별자 문자열입니다(케밥 케이스 권장).
* `label`은 UI에 표시되는 표시 이름입니다.
* `prompt`는 에이전트의 동작을 정의하는 시스템 프롬프트입니다.
* `description` (선택 사항)은 에이전트가 수행하는 작업에 대한 컨텍스트를 제공합니다.
* `icon` (선택 사항)은 UI에 표시되는 아이콘을 설정합니다.
* `modelId` (선택 사항)은 에이전트가 사용하는 기본 AI 모델을 재정의합니다.
* `responseFormat`(선택 사항)은 에이전트 출력의 형태를 제어합니다. 자유 형식 텍스트의 기본값은 `{ type: 'text' }`입니다. 구조화된 JSON 출력을 강제하려면 `{ type: 'json', schema }`를 사용하세요.
기본적으로 에이전트는 자유 형식 텍스트를 반환합니다. 구조화된 출력을 받으려면, `responseFormat`을 `{ type: 'json' }`으로 설정하고 `schema`를 제공하세요:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
스키마 참고:
* 스키마는 단일 수준의 객체입니다. 각 속성의 `type`은 반드시 기본형(`string`, `number`, 또는 `boolean`)이어야 합니다. 중첩 객체와 배열은 지원되지 않습니다.
* 각 속성의 `description`(선택 사항)은 해당 위치에 무엇을 넣어야 하는지 모델을 안내합니다.
* `required` (선택 사항)은 모델이 항상 반환해야 하는 속성을 나열합니다.
* `additionalProperties: false` (선택 사항)은 `properties`에 선언되지 않은 모든 속성을 금지합니다.
</Accordion>
<Accordion title="runAgent" description="로직 함수에서 에이전트를 실행">
`runAgent()`를 사용하면 로직 함수가 앱의 에이전트(해당 skills 및 tools 포함) 중 하나를 실행할 수 있습니다. `defineAgent()`에 전달한 `universalIdentifier`로 에이전트를 식별하세요: `prompt` 문자열 또는 `messages` 대화 기록 중 하나만 전달하고, 둘 다 전달하지는 마세요:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
멀티 턴 봇(Slack, Discord, Teams 등)의 경우, 단일 `prompt` 대신 스레드 기록을 `messages`로 전달하세요:
```ts src/logic-functions/reply-in-thread.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
messages: [
{ role: 'user', content: 'Who owns Acme?' },
{ role: 'assistant', content: 'Sarah owns the Acme account.' },
{ role: 'user', content: 'What was the last touchpoint?' },
],
});
```
핵심 요점:
* `prompt`(문자열) 또는 `messages`(1개에서 100개의 `{ role: 'user' | 'assistant', content: string }` 항목) 중 **정확히 하나만** 제공해야 합니다.
* 에이전트는 **동기적으로** 실행되며, 자체 tools를 통해 레코드를 읽고/업데이트할 수 있습니다. 실행이 완료되면 `runAgent()`가 resolve됩니다.
* 앱은 자신의 에이전트만 실행할 수 있습니다.
* 앱의 [기본 역할](/l/ko/developers/extend/apps/config/roles)은 `AI` 권한 플래그를 부여해야 합니다. 이를 위해 `permissionFlagUniversalIdentifiers`에 `SystemPermissionFlag.AI`를 추가하거나, `canAccessAllTools: true`로 설정하세요.
이 권한이 없으면 `runAgent()`는 권한 오류로 실패합니다.
* 로직 함수에 충분히 큰 `timeoutSeconds`를 설정하세요. 에이전트 실행에는 여러 초가 걸릴 수 있습니다.
* 실행이 완료되면 `success`는 `true`이고 `result`는 null이 아닙니다. 실패 시 `success`는 `false`, `result`는 `null`이며, `error`에 이유가 들어 있습니다(예: 실행 도중 워크스페이스의 AI 크레딧이 소진된 경우).
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
**루프를 피하세요:** `*.updated` 데이터베이스 이벤트 트리거에서 `runAgent()`를 호출하고 에이전트가 동일한 레코드를 업데이트하는 경우, `updatedFields`를 에이전트가 절대 쓰지 않는 필드(예: 원본 URL)로 한정하거나, `runAgent()`를 호출하기 전에 대상 필드 중 어느 것이든 아직 비어 있는지 여부를 검사해 가드하세요.
</Warning>
### 워크스페이스 멤버를 대신해 실행하기
실행이 사람에 의해 트리거되는 경우(예: 메시지에 응답하는 챗봇) `runAsWorkspaceMemberId`를 전달하면, 에이전트가 앱이 아니라 해당 멤버로서 동작합니다:
```ts src/logic-functions/answer-question.ts
const { result } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'How many open opportunities do we have?',
runAsWorkspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
});
```
실행은 이후 **구성원의 고유 역할**로 동작합니다. 해당 구성원이 할 수 있는 작업만 수행할 수 있으며, 그 이상은 할 수 없습니다. 실행이 생성하는 레코드는 해당 구성원에게 귀속되고, 구성원의 행(row) 수준 권한이 적용됩니다. 에이전트의 역할은 여기에 관여하지 않습니다. 이는 배후에 사람이 없는 실행에 대해 애플리케이션의 기본값으로 사용됩니다. 자율 실행(예약된 작업, 데이터베이스 이벤트 트리거)에는 이 필드를 생략하세요. 이러한 실행은 에이전트의 고유 역할을 유지합니다. 동작 중인 애플리케이션은 출처(provenance)를 위해 실행의 컨텍스트에 계속 연결되어 있지만, 구성원의 권한 범위를 축소하지는 않습니다.
실행을 트리거한 사람을 워크스페이스 멤버와 매핑하는 책임은 앱에 있습니다. 이름을 지정하려면 앱 액세스 토큰이 필요하며, 토큰이 어떤 멤버를
지정할 수 있는지는 그 토큰에 사용자가 포함되어 있는지 여부에 따라 달라집니다:
* **사용자가 연결되지 않은** 토큰은 어떤 멤버든 이름을 지정할 수 있습니다. 하나를 사용해 로직 함수가 실행되며,
`client_credentials`를 통해 발급된 토큰이나 API 키에서 발급된 토큰도 마찬가지입니다.
* 프론트 컴포넌트가 받는 것처럼 **사용자를 대신해 발급된** 토큰은 해당 사용자의 멤버만 지정할 수 있습니다.
그 밖의 것들 — 일반 사용자 세션, 앱 토큰이 없는 API 키 — 은 멤버를 전혀 지정할 수 없습니다.
<Warning>
`runAgent()`는 워크스페이스 멤버를 확인할 수 없을 때 — 알 수 없거나 제거되었거나, 역할이 없는 멤버인 경우 — 예외를 발생시킵니다. 호출자가 요청한 것보다 더 많은 액세스를 부여하게 되므로,
에이전트 자신의 역할로는 절대 폴백하지 않습니다.
</Warning>
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,131 @@
---
title: CLI
description: "`yarn twenty`는 함수를 실행하고, 로그를 스트리밍하며, 앱 설치를 관리하고, 리모트를 전환하기 위한 명령들을 제공합니다."
icon: terminal
---
`yarn twenty` CLI는 앱과 관련된 모든 작업을 위한 인터페이스입니다. 전체 명령어 목록:
| 명령 | 하는 일 | 관련 문서 |
| ----------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `dev` | 소스 파일을 감시하고 변경 사항을 실시간으로 동기화합니다. | [빠른 시작](/l/ko/developers/extend/apps/getting-started/quick-start) |
| `플랜` | 변경 사항을 적용하지 않고 메타데이터 변경 내용을 미리 봅니다. | [동기화 및 복구](/l/ko/developers/extend/apps/operations/sync-and-recovery#previewing-changes-plan) |
| `적용` | 계획을 표시한 후 메타데이터 변경 사항을 적용합니다. | [동기화 및 복구](/l/ko/developers/extend/apps/operations/sync-and-recovery) |
| `dev:build` | 앱을 컴파일하고 API 클라이언트를 생성합니다 (`--tarball`을 사용하여 `.tgz`로 패키징합니다). | [게시하기](/l/ko/developers/extend/apps/operations/publishing) |
| `dev:typecheck` | TypeScript 타입 검사를 실행합니다. | [테스트](/l/ko/developers/extend/apps/operations/testing) |
| `dev:add` | 새 엔터티를 스캐폴딩합니다. | [스캐폴딩](/l/ko/developers/extend/apps/getting-started/scaffolding) |
| `dev:generate-client` | 타입이 지정된 API 클라이언트를 다시 생성합니다. | 이 페이지 |
| `dev:function:exec` / `dev:function:logs` | 함수를 실행하고 해당 로그를 스트리밍합니다. | 이 페이지 |
| `dev:translations-extract` | 번역 가능한 문자열을 `locales/` 카탈로그로 추출합니다. | [번역](/l/ko/developers/extend/apps/operations/translations) |
| `dev:catalog-sync` | 마켓플레이스 카탈로그 동기화를 트리거합니다. | [게시하기](/l/ko/developers/extend/apps/operations/publishing#how-marketplace-discovery-works) |
| `app:publish` / `app:install` / `app:uninstall` | 릴리스 라이프사이클 | [게시하기](/l/ko/developers/extend/apps/operations/publishing) 및 이 페이지 |
| `docker:*` | 로컬 Twenty 서버 컨테이너를 관리합니다. | [로컬 서버](/l/ko/developers/extend/apps/getting-started/local-server) |
| `remote:*` | 서버 연결을 관리합니다. | 이 페이지 |
모든 명령은 기본 원격 대신 특정 원격을 대상으로 지정할 수 있도록 `-r, --remote \<name>`를 지원합니다.
## 함수 실행(`yarn twenty dev:function:exec`)
HTTP, 크론, 데이터베이스 이벤트로 트리거하지 않고 로직 함수를 수동으로 실행하세요:
```bash filename="Terminal"
# Execute by function name
yarn twenty dev:function:exec -n create-new-post-card
# Execute by universalIdentifier
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
# Pass a JSON payload
yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}'
# Execute the install and uninstall hooks
yarn twenty dev:function:exec --postInstall
yarn twenty dev:function:exec --preInstall
yarn twenty dev:function:exec --uninstall
```
## 함수 로그 보기(`yarn twenty dev:function:logs`)
앱의 로직 함수 실행 로그를 스트리밍합니다:
```bash filename="Terminal"
# Stream all function logs
yarn twenty dev:function:logs
# Filter by function name
yarn twenty dev:function:logs -n create-new-post-card
# Filter by universalIdentifier
yarn twenty dev:function:logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
```
<Note>
이는 Docker 컨테이너 로그를 표시하는 `yarn twenty docker:logs`와는 다릅니다. `yarn twenty dev:function:logs`는 Twenty 서버에서 앱의 함수 실행 로그를 표시합니다.
</Note>
## 타입이 지정된 클라이언트(`yarn twenty dev:generate-client`) 생성하기
앱을 빌드하거나 동기화하지 않고, 활성 원격의 스키마로부터 타입이 지정된 API 클라이언트(`twenty-client-sdk`)를 다시 생성합니다. 이를 사용해 별도 리포지토리에 존재하는 백엔드 서비스처럼, Twenty 인스턴스와 통신하는 어떤 프로젝트에서든 타입이 지정된 클라이언트를 얻을 수 있습니다:
```bash filename="Terminal"
# In your project (no Twenty app definition required)
yarn add twenty-sdk twenty-client-sdk
# Connect to the Twenty instance to generate the client from
yarn twenty remote:add
# Generate the typed client into node_modules/twenty-client-sdk
yarn twenty dev:generate-client
```
그런 다음 코드에서 클라이언트를 임포트하세요:
```typescript
import { CoreApiClient } from 'twenty-client-sdk/core';
```
데이터 모델이 변경될 때마다 생성된 타입을 갱신하려면 명령을 다시 실행하세요.
<Note>
클라이언트는 `node_modules` 내부에서 생성되므로, 코드와 함께 커밋되지 않습니다. 매번 설치 후(`postinstall` 스크립트나 CI 등) `yarn twenty dev:generate-client`를 실행하세요.
</Note>
## 앱 제거(`yarn twenty app:uninstall`)
활성 워크스페이스에서 앱을 제거하세요:
```bash filename="Terminal"
yarn twenty app:uninstall
# Skip the confirmation prompt
yarn twenty app:uninstall --yes
```
## 리모트 관리
**리모트**는 앱이 연결하는 Twenty 서버를 의미합니다. 설정 과정에서 스캐폴더가 자동으로 하나를 생성합니다. 언제든지 리모트를 추가하거나 전환할 수 있습니다.
```bash filename="Terminal"
# Add a new remote (opens a browser for OAuth login)
yarn twenty remote:add
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
yarn twenty remote:add --local
# Add a remote non-interactively (useful for CI)
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
# List all configured remotes
yarn twenty remote:list
# Set the active remote
yarn twenty remote:use <name>
# Check that the active remote's authentication is still valid
yarn twenty remote:status
# Remove a remote
yarn twenty remote:remove <name>
```
자격 증명은 `~/.twenty/config.json`에 저장됩니다.

View File

@@ -0,0 +1,32 @@
---
title: 개요
description: 앱을 빌드하고, 테스트하며, 배포하세요 — CLI 명령, 통합 테스트, CI, 그리고 서버 또는 npm으로의 게시까지 포함됩니다.
icon: rocket
---
\*\*운영 계층(operations layer)\*\*은 앱을 *사용하는 것*이 아니라 앱에 *수행하는* 모든 작업을 의미합니다. 예를 들어 CLI 명령 실행, 실제 Twenty 서버를 대상으로 한 통합 테스트 실행, CI 구성, 그리고 단일 서버에 배포되는 tarball로 또는 마켓플레이스에 등록되는 npm 패키지로 릴리스를 배포하는 작업 등이 여기에 포함됩니다.
```text
develop ─▶ test ─▶ build ─▶ deploy / publish
─────── ──── ───── ─────────────────
yarn yarn yarn yarn twenty app:publish --private (tarball → one server)
twenty test twenty
dev dev:build yarn twenty app:publish (npm → marketplace)
```
## 이 섹션에서 다루는 내용
<CardGroup cols={2}>
<Card title="CLI" icon="terminal" href="/l/ko/developers/extend/apps/operations/cli">
`yarn twenty` 참고 — exec, logs, uninstall, remotes.
</Card>
<Card title="동기화 및 복구" icon="compass" href="/l/ko/developers/extend/apps/operations/sync-and-recovery">
동기화 차분을 읽을 때 어떤 명령을 사용할지와 복구 단계에 대해 다룹니다.
</Card>
<Card title="테스트" icon="flask" href="/l/ko/developers/extend/apps/operations/testing">
Vitest 설정, 통합 테스트, 타입 체크, CI 워크플로.
</Card>
<Card title="게시" icon="upload" href="/l/ko/developers/extend/apps/operations/publishing">
빌드, tarball 배포, npm 게시, 설치.
</Card>
</CardGroup>

View File

@@ -0,0 +1,293 @@
---
title: 게시
icon: upload
description: Twenty 앱을 마켓플레이스에 배포하거나 내부에 배포하세요.
---
## 개요
앱을 [로컬에서 빌드하고 테스트](/l/ko/developers/extend/apps/getting-started/concepts)하면, 배포 경로는 두 가지입니다:
* **타르볼 배포** — 내부 또는 비공개 사용을 위해 특정 Twenty 서버에 앱을 직접 업로드합니다.
* **npm에 게시** — 모든 워크스페이스가 검색하고 설치할 수 있도록 Twenty 마켓플레이스에 앱을 등재합니다.
두 경로는 동일한 **build** 단계에서 시작합니다.
## 앱 빌드
앱을 컴파일하고 배포 준비가 된 `manifest.json`을 생성하려면 빌드 명령을 실행하세요:
```bash filename="Terminal"
yarn twenty dev:build
```
이는 TypeScript 소스를 컴파일하고, 로직 함수와 프런트 컴포넌트를 트랜스파일하며, 모든 항목을 `.twenty/output/`에 기록합니다. 수동 배포 또는 publish 명령을 위한 `.tgz` 패키지도 생성하려면 `--tarball`을 추가하세요.
## 서버에 배포(타르볼)
공개하고 싶지 않은 앱 — 독점 도구, 엔터프라이즈 전용 통합 또는 실험적 빌드 — 의 경우, 타르볼을 Twenty 서버에 직접 배포할 수 있습니다.
### 사전 준비
배포 전에 대상 서버를 가리키도록 구성된 원격이 필요합니다. 원격은 서버 URL과 인증 자격 증명을 로컬의 `~/.twenty/config.json`에 저장합니다.
원격 추가:
```bash filename="Terminal"
yarn twenty remote:add --url https://your-twenty-server.com --as production
```
### 배포
앱을 한 번에 빌드하여 서버에 업로드:
```bash filename="Terminal"
yarn twenty app:publish --private
# To deploy to a specific remote:
# yarn twenty app:publish --private --remote production
```
### 배포된 앱 공유
타르볼 앱은 공개 마켓플레이스에 나열되지 않으므로, 동일한 서버의 다른 워크스페이스는 탐색만으로는 이를 찾을 수 없습니다. 배포된 앱을 공유하려면:
1. **설정 > 애플리케이션 > 등록**으로 이동하여 앱을 엽니다
2. **Distribution** 탭에서 **공유 링크 복사**를 클릭합니다
3. 이 링크를 다른 워크스페이스의 사용자와 공유하세요 — 해당 앱의 설치 페이지로 바로 이동합니다
공유 링크는 서버의 기본 URL(워크스페이스 하위 도메인 없이)을 사용하므로 서버의 모든 워크스페이스에서 동작합니다.
### 버전 관리
이미 배포된 타르볼 앱을 업데이트할 때, 서버는 `package.json`의 `version`이 현재 배포된 버전보다 [semver](https://semver.org) 순서 기준으로 엄격히 더 높아야 합니다. 동일한 버전을 다시 배포하거나 더 낮은 버전을 푸시하는 경우, 타르볼이 저장되기 전에 거부됩니다 — CLI에서 `VERSION_ALREADY_EXISTS` 오류가 표시됩니다.
업데이트를 릴리스하려면:
1. `package.json`의 `version` 필드를 올리세요(예: `1.2.3` → `1.2.4`, `1.3.0`, 또는 `2.0.0`)
2. `yarn twenty app:publish --private`(또는 `yarn twenty app:publish --private --remote production`)를 실행합니다.
3. 앱이 설치되어 있고 해당 앱의 Settings 탭에서 자동 업그레이드를 활성화한 워크스페이스는 백그라운드에서 자동으로 업그레이드되며, 나머지 워크스페이스는 설정에서 사용 가능한 업그레이드를 확인할 수 있습니다.
<Note>
프리릴리스 태그는 예상대로 동작합니다: `1.0.0-rc.1` → `1.0.0-rc.2`로 올리는 것은 허용되며, `1.0.0`과 같은 최종 릴리스는 `1.0.0-rc.5`보다 더 높은 버전으로 올바르게 인식됩니다. `package.json`의 버전은 유효한 semver 문자열이어야 합니다.
</Note>
{/* TODO: add screenshot of the Upgrade button */}
### 서버 버전 호환성
앱이 특정 Twenty 서버 버전에서 도입된 기능(예: v2.3.0에 추가된 OAuth 공급자)을 사용한다면, `package.json`의 `engines.twenty` 필드를 사용하여 앱에 필요한 최소 서버 버전을 선언해야 합니다:
```json filename="package.json"
{
"name": "twenty-my-app",
"version": "1.0.0",
"engines": {
"node": "^24.5.0",
"twenty": ">=2.3.0"
}
}
```
값은 표준 [semver 범위](https://github.com/npm/node-semver#ranges)입니다. 일반적인 패턴:
| 범위 | 의미 |
| ---------------------------------- | -------------------------------------- |
| `>=2.3.0` | 2.3.0 이상인 모든 서버 |
| `>=2.3.0 \<3.0.0` | 2.3.0 이상이지만 다음 메이저 버전 미만 |
| `^2.3.0` | `>=2.3.0 \<3.0.0`과 동일 |
**배포 및 설치 시 동작:**
* `engines.twenty`가 설정되어 있고 대상 서버의 버전이 해당 범위를 충족하지 않으면, 배포(tarball 업로드) 또는 설치가 `SERVER_VERSION_INCOMPATIBLE` 오류와 함께 거부되며 필요한 범위와 실제 서버 버전을 모두 나타내는 메시지가 표시됩니다.
* `engines.twenty`가 **설정되어 있지 않으면**, 앱은 모든 서버 버전에서 허용됩니다(기존 앱과 하위 호환).
* 서버에 `APP_VERSION`이 구성되어 있지 않으면, 검사가 생략됩니다.
<Note>
서버가 최종 검증을 수행합니다 — 타르볼 업로드와 워크스페이스 설치 모두에서 `engines.twenty`를 검증합니다. 타르볼을 별도 경로로 배포하거나 마켓플레이스에서 설치하더라도, 서버는 여전히 호환성을 강제합니다.
</Note>
## 자동화된 CI/CD (스캐폴드된 워크플로)
`create-twenty-app`으로 생성된 앱은 `.github/workflows/` 아래에 기본으로 세 개의 GitHub Actions 워크플로가 포함되어 있습니다. CI는 별도 설정 없이 실행되며, CD는 하나의 시크릿만 필요하고, npm으로의 배포는 한 번만 설정하면 되는 npm trusted-publisher 설정이 필요합니다.
### CI — `ci.yml`
`main`으로의 푸시와 풀 리퀘스트마다 통합 테스트를 자동으로 실행합니다.
**하는 일:**
1. 앱의 소스 코드를 체크아웃합니다.
2. `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` 컴포지트 액션을 사용해 격리된 Twenty 테스트 인스턴스를 생성합니다(CI에서 `yarn twenty docker:start --test`와 동등합니다).
3. Corepack을 활성화하고, `.nvmrc`에 따라 Node.js를 설정하며, `yarn install --immutable`로 의존성을 설치합니다.
4. 생성된 인스턴스에서 `TWENTY_API_URL`과 `TWENTY_API_KEY`를 전달하여 실제 서버와 통신할 수 있도록 `yarn test`를 실행합니다.
**구성 옵션:**
* `TWENTY_VERSION`(환경 변수, 기본값 `latest`) — `ci.yml`에서 이 값을 수정하여 CI에서 사용하는 Twenty 서버 버전을 고정합니다.
* 동시 실행은 `github.ref`로 그룹화되며, 새 푸시가 발생하면 진행 중인 실행을 취소합니다.
시크릿이 필요하지 않습니다 — 테스트 인스턴스는 일시적이며 작업이 진행되는 동안에만 존재합니다.
### CD — `cd.yml`
매번 `main`으로 푸시될 때 구성된 Twenty 서버에 앱을 배포하며, `deploy` 라벨이 적용된 경우 풀 리퀘스트에서도 선택적으로 배포합니다.
**하는 일:**
1. 라벨이 지정된 PR의 경우 PR 헤드, 아니면 푸시된 커밋을 체크아웃합니다.
2. `twentyhq/twenty/.github/actions/deploy-twenty-app@main`를 실행합니다 — CI에서의 `yarn twenty app:publish --private`에 해당합니다.
3. `twentyhq/twenty/.github/actions/install-twenty-app@main`를 실행하여 새로 배포된 버전을 대상 워크스페이스에 설치합니다.
**필수 구성:**
| 설정 | 위치 | 목적 |
| ----------------------- | --------------------------------------------------------- | ------------------------------------------------ |
| `TWENTY_DEPLOY_URL` | `cd.yml`의 `env`(기본값 `http://localhost:3000`) | 배포 대상 Twenty 서버입니다. 처음 사용하기 전에 실제 서버 URL로 변경하세요. |
| `TWENTY_DEPLOY_API_KEY` | GitHub 저장소 **Settings → Secrets and variables → Actions** | 대상 서버에서 배포 권한이 있는 API 키입니다. |
<Note>
기본 `TWENTY_DEPLOY_URL` 값 `http://localhost:3000`은 플레이스홀더이며 — GitHub 호스팅 러너에서는 아무 곳에도 도달하지 않습니다. CD를 활성화하기 전에 서버의 퍼블릭 URL로 업데이트하세요(또는 네트워크에 접근 가능한 셀프 호스티드 러너를 사용하세요).
</Note>
**PR에서 프리뷰 배포 트리거하기:**
풀 리퀘스트에 `deploy` 라벨을 추가하세요. `cd.yml`의 `if:` 가드는 해당 PR의 헤드 커밋을 사용해 그 PR에 대한 잡을 실행하므로, 머지 전에 대상 서버에서 변경 사항을 검증할 수 있습니다.
### 게시 — `publish.yml`
버전 태그(예: `v1.0.0`)를 푸시하거나 Actions 탭에서 워크플로를 수동으로 실행하면, 출처 정보(provenance)와 함께 앱을 npm에 게시합니다.
**하는 일:**
1. 앱을 체크아웃하고 Node.js를 설정한 다음 npm을 업데이트합니다(신뢰할 수 있는 게시 기능을 사용하려면 npm 11.5.1 이상이 필요합니다).
2. `yarn twenty app:publish`를 실행하여 앱을 빌드하고 `.twenty/output`를 npm에 게시합니다. CI에서는 자동으로 `--provenance` 및 `--access public`을 추가하므로, 워크플로에는 플래그가 필요하지 않습니다.
**한 번만 설정:**
npmjs.com에서 패키지를 연 다음 **Settings → Trusted Publisher**로 이동하여 이 저장소를 `publish.yml` 워크플로에 등록하세요([npm trusted publishing 문서](https://docs.npmjs.com/trusted-publishers)를 참고하세요). provenance와 함께 게시하면 어떤 GitHub 저장소가 패키지를 빌드했는지 증명되며, 이는 Twenty 마켓플레이스에서 앱 소유권을 주장하는 방법이기도 합니다.
<Note>
npm은 **공개(public)** 소스 저장소의 provenance만 허용합니다. 비공개 저장소에서 publish를 수행하면, npm은 OIDC provenance 번들을 `E422 ...` 오류와 함께 거부합니다. 지원되지 않는 GitHub Actions 소스 저장소 가시성: "private"`오류입니다. 비공개 저장소에서 publish하려면, publish 단계의`env`에서 `TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'`를 설정하여 provenance를 사용하지 않도록(opt out) 해야 합니다(스캐폴딩된 `publish.yml\`에 주석으로 된 힌트가 포함되어 있습니다):
```yaml filename=".github/workflows/publish.yml"
- name: Publish to npm
env:
TWENTY_APP_PUBLISH_DISABLE_PROVENANCE: 'true'
run: yarn twenty app:publish
```
</Note>
### 재사용 가능한 액션 고정하기
`ci.yml` 및 `cd.yml` 워크플로는 `@main`의 재사용 가능한 액션을 참조하므로, `twentyhq/twenty` 저장소의 액션 업데이트가 자동으로 반영됩니다. 결정적 빌드를 원한다면, 각 `uses:` 줄에서 `@main`을 커밋 SHA 또는 릴리스 태그로 바꾸세요.
## npm에 게시
npm에 게시하면 Twenty 마켓플레이스에서 앱을 찾을 수 있게 됩니다. 모든 Twenty 워크스페이스는 UI에서 바로 마켓플레이스 앱을 탐색, 설치 및 업그레이드할 수 있습니다.
### 요구 사항
* [npm](https://www.npmjs.com) 계정
* `package.json`의 `keywords` 배열에 있는 `twenty-app` 키워드(수동으로 추가하세요 — `create-twenty-app` 템플릿에 기본적으로 포함되어 있지 않습니다)
```json filename="package.json"
{
"name": "twenty-app-postcard-sender",
"version": "1.0.0",
"keywords": ["twenty-app"]
}
```
### 마켓플레이스 메타데이터
`defineApplication()` 구성은 마켓플레이스에서 앱이 표시되는 방식을 제어하는 선택적 필드를 지원합니다. `public/` 폴더의 이미지를 참조하려면 `logo`와 `galleryImages`를 사용하세요:
```ts src/application-config.ts
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
logo: 'public/logo.png',
galleryImages: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
});
```
마켓플레이스 필드 전체 목록(`author`, `category`, `aboutDescription`, `websiteUrl`, `termsUrl` 등)은 Building Apps 페이지의 [defineApplication 아코디언](/l/ko/developers/extend/apps/config/application#marketplace-metadata)을 참조하세요.
#### 권장 갤러리 이미지 크기
마켓플레이스는 `galleryImages`를 고정된 `8:5` 컨테이너에 렌더링합니다(예: `1600×1000 px`).
<Note>
어떤 종횡비의 갤러리 이미지든 전체가 표시되며 잘리지 않습니다. 그러나 `8:5`보다 훨씬 세로로 길거나 가로로 좁은 경우 양쪽에 빈 띠가 표시됩니다.
</Note>
#### 이미지 크기 제한
`logo` 및 각 `galleryImages` 파일은 **10 MB**를 초과해서는 안 됩니다. 마켓플레이스가 게시된 에셋을 다시 호스팅할 때 더 큰 파일은 건너뛰므로 표시되지 않습니다.
### 게시
```bash filename="Terminal"
yarn twenty app:publish
```
특정 dist-tag(예: `beta` 또는 `next`)로 게시하려면:
```bash filename="Terminal"
yarn twenty app:publish --tag beta
```
### 마켓플레이스 검색 방식
Twenty 서버는 npm 레지스트리에서 마켓플레이스 카탈로그를 **매시간** 동기화합니다.
기다리지 않고 즉시 동기화를 트리거할 수 있습니다:
```bash filename="Terminal"
yarn twenty dev:catalog-sync
# To target a specific remote:
# yarn twenty dev:catalog-sync --remote production
```
마켓플레이스에 표시되는 메타데이터는 `defineApplication()` 구성에서 가져옵니다. 위의 [Marketplace metadata](#marketplace-metadata)를 참고하세요.
<Note>
앱에서 `defineApplication()`에 `aboutDescription`을 정의하지 않으면, 마켓플레이스는 소개 페이지 콘텐츠로 npm에 게시된 패키지의 `README.md`를 자동으로 사용합니다. 즉, npm과 Twenty 마켓플레이스 모두에서 하나의 README만 관리하면 됩니다. 마켓플레이스에서 다른 설명을 사용하려면 `aboutDescription`을 명시적으로 설정하세요.
</Note>
### CI 게시
위에서 설명한 스캐폴딩된 `publish.yml` 워크플로는 버전 태그에 따라 provenance와 함께 npm에 자동으로 게시합니다. `yarn twenty app:publish`는 CI에서 실행될 때 `--provenance` 및 `--access public`을 자동으로 추가하므로, 워크플로에는 npm 플래그가 전혀 필요하지 않고 Trusted Publisher를 한 번만 설정하면 됩니다.
다른 CI 시스템(GitLab CI, CircleCI 등)에서는 `yarn install`을 실행한 다음 `yarn twenty app:publish`를 실행하세요. 환경에서 OIDC 토큰을 생성할 수 있을 때는 provenance가 생성되고, 그렇지 않은 경우에는 자동으로 건너뜁니다.
<Note>
**npm provenance**는 npm 목록에 신뢰 배지를 추가하여, 사용자가 공개 CI 파이프라인의 특정 커밋에서 패키지가 빌드되었는지 검증할 수 있게 해줍니다. 또한 Twenty 마켓플레이스에서 앱 소유권을 주장할 수 있게 해 주는 요소이기도 합니다. 자세한 내용은 [npm provenance 문서](https://docs.npmjs.com/generating-provenance-statements)를 참고하세요.
</Note>
## 앱 설치
앱이 게시(npm)되었거나 배포(타르볼)되면, 워크스페이스는 UI를 통해 이를 설치할 수 있습니다.
Twenty UI의 **설정 > 애플리케이션** 페이지로 이동하면 마켓플레이스 앱과 타르볼로 배포된 앱을 모두 탐색하고 설치할 수 있습니다.
{/* TODO: add screenshot of the UI when the app is registered */}
명령줄에서 앱을 설치할 수도 있습니다:
```bash filename="Terminal"
yarn twenty app:install
```
<Note>
서버는 설치 시 semver 버전 규칙을 강제하며, 배포와 동일한 규칙을 적용합니다:
* 워크스페이스에 이미 설치된 것과 동일한 버전을 설치하려고 하면 `APP_ALREADY_INSTALLED` 오류와 함께 거부됩니다.
* 현재 설치된 것보다 낮은 버전을 설치하려고 하면 `CANNOT_DOWNGRADE_APPLICATION` 오류와 함께 거부됩니다.
더 최신 버전을 설치하려면 먼저 배포하거나 게시한 다음 `yarn twenty app:install`을 다시 실행하세요.
</Note>

View File

@@ -0,0 +1,126 @@
---
title: 동기화 및 복구
description: 언제 어떤 명령을 사용할지, 동기화 출력 결과를 읽는 방법, 로컬 메타데이터가 완전 초기화에 이르기 전에 드리프트할 때를 위한 복구 단계별 절차를 다룹니다.
icon: compass
---
로컬 앱 개발은 **동기화**를 중심으로 이루어집니다. CLI는 매니페스트를 다시 빌드하고, 서버는 워크스페이스에 이미 있는 메타데이터와의 차이만 적용합니다. 이 페이지에서는 어떤 명령을 사용할지, 동기화로 무엇이 변경되었는지 읽는 방법, 그리고 로컬 상태가 일관되지 않아 보일 때 순서대로 무엇을 해야 하는지를 설명합니다.
## 언제 어떤 명령을 사용할지
<Note>
일상적인 로컬 반복 개발에서는 거의 항상 `yarn twenty dev`를 사용하면 됩니다. 배포와 게시(publish)는 릴리스를 배포할 때 사용하는 것이며, 로컬 개발 루프용이 **아닙니다**.
</Note>
| 원하는 작업… | 명령 | 노트 |
| ---------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 라이브 동기화로 로컬에서 반복 개발 | `yarn twenty dev` | 파일을 감시하고 변경될 때마다 동기화합니다. |
| 한 번만 동기화하고 종료 (CI, 스크립트, 훅) | `yarn twenty apply` | 한 번 빌드 + 동기화한 뒤 종료합니다. 파괴적인 변경 확인을 건너뛰려면 `--force`를 추가하세요. |
| 변경 사항을 **실제로 적용하지 않고** 미리 보기 | `yarn twenty plan` | 차이를 계산해 출력만 하고, 아무것도 기록하지 않습니다. |
| 워크스페이스에서 앱 제거 | `yarn twenty app:uninstall` | 프롬프트를 건너뛰려면 `--yes`를 추가하세요. |
| 타르볼을 서버로 전송 | `yarn twenty app:publish --private` | `package.json`의 버전이 **엄격하게 더 높아야** 합니다. 자세한 내용은 [Publishing](/l/ko/developers/extend/apps/operations/publishing)을 참고하세요. |
| 마켓플레이스(npm)에 게시 | `yarn twenty app:publish` | — |
| 배포된 버전 설치 / 업그레이드 | `yarn twenty app:install` | 현재 배포된 버전을 설치합니다. |
| 로컬 서버를 초기화하고 깨끗하게 시작 | `yarn twenty docker:reset` | 로컬 데이터 **전체**를 삭제합니다. 최후의 수단입니다. |
<Note>
`yarn twenty dev --once` 및 `yarn twenty dev --once --dry-run`은 여전히 `yarn twenty apply`와 `yarn twenty plan`의 더 이상 사용되지 않는 별칭으로 작동합니다.
</Note>
### 로컬 동기화에는 버전 증가가 필요 없음
엄격히 증가하는 `version` 규칙(`deploy` 시 `VERSION_ALREADY_EXISTS`, `install` 시 `APP_ALREADY_INSTALLED` / `CANNOT_DOWNGRADE_APPLICATION`)은 **`app:publish` / `app:install`**, 즉 릴리스 경로에만 적용됩니다. `yarn twenty dev`는 매니페스트를 제자리에서 동기화하므로 버전을 변경할 필요가 없습니다. 따라서 반복 개발을 위해 `package.json`을 수정할 필요가 없습니다. 로컬 변경을 테스트하기 위해 버전을 올리고 있다면, 개발 루프가 아니라 릴리스 경로를 사용하고 있는 것입니다.
## 동기화 출력 읽기
각 동기화는 적용된 메타데이터 변경 사항(또는 `plan`으로 했을 때는 적용될 변경 사항)을 Terraform 스타일로 출력합니다. 각 엔티티마다 해당 속성이 포함된 하나의 블록이 출력되고, 그 뒤에 요약 한 줄이 이어집니다:
```text filename="Terminal"
# objectMetadata "rocket" will be created
+ icon = "IconRocket"
+ labelSingular = "Rocket"
+ ...
# fieldMetadata "launchedAt" will be updated
~ isNullable = false -> true
Plan: 2 to add, 1 to change, 1 to destroy.
✓ Synced My App (4 files)
```
이 출력이 1차 진단 도구입니다. 어떤 객체, 필드, 레이아웃이 변경되었는지 정확히 알려주므로, UI를 확인하기 전에 동기화가 예상대로 동작했는지 검증할 수 있습니다.
파괴적인 변경(`to destroy`)은 무엇을 삭제하는지와 함께 나열됩니다(예: `objectMetadata "auditNote" — drops the table and all its rows`), 그리고 대화형 확인이 필요하거나, 스크립트에서는 `--force`가 필요합니다.
동기화가 단일 엔티티에서 실패하면, 오류 메시지에 문제의 엔티티와 그 `universalIdentifier`가 함께 표시됩니다. 예를 들면 다음과 같습니다.
```text
Migration action 'create' for 'fieldMetadata' (universalIdentifier: 2020...4337) failed
```
그 식별자를 사용해 매니페스트(필요하다면 워크스페이스)에서 해당 엔티티를 찾아, 어떤 것이 충돌하는지 추측하지 말고 정확히 확인하세요.
## 변경 사항 미리 보기(plan)
`yarn twenty plan`은 매니페스트를 빌드하고, 서버에 마이그레이션 계획을 요청한 뒤, 이를 출력만 합니다. **아무것도 실제로 적용하지 않습니다**. "이 동기화로 무엇이 바뀔까?"에 안전하게 답할 수 있는 방법으로, 실제로 적용하기 전에 확인할 수 있습니다.
```bash filename="Terminal"
yarn twenty plan
```
```text filename="Terminal"
Building manifest...
Computing metadata plan (read-only, nothing will be applied)...
# fieldMetadata "crewCapacity" will be created
+ ...
Plan: 1 to add, 1 to change, 0 to destroy.
✓ Plan complete for My App — no changes were applied
```
플랜:
* **아무것도 기록하지 않습니다**. 메타데이터 마이그레이션, 애플리케이션 레코드 업데이트, 기본 역할/탭 변경, API 클라이언트 생성이 모두 수행되지 않습니다.
* 실제 동기화에서 적용될 **동일한 diff**를 반환하므로, 생성/업데이트/삭제되는 엔티티를 미리 검토할 수 있습니다.
* 위험한 변경 전에, AI가 생성한 변경 사항을 검토할 때, 또는 예기치 않은 변경이 적용되려 하면 실패해야 하는 스크립트에서 유용합니다.
<Note>
plan은 **메타데이터** 변경만 미리 보여 주며, 앱이 최소 한 번 이상 동기화된 상태(워크스페이스가 이 앱을 알고 있는 상태)여야 합니다. 한 번도 동기화된 적이 없는 앱에 대해 dry run을 실행하면, 서버는 앱이 설치되지 않았다고 보고합니다. 먼저 `yarn twenty dev`를 한 번 실행하세요.
</Note>
## 복구 단계별 절차
로컬 메타데이터가 잘못된 것처럼 보일 때는, 아래 순서대로 단계를 진행하면서 문제가 해결되는 즉시 멈추세요. 각 단계는 이전 단계보다 더 많은 영향을 미칩니다.
1. **재동기화.** `yarn twenty apply`를 다시 실행하세요. 동기화는 멱등적이므로, 깨끗한 매니페스트를 다시 실행해도 안전하며 일시적인 오류가 이 방식으로 해결되는 경우가 많습니다.
2. **계획 미리 보기.** `yarn twenty plan`을 실행해, 다음 동기화가 실제로 적용하지 않고 정확히 무엇을 변경하려 하는지 확인하세요.
3. **명시적인 오류 읽기.** 동기화가 실패하면, 메시지에 포함된 메타데이터 타입과 `universalIdentifier`(위 참조)를 확인한 뒤, 매니페스트에서 해당 엔티티를 찾으세요. 충돌은 보통 중복되었거나 재사용된 식별자를 가리킵니다.
4. **삭제 후 재설치.** `yarn twenty app:uninstall`을 실행한 뒤, 다시 동기화합니다(`yarn twenty dev`). 이 방법은 워크스페이스의 나머지 부분은 그대로 둔 채, 앱의 메타데이터를 깨끗한 상태에서 다시 구축합니다.
5. **전체 초기화(최후의 수단).** `yarn twenty docker:reset`을 실행한 뒤, 다시 시드하고 재동기화합니다.
<Warning>
`yarn twenty docker:reset`은 로컬 인스턴스의 **모든** 데이터를 삭제합니다. 모든 워크스페이스, 레코드, 앱이 제거됩니다. 이전 단계들이 모두 실패했을 때에만 사용하세요.
</Warning>
<Note>
메타데이터 오류가 발생했나요? [이슈를 생성](https://github.com/twentyhq/twenty/issues/new/choose)해 주세요. 이때 실패한 마이그레이션 메시지(메타데이터 타입과 `universalIdentifier` 포함), 동기화 시 출력된 `Metadata changes`, 그리고 실행한 명령들을 함께 첨부해 주세요.
</Note>
## 하나의 워크스페이스에서 동시 동기화 피하기
동기화는 메타데이터 마이그레이션을 적용합니다. **동일한 워크스페이스에 대해 동시에** 여러 번 동기화, 배포, 설치 작업을 실행하면(예: 여러 터미널이나 AI 에이전트가 병렬로 반복 실행하는 경우), 마이그레이션이 서로 얽혀 메타데이터가 부분적으로만 적용된 상태로 남을 수 있습니다.
서버는 이를 방지하기 위해 워크스페이스별로 동기화를 직렬화하지만, 그럼에도 중요한 메타데이터 작업은 동시에 실행하지 말고 **단일** 프로세스를 통해 순차적으로 실행하는 것이 좋습니다. 여러 에이전트로 개발을 오케스트레이션한다면, 이들의 sync/deploy/install 호출을 하나의 큐로 모아 한 번에 하나씩만 실행되도록 하세요.
## 실패 유형 구분하기
문제가 발생했을 때, 메타데이터 diff와 명시적인 오류를 통해 실패 지점을 파악할 수 있습니다.
* **매니페스트 빌드 오류** — 동기화 전에 CLI가 실패합니다(`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`). 앱 소스를 수정하세요.
* **동기화 / 마이그레이션 오류** — 빌드는 성공했지만 diff를 적용하는 데 실패하며, 메시지에 엔티티와 `universalIdentifier`가 표시됩니다. 충돌하는 메타데이터를 수정하세요.
* **종속 항목 크기 오류** — 동기화 또는 설치가 실패했습니다. 앱의 프로덕션 `dependencies`가 런타임 레이어로 설치하기에는 너무 커서 실패했습니다 (`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED`). 런타임 중에 로직 함수에서 실제로 import하지 않는 패키지(UI 라이브러리, 개발 도구 등)는 `devDependencies`로 이동하세요.
* **앱 코드 런타임 오류** — 동기화는 성공했지만 로직 함수나 컴포넌트가 런타임에 올바르게 동작하지 않는 경우입니다. [function logs](/l/ko/developers/extend/apps/operations/cli)를 확인하세요.
* **로컬 인스턴스 상태** — 위의 어느 경우에도 해당하지 않지만 워크스페이스가 여전히 잘못되어 보이는 경우입니다. 복구 사다리를 순서대로 따라가세요.

View File

@@ -0,0 +1,264 @@
---
title: 테스트
description: Vitest 설정, 실제 Twenty 서버를 대상으로 한 통합 테스트, 타입 검사, 그리고 GitHub Actions를 사용하는 CI.
icon: flask
---
SDK는 테스트 코드에서 앱을 빌드, 배포, 설치 및 제거할 수 있는 프로그래매틱 API를 제공합니다. 이것을 [Vitest](https://vitest.dev/) 및 타입이 지정된 API 클라이언트와 함께 사용하면 실제 Twenty 서버를 대상으로 앱이 종단 간으로 동작하는지 검증하는 통합 테스트를 작성할 수 있습니다.
## npm 패키지 사용
앱에서 원하는 npm 패키지를 설치해 사용할 수 있습니다. 로직 함수와 프런트 컴포넌트는 모두 [esbuild](https://esbuild.github.io/)로 번들되며, 그 의존성은 출력물에 인라인되기 때문에 런타임에는 `node_modules`가 필요하지 않습니다.
### 패키지 설치
```bash filename="Terminal"
yarn add axios
```
그런 다음 코드에서 임포트하세요:
```ts src/logic-functions/fetch-data.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import axios from 'axios';
const handler = async (): Promise<any> => {
const { data } = await axios.get('https://api.example.com/data');
return { data };
};
export default defineLogicFunction({
universalIdentifier: '...',
name: 'fetch-data',
description: 'Fetches data from an external API',
timeoutSeconds: 10,
handler,
});
```
프런트 컴포넌트에서도 동일하게 작동합니다:
```tsx src/front-components/chart.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { format } from 'date-fns';
const DateWidget = () => {
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'date-widget',
component: DateWidget,
});
```
### 번들링 동작 방식
빌드 단계에서는 esbuild를 사용하여 로직 함수와 프런트 컴포넌트마다 단일 자체 포함 파일을 생성합니다. 가져온 패키지는 아래에 나열된 항목을 제외하고 번들에 인라인됩니다.
**로직 함수**는 Node.js 환경에서 실행됩니다. Node 기본 모듈(`fs`, `path`, `crypto`, `http` 등) 을(를) 사용할 수 있으며 설치할 필요가 없습니다.
**프런트 컴포넌트**는 Web Worker에서 실행됩니다. Node 기본 모듈은 **사용할 수 없습니다** — 브라우저 환경에서 동작하는 npm 패키지만 사용할 수 있습니다. 샌드박스는 *부분적인* DOM만 구현하므로, 패키지가 빌드에는 성공하더라도 런타임에 실패할 수 있습니다. 자세한 내용은 [현재 제한 사항](/l/ko/developers/extend/apps/layout/front-components#current-limitations)을 참고하세요.
두 환경 모두에서 `twenty-client-sdk/core`와 `twenty-client-sdk/metadata`가 사전 제공 모듈로 사용 가능합니다 — 이는 번들되지 않고 서버가 런타임에 해석합니다.
프런트 컴포넌트는 또한 [공유 의존성 번들](/l/ko/developers/extend/apps/layout/front-components#sharing-dependencies-across-front-components)을 통해 의존성을 공유할 수 있습니다. 앱의 package.json에 있는 `frontComponentSharedDependencies` 필드에 나열된 패키지는 한 번만 별도의 파일로 빌드되어, 각 컴포넌트에 인라인되는 대신 런타임에 그 파일에서 로드됩니다. 따라서 공유 패키지만 가져오는 컴포넌트는 거의 자체 의존성 코드를 포함하지 않습니다.
## 설정
스캐폴딩된 앱에는 이미 Vitest가 포함되어 있습니다. 수동으로 설정하는 경우, 의존성을 설치하세요:
```bash filename="Terminal"
yarn add -D vitest vite-tsconfig-paths
```
앱 루트에 `vitest.config.ts`를 생성하세요:
```ts vitest.config.ts
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? '<the pre-seeded local dev key>';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});
```
서버에 연결할 수 있는지 확인하고, SDK용 테스트 구성 파일(`~/.twenty/config.test.json`)을 작성한 다음, 테스트 실행 전에 앱을 동기화하는 글로벌 설정 파일을 생성합니다:
```ts src/__tests__/global-setup.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
// Verify the server is running
const response = await fetch(`${apiUrl}/healthz`);
if (!response.ok) {
throw new Error(`Twenty server is not reachable at ${apiUrl}.`);
}
// Write the SDK's test config (the CLI reads config.test.json when NODE_ENV=test)
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
path.join(CONFIG_DIR, 'config.test.json'),
JSON.stringify({
remotes: { local: { apiUrl, apiKey } },
defaultRemote: 'local',
}, null, 2),
);
// Start from a clean slate, then sync the app
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({ appPath: APP_PATH });
if (!result.success) {
throw new Error(`Dev sync failed: ${result.error?.message}`);
}
}
export async function teardown() {
await appUninstall({ appPath: APP_PATH });
}
```
## 프로그래매틱 SDK API
`twenty-sdk/cli` 서브 경로는 테스트 코드에서 직접 호출할 수 있는 함수를 내보냅니다:
| 함수 | 설명 |
| -------------- | --------------------------------------------- |
| `appBuild` | 앱을 빌드하고 필요하면 타르볼로 패키징 |
| `appDeploy` | 타르볼을 서버로 업로드 |
| `appDevOnce` | 앱을 한 번만 빌드하고 동기화합니다(`yarn twenty apply`와 동일). |
| `appInstall` | 활성 워크스페이스에 앱 설치 |
| `appUninstall` | 활성 워크스페이스에서 앱 제거 |
각 함수는 `success: boolean`과 `data` 또는 `error`를 포함한 결과 객체를 반환합니다.
## 통합 테스트 작성
다음은 앱을 빌드, 배포, 설치한 후 워크스페이스에 표시되는지 검증하는 전체 예제입니다:
```ts src/__tests__/app-install.integration-test.ts
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(`Build failed: ${buildResult.error?.message}`);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(`Deploy failed: ${deployResult.error?.message}`);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(`Install failed: ${installResult.error?.message}`);
}
});
afterAll(async () => {
await appUninstall({ appPath: APP_PATH });
});
it('should find the installed app in the workspace', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(app: { universalIdentifier: string }) =>
app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
```
## 테스트 실행
로컬 Twenty 서버가 실행 중인지 확인한 다음, 다음을 실행하세요:
```bash filename="Terminal"
yarn test
```
또는 개발 중에는 워치 모드로 실행하세요:
```bash filename="Terminal"
yarn test:watch
```
## 타입 검사
테스트를 실행하지 않고도 앱에 대해 타입 검사를 수행할 수 있습니다:
```bash filename="Terminal"
yarn twenty dev:typecheck
```
이는 앱의 `tsconfig.json`에 대해 `tsc --noEmit`를 실행하고 모든 타입 오류를 보고합니다. 스캐폴딩된 앱에는 테스트 파일(`tsconfig.spec.json`)도 검사하는 `yarn typecheck` 스크립트가 함께 제공됩니다.
## GitHub Actions로 CI
스캐폴더는 `.github/workflows/ci.yml`에 바로 사용할 수 있는 워크플로를 생성합니다. `main`으로의 모든 푸시와 모든 풀 리퀘스트마다, 러너에서 임시 Twenty 서버를 실행하고(`twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test` 액션을 통해), 그 서버를 가리키도록 설정된 `TWENTY_API_URL` / `TWENTY_API_KEY`와 함께 `yarn lint`, `yarn typecheck`, `yarn test:unit`, `yarn test`를 실행합니다. 시크릿은 필요 없으며, 워크플로 상단의 `TWENTY_VERSION` 환경 변수로 서버 버전을 고정할 수 있습니다.
스캐폴딩된 세 워크플로(`ci.yml`, `cd.yml` 배포 파이프라인, npm 게시용 `publish.yml`)에 대한 전체 단계별 안내는 [Publishing → Automated CI/CD](/l/ko/developers/extend/apps/operations/publishing#automated-cicd-scaffolded-workflows)를 참고하세요.

View File

@@ -0,0 +1,128 @@
---
title: 번역
description: 여러 언어로 앱을 출시하세요 — 단일 로케일 카탈로그에서 매니페스트 레이블과 UI 문자열을 번역하세요.
icon: language
---
Twenty 앱은 **영어**로 작성됩니다. 소스와 매니페스트의 문자열은 영어 원문이며, `en`은 번역의 기준이 되는 소스 로케일입니다. 번역되지 않은 로케일은 `en`을 기본값으로 사용합니다.
앱에는 두 가지 유형의 번역 가능한 텍스트가 있으며, 둘 다 동일한 `locales/` 카탈로그를 통해 처리됩니다.
* **매니페스트 레이블** — 오브젝트와 필드 이름, 뷰 제목, 메뉴 항목 및 앱 메타데이터에 선언된 기타 문자열.
* **Front-component 문자열** — React 프론트 컴포넌트가 렌더링하는 UI 텍스트입니다.
번역 가능한 문자열에 표시를 하고, 이를 로캘별 카탈로그로 추출한 뒤,
그 카탈로그들을 번역하면, 빌드가 현재 사용자에게 맞는 언어를 제공하므로
별도의 추가 작업이 필요 없습니다.
## Front-component 문자열 표시하기
`twenty-sdk/front-component`에서 번역 헬퍼를 import 하세요:
```tsx
import { Trans, t, msg, useTranslate } from 'twenty-sdk/front-component';
const STATUSES = [
{ id: 'draft', label: msg('Draft') },
{ id: 'sent', label: msg('Sent') },
];
const Card = ({ count, name }: { count: number; name: string }) => {
const { t } = useTranslate();
return (
<section>
{/* Static text — reactive to the user's locale */}
<Trans>Loading postcard…</Trans>
{/* Disambiguate identical sources with a context */}
<Trans context="card-title">Untitled</Trans>
{/* Interpolation: pass values explicitly */}
<p>{t('Hi {name}', { name })}</p>
<p>{t('Saved {count} cards', { count })}</p>
{/* Resolve a lazily-declared descriptor */}
<ul>{STATUSES.map((s) => <li key={s.id}>{t(s.label)}</li>)}</ul>
</section>
);
};
```
### 언제 무엇을 사용할지
* **`Trans`** — JSX 안의 정적 텍스트입니다. 보간을 위해 `message` 및 `values` props를 사용하세요
(`Trans message="Hi {name}" values={{ name }}`);
children 안에서 직접 보간하는 것은 정적으로 추출할 수 없습니다.
* **`useTranslate().t`** — 컴포넌트 내부에서 사용하는 동적 문자열입니다. 사용자가 언어를 전환하면
다시 렌더링됩니다. render 내부에서는 이것을 사용하는 것을 권장합니다.
* **`t(...)`** (직접 import) — 이벤트 핸들러, 헬퍼, 모듈 스코프 등
render 내부뿐만 아니라 **어디서든** 사용할 수 있는 즉시 번역 함수입니다.
* **`msg(...)`** — 데이터(상수, 설정)로 선언된 문자열을 위한 지연 평가용 서술자입니다. 나중에 `t(descriptor)`로 이를 해석하세요.
### 컨텍스트
서로 다른 방식으로 번역되는 동일한 소스 문자열을 구분하기 위해 `context`를 전달하세요:
```tsx
t({ message: 'Open', context: 'door' });
t({ message: 'Open', context: 'window' });
<Trans context="card-title">Untitled</Trans>
```
컨텍스트 문자열은 로케일 카탈로그에서 해당 컨텍스트를 키로 하는 그룹으로 나타납니다:
`"\<context>": { "\<message>": "\<translation>" }`. 매니페스트 레이블은 설계상 컨텍스트 정보를 포함합니다. 각 레이블은 자신이 수행하는 메타데이터 역할(`objectMetadata.labelSingular`, `fieldMetadata.label`, `view.name`, …)을 담고 있으므로, 같은 단어라도 객체 이름으로 쓰일 때와 뷰 이름으로 쓰일 때 서로 다르게 번역될 수 있습니다.
## 추출 및 번역
앱 디렉터리에서 extract 명령을 실행하세요:
```bash
twenty dev:translations-extract # collect strings into locales/en.json
twenty dev:translations-extract --locale fr-FR # also scaffold a target locale
```
추출 과정에서는 manifest 레이블과 front-component 소스의 `t()`/`msg()`/`Trans`
문자열을 모두 `locales/\<locale>.json` 파일에 소스 문자열을 키로 하여 수집합니다. 번역을 채워 넣으세요:
```json
// locales/fr-FR.json
{
"Loading postcard…": "Chargement de la carte…",
"Hi {name}": "Bonjour {name}",
"Saved {count} cards": "{count} cartes enregistrées",
"door": {
"Open": "Ouvrir"
},
"objectMetadata.labelSingular": {
"Postcard": "Carte postale"
}
}
```
문맥이 없는 문자열은 평면 엔트리이고, 문맥이 있는 문자열(모든 매니페스트 레이블 포함)은 해당 컨텍스트 키 아래에 중첩됩니다.
`{name}`과 같은 플레이스홀더는 런타임에 치환되므로 — 번역문에도 그대로 유지해야 합니다. 비워 둔 문자열은 소스 텍스트를 그대로 사용합니다.
## 실행 방식
`twenty dev:build`는 카탈로그를 컴파일하고 현재 사용자에게 맞는 언어를 제공합니다.
manifest 레이블은 서버 측에서 처리되고, front-component 카탈로그는 각 컴포넌트 번들에 내장됩니다. 런타임에는 컴포넌트가 실행 컨텍스트(호스트의 현재 언어)에서 로케일을 읽고, 각 문자열을 해당 카탈로그에서 찾아 해석하며, 번역이 없으면 소스 텍스트로 대체합니다. 호스트에서 언어를 전환하면 `Trans`와
`useTranslate().t` 문자열이 실시간으로 다시 렌더링됩니다.
매니페스트 레이블은 모든 동기화 시 — `twenty dev:build`, `twenty apply`
및 연속적인 `twenty dev` 감시 모두에서 — 컴파일되므로, 로캘 파일을 편집하고
동기화하면 새 텍스트를 확인할 수 있습니다. Front 컴포넌트 카탈로그는 빌드 시
각 컴포넌트 번들에 포함되므로, 그중 하나를 변경하려면 여전히
`twenty dev:build`를 다시 실행하고 (재배포해야) 합니다.
`Trans`의 텍스트 children은 여러 줄에 걸쳐 있을 수 있으며 — 공백은 JSX에서와 동일한 방식으로 축약되므로,
둘 다 `Welcome back` 키로 추출됩니다.
```tsx
<Trans>Welcome back</Trans>
<Trans>
Welcome
back
</Trans>
```

View File

@@ -0,0 +1,76 @@
---
title: 5. AI 에이전트
icon: robot
description: 에이전트가 귀하의 도구를 사용해 채팅에서 문서를 생성하도록 하세요.
---
`generate-document`가 **tool**로 노출되어 있기 때문에, AI 에이전트가 이를 호출할 수 있습니다.
사용자가 그냥 \*"generate a proposal for
Jeffery Griffin"\*이라고 말하기만 하면 되도록 에이전트와 스킬을 추가해 봅시다.
## 스킬
[skill](/l/ko/developers/extend/apps/logic/skills-and-agents)은(는) 재사용 가능한 지침으로,
에이전트에 연결하는 지식입니다. 우리 스킬은 모델에게 그 도구를 어떻게 사용하는지 알려 줍니다.
```ts filename="src/skills/document-drafting.skill.ts"
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
name: 'document-drafting',
label: 'Document drafting',
icon: 'IconFileText',
content: [
'To generate a document, call the `generate-document` tool with:',
'- `templateId`: the id of the document template to use.',
'- `recordId`: the id of the Person or Company the document is for.',
'',
'If the user names a template or person instead of an id, find the record first,',
'then pass its id. Make sure the template target matches the record type.',
].join('\n'),
});
```
## 에이전트
[agent](/l/ko/developers/extend/apps/logic/skills-and-agents)는 프롬프트와 모델을
연결합니다. 빌드 경고를 피하려면 `responseFormat`을 명시적으로 설정하세요.
```ts filename="src/agents/document-assistant.agent.ts"
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER,
name: 'document-assistant',
label: 'Document Assistant',
description: 'Generates documents from your templates and CRM records.',
icon: 'IconFileText',
responseFormat: { type: 'text' },
prompt: [
'You are the Document Assistant for a CRM.',
'You help users generate personalized documents from reusable templates',
'and the data already in their CRM. Use the generate-document tool, and',
'always confirm what you created.',
].join(' '),
});
```
<Note>
에이전트는 자신의 역할이 허용할 때만 그 도구를 호출할 수 있습니다. 우리는 이미 [2장](/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access)에서 앱의 역할에 `canAccessAllTools: true` 및 `canBeAssignedToAgents: true`를 설정했습니다.
</Note>
## 사용해 보기
**Document Assistant**와 채팅을 열고, CRM에 있는 사람을 위한 문서를 작성해 달라고 요청하세요. 에이전트는 레코드를 찾고 `generate-document`를 호출한 다음,
생성한 문서를 다시 알려 줍니다. 이 문서는 이제 **Documents** 보기에도 표시되며,
명령 메뉴와 워크플로 경로를 사용할 때와 정확히 동일하게 동작합니다.
로직을 도구로 노출하면 이런 이점이 있습니다: **하나의 함수, 여러 개의 진입점** —
명령 메뉴, HTTP, 워크플로 단계, 그리고 이제는 자연어.
**이 단계를 마치면:** 앱은 기능이 완전해지고 실제로 유용해집니다. 이제 배포할 시간입니다.
<Card title="다음: 게시 →" icon="rocket" href="/l/ko/developers/extend/apps/tutorials/document-generator/publishing">
마켓플레이스 메타데이터를 추가하고 게시하세요.
</Card>

View File

@@ -0,0 +1,276 @@
---
title: 4. UI 구성하기
icon: table-columns
description: 뷰, 사이드바 내비게이션, 커맨드, 그리고 프런트 컴포넌트.
---
현재는 설정(Settings)에서만 해당 객체에 접근할 수 있습니다. 이제 앱이 UI에서 진짜 존재감을 갖도록 해 봅시다. 목록 뷰, 사이드바 항목, 원클릭 **Generate document** 명령, 문서를 **미리 보기** 위한 레코드 페이지용 프런트 컴포넌트, 템플릿용 기본 제공 리치 텍스트 **editor** 탭을 추가합니다.
## 뷰와 내비게이션
[뷰](/l/ko/developers/extend/apps/layout/views)는 특정 객체에 대해 저장된 목록입니다.
[내비게이션 메뉴 항목](/l/ko/developers/extend/apps/layout/navigation-menu-items)은 해당 뷰를 사이드바에 배치합니다. 서버는 이미 각 객체의 기본 "All ..." 보기를 자동으로 생성하므로, 앱이 정의하는 보기는 추가로 제공되는 선별된 보기입니다.
```ts filename="src/views/documents.view.ts"
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All documents',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconFile',
position: 0,
fields: [
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0, isVisible: true, size: 280 },
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1, isVisible: true, size: 120 },
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2, isVisible: true, size: 200 },
],
});
```
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Documents',
icon: 'IconFile',
color: 'green',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
});
```
템플릿에 대해서도 동일한 쌍을 추가하세요. 이제 둘 다 사이드바에 표시됩니다:
<Frame caption="사이드바의 Documents와 Templates, 그리고 생성된 문서가 목록에 표시되어 있습니다.">
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="생성된 문서가 있는 Documents 뷰" />
</Frame>
## 프런트 컴포넌트
[프런트 컴포넌트](/l/ko/developers/extend/apps/layout/front-components)는 Twenty 안에 샌드박스된 React 컴포넌트입니다. 우리 컴포넌트는 선택된 레코드를 읽고, `CoreApiClient`를 통해 person 템플릿을 로드한 뒤, 이전 장에서 만든 라우트로 POST 요청을 보냅니다.
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
const GenerateDocumentForm = () => {
const selectedRecordIds = useSelectedRecordIds();
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [templateId, setTemplateId] = useState('');
useEffect(() => {
new CoreApiClient()
.query({ documentTemplates: {
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
edges: { node: { id: true, name: true } } } })
.then(({ documentTemplates }) => {
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
setTemplates(list);
if (list[0]) setTemplateId(list[0].id);
});
}, []);
const generate = async () => {
const res = await new RestApiClient().post<{ success: boolean }>(
'/s/documents/generate',
{ templateId, recordId },
);
await enqueueSnackbar({
message: res.success ? 'Document generated.' : 'Generation failed.',
variant: res.success ? 'success' : 'error',
});
};
// ...render a <select> of templates and a Generate button
};
export default defineFrontComponent({
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'generate-document-form',
component: GenerateDocumentForm,
});
```
<Warning>
값을 `twenty-ui`에서 임포트하지 말고 인라인 CSS 변수(`var(--t-color-blue)`)로 스타일을 지정하세요. SDK 가 빌드 중에 해당 패키지를 모킹하므로, 테마 상수를 모듈 레벨에서 임포트하면 `undefined`가 됩니다. 자세한 내용은
[전체 컴포넌트](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx)를 참고하세요.
</Warning>
## 이를 여는 커맨드
`availabilityType: 'RECORD_SELECTION'`이 있는 [커맨드 메뉴 항목](/l/ko/developers/extend/apps/layout/command-menu-items)은 Person 이 선택되었을 때 표시되며, 컴포넌트를 사이드 패널에서 엽니다.
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Generate document',
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
frontComponentUniversalIdentifier:
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
```
## 전체 플로우 사용해 보기
**People**을 열고, 사람을 하나 선택한 뒤 <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>를 누르세요.
"Generate document"가 앱 태그와 함께 표시됩니다:
<Frame caption="Person 이 선택되었을 때 이 커맨드가 표시됩니다.">
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Generate document 커맨드가 있는 커맨드 메뉴" />
</Frame>
이 커맨드를 실행하면 컴포넌트가 사이드 패널에서 열립니다. 템플릿을 선택하고 **Generate**를 클릭하면, 새 레코드가 **Documents**에 생성됩니다.
<Frame caption="프런트 컴포넌트가 템플릿을 로드하고 클릭 시 문서를 생성합니다.">
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document 사이드 패널" />
</Frame>
생성된 모든 문서는 작성자로 여러분의 앱을 기록합니다:
<Frame caption="Document Generator가 생성자로 표시되고, 상태는 Generated입니다.">
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="생성된 문서 레코드" />
</Frame>
## 레코드 페이지에서 문서 미리 보기
프런트 컴포넌트는 커맨드 메뉴에만 쓰이는 것이 아닙니다. **레코드 페이지의 탭**으로 마운트할 수도 있습니다. 문서 레코드에 *Preview* 탭을 추가하여 Markdown 본문을 정돈된 인쇄용 페이지로 렌더링해 봅시다.
컴포넌트는 실행 컨텍스트에서 현재 레코드 ID 를 읽어 문서를 로드하고 렌더링합니다. 프런트 컴포넌트는 허용된 HTML 태그 화이트리스트만 사용할 수 있는 **샌드박스**에서 실행됩니다. 원시 HTML 주입(`dangerouslySetInnerHTML`)과 `\<style>`은 차단되므로, 작은 [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx) 헬퍼를 통해 Markdown을 인라인 스타일이 적용된 React 엘리먼트로 렌더링합니다.
```tsx filename="src/front-components/document-viewer.front-component.tsx"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
import { Markdown } from 'src/utils/markdown-to-react';
const DocumentViewer = () => {
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
// ...load { content, file } for recordId, then derive the links:
const pdfUrl = document.file?.[0]?.url;
const webUrl = new RestApiClient().resolveUrl('/s/documents/view', {
query: { id: recordId },
});
// Render the template body, plus quick links to the web page and the PDF.
// Links open in a new tab so they don't navigate the embedded component.
return (
<div style={styles.scroll}>
<div style={styles.actions}>
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
Open web page
</a>
{pdfUrl ? (
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
Download PDF
</a>
) : null}
</div>
<div style={styles.paper}>
<div style={styles.body}>
<Markdown content={document.content} />
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'document-viewer',
component: DocumentViewer,
});
```
[페이지 레이아웃](/l/ko/developers/extend/apps/layout/page-layouts)으로 이를 마운트합니다. `RECORD_PAGE` 레이아웃은 객체의 레코드 뷰에 탭을 추가합니다. 단일 `FRONT_COMPONENT` 위젯이 있는 탭은 컴포넌트를 전체 폭으로 호스팅합니다:
```ts filename="src/page-layouts/document-record.page-layout.ts"
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Document record page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
icon: 'IconEye',
position: 50,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Document preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
}],
}],
});
```
아무 문서나 열어 보면 **Preview** 탭이 문서를 보기 좋게 렌더링하고, 공유 가능한 웹 페이지와 PDF 링크도 함께 제공합니다:
<Frame caption="Preview 탭은 인라인 스타일로 문서를 렌더링하고, 빠른 링크도 제공합니다.">
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="레코드 페이지 탭 안의 문서 뷰어 프런트 컴포넌트" />
</Frame>
## 리치 텍스트 에디터로 템플릿 편집하기
템플릿에는 별도의 커스텀 컴포넌트가 전혀 필요하지 않습니다. `body`가 `RICH_TEXT` 필드이기 때문에, Twenty는 이미 해당 필드에 사용할 수 있는 완전한 리치 텍스트 편집기를 제공합니다. 표준 Note 및 Task 객체에서 사용하는 것과 동일한 편집기입니다. 우리는 이 에디터를 템플릿 레코드 페이지에 노출하기만 하면 됩니다.
`EDITOR` 디스플레이 모드의 `FIELD` 위젯을 가진 탭을 추가하고, `fieldMetadataId`를 통해 `body` 필드를 가리키도록 설정하세요:
```ts filename="src/page-layouts/template-record.page-layout.ts"
{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Template',
position: 1,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [{
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template',
type: 'FIELD',
position: { layoutMode: PageLayoutTabLayoutMode.GRID, row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
configuration: {
configurationType: 'FIELD',
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'EDITOR',
},
}],
}
```
`RICH_TEXT` 필드는 편집기의 블록 JSON과 Markdown 프로젝션을 모두 저장합니다. 생성 파이프라인은 그 Markdown 프로젝션을 읽으므로, 플레이스홀더, PDF, 공유 가능한 웹 페이지가 모두 변경 없이 계속 동작합니다. 전체 코드는
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts)에서 확인하세요.
`FIELD` 위젯은 중첩된 관계의 레코드를 두 단계 떨어진 곳까지 나열하는 것을 포함하여, 다른 표시 모드와 옵션도 지원합니다. 자세한 내용은 [필드 위젯 레퍼런스](/l/ko/developers/extend/apps/layout/page-layouts#field-widgets)를 참고하세요.
이제 편집자는 제대로 된 리치 텍스트 편집기에서 템플릿을 작성할 수 있습니다:
<Frame caption="Template 탭: Twenty 의 네이티브 리치 텍스트 에디터가 body 필드에 연결되어 있습니다.">
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="네이티브 리치 텍스트 에디터 탭이 있는 템플릿 레코드" />
</Frame>
**이 단계를 마치면:** 문서는 보기 좋게 미리 볼 수 있고, 템플릿은 앱 내에서 편집할 수 있습니다. 다음으로, AI 에이전트가 채팅에서 문서를 생성하도록 만들어 봅시다.
<Card title="다음: AI 에이전트 →" icon="robot" href="/l/ko/developers/extend/apps/tutorials/document-generator/ai-agent">
도구를 호출하는 에이전트와 스킬을 추가하세요.
</Card>

View File

@@ -0,0 +1,134 @@
---
title: 1. 데이터 모델
icon: database
description: 문서와 템플릿을 객체, 필드, 관계로 모델링합니다.
---
우리 앱에는 두 개의 커스텀 객체가 필요합니다: **document templates**(무엇을 쓸지)와
**documents**(생성된 결과)입니다. 이들을 정의해 보겠습니다.
각 엔티티 파일을 CLI로 스캐폴딩하세요 — 그러면 유효한 UUID와 올바른
폴더가 자동으로 생성됩니다:
```bash filename="Terminal"
yarn twenty dev:add object
```
아래에 완성된 파일들을 보여 줍니다.
<Note>
모든 `*_UNIVERSAL_IDENTIFIER` 상수는
`src/constants/universal-identifiers.ts`에 있으며, 사용하는 곳에서 import됩니다. 아래 코드 조각에서는 설명을 위해 해당 import를 생략했지만 — 실제 파일에서는 반드시 포함해야 합니다.
</Note>
## 템플릿 객체
템플릿에는 `name`, `{{placeholders}}`가 포함된 `body`, 그리고 Person 또는 Company 중
어느 쪽을 대상으로 작성되었는지 나타내는 `target`이 있습니다. `body`는
`RICH_TEXT` 필드이므로, Twenty는 여기에 완전한 리치 텍스트 에디터를 제공합니다.
```ts filename="src/objects/document-template.object.ts"
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'documentTemplate',
namePlural: 'documentTemplates',
labelSingular: 'Document template',
labelPlural: 'Document templates',
icon: 'IconFileText',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
defaultValue: `'PERSON'`,
options: [
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
] },
],
});
```
<Warning>
`SELECT` 옵션 **values**는 반드시 `UPPER_CASE`(`person`이 아니라 `PERSON`)여야 하며,
`defaultValue`는 따옴표로 한 번 더 감싸야 합니다: `` `'PERSON'` ``. `label`은
사용자에게 표시되는 값입니다.
</Warning>
## 문서 객체
생성된 문서는 렌더링된 `content`와 `status`를 저장합니다. `status`가 `DRAFT` / `GENERATED`인 `select` 필드로
같은 방식으로 정의합니다. 전체 파일:
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
## 관계를 사용해 둘을 연결하기
각 문서는 자신이 생성된 템플릿을 가리켜야 합니다. 관계는
항상 **양방향**이며, 각 필드 파일에서 각각 한쪽씩 정의합니다.
```ts filename="src/fields/document-template-relation.field.ts"
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
// The "many" side: each document belongs to one template.
export default defineField({
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'template',
label: 'Template',
relationTargetObjectMetadataUniversalIdentifier:
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'templateId',
},
});
```
반대편(`template-documents-relation.field.ts`)은
반대 방향을 가리키는 `documents`라는 이름의 `RelationType.ONE_TO_MANY` 필드입니다.
전체 패턴은 [Relations](/l/ko/developers/extend/apps/data/relations)를 참조하세요.
## Twenty에서 확인하기
`yarn twenty dev`를 실행한 상태에서 **Settings → Data model**을 엽니다. 두 객체가
모두 표시되며, 여러분의 앱으로 태깅되어 있습니다.
<Frame caption="Document Generator 앱이 소유한 두 개의 커스텀 객체.">
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Documents와 Document templates가 표시된 데이터 모델 설정" />
</Frame>
테스트용으로 템플릿 하나를 생성하세요 — 이름을 *Sales proposal*로 지정하고, **Target**을
*Person*으로 설정한 뒤, placeholder 몇 개가 포함된 body를 붙여넣습니다:
```text
Dear {{name.firstName}} {{name.lastName}},
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
Best,
The Team
```
<Frame caption="템플릿 레코드입니다. 문서가 생성될 때까지 body에는 placeholder가 유지됩니다.">
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="플레이스홀더 본문이 있는 Sales proposal 템플릿 레코드" />
</Frame>
**이 단계를 마치면:** relation으로 연결된 `documentTemplate` 및 `document` 객체가 있고,
생성을 위한 템플릿이 하나 준비된 상태입니다. 다음은 이를 채워 넣는 로직입니다.
<Card title="다음: 문서 생성 →" icon="bolt" href="/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents">
템플릿을 채우는 로직 함수를 작성합니다.
</Card>

View File

@@ -0,0 +1,210 @@
---
title: 2. 문서 생성하기
icon: bolt
description: 하나의 로직 함수로, AI 도구이자 워크플로 작업으로 노출됩니다.
---
이제 핵심입니다. 템플릿과 레코드를 불러와 플레이스홀더를 채우고 새 문서를 저장하는 [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions)입니다.
비즈니스 로직은 **핸들러**로 한 번만 작성한 다음, 여러 트리거를 통해 노출합니다. 이 장에서는 그중 두 가지, 즉 **AI 도구**와 **워크플로 작업**을 연결합니다.
## 렌더링 헬퍼
순수 로직은 별도의 파일로 분리해 두어 단위 테스트를 쉽게 할 수 있도록 하세요. 이는 레코드를 `{{dot.path}}` 토큰으로 평탄화하고, 해당 토큰을 치환합니다.
```ts filename="src/logic-functions/utils/render-template.ts"
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
export const renderTemplate = (body: string, values: Record<string, string>) => {
const missingTokens = new Set<string>();
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
const value = values[token];
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
return value;
});
return { content, missingTokens: [...missingTokens] };
};
```
<Tip>
이 파일은 부작용이 없으므로, 빠른 단위 테스트(`yarn test:unit`)로 검증할 수 있습니다. [테스트](/l/ko/developers/extend/apps/operations/testing)를 참조하세요.
</Tip>
## 핸들러
핸들러는 생성된 [`CoreApiClient`](/l/ko/developers/extend/apps/logic/logic-functions)를 사용해 CRM 데이터를 읽고 씁니다. 템플릿을 불러오고, 대상 레코드를 불러온 뒤, 본문을 채우고 `document`를 생성합니다.
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';
export const generateDocumentHandler = async (
input: { templateId: string; recordId: string },
) => {
const client = new CoreApiClient();
// Use a filtered list query, not the singular lookup: the singular query
// throws when nothing matches, which would become a 500 instead of a 404.
const { documentTemplates } = await client.query({
documentTemplates: {
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
edges: { node: { id: true, name: true, body: true, target: true } },
},
});
const documentTemplate = documentTemplates?.edges?.[0]?.node;
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
const { createDocument } = await client.mutation({
createDocument: {
__args: { data: {
name: `${documentTemplate.name} — ${record.displayName}`,
content, status: 'GENERATED', templateId: documentTemplate.id,
} },
id: true, name: true,
},
});
return { success: true, documentId: createDocument.id, content, missingTokens };
};
```
`loadRecordValues`는 Person과 Company에 대해 서로 다른 쿼리를 실행하고 결과를 평탄화합니다. 자세한 내용은
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts)를 참조하세요.
## 도구와 워크플로 작업으로 노출하기
하나의 `defineLogicFunction`에 여러 트리거를 실을 수 있습니다. 여기서는 `toolTriggerSettings`로 AI 에이전트가 호출할 수 있게 하고, `workflowActionTriggerSettings`로 시각적 워크플로 빌더에서 하나의 단계가 되도록 합니다. 둘 모두 JSON 스키마로 입력을 설명합니다.
```ts filename="src/logic-functions/generate-document.ts"
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'generate-document',
description: 'Generate a document from a template and a CRM record.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: generateDocumentInputSchema,
},
workflowActionTriggerSettings: {
label: 'Generate Document',
icon: 'IconFileText',
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
outputSchema: [{ type: 'object', properties: {
success: { type: 'boolean' }, documentId: { type: 'string' },
} }],
},
handler: generateDocumentHandler,
});
```
입력 스키마는 `templateId`와 `recordId`를 설명하는 일반 JSON 스키마입니다. 자세한 내용은 [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts)를 참조하세요.
## 접근 권한 부여하기
로직 함수는 앱의 역할로 실행됩니다. 템플릿과 레코드를 읽고 문서를 생성해야 하므로, `src/roles/default-role.ts`에서 해당 권한을 허용하세요:
```ts
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Document Generator default role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canAccessAllTools: true,
canBeAssignedToAgents: true,
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
```
`UPLOAD_FILE`은 다음 섹션에서 함수가 생성된 PDF를 업로드할 수 있게 해 줍니다.
보다 세분화된 권한에 대해서는 [Roles](/l/ko/developers/extend/apps/config/roles)를 참조하세요.
## 실제 PDF 파일 첨부하기
렌더링된 텍스트 필드만으로도 유용하지만, 사용자들은 실제 문서를 원합니다. 이제 **PDF**를 생성하여 레코드에 다운로드 가능한 파일로 저장해 봅시다.
먼저, `document` 객체에 PDF를 담을 `FILES` 필드를 추가합니다. 앱은 **자신의** 파일 필드로 업로드하므로, 이 필드가 업로드 경로를 결정합니다.
```ts filename="src/objects/document.object.ts"
{
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FILES,
name: 'file',
label: 'File',
icon: 'IconFileTypePdf',
universalSettings: { maxNumberOfValues: 1 },
}
```
이제 해당 PDF를 렌더링합니다. 앱은 실제 Node 프로젝트이므로, 필요한 npm 패키지를 자유롭게 추가하고 다른 곳과 마찬가지로 import할 수 있습니다. 여기서는 \*\*[pdf-lib](https://pdf-lib.js.org/)\*\*로 PDF를 그리고, \*\*[marked](https://marked.js.org/)\*\*로 Markdown 본문을 파싱합니다. CLI가 이들을 함수 런타임에 설치해 줍니다.
```bash filename="Terminal"
yarn add pdf-lib marked
```
전체 헬퍼 코드는
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts)에 있습니다.
이 헬퍼는 `marked.lexer`로 Markdown을 토큰으로 파싱한 뒤, pdf-lib으로 레이아웃합니다. 실제 제목, **굵게**/*기울임* 처리, 글머리 기호 및 번호 목록, 인용 블록과 구분선 등, 텍스트 덩어리가 아니라 템플릿 자체를 다듬어진 다중 페이지 A4 렌더링으로 만들어 줍니다.
<Frame caption="생성된 PDF: 실제 타이포그래피와 Markdown 서식을 사용해 템플릿 본문을 렌더링합니다.">
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="다듬어진, 상용 수준의 생성된 PDF" />
</Frame>
<Note>
pdf-lib의 기본 제공 폰트는 WinAnsi 인코딩을 사용하므로, 서유럽 악센트 문자는 별도 설정 없이 렌더링됩니다. 헬퍼는 스마트 따옴표와 대시를 매핑하고, 인코딩할 수 없는 문자는 제거합니다. 비라틴 문자(중국어, 아랍어, 키릴 문자 등)를 렌더링하려면 Unicode 폰트를 임베딩해야 합니다.
</Note>
이제 이를 업로드하고 레코드에 해당 참조를 저장합니다. `uploadFile`은 바이트를 앱이 소유한 파일 필드로 라우팅하며, 반환된 `id`가 저장해야 할 값입니다.
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
const documentName = `${documentTemplate.name} — ${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';
const uploaded = await new MetadataApiClient().uploadFile(
Buffer.from(bytes),
fileName,
'application/pdf',
DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);
await client.mutation({
updateDocument: {
__args: {
id: documentId,
data: { file: [{ fileId: uploaded.id, label: fileName }] },
},
id: true,
},
});
```
이제 생성된 문서에는 다운로드 가능한 PDF가 연결됩니다.
<Frame caption="문서의 파일 필드에 저장된 생성된 PDF.">
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="생성된 PDF 파일이 연결된 문서 레코드" />
</Frame>
<Note>
`uploadFile`은 **앱이 소유한** 파일 필드만을 대상으로 합니다(따라서 업로드에는 항상 해당 필드를 소유한 앱과 `UPLOAD_FILE` 역할 플래그가 필요합니다). 그래서 PDF는 레코드의 자체 `file` 필드에 저장됩니다. 이는
[call-recorder 앱](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)이 녹음을 위해 사용하는 것과 동일한 패턴입니다.
</Note>
**이 단계를 마치면:** 각 생성된 문서에 실제 다운로드 가능한 PDF가 포함됩니다. 하지만 아직 UI에서 생성기를 *호출*할 수는 없습니다. 그러려면 HTTP 경로가 필요합니다.
<Card title="다음: HTTP 경로 →" icon="globe" href="/l/ko/developers/extend/apps/tutorials/document-generator/http-routes">
HTTP를 통해 함수를 제공하고 문서를 웹 페이지로 렌더링합니다.
</Card>

View File

@@ -0,0 +1,135 @@
---
title: 3. HTTP 경로
icon: globe
description: HTTP를 통해 함수를 트리거하고 문서를 웹 페이지로 렌더링합니다.
---
같은 핸들러가 HTTP 요청에도 응답할 수 있습니다. 두 개의 경로를 추가하겠습니다:
* UI가 문서를 생성하기 위해 호출하는 **POST** 엔드포인트, 그리고
* 문서를 인쇄 가능한 웹 페이지로 렌더링하는 공개 **GET** 엔드포인트입니다.
둘 다 `httpRouteTriggerSettings`를 사용합니다. 앱 라우트는 `/s/` 경로로 접근합니다. 로컬 및 셀프 호스팅 인스턴스에서는 해당 경로가 Twenty 서버에서 제공됩니다(예: `http://localhost:2020/s/documents/generate`). Twenty Cloud에서는 `RestApiClient`가 워크스페이스의 functions 도메인을 통해 경로를 해석합니다.
## POST 경로 — 온디맨드로 생성하기
이는 `generateDocumentHandler`를 재사용하므로 반복해야 할 로직은 없고, 요청 본문을 읽는 간단한 어댑터만 있으면 됩니다.
```ts filename="src/logic-functions/generate-document-route.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
const handler = async (event: RoutePayload): Promise<Response> => {
const body = event.body as Record<string, unknown> | null;
const result = await generateDocumentHandler({
templateId: (body?.templateId as string) ?? '',
recordId: (body?.recordId as string) ?? '',
});
// Map the handler's failure reason onto a real HTTP status (400/404/500)
// instead of always returning 200.
return new Response(JSON.stringify(result), {
status: result.success ? 200 : (result.status ?? 400),
headers: { 'Content-Type': 'application/json' },
});
};
export default defineLogicFunction({
universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'generate-document-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/documents/generate',
httpMethod: 'POST',
isAuthRequired: true,
},
});
```
공유 핸들러는 실패 시 제안된 `status`를 반환하므로, 경로가 적절한 `4xx`/`5xx` 코드로 응답할 수 있습니다. `isAuthRequired: true`는 호출자가 유효한 토큰을 제공해야 함을 의미합니다. 다음 장의 프런트 컴포넌트가 사용자의 액세스 토큰을 자동으로 전달합니다.
## GET 경로 — 웹 페이지로 렌더링하기
JSON 대신 HTML을 반환하려면, 본문을 `Content-Type` 헤더가 있는 `Response`로 감싸면 됩니다. 이 경로는 공개(`isAuthRequired: false`)되어 있으므로 생성된 문서를 링크로 공유할 수 있습니다.
```ts filename="src/logic-functions/view-document.ts"
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { Response } from 'twenty-sdk/logic-function';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { documentHtmlPage } from 'src/utils/render-document';
const htmlResponse = (html: string, status = 200): Response =>
new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
const handler = async (event: RoutePayload): Promise<Response> => {
const documentId = event.queryStringParameters?.id;
if (!documentId) {
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
}
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
const { documents } = await new CoreApiClient().query({
documents: {
__args: { filter: { id: { eq: documentId } }, first: 1 },
edges: { node: { id: true, name: true, content: true } },
},
});
const document = documents?.edges?.[0]?.node;
if (!document?.id) {
return htmlResponse(documentHtmlPage('Document not found', `No document with id ${documentId}.`), 404);
}
return htmlResponse(documentHtmlPage(document.name ?? 'Document', document.content ?? ''));
};
export default defineLogicFunction({
universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'view-document',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/documents/view',
httpMethod: 'GET',
isAuthRequired: false,
},
});
```
`documentHtmlPage`는 Markdown 본문을 HTML로 렌더링하고([marked](https://marked.js.org/)로, sanitization 적용), 템플릿 콘텐츠만 표시되는 깔끔하고 인쇄 가능한 페이지에 이를 삽입합니다. 이는 PDF와 앱 내 미리보기와 동일한 모습입니다.
[헬퍼를 확인하세요](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
## 사용해 보기
워크스페이스에 템플릿과 Person이 준비되면, 경로를 호출하세요(**Settings → APIs & Webhooks**에서 토큰을 가져오세요):
```bash filename="Terminal"
curl -X POST http://localhost:2020/s/documents/generate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
```
반환된 문서를 브라우저에서 여세요:
```
http://localhost:2020/s/documents/view?id=<documentId>
```
<Frame caption="공개 GET 경로는 문서를 인쇄 가능한 페이지로 렌더링합니다.">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="렌더링된 문서 웹 페이지" />
</Frame>
<Tip>
테스트하는 동안 `yarn twenty dev:function:logs`로 함수의 로그를 스트리밍하거나, `yarn twenty dev:function:exec`로 직접 호출할 수도 있습니다.
</Tip>
**이 단계를 마치면:** 앱은 HTTP를 통해 문서를 생성하고 이를 웹 페이지로 제공할 수 있습니다. 이제 `curl` 없이도 사용할 수 있도록 만들어 봅시다.
<Card title="다음: UI 구성하기 →" icon="table-columns" href="/l/ko/developers/extend/apps/tutorials/document-generator/building-the-ui">
뷰, 내비게이션, 커맨드, 그리고 프런트 컴포넌트.
</Card>

View File

@@ -0,0 +1,61 @@
---
title: "튜토리얼: 문서 생성기"
icon: wand-magic-sparkles
description: 실제 Twenty 앱을 만들어 CRM 데이터에서 개인 맞춤형 문서를 생성해 보세요.
---
이 튜토리얼에서는 재사용 가능한 템플릿을 CRM에 이미 있는 데이터를 사용해 개인 맞춤형 문서로 변환하는 앱인 **Document Generator**를 만들어 보겠습니다.
`{{placeholders}}`로 한 번 템플릿을 작성한 다음, 명령 메뉴, AI 에이전트 또는 워크플로에서 한 번의 클릭으로 어떤 사람(Person)이나 회사(Company)에 대해서도 내용이 채워진 문서를 생성할 수 있습니다.
<Frame caption="특정 사람을 위해 생성된 문서가 인쇄 가능한 페이지로 열려 있습니다.">
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="생성된 영업 제안(Sales proposal) 문서" />
</Frame>
## 학습하게 될 내용
각 챕터는 하나의 기능을 추가합니다. 마지막에는 SDK의 대부분을 한 번씩 다뤄 보게 됩니다.
| 챕터 | 기능 | 참고 |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ |
| [1. 데이터 모델](/l/ko/developers/extend/apps/tutorials/document-generator/data-model) | 객체, 필드, 그리고 관계 | [데이터](/l/ko/developers/extend/apps/data/overview) |
| [2. 문서 생성](/l/ko/developers/extend/apps/tutorials/document-generator/generating-documents) | Markdown 템플릿을 채우고 다듬어진 PDF를 첨부하는 로직 함수(AI 도구 + 워크플로 작업) | [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) |
| [3. HTTP 경로](/l/ko/developers/extend/apps/tutorials/document-generator/http-routes) | 경로에서 JSON과 공유 가능한 HTML 페이지 제공 | [로직 함수](/l/ko/developers/extend/apps/logic/logic-functions) |
| [4. UI 빌드](/l/ko/developers/extend/apps/tutorials/document-generator/building-the-ui) | 뷰, 내비게이션, 명령 메뉴, 그리고 문서를 미리 보고 템플릿을 편집하는 프런트 컴포넌트 | [레이아웃](/l/ko/developers/extend/apps/layout/overview) |
| [5. AI 에이전트](/l/ko/developers/extend/apps/tutorials/document-generator/ai-agent) | 에이전트 + 스킬 | [스킬 및 에이전트](/l/ko/developers/extend/apps/logic/skills-and-agents) |
| [6. 게시하기](/l/ko/developers/extend/apps/tutorials/document-generator/publishing) | 마켓플레이스에 출시하기 | [게시하기](/l/ko/developers/extend/apps/operations/publishing) |
## 사전 준비
[빠른 시작](/l/ko/developers/extend/apps/getting-started/quick-start)을 이미 완료했다고 가정합니다.
포트 `2020`에서 실행 중인 로컬 Twenty 서버와, 해당 서버에 인증된 CLI가 있어야 합니다.
아직 아니라면, 지금 스캐폴딩하고 서버를 시작하세요:
```bash filename="Terminal"
npx create-twenty-app@latest document-generator
cd document-generator
yarn twenty dev
```
<Note>
완성된 코드를 먼저 보고 싶으신가요? 전체 앱은
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)에 있습니다.
아래의 모든 코드 스니펫은 이 앱에서 가져온 것입니다.
</Note>
## 앱이 어떻게 구성되는지
<Frame>
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="플레이스홀더가 있는 템플릿은 명령 메뉴, AI 에이전트, 워크플로, 또는 공유 가능한 링크에서 트리거되어 PDF가 포함된 다듬어진 문서로 생성됩니다." />
</Frame>
리치 텍스트 편집기에서 `{{placeholders}}`가 포함된 **템플릿**을 한 번 작성합니다. 템플릿과 CRM 레코드를 선택하면 플레이스홀더가 채워지고, 다듬어진 **문서**(PDF 파일 포함)가 저장됩니다. 나머지 — 명령 메뉴, AI 에이전트, 워크플로 단계, 공유 가능한 링크 — 는 모두 해당 하나의 생성기를 트리거하는 서로 다른 방식일 뿐입니다.
## 이 루프를 계속 유지하세요
튜토리얼 전체 동안 터미널에서 `yarn twenty dev`를 실행 상태로 두세요. `src/` 아래에 파일을 추가하거나 편집할 때마다 몇 초 안에 서버와 다시 동기화되므로, 빌드하면서 각 기능이 UI에 나타나는 과정을 확인할 수 있습니다.
<Card title="빌드 시작 →" icon="database" href="/l/ko/developers/extend/apps/tutorials/document-generator/data-model">
챕터 1: 문서와 템플릿 모델링.
</Card>

View File

@@ -0,0 +1,127 @@
---
title: 6. 게시
icon: rocket
description: 마켓플레이스 메타데이터를 추가하고 앱을 게시하세요.
---
앱이 작동합니다. 마지막 단계는 마켓플레이스를 위한 설명을 추가하고 게시하는 것입니다.
## 마켓플레이스 메타데이터 추가
[application config](/l/ko/developers/extend/apps/config/application)는 마켓플레이스에 표시되는 식별 정보를 포함합니다. 작성자, 카테고리, 로고, 지원 링크 등이 여기에 포함됩니다. `public/`에 로고를 넣고 `logo`로 참조하세요.
```ts filename="src/application-config.ts"
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Document Generator',
description:
'Create reusable document templates and generate personalized documents from your CRM records.',
logo: 'public/document-generator.svg',
author: 'Twenty',
category: 'Productivity',
websiteUrl: 'https://docs.twenty.com/l/ko/developers/extend/apps',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
});
```
<Tip>
기본 역할은 자체 파일에서 `defineApplicationRole()`로 선언합니다. 이제 여기에서는 `defaultRoleUniversalIdentifier`를 더 이상 전달하지 않습니다.
</Tip>
앱을 쉽게 찾을 수 있도록 `package.json`에 `twenty-app` 키워드도 추가하세요:
```json filename="package.json"
{ "keywords": ["twenty-app"] }
```
## 갤러리 스크린샷 추가
마켓플레이스 등록 정보는 스크린샷만으로도 충분히 어필됩니다. 몇 개의 PNG 파일을
`public/gallery/`에 넣고 `galleryImages`로 참조하세요. 그러면 목록 페이지에서 배열 순서대로 갤러리로 렌더링됩니다.
```ts filename="src/application-config.ts"
export default defineApplication({
// ...identity from above
galleryImages: [
'public/gallery/01-generated-document.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-template-editor.png',
'public/gallery/04-documents.png',
],
});
```
<Tip>
핵심 효과부터 보여 주세요. 첫 번째 스크린샷은 최종 결과(생성된 문서)로 두고, 그다음에 어떻게 트리거되고 작성되는지를 보여 주세요. 선명한 고해상도 캡처를 사용하세요. 사용자가 가장 먼저 보게 되는 요소입니다.
</Tip>
`README.md`도 동일한 방식으로 다뤄 주세요. npm과 GitHub에서의 첫 페이지 역할을 합니다.
가치 제안과 스크린샷으로 시작하고 핵심 기능을 나열한 다음, 빌드 세부 정보는 접힌 영역 아래에 배치하세요.
## 출시 전 점검
CI에서 수행하는 것과 동일한 검증 단계를 실행하세요:
```bash filename="Terminal"
yarn lint # oxlint
yarn typecheck # tsgo
yarn test:unit # unit tests
yarn twenty plan # preview the metadata diff
```
플랜은 서버에 실제로 적용하지 않고 무엇이 변경될지를 그대로 출력합니다.
마지막으로 확인하기에 좋은 방법입니다. [테스트](/l/ko/developers/extend/apps/operations/testing)와
[동기화 및 복구](/l/ko/developers/extend/apps/operations/sync-and-recovery)를 참조하세요.
## 게시
```bash filename="Terminal"
# Public app → npm (default)
yarn twenty app:publish
# Or deploy privately to a specific server's registry
yarn twenty app:publish --private -r <remote>
```
`app:publish`는 기본적으로 빌드하고 npm에 게시합니다. `--private`는 대신 Twenty 서버의 프라이빗 레지스트리에 tarball을 업로드합니다. 배포된 앱을 인스턴스의 마켓플레이스에 표시하려면 카탈로그 동기화를 트리거하세요:
```bash filename="Terminal"
yarn twenty dev:catalog-sync -r <remote>
```
자세한 내용과 릴리스 체크리스트는
[게시](/l/ko/developers/extend/apps/operations/publishing)를 참조하세요.
## 앱을 만들었습니다 🎉
여섯 개의 장에서 SDK의 대부분 기능 영역을 사용했습니다:
* 데이터를 모델링하기 위한 **객체, 필드, 그리고 관계**
* **AI 도구**, **워크플로 작업**, **HTTP 라우트**로 노출되는 **로직 함수**
* UI를 위한 **뷰, 내비게이션, 커맨드, 프런트 컴포넌트**
* 자연어 생성을 위한 **에이전트 + 스킬**
* **마켓플레이스 메타데이터**와 게시 플로우
완성된 앱은
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator)에 있습니다.
## 다음 단계
<CardGroup cols={2}>
<Card title="데이터 참고" icon="database" href="/l/ko/developers/extend/apps/data/overview">
모든 필드 타입, 관계, 인덱스 옵션.
</Card>
<Card title="로직 참고" icon="bolt" href="/l/ko/developers/extend/apps/logic/overview">
Cron 및 데이터베이스 이벤트 트리거, 키-값 저장소, OAuth 연결.
</Card>
<Card title="레이아웃 참고" icon="table-columns" href="/l/ko/developers/extend/apps/layout/overview">
페이지 레이아웃, 대시보드 위젯, 그 외 다양한 UI 영역.
</Card>
<Card title="작업" icon="rocket" href="/l/ko/developers/extend/apps/operations/overview">
CLI, 테스트, 리모트, CI.
</Card>
</CardGroup>

View File

@@ -0,0 +1,147 @@
---
title: API
description: REST 또는 GraphQL을 사용해 프로그래밍 방식으로 CRM 데이터를 쿼리하고 수정하세요.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
Twenty는 개발자 친화적으로 설계되어 있으며, 맞춤형 데이터 모델에 적합한 강력한 API를 제공합니다. 우리는 여러 통합 요구에 맞는 네 가지 고유한 API 유형을 제공합니다.
## 개발자 우선 접근 방식
Twenty는 귀하의 데이터 모델에 맞는 API를 특별히 생성합니다:
* **긴 ID가 필요하지 않습니다**: 객체 및 필드 이름을 직접 엔드포인트에 사용합니다.
* **표준 및 사용자 정의 객체가 동등하게 처리됩니다**: 내장된 객체와 동일한 API 처리를 사용자 정의 객체에도 제공합니다.
* **전용 엔드포인트**: 각 객체와 필드에 자체 API 엔드포인트가 할당됩니다.
* **맞춤형 문서**: 작업 공간의 데이터 모델에 맞게 특별히 생성됩니다.
<Note>
맞춤형 API 문서는 API 키 생성 후 **설정 → API 및 웹훅**에서 확인할 수 있습니다. Twenty가 사용자 지정 데이터 모델에 맞는 API를 생성하므로, 문서는 귀하의 워크스페이스에 고유합니다.
</Note>
## 두 가지 API 유형
### Core API
`/rest/` 또는 `/graphql/`에서 접근할 수 있습니다.
실제 **레코드**(데이터)로 작업합니다:
* People, Companies, Opportunities 등을 생성, 조회, 업데이트, 삭제합니다.
* 데이터를 쿼리하고 필터링합니다
* 데이터 기록의 관계를 관리합니다.
### 메타데이터 API
`/rest/metadata/` 또는 `/metadata/`에서 접근할 수 있습니다.
**워크스페이스와 데이터 모델** 관리:
* 객체 및 필드를 생성, 수정 또는 삭제합니다.
* 작업 공간 설정을 구성합니다.
* 객체 간 관계를 정의합니다
## REST와 GraphQL
코어 및 메타데이터 API는 REST와 GraphQL 형식으로 제공됩니다:
| 형식 | 사용 가능한 작업 |
| ----------- | ------------------------------- |
| **REST** | CRUD, 배치 작업, 업서트 |
| **GraphQL** | 동일 + **배치 업서트**, 한 번의 호출로 관계 쿼리 |
필요에 따라 선택하세요 — 두 형식 모두 동일한 데이터에 접근합니다.
## API 엔드포인트
| 환경 | 기본 URL |
| ---------- | ------------------------- |
| **클라우드** | `https://api.twenty.com/` |
| **셀프 호스팅** | `https://{your-domain}/` |
## 인증
모든 API 요청에는 헤더에 API 키가 필요합니다:
```
Authorization: Bearer YOUR_API_KEY
```
### API 키 생성
1. **설정 → API 및 웹훅**으로 이동하세요
2. **+ 키 생성**을 클릭하세요
3. 구성:
* **이름**: 키를 설명하는 이름
* **만료 날짜**: 키가 만료되는 시점
4. **저장** 클릭
5. **즉시 복사** — 키는 한 번만 표시됩니다
<VimeoEmbed videoId="928786722" title="API 키 생성" />
<Warning>
API 키는 민감한 데이터에 대한 액세스를 부여합니다. 신뢰할 수 없는 서비스와 공유하지 마세요. 유출되었으면 즉시 비활성화하고 새 키를 생성하세요.
</Warning>
### API 키에 역할 할당
보안을 강화하려면 액세스를 제한할 특정 역할을 할당하세요:
1. **설정 → 구성원 → 역할**로 이동하세요
2. 할당할 역할을 클릭하세요
3. **배정** 탭 열기
4. **API 키**에서 **+ API 키에 할당**을 클릭하세요
5. API 키를 선택하세요
키가 해당 역할의 권한을 상속받습니다. 자세한 내용은 [권한](/l/ko/user-guide/permissions-access/capabilities/permissions)을 참조하세요.
### API 키 관리
**재생성**: 설정 → API 및 웹훅 → 키 클릭 → **재생성**
**삭제**: 설정 → API 및 웹훅 → 키 클릭 → **삭제**
## API 플레이그라운드
내장 플레이그라운드를 사용해 브라우저에서 직접 API를 테스트하세요 — **REST**와 **GraphQL** 모두에 대해 사용할 수 있습니다.
### 플레이그라운드에 액세스
1. **설정 → API 및 웹훅**으로 이동하세요
2. API 키 생성(필수)
3. 플레이그라운드를 열려면 **REST API** 또는 **GraphQL API**를 클릭하세요
### 얻을 수 있는 내용
* **대화형 문서**: 귀하의 특정 데이터 모델에 맞게 생성됩니다
* 워크스페이스를 대상으로 실제 API 호출을 실행합니다
* **스키마 탐색기**: 사용 가능한 객체, 필드 및 관계를 탐색합니다
* **요청 빌더**: 자동 완성을 통해 쿼리를 구성합니다
플레이그라운드는 사용자 지정 객체와 필드를 반영하므로 문서는 항상 워크스페이스에 대해 정확합니다.
## 배치 작업
REST와 GraphQL 모두 배치 작업을 지원합니다:
* **배치 크기**: 요청당 최대 60개의 기록
* **작업**: 여러 기록을 생성, 업데이트, 삭제합니다
**GraphQL 전용 기능:**
* **배치 업서트**: 한 번의 호출로 생성하거나 업데이트합니다
* 복수형 객체 이름을 사용하세요(예: `CreateCompanies` 대신 `CreateCompany`)
## 속도 제한
플랫폼 안정성을 위해 API 요청에는 제한이 적용됩니다:
| 제한 | 값 |
| --------- | ---------- |
| **요청** | 분당 100회 호출 |
| **배치 크기** | 호출당 기록 60개 |
<Tip>
처리량을 극대화하려면 배치 작업을 사용하세요 — 개별 요청 대신 단일 API 호출로 최대 60개의 기록을 처리할 수 있습니다.
</Tip>

View File

@@ -0,0 +1,112 @@
---
title: Webhooks
description: CRM에서 이벤트가 발생하면 실시간 알림을 받으세요.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
웹훅은 Twenty에서 이벤트가 발생하면 데이터를 실시간으로 귀하의 시스템으로 푸시합니다 — 폴링이 필요 없습니다. 이를 사용하여 외부 시스템을 동기화 상태로 유지하고, 자동화를 트리거하거나, 알림을 보낼 수 있습니다.
## Webhook 생성
1. **설정 → API 및 웹훅 → 웹훅**으로 이동
2. **+ Webhook 생성** 클릭
3. 웹훅 URL을 입력하세요(공개적으로 액세스 가능해야 함)
4. **저장** 클릭
웹훅이 즉시 활성화되어 알림 전송을 시작합니다.
<VimeoEmbed videoId="928786708" title="웹훅 생성" />
### 웹훅 관리
**편집**: 웹훅을 클릭 → URL 업데이트 → **저장**
**삭제**: 웹훅을 클릭 → **삭제** → 확인
## 이벤트
Twenty는 다음 이벤트 유형에 대해 웹훅을 전송합니다:
| 이벤트 | 예시 |
| ------------- | ---------------------------------------------------------- |
| **레코드 생성됨** | `person.created`, `company.created`, `note.created` |
| **레코드 업데이트됨** | `person.updated`, `company.updated`, `opportunity.updated` |
| **레코드 삭제됨** | `person.deleted`, `company.deleted` |
모든 이벤트 유형은 귀하의 웹훅 URL로 전송됩니다. 이벤트 필터링은 향후 릴리스에서 추가될 수 있습니다.
## 페이로드 형식
각 웹훅은 JSON 본문을 포함한 HTTP POST를 전송합니다:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| 필드 | 설명 |
| ------- | -------------------------------- |
| `이벤트` | 무슨 일이 발생했는지(예: `person.created`) |
| `데이터` | 생성/업데이트/삭제된 전체 레코드 |
| `타임스탬프` | 이벤트가 발생한 시각(UTC) |
<Note>
수신을 확인하기 위해 **2xx HTTP 상태**(200-299)로 응답하세요. 2xx가 아닌 응답은 전달 실패로 기록됩니다.
</Note>
## 웹훅 검증
Twenty는 보안을 위해 각 웹훅 요청에 서명합니다. 요청의 진위를 보장하기 위해 서명을 검증하세요.
### 헤더
| 헤더 | 설명 |
| ---------------------------- | -------------- |
| `X-Twenty-Webhook-Signature` | HMAC SHA256 서명 |
| `X-Twenty-Webhook-Timestamp` | 요청 타임스탬프 |
### 검증 단계
1. `X-Twenty-Webhook-Timestamp`에서 타임스탬프를 가져옵니다
2. 다음 문자열을 생성합니다: `{timestamp}:{JSON payload}`
3. 웹훅 비밀을 사용하여 HMAC SHA256을 계산합니다
4. `X-Twenty-Webhook-Signature`와 비교합니다
### 예시(Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const isValid = expectedSignature === req.headers["x-twenty-webhook-signature"];
```
## 웹훅 vs 워크플로
| 방법 | 방향 | 사용 사례 |
| ------------------ | --- | ---------------------------------- |
| **웹훅** | OUT | 레코드 변경 사항을 외부 시스템에 자동으로 알립니다 |
| **워크플로 + HTTP 요청** | OUT | 사용자 지정 로직(필터, 변환)으로 데이터를 외부로 전송합니다 |
| **워크플로 웹훅 트리거** | IN | 외부 시스템에서 Twenty로 데이터를 수신합니다 |
외부 데이터를 수신하려면 [웹훅 트리거 설정](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger)을 참조하세요.

View File

@@ -0,0 +1,32 @@
---
title: 확장
description: API, 웹훅 및 맞춤형 앱으로 Twenty의 기능을 확장하세요.
---
<Frame>
<img src="/images/user-guide/integrations/plug.png" alt="AI" />
</Frame>
## 개요
Twenty는 확장 가능하도록 설계되었습니다. 당사의 API, 웹훅 및 앱 프레임워크를 사용하여 기존 도구와 통합하고 맞춤형 기능을 구축하세요.
## 가능한 작업
* **API**: REST 또는 GraphQL을 사용해 프로그래밍 방식으로 CRM 데이터를 쿼리하고 수정하세요
* **웹훅**: Twenty에서 이벤트가 발생하면 실시간 알림을 받으세요
* **앱**: Twenty의 기능을 확장하는 맞춤형 앱을 구축하세요
## 시작하기
<CardGroup cols={2}>
<Card title="API" icon="code" href="/l/ko/developers/extend/api">
프로그래밍 방식으로 Twenty에 연결하세요
</Card>
<Card title="웹훅" icon="bell" href="/l/ko/developers/extend/webhooks">
이벤트에 대한 실시간 알림을 받으세요
</Card>
<Card title="앱" icon="puzzle-piece" href="/l/ko/developers/extend/apps/getting-started/quick-start">
코드로 사용자 지정을 구축하세요
</Card>
</CardGroup>

View File

@@ -0,0 +1,189 @@
---
title: OAuth
icon: key
description: 서버 간 액세스를 위한 PKCE가 포함된 권한 부여 코드 플로우와 클라이언트 자격 증명.
---
Twenty는 사용자 대상 앱에는 권한 부여 코드 + PKCE를, 서버 간 액세스에는 클라이언트 자격 증명을 사용하여 OAuth 2.0을 구현합니다. 클라이언트는 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)에 따라 동적으로 등록되며 — 대시보드에서 수동 설정은 필요하지 않습니다.
## OAuth 사용 시기
| 시나리오 | 인증 방식 |
| ------------------- | ---------------------------------------------------------------------------- |
| 내부 스크립트, 자동화 | [API 키](/l/ko/developers/extend/api#authentication) |
| 사용자를 대신하여 동작하는 외부 앱 | **OAuth — 권한 부여 코드** |
| 서버 간, 사용자 컨텍스트 없음 | **OAuth — 클라이언트 자격 증명** |
| UI 확장이 포함된 Twenty 앱 | [앱](/l/ko/developers/extend/apps/getting-started/quick-start) (OAuth는 자동으로 처리됩니다) |
## 클라이언트 등록
Twenty는 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)에 따라 **동적 클라이언트 등록**을 지원합니다. 수동 설정이 필요 없습니다 — 프로그래밍 방식으로 등록하세요:
```bash
POST /oauth/register
Content-Type: application/json
{
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_post"
}
```
**응답:**
```json
{
"client_id": "abc123",
"client_secret": "secret456",
"client_name": "My Integration",
"redirect_uris": ["https://myapp.com/callback"]
}
```
<Warning>
`client_secret`을 안전하게 저장하세요 — 나중에 다시 조회할 수 없습니다.
</Warning>
## 범위
| 범위 | 액세스 |
| ----- | ------------------------------------ |
| `api` | Core 및 Metadata API에 대한 전체 읽기/쓰기 액세스 |
| `프로필` | 인증된 사용자의 프로필 정보 읽기 |
범위를 공백으로 구분된 문자열로 요청: `scope=api profile`
## 권한 부여 코드 플로우
앱이 Twenty 사용자를 대신해 동작할 때 이 플로우를 사용하세요.
### 1. 사용자를 권한 부여 화면으로 리디렉션
```
GET /oauth/authorize?
client_id=YOUR_CLIENT_ID&
response_type=code&
redirect_uri=https://myapp.com/callback&
scope=api&
state=random_state_value&
code_challenge=CHALLENGE&
code_challenge_method=S256
```
| 매개변수 | 필수 | 설명 |
| ----------------------- | --- | ------------------------------------------ |
| `client_id` | 예 | 등록된 클라이언트 ID |
| `response_type` | 예 | 반드시 `code`여야 합니다 |
| `redirect_uri` | 예 | 등록된 리디렉션 URI와 일치해야 합니다 |
| `scope` | 아니요 | 공백으로 구분된 범위(기본값은 `api`) |
| `상태` | 권장 | CSRF 공격을 방지하기 위한 랜덤 문자열 |
| `code_challenge` | 권장 | PKCE 챌린지(검증자 값의 SHA-256 해시, base64url 인코딩) |
| `code_challenge_method` | 권장 | PKCE를 사용할 때는 `S256`이어야 합니다 |
사용자는 동의 화면을 보고 액세스를 승인하거나 거부합니다.
### 2. 콜백 처리
권한 부여 후, Twenty는 귀하의 `redirect_uri`로 다시 리디렉션합니다:
```
https://myapp.com/callback?code=AUTH_CODE&state=random_state_value
```
`state`가 보낸 값과 일치하는지 확인하세요.
### 3. 코드를 토큰으로 교환
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://myapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
code_verifier=YOUR_PKCE_VERIFIER
```
**응답:**
```json
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBh..."
}
```
### 4. 액세스 토큰 사용
```bash
GET /rest/companies
Authorization: Bearer ACCESS_TOKEN
```
### 5. 만료 시 갱신
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=YOUR_REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET
```
## 클라이언트 자격 증명 플로우
사용자 상호작용이 없는 서버 간 통합의 경우:
```bash
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
scope=api
```
반환된 토큰은 특정 사용자에 연결되지 않고 워크스페이스 수준의 액세스를 가집니다.
## 서버 디스커버리
Twenty는 표준 디스커버리 엔드포인트에서 OAuth 구성을 게시합니다:
```
GET /.well-known/oauth-authorization-server
```
이 엔드포인트는 모든 엔드포인트, 지원되는 그랜트 타입, 범위 및 기능을 반환하며 — 범용 OAuth 클라이언트를 구축하는 데 유용합니다.
## API 엔드포인트 요약
| 엔드포인트 | 목적 |
| ----------------------------------------- | -------------- |
| `/.well-known/oauth-authorization-server` | 서버 메타데이터 디스커버리 |
| `/oauth/register` | 동적 클라이언트 등록 |
| `/oauth/authorize` | 사용자 권한 부여 |
| `/oauth/token` | 토큰 교환 및 갱신 |
| 환경 | 기본 URL |
| ---------- | ------------------------ |
| **클라우드** | `https://api.twenty.com` |
| **셀프 호스팅** | `https://{your-domain}` |
## OAuth 대 API 키
| | API 키 | OAuth |
| ------------- | ------------- | ---------------- |
| **설정** | 설정에서 생성 | 클라이언트 등록, 플로우 구현 |
| **사용자 컨텍스트** | 없음(워크스페이스 수준) | 특정 사용자의 권한 |
| **적합한 용도** | 스크립트, 내부 도구 | 외부 앱, 다중 사용자 통합 |
| **토큰 로테이션** | 수동 | 리프레시 토큰으로 자동 |
| **범위 지정 액세스** | 전체 API 액세스 | 범위를 통해 세분화된 액세스 |

View File

@@ -0,0 +1,117 @@
---
title: 웹훅
icon: satellite-dish
description: 레코드가 변경될 때 알림을 받으세요 — 생성, 업데이트, 삭제마다 귀하의 엔드포인트로 HTTP POST를 전송합니다.
---
import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
레코드가 생성, 업데이트 또는 삭제될 때마다 Twenty가 귀하의 URL로 HTTP POST를 전송합니다. 사용자 지정 오브젝트를 포함한 모든 오브젝트 유형이 지원됩니다.
## 웹훅 생성
1. **설정 → API 및 웹훅 → 웹훅**으로 이동
2. **+ 웹훅 생성** 클릭
3. 웹훅 URL을 입력하세요(공개적으로 액세스 가능해야 함)
4. **저장**을 클릭합니다
웹훅이 즉시 활성화되어 알림 전송을 시작합니다.
<VimeoEmbed videoId="928786708" title="웹훅 생성" />
### 웹훅 관리
**편집**: 웹훅을 클릭 → URL 업데이트 → **저장**
**삭제**: 웹훅을 클릭 → **삭제** → 확인
## 이벤트
Twenty는 다음 이벤트 유형에 대해 웹훅을 전송합니다:
| 이벤트 | 예시 |
| ------------- | ---------------------------------------------------------- |
| **레코드 생성됨** | `person.created`, `company.created`, `note.created` |
| **레코드 업데이트됨** | `person.updated`, `company.updated`, `opportunity.updated` |
| **레코드 삭제됨** | `person.deleted`, `company.deleted` |
모든 이벤트 유형은 귀하의 웹훅 URL로 전송됩니다. 이벤트 필터링은 향후 릴리스에서 추가될 수 있습니다.
## 페이로드 형식
각 웹훅은 JSON 본문을 포함한 HTTP POST를 전송합니다:
```json
{
"event": "person.created",
"data": {
"id": "abc12345",
"firstName": "Alice",
"lastName": "Doe",
"email": "alice@example.com",
"createdAt": "2025-02-10T15:30:45Z",
"createdBy": "user_123"
},
"timestamp": "2025-02-10T15:30:50Z"
}
```
| 필드 | 설명 |
| ----------- | -------------------------------- |
| `event` | 무슨 일이 발생했는지(예: `person.created`) |
| `data` | 생성/업데이트/삭제된 전체 레코드 |
| `timestamp` | 이벤트가 발생한 시각(UTC) |
<Note>
수신을 확인하기 위해 **2xx HTTP 상태**(200-299)로 응답하세요. 2xx가 아닌 응답은 전달 실패로 기록됩니다.
</Note>
## 웹훅 검증
Twenty는 보안을 위해 각 웹훅 요청에 서명합니다. 요청의 진위를 보장하기 위해 서명을 검증하세요.
### 헤더
| 헤더 | 설명 |
| ---------------------------- | -------------- |
| `X-Twenty-Webhook-Signature` | HMAC SHA256 서명 |
| `X-Twenty-Webhook-Timestamp` | 요청 타임스탬프 |
### 검증 단계
1. `X-Twenty-Webhook-Timestamp`에서 타임스탬프를 가져옵니다
2. 다음 문자열을 생성합니다: `{timestamp}:{JSON payload}`
3. 웹훅 비밀을 사용하여 HMAC SHA256을 계산합니다
4. `X-Twenty-Webhook-Signature`와 비교합니다
### 예시(Node.js)
```javascript
const crypto = require("crypto");
const timestamp = req.headers["x-twenty-webhook-timestamp"];
const payload = JSON.stringify(req.body);
const secret = "your-webhook-secret";
const stringToSign = `${timestamp}:${payload}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(stringToSign)
.digest("hex");
const receivedSignature = req.headers["x-twenty-webhook-signature"];
const isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(receivedSignature, "hex")
);
```
## 웹훅 vs 워크플로
| 방법 | 방향 | 사용 사례 |
| ------------------ | --- | ---------------------------------- |
| **웹훅** | OUT | 레코드 변경 사항을 외부 시스템에 자동으로 알립니다 |
| **워크플로 + HTTP 요청** | OUT | 사용자 지정 로직(필터, 변환)으로 데이터를 외부로 전송합니다 |
| **워크플로 웹훅 트리거** | IN | 외부 시스템에서 Twenty로 데이터를 수신합니다 |
외부 데이터를 수신하려면 [웹훅 트리거 설정](/l/ko/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger)을 참조하세요.