제약이 설계를 만든다

제약이 설계를 만든다

브라우저 OCR, 오프라인 POS, Preview-First UX — 세 팀이 한계를 마주한 방식에서 찾은 더 나은 설계의 공통 문법

오프라인 퍼스트 브라우저 OCR Preview-First UX 제약 기반 설계 IndexedDB 오프라인 웹앱 UX 아키텍처 프론트엔드 설계 결정
광고

'제약'이라는 단어는 대개 문제 앞에 붙는다. 서버를 쓸 수 없다, 네트워크가 없다, 사용자가 결과를 믿지 못한다. 그런데 이 세 팀의 이야기를 나란히 놓고 보면 흥미로운 역전이 보인다. 그들이 만든 가장 좋은 설계 결정은 모두 제약을 우회하려다 나온 게 아니라, 제약을 정면으로 응시하다가 나왔다.

브라우저 안에서 OCR을 돌린다는 것: 파이프라인의 경계를 다시 긋다

@stabrise/scaledp는 PDF 렌더링부터 OCR, NER까지 클라이언트 사이드에서 완결되는 문서 처리 파이프라인이다. 신분증, 계약서, 의료 서류처럼 서버에 올리는 것 자체가 민감한 문서를 다루는 특성상, '업로드 없음'은 단순한 성능 선택이 아니라 제품의 존재 이유다.

그런데 이 제약이 예상치 못한 문제를 드러냈다. 팀이 사용하던 PaddleOCR 래퍼(ppu-paddle-ocr)는 텍스트 감지와 인식을 하나의 run() 호출 안에 묶어두고 있었다. 즉, "이 박스들을 읽어라"는 외부 명령 자체가 불가능했다. DBNet 감지기나 YOLO 서명 감지기가 찾아낸 박스를 PaddleOCR의 인식 모델에 넘길 수 있는 경로가 없었던 것이다. 그 결과 정밀한 감지기를 파이프라인에 추가해도, 최종 인식은 항상 PaddleOCR 자체 감지기의 결과에만 의존해야 했다.

해법은 추상화 레이어를 늘리는 게 아니었다. PaddleRecognizer라는 두 번째 스테이지를 만들어 계약을 명확히 했다: [image, boxes] → Document. TesseractRecognizer와 동일한 시그니처를 공유하도록 설계해서, 어떤 감지기든 자신이 찾은 박스를 PaddleOCR 인식 모델에 넘길 수 있게 됐다. 감지와 인식이 독립적인 스테이지가 되는 순간, 파이프라인의 조합 가능성은 기하급수적으로 늘어났다.

그 과정에서 드러난 세 가지 디테일이 실제 제품의 품질을 갈랐다. 회전된 박스는 원근 변환으로 먼저 펴야 했고(축 정렬 크롭만으로는 15도 기울어진 텍스트를 읽을 수 없다), 박스마다 개별 추론을 호출하는 대신 한 캔버스에 전부 쌓아 단일 배치로 처리해야 했으며, WebGPU 백엔드가 PP-OCR의 특정 연산 그래프를 지원하지 않는 하드 에러는 명시적인 fallbackToWasm 플래그로만 조용히 처리하도록 했다—기본값은 '실패를 크게 드러내는 것'이다. 그리고 단어 분리 버그: 기존 코드는 줄 전체를 읽기 전에 단어를 먼저 잘라 CTC 모델에 각각 넣고 있었다. https:/stabrise.com처럼 슬래시 하나가 사라지는 현상의 원인이었다. 줄 전체를 먼저 인식하고 결과에서 단어를 분리하는 순서의 역전으로, 추론 횟수는 줄고 정확도는 올라갔다.

브라우저 안에서만 돌아야 한다는 제약이 없었다면, 감지와 인식의 강결합이 문제가 되는 순간은 훨씬 늦게 왔을 것이다. 서버에서는 언제든 파이프라인을 재구성하면 그만이니까. 제약이 설계의 정교함을 요구했다.

네트워크가 죽어도 계산대는 멈추면 안 된다: 오프라인 퍼스트의 진짜 의미

라고스 아일랜드의 한 원단 가게. 인터넷은 신호가 잡히는 구석에 세워둔 물병 위의 폰 하나. 세 번째 아이템을 스캔하던 중 연결이 끊겼다. 계산원은 노트에 손으로 적고, 현금을 받고, "나중에 입력할게요"라고 말했다. johnsonfash의 글이 시작되는 장면이다.

그 노트가 진짜 버그다. 스피너가 아니라. 오프라인 상태에서 판매가 노트로 가는 순간, 그 가게의 재고 정확도는 'NTEL이 오늘 기분 좋을 때'라는 조건에 묶인다. Dallio POS의 핵심 약속—'당신의 숫자는 정확하다'—이 조용히 무너지는 지점이다.

팀이 거쳐온 해결 시도들의 계보가 솔직하다. localStorage + JSON 블롭은 일주일쯤 버틴다. 손으로 짠 IndexedDB 래퍼는 쿼리 엔진의 15%를 스스로 재발명하는 꼴이다. 서버 모델을 그대로 클라이언트에 복제하는 두 번째 데이터 레이어는 서버에서 고친 버그를 탭 안에서도 다시 고쳐야 하는 악순환이다. 그래서 선택한 방향은 단순했다: 같은 모델, 같은 findMany, 다른 드라이버.

forge-orm의 설계 원칙은 드라이버를 선택 가능한 피어 의존성으로 분리하는 것이다. 동일한 스키마와 동일한 쿼리 API가 Postgres 위에서도, IndexedDB 위에서도, OPFS의 SQLite 위에서도 동작한다. 오프라인 퍼스트 패턴의 핵심 형태는 간단하다: 로컬에 먼저 쓰고, 아웃박스에 큐잉하고, 연결이 돌아오면 드레인한다. Save 버튼은 밀리초 안에 응답하고, 화면 한 구석의 숫자('3 pending')가 스피너보다 정직하게 상태를 알린다.

