웹으로 만든 4컷 포토부스 (1) - 촬영부터 사진 합성, 공유까지

2026년 8월 5일

들어가며

교내 행사인 2025 세종 그리디콘에서 운영할 부스를 준비하게 되었는데, 여기서 사용할 4컷 포토부스를 웹으로 구현하기로 했다. 이후 세종대학교 SW중심대학사업단에서 주최하는 2026 어린이를 위한 AI/SW 체험프로그램에서도 부스를 운영하게 되면서 AI 사진 변환 기능까지 추가하게 되었다.

위 행사들을 준비하는 과정에서 겪은 시행착오와 고민 과정들을 회고 형식으로 서술해보고자 한다.

요구사항 정리

포토부스에 필요한 기능을 정리하면 다음과 같다.

  • 카메라로 사진을 촬영한 후 마음에 드는 4장을 선택
  • 프레임을 선택하여 4컷 사진으로 합성
  • 합성된 사진을 실물로 인쇄
  • QR 코드를 통해 모바일에서도 사진을 받을 수 있도록 제공
  • 촬영된 사진들이 모이는 방명록 제공

기술 스택 선정

프론트엔드는 React, Vite, Tailwind CSS로 구성했고, 백엔드는 Fastify와 SQLite, 이미지 저장소로는 Cloudflare R2를 사용하였다.

백엔드에서 SQLite를 사용한 이유는 이 서비스가 사실상 행사장 부스 한 곳에서만 동작하는 서비스이기 때문이다. 따라서 외부 의존성을 줄이고 최대한 단순하게 운영하고자 SQLite를 선택하였다.

화면 흐름 설계

포토부스는 사용자가 자유롭게 이동하는 서비스가 아니라 한 방향으로 흘러가는 파이프라인에 가깝다. 따라서 촬영 단계를 그대로 Route에 대응시키도록 구현하였다.

시작 → 사진 촬영 → 사진 선택 → 프레임 선택 → 사진 출력 → 출력 대기

여기에 부스 외부에서 동작하는 화면으로 촬영된 사진이 모이는 방명록, 운영자용 페이지, QR 코드로 접근했을 때 보여지는 공유 페이지가 추가된다.

문제는 단계가 여러 페이지로 나뉘어 있지만 상태는 하나로 이어져야 한다는 점이었다. 촬영한 사진, 선택한 사진, 선택한 프레임, 출력 매수, 방명록 등록 여부, 업로드된 사진 ID, QR 이미지까지 모두 페이지를 넘나들며 유지되어야 했다. 그렇기에 이를 Context 하나로 묶어 처리하였다.

const [capturedPhotos, setCapturedPhotos] = useState<string[]>([]);
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]);
const [selectedFrameSkin, setSelectedFrameSkin] = useState<FrameSkin | null>(DefaultFrame);
const [printCount, setPrintCount] = useState(2);
const [shouldPublishToGuestbook, setPublishToGuestbook] = useState(true);
const [uploadedPhotoId, setUploadedPhotoId] = useState<number>(-1);
const [qrImage, setQrImage] = useState<string>("");

이 과정에서 별도의 상태 관리 라이브러리를 도입할지 고민하였으나, 전역 상태가 하나뿐이고, 그 수명이 한 팀이 부스를 이용하는 몇 분에 불과했기에 도입하지 않기로 했다.

다만 이 선택에는 명확한 단점이 있었는데, 바로 새로고침이 발생하면 모든 상태가 소실된다는 것이었다. 실제로 운영 중에 현장 네트워크 상태가 좋지 않아 새로고침을 하는 경우 처음부터 다시 촬영해야 했다. 지금 돌이켜 생각해보면 최소한 로컬 스토리지에 스냅샷을 저장해두는 정도의 처리는 필요하지 않았을까 하는 생각이 든다.

촬영 기능 구현

카메라는 getUserMedia로 스트림을 받아 <video> 요소에 연결하고, 촬영 시점에 해당 프레임을 캔버스에 그려 데이터 URL로 반환하는 방식으로 구현하였다.

const takePhoto = () => {
    const video = videoRef.current;
    if (!video) return;

    const canvas = document.createElement("canvas");
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    const ctx = canvas.getContext("2d")!;
    ctx.translate(canvas.width, 0);
    ctx.scale(-1, 1);
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

    setCapturedPhotos((prev) => [...prev, canvas.toDataURL("image/webp")]);
};

여기서 카메라 미리보기가 어색하다는 피드백이 있어 translatescale(-1, 1)을 적용하여 좌우 반전 처리도 추가하였다.

