현장에 설치된 수많은 키오스크 기기(Electron / Android)의 화면을 중앙에서 실시간으로 원격 관리하고, AI로 생성된 웹 앱을 안전하게 배포하기 위해서는 정교한 통신 및 배포 파이프라인이 필수적입니다.
단순히 "실시간 라이브러리를 붙였다" 수준을 넘어, L4 TCP 3-Way Handshake부터 L7 HTTP Upgrade, 커널 레벨의 소켓 버퍼 직접 쓰기(Push), Coolify를 통한 컨테이너 격리 배포 파이프라인, 그리고 실제 프로덕션 코드 구현까지 네트워크 엔지니어링 관점에서 총망라하여 정리합니다.
┌──────────────────┐ ┌──────────────────────────────────────┐
│ 관리자 웹 (Admin) │ │ 백엔드 (NestJS) │
│ - React / Vite │ ── HTTPS ───▶ │ - REST API / OpenAPI │
│ - AI 채팅 / 승인 │ │ - Socket.IO 게이트웨이 (/device) │
└──────────────────┘ └──────────────────┬───────────────────┘
│
┌──────────────────────────────┼──────────────────────────────┐
│ (배포 트리거) │ (내부 SSE) │ (WebSocket Push)
▼ ▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
│ 호스팅 인프라 (Coolify) │ │ AI 생성 런타임 (Codex) │ │ 키오스크 (Client) │
│ - 정적 Nginx 컨테이너 │ │ - 격리 작업공간 │ │ - React 셸 (Electron/ │
│ - 고정 도메인 / 자동 SSL │ │ - 정적 HTML/CSS/JS 생성 │ │ Capacitor Android) │
│ - 기기별 배포 격리 │ └─────────────────────────┘ │ - 샌드박스 iframe 렌더링 │
└──────────────┬──────────────┘ └────────────┬────────────┘
│ │
└─────────────── HTTPS 정적 콘텐츠 요청 ──────────────────────┘
https://{deviceId}.<BASE_DOMAIN>)과 SSL 인증서를 자동으로 발급·라우팅합니다.모든 웹소켓 통신의 바닥에는 OS 커널 레벨의 TCP 연결 지향형 스트림이 존재합니다.
클라이언트 (키오스크) 서버 (NestJS)
│ │
│ ── 1. TCP SYN ─────────────────────────────────────────────> │
│ <── 2. TCP SYN + ACK ─────────────────────────────────────── │ (3-Way Handshake)
│ ── 3. TCP ACK ─────────────────────────────────────────────> │
│ │
│ === 커널 레벨 소켓 파일 디스크립터(Socket FD) 및 버퍼 할당 완료 === │
이 핸드셰이크가 끝나면 양측 OS 커널에는 소켓 파일 디스크립터(Socket FD)와 송수신 버퍼(TCP Send/Receive Buffer)가 할당되며, 출발지와 목적지의 IP:Port 4-튜플 가상 회선이 연결됩니다.
TCP 소켓이 열려도 곧바로 독자적인 바이너리 패킷을 쏘지 않습니다. 기존 방화벽, L7 역방향 프록시, NAT 장비들이 80/443 외의 낯선 포트와 비표준 패킷을 차단하는 것을 회피하기 위해, 처음에는 합법적인 일반 HTTPS 웹 요청으로 위장하여 진입합니다.
GET /socket/?transport=websocket HTTP/1.1
Host: <API_HOST>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Upgrade: websocket: 이 TCP 연결의 프로토콜을 웹소켓으로 승격해 달라는 선언.Connection: Upgrade: 중간 프록시 장비에 프로토콜 업그레이드 트랜잭션임을 알리는 Hop-by-hop 헤더.Sec-WebSocket-Key: 16바이트 난수의 Base64 인코딩 값. 단순 HTTP 캐시 오동작을 막고 서버의 웹소켓 지원 여부를 검증하기 위한 챌린지 키.HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Key 뒤에 RFC 6455 표준에 정의된 고정 매직 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)를 이어 붙입니다.Sec-WebSocket-Accept 헤더에 담아 보냅니다.HTTP 101 Switching Protocols 응답이 오고 가는 바로 그 순간, 양측 네트워크 엔진의 내부 상태 머신에 결정적인 변화가 일어납니다.
"소켓 파이프라인(동일한 Socket FD)을 그대로 유지한 채, 바이트 스트림을 해석하는 파서(Parser)를 텍스트 라인 파서에서 비트 단위 바이너리 프레임 파서로 완전히 갈아끼웁니다."
| 구분 | 핸드셰이크 이전 (HTTP 모드) | 핸드셰이크 이후 (WebSocket 모드) |
|---|---|---|
| 파서 (Parser) | HTTP 텍스트 파서 | RFC 6455 바이너리 프레임 파서 |
| 해석 규칙 | 줄바꿈(\r\n)을 찾는 문자열 파싱 | 첫 1바이트부터 비트(bit) 단위 디코딩 |
| 헤더 크기 | 요청마다 수백 바이트 ~ 수 KB (Cookie, User-Agent 등) | 단 2 ~ 6 바이트 (FIN, Opcode, Length) |
| 통신 방향 | 반이중(Half-Duplex) / 요청-응답(Pull) 모델 | 완전 전이중(Full-Duplex) / 상호 독립 전송 |
| 서버 동작 | 클라이언트가 GET을 요청해야만 대답(수동적) | 원할 때 언제든 데이터를 밀어 넣음(능동적 푸시) |
[ 핸드셰이크 이전: HTTP 텍스트 파서 ]
TCP Receive Buffer ──▶ [ HTTP Text Parser ]
- 바이트를 ASCII/UTF-8 문자열로 변환
- \r\n(줄바꿈)을 찾으며 헤더 라인 파싱
- 빈 줄(\r\n\r\n) 도달 시 헤더 파싱 종료
▼ (101 응답 발생: 파서 전환!)
[ 핸드셰이크 이후: RFC 6455 바이너리 프레임 파서 ]
TCP Receive Buffer ──▶ [ Binary Frame Parser ]
- 더 이상 GET, POST, \r\n 같은 텍스트 라인을 찾지 않음
- 들어오는 첫 바이트부터 비트 단위 플래그로 즉각 디코딩
- FIN(1bit), Opcode(4bit), Mask(1bit), Length(7bit) 해석
이 순간부터는 GET /... 같은 텍스트를 전송하면 정상적인 요청이 아니라 프로토콜 에러(Protocol Error)로 간주되어 소켓이 즉시 강제 종료됩니다.
프로토콜 스위칭 이후 전송되는 모든 패킷은 아래의 비트 단위 바이너리 프레임 구조를 갖습니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Masking-key (클라이언트 -> 서버 전송 시 32-bit XOR 마스킹 키) |
+---------------------------------------------------------------+
| Payload Data (실제 데이터) |
+---------------------------------------------------------------+
000).0x1: 텍스트 데이터 (UTF-8 문자열, 예: JSON)0x2: 순수 바이너리 데이터 (파일, 바이너리 버퍼)0x8: 커넥션 종료 (Close)0x9: Ping (L7 Heartbeat)0xA: Pong (Heartbeat 응답)0 ~ 125: 데이터의 실제 바이트 크기.126: 뒤따라오는 2바이트(16비트)를 읽어 길이로 사용 (최대 64KB).127: 뒤따라오는 8바이트(64비트)를 읽어 길이로 사용 (대용량 데이터).Payload[i] XOR MaskingKey[i % 4] 형태로 변환되어 날아가며, 서버는 동일한 XOR 연산으로 원본을 복원."서버가 푸시한다"는 말의 본질은 "클라이언트가 요청하지도 않았는데, 클라이언트 컴퓨터 커널의 수신 버퍼(TCP Receive Buffer)에 데이터가 이미 쓰여져 들어와 있는 상태"를 만듭니다.
서버가 원격지 클라이언트의 물리 RAM을 직접 쓰는 것은 불가능하므로, 양측 OS 커널과 랜카드(NIC)가 협력하여 이를 수행합니다.
[서버 프로세스]
│ 1. write(socket_fd, buffer, length) 시스템 콜
▼
[서버 OS 커널 (Send Buffer)]
│
▼ ─── 2. TCP PSH(Push) 플래그 켜서 패킷 방출! ───┐
│ (인터넷 선로)
▼
[클라이언트 랜카드(NIC)]
│
▼ 3. 하드웨어 인터럽트 (DMA 쓰기)
[클라이언트 OS 커널 (Receive Buffer)]
★ 여기에 데이터가 이미 '써짐'! ★
│
▼ 4. "버퍼링 말고 즉시 올려보내!"
[클라이언트 앱 (이벤트 루프)]
socket.on('content:refresh') 즉각 실행
write):read() 요청을 기다립니다.uv_write() 시스템 콜을 호출하여 자기 커널의 Send Buffer에 데이터를 씁니다.PSH(Push) 플래그의 역할:PSH 플래그를 1로 셋팅하여 전송합니다.Receive Buffer 메모리에 데이터를 직접 기록(Write)합니다.epoll/kqueue 이벤트 루프를 즉각 깨웁니다.공공장소에 배치되는 키오스크의 특성을 고려하여 6자리 일회용 코드 페어링과 토큰 해싱을 사용하는 2단계 보안 인증 방식을 적용합니다.
[키오스크 기동]
│
├─ 1. installationId(UUID) 생성
├─ 2. socket.emit('pairing:request') ───▶ [NestJS 서버]
│ │
│ ◀── 3. 6자리 페어링 코드 발급 ───────────────┤ (10분 유효, Rate Limit)
│
[화면에 6자리 코드 표시]
│
[관리자가 Admin 웹에서 6자리 코드 입력 및 승인]
│
│ ◀── 4. socket.emit('device:paired', { deviceToken }) ─┤ (고엔트로피 난수)
│
[키오스크 로컬 보안 저장소에 보관] [DB에 SHA-256 해시값만 저장]
- Electron: safeStorage (authTokenHash)
- Android: Keystore Secure Storage
installationId를 생성한 뒤 소켓으로 임시 코드를 요청합니다.deviceToken이 전달됩니다.safeStorage, Android Keystore)에 암호화하여 보관합니다.auth: { deviceToken }을 검증하여 통과한 소켓만 device:{deviceId} 룸에 바인딩합니다.disconnectSockets(true))됩니다.생성된 웹 화면을 각 키오스크에 서빙하는 인프라 엔진으로 오픈소스 PaaS인 Coolify를 활용합니다.
kiosk-device-{hash})을 생성하여 특정 기기의 장애가 다른 기기에 전파되지 않습니다.https://{deviceId}.<BASE_DOMAIN> 형태의 고유 서브도메인을 할당하고, Let's Encrypt 인증서 발급과 Traefik 역방향 프록시 라우팅을 자동화합니다.site.tar.gz)의 SHA-256 해시를 다운로드 시점에 검증합니다.# 아티팩트 다운로드 및 무결성 검증 스테이지
FROM alpine:3.22 AS artifact
RUN apk add --no-cache ca-certificates curl
ARG KIOSK_ARTIFACT_URL
ARG KIOSK_ARTIFACT_SHA256
RUN curl -fsSL "$KIOSK_ARTIFACT_URL" -o /tmp/site.tar.gz \
&& echo "$KIOSK_ARTIFACT_SHA256 /tmp/site.tar.gz" | sha256sum -c - \
&& mkdir -p /site && tar -xzf /tmp/site.tar.gz -C /site
# 최종 정적 Nginx 서빙 스테이지
FROM nginx:1.29-alpine
COPY --from=artifact /site/ /usr/share/nginx/html/
# 강력한 Content-Security-Policy(CSP) 설정 적용
EXPOSE 80
https://{deviceId}.<BASE_DOMAIN>/__kiosk_manifest.json을 직접 HTTP GET으로 호출하여 배포 ID, 버전 번호, Canonical SHA-256 해시가 정확히 일치하는지 확인한 뒤에만 활성화(ACTIVE) 상태로 전환하고 소켓 푸시를 트리거합니다.이 프로젝트에서 실제로 동작하는 서버와 클라이언트의 소켓 통신 구현 코드입니다.
서버는 핸드셰이크 단계에서 토큰을 검증하고, 기기를 고유 룸(device:{deviceId})에 바인딩한 뒤 필요할 때 즉시 푸시를 발송합니다.
// apps/server/src/gateways/device.gateway.ts
import { WebSocketGateway, WebSocketServer, type OnGatewayInit, type OnGatewayConnection } from '@nestjs/websockets';
import type { Namespace, Socket } from 'socket.io';
@WebSocketGateway({ namespace: '/device', path: '/socket' })
export class DeviceGateway implements OnGatewayInit, OnGatewayConnection {
@WebSocketServer()
server!: Namespace;
// 1. 핸드셰이크 단계 미들웨어: 소켓 연결 시 토큰 인증
afterInit(server: typeof this.server): void {
server.use(async (socket, next) => {
const deviceToken = socket.handshake.auth?.deviceToken;
if (!deviceToken) return next(new Error('UNAUTHORIZED'));
// DB의 SHA-256 토큰 해시 대조
const principal = await this.tokens.authenticate(deviceToken);
if (!principal) return next(new Error('UNAUTHORIZED'));
socket.data.deviceId = principal.deviceId;
next();
});
}
// 2. 연결 체결: 기기 전용 가상 룸(Room)에 조인
async handleConnection(socket: Socket): Promise<void> {
const deviceId = socket.data.deviceId;
await socket.join(`device:${deviceId}`);
// 재접속 시 누락된 최신 배포가 있다면 즉각 동기화 푸시
const current = await this.devices.getCurrentDeployment(deviceId);
if (current && current.isNewerThanLastAck) {
this.pushRefresh(deviceId, current);
}
// 4. 클라이언트의 렌더링 완료 ACK 수신
socket.on('content:ack', async (payload, ack) => {
await this.prisma.device.update({
where: { id: deviceId },
data: { lastAcknowledgedDeploymentId: payload.deploymentId }
});
if (typeof ack === 'function') ack({ ok: true });
});
}
// 3. 서버 푸시(Server Push) 실행 메서드
pushRefresh(deviceId: string, payload: ContentRefreshEvent): void {
// 특정 기기 소켓 룸으로 'content:refresh' 이벤트를 직접 방출!
this.server.to(`device:${deviceId}`).emit('content:refresh', payload);
}
}
클라이언트는 상시 대기하고 있다가 푸시 이벤트를 감지하면, 무결성을 검증하고 화면을 교체한 뒤 서버로 ACK를 전송합니다.
// apps/client/src/socket/device-socket.ts
import { io, type Socket } from 'socket.io-client';
export function createDeviceSocket(adapter: PlatformAdapter) {
// 1. WebSocket 전송 계층으로 즉시 연결 수립
const socket = io('/device', {
path: '/socket',
transports: ['websocket'], // HTTP Long-Polling 폴백 비활성화
auth: async (callback) => {
const deviceToken = await adapter.getDeviceToken(); // OS 보안 저장소에서 로드
callback({ deviceToken });
},
});
// 2. 서버 푸시(content:refresh) 감지 및 화면 갱신
socket.on('content:refresh', async (content) => {
console.log(`새 배포 버전 ${content.version} 푸시 감지!`);
// Zero-Trust: 실제 HTTPS 배포 도메인의 manifest 무결성 검증
const isValid = await verifyManifest(content.url, content.contentHash);
if (!isValid) return;
// 샌드박스 iframe src 갱신 (화면 교체)
updateKioskIframe(content.url);
// 3. 화면 반영 성공 후 서버로 역방향 확인(ACK) 패킷 전송
socket.timeout(5000).emitWithAck('content:ack', {
deploymentId: content.deploymentId,
version: content.version,
loadedAt: new Date().toISOString(),
});
});
// 4. 30초 주기 애플리케이션 Heartbeat 전송
setInterval(() => {
if (socket.connected) {
socket.emit('device:heartbeat', { timestamp: Date.now() });
}
}, 30000);
}
destroy)하여 고스트 커넥션을 제거.ACTIVE 배포와 기기의 lastAcknowledgedDeploymentId를 대조하여 누락된 버전을 즉시 다시 푸시합니다.sandbox="allow-scripts allow-same-origin" 속성으로 제한되어, AI가 생성한 웹 코드가 Electron의 Node.js 네이티브 바인딩이나 OS API에 접근하는 것을 원천 차단합니다.Upgrade: websocket 요청 후 101 Switching Protocols 합의를 완료.write())을 통해 TCP PSH 세그먼트를 방출하면, 클라이언트 OS 커널의 TCP Receive Buffer에 데이터가 이미 쓰여져 들어와 있는 상태가 됨으로써 지연 없는 초고속 실시간 화면 갱신이 달성됨.