두 개의 브라우저 티어도 제약에서 나왔다. IndexedDB는 추가 설치 없이 모든 브라우저에서 동작하지만 SQL이 없다. 하루 매출 집계처럼 JOIN과 GROUP BY가 필요한 쿼리가 생기는 순간, OPFS 위의 SQLite(WASM, Web Worker 필수)가 두 번째 티어로 등장한다. 각 티어의 적용 기준이 명확하다는 점이 중요하다—IndexedDB는 오늘의 작업 세트(판매, 재고, 아웃박스)에 충분하고, 심각한 리포팅은 서버 또는 SQLite 티어에서 처리한다. 그 경계를 흐리지 않은 덕분에 선택의 비용이 명확해졌다.

네트워크가 없는 환경이 아니었다면 이 설계가 필요하지 않았다. 하지만 그 제약이 없었다면 '로컬 쓰기가 먼저'라는 데이터 일관성의 원칙도 이렇게 선명하게 구현되지 않았을 것이다.

결과를 보기 전까지는 아무것도 약속하지 않는다: Preview-First의 신뢰 아키텍처

배경 소음 제거 워크플로우의 표면적 버전은 단순하다: 업로드 → 처리 → 다운로드. 어려운 버전은 프로덕트 설계다: 사람이 결과를 믿기 전에 무엇을 알아야 하는가?

Preview-First 워크플로우는 불확실성을 시스템의 일급 요소로 취급한다. 긴 처리를 믿으라고 요구하는 대신, 결과를 고르기 전에 제한된 방식으로 먼저 들을 기회를 준다. 핵심 시퀀스는 이렇다: upload → 호환성 검사 → preview → 동일 구간 before/after → 내보내기 선택. 각 경계마다 제품과 엔지니어링의 판단이 녹아있다.

프리뷰가 해야 하는 일은 하나다: '이 결과가 나에게 충분히 유용한가?'라는 결정을 돕는 것. 그래서 비교 구간은 반드시 동일해야 한다—원본과 처리된 오디오가 다른 시간대를 사용하면, 조용한 구간과 시끄러운 구간의 차이가 처리 품질의 차이로 오인된다. 구간을 맞추면 그 오해의 여지가 사라진다. 이건 UX 세부 사항이 아니라 정직함의 문제다.

호환성 검사를 프리뷰 요청 이전에 두는 것도 같은 맥락이다. 확장자가 맞아도 정책 검사(계정 허용량, 채널 레이아웃)에서 실패할 수 있다. '파일 선택 = 처리 시작'이라는 암묵적 약속을 하지 않는 것, 그리고 실패 이유를 '파일 문제인지, 계정 문제인지, 일시적 오류인지' 구분해서 알려주는 것—이 두 가지가 지원팀과 사용자 모두의 시간을 아낀다.

상태 설계도 명시적이어야 한다. selected → uploaded → compatible → preview requested → preview available → user chooses export or leaves. 각 상태는 좁은 질문 하나에만 답한다. 프리뷰를 최종 익스포트 잡의 플래그로 모델링하면 재시도, 중단된 세션, 중복 클릭을 처리할 때 취약해진다. 게스트의 1회 무료 프리뷰 제한 역시 브라우저의 버튼 비활성화가 아니라 서버에서 권한을 강제해야 한다—브라우저는 상태를 반영할 뿐, 결정의 권한자가 되어서는 안 된다.

자격 검사(이 미디어가 작업 가능한가)와 권한 검사(이 사용자가 이 작업을 할 수 있는가)를 분리하는 것도 중요하다. 이 두 가지를 하나의 성공 카운터로 합산하면, 사용자가 실제로 어디서 이탈하는지를 분석으로 볼 수 없게 된다. 'preview heard'와 'export chosen'은 다른 의도의 순간이다.

세 사례가 공유하는 문법

세 팀의 제약은 달랐다. 브라우저 안에서만 돌아야 하는 AI 파이프라인, 네트워크 없이도 계속 파는 POS, 처리를 시작하기 전에 먼저 들어볼 수 있어야 하는 오디오 도구. 그런데 이들이 내린 설계 결정에는 공통된 문법이 있다.

경계를 명시한다. PaddleRecognizer가 [image, boxes] → Document라는 계약을 선언하고, 아웃박스가 큐의 깊이를 화면에 드러내고, 호환성 검사가 '확인 중'이라는 문구로 처리 시작을 보류하는 것—모두 시스템의 경계를 사용자와 개발자 모두에게 투명하게 만드는 행위다.

조합 가능성을 확보한다. 감지와 인식을 분리하면 어떤 감지기도 어떤 인식기와 연결될 수 있다. 드라이버를 교체 가능하게 만들면 동일한 쿼리 코드가 오프라인과 온라인 모두에서 동작한다. 자격과 권한을 분리하면 사용자 유형에 따라 다른 경로를 조합할 수 있다.

실패를 조용히 숨기지 않는다. WebGPU 폴백은 기본값이 아니다. 스피너는 정직하지 않다. 브라우저의 비활성화 버튼은 권한의 근거가 되지 않는다. 세 팀 모두 시스템이 나쁜 상태에 빠졌을 때 그것을 명확히 드러내는 쪽을 선택했다.

제약은 설계자를 불편하게 만든다. 하지만 그 불편함이 '이 경험이 사용자에게 진짜 도움이 되는가'라는 질문을 피하지 못하게 한다. 쉬운 선택지가 없을 때 비로소 좋은 선택지가 보인다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요