최근 포스팅(리액트 프로덕트에서 POS 구현하기)을 통해 웹 어플리케이션에서 POS 프린터 기능 구현에 대한 경험을 풀어냈습니다.
회사에 구비해 놓은 앱솔론 POS 프린터기에서 꽤 잘 작동했고, QA 팀의 검수도 통과해 큰 문제가 없을거라고 생각했었는데요? 문제가 일어나고 말았습니다.
기능을 구현하기에 앞서, 해당 프로덕트를 사용할 환경에서 사용하고 있는 동일한 기기를 구비해달라고 그렇게 요청드렸건만...
막상 현장에 실사 테스트에 나가보니, 매장에는 기대하던 POS 프린터기와 평범한 PC가 아닌 KCP-C3100이라고 부르는 카드 리더기와 OKPOS 단말기가 세팅되어 있었습니다.
(okpos 단말기는 식당에서 아주 많이 사용하는..네 그 기기가 맞습니다.)
OKPOS 단말기야 앞뒤로 디스플레이가 세팅된 일종의 PC라고 생각하면 문제될 것이 없는데, 카드 단말기가 말썽이었습니다. 분명, 앱솔론 POS 프린터에서 잘만 출력되는데, 이 카드리더기가 물고 있는 COM1 포트로 프린트 요청을 보내면 정상적인 텍스트 대신 ****라는 별표가 대신 프린트됩니다.
이 문제를 해결하기 위해서는 kcp-c3100 가맹 안내 블로그에서 제공하고 있는 POS 기능 활성화 안내를 참고해야했습니다. 링크
특수키 입력 후 1200
포스사용: 1번 선택
단말 연동 POS 통신 방식: 1번 선택
통신포트선택: 2번 선택
통신속도선택: 3번(38400) 선택
한줄 인쇄 모드: 1번 선택
단말 연동 화면 표시시간 설정: 3초
라이브러리에서 포트를 열때, 넘겨주고 있는 통신속도는 9600(baudRate: 9600
)입니다. pos 프린터기에서는 9600속도를 일반적으로 가져가지만, 카드리더기인만큼 4배나 빠른 속도를 사용하는 것 같네요.
그래서 코드를 아래와 같이 수정해주었습니다.
const onClickPrintHandler = async () => {
const data = await render(UserReceipt({ orderinfo }));
const port = await window.navigator?.serial?.requestPort();
if (port.writable === null) {
await port.open({ baudRate: 38400 }); // ← 9600을 38400으로
}
const writer = port.writable?.getWriter();
if (writer !== null) {
await writer!.write(data)
writer!.releaseLock();
}
await port.close();
};
해당 방식대로 카드 리더기의 세팅을 손 봐주니, 프린트 기능은 작동합니다.
하지만 문제 하나 건너 문제라더니, 또 다른 문제가 발생하더군요.
해당 이슈는 꽤 곤혹스러웠습니다. 인쇄물이 출력된다는 것은 print 관련 함수가 제대로 동작한다는 뜻일텐데, 유독 커팅 명령만 제대로 작동하지 않고 있었거든요.
주문이 몰리거나, 바쁠 시 매장에서 일일히 영수증을 가위로 자를 수는 없습니다. 그래서 반드시 해결해야 할 이슈라고 생각하고 매장 구석에 앉아 라이브러리를 뜯어보기 시작했습니다.
// react-thermal-printer/packages/printer/src/BasePrinter.ts
cut(): this {
this.cmds.push({
name: 'cut',
data: cut(48),
});
return this;
}
// react-thermal-printer/packages/printer/src/commands/cut.ts
import { GS } from './common';
/**
* Select cut mode and cut paper
* <Function A>
* | Format | Value |
* |---------|----------|
* | ASCII | GS V m |
* | Hex | 1D 56 m |
* | Decimal | 29 86 m |
*
* <Function B, C, D>
* | Format | Value |
* |---------|-----------|
* | ASCII | GS V m n |
* | Hex | 1D 56 m n |
* | Decimal | 29 86 m n |
*
* @see https://www.epson-biz.com/modules/ref_escpos/index.php?content_id=87
*/
export function cut(m: number, n?: number) {
const cmd = [GS, 0x56, m];
if (n != null) {
cmd.push(n);
}
return cmd;
}
위 코드와 epson 사의 프린트 기술 문서를 확인해 보니, 프린터 대부분이 커팅관련해서는 Function A
와 Function B
를 지원하고 있었습니다. 그리고 라이브러리에는 A
타입을 선택하는지 cut
함수의 파라미터로 숫자48
을 넘겨주고 있구요.
그래서 커팅함수의 타입을 B로 바꾸면 커팅기능이 제대로 작동할까 싶어,BasePrinter.ts
의 cut
함수의 파라미터를 48에서 65와 0에서 250 사이의 정수를 선택해 넣어주었습니다.
// react-thermal-printer/packages/printer/src/BasePrinter.ts
cut(): this {
this.cmds.push({
name: 'cut',
data: cut(65, 65),
});
return this;
}
이렇게 전달하니 분명 해결될 것 같았는데, 역시나 오토커팅은 해결되지 않습니다.
그 순간 불현듯 머리를 스쳐가는게 하나 있었습니다.
const onClickPrintHandler = async () => {
const data = await render(UserReceipt({ orderinfo }));
const port = await window.navigator?.serial?.requestPort();
if (port.writable === null) {
await port.open({ baudRate: 38400 }); // 9600에서 38400으로 수치 조절
}
const writer = port.writable?.getWriter(); // 1)
if (writer !== null) {
await writer!.write(data) // 2)
writer!.releaseLock(); // 3)
}
await port.close(); // 4)
};
짧막하게 프린트 핸들러 함수를 설명하면,
1) port를 열어 열거 가능한 객체를 나타내는 writer를 조회한다.
2) writer 객체의 프로퍼티 중에서 write의 파라미터로
영수증 컴포넌트를 전달함으로서 프린트 함수가 작동한다.
3) releaseLock()은 포트 요청에 외부 변인이 침범이 발생하지 않도록,
포트 요청을 잠그는 역할을 한다.
4) 포트가 이미 열려있다면, 이후 프린트 요청 간 포트를 열려고 할 때
에러가 나기 때문에, 포트를 닫아주어야 한다.
라이브러리 샘플 예제에는 4번 항목이 존재하지 않습니다.
하지만, 포트를 열고난 후 닫아주지 않는다면 거듭 영수증 출력을 할 수 없을 뿐더러, POS기기에서도 카드 결제로 영수증을 출력하려 할때 제대로 작동하지 않게 됩니다. (모두 COM1 포트를 사용하고 있기 때문입니다.)
위 코드의 함수는 사내에서 테스트 할 경우 아무런 문제가 없었지만, 실제 사용환경에서는 port.close()
명령이 영수증 출력에 채 끝나기도 전에 port를 닫도록 요청을 보내는듯 했습니다.
await는 함수의 동기적 실행 순서를 보장할 뿐, 그 타이밍까지는 보장하지 않으니깐요
하여 다음과 같이 코드를 수정했습니다.
const onClickPrintHandler = async () => {
const data = await render(UserReceipt({ orderinfo }));
const port = await window.navigator?.serial?.requestPort();
if (port.writable === null) { // ← 최초 실행에만 포트를 연다.
await port.open({ baudRate: 38400 });
}
const writer = port.writable?.getWriter();
if (writer !== null) {
await writer!.write(data).then(() => setTimeout(() => port.close(), 500)); // ←
writer!.releaseLock();
}
};
포트의 열거 가능한 객체 writer에 출력 요청을 보내고 난 후,
setTimeout
함수를 이용해 port.close()
를 실행하도록 조치했습니다.
더불어 열거가능한 객체가 없을 때를 나타내는, 포트를 맨 처음 열때만 port.open()
이 실행되도록 if 문으로 감싸주었습니다
이 조치를 통해 포트를 닫는 요청은 콜스택이 모두 비워질때까지 테스크 큐에서 기다렸다가 실행될 것입니다. 동기적으로 함수가 작동할 수 있도록 트릭을 이용해 프로그래밍해준 것이죠.
이 결과 영수증 출력이 아주 잘 되었습니다.
- 500밀리 세컨즈를 인자로 넣어주었지만, 사실 0을 넣어도 결과는 마찬가지일겁니다.
- if 문을 제거하더라도 함수는 잘 동작할 것 같네요. 포트는 언제나 닫히지깐요!
결국, 영수증 출력이 되지 않는 범인은 port.close()
이 한 줄이 원인이었네요. 😪
이렇게 트러블 슈팅 성공!!
마블러스에서 마지막 테스크가 될 POS 프린터를 깔끔하게 마무리 지을 수 있어서, 굉장히 기분 좋은 트러블 슈팅 경험을 쌓게 되었습니다. 끝끝내 마무리 하지 못했다면 두고 두고 생각났을 것 같거든요.
라이브러리를 이용해 간단하게 구현될 수 있을 것 같은 문제도 때때로는 실행 환경에 따라서, 제대로 실행되지 않거나, 라이브러리 자체의 세팅 값을 바꿔주어야 할 경우도 많이 발생합니다.
그때는 좌절하지 말고, 적극적으로 트러블 슈팅에 몰입한다면 온전히 나만의 경험으로 만들 수 있을 뿐만 아니라, 라이브러리를 보다 정교화할 수 있는 경험을 쌓을 수 있게 될 것 같습니다.
아마, 윈도우 네이티브 앱 혹은 모바일 네이티브 앱으로 POS를 구현해본 분들은 많겠지만, 웹 클라이언트, okpos 그리고 카드리더기 환경을 이용하는 pos 영수증 출력 기능을 구현해본 사람은 많지 않을 것 같습니다.
위 조합은 자영업자들에게는 국룰 처럼 여겨지기에 누군가 이 블로그를 보고 동일한 과업을 받는다면 쉽게 해결할 수 있기를 기대하겠습니다.
긴 글 읽어주셔서 감사합니다 :-)
테스크 큐 활용 관련해서 글 잘 읽었습니다!
그런데 이해가 안 가는 부분이 있어서 여쭤봅니다.
await writer!.write(data).then(() => setTimeout(() => port.close(), 500))
위 코드블럭에서 꼭 macro task를 사용해야 하나요?
write함수의 microtask 작업이 모두 진행된 이후에 then 핸들러로 순서보장이 될 거 같다는 생각을 했는데 틀린 생각일까요?