앱 블루프린트
이 페이지에서는 플랫폼을 설계 의도대로 활용하는 IronFlock 앱을 구축하는 방법을 설명합니다. 핵심을 한 문장으로 요약하면 다음과 같습니다: 컨테이너는 모든 디바이스에서 실행되며 앱의 모든 상태를 앱의 테이블에 보관하고, 보드는 앱의 사용자 인터페이스이며, 이 둘은 서로 직접 통신하지 않습니다. 디바이스가 직접 제공하는 웹 UI는 원격 접근 터널을 통해 사용자에게 전달되어 보드 안에 표시됩니다.
각 구성 요소는 해당 페이지에서 자세히 설명합니다. 이 페이지에서는 구성 요소들이 어떻게 맞물리는지를 보여 줍니다.
앱의 구성 요소
앱은 여러 디바이스에서 동시에 실행되며, 이 디바이스는 대개 기계 옆에 설치된 산업용 PC입니다. 앱은 다음으로 구성됩니다:
| 구성 요소 | 설명 | 정의 위치 |
|---|---|---|
| 엣지 코드 | 앱이 설치된 모든 디바이스에서 실행되는 컨테이너 또는 Docker Compose 스택 | Dockerfile 또는 docker-compose.yml |
| SDK | 코드와 플랫폼을 잇는 유일한 연결 고리: 테이블, 실시간 업데이트, 디바이스 함수, 파일 | IronFlock SDK (Python, JavaScript, Go) |
| 테이블 | 프로젝트 자체 데이터베이스에 저장되는 앱의 상태 | data-template.yml |
| 보드 | 테이블에 실시간으로 바인딩되어 모든 디바이스를 아우르는 앱의 중심 사용자 인터페이스 | board-template.yml |
| 파라미터 | 개별 디바이스 또는 디바이스 그룹의 설정 | env-template.yml |
| 원격 접근 | 웹 UI처럼 컨테이너가 제공하는 포트로 연결되는 터널 | port-template.yml |
| AI 에이전트 | 디바이스 함수를 도구로 사용하는 IronFlock 어시스턴트용 에이전트 | ai-template.yml |
사용자가 프로젝트에 앱을 설치하면 IronFlock은 해당 프로젝트의 데이터베이스에 앱의 테이블을 만들고, 앱의 보드를 설치하며, 모든 디바이스에 있는 앱의 컨테이너와 테이블, 보드를 실시간으로 연결하는 비공개 메시징 영역을 엽니다. SDK는 디바이스 에이전트가 주입한 신원으로 이 영역에 참여하므로, 코드에는 주소도 자격 증명도 필요하지 않습니다.
규칙
- 테이블이 곧 앱의 상태입니다. 보드, 다른 디바이스, 재시작된 컨테이너, AI 에이전트가 필요로 하는 것은 모두 테이블에 저장됩니다. 컨테이너는 메모리에 올려 둔 설정 사본처럼 테이블에서 다시 만들 수 있는 것만 보관합니다.
- 구성 요소는 서로 직접 통신하지 않고 테이블을 통해 소통합니다. 디바이스는 측정한 값과 자신의 상태를 쓰고, 보드는 이를 표시합니다. 보드는 사용자가 결정한 내용을 쓰고, 디바이스는 이를 적용합니다. 어느 쪽도 상대를 호출하지 않으므로 디바이스가 오프라인이어도 보드는 작동하고, 아무도 보드를 열어 두지 않아도 디바이스는 계속 작동합니다.
- 테이블마다 쓰기 주체는 하나입니다. 각 테이블은 디바이스(각 디바이스가 자기 자신에 관해), 보드, 또는 하나의 서비스 중 한쪽만 씁니다. 어느 쪽인지는 테이블의
description에 밝혀 두십시오. 같은 행을 두 주체가 쓰면 서로의 변경 사항을 덮어쓰게 됩니다. - 명령이 아니라 의도한 상태를 씁니다. 디바이스의 동작을 바꾸려면 목표(한계값, 설정값, 레시피, 스케줄)를 테이블에 쓰고, 모든 디바이스가 가장 최신 행을 적용하게 하십시오. 오프라인이었던 디바이스는 다시 연결되면 이를 적용하며, 두 번 적용해도 달라지는 것은 없습니다.
- 변경될 때 쓰고, 실시간으로 따라가며, 절대 폴링하지 마십시오. 엔티티의 행은 값 중 하나가 바뀔 때 쓰고, 측정값은 샘플링 주기에 맞춰 쓰십시오. 읽는 쪽은 모두 테이블을 실시간으로 따라갑니다: 보드의 바인딩, 디바이스의 테이블 구독, 디바이스 웹 UI의 WebSocket이 그렇습니다. 타이머로 테이블을 반복해서 읽는 것은 언제나 잘못된 설계입니다.
- UI는 보드이며, 디바이스의 웹 UI는 이를 보완합니다. 디바이스에서 웹 페이지를 제공하는 것은 보드가 보여 줄 수 없는 것에 한정하십시오: 기계 자체의 HMI, 영상, 빠르게 갱신되는 라이브 뷰, 기계 옆의 운영자 화면 등입니다. 보드는 이를 임베드합니다.
데이터 종류별 저장 위치
| 데이터 | 위치 | 쓰는 쪽 | 읽는 쪽 |
|---|---|---|---|
| 시간에 따른 판독값 | tsp가 있는 테이블 | publishToTable, publishRowsToTable | 시간 창과 집계를 사용하는 차트 |
| 디바이스, 기계, 주문의 현재 상태 | 엔티티 테이블(maintainLatestFlagFor) | appendToTable: 키, tsp, 변경된 컬럼을 담은 행 | latest 필터 |
| 사용자가 설정하는 한계값, 설정값, 레시피 | 보드의 양식이 쓰는 테이블 | 양식 | 모든 디바이스(아래 참조) |
| 사용자가 생성, 편집, 삭제하는 레코드 | deleted 컬럼이 있는 엔티티 테이블 | 삭제 시 플래그를 설정하는 양식 | 보드의 목록 및 편집 페이지 |
| 운영자를 위한 오류 및 알림 | error-logs 테이블 | userMessage를 포함한 reportError | 보드의 토스트 위젯 |
| 이미지, 문서, 녹화 파일 | 앱의 파일 저장소 | files.put | 컬럼에 담긴 파일 URL을 위젯이 표시 |
| 비밀번호 및 API 키 | secret: true 컬럼 | 모든 테이블 쓰기 | revealSecrets, verifySecret(앱 자체 컨테이너에서만) |
| 개별 디바이스의 설치 정보: 기계 주소, 시리얼 포트 | 앱 파라미터 | 디바이스 또는 그룹의 앱 설정 | 환경 변수 |
| 나중에 아무도 필요로 하지 않는 구성 요소 간 메시지 | 자체 토픽 | publish | subscribe |
| 특정 디바이스의 즉각적인 응답이 필요한 요청 | 디바이스 함수 | registerDeviceFunction | callDeviceFunction, 위젯 액션, AI 도구 |
추가인가, 발행인가? appendToTable은 재시작 중인 데이터 백엔드를 기다리며, 데이터 백엔드가 행을 거부하면 이를 알려 줍니다. publishToTable은 연결만 기다리므로, 데이터 백엔드가 재시작하는 동안 발행된 행은 유실됩니다. 상태 변경과 레코드는 추가하고, 고빈도 판독값은 발행하십시오.
파라미터인가, 설정 테이블인가? 파라미터는 디바이스 또는 디바이스 그룹에 속합니다: 디바이스를 설치하거나 구성하는 사람이 앱 설정에서 값을 정하고, 코드는 환경에서 이를 읽습니다. 설치마다 달라지는 값에는 파라미터를 사용하십시오. 앱이 실행되는 동안 사용자가 바꾸는 것 — 한계값, 레시피, 스케줄, 그리고 여러 디바이스에 적용되는 모든 것 — 은 보드가 쓰는 테이블에 두어야 합니다. 보드는 사용자가 앱을 다루는 곳이고, 모든 디바이스가 변경 사항을 즉시 따르며, 테이블에는 누가 언제 무엇을 바꾸었는지 이력이 남습니다.
보드가 쓰는 내용 따라가기
디바이스는 보드에서 설정한 한계값처럼 보드가 쓴 상태를 다음 세 단계로 적용합니다:
- 테이블을 읽기 전에 먼저 구독하여 읽기와 구독 사이에 빠지는 행이 없게 하십시오.
- 시작할 때
latest필터로 가장 최신 행을 한 번 읽어 적용하십시오. - 현재 적용 중인 행보다 새로운 행만 적용하십시오(
tsp기준). 프로그램이 시작되는 바로 그 순간에 저장된 행은 읽기와 구독 양쪽으로 도착할 수 있습니다.
Python
await ironflock.subscribe_to_table("settings", lambda row, *args, **kwargs: apply(row))
rows = await ironflock.getHistory("settings", {"limit": 1, "filterAnd": [{"latest": True}]})
for row in rows or []:
apply(row)이것이 전부이며, 오프라인이 되는 디바이스에도 그대로 적용됩니다. 연결은 SDK가 처리하므로 코드에서 다룰 필요가 없습니다: SDK는 재연결하고, 구독과 디바이스 함수를 복원한 다음, 디바이스가 오프라인이던 동안 저장된 행을 오래된 것부터 각 테이블 구독에 전달하며, 실시간으로 도착하는 행은 그 뒤에 이어집니다. 디바이스가 오프라인일 때 저장된 한계값은 디바이스가 돌아오는 즉시 전달됩니다. 따라잡기는 tsp를 기준으로 합니다: 핸들러가 받은 가장 최신 행보다 새로운 행을 전달하되, 가장 최신 10,000행까지만 전달합니다. 따라잡기 읽기는 다른 이력 읽기와 마찬가지로 최대 재연결 대기 시간만큼 데이터 백엔드를 기다리며, 읽기가 실패하면 해당 행은 전달되지 않고 경고가 기록됩니다. 따라잡기는 SDK 1.10.0 버전부터 포함되어 있습니다.
명령, 디바이스 함수, 토픽
상태를 우선하십시오. 사용자가 “명령”하는 것의 대부분은 의도한 상태입니다: 설정값, 켜짐 또는 꺼짐, 레시피 같은 것입니다. 위에서처럼 이를 테이블에 쓰십시오.
라벨 인쇄나 세척 사이클 시작처럼 한 번만 실행되어야 하는 명령은 보드가 쓰는 테이블에 id를 가진 행으로 기록합니다. 디바이스는 실행한 id를 자체 테이블에 기록하므로, 같은 명령을 두 번 받아도 한 번만 실행됩니다.
디바이스 함수는 디바이스가 registerDeviceFunction으로 등록하는 프로시저입니다. 호출하는 쪽이 디바이스의 응답을 바로 받아야 할 때 사용하십시오:
- AI 에이전트:
ai-template.yml의 도구가 어시스턴트가 선택한 디바이스에서 함수를 호출합니다. AI 에이전트 및 도구를 참조하십시오. - 보드의 스위치 위젯과 SCADA 위젯은 디바이스가 온라인일 때 이를 호출합니다.
- 자체 컨테이너와 디바이스는
callDeviceFunction으로 서로를 호출합니다.
디바이스 함수에서는 읽기 쉬운 텍스트나 작은 JSON을 반환하고, 예상된 실패(전원이 꺼진 기계 등)는 오류를 발생시키는 대신 결과로 반환하십시오.
토픽은 나중에 아무도 필요로 하지 않는 구성 요소 간 메시지를 전달합니다: myapp.events처럼 자체 네임스페이스에 있는 이름에 publish와 subscribe를 사용합니다. 보드는 토픽을 구독할 수 없습니다: 보드가 표시하는 것은 모두 테이블에 있어야 합니다. 디바이스가 오프라인인 동안 발행된 메시지는 저장되지 않으므로, 반드시 도착해야 하는 것에는 테이블을 사용하십시오.
디바이스의 컨테이너
디바이스 에이전트가 컨테이너에 제공하는 것과 컨테이너에 기대하는 것은 다음과 같습니다:
- 신원: 에이전트는
DEVICE_KEY,DEVICE_NAME,DEVICE_SERIAL_NUMBER,SWARM_KEY,APP_KEY,APP_NAME,ENV(DEV또는PROD),TUNNEL_DOMAIN,DEVICE_LAN_IP,DEVICE_PORT_FOR_<port>와 함께 SDK가 사용할 앱 자체의 자격 증명을 설정합니다. SDK는 필요한 값을 스스로 읽습니다. 이 값들은 절대 하드코딩하지 마십시오. - 파라미터: 프로덕션 설치는 사용자가 설정한 값을 받고, 나머지 파라미터는 각자의
defaultValue를 받습니다. 개발 실행은 기본값만 받습니다. 파라미터에는 구체적인 이름을 붙이십시오(HOST가 아니라PLC_HOST). - 저장소: 단일 이미지 앱은 앱 전용이며 재시작과 업데이트 후에도 유지되는(앱과 함께 삭제됨)
/data와, 디바이스의 모든 앱이 공유하는/shared를 받습니다. 그래도 앱의 상태는 테이블에 보관하고, 이 폴더들은 다운로드한 모델이나 캐시처럼 디바이스에 속하는 파일에 사용하십시오. - 하드웨어: 단일 이미지 컨테이너는 디바이스의
/dev에 접근할 수 있는 특권 모드로 실행됩니다. - 로그: 프로그램이 stdout과 stderr에 쓰는 모든 내용이 IronFlock에서 앱의 로그가 됩니다. 버퍼링 없이 출력하십시오(
python -u). - 시작: 컨테이너는 시작하고 30초가 지난 시점에 실행 중이어야 하며, 그렇지 않으면 시작이 실패한 것으로 간주됩니다.
- 크래시: 프로덕션 앱이 종료되면 에이전트가 앱을 다시 시작하며, 실패할 때마다 더 오래 기다립니다. 개발 실행은 다시 시작되지 않습니다. 응답하지 않는 센서처럼 예상되는 실패는 프로그램 안에서 처리하고, 복구할 수 없는 오류가 발생하면 프로그램이 종료되도록 두십시오.
- 중지: 프로그램은 컨테이너의 메인 프로세스(PID 1)이며, PID 1은 직접 처리하지 않는 한 중지 신호 SIGTERM을 무시합니다. SIGTERM을 받으면 종료하십시오. 그렇지 않으면 앱을 중지할 때 Docker가 프로세스를 강제로 종료할 때까지 기다려야 합니다. 프로그램은 셸을 거치지 말고 exec 형식(
CMD ["python", "-u", "main.py"])으로 시작하십시오. - 개발 실행: 클라우드 IDE에서 앱을 실행하면 개발 실행이 됩니다(
ENV가DEV). 개발 실행은 앱의 개발용 테이블에 쓰고, 파라미터의 기본값을 받으며, 원격 접근 터널은 받지 않습니다.
Docker Compose
앱이 여러 프로세스를 실행하거나, 자체 코드 외에 MQTT 브로커나 데이터베이스 같은 서드파티 서비스를 함께 묶는 경우 docker-compose.yml을 사용하십시오. 기본 사항은 Docker 및 Compose를 참조하십시오. 단일 이미지와 비교하면 다음이 다릅니다:
- 모든 서비스가 주입된 환경을 받습니다. 에이전트는 위의 변수를 담은 별도의 환경 파일을 서비스마다 제공하며, 직접 작성한
env_file:은 대체됩니다. Compose 파일의${VAR}은 주입된 값으로 해석됩니다. /data가 없습니다. 서비스는 앱 폴더를 받지 않습니다. 파일은 명명된 볼륨에 보관하십시오: 명명된 볼륨은 중지와 업데이트 후에도 유지되며, 디바이스에서 앱을 제거하면 삭제됩니다.- 게시된 포트는 다시 매핑됩니다. 게시된 각 포트는 디바이스의 모든 인터페이스에서 비어 있는 호스트 포트를 받으며,
DEVICE_PORT_FOR_<port>가 코드에 실제 포트를 알려 줍니다.port-template.yml에서는 Compose 파일에 적힌 그대로의 호스트 포트를 사용하십시오. 사용자가 접근해야 하는 포트만 게시하고, 서비스끼리는 Compose 네트워크에서 서비스 이름으로 통신하게 하십시오.${VAR}로 작성한 호스트 포트는 관리되지 않습니다: 다시 매핑되지도 않고 터널도 생기지 않습니다. - 아무것도 추가되지 않습니다. 에이전트는 Compose 서비스에 특권도 장치도 부여하지 않습니다.
privileged,devices또는device_cgroup_rules는 직접 선언하십시오.devices:항목은 반드시 존재해야 하는 하드웨어를 지정하며, 해당 하드웨어가 없으면 컨테이너가 시작되지 않습니다.device_cgroup_rules를/dev볼륨과 함께 사용하면 모든 디바이스에서 작동합니다. profiles:는 사용할 수 없습니다. profiles가 지정된 서비스는 릴리스에서 제외됩니다.- 시작 규칙은 동일합니다. 30초 안에 모든 컨테이너가 실행 중이거나 종료 코드 0으로 종료된 상태여야 하며, 적어도 하나는 실행 중이어야 합니다. 장시간 실행되는 서비스에는
restart: unless-stopped를 지정하십시오.
디바이스의 웹 UI
컨테이너의 웹 서버는 해당 포트의 원격 접근 터널을 통해 어디서나 접근할 수 있게 되며, 보드가 이를 표시합니다:
- 고정 포트에서 제공하고(
0.0.0.0에 바인딩),port-template.yml에protocol: http로 선언하십시오. 프로덕션 설치는 터널을 켜고 WebSocket을 포함해 HTTPS로 제공합니다. - 터널의 URL을 테이블에 쓰십시오. SDK의
getRemoteAccessUrlForPort(port)가 URL을 알려 줍니다. 시작할 때 이 URL을 디바이스 테이블에서 해당 디바이스의 행에 쓰십시오. 터널은 프로덕션 설치에만 있으므로 개발 실행은 URL을 쓰지 않습니다. - 보드에 임베드하십시오. 디바이스로 필터링하는 디바이스별 페이지에서 Embed 위젯의 URL을 그 컬럼에 바인딩하십시오. UI Builder에서는 이것도 다른 데이터 바인딩과 다르지 않습니다.
페이지 자체에 관해서는 다음과 같습니다:
- 열 수 있는 사람: http 터널은 비공개입니다. 사용자가 포트를 공개로 전환하지 않는 한, IronFlock에 로그인하고 디바이스에 대한 접근 권한이 있는 사람만 열 수 있습니다. 디바이스가 속한 네트워크에서는 페이지가 누구에게나 열려 있으므로, 공장 네트워크에서 보여서는 안 되는 내용은 표시하지 말고, 기계 상태를 바꾸는 모든 작업에는 자체 로그인을 요구하십시오.
- 상대 URL: 에셋 로드, API 호출, WebSocket 연결을 모두 페이지 기준 상대 경로로 처리하십시오. 그래야 디바이스 네트워크에서도, 터널을 통해서도, 리버스 프록시 뒤에서도 작동합니다.
- 폴링 대신 푸시: 업데이트는 WebSocket으로 보내십시오.
- 브라우저에 플랫폼 자격 증명을 두지 않기: 페이지는 컨테이너에 있는 자체 서버와 통신하고, SDK 연결은 그 서버가 유지합니다.
직접 만든 페이지 대신 공장 네트워크에 있는 기계의 웹 HMI를 보여 주려면, 컨테이너에서 해당 HMI를 리버스 프록시하십시오. 외부 HMI 임베드를 참조하십시오.
여러 디바이스
- 모든 행에 디바이스가 기록됩니다: IronFlock은 행을 쓴 디바이스의 키를
device_key에 저장합니다. 디바이스별 테이블은device_key를 키로 삼고, 디바이스 이름은path: kwargs.DEVICE_NAME으로 컬럼에 저장하며, 차트는 그 이름으로 분할하십시오. - 디바이스 목록과 디바이스별 페이지: 디바이스 테이블로 만든 사이드 내비게이션과, 모든 디바이스에 쓸 수 있으며 라우트의 디바이스로 위젯을 필터링하는 페이지 하나를 사용합니다. 보드 구축을 참조하십시오.
- 디바이스별 또는 기계별 설정: 설정 테이블은 대상을 가리키는 컬럼을 키로 삼고(보드가 쓰는 행에는 자체 디바이스가 없습니다), 대상 페이지의 양식에서 그 컬럼을 채우십시오. 각 디바이스는 자신의 키에 해당하는 가장 최신 행을 적용합니다.
- 떠난 디바이스의 행은 그대로 남습니다. 사용자가 목록에서 디바이스를 제거할 수 있어야 한다면, 디바이스 테이블에
deleted컬럼을 추가하고 보드에는 이를 설정하는 양식을 추가하십시오.
체크리스트
- 모든 상태가 테이블에 있고, 각 테이블의 설명에 하나뿐인 쓰기 주체가 명시되어 있으며, 코드가 쓰는 모든 컬럼이
data-template.yml에 선언되어 있습니다. - 모든 행에
tsp가 있고, 상태 행은 변경될 때 키와 변경된 컬럼만 담아 씁니다. - 보드가 쓴 상태를 SDK 1.10.0 이상에서 위의 세 단계로 따라가며, 코드는 연결이나 세션을 전혀 다루지 않습니다.
- 장시간 실행되는 루프의 테이블 쓰기는 플랫폼에 1분 동안 연결할 수 없을 때 쓰기가 발생시키는 오류를 처리하며, 예상된 실패는
reportError와userMessage로 보고합니다. - 프로그램은 SIGTERM을 받으면 종료하고, exec 형식으로 시작하며, 버퍼링 없이 로그를 출력합니다.
- 아무것도 폴링하지 않습니다: 보드는 바인딩하고, 디바이스는 구독하며, 웹 UI는 푸시합니다.
- 앱이 제공하는 각 포트가
port-template.yml에 선언되어 있고, 그 URL은getRemoteAccessUrlForPort로 얻어 테이블에 쓰며, 보드는 그 테이블에서 URL을 가져와 임베드합니다. - Compose 앱에는
env_file:,profiles:,${VAR}호스트 포트가 없고, 파일은 명명된 볼륨에 보관하며, 사용자에게 필요한 포트만 게시합니다.
스타터 앱으로 시작하기
Claude Code용 IronFlock 플러그인에는 이 블루프린트를 따르는 스타터 앱이 포함되어 있습니다: 모든 디바이스에서 기계의 데이터를 읽고, 각 디바이스의 상태와 판독값을 테이블에 보관하며, 사용자가 보드에서 설정한 한계값을 적용하고, 보드가 각 디바이스 페이지에 임베드하는 라이브 뷰를 제공하는 프로그램입니다. Claude Code는 이 앱을 바탕으로 새 앱을 만들고 사용 사례에 맞게 조정합니다.