촬영 방식의 경우 8장을 촬영한 후 그중 4장을 선택하도록 구현하였다. 4장만 촬영하게 하면 한장이라도 잘못 나왔을 때 처음부터 다시 촬영해야한다는 점에서 처음부터 8장을 촬영하도록 하는 것이 더 효율적이라고 판단하였다.

const TOTAL_SHOTS = 8;
const SHOOT_INTERVAL = 5000;

촬영 시간 간격은 5초로 설정하였는데, 여러번 테스트를 거듭한 끝에 5초가 가장 적절한 시간이라고 판단되어 적용하였다.

남은 시간이 3초 아래가 될 경우 화면 전체에 카운트다운 숫자를 표시하고, 촬영되는 순간 흰 화면을 잠깐 보여주어 플래시 효과를 주었다. 물리적인 셔터음이나 동작이 없는 웹 애플리케이션 특성상 사용자로 하여금 촬영이 완료되었다는 사실을 알려주는 피드백이 필요하다고 생각해 적용하였다.

photo-shoot

추가로 대기하지 않고 바로 촬영하고 싶은 경우를 위해 화면을 터치하면 즉시 촬영되고 타이머가 초기화되도록 구현하였다.

사진 합성 구현

4컷 사진을 만드는 방법은 크게 두 가지로 나눌 수 있다. 캔버스에 좌표를 계산하여 직접 그리는 방식과, DOM으로 구성한 뒤 이를 캡처하는 방식이다. 나는 후자의 방법을 선택하였다.

그 이유는 프레임 디자인이 지속적으로 추가되고 변경될 것으로 예상되었기 때문이다. 캔버스에 직접 그리는 방식은 프레임을 하나 추가할 때마다 매번 좌표 계산을 다시 수행해야 하지만, DOM 방식에서는 프레임을 단순히 JSX로 구성하면 된다.

다만 크기 단위의 경우 픽셀이 아닌 인치로 지정하였다.

<div className={[
    "flex flex-col relative",
    frameType === "landscape" ? "w-[2in]" : "w-[4in]",
    "h-[6in]",
    "p-[0.125in]",
    "gap-[0.0625in]",
].join(" ")}>

2in x 6in 규격의 경우 우리가 흔히 볼 수 있는 세로 네컷 사진 형태이다. 화면에 실물 인화지와 동일한 치수로 렌더링한 뒤 그대로 캡처하는 방식이기에, 여백과 사진 간 간격 역시 모두 인치로 지정했다. 이렇게 하면 인쇄 단계에서 배율을 맞추기 위해 별도로 조정할 필요가 없어진다.

DOM 캡처의 경우 html2canvas 라이브러리를 사용했다.

프레임 구조 설계

프레임 구조는 다음과 같이 정의하였다.

export interface FrameSkin {
    bgColor?: string;
    decorations?: React.ReactNode;
}

위에서 알 수 있듯이 프레임을 구성하는 요소는 배경색과 장식 요소를 담는 ReactNode가 전부이다. 실제 프레임 컴포넌트는 사진이 들어갈 4개의 칸만 그리고, 그 위에 전달받은 decorations를 얹는 방식으로 구성하였다.

<div style={{ backgroundColor: skin?.bgColor }}>
    {/* 사진 4칸 */}
    {skin?.decorations}
</div>

decorations의 타입이 ReactNode이기에 SVG든 절대 위치를 잡은 div든 제약 없이 넣을 수 있고, 디자인을 담당하는 팀원이 좌표 계산이나 이미지 편집 도구 없이 JSX와 Tailwind만으로 프레임을 작업할 수 있었다는 점이 컸다.

물론 DOM을 캡처하는 방식에는 대가가 존재한다. html2canvas는 브라우저의 렌더링 과정을 자체적으로 다시 구현한 라이브러리이기 때문에, 지원하지 않는 CSS를 사용하면 화면과 캡처 결과가 달라지는 현상이 발생할 수 있는 가능성은 존재했다.

업로드 대기 시간 처리

합성된 이미지는 서버에 업로드해야 하는데, 이미지 자체 용량 뿐만 아니라 현장 네트워크 상황이 상이하기 때문에 업로드가 완료될때 까지 로딩 화면을 표시하면 사용자는 부스 앞에서 몇 초간 대기하게 된다.

이를 해결하기 위해 캡처할 DOM을 먼저 복제해둔 뒤 페이지를 먼저 이동시키고, 업로드는 백그라운드에서 처리하도록 구현했다.

