웹으로 만든 4컷 포토부스 (2) - 프린트 큐와 워커로 인쇄 분리하기
들어가며
이전 글에서는 4컷 포토부스의 촬영과 합성, 공유 기능을 구현하는 과정을 다루었다. 이번 글에서는 완성된 사진을 실제 프린터로 인쇄하는 부분을 서술하고자 한다.
웹 애플리케이션은 기본적으로 브라우저 샌드박스 안에서 동작하기 때문에 프린터라는 물리 장치에 직접 접근할 수 없다. 처음에는 브라우저가 제공하는 인쇄 기능을 그대로 사용했지만, 운영 규모가 커지면서 이 방식이 한계에 부딪혔고 결국 인쇄를 별도의 프로세스로 분리하게 되었다.
1차 구현: 브라우저 인쇄 기능 활용
그리디콘 부스의 구성은 노트북 한 대에 포토 프린터를 직접 연결하는 방식이었다. 카메라도 노트북 내장 웹캠을 사용했기 때문에, 촬영부터 인쇄까지 모든 과정이 노트북 한 대 안에서 이루어졌다.
이 구성에서는 브라우저의 window.print()를 호출하는 것만으로 인쇄가 가능했다. 인쇄할 영역만 남기고 나머지 UI는 CSS로 숨기는 방식으로 처리하였다.
<div className="... print:hidden">
다만 window.print()를 그대로 호출하면 인쇄 대화상자가 뜨고 사용자가 확인 버튼을 눌러야 한다. 부스를 이용하는 사람에게 브라우저 인쇄 설정 창을 보여줄 수는 없는 노릇이었다. 이 문제는 Chrome을 키오스크 인쇄 모드로 실행하여 해결했다.
chrome --kiosk --kiosk-printing
--kiosk-printing 옵션을 주면 인쇄 대화상자 없이 기본 프린터로 즉시 인쇄가 진행된다. 부스 환경에서는 브라우저를 우리가 직접 실행하기 때문에 이런 실행 옵션을 전제로 두는 것이 가능했다.
이 방식으로 그리디콘 부스는 문제없이 운영할 수 있었다. 구현 비용이 거의 들지 않았고, 부스가 하나뿐이었기에 충분했다.
문제 인식
이후 어린이날 행사 부스에서 같은 서비스를 운영하게 되면서 상황이 달라졌다. 정리하면 두 가지 문제가 있었다.
첫번째, 처리량 — 이용 인원이 크게 늘어날 것으로 예상되었는데, 프린터 한 대의 인쇄 속도는 고정되어 있다. 포토 프린터는 한 장을 뽑는 데 시간이 꽤 걸리기 때문에 촬영 속도가 아무리 빨라도 인쇄에서 병목이 발생한다. 프린터를 여러 대 사용해야 했지만, window.print()는 기본 프린터 한 대로만 인쇄를 보낸다.
두 번째, 장비 의존성 — 인쇄가 브라우저에서 일어나기 때문에 촬영용 노트북이 곧 인쇄 서버가 된다. 촬영 중인 노트북이 인쇄 작업까지 떠안게 되고, 노트북에 문제가 생기면 인쇄도 함께 멈춘다. 촬영 화면을 새로고침하거나 브라우저를 다시 띄우는 순간 진행 중이던 인쇄가 어떻게 될지도 보장할 수 없었다.
결국 인쇄를 브라우저 밖으로 꺼내야 한다는 결론에 도달했다. 브라우저는 사진 인쇄 요청만 보내고, 실제 인쇄는 별도의 프로세스가 담당하는 구조로 변경하기로 했다.
구조 설계
새로운 구조는 다음과 같다.
[촬영 노트북] --인쇄 요청--> [백엔드 서버 / 프린트 큐] <--폴링-- [워커 PC] --> [프린터]
핵심은 백엔드에 프린트 큐를 두고, 프린터에 연결된 PC에서 워커 프로세스가 해당 큐를 폴링하며 작업을 가져가는 것이다. 워커를 여러 개 띄우면 그만큼 프린터를 병렬로 사용할 수 있고, 촬영 노트북은 인쇄 요청만 보내고 그 이후는 관여하지 않는다.
작업을 서버가 워커에게 밀어주는 방식도 고려했지만, 결론적으로는 워커가 작업을 가져가는 폴링 방식을 선택했다. 워커 PC는 행사장에서 임의로 켜지고 꺼질 수 있는 장비이기 때문에 서버가 워커의 주소나 생존 여부를 알고 있어야 하는 구조는 부담이 컸다. 폴링 방식에서는 워커가 서버로 요청을 보내기만 하면 되므로 워커가 몇 대인지, 주소는 무엇인지를 서버가 알 필요가 없다.
프린트 큐 구현
큐는 백엔드 서버 메모리에 단순하게 Map으로 구현하였다.
const queue = new Map();
const JobStatus = Object.freeze({
PENDING: "pending",
PRINTING: "printing",
COMPLETED: "completed",
FAILED: "failed"
});
사진 데이터 자체는 SQLite에 저장하고 있음에도 큐를 DB에 두지 않은 이유는, 인쇄 작업은 행사가 끝나면 의미가 없는 데이터이기 때문이다. 서버가 재시작되면 진행 중이던 인쇄 작업은 어차피 다시 요청받아야 하고, 지난 행사의 인쇄 이력을 조회할 일도 없다.
작업 등록의 경우 이미지 URL만 받아 큐에 넣는다.
export function enqueue(imageUrl) {
const id = makeId();
queue.set(id, {
id,
imageUrl,
status: JobStatus.PENDING,
printerId: null,
createdAt: Date.now(),
claimedAt: null
});
return id;
}
여기서 중요한 부분은 작업을 가져가는 claimNext이다.
export function claimNext(printerId) {
for (const job of queue.values()) {
if (job.status === JobStatus.PENDING) {
job.status = JobStatus.PRINTING;
job.printerId = printerId;
job.claimedAt = Date.now();
return job;
}
}
return null;
}
작업을 반환하면서 동시에 상태를 PRINTING으로 바꾸고 가져간 프린터의 ID를 기록한다. 조회와 점유가 하나의 동작으로 묶여 있어야 두 대의 워커가 같은 작업을 동시에 집어가는 상황을 막을 수 있다. 만약 조회와 상태 변경이 분리되어 있다면 워커 A가 작업을 조회한 직후 워커 B가 같은 작업을 조회해 같은 사진이 두 장 인쇄되는 문제가 발생할 수 있다.
Node.js는 단일 스레드로 동작하기 때문에 이 함수가 중간에 끊기지 않는다는 점을 전제로 삼을 수 있었다.
완료 처리에서도 작업을 가져간 워커만 그 작업을 완료 처리할 수 있도록 하여, 다른 워커가 남의 작업 상태를 건드리지 못하게 막았다.
export function markDone(jobId, printerId) {
const job = queue.get(jobId);
if (!job || job.printerId !== printerId || job.status !== JobStatus.PRINTING) return false;
job.status = JobStatus.COMPLETED;
return true;
}
API 설계
큐를 조작하는 엔드포인트는 크게 세 종류로 나뉜다.
| 엔드포인트 | 용도 | 인증 |
|---|---|---|
POST /print | 인쇄 요청 (촬영 클라이언트) | 없음 |
GET /print-queue/next | 다음 작업 가져오기 | 워커 |
POST /print-queue/:id/done | 인쇄 완료 보고 | 워커 |
POST /print-queue/:id/fail | 인쇄 실패 보고 | 워커 |
GET /print-queue | 큐 전체 조회 | 관리자 |
POST /print-queue/:id/retry | 실패 작업 재시도 | 관리자 |
워커용 엔드포인트와 관리자용 엔드포인트는 인증 방식을 다르게 두었다. 관리자는 사람이 로그인해서 사용하므로 JWT를 통해 막아두었지만, 워커는 사람이 아니라 부스에 설치된 프로세스이기에 공유 시크릿 키를 헤더로 확인하는 것으로 충분하다고 판단했다.
export function verifyWorker(request, reply) {
if (request.headers["x-worker-secret"] !== process.env.WORKER_SECRET) {
reply.code(401).send({ status: false, message: "Unauthorized" });
return false;
}
return true;
}
인쇄 요청 엔드포인트는 이미지 파일을 받아 R2에 업로드한 뒤 그 URL을 큐에 넣는다.
app.post("/print", async (request, reply) => {
const data = await request.file();
// ...
const imageUrl = await uploadImage(buffer, "print.png", "image/png", "temp");
const jobId = enqueue(imageUrl);
return reply.code(201).send({ jobId });
});
큐에 이미지 데이터를 직접 담지 않고 URL만 담은 이유는 서버 메모리에 수십 MB짜리 이미지가 쌓이는 것을 피하기 위해서다. 워커가 작업을 가져갈 때 URL로 직접 다운로드하면 되므로 큐에는 참조만 있으면 충분하다.
워커 구현
워커 자체는 독립 실행 스크립트로 구현했다. 실행할 때 담당할 프린터 이름과 API 주소를 인자로 받는다.
node scripts/print-worker.js --printer="Canon SELPHY CP1500" --api="http://192.168.0.10:3000"
프린터 이름을 인자로 받도록 함으로써 프린터를 여러 대 운영할 수 있다. 같은 스크립트를 프린터 이름만 바꿔 두 번 실행하면 두 개의 워커가 각자 다른 프린터를 담당하며 같은 큐에서 작업을 나눠 가져간다.
워커 동작 자체는 단순한 무한 루프로 구성했다.
async function loop() {
while (true) {
try {
const job = await pollNext();
if (job) await processJob(job);
} catch (err) {
console.error(`[${PRINTER}] Poll 오류:`, err.message);
}
await new Promise(r => setTimeout(r, POLL_INTERVAL));
}
}
작업 처리는 이미지를 임시 디렉터리에 내려받은 뒤 인쇄 명령을 실행하고, 결과를 서버에 보고하는 흐름이다.
async function processJob(job) {
let tmpPath = null;
try {
tmpPath = await downloadToTemp(job.imageUrl);
printFile(tmpPath);
await reportDone(job.id);
} catch (err) {
await reportFail(job.id);
} finally {
if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath);
}
}
실제 인쇄를 수행하는 부분은 운영체제가 네이티브로 제공하는 인쇄 명령을 호출하는 방식으로 처리했다.
function printFile(filePath) {
execSync(`lpr -P "${PRINTER}" "${filePath}"`, { timeout: 30_000 });
}
execSync에 타임아웃을 건 이유는 프린터가 응답하지 않을 때 워커가 무한정 대기하는 상황을 막기 위해서다. 30초가 지나면 예외가 발생하고, 해당 작업은 실패로 보고된 뒤 워커는 다음 작업으로 넘어간다.
여기서 의도적으로 자동 재시도 로직은 넣지 않았는데, 인쇄 실패는 대부분 용지 소진이나 프린터 오류처럼 사람이 개입해야 해결되는 문제이기에 실패한 작업은 FAILED 상태로 두고, 관리자가 상황을 확인한 뒤 직접 재시도하도록 했다.
관리자 페이지
큐를 서버로 옮기면서 인쇄 상태를 한곳에서 볼 수 있게 되었다는 부수적인 이점이 생겼다. 브라우저가 직접 인쇄하던 시절에는 어떤 작업이 밀려 있는지 확인할 방법이 없었지만, 이제는 큐를 조회하면 된다.
관리자 페이지에서 대기 중인 작업과 각 작업을 어느 프린터가 처리하고 있는지 확인할 수 있고, 실패한 작업을 재시도하거나 필요 없는 작업을 큐에서 삭제할 수 있도록 했다.
어린이날 부스 운영
최종적으로 어린이날 부스는 다음과 같이 구성했다.
- 촬영용 노트북 — 촬영, 합성, 인쇄 요청
- 워커 PC — 워커 프로세스 2개 실행
- Canon SELPHY CP1500 2대 — 워커 PC에 연결
프린터 두 대가 같은 큐에서 작업을 나눠 가져가므로 인쇄 처리량이 두 배가 되었고, 촬영용 노트북은 인쇄 요청을 보낸 뒤로는 인쇄에 전혀 관여하지 않는다. 촬영 화면을 새로고침하거나 브라우저를 다시 띄워도 이미 큐에 들어간 작업은 그대로 인쇄된다.
다만 이 구조에도 한계는 남아 있다. 서버는 워커가 살아 있는지 알 수 없기 때문에, 워커 프로세스가 죽더라도 작업은 PENDING 상태로 계속 쌓이기만 한다. 행사 중에는 관리자 페이지에서 대기열이 줄어들지 않는 것을 보고 사람이 직접 확인하는 수밖에 없었다.
다음 글에서는 어린이날 부스를 준비하며 추가한 Gemini 기반 사진 스타일 변환 기능에 대해 다룰 예정이다.