Web NFC 한계를 데스크톱 Bridge로 돌파하기 — ACR1252U + Electron + WebSocket으로 사내 NFC 발급 시스템 만들기
💡 이 글이 다루는 범위: 브라우저 ↔ 로컬 에이전트 통신 패턴, NTAG213 NDEF URL write APDU 시퀀스, Electron 패키징(installer),
%APPDATA%로그 운영, 트러블슈팅. 특정 기업 코드/인프라는 모두 배제했다.
브라우저에서 NFC 태그에 URL을 기록해 모바일로 접근 가능한 라벨을 발급하는 시스템을 만들고 싶었다. 하지만 Web NFC API는 안드로이드 Chrome + HTTPS 환경에서만 동작한다. 데스크톱 PC에서 USB NFC 리더기로도 같은 동작을 해야 한다는 요구사항이 더해지자, 브라우저만으로는 답이 없었다.
이 글은 그 한계를 Electron 기반 로컬 에이전트(Bridge)로 우회한 과정을 정리한 것이다. 브라우저는 ws://127.0.0.1로 Bridge에 요청을 보내고, Bridge는 PC/SC 표준을 통해 ACR1252U USB 리더기로 실제 APDU 명령을 전송한다. 결과는 다시 WebSocket으로 회신된다.
스택 구성
| 영역 | 기술 | 비고 |
|---|---|---|
| 브라우저 (FE) | React + TypeScript + WebSocket Client | NFC 발급 다이얼로그 |
| 로컬 에이전트 (Bridge) | Electron + Node.js + nfc-pcsc + ws | 트레이 아이콘 GUI |
| 하드웨어 | ACR1252U-M1 USB NFC Reader | PC/SC CCID 표준 |
| 태그 | NTAG213 (NXP) | 144B user memory |
| 프로토콜 | WebSocket v1.0 (자체 정의) | id 기반 req/res |
| 패키징 | electron-builder + NSIS | Windows installer |
⚠️ 하드웨어 선정 주의: ACR1252U는 PICC + SAM 두 인터페이스가 인식된다. PICC만 사용해야 NTAG 직접 write가 가능하다. SAM 쪽으로 보낸 APDU는 절대 카드에 닿지 않는다.
Part 1 — 왜 Web NFC만으로는 부족한가
Web NFC의 환경 제약
NDEFReader는 다음 조건을 모두 만족해야 동작한다.
- 안드로이드 OS (iOS Safari 미지원)
- Chrome / Edge Chromium
- HTTPS 또는 localhost
- 사용자가 명시적으로 NFC 권한 허용
데스크톱에서는 'NDEFReader' in window 자체가 false다. 환경 미지원 분기로 즉시 끝난다.
데스크톱에서 NFC를 쓰려면
윈도우는 PC/SC(Personal Computer / Smart Card)라는 OS 레벨 표준을 제공한다. ACR1252U 같은 CCID 호환 리더기는 드라이버 설치 없이(혹은 ACS사 표준 드라이버로) 인식된다.
브라우저는 PC/SC에 직접 접근할 수 없다. 별도 네이티브 프로세스가 PC/SC ↔ HTTP/WS 다리를 놓아야 한다. 이걸 "Local Agent" 또는 "Bridge"라고 부른다.
채널 추상화 — 한 코드, 두 경로
브라우저 측에서는 환경에 따라 두 채널 중 하나를 고른다.
type BridgeChannel = 'web-nfc' | 'bridge'
const detectChannel = (): BridgeChannel => {
if ('NDEFReader' in window) return 'web-nfc'
return 'bridge'
}
이렇게 추상화해 두면 모바일은 Web NFC, 데스크톱은 Bridge로 자연스럽게 분기된다.
💡 하나의 에러 체계로 통합: 두 채널의 실패 사유를 동일 enum으로 매핑하면 사용자 안내 메시지를 한 곳에서 관리할 수 있다. 예:
NOT_ALLOWED,NETWORK,BRIDGE_NO_READER,BRIDGE_READBACK_MISMATCH…
Part 2 — Bridge 프로젝트 부트스트랩
디렉터리 구조
nfc-bridge/
├── src/
│ ├── main.ts # Electron entry (tray + lifecycle)
│ ├── server.ts # WebSocket 서버
│ ├── reader-manager.ts # nfc-pcsc 인스턴스 관리
│ ├── write.ts # NTAG213 APDU write 시퀀스
│ ├── encoder.ts # NDEF URL 메시지 생성
│ ├── tlv.ts # TLV + page 분할
│ ├── state.ts # in-flight write 상태
│ ├── log.ts # 파일 sink 로거
│ └── config.ts # 버전/포트 상수
├── electron/ # preload 등
├── electron-builder.yml # NSIS 설정
├── tests/
└── package.json
의존성
npm i ws nfc-pcsc
npm i -D electron electron-builder typescript tsx vitest
⚠️
nfc-pcsc는 네이티브 모듈이다. Windows 빌드 시 Visual Studio Build Tools(C++ workload) + Python이 필요하다. CI 머신과 개발자 PC 모두 갖춰야 한다.
WebSocket 프로토콜 v1.0
요청/응답은 모두 JSON이다. id 필드로 매칭한다.
// C → S
type Request =
| { type: 'ping'; id: string }
| { type: 'write'; id: string; url: string; tagType: string }
// S → C (응답)
type Response =
| { type: 'pong'; id: string; version: string; readers: string[] }
| {
type: 'write-result'
id: string
success: boolean
uid?: string
연결 직후 hello 푸시, 리더기 탈착 시 reader 푸시를 보내면 UI가 실시간으로 갱신된다.
💡 요청 envelope에
id강제: in-flight 요청이 여러 개일 때 응답을 매칭하지 못하면 디버깅 지옥이 펼쳐진다.crypto.randomUUID()정도면 충분하다.
Part 3 — NTAG213에 NDEF URL 쓰기
NDEF URL 메시지 인코딩
NDEF(NFC Data Exchange Format)는 NFC 데이터의 표준 컨테이너다. URL Record 한 개를 담는 메시지의 바이트 구조는 다음과 같다.
[MB+ME+SR+TNF=01] [Type Length=01] [Payload Length] [Type='U'] [Identifier Code] [URI bytes]
0xD1 0x01 0xXX 0x55 0x00 ...
- MB / ME = Message Begin / End (단일 레코드라 둘 다 1)
- SR = Short Record (payload < 256B)
- TNF=01 = Well-known type
- Identifier Code
0x00= 단축 prefix 없음 (URL 전체를 그대로 기록)
여기에 NTAG TLV 래퍼가 한 겹 더 씌워진다.
[Tag=0x03] [Length] [NDEF Message...] [Terminator=0xFE]
페이지 분할 + APDU WRITE
NTAG213은 page = 4 byte 단위. user memory는 page 4 ~ 39(총 144B). page 단위로 잘라 FF D6 P1 P2 04 D0 D1 D2 D3 APDU로 보낸다.
| 필드 | 값 | 의미 |
|---|---|---|
| CLA INS | FF D6 | ACR PICC pseudo-APDU (Update Binary) |
| P1 P2 | 00 page | 페이지 번호 (4~39) |
| Lc | 04 | 길이 = 4B |
| Data | D0..D3 | 페이지 데이터 |
리더기 응답은 SW1 SW2 2바이트만. 9000 = 성공, 그 외는 실패.
const APDU_WRITE_PAGE = (page: number, data: Buffer) =>
Buffer.from([0xff, 0xd6, 0x00, page, 0x04, ...data])
for (const packet of pages) {
const resp = await reader.transmit(APDU_WRITE_PAGE(packet.page, packet.data), 2)
const sw = resp.toString('hex').toUpperCase()
if (sw !== '9000') {
throw new BridgeWriteError('BRIDGE_APDU_FAIL', `page ${packet.page} SW=${sw}`
Read-back 검증 — 신뢰성의 핵심
write SW=9000이 떨어져도 카드를 너무 빨리 떼면 마지막 페이지가 미반영될 수 있다. 운영 신뢰성을 위해 read-back 단계를 무조건 끼운다.
APDU: FF B0 00 04 10 ← page 4부터 16B 읽기
응답: [Data 16B] [SW 2B]
읽은 16B와 memory image 앞 16B가 일치하지 않으면 BRIDGE_READBACK_MISMATCH로 분류한다. 사용자에게는 "새 태그로 교체 후 다시 시도해주세요"로 안내한다.
⚠️ autoProcessing 끄기 필수: nfc-pcsc의 Reader는 기본적으로 카드 접촉 시 ISO7816 SELECT APDU를 자동 송신한다. NTAG는 AID 자체가 없어서 이 SELECT가 실패하면 카드 통신이 깨진다. 리더기 연결 시
reader.autoProcessing = false를 즉시 설정해야 한다.
에러 분류 체계
| code | 사용자 메시지 | 원인 |
|---|---|---|
BRIDGE_NO_READER | 리더기가 연결되지 않았습니다 | USB 미연결 |
BRIDGE_NO_CARD | 카드가 감지되지 않았습니다 | 카드 미접촉 (즉시형 read만) |
BRIDGE_TIMEOUT | 제한시간 내 카드가 접촉되지 않았습니다 | 30s 대기 만료 |
BRIDGE_APDU_FAIL | 카드 명령 처리에 실패했습니다 | SW ≠ 9000 / transmit 예외 |
BRIDGE_READBACK_MISMATCH | 기록 검증에 실패했습니다 | read-back 불일치 |
BRIDGE_DISCONNECTED | 리더기 연결이 끊어졌습니다 | write 도중 USB 분리 |
Part 4 — Electron 패키징 + 배포
NSIS Installer 설정
electron-builder.yml
appId: com.example.nfc-bridge
productName: NFC Bridge
directories:
output: release
win:
target: nsis
artifactName: 'NFC Bridge Installer ${version}.exe'
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false # 사용자별 설치 (관리자 권한 불필요)
shortcutName: NFC Bridge
💡
perMachine: false권장: 관리자 권한 없이 일반 사용자가 설치/제거 가능. 사내 배포에서 IT 헬프데스크 부담을 크게 줄인다.
Tray 아이콘 + 자동 시작
// main.ts (Electron main process)
app.whenReady().then(() => {
app.setLoginItemSettings({ openAtLogin: true }) // 부팅 시 자동 실행
startServer() // WebSocket 시작
createTray() // 트레이 아이콘
})
GUI 창은 띄우지 않는다. 사용자 입장에서 Bridge는 "트레이에 떠 있는 작은 아이콘"으로만 존재한다.
빌드 명령
npm run build # tsc + electron 번들
npm run dist # electron-builder → NSIS .exe 생성
결과물은 release/NFC Bridge Installer 1.0.0.exe. 사용자 PC에 보내 더블클릭만 하면 설치가 완료된다.
사내 배포 흐름
- Bridge
.exe빌드 - 사내 파일 저장소(S3 등)에 업로드 → atchFileId 발급
- FE
constants.ts4값 교체LATEST_VERSIONAGENT_FILE_NAMEAGENT_VERSIONDOWNLOAD_ATCH_FILE_ID
- verify + PR + 머지
- 사용자 PC에서 다이얼로그 "Bridge 다운로드" 클릭 → 신버전 설치
⚠️ 버전 동기화 4곳:
package.json/config.ts(Bridge) /electron-builder.yml::artifactName/ FEconstants.ts. 하나라도 어긋나면 사용자가 받는 파일명과 실제 버전이 따로 논다.
Part 5 — 운영 + 트러블슈팅
파일 로그 sink — Electron GUI 모드의 함정
Electron을 GUI subsystem으로 패키징하면 stdout/stderr가 어디에도 연결되지 않는다. console.log를 아무리 찍어도 사용자 PC에서 볼 방법이 없다.
해결: OS별 사용자 디렉터리에 파일 로그 sink를 두는 것.
// log.ts
const LOG_DIR = path.join(app.getPath('userData')) // %APPDATA%\NFC Bridge (Windows)
const LOG_FILE = path.join(LOG_DIR, 'bridge.log')
fs.mkdirSync(LOG_DIR, { recursive: true })
export const log = {
info: (msg: string, ctx?: object) => {
const line = `[${new Date().toISOString()}] INFO ${msg} ${ctx ? JSON.stringify(ctx) : ''}\n`
운영자는 PowerShell 한 줄로 실시간 추적할 수 있다.
Get-Content -Wait $env:APPDATA\NFC` Bridge\bridge.log
in-flight write 상태 — BUSY 무한루프 방지
처음에는 "write 진행 중에 또 write가 오면 즉시 BUSY 거부"로 설계했다. 하지만 사용자가 태깅 도중 팝업을 닫았다가 다시 열고 재시도하는 시나리오에서 30초간 BUSY만 반복되는 버그가 나왔다.
교훈: 단일 사용자 환경에서 BUSY 보호는 거의 의미가 없다. 새 write 진입 시 stale pending을 강제 clear하고 시작하는 편이 훨씬 자연스럽다.
if (hasPending()) {
const stale = clearPending()
log.warn('overriding stale pending slot', {
staleId: stale.id,
ageMs: Date.now() - stale.startedAt,
})
}
beginPending({ id, url, startedAt: Date.now() })
트러블슈팅 키워드 사전
운영자가 로그에서 무엇을 찾아야 하는지 미리 정리해두면 인계 부담이 확 줄어든다.
| 로그 키워드 | 의미 | 일반적인 조치 |
|---|---|---|
reader connected | USB 인식 성공 | — |
non-PICC reader ignored | SAM 쪽 인식 (정상) | — |
card detected | 카드 접촉 감지 | — |
apdu write success | write 성공 | — |
page X SW=63XX | NTAG 인증 실패 (보호된 페이지) | 미사용 태그로 교체 |
readback mismatch | 기록 검증 실패 | 새 태그로 재시도 |
자주 보고된 사용자 케이스
- "3건 발급 후 4번째부터 실패" → 카드 접촉 시간이 너무 짧음. 1초 이상 밀착 유지 안내.
- "리더기 미연결로 표시" → USB 케이블이 데이터선 없는 충전 전용 케이블. 동봉 케이블 사용.
- "한 번 설치하면 자동으로 뜸" → 정상. 트레이 아이콘으로 확인.
마치며
브라우저의 표준 API 한계는 로컬 에이전트 패턴으로 거의 모두 우회 가능하다. NFC뿐 아니라 USB 시리얼, 영수증 프린터, 바코드 스캐너 같은 하드웨어 연동도 같은 구조다.
이번 프로젝트에서 가장 가치 있었던 의사결정 세 가지:
- 채널 추상화 — 두 환경(Web NFC / Bridge)을 같은 인터페이스로 묶어 FE가 한 코드로 동작.
- 파일 로그 sink — GUI Electron의 디버깅 가시성 문제를 근본 해결.
- read-back 검증 — write SW=9000만 믿지 않는 것. 운영 신뢰성의 절대 기준선.
설치 한 번, 트레이 아이콘 하나로 데스크톱이 모바일 못지않은 NFC 발급 단말이 된다. 그게 이 패턴의 매력이다.
💡 다음에 시도하고 싶은 것: NDEF Sign(서명)으로 위변조 방지, NTAG215/216으로 용량 확장, macOS / Linux 멀티 플랫폼 빌드, AES Lock으로 read-only 보호.