const handleFinishSelect = async () => {
    const frameSrc = frameRef.current;
    if (!frameSrc) return;

    const clone = frameSrc.cloneNode(true) as HTMLElement;
    document.body.appendChild(clone);

    navigate(PRINT); // 화면 먼저 이동

    generateAndUploadImage(clone);  // await x
};

cloneNode가 필요한 이유는 navigate 호출과 함께 원본 컴포넌트가 언마운트되기 때문이다. 복제본을 document.body에 붙여두면 화면이 전환되어도 캡처 대상이 유지되며, 업로드가 완료된 후 element.remove()로 정리한다.

사용자가 다음 화면에서 출력 매수를 확인하고 방명록 등록 여부를 선택하는 사이에 업로드가 완료된다. 다만 업로드가 끝나기 전에 출력이 요청되면 안 되므로, 출력 버튼은 사진 ID가 발급된 후에만 활성화되도록 처리하였다.

<Button onClick={handlePrintClick} disabled={uploadedPhotoId === -1}>출력하기</Button>

실제 처리 속도가 빨라진 것은 아니지만, 위와 같은 트릭을 통해 사용자가 대기한다고 느끼는 구간을 없앨 수 있었다.

공유 링크 & QR 구현

실물 사진을 받더라도 모바일에 저장하고 싶은 경우가 많을 것이라 판단하여, 인화되는 이미지 자체에 QR 코드를 합성해 넣기로 했다.

서버는 공유 링크 생성 시 uuid를 발급하고 이를 담은 QR 코드를 base64로 함께 반환한다. 프론트엔드에서는 이를 받아 사진 우측 하단에 합성한다.

const qrSize = canvas.width * 0.2;
const qrX = canvas.width - qrSize - 24;
const qrY = canvas.height - qrSize - 24;

ctx.drawImage(qr, qrX, qrY, qrSize, qrSize);

이렇게 생성된 최종 이미지가 인쇄로 전달되는 동시에 share_links 테이블에 기록된다. QR 코드를 스캔하면 Share 페이지로 이동하여 원본 이미지를 다운로드할 수 있다.

다만 이 과정에서 간헐적인 CORS 문제를 겪었는데, 원인은 다음과 같았다.

const photo = await loadImage(`${photoUrl}?t=${Date.now()}`);

R2에 저장된 이미지를 가져와 canvas에 그리려면 crossOrigin = "anonymous" 설정이 필요한데, CORS 헤더가 없는 응답이 브라우저에 캐시되어 있으면 이후 canvas가 오염(tainted) 상태가 되어 toDataURL() 호출이 실패한다. 캐시 상태에 따라 재현 여부가 달라지는 문제였기에 원인을 찾는 데 시간이 걸렸다.

결국 쿼리스트링에 타임스탬프를 붙여 캐시를 우회하는 방식으로 해결했다. 근본적인 해결책은 R2 버킷의 CORS 설정과 캐시 정책을 정리하는 것이겠지만, 행사를 앞둔 시점에서는 이 방법이 최선이었다.

방명록 구현

촬영된 사진은 기본적으로 방명록에 등록되도록 하였다.

const [shouldPublishToGuestbook, setPublishToGuestbook] = useState(true);

기본값을 true로 설정한 것은 의도적인 선택이다. Opt-in 방식으로 두면 대부분 체크하지 않아 방명록이 비어 있게 되고, 그렇게 되면 부스 옆 화면에 표시할 콘텐츠가 없어지기 때문이다. 대신 체크박스는 눈에 잘 띄는 위치에 배치하여 원하지 않는 경우 쉽게 해제할 수 있도록 했다.

DB에서는 photo_idUNIQUE 제약을 걸어 동일한 사진이 중복 등록되지 않도록 처리하였다.

CREATE TABLE IF NOT EXISTS guestbook (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    photo_id INTEGER NOT NULL UNIQUE REFERENCES photos(id),
    created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);

현장 운영

그리디콘 부스의 구성은 단순했다. 노트북 한 대에 포토 프린터를 직접 연결하고, 카메라는 노트북 내장 웹캠을 그대로 사용하는 방식이었다. 촬영, 합성, 업로드, 인쇄가 모두 노트북 한 대에서 이루어지는 구조다.

부스를 하나만 운영하고 이용자 수도 감당 가능한 수준이었기에 이 구성으로도 문제없이 운영할 수 있었다. 다만 이 구조는 이후 어린이날 행사에서 그대로 한계에 부딪히게 되는데, 이에 대해서는 다음 글에서 자세히 다룰 예정이다.

댓글