본문으로 건너뛰기

튜토리얼: Fascia로 예약 시스템 만들기

이 튜토리얼에서는 Fascia를 사용하여 완전한 예약 시스템을 단계별로 구축합니다. Entity(엔티티) 정의부터 결제 처리 Tool(도구) 설계, 리스크 분석, Spec Registry 등록까지 전체 과정을 다룹니다.

만들게 될 것

이 튜토리얼을 완료하면 다음을 갖추게 됩니다:

  • Reservation Entity -- 6개 상태를 가진 상태 머신 (pending -> confirmed -> checked_in -> completed)과 비즈니스 Invariant(불변식)를 포함합니다.
  • processPayment Tool -- 결제 캡처, 트랜잭션 관리, 보상 플로우(자동 환불)를 포함하는 결제 처리 도구입니다.
  • 리스크 분석 결과 -- Risk Engine이 Tool의 안전성을 어떻게 검증하는지 확인합니다.

사전 요구 사항

  • Fascia 플랫폼 접근 권한 (얼리 액세스 또는 로컬 개발 환경)
  • Entity 개념 이해 -- 필드, 관계, 상태 머신, Invariant
  • Tool 개념 이해 -- Flow(플로우) 그래프, 트리거, Execution Contract

Step 1: Reservation Entity 정의

예약 시스템의 핵심은 Reservation Entity입니다. 고객이 특정 시간대와 자원에 대해 생성하는 예약을 나타냅니다.

Entity Spec 전체

{
"name": "Reservation",
"version": 1,
"description": "A booking made by a customer for a specific time slot and resource",
"fields": {
"customerId": { "type": "reference", "referenceTo": "Customer", "required": true },
"resourceId": { "type": "reference", "referenceTo": "Resource", "required": true },
"startDate": { "type": "datetime", "required": true },
"endDate": { "type": "datetime", "required": true },
"totalPrice": { "type": "number", "required": true },
"depositAmount": { "type": "number", "required": false, "default": 0 },
"notes": { "type": "string", "required": false },
"cancellationReason": { "type": "string", "required": false }
},
"relationships": {
"customer": { "type": "belongsTo", "target": "Customer", "foreignKey": "customerId" },
"resource": { "type": "belongsTo", "target": "Resource", "foreignKey": "resourceId" },
"payments": { "type": "hasMany", "target": "Payment" }
},
"statusMachine": {
"states": ["pending", "confirmed", "checked_in", "completed", "cancelled", "no_show"],
"initialState": "pending",
"transitions": [
{ "from": "pending", "to": "confirmed" },
{ "from": "pending", "to": "cancelled" },
{ "from": "confirmed", "to": "checked_in" },
{ "from": "confirmed", "to": "cancelled", "guard": "now() < reservation.startDate" },
{ "from": "confirmed", "to": "no_show", "guard": "now() > addHours(reservation.startDate, 1)" },
{ "from": "checked_in", "to": "completed" }
]
},
"invariants": [
{ "name": "endDateAfterStart", "expression": "reservation.endDate > reservation.startDate", "message": "End date must be after start date" },
{ "name": "totalPricePositive", "expression": "reservation.totalPrice > 0", "message": "Total price must be positive" },
{ "name": "depositNotExceedTotal", "expression": "reservation.depositAmount <= reservation.totalPrice", "message": "Deposit cannot exceed total price" }
],
"rowLevelAccess": true,
"ownerField": "customerId"
}

상태 머신 상세

Reservation Entity는 6개의 상태를 가집니다:

상태의미
pending예약이 생성되었으나 아직 결제되지 않은 상태입니다
confirmed결제가 완료되어 예약이 확정된 상태입니다
checked_in고객이 실제로 도착하여 체크인한 상태입니다
completed서비스 이용이 완료된 상태입니다
cancelled예약이 취소된 상태입니다
no_show고객이 예약 시간에 나타나지 않은 상태입니다

상태 전이에는 Guard(가드) 표현식이 포함될 수 있습니다. Guard는 Value DSL로 작성된 조건식으로, 전이가 허용되는 조건을 정의합니다:

  • confirmed -> cancelled: now() < reservation.startDate -- 시작 시간 이전에만 취소가 가능합니다.
  • confirmed -> no_show: now() > addHours(reservation.startDate, 1) -- 시작 시간으로부터 1시간이 경과한 후에만 노쇼로 처리할 수 있습니다.

Invariant (불변식)

Invariant는 Entity의 데이터 무결성을 보장하는 비즈니스 규칙입니다. Execution Contract의 6단계(플로우 실행 후, 트랜잭션 커밋 전)에서 강제됩니다:

Invariant표현식목적
endDateAfterStartreservation.endDate > reservation.startDate종료일이 시작일 이후인지 검증합니다
totalPricePositivereservation.totalPrice > 0총 금액이 양수인지 검증합니다
depositNotExceedTotalreservation.depositAmount <= reservation.totalPrice보증금이 총 금액을 초과하지 않는지 검증합니다

