---
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`로 전송합니다.

| 방법                                | 설명                                    |
| --------------------------------- | ------------------------------------- |
| `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                                |
| `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)`                      | 진행률 표시기 업데이트  |

호스트 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,
});
```

### 여러 기록과의 작업

여러 개의 선택된 기록을 처리하려면 `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)을 참조하세요.

## 스타일링

프런트 컴포넌트는 여러 스타일링 방식을 지원합니다. 다음과 같은 방식을 사용할 수 있습니다:

* **인라인 스타일** — `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) 주세요.

### 레이아웃과 측정

아직 어떤 요소도 스스로를 측정할 수 없습니다.

| API                                                           | 무슨 일이 일어나는지                                                               |
| ------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `getBoundingClientRect()`, `getClientRects()`                 | 예외 발생                                                                     |
| `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | 조용히 `undefined`가 되어, `width ?? 0`은 `0`을 반환하고, `width > 600`은 항상 false입니다. |
| `ResizeObserver`, `IntersectionObserver`                      | `ReferenceError` 발생(단, `typeof` 가드는 동작함)                                  |
| `window.matchMedia()`, `window.getComputedStyle()`            | 예외 발생                                                                     |
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`        | 조용히 `undefined`가 됨                                                        |
| `new MutationObserver(fn)`                                    | 생성은 되지만 이후 `.observe()`에서 예외가 발생합니다.                                      |

따라서 recharts의 `ResponsiveContainer`, Floating UI / Popper, 리스트 가상화, 드래그로 크기 조절 등은 아직 동작하지 않습니다. 대신 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.getElementById()`, `getElementsByClassName()`, `createTreeWalker()`                               | 예외 발생                                     | 정상 동작하는 `querySelector()` / `querySelectorAll()`을 사용                |
| `document.activeElement`                                                                                    | 항상 `undefined`                            | `onFocus` / `onBlur`로 포커스를 추적하세요.                                   |
| `\<canvas>`                                                                                | 아무것도 렌더링되지 않으며, 오류도 없습니다.                 | SVG를 사용하거나, 오프스크린에서 그린 뒤 `<img src={dataUrl}>`로 표시하세요.              |
| `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으로 전달합니다(`href`는 `\<a>`, `src`/`alt`는 `<img>`, `value`/`placeholder`/`disabled`는 `<input>` 등). 또한 모든 요소에 공통으로 `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`, IndexedDB, 쿠키, Cache API 및 `BroadcastChannel`은 모두 사용할 수 없습니다. 컴포넌트가 불투명한 origin의 워커에서 실행되기 때문입니다. 상태를 영속화하려면 [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와 절대 매칭되지 않습니다.

### 기타 제약 사항

* **파일 내용.** `<input type="file">`은 핸들러에 바이트가 아닌 파일 메타데이터만 제공하므로, 현재는 `FileReader` 사용과 업로드가 불가능합니다.
* **드래그 앤 드롭 페이로드.** 드래그 이벤트는 발생하지만, `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`도 지원되지 않습니다.
