> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alforse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 아키텍처

> Alforse 스택에 대한 개발자용 안내: 두 애플리케이션, 백엔드 게이트웨이, 권한과 행 수준 보안, 데이터베이스, 아웃박스와 러너, 워크플로 및 CAD 엔진.

Alforse와 연동하거나 화면이 내부에서 무엇을 하는지 알고 싶은 개발자를 위해 작성되었습니다. 이 페이지는 먼저 하나의 쓰기가 브라우저에서 데이터베이스로 들어갔다가 다시 나오는 과정을 따라간 다음, 계층을 하나씩 살펴봅니다.

## 개요

Alforse는 하나의 제조 데이터 모델을 공유하는 두 애플리케이션으로 구성됩니다.

| 애플리케이션 | 호스트 | 용도 |
| - | - | - |
| **ERP** | `app.alforse.com` | 견적, 구매, 계획, 재고, 회계, 출하 같은 업무 측면을 운영합니다. |
| **MES** | `mes.app.alforse.com` | 보통 각 작업장의 태블릿에서 현장 작업을 수집하고 지시합니다. |

둘 뒤에는 하나의 백엔드가 있습니다.

### 이 페이지를 읽는 방법

[시스템 한눈에 보기](#the-system-at-a-glance)로 시작한 다음 [하나의 쓰기를 처음부터 끝까지 추적](#tracing-one-write-end-to-end)을 읽으십시오. 두 번째는 첫 번째를 전제로 합니다. 그 이후의 섹션은 독립적이며 어떤 순서로든 읽을 수 있습니다.

기술 용어는 페이지 끝의 [용어집](#glossary)에 있는 짧은 정의로 연결되며, [Postgres](#postgres)가 한 예입니다.

NOTE

**경로와 개수는 달라질 수 있습니다**

아래의 모든 파일 경로, 줄 번호, 개수는 작성 시점의 저장소를 반영합니다. 보장이 아니라 참고 지점으로 취급하십시오.

***

## 시스템 한눈에 보기

모든 것은 pnpm 워크스페이스와 [Turborepo](#turborepo)로 실행되는 하나의 [모노레포](#monorepo)에 있으며 두 계층으로 구성됩니다.

### 화면

두 React Router 애플리케이션 `apps/erp`와 `apps/mes`가 서버에서 React를 렌더링합니다. 둘이 공통으로 사용하는 코드는 `packages/web/*` 아래에 패키지로 묶여 있으며 `auth`, `database`, `api`, `jobs`, `planning`, `react`, `utils`, `workflows` 패키지가 포함됩니다.

### 백엔드

애플리케이션 호스트는 `apps/backend` 하나뿐입니다. API를 제공하는 것 외에 다음 네이티브 계층을 구성합니다.

| 폴더 | 내용 |
| - | - |
| `kernel/` | 권한, 신원, 승인, 이벤트, 레코드, 워크플로, 트랜잭션 포트처럼 모든 영역이 공유하는 계약. |
| `modules/` | 제품 간에 사용되는 마스터 데이터. 예: 단위, 제품, 주소가 있는 당사자, 문서 번호 부여, 인원. |
| `products/` | 열일곱 개의 제품 영역. `inventory-and-logistics`, `quality-management`, `source-to-pay`가 그중 셋입니다. |
| `packages/database/` | SQL 마이그레이션, 이를 적용하는 러너, Postgres 저장소. |

모든 데이터는 기록 시스템인 하나의 [Postgres](#postgres) 데이터베이스에 있습니다. 회사별로 행 수준 권한을 적용하며 백그라운드 작업에 입력을 공급하는 트랜잭션 아웃박스도 포함합니다. 각 부분은 [계층별 설명](#layer-by-layer)에서 설명합니다.

### 모든 것을 좌우하는 다섯 가지 특성

1. **화면은 데이터베이스에 직접 접근하지 않습니다.** ERP와 MES의 서버 코드는 `/api/web`에 마운트된 백엔드 게이트웨이와 통신합니다. 게이트웨이는 화면이 처음 작성된 방식과 같은 REST, RPC, 인증, 스토리지 프로토콜을 이해하며 각 호출을 `kernel/`, `modules/`, `products/`, `packages/database/` 중 하나에 있는 네이티브 소유자에게 전달합니다.
2. **데이터베이스가 테넌트를 분리합니다.** 행 수준 보안 덕분에 한 회사의 행은 다른 회사에서 접근할 수 없습니다. 방법은 [인증 및 권한 부여](#authentication-and-authorization)에서 설명합니다.
3. **관련 쓰기는 하나로 커밋됩니다.** 네이티브 소유자는 호출자의 작업 단위에 참여하며, 소유자가 발생시키는 이벤트는 바로 그 트랜잭션 안에서 아웃박스에 들어갑니다.
4. **별도의 작업 시스템은 없습니다.** 백엔드 프로세스 안의 러너가 예약 및 대기열 작업을 처리하고, 전달 워커가 알림과 웹훅을 보냅니다. 자세한 내용은 [쓰기 이후](#after-the-write-events-and-background-work)에 있습니다.
5. **CAD 처리도 백엔드에 있습니다.** 지오메트리 변환과 조립 계획은 모두 `/api/web/assembler` 뒤에서 이루어지며 이를 위해 따로 배포할 것은 없습니다.

***

## 하나의 쓰기를 처음부터 끝까지 추적

예시: 구매 담당자가 새 발주서를 저장합니다. 양식, 검증, 권한 확인, 데이터베이스 쓰기, 문서 번호 할당, 마지막으로 새로 고쳐진 화면까지 요청을 따라갑니다.

### 전체 여정을 하나의 다이어그램으로

```mermaid theme={null}
sequenceDiagram
  participant B as Browser
  participant E as ERP server (React Router)
  participant G as Backend gateway
  participant P as Postgres
  B->>E: POST form
  E->>E: validate with zod schema
  E->>G: requirePermissions(create: purchasing)
  G-->>E: permission decision
  E->>G: rpc get_next_sequence
  G-->>E: allocate PO000123
  E->>G: insert purchaseOrder + children
  G->>P: write rows and outbox row
  E-->>B: 302 redirect
  B->>E: GET the new order page
  E->>G: loader reads through the gateway
```

행이 저장되는 즉시 요청은 완료됩니다. 알림과 웹훅 같은 부수 효과는 나중에 아웃박스에서 가져와 처리됩니다.

### 코드로 보는 단계별 과정

#### 1. 버튼

`apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535`

```tsx theme={null}
<New label={t`Purchase Order`} to={path.to.newPurchaseOrder} />
```

이것은 제출 버튼이 아니라 양식으로 가는 링크입니다. UI에서 `permissions.can(...)`은 사용자가 할 수 없는 것을 숨기는 단순한 편의이며, 중요한 결정은 서버에서 이루어집니다. 링크는 `apps/erp/app/utils/path.ts`의 라우트 레지스트리인 `path.to.*`를 거치므로 애플리케이션 URL을 직접 입력하는 일이 없습니다.

#### 2. 양식

`apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116`

양식 컴포넌트는 `ValidatedForm`(`@alforse/web-form` 패키지)입니다. 내부적으로는 일반 HTML 양식이며, 그 위에 브라우저에서 오든 서버에서 오든 스키마의 오류를 표시합니다. 검증기와 기본값은 props로 받습니다.

#### 3. 스키마

`apps/erp/app/modules/purchasing/purchasing.models.ts:146`

규칙은 [zod](#zod) 객체인 `purchaseOrderValidator`에 있으며 런타임 검사와 그에 대응하는 TypeScript 타입을 모두 생성합니다. HTML 양식 값은 `zod-form-data`(`zfd`)가 정리합니다. 예를 들어 `zfd.text()`는 빈 필드의 `""`를 `undefined`로 매핑합니다. 브라우저와 서버가 이 하나의 스키마를 공유하므로 같은 규칙을 적용합니다.

#### 4. 라우트

`apps/erp/app/routes/x+/purchase-order+/new.tsx:30`

```ts theme={null}
export async function action({ request }: ActionFunctionArgs) {
  assertIsPost(request);
  const { client, companyId, companyGroupId, userId } =
    await requirePermissions(request, {
      create: "purchasing",
      bypassRls: true
    });

  const formData = await request.formData();
  const validation = await validator(newPurchaseOrderValidator).validate(
    formData
  );
  if (validation.error) return validationError(validation.error);
  /* ... check that the supplier and location belong to the company ... */
  const result = await insertPurchaseOrder(client, {
    ...validation.data,
    companyId,
    companyGroupId,
    createdBy: userId,
    customFields: setCustomFields(formData)
  });
  /* ... flash an error and redirect, or redirect to the new order ... */
}
```

Alforse의 모든 쓰기는 이 순서를 공유합니다.

> **1. 메서드 확인, 2. 권한 확인, 3. 검증, 4. 서비스 호출, 5. 리디렉션.**

`apps/erp/app/routes/` 아래의 라우트 파일은 이 흐름과 관련된 세 가지를 내보낼 수 있습니다.

| 내보내기 | 역할 | 실행 위치 |
| - | - | - |
| [`loader`](#loader) | 페이지에 필요한 것을 가져옴 | 서버에서만 |
| [`action`](#action) | 제출된 양식을 처리 | 서버에서만 |
| `export default` | 페이지의 React 컴포넌트 | 서버, 그다음 브라우저 |

`loader`와 `action` 모두 브라우저 번들에 포함되지 않으므로 서버 전용 코드를 사용할 수 있습니다.

#### 5. 서비스 함수

`apps/erp/app/modules/purchasing/purchasing.service.ts:1663` (`insertPurchaseOrder`)

데이터 접근은 라우트에서 하지 않고 서비스 함수에 위임됩니다. 각 함수는 클라이언트를 첫 번째 매개변수로 받고 `supabase-js`에서 알려진 `{ data, error }` 형태로 응답합니다. 이 클라이언트는 `getAlforseServiceRole()`(`packages/web/auth/src/lib/supabase/client.server.ts:13`)이 만듭니다. 레거시 이름과 달리 이 요청에 로그인한 사용자로서 `/api/web`의 네이티브 게이트웨이와 통신합니다. 클라이언트는 하나의 요청에 속하며 다른 요청에서는 사용할 수 없습니다.

```ts theme={null}
const seq = await client.rpc("get_next_sequence", {
  sequence_name: "purchaseOrder",
  company_id: input.companyId
});

const order = await client
  .from("purchaseOrder")
  .insert({
    purchaseOrderId,
    supplierId: input.supplierId,
    /* ... */
  });
```

서비스는 계속해서 주문의 배송 및 지불 행을 저장합니다. 이러한 클라이언트 호출은 각각 별도의 요청으로 게이트웨이에 전달됩니다. 일련의 쓰기가 하나로 성공하거나 실패해야 하는 경우, 게이트웨이는 이를 하나의 데이터베이스 트랜잭션 안에서 전체 작업을 수행하는 네이티브 소유자로 라우팅합니다([데이터 쓰기](#writing-data) 참조).

#### 6. 데이터베이스의 번호 부여

`client.rpc("get_next_sequence", ...)` 호출은 번호 부여 모듈 `modules/master-document-numbering/`가 응답하는 [RPC](#rpc)입니다. 모듈은 제출과 같은 트랜잭션 안에서 다음 카운터 값을 확보하고 회사의 접두사, 자릿수, 날짜 토큰을 적용합니다. 형식은 [채번 규칙](https://docs.alforse.com/ko/docs/reference/sequences.md)에서 설명합니다.

#### 7. 화면으로 돌아가기

마무리로 액션이 `throw redirect(...)`를 실행합니다. 그러면 React Router가 목적지를 로드합니다. 해당 `loader`가 게이트웨이를 통해 최신 상태를 가져오고 페이지가 렌더링됩니다. 별도로 GET이 아닌 모든 제출 후 React Router는 현재 페이지의 로더를 스스로 다시 실행합니다.

***

## 계층별 설명

### 브라우저와 라우트 파일

두 화면 애플리케이션은 프레임워크 모드의 **React Router v7**로 구축되어 서버에서 React를 렌더링하므로 하나의 라우트 파일이 데이터 로딩과 UI를 함께 담을 수 있습니다. Remix와 Next.js는 ERP나 MES 화면에 사용하지 않습니다. 빌드는 [Vite](#vite)가, 스타일링은 Tailwind v4가 담당합니다. `apps/backend`만 다르며 Next.js 애플리케이션 호스트입니다.

#### 라우트는 파일입니다

1,600개가 넘는 파일이 있는 `apps/erp/app/routes/`는 URL 공간에 곧바로 매핑됩니다. `remix-flat-routes`(`apps/erp/app/routes.ts`에서 구성) 덕분에 폴더 구조가 곧 URL 구조입니다.

| 패턴 | 예 | URL에 미치는 영향 |
| - | - | - |
| `x+/`처럼 `+/`로 끝나는 폴더 | `x+/purchase-order+/new.tsx` | 폴더와 URL 세그먼트가 동시에: `/x/purchase-order/new` |
| `_public+/` | `_public+/` | 없음. 폴더는 파일을 묶기만 하며 세그먼트를 **추가하지 않음** |
| `_layout.tsx` | `_layout.tsx` | 없음. 폴더 안의 모든 라우트를 감쌈 |
| `$` 접두사 | `$orderId.tsx` | 매개변수: `/:orderId` |
| 이름 안의 `.` | `dimensions.new.tsx` | `/`가 됨: `dimensions/new` |
| `_index.tsx` | `_index.tsx` | 폴더 자체의 URL에 있는 페이지 |
| `.server.ts` 접미사 | `*.server.ts` | 없음. 라우터가 무시하며 헬퍼는 브라우저로 전송되지 않음 |

최상위 폴더는 대상에 따라 앱을 나눕니다.

| 폴더 | 내용 |
| - | - |
| `x+` | 인증된 애플리케이션 라우트 |
| `api+` | JSON 엔드포인트 |
| `_public+` | 인증 라우트 |
| `share+` | 토큰으로 인증되는 공개 링크 |
| `select-company+`, `onboarding+` | 회사 선택과 최초 실행 설정 |

#### 데이터 읽기

페이지는 서버의 `loader`에서 데이터를 가져오며, 로더는 서비스 함수를 호출하고 JSON을 반환합니다. 컴포넌트는 `useLoaderData<typeof loader>()`로 이를 받습니다. 모든 페이지에 필요한 데이터(사용자, 회사, 권한, 기능 플래그)는 `apps/erp/app/routes/x+/_layout.tsx`가 한 번 로드합니다. 컴포넌트는 `apps/erp/app/hooks/`의 훅, 예를 들어 `useUser()`, `usePermissions()`, `useCompanySettings()`를 통해 접근합니다.

#### UI에서 데이터 쓰기

양식은 [추적한 쓰기](#tracing-one-write-end-to-end)와 똑같이 라우트의 `action`으로 전송됩니다. 행 순서 변경, 인라인 편집, 삭제처럼 사용자가 페이지에 머물러야 하는 경우에는 대신 `useFetcher()`로 제출하며, 페이지는 이후에도 여전히 다시 검증됩니다.

#### 공유 UI

새 컴포넌트를 작성하기 전에 다음 위치를 살펴보십시오.

| 위치 | 찾을 수 있는 것 |
| - | - |
| `packages/web/react/` | 디자인 시스템인 `@alforse/web-react`: 버튼, 모달, 콤보박스, 메뉴, 레이아웃 기본 요소. |
| `apps/erp/app/components/` | 애플리케이션 전용 컴포넌트이며 그중 모든 목록 화면이 사용하는 `components/Table/Table.tsx`가 가장 중요합니다. |

#### 브라우저의 상태

브라우저는 상태를 거의 보유하지 않습니다. 어디서나 사용되는 참조 목록(품목, 고객, 공급업체, 인원)은 `apps/erp/app/stores/` 아래의 스토어에 보관되며 `RealtimeDataProvider`가 새로 고칩니다. 일회성 UI 상태는 [zustand](#zustand)에 둡니다. 사용자와 그 권한의 출처는 항상 라우트 로더입니다.

#### 번역

텍스트는 [Lingui](#lingui)로 번역합니다. 문자열을 `` t`...` `` 또는 `<Trans>`로 감싼 다음 `pnpm lingui:compile`을 실행하여 카탈로그를 빌드합니다.

#### MES

MES는 스택을 공유하지만 더 평평한 형태를 유지합니다. `modules/` 폴더가 없으며 서비스는 `apps/mes/app/services/`에 있고, 약 100개의 라우트가 한 단계에 놓여 있으며, UI는 태블릿을 장갑 낀 손으로 사용할 수 있는 크기로 만들어졌습니다.

### 인증 및 권한 부여

인증과 권한 부여를 모두 처리하는 `requirePermissions`를 건너뛰는 서버 측 진입점은 없습니다.

```ts theme={null}
const { client, companyId, userId } = await requirePermissions(request, {
  create: "purchasing",
});
```

정의는 `packages/web/auth/src/services/auth.server.ts:152`에 있습니다. 호출은 네 단계를 거칩니다.

1. **호출자 식별.** 브라우저 요청은 네이티브 세션 쿠키를 전달하고 공개 API 요청은 `alforse-key` 헤더를 전달합니다. 키의 경우 네이티브 키 소유자가 라우트에 필요한 권한에 대해 키를 검증하고 속도 제한을 적용합니다. 자세한 내용은 [API 키](https://docs.alforse.com/ko/docs/building/api-keys.md)에 있습니다.
2. **결정 요청.** `view`, `create`, `update`, `delete`와 선택적으로 `role` 중에서 가져온 라우트의 필요 권한이 백엔드의 네이티브 권한 부여 소유자에게 전송됩니다. 선택한 회사에서 사용자의 권한 부여를 바탕으로 결정합니다. 권한 부여는 세 가지 출처, 즉 직원 유형, 사용자의 그룹, 직접 배정에서 합산됩니다.
3. **허용되지 않는 것은 거부.** 유효한 세션이 없으면(없거나 만료됨) 세션이 폐기되고 사용자는 로그인으로 이동합니다. 권한이 없으면 **접근 거부** 메시지가 있는 리디렉션이 발생합니다. 결정이 세션이 가진 것과 다른 사용자나 회사에 대해 돌아오면 요청은 일관성 없는 신원으로 거부됩니다.
4. **컨텍스트 반환.** 성공하면 라우트는 이 요청으로 범위가 지정된 클라이언트와 `companyId`, `companyGroupId`, `userId`를 받습니다.

#### 멀티 테넌시와 행 수준 보안

많은 회사가 하나의 데이터베이스를 공유하므로 Alforse는 **[멀티 테넌트](#multi-tenancy)**입니다. 활성 회사는 세션이 결정하며 URL은 아무 역할도 하지 않습니다.

테넌트 테이블에는 **[행 수준 보안(RLS)](#rls)**이 켜져 있고 강제됩니다. 기준선 마이그레이션 `packages/database/migrations/0001_baseline.sql`이 `ALTER TABLE ... FORCE ROW LEVEL SECURITY`로 이를 수행합니다. 각 트랜잭션이 시작될 때 현재 회사와 행위자가 트랜잭션 로컬 설정으로 저장되며 모든 정책이 이 값에 대해 행을 확인합니다. 다른 회사의 레코드를 요청하면 결과는 비어 있습니다.

#### 플랫폼과 작업 공간은 분리되어 있습니다

작업 공간 수명 주기, 플랜, 권한은 각 고객의 작업 공간과 분리된 보안 도메인인 플랫폼 제어 플레인이 관리합니다. 플랫폼 측의 권한은 고객의 업무 레코드를 읽을 암묵적인 권리를 부여하지 않습니다.

#### 다른 종류의 사용자

포털을 사용하는 고객과 공급업체는 더 좁은 권한을 받으며 모든 라우트는 `{ role: "employee" }`를 요구할 수 있습니다. `share+/` 아래의 라우트는 로그인 없이 작동합니다. 접근은 URL에 담긴 추측할 수 없는 토큰에 달려 있습니다.

### 데이터 쓰기

ERP와 MES의 화면은 원래 작성된 데이터 접근 방식을 유지했습니다. 백엔드는 데이터베이스 연결을 내주는 대신 각 방식을 네이티브 소유자로 변환합니다. 네 가지 경로가 있습니다.

#### 1. 게이트웨이 클라이언트

| 호출 | 엔드포인트 | 해석되는 대상 |
| - | - | - |
| `from(table).select` | `/api/web/rest/v1/[relation]` | 네이티브 읽기 관계 |
| `from(table).insert/update/delete` | `/api/web/rest/v1/[relation]` | 데이터를 소유하는 제품 또는 모듈. 명령을 검증한 다음 `packages/database/src/`의 저장소를 통해 기록합니다. |
| `rpc(name, args)` | `/api/web/rest/v1/rpc/[name]` | 해당 RPC에 매핑된 네이티브 소유자 |

#### 2. 이름이 있는 네이티브 작업

여러 화면이 `packages/web/database/src/client.ts`의 `getPostgresClient`를 통해 [Kysely](#kysely)로 쿼리를 기술합니다. 빌더는 SQL을 내보내지 않습니다. 쿼리의 모양을 기록하고 `packages/web/database/native-operations/`의 레지스트리에서 정확히 하나의 이름 있는 작업과 일치시키며, 이 레지스트리에는 `purchasing.ts`, `inventory.ts`, `production-*`, `quality.ts` 같은 파일이 있습니다. 어느 것과도 일치하지 않는 쿼리는 거부되며 거부 메시지가 해당 쿼리를 지명합니다. 드래그하여 순서 변경(`apps/erp/app/modules/shared/sort-order.ts`) 같은 다중 행 쓰기는 이를 통해 전부 아니면 전무로 처리됩니다.

#### 3. 네이티브 소유자와 작업 단위

상당한 업무 쓰기는 `products/*`와 `packages/database/src/`의 클래스로 구현됩니다. 출하 또는 송장 전기, 자재 불출, 생산 수량 완료가 예입니다. 각각은 **작업 단위** 안에서 실행되며, 이는 행위자와 회사가 설정된, 전체 업무 트랜잭션을 위한 하나의 Postgres 연결을 뜻합니다. 본문에서 어떤 실패가 일어나면 전부 롤백되며, 쓰기 성공 후에 이어지도록 만든 훅은 커밋이 이루어진 후에만 실행됩니다.

#### 4. RPC

가장 좁은 경로는 `client.rpc("name", args)`로 호출하는 이름 있는 함수이며, [추적한 쓰기](#tracing-one-write-end-to-end)의 `get_next_sequence`가 그 예입니다. 각 RPC에는 매핑된 네이티브 소유자가 있거나 매핑되지 않은 RPC의 명시적 목록에 나타납니다.

계획은 별도의 영역입니다. [MRP](#mrp)와 생산 일정 수립은 계획 소유자와 함께 `packages/web/planning`에 구현되어 있습니다.

### 데이터베이스

데이터베이스는 PostgreSQL이며 그 앞에는 아무것도 없습니다. PostgREST도, Supabase 프로젝트도, 엣지 함수도 없습니다. 백엔드가 데이터베이스의 유일한 클라이언트이며 전용 역할, 즉 RLS의 제약을 받는 런타임 역할과 일정 수립기 및 전달 워커를 위한 별도의 역할로 연결합니다.

#### 마이그레이션

각 **[마이그레이션](#migration)**은 `packages/database/migrations/`의 번호가 매겨진 SQL 파일입니다.

| 규칙 | 상세 |
| - | - |
| 기준선 | `0001_baseline.sql`은 절대 바뀌지 않습니다. 새 데이터베이스의 전체 스키마와 참조 행을 포함합니다. |
| 이후 변경 | 변경마다 새 파일 하나. `0002`부터 번호를 매기고 `<ordinal>_<description>.sql`로 이름을 붙입니다. |
| 순서 | 숫자 순서. 각 파일은 러너가 소유한 트랜잭션 안에서 실행됩니다. |
| 불변성 | 적용된 후에는 파일을 편집, 이름 변경, 순서 변경, 삭제하지 않습니다. 러너가 이를 강제하기 위해 모든 파일의 체크섬을 저장합니다. |

규칙은 `packages/database/migrations/README.md`에 문서화되어 있으며 러너는 `packages/database/src/migrations.ts`입니다.

#### 스키마와 테이블

기준선에서 약 740개의 테이블이 두 스키마로 나뉘어 제공됩니다. `platform`은 제어 플레인과 네이티브 소유자를 위한 것이고, `web`은 화면이 읽는 계약 스키마입니다. [뷰](#view)가 읽기 측을 제공합니다.

#### 식별자

ID는 Postgres가 아니라 애플리케이션이 만들며 `{prefix}_{base58(uuidv7)}` 형태로 `text` 기본 키에 저장됩니다. 모든 레코드 유형은 다른 유형이 사용하지 않는 접두사를 가집니다. 결과는 생성 시각순으로 정렬되며 로그 줄이나 URL에서도 읽을 수 있습니다.

#### 읽기와 쓰기

규칙은 **소유자를 통해 쓰고 관계에서 읽는다**입니다. 쓰기는 원천 데이터만 저장합니다. 합계, 이름, 썸네일처럼 화면이 그 위에 보여 주는 모든 것은 읽기 관계가 도출합니다.

#### 파일

업로드는 객체 스토리지에 기록되며 화면은 `/api/web/storage` 라우트로 이에 접근합니다. 세 가지 공급자 유형을 지원합니다. Cloudflare R2, S3 호환 스토어, 고객 스토리지 게이트웨이입니다(`apps/backend/src/production/blob-storage-provider.ts`와 그 이웃 파일 참조). 각 회사는 고유한 키 네임스페이스와 할당량을 가지며 백업 복사 프로필을 추가할 수 있습니다.

***

## 쓰기 이후: 이벤트와 백그라운드 작업

쓰는 코드가 후속 작업까지 실행하지는 않습니다. 대신 소유자는 업무 변경을 만드는 트랜잭션 안에서 **트랜잭션 아웃박스**(`platform."outbox"`)에 도메인 이벤트를 추가합니다. 해당 트랜잭션이 롤백되면 이벤트도 함께 사라지고, 커밋되면 이벤트가 반드시 존재합니다.

```mermaid theme={null}
flowchart TD
  W[Business write] -->|same transaction| O[(Outbox row)]
  O -->|after commit| D[Delivery worker]
  O -->|after commit| R[Native runner]
  D --> N[Notification inbox, email, webhooks]
  R --> T[Queued tasks and scheduled tasks]
```

### 전달 보장

전달은 최소 한 번(at-least-once)이므로 핸들러는 멱등으로 작성됩니다. 일시적인 실패는 시도 횟수 제한과 상한이 있는 지수 백오프 정책으로 재시도되며, 영구적인 실패는 중단됩니다. `kernel/events/`에 이벤트 버스, 그 재시도 정책, 웹훅 전달이 있습니다.

### 네이티브 러너

백그라운드 작업은 백엔드 프로세스 자체의 일부인 러너가 실행합니다(`apps/backend/src/runner/`). 모든 작업은 네 단계를 거칩니다.

1. **접수.** 회사, 작업, 멱등 키를 지정하는 행이 `platform."backgroundTaskRun"`에 추가됩니다.
2. **확보.** 러너가 행에 대한 임대를 가져갑니다.
3. **실행.** 해당 작업에 등록된 핸들러가 회사에 바인딩된 트랜잭션에서 실행됩니다.
4. **기록.** 실행은 `pending`, `running`, `retrying`, `succeeded`, `deadLettered` 중 하나로 끝납니다.

실패한 시도는 제한까지 재시도됩니다. 멱등 키는 재시도와 임대 인계 동안 고정되므로 핸들러는 이미 작업을 마쳤는지 알 수 있습니다. 화면은 여전히 익숙한 이름으로 작업을 참조하며 이는 `alforse/company-export` 같은 작업 이름과 `packages/web/jobs` 패키지가 보존하지만, 그 뒤에 외부 작업 서비스는 없습니다.

### 예약된 작업

`apps/backend/src/runner/native-runner-schedules.ts`가 일정을 선언합니다. cron 표현식은 UTC로 해석되며 모든 틱은 모든 활성 회사에 분산됩니다.

| 빈도 | 작업 |
| - | - |
| 15분마다 | MRP, 알림 요약 |
| 매시간 | 임대 청구, 계약 시간 청구 |
| 매일 00:00 | 환율 갱신 |
| 매일 01:00 | 야간 재계획 |
| 매일 02:00 | 감사 로그 아카이브 |
| 매일 04:00 | 워크플로 실행 보존 |

전달에는 자체 일정이 있습니다. 알림 릴레이는 매분, 유지보수 기한 도래 배차는 2분마다 실행되며, Blob 정리와 새로 추가된 파일 백업을 위한 추가 작업이 있습니다.

### 알림

알림은 사용자별 받은함에 도착합니다. 백엔드는 변경 피드를 게시합니다. 예를 들어 비공개 주제 `user:<userId>:notification`입니다. 피드는 내용을 포함하지 않고 어떤 행이 변경되었는지만 ERP에 알리며 ERP가 필요한 것을 가져옵니다. 이메일은 구성된 트랜잭션 이메일 전송 수단을 통해 나갑니다.

***

## 기타 엔진

### 워크플로

워크플로는 고객이 드래그 앤 드롭 캔버스에서 직접 설계하는 자동화입니다. 러너와 함께 제공되는 작업과 혼동해서는 안 됩니다.

| 측면 | 워크플로 | 기본 제공 작업 |
| - | - | - |
| 작성자 | 캔버스를 사용하는 고객 | TypeScript로 작성하는 Alforse 엔지니어 |
| 보관 형태 | 버전이 있는 워크플로 정의 | 소스 코드 |
| 변경 방식 | 고객이 편집할 때마다 | 배포할 때만 |

정의는 `packages/web/workflows`와 `kernel/workflows`의 코드가 기술하고 검증합니다. 각 실행과 그 안의 각 단계는 나중에 확인할 수 있도록 엔진이 기록합니다. 두 러너 일정이 이를 지원합니다. 10분마다 실행되는 워크플로 백스톱과 실행 이력 보존입니다. 기능 자체는 [워크플로](https://docs.alforse.com/ko/docs/reference/workflows.md)에서 다룹니다.

### CAD 및 조립 계획

여기서 두 가지 작업이 이루어집니다. STEP 형식의 CAD 파일이 브라우저에 표시할 수 있는 3D 모델이 되고, 조립 지침을 구동하기 위한 충돌 없는 분해 순서가 계산됩니다. 코드는 백엔드의 `apps/backend/src/cad/`에 있습니다. `occt-import-js`(WebAssembly로 빌드된 OpenCASCADE)가 지오메트리를 파싱하고 모션 플래너가 그 옆에 있습니다.

두 애플리케이션 모두 `/api/web/assembler`를 호출하며 베어러 키로 인증하고 `packages/web/viewer`로 출력을 표시합니다. 시간이 걸리는 변환은 러너 작업으로 실행되며 호출하는 페이지는 완료를 기다리거나 폴링할 수 있습니다.

***

## 용어집

이 페이지에서 링크된 용어의 짧은 정의이며 A부터 Z 순입니다.

### 액션(Action)

제출된 양식을 처리하는 라우트 파일의 내보내기입니다. 서버에서 실행되며 브라우저로 전송되지 않습니다.

### Kysely

TypeScript용 SQL 쿼리 빌더입니다. 여기서는 작업을 기술하기만 하며, 백엔드가 그 기술을 이름이 있는 네이티브 작업으로 변환합니다.

### Lingui

번역에 사용하는 라이브러리입니다. 코드에서 표시된 문자열은 번역가가 채울 수 있도록 언어별 파일로 추출됩니다.

### 로더(Loader)

페이지가 표시하는 데이터를 수집하는 라우트 파일의 내보내기입니다. 액션처럼 서버 측 전용입니다.

### 마이그레이션(Migration)

하나의 스키마 변경을 담은 번호가 매겨진 SQL 파일 하나입니다. 기준선과 이후 파일을 순서대로 합친 것이 곧 스키마입니다.

### 모노레포(Monorepo)

하나로 버전 관리되고 배포되는 여러 애플리케이션과 공유 라이브러리를 위한 단일 Git 저장소입니다.

### MRP

자재 소요량 계획(Material Requirements Planning)의 약자입니다. 수요와 현재고로 무엇을 언제까지 구매하거나 생산할지 결정하는 것입니다.

### 멀티 테넌시(Multi-tenancy)

하나의 데이터베이스와 하나의 실행 중인 애플리케이션으로 독립적인 여러 고객 회사를 서비스하면서 각 회사가 자신의 데이터만 보게 하는 것입니다.

### Postgres

Alforse가 기반으로 하는 관계형 데이터베이스인 PostgreSQL입니다.

### RLS

행 수준 보안(Row Level Security)입니다. Postgres가 모든 쿼리에 테이블별 규칙을 스스로 붙이며, 이 규칙이 회사들이 서로의 데이터를 보지 못하게 합니다.

### RPC

이 페이지에서는 애플리케이션 코드에서 `client.rpc("name", args)`로 이름 있는 함수를 호출하는 것이며, 백엔드가 이를 네이티브 소유자로 라우팅합니다.

### Turborepo

어느 패키지가 어느 것에 의존하는지 추적하여 빌드를 조율하므로 `pnpm build`가 변경된 것만 다시 수행합니다.

### 뷰(View)

테이블처럼 읽을 수 있지만 쓸 수는 없는 저장된 쿼리입니다.

### Vite

ERP와 MES를 빌드하고 개발 중에 제공합니다.

### zod

데이터의 모양을 한 번 정의하고 거기서 검증과 TypeScript 타입을 모두 도출할 수 있게 합니다.

### zustand

React용 최소 상태 라이브러리이며, 여기서는 모달 표시 여부 같은 일회성 UI 상태로 제한됩니다.

***

## 관련 문서

**웹훅**

이벤트가 발생하는 대로 자체 엔드포인트로 받습니다.

[Open](https://docs.alforse.com/ko/docs/building/webhooks.md)

**API 키**

프로그램에 공개 API용 자체 자격 증명을 부여합니다.

[Open](https://docs.alforse.com/ko/docs/building/api-keys.md)

**채번 규칙**

`get_next_sequence`가 문서 번호를 형식화하는 방식입니다.

[Open](https://docs.alforse.com/ko/docs/reference/sequences.md)

**백업**

회사 데이터와 파일의 스냅샷을 만들고 복원합니다.

[Open](https://docs.alforse.com/ko/docs/platform/backups.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.