Invariant를 위반하는 쓰기 작업은 트랜잭션이 롤백되어 데이터베이스에 반영되지 않습니다.

행 수준 접근 제어

rowLevelAccess: trueownerField: "customerId" 설정을 통해 각 고객은 자신의 예약만 조회할 수 있습니다. 관리자(admin) 역할은 모든 예약에 접근할 수 있으며, 일반 고객(customer) 역할은 자신이 소유한 행만 읽고 수정할 수 있습니다.

시스템 필드

다음 필드는 모든 Entity에 자동으로 추가되므로 스펙에 직접 정의할 필요가 없습니다:

필드타입용도
iduuid고유 식별자입니다
createdAtdatetime생성 시각입니다
updatedAtdatetime최종 수정 시각입니다
deletedAtdatetime (nullable)소프트 삭제 시각입니다. Fascia는 하드 삭제를 허용하지 않습니다
versioninteger낙관적 잠금(Optimistic Locking)에 사용됩니다

Step 2: 결제 Tool 설계

Reservation Entity를 정의했으므로, 이제 결제를 처리하고 예약을 확정하는 processPayment Tool을 설계합니다.

Tool Spec 전체

{
"name": "processPayment",
"version": 1,
"description": "Process payment for a reservation",
"trigger": { "type": "http", "method": "POST", "path": "/reservations/:reservationId/pay" },
"input": {
"type": "object",
"required": ["reservationId", "paymentMethod"],
"properties": {
"reservationId": { "type": "string", "format": "uuid" },
"paymentMethod": { "type": "string", "enum": ["card", "bank_transfer"] },
"cardToken": { "type": "string" }
}
},
"flow": {
"startNode": "readReservation",
"nodes": {
"readReservation": { "type": "read", "config": { "entity": "Reservation", "filter": { "id": "input.reservationId" }, "single": true } },
"checkStatus": { "type": "assert", "config": { "expression": "reservation.status == 'pending'", "message": "Reservation must be in pending status" } },
"txBoundary": { "type": "transaction", "config": { "contains": ["createPayment", "confirmReservation"] } },
"createPayment": { "type": "write", "config": { "entity": "Payment", "operation": "create", "fields": { "reservationId": "reservation.id", "amount": "reservation.totalPrice", "method": "input.paymentMethod", "status": "pending" } } },
"capturePayment": { "type": "payment", "config": { "provider": "stripe", "action": "capture", "amount": "reservation.totalPrice", "token": "input.cardToken" } },
"confirmReservation": { "type": "write", "config": { "entity": "Reservation", "operation": "transition", "target": "reservation", "toStatus": "confirmed" } },
"sendConfirmation": { "type": "email", "config": { "to": "reservation.customer.email", "template": "reservation_confirmed" } },
"retryEmail": { "type": "retry", "config": { "target": "sendConfirmation", "maxAttempts": 3, "backoff": "exponential" } }
},
"edges": [
{ "from": "readReservation", "to": "checkStatus" },
{ "from": "checkStatus", "to": "txBoundary" },
{ "from": "txBoundary", "to": "capturePayment" },
{ "from": "capturePayment", "to": "sendConfirmation" }
]
},
"compensation": {
"trigger": "capturePayment",
"flow": {
"nodes": {
"refundPayment": { "type": "payment", "config": { "provider": "stripe", "action": "refund" } }
}
}
}
}

Flow 그래프 설명

이 Tool의 Flow는 다음 순서로 실행됩니다:

  1. Read (readReservation) -- Reservation Entity에서 해당 예약 데이터를 조회합니다.
  2. Assert (checkStatus) -- 예약 상태가 pending인지 검증합니다. pending이 아니면 실행을 중단합니다.
  3. Transaction (txBoundary) -- Payment 생성과 Reservation 상태 전이를 하나의 트랜잭션으로 묶습니다.
  4. Payment (capturePayment) -- Stripe를 통해 결제를 캡처합니다. 트랜잭션 외부에 배치됩니다.
  5. Email (sendConfirmation) -- 예약 확정 이메일을 발송합니다. 재시도가 설정되어 있습니다.

트랜잭션 경계 배치

트랜잭션 경계(txBoundary)는 쓰기 작업만 감쌉니다 (createPayment, confirmReservation). 이것이 올바른 설계입니다:

  • 외부 호출(결제 캡처, 이메일 발송)은 트랜잭션 외부에 위치해야 합니다.
  • 트랜잭션 내부에서 외부 호출을 실행하면, 트랜잭션이 롤백되더라도 외부 부작용은 취소되지 않아 데이터 불일치가 발생합니다.
  • 이 규칙은 Fascia의 리스크 규칙에서 Red 등급으로 분류되는 위반 사항입니다.

보상 플로우 (Compensation Flow)

