회사에서 진행하는 커머스 프로젝트는 "고도몰"로 갈음처리되었지만,
운 좋게도(?) 벤더용 어드민 기능은 어찌저찌 살아남아 벤더어드민 기능을 덧붙이는 작업을 12월 동안 진행하고 있습니다.
이번 콘텐츠는 가장 최근 올린 새로운 기능 중 "POS 프린터 기능"에 대한 글입니다.
이번 기능을 위해 오픈 소스 라이브러리 생태계에 기여해주신 "나석주"개발자님께 먼저 감사의 인사를 전합니다. (깃헙 라이브러리 링크]
일반적인 프린터와는 다르게, 카드 영수증 출력이나 POS기의 주문을 출력하는 프린터는 "써멀 프린터"라고 부릅니다.
용지는 얇고 폭이 좁아서 일반적인 프린터의 그것과는 많이 다른데다, 윈도우7 혹은 심지어 윈도우 XP 환경에서 구동해야 하기 때문에 블루투스로 프린팅 기능을 지원하는 요즘과는 다르게 대부분 시리얼 포트 규격을 채택하고 있습니다.
(물론 USB 2.0을 지원하긴 합니다만,가상 포트 드라이버를 설치해서 사용합니다.)
때문에, "프린팅 기능"을 주문받았을때 가장 먼저 확인한 것은 POS 구동환경과 프린터 기기의 종류였습니다.
아니나 다를까, 제품을 사용할 협력사는 윈도우 7과 구형 써멀 프린터를 사용하고 있었습니다 😇
리액트에서 프린트 기능을 구현할 수 있는 대표적인 라이브러리가 있습니다
바로 React to Print 라이브러리입니다.
링크
import React, { useRef } from 'react';
import { useReactToPrint } from 'react-to-print';
import { ComponentToPrint } from './ComponentToPrint';
const Example = () => {
const componentRef = useRef();
const handlePrint = useReactToPrint({
content: () => componentRef.current,
});
return (
<div>
<ComponentToPrint ref={componentRef} />
<button onClick={handlePrint}>Print this out!</button>
</div>
);
};
react to print
라이브러리에서 소개하는 예시를 살펴보니
useRef
를 이용해서 ref의 current 객체에 프린트하고자 하는 HTML 돔요소를 업데이트 시켜, 출력함수의 content
키의 벨류로 전달하고 있습니다.
직관적이고 사용이 편하다는 생각이 듭니다. 하지만, 위 라이브러리를 사용해서 프린터 기능을 구현하더라도 시리얼 포트를 사용하는 써멀 프린터의 작동방식과는 사못 달라 적용하기 어렵다는 생각이 들었습니다.
바쁜 주방에서 하나하나 프린터 세팅을 다뤄가면서 출력을 할 수는 없을테니깐요 😪
위 질문을 해결하기 위해
다시 열심히 구글에 자료를 서칭한 결과, 엄청난 자료를 찾고야 말았습니다.
JSConf2022 행사에서 요기요 개발자 팀의 나석주 님께서 발표하신 세션을 찾은 것입니다.
위 콘텐츠 덕분에 훨씬 더 자세하게 써멀프린터의 구동환경을 알게 되었습니다.
더불어, 위 세션에서 발표하신 결과물을 npm에 배포해주신 덕분에 제가 필요로한 라이브러리를 바로 적용해볼 수 있었습니다.
해당 라이브러리에 나와있는 깃헙 주소로 들어가면 README.md에 상세하게 라이브러리 사용법이 나와있습니다. 하여, 하단의 코드에서는 제가 라이브러리를 어떻게 적용했는지 소개하려합니다
먼저 주방에 전달될 주문지에 담을 정보를 나타내는 UserReceipt.jsx 파일을 먼저 생성했습니다.
// src/components/UserReceipt
import { Br, Cut, Line, Printer, Text, Row } from "react-thermal-printer";
import moment from "moment";
import { FlexBox } from "src/components/flex-box";
import { Typography } from "@mui/material";
const UserReceipt = (props) => {
const { orderinfo } = props;
if (!orderinfo) return <></>;
return (
<Printer type="epson" width={42} characterSet="korea">
<FlexBox sx={{ alignItems: "center", justifyContent: "center" }}>
<Text>주방주문서</Text>
</FlexBox>
<Text size={{ width: 2, height: 2 }}>
[주문시간] {moment(orderinfo?.paymentDt).format("YYYY-MM-DD HH:mm")}
</Text>
<Text>[오더번호] {String(orderinfo[0]).slice(-3)}</Text>
<Line />
<Row left="메뉴명" right="수량(구분)" />
<Line />
<Br />
<FlexBox sx={{ flexDirection: "column" }}>
{orderItem &&
orderItem?.map((item, index) => {
return (
<>
<Row left={item.goodsNm} right={`${item.goodsCnt} (신규)`} />
{item.optionInfo !== null && (
<Row
left={`ㄴ ${item.optionInfo}`}
right={`${item.goodsCnt} (속성)`}
/>
)}
</>
);
})}
<Br />
</FlexBox>
<Line />
<Text>매장컵</Text>
<Line />
<Br />
<Line />
<Cut />
</Printer>
);
};
export default UserReceipt;
코드 상단에서 임포트하고 있는 라이브러리의 Line
Br
Row
Printer
Text
Cut
등 기본 컴포넌트만 사용해서 구현해도 훌륭하게 영수증을 출력할 수 있습니다.
각 컴포넌트들은 시멘틱하게 네이밍 되어 있기 때문에 뜻 그대로의 기능을 제공합니다.
저 중 가장 중요한 것은 Printer
컴포넌트인데, 일반적인 Container 컴포넌트의 기능을 제공하는 것같지만, 프린터 기능에 관련된 props를 전달하는 아주 중요한 기능을 합니다.
// src/components/VendorOrderList
import { render } from "react-thermal-printer";
import UserReceipt from "./UserReciept";
const VendorOrderListItem = (props) => {
const orderInfo = props.orderInfo;
const onClickPrintHandler = async () => {
const data = await render(UserReceipt({ orderinfo }));
const port = await window.navigator.serial.requestPort();
await port.open({ baudRate: 9600 });
const writer = port.writable?.getWriter();
if (writer !== null) {
await writer.write(data);
await writer.releaseLock();
}
await port.close({ baudRate: 9600 });
};
return (
<Container>
...
<Button
onClick={async () => {
await onClickPrintHandler();
await toggleAlertDialog("smartorder-preparing");
}}
> 접수하기 </Button>
...
</Container>
);
};
버튼을 클릭하면 주문접수와 함께 주방에 있는 써멀프린터로 영수증이 전달될 수 있도록 onClick 함수를 구현해보았습니다.
라이브러리의 README.md의 예제에는 영수증 양식을 변수에 직접 할당해 render
함수의 인자로 전달하고 있었지만, 관심사 분리를 위해 컴포넌트로 분리해 작업했습니다.
JSX 컴포넌트 또한 일종의 함수로 리턴 값으로 HTML Element를 리턴하기 때문에 함수의 형식으로 render 함수의 인자로 전달해주었습니다.
const data = await render(UserReceipt({orderInfo})
window.navigator.serial.requestPort()
문을 통해 실제로는 usb에 연결되어 있지만, 드라이버를 통해 가상 포트에 할당되어 있는 프린터에 접근할 수 있습니다. (전 COM3 포트로 연결을 했습니다.)
함수의 흐름은 라이브러리가 제공한 가이드 대로 진행했지만,
한편으로는 주방 환경에서 영수증을 재 출력하거나 포트가 계속 열려있어 다른 출력요청과 충돌할 수 있다고 생각했습니다.
const onClickPrintHandler = async () => {
const data = await render(UserReceipt({ orderinfo }));
const port = await window.navigator.serial.requestPort();
await port.open({ baudRate: 9600 });
const writer = port.writable?.getWriter();
if (writer !== null) {
await writer.write(data);
await writer.releaseLock();
}
// 작업을 마쳤다면 다시 포트를 닫습니다. 이 한줄로 충돌을 방지
await port.close({ baudRate: 9600 });
};
하여, 하단에 한줄을 더 추가해 출력이 마치면 기존에 열어놓았던 포트 요청을 다시 닫도록 했습니다.
await port.close({ baudRate: 9600 })
라이브러리를 발견한 순간부터 해당 기능을 구현하기까지 2시간 남짓 걸려서 테스트까지 모두 마칠 수 있었습니다.
웹에서 프린트 기능을 구현할 수 있냐는 리드 개발자 님의 우려를 빠르게 해치울 수 있어서 굉장히 통쾌한 기억으로 남을 것 같네요.
이 블로그에 매번 콘텐츠를 작성하는 이유는 오픈 소스 라이브러리에 기여하기 위함입니다. 이번에는 현명하신 개발자 "나석주"님의 도움을 받아 업무를 일사천리로 해결할 수 있었습니다.
제가 도움을 받았듯이, 제가 발행하는 콘텐츠들이 다른 개발자 분들께 유용하게 다가갔으면 좋겠다는 마음을 다시 한 번 갖게 되었습니다.
(React) 트러블 슈팅 : 카드리더기 프린터를 이용하는 환경에서 오토커팅 구현하기
writable null로 나오는데 이럴 경우..기기에서 지원을 안하는 걸까요?