카페에 단말이 몇 개나 있는지 세어 본 적 있는가. 주문 키오스크, 카운터 POS, 카드 결제기, 주방으로 넘어가는 종이 전표, 그리고 재고 장부. 단말은 많은데 서로 말을 안 한다. 그래서 그 사이를 사람이 잇는다. 키오스크에서 들어온 주문을 점원이 보고 주방에 소리쳐 알리고, 결제는 또 다른 기계에서 하고, 재고는 밤에 손으로 맞춘다.
여기에 단말을 하나 더 놓는 게 답일 리 없다. 이미 매장에 있는 결제기가 화면을 나눠 줄 줄 모를 뿐이다.
이 글은 그 결제기 옆에 서버 하나를 세우고, 태블릿과 PC와 주방 화면을 붙여서 — 붙인 기기가 각각 키오스크와 POS와 주방 디스플레이가 되는 것까지 만들어 본 기록이다. 세 기기 어디에도 앱을 만들지 않았다. 그리고 카드 승인 경로는 한 줄도 건드리지 않았다. 아래 화면은 전부 실제로 실행해서 렌더된 픽셀을 그대로 캡처한 것이다.
결과부터 — 세 화면
손님이 태블릿에서 주문한다.

같은 순간, 주방 화면에는 그 주문이 떠 있다.

카운터에서 결제를 걸면 결제기가 승인을 돌려준다.

세 화면은 같은 서버가 내준 서로 다른 정의 세 벌이다. 태블릿용 앱, POS용 앱, 주방용 앱이 따로 있는 게 아니다.
전체 그림
terminal_sim (C) store_server (Dart · mcp_server) 클라이언트 ×3
결제기 종단 ──serial/usb──▶ 주문·매출 상태 소유 ──MCP──▶ 태블릿 ui://kiosk
승인만 답한다 화면 정의 3벌 서빙 ──MCP──▶ PC ui://pos
어댑터로 결제기에 요청 전달 ──MCP──▶ 주방 ui://kds
| 조각 | 무엇을 하나 | 누가 쓰나 |
|---|---|---|
| 매장 서버앱 | 주문·조리·매출 상태를 갖고, 접속한 역할에 맞는 화면 정의를 내준다 | 개발자가 작성 |
| 결제기 어댑터 | 승인 요청을 결제기에 넘기고 답을 받아 온다. 그게 전부다 | 개발자가 작성 |
| 클라이언트 ×3 | 서버가 준 정의를 렌더한다 | 역할 지정 몇 줄 |
| 결제기 종단 | 승인을 돌려준다 | (이 샘플에선 시뮬 — 아래 참조) |
여기서 결제기 안쪽은 우리 코드가 아니다. 카드 데이터도, 승인 판정도, VAN 통신도 결제기의 인증받은 보안 영역 안에 그대로 있다. 우리가 하는 일은 "이 금액 승인해 달라"고 묻고 답을 받는 것뿐이다. 그 비대칭이 이 글의 전부다.
① 하나의 서버가 세 화면을 내주는 곳
이게 이 글의 중심이다. 기기가 접속해서 자기 화면을 달라고 하면, 그 화면을 준다.
const screens = <String, (String, String, Map<String, dynamic>)>{
'ui://kiosk': ('Self Order Kiosk', 'Customer-facing order screen', kioskDefinition),
'ui://pos': ('Counter POS', 'Owner-facing sales and payment screen', posDefinition),
'ui://kds': ('Kitchen Display', 'Kitchen order queue', kdsDefinition),
};
screens.forEach((uri, spec) {
final (name, description, definition) = spec;
server.addResource(
uri: uri,
name: name,
description: description,
mimeType: 'application/json',
handler: (requestedUri, params) async => ReadResourceResult(
contents: [
ResourceContentInfo(
uri: requestedUri,
mimeType: 'application/json',
text: jsonEncode(definition),
),
],
),
);
});
"기기가 단말이 된다"는 말의 실체가 이 세 줄짜리 표다. 화면을 늘리고 싶으면 — 창가에 대기번호 표시기를 하나 걸고 싶다면 — 여기에 한 줄을 더한다. 그 기기에 설치할 앱을 만드는 게 아니라, 이 표에 항목을 더한다.
클라이언트 쪽은 이렇게 짧다. 역할이 곧 화면 이름이다.
const _role = String.fromEnvironment('ROLE', defaultValue: 'kiosk');
const _screenForRole = <String, String>{
'kiosk': 'ui://kiosk',
'pos': 'ui://pos',
'kds': 'ui://kds',
};
// 서버에 붙어서 내 화면을 받아 런타임에 넘긴다.
final uri = _screenForRole[_role] ?? 'ui://kiosk';
final resource = await client.readResource(uri);
final definition = jsonDecode(resource.contents.first.text!) as Map<String, dynamic>;
final runtime = MCPUIRuntime();
await runtime.initialize(definition);
주방 디스플레이에서는 --dart-define=ROLE=kds로 띄우고, 카운터 PC에서는 ROLE=pos로 띄운다. 같은 바이너리다. 이 파일 어디에도 주방 화면이 어떻게 생겼는지에 대한 코드가 없다 — 그건 서버가 안다.
② 결제기 어댑터 — 이 글에서 가장 옮겨 적힐 코드
승인 요청을 보내고 답을 기다린다. 하는 일이 정말 이것뿐이고, 그 얇음이 곧 주장이다.
/// 요청 하나를 보내고 같은 id를 단 답을 기다린다.
///
/// 도착 순서가 아니라 id로 짝짓는 이유: 링크는 호출 스택이 아니라 스트림이다.
/// 승인이 아직 날아가는 중에 끼어든 상태 조회가 승인의 답을 가로채면 안 된다.
Future<Map<String, dynamic>> call(
String tool, [
Map<String, dynamic> args = const {},
]) async {
final id = _nextId++;
final completer = Completer<Map<String, dynamic>>();
_pending[id] = completer;
final request = jsonEncode({'id': id, 'tool': tool, 'args': args});
transcript.add('=> $request');
_proc.stdin.writeln(request);
final reply = await completer.future.timeout(
requestTimeout,
onTimeout: () {
_pending.remove(id);
throw TimeoutException('terminal did not answer $tool', requestTimeout);
},
);
if (reply['ok'] != true) {
throw StateError('terminal refused $tool: ${reply['error']}');
}
return (reply['result'] as Map).cast<String, dynamic>();
}
타임아웃을 3초로 잡은 이유를 적어 둔다. 이건 취향이 아니라 계산이다.
/// 결제기 응답을 얼마나 기다릴 것인가.
///
/// 시뮬레이터는 70~160 ms에 답한다. 실제 승인도 VAN 왕복이 시간을 지배하므로
/// 자릿수가 같다. 3초는 그 분포보다 한참 위에 일부러 잡은 값이다. 여기서
/// 타임아웃이 났다면 "결제기가 사라졌다"는 뜻이어야지 "평소보다 느렸다"는
/// 뜻이면 안 된다. 수백 ms로 조이면 멀쩡한 승인이 실패로 둔갑하고 —
/// 실패했다고 알렸는데 실제로는 승인이 통과한 결제야말로 이 어댑터가 만들 수
/// 있는 최악의 결과다.
final Duration requestTimeout;
그리고 결제 핸들러. 이 코드가 하지 않는 일을 보는 게 더 중요하다.
handler: (args) async {
final unpaid = _orders.where((o) => o['paid'] == false).toList();
if (unpaid.isEmpty) { _notice = 'Nothing to charge'; return _state(); }
final amount = unpaid.fold<int>(0, (sum, o) => sum + (o['price'] as int));
final started = DateTime.now();
try {
final result = await terminal.call('terminal.authorize', {'amount': amount});
final elapsed = DateTime.now().difference(started);
for (final o in unpaid) { o['paid'] = true; }
_salesTotal += amount;
_lastApproval = '${result['approvalCode']} (${result['last4']})';
_lastRoundTripMs = elapsed.inMilliseconds;
} on TimeoutException {
// 타임아웃은 거절이 아니다. 결제기가 무엇을 했는지 우리는 모른다.
// 그러니 모른다고 말한다. 임의로 실패 처리하지 않는다.
_notice = 'Terminal did not answer — check the receipt before retrying';
}
return _state();
}
카드를 읽지 않는다. 결제해도 되는 손님인지 판단하지 않는다. VAN에 접속하지 않는다. 얼마인지 세어서 묻고, 답을 기록한다. "묻는다"와 "답"의 사이에서 일어나는 모든 일은 이 샘플이 한 번도 열어 보지 않는 인증받은 하드웨어 안에서 일어난다.
타임아웃 처리를 눈여겨봐 주기 바란다. 결제에서 가장 위험한 상태는 거절이 아니라 모름이다. 승인이 실제로 통과했는데 우리가 실패로 처리해 버리면 손님은 돈이 나갔는데 주문은 없는 상태가 된다. 그래서 모를 때는 모른다고 화면에 쓴다.