compensation 섹션은 결제 캡처 이후 후속 단계에서 실패가 발생했을 때 자동으로 실행되는 복구 로직입니다:

  • 트리거 조건: capturePayment 노드 실행 이후 실패가 발생한 경우
  • 복구 동작: Stripe 환불 (refund)을 자동으로 실행합니다

보상 플로우가 없는 결제 호출은 Risk Engine에서 Red 등급으로 분류되어 배포가 차단됩니다.

Step 3: Flow 그래프 이해하기

Tool의 Flow는 방향 비순환 그래프(DAG)입니다. processPayment의 실행 흐름을 시각적으로 표현하면 다음과 같습니다:

readReservation
|
v
checkStatus
|
v
txBoundary ─────────────────────┐
| createPayment |
| confirmReservation |
└───────────────────────────────┘
|
v
capturePayment
|
v
sendConfirmation (retryEmail: max 3, exponential backoff)

Execution Contract 관점에서의 실행 순서

모든 Tool 실행은 Execution Contract의 9단계를 따릅니다. processPayment의 경우:

단계Execution ContractprocessPayment에서의 동작
1입력 검증reservationId (uuid), paymentMethod (enum) 스키마 검증
2인가JWT 토큰 검증 + RBAC 권한 확인
3정책 검사등록된 Policy(정책) 규칙 평가
4트랜잭션 시작txBoundary 노드에서 트랜잭션 시작
5Flow 그래프 실행Read -> Assert -> Write(Payment, Reservation) -> Payment -> Email
6Invariant 강제Reservation의 3개 불변식 검증
7커밋 / 롤백성공 시 커밋, 실패 시 롤백 + 보상 플로우 실행
8감사 로그실행 결과, 변경 사항, 사용자 정보 기록
9출력 반환정규화된 응답 반환

이 시퀀스에서 LLM은 관여하지 않습니다. 모든 실행은 결정적(deterministic)이며 스펙에서 도출됩니다.

Step 4: 리스크 수준 확인

배포 전에 Risk Engine이 processPayment Tool을 분석합니다. 각 리스크 신호에 대한 평가 결과는 다음과 같습니다:

검사 항목결과리스크 레벨
트랜잭션 경계 존재 여부존재함 (txBoundary가 쓰기 작업을 감쌈)Green
쓰기 작업이 트랜잭션 내부에 있는지createPayment, confirmReservation 모두 포함됨Green
외부 호출이 트랜잭션 외부에 있는지capturePayment, sendConfirmation 모두 외부에 배치됨Green
결제 호출에 보상 플로우가 있는지compensation 섹션에 환불 플로우 정의됨Green
이메일에 재시도 설정이 있는지retryEmail로 최대 3회, 지수 백오프 설정됨Green
외부 결제 호출 존재Stripe capture 호출이 존재함Yellow

종합 리스크 레벨: Yellow

외부 결제 서비스 호출이 포함되어 있으므로 Yellow로 분류됩니다. Yellow 등급은 배포가 가능하지만, 사용자가 리스크를 확인(acknowledge)해야 합니다. 확인 내역은 감사 로그에 기록됩니다.

만약 보상 플로우가 없었다면 Red 등급으로 분류되어 배포가 차단되었을 것입니다. 결제 호출에 롤백/보상 메커니즘이 없는 것은 Fascia의 리스크 규칙에서 가장 심각한 위반 사항 중 하나입니다.

Step 5: API로 등록

Entity와 Tool 스펙이 준비되었으므로 Spec Registry API를 통해 등록합니다.

Reservation Entity 등록

Step 1에서 작성한 JSON 파일을 reservation-entity.json으로 저장한 후 다음 명령을 실행합니다:

curl -X POST http://localhost:3001/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @reservation-entity.json

응답 예시:

{
"id": "spec_abc123",
"name": "Reservation",
"version": 1,
"type": "entity",
"createdAt": "2026-02-11T10:00:00Z"
}

processPayment Tool 등록

Step 2에서 작성한 JSON 파일을 process-payment-tool.json으로 저장한 후 다음 명령을 실행합니다:

curl -X POST http://localhost:3001/api/v1/specs/tool \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @process-payment-tool.json

등록이 성공하면 각 스펙에 고유한 idversion이 부여됩니다. Tool 스펙의 경우 Risk Engine이 등록 시점에 자동으로 리스크 분석을 수행하여 riskLevelriskSignals를 응답에 포함합니다.

등록된 스펙은 다음과 같이 조회할 수 있습니다:

# Entity 스펙 목록 조회
curl http://localhost:3001/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN"

# 특정 Tool 스펙 조회
curl http://localhost:3001/api/v1/specs/tool/processPayment \
-H "Authorization: Bearer $TOKEN"

다음 단계

이 튜토리얼에서 다룬 개념을 더 깊이 이해하려면 다음 문서를 참조하십시오: