창고, 도서관, 출입통제 등 현장에서는 RFID 리더기가 키보드처럼 입력을 쏟아내거나, 직렬(USB/RS-232)로 데이터를 흘려보내거나, 게이트웨이를 통해 소켓으로 전달합니다. 이 글은 React 앱에서 이런 데이터를 안정적으로 수집·파싱·상태관리하는 실무 패턴을 요약합니다.
1. 아키텍처 선택 가이드
키보드 웨지(HID): 대부분의 USB 리더기는 스캐닝 후 문자들의 나열 + Enter/Tab 전송을 합니다. 설치가 간단하고 브라우저 호환성이 좋습니다. 단, 포커스 제어와 버퍼링이 필요합니다.
Web Serial/WebHID/WebUSB: 전용 프로토콜을 쓰는 리더기나 게이트 리더에서 직렬 프레이밍을 다뤄야 할 때 유용합니다. 사용자 권한 허용이 필요하며 크롬 계열 브라우저 중심입니다.
백엔드 중계(WebSocket/HTTP): 고정형 리더(게이트/컨베이어)나 여러 리더를 통합하는 경우 백엔드에서 장비를 제어하고 클라이언트는 소켓으로 이벤트만 수신합니다.
2. 키보드 웨지 리더 처리(useRfidKeyboard)
핵심은 버퍼링과 프레이밍입니다. 일반적으로 Enter가 패킷 종료(suffix)로 오므로, 입력 필드가 아닌 곳에서 키다운을 가로채 버퍼에 쌓다가 Enter에서 확정합니다. 노이즈(길이/문자셋) 필터링과 타임아웃도 함께 둡니다.
import { useEffect, useRef, useState } from 'react';
function isEditable(el) {
if (!el) return false;
const tag = el.tagName?.toLowerCase();
const editable = el.isContentEditable;
return editable || tag === 'input' || tag === 'textarea' || tag === 'select';
}
export function useRfidKeyboard({
suffixKey = 'Enter',
minLen = 5,
maxLen = 32,
idleMs = 30,
enabled = true,
} = {}) {
const bufferRef = useRef('');
const timerRef = useRef(null);
const [lastTag, setLastTag] = useState(null);
const [error, setError] = useState(null);
const resetTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => finalize('idle'), idleMs);
};
const validate = (raw) => {
const s = raw.trim();
if (s.length < minLen || s.length > maxLen) return null;
// 숫자/대문자/소문자/하이픈/콜론 정도만 허용
if (!/^[0-9A-Za-z:\-]+$/.test(s)) return null;
return s;
};
const finalize = (reason) => {
if (!bufferRef.current) return;
const parsed = validate(bufferRef.current);
if (parsed) {
setLastTag({ id: parsed, ts: Date.now(), source: 'keyboard', reason });
setError(null);
} else {
setError('RFID 형식 오류');
}
bufferRef.current = '';
};
useEffect(() => {
if (!enabled) return;
const onKeyDown = (e) => {
if (isEditable(e.target)) return; // 사용자가 입력 중이면 무시
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === suffixKey) {
e.preventDefault();
finalize('suffix');
return;
}
if (e.key.length === 1) {
bufferRef.current += e.key;
resetTimer();
}
};
window.addEventListener('keydown', onKeyDown, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [enabled, suffixKey, idleMs, minLen, maxLen]);
return { lastTag, error };
}
// 사용 예시
// const { lastTag } = useRfidKeyboard();
팁: 리더기 설정(접두/접미, Enter/Tab 선택)은 장비 메뉴얼로 변경 가능합니다. 가능한 한 확실한 접미(Enter)와 고유 접두(예: % 또는 STX)를 쓰면 오인식을 줄입니다.
3. Web Serial API로 직렬 리더 처리
브라우저에서 직렬 장치와 통신하려면 사용자 제스처로 권한을 요청하고, 텍스트 스트림을 라인 단위로 파싱합니다. 크롬/엣지 최신 버전 권장입니다.
// 간단한 직렬 연결 및 라인 파서
export async function connectSerial({ baudRate = 9600, onTag }) {
if (!('serial' in navigator)) throw new Error('이 브라우저는 Web Serial을 지원하지 않습니다');
const port = await navigator.serial.requestPort();
await port.open({ baudRate });
const textDecoder = new TextDecoderStream();
const readableClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();
let lineBuffer = '';
(async () => {
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
lineBuffer += value;
let idx;
while ((idx = lineBuffer.indexOf('\n')) > -1) {
const line = lineBuffer.slice(0, idx).replace(/[\r\n]+/g, '');
lineBuffer = lineBuffer.slice(idx + 1);
const tag = line.trim();
if (tag) onTag({ id: tag, ts: Date.now(), source: 'serial' });
}
}
} catch (e) {
console.error(e);
} finally {
reader.releaseLock();
await readableClosed.catch(() => {});
await port.close().catch(() => {});
}
})();
return () => port.close().catch(() => {});
}
장비에 따라 체크섬(LRC/CRC) 또는 STX/ETX 프레이밍을 씁니다. 이 경우 라인 대신 구분자 기반 상태 머신으로 파싱하세요.
4. 백엔드 중계(WebSocket) 패턴
현장에 여러 리더가 있거나 보안·드라이버 제약이 있다면 서버가 장비를 관리하고 브라우저는 소켓 이벤트만 구독합니다. 재연결과 하트비트를 꼭 넣습니다.
import { useEffect, useRef, useState } from 'react';
export function useRfidSocket(url, { retryMs = 1000 } = {}) {
const [lastTag, setLastTag] = useState(null);
const wsRef = useRef(null);
const timerRef = useRef(null);
useEffect(() => {
let stopped = false;
const connect = () => {
wsRef.current = new WebSocket(url);
wsRef.current.onopen = () => {
// 하트비트
timerRef.current = setInterval(() => {
if (wsRef.current?.readyState === 1) wsRef.current.send(JSON.stringify({ type: 'ping' }));
}, 10000);
};
wsRef.current.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'rfid' && data.tagId) {
setLastTag({ id: String(data.tagId), ts: Date.now(), source: 'socket' });
}
} catch {}
};
wsRef.current.onclose = () => {
if (timerRef.current) clearInterval(timerRef.current);
if (!stopped) setTimeout(connect, retryMs);
};
};
connect();
return () => {
stopped = true;
if (timerRef.current) clearInterval(timerRef.current);
wsRef.current?.close();
};
}, [url, retryMs]);
return { lastTag };
}
5. 공통 파싱·디듀프 유틸
중복 태그 스캔 방지, 허용 문자·길이 검증, 맵핑(EPC→비즈니스 키)을 한 곳에 묶습니다.
export function createRfidProcessor({ ttlMs = 800, minLen = 5, maxLen = 32 } = {}) {
let lastSeenId = null;
let lastTs = 0;
const normalize = (raw) => String(raw).trim();
const isValid = (id) => id.length >= minLen && id.length <= maxLen && /^[0-9A-Fa-f:\-]+$/.test(id);
return {
feed(raw, meta = {}) {
const id = normalize(raw);
const now = Date.now();
if (!isValid(id)) return null;
if (id === lastSeenId && now - lastTs < ttlMs) return null; // 디듀프
lastSeenId = id;
lastTs = now;
return { id, ts: now, ...meta };
},
};
}
6. Context로 일원화(Keyboard/Serial/Socket 믹스)
여러 소스에서 들어오는 태그 이벤트를 하나의 컨텍스트로 통합하여 UI 어디서든 구독합니다.
import React, { createContext, useContext, useMemo, useState } from 'react';
import { useRfidKeyboard } from './useRfidKeyboard';
import { useRfidSocket } from './useRfidSocket';
import { createRfidProcessor } from './rfidProcessor';
const RfidContext = createContext(null);
export function RfidProvider({ children, socketUrl, enableKeyboard = true }) {
const [history, setHistory] = useState([]);
const processor = useMemo(() => createRfidProcessor({ ttlMs: 800 }), []);
const kb = useRfidKeyboard({ enabled: enableKeyboard });
const ws = socketUrl ? useRfidSocket(socketUrl) : { lastTag: null };
const lastTag = kb.lastTag || ws.lastTag || null;
React.useEffect(() => {
if (!lastTag) return;
const processed = processor.feed(lastTag.id, { source: lastTag.source });
if (processed) setHistory((h) => [{ ...processed }, ...h].slice(0, 50));
}, [lastTag, processor]);
const value = useMemo(() => ({ lastTag: history[0] || null, history }), [history]);
return <RfidContext.Provider value={value}>{children}</RfidContext.Provider>;
}
export const useRfid = () => useContext(RfidContext);
// 소비 예시
// const { lastTag, history } = useRfid();
UI에서는 마지막 태그를 강조하고, 성공/오류 피드백(사운드/색상)을 즉시 보여주면 작업 효율이 올라갑니다.
7. 보안·안정성 체크리스트
권한: Web Serial/WebHID는 사용자 제스처로만 권한을 얻습니다. 버튼을 두고 연결 상태를 명확히 표시합니다.
포커스 잠금: 키보드 웨지는 포커스가 검색창 등에 가면 데이터가 섞입니다. 스캔 전용 화면에서는 포커스를 강제로 비편집 영역에 둡니다.
로깅: 태그 원문은 민감정보일 수 있습니다. 필드 마스킹 또는 해시 후 저장을 고려합니다.
오프라인: 소켓/네트워크 불안정 시 로컬 큐에 임시 저장 후 재전송 전략을 둡니다.
8. 개발·테스트용 시뮬레이터
실장비 없이도 키보드 웨지 입력을 흉내 내며 UI를 개발할 수 있습니다.
export function simulateKeyboardTag(tag = 'E20034120123456789', suffix = 'Enter', delay = 8) {
const typeKey = (key) => window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
let i = 0;
const timer = setInterval(() => {
if (i < tag.length) {
typeKey(tag[i]);
i += 1;
} else {
typeKey(suffix);
clearInterval(timer);
}
}, delay);
}
// simulateKeyboardTag();
9. 성능·UX 팁
피드백은 100ms 내로: 성공음/녹색 플래시로 작업자의 심리적 대기시간을 줄입니다.
디바운스/디듀프: 근접 리더는 같은 태그를 연속 감지할 수 있으므로 TTL 기반 중복 제거가 필수입니다.
로그는 비동기: 서버 전송은 백그라운드 큐로 처리하고 UI를 블록하지 않습니다.
10. 마무리
React에서 RFID를 다루는 핵심은 입력 소스마다 프레이밍과 오류를 견고히 처리하고, 통합된 상태로 UI에 전달하는 것입니다. 위 훅/컨텍스트 조합을 프로젝트에 맞게 확장하면 대부분의 현장 요구를 커버할 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 지도 경로 탐색(Route Finding) 기능 추가하기 (0) | 2026.07.08 |
|---|---|
| React에서 스크롤 기반 콘텐츠 페이드 인/아웃 구현하기 (0) | 2026.07.08 |
| React에서 웹 브라우저 멀티 스레드(Web Workers) 활용하기 (0) | 2026.07.07 |
| React 앱에서 시각적 차이를 비교하는 Visual Regression 테스트 (0) | 2026.07.07 |
| React에서 커스텀 훅으로 이벤트 디바운스 처리하기 (0) | 2026.07.03 |