로직 함수는 단기간 실행되는 샌드박스된 Node.js 프로세스에서 동작하며, 한 번 실행이 끝나면 메모리에 남아 있는 것은 아무것도 유지되지 않습니다. 실행 사이에서 어떤 값을 기억해야 할 때(비용이 큰 API 응답을 캐시하거나, 증분 동기화를 위한 커서를 저장하거나, 작업을 디바운스하거나, 한 함수에서 다른 함수로 상태를 넘길 때)는 워크스페이스 데이터베이스에 상태를 저장하세요.
이를 위해 별도의 저장용 프리미티브가 필요하지는 않습니다. key 필드와 value 필드가 있는 작은 기술용 객체만 있으면, 워크스페이스 범위에서 내구성이 있는 키-값 저장소를 만들 수 있으며, 이미 레코드에 사용 중인 동일한 typed API client를 통해 조회할 수 있습니다.
┌─────────────────┐ set(key, value) ┌──────────────────────────┐
│ Logic function │ ───────────────────▶ │ "KV Store" object │
│ (your handler) │ ◀─────────────────── │ key (unique) │ value │
└─────────────────┘ get(key) └──────────────────────────┘
스토어 객체 정의하기
두 개의 필드가 있는 커스텀 객체를 선언합니다. key(고유한 TEXT)와 value(RAW_JSON으로, JSON으로 직렬화할 수 있는 어떤 페이로드든 저장할 수 있음)입니다. 전체 defineObject 레퍼런스는 Objects를 참고하세요.
src/objects/kv-store.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
export const KV_STORE_UNIVERSAL_IDENTIFIER =
'2f1c8a90-3b6d-4e2a-9c47-7d0e5a1b9f33';
export const KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER =
'4a7e2d11-9c83-4f60-b5a2-1e6c8d0f4b21';
export const KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER =
'8b3f6c02-5d19-47ae-9f31-2c4a7e0b6d58';
export default defineObject({
universalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER,
nameSingular: 'kvStore',
namePlural: 'kvStores',
labelSingular: 'KV Store',
labelPlural: 'KV Store',
description: 'Key-value storage for logic functions',
icon: 'IconDatabase',
fields: [
{
universalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
name: 'key',
type: FieldType.TEXT,
label: 'Key',
description: 'Unique lookup key',
icon: 'IconKey',
},
{
universalIdentifier: KV_STORE_VALUE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'value',
type: FieldType.RAW_JSON,
label: 'Value',
description: 'Stored JSON payload',
icon: 'IconJson',
},
],
});
키 고유성 강제하기
key에 고유 인덱스를 추가해서 동일한 키에 두 개의 행이 존재하지 못하도록 합니다. 이는 고유성을 위한 권장 프리미티브입니다. 자세한 내용은 Data → Unique indexes를 참고하세요.
src/indexes/kv-store-key.index.ts
import { defineIndex } from 'twenty-sdk/define';
import {
KV_STORE_UNIVERSAL_IDENTIFIER,
KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/kv-store.object';
export default defineIndex({
universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e14',
objectUniversalIdentifier: KV_STORE_UNIVERSAL_IDENTIFIER,
isUnique: true,
fields: [
{
universalIdentifier: 'c0d4e8f2-6a1b-4c93-8e57-3f9a2d0b7e15',
fieldUniversalIdentifier: KV_STORE_KEY_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
로직 함수에서 읽기 및 쓰기
객체를 몇 개의 작은 헬퍼 뒤에 래핑해서, 나머지 코드가 get, set, del 같은 키-값 API를 사용하는 것처럼 보이도록 만드세요. 이들은 워크스페이스 스키마에서 생성되며 kvStore 객체에 대해 완전히 타입이 지정된 CoreApiClient를 사용합니다.
src/logic-functions/handlers/kv-store.ts
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-sdk/utils';
const client = new CoreApiClient();
// Look up a single row by its key.
const findByKey = async (key: string) => {
const { kvStores } = await client.query({
kvStores: {
__args: { filter: { key: { eq: key } }, first: 1 },
edges: { node: { id: true, value: true } },
},
});
return kvStores.edges[0]?.node;
};
// Read a value. Returns undefined when the key is missing.
export const get = async <TValue>(key: string): Promise<TValue | undefined> => {
const row = await findByKey(key);
return isDefined(row) ? (row.value as TValue) : undefined;
};
// Write a value. Creates the row on first write, updates it afterwards (upsert).
export const set = async (key: string, value: unknown): Promise<void> => {
const existing = await findByKey(key);
if (isDefined(existing)) {
await client.mutation({
updateKvStore: {
__args: { id: existing.id, data: { value } },
id: true,
},
});
return;
}
await client.mutation({
createKvStore: {
__args: { data: { key, value } },
id: true,
},
});
};
// Delete a value. No-op when the key is missing.
export const del = async (key: string): Promise<void> => {
const existing = await findByKey(key);
if (isDefined(existing)) {
await client.mutation({
deleteKvStore: { __args: { id: existing.id }, id: true },
});
}
};
고유 인덱스는 중복을 방지하지만, 두 번의 실행이 동일한 새로운 키를 정확히 같은 시점에 쓰려고 하면 조회와 생성 사이에서 여전히 경쟁 상태가 발생할 수 있습니다. 생성이 고유성 제약 조건으로 실패하면 이를 “다른 누군가가 먼저 썼다”라고 간주하고, 예외를 처리해 다시 읽거나, 업데이트로 재시도하세요.
사용 예: 비용이 큰 호출 캐시하기
일반적인 사용 예는 느리거나 rate limit이 걸린 서드파티 응답을 캐시해서, 반복 실행 시 매번 비용을 지불하지 않고 재사용하도록 하는 것입니다.
src/logic-functions/getExchangeRate.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
import { get, set } from './handlers/kv-store';
const ONE_HOUR_MS = 60 * 60 * 1000;
type CachedRate = { rate: number; fetchedAt: number };
const handler = async (params: { from: string; to: string }) => {
const cacheKey = `exchange-rate:${params.from}:${params.to}`;
const cached = await 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 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. 전체 네임스페이스를 나열하거나 비우려면 key: { like: 'cache:%' }으로 필터링하세요.
- 만료(TTL). 이 스토어에는 내장된 만료 기능이 없습니다. 읽을 때 확인할 수 있도록
value 안에 타임스탬프를 저장(캐시 예제처럼)하거나, DATE_TIME 필드를 추가하고 cron-triggered function을 통해 주기적으로 오래된 행을 정리하세요.
- 무엇을 저장할지.
RAW_JSON은 숫자, 문자열, 배열, 객체 등 JSON으로 직렬화 가능한 어떤 값이든 저장할 수 있습니다. 엔트리는 작게 유지하세요. 이 스토어는 대용량 blob이나 파일이 아니라, 조정 및 캐싱용입니다. 파일의 경우 FILES 필드와 uploadFile을 사용하세요.
- 가시성 및 권한. 행은 다른 레코드와 마찬가지로 워크스페이스 데이터베이스에 저장되므로, API를 통해 조회할 수 있고 앱의 role을 그대로 따릅니다. 스토어를 기본 UI에서 숨기고 싶다면, navigation menu에 추가하지 마세요.
- 레코드 단위 스코핑. 전역 키 대신 레코드별 상태가 필요하신가요? 스토어 객체에서 대상 객체로 relation을 추가하고, id를 키에 인코딩하지 마세요.
이는 별도의 기능이 아니라 하나의 컨벤션일 뿐입니다. “KV Store”는 여러분이 정의하고 표준 API로 조회하는 일반 커스텀 객체입니다. 즉, 앱의 다른 데이터와 동일한 동기화, 권한, 도구의 이점을 모두 누릴 수 있습니다.