온라인 포토부스 썸네일 이미지

Hapqy Photobooth는 설치 없이 웹 브라우저에서 바로 사용할 수 있는 무료 온라인 포토부스 웹앱이다. 처음에는 “웹캠으로 네컷사진 찍고 다운로드하면 끝”이라고 생각했지만, 실제로는 카메라 비율, 모바일 Safari 필터, Canvas 합성, 프레임 좌표, GIF 용량, AdSense, SEO, Cloudflare 배포까지 하나씩 해결해야 했다.

이 글은 바이브코딩으로 온라인 포토부스를 만들면서 실제로 사용했던 개발 방향, 수정한 코드 구조, 문제 해결 과정, 그리고 배포 후 SEO 세팅까지 정리한 개발일지다.

1. 처음 목표는 “웹에서 바로 쓰는 포토부스”였다

처음 만들고 싶었던 것은 앱 설치 없이 브라우저에서 바로 실행되는 포토부스였다. 반대로 링크만 있으면 모바일에서도 당연 사용할 수 있게 만드는 것이 목표였다. 사용자는 웹캠으로 사진을 찍고, 3컷 또는 4컷 프레임을 고른 뒤, 필터와 스티커를 적용하고, 최종 결과물을 다운로드할 수 있어야 했다.

초기 기획 기능

  • 웹캠 촬영
  • 이미지 업로드
  • 3컷·4컷·4:5 프레임 선택
  • 흑백, 하두리, Y2K, Dream POP, Fisheye 필터
  • 스티커와 날짜 스탬프
  • 최종 이미지 다운로드
  • 모바일과 데스크탑 동시 지원

초기 프롬프트는 대략 이런 방향이었다.

React로 온라인 포토부스 웹앱을 만들어줘.

기능:
- 웹캠으로 사진 촬영
- 3컷, 4컷 프레임 선택
- Y2K, 하두리, 흑백 필터 적용
- 스티커 꾸미기
- 완성된 이미지 다운로드
- 모바일과 PC 모두 지원

디자인:
- Y2K 감성
- 윈도우 XP 스타일
- 핑크, 하늘색, 레트로 UI

AI가 기본 구조를 만드는 데는 도움이 됐지만, 실제 서비스로 만들기 위해서는 대부분의 세부 기능을 직접 테스트하고 수정해야 했다. 특히 모바일 환경에서 발생하는 문제는 프롬프트 한 번으로 해결되지 않았다.

온라인 포토부스 초기 기획 이미지
솔직히 몇 번이나 필터를 고치고, 테스트하는 동안 4컷 찍기를 반복하며..
네컷사진에 대해 흥미를 잃을 정도였다ㅎ 징글징글한 수정의 굴레..

2. 프로젝트 구조는 React + Vite + Canvas로 잡았다

프로젝트는 React와 Vite 기반으로 만들었다. 화면 전환과 상태 관리는 App.jsx에서 처리했고, 사진 합성과 프레임 적용은 src/utils/canvas.js에서 Canvas API로 처리했다.

파일역할
src/App.jsx화면 전환, 카메라 실행, 촬영, 편집 UI, 다운로드, 광고 호출
src/utils/canvas.js사진 합성, 필터 처리, 프레임 PNG 적용, 최종 이미지 생성
src/index.css전체 UI, 모바일 레이아웃, 포토부스 화면, 편집창 스타일
public/frames투명 PNG 프레임 저장
public/images로고, 배경, 인트로 이미지, GIF 저장

처음부터 완벽한 구조를 만든 것은 아니었다. 초기MVP로 시작해서 자꾸 욕심이 나 기능을 추가하다 보니 App.jsx가 커졌고, 이후 핵심 합성 로직을 canvas.js로 분리했다. 이 구조 덕분에 프레임과 필터를 계속 추가할 수 있었다.

코딩 전문가는 아니어도 GPT의 설명 덕분에 조금씩 이해는 하면서 진행했던 것 같다.

3. 웹캠 촬영 기능에서 첫 번째 난관이 나왔다

웹캠은 navigator.mediaDevices.getUserMedia를 사용했다. PC에서는 비교적 쉽게 동작했지만, 모바일에서는 카메라 비율과 확대 문제가 계속 발생했다.

const stream = await navigator.mediaDevices.getUserMedia({
  video: {
    facingMode: "user",
    width: { ideal: 1280 },
    height: { ideal: 720 },
  },
  audio: false,
});

처음에는 모바일 화면을 4:5처럼 보이게 만들려고 했다. 그런데 이 방식은 촬영된 사진 자체가 세로형으로 저장되는 문제를 만들었다. 4컷 프레임은 가로로 긴 직사각형 슬롯인데, 모바일 촬영본이 세로로 들어가면서 얼굴이 과하게 확대되어 보였다.

결론

모바일 화면을 예쁘게 보이게 하는 것보다, 최종 사진이 프레임 슬롯 안에 자연스럽게 들어가는 것이 더 중요했다. 그래서 촬영 결과물은 프레임 비율 기준으로 저장하고, 미리보기 UI만 모바일에 맞게 조정하는 방향으로 바꿨다.

그리고 애초에 카페에서 노트북 앞에 앉아 웹캠을 찍으며 꺄르르 거리는 여고생들을 보고 레퍼런스를 착안하기도 했다.
이후 생각보다 네컷사진 니즈가 있는 사람들에겐 필요한 순간이 많다는 것을 깨닫고 퍼소나를 하나씩 쌓아갔다.

4. 모바일 셀카가 너무 확대되는 문제

모바일에서 한 손으로 셀카를 찍으면 얼굴이 너무 크게 들어오는 문제가 있었다. 데스크탑에서는 자연스럽게 넓게 보이는데, 모바일에서는 화면이 좁고 카메라가 확대된 것처럼 보였다.

원인은 모바일 전면 카메라의 실제 센서 비율과 브라우저 표시 비율이 다르기 때문이었다. 특히 세로 화면에서 16:9 프레임 슬롯에 맞추면 중앙 크롭이 강해지고, 그 결과 얼굴이 확대되어 보였다.

처음에는 Zoom 값을 줄여 해결하려고 했다. 하지만 단순히 scale만 줄이면 프레임 안에 여백이 생기거나, 사진이 세로형으로 보이는 문제가 생겼다. 그래서 최종적으로는 촬영 저장 단계와 편집 합성 단계를 분리해서 해결했다.

수정 방향

  • 모바일 촬영 화면은 사용자가 보기 편하게 유지
  • 촬영 저장본은 프레임 슬롯 비율에 맞춰 저장
  • 편집 화면에서는 Zoom 최소값을 낮춰 사용자가 더 넓게 조정 가능하게 변경
  • 세로형으로 강제 변환하지 않도록 수정
근데 지금도 모바일 환경보다는 웹에서 찍는 게 훨씬 광각이라 좀 더 예쁘게 나오는 느낌…?
난 부모님이랑 모바일로 네컷 찍는 게 목표였는데.. 모바일 화면 비율이 아직도 100% 맘에 들지는 않는다ㅜ

5. 필터는 PC와 모바일에서 다르게 동작했다

필터는 처음에 Canvas의 ctx.filter를 사용했다. PC 크롬에서는 흑백 필터가 정상적으로 적용됐지만, 모바일 Safari에서는 촬영 화면과 촬영 후 결과물 모두 흑백이 적용되지 않았다.

ctx.filter = "grayscale(1) contrast(1.18) brightness(1.04)";
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

이 방식은 브라우저 의존성이 있었다. 특히 모바일 Safari에서는 Canvas filter가 기대대로 동작하지 않을 수 있었다. 그래서 최종 이미지 합성 단계에서는 필터를 CSS나 ctx.filter에만 의존하지 않고, 픽셀 데이터를 직접 조정하는 방식으로 바꿨다.

핵심 인사이트

미리보기 필터와 최종 저장 필터는 분리하는 것이 안전하다. 사용자가 보는 화면에는 브라우저 필터를 쓰더라도, 최종 저장본은 Canvas 픽셀 처리로 강제 적용하는 방식이 더 안정적이었다.

그래서 사진 찍을 때 입혀지는 필터 화면이랑 사진 결과물 필터 차이가 있어서 이걸 해결해보고자 여러 시도를 했는데, 내가 구조 설명을 잘 못하는 탓일까..? 아직 해결이 안 된다. 어케 일치시키는 지 꿀팁 좀 날려주십쇼ㅜ

6. 프레임 시스템은 투명 PNG와 좌표값이 핵심이었다

포토부스의 핵심은 프레임이다. 프레임은 단순 이미지가 아니라, 사진이 들어갈 영역이 투명하게 뚫린 PNG여야 했다. Canvas에서는 사진을 먼저 그리고, 그 위에 PNG 프레임을 덮는 방식으로 합성했다.

public/frames/
├─ 4cut_black.png
├─ 4cut_white.png
├─ 4cut_sky.png
├─ 4cut_whitestar.png
├─ 4cut_blackdot.png
├─ 3cut_princess.png
├─ 3cut_kimdokrae.png
├─ 3cut_catofqueen.png
├─ 3cut_bluedot.png
└─ 3cut_pinkdot.png

프레임을 추가할 때는 PNG 파일만 넣는다고 끝나지 않았다. 사진이 들어갈 위치를 FRAME_CONFIG 안에 직접 등록해야 했다.

{
  key: "whitestar",
  label: "White Star",
  src: "/frames/4cut_whitestar.png",
  slots: [
    { x: 51, y: 54, w: 799, h: 420, radius: 0 },
    { x: 51, y: 514, w: 799, h: 420, radius: 0 },
    { x: 51, y: 974, w: 799, h: 420, radius: 0 },
    { x: 51, y: 1434, w: 799, h: 420, radius: 0 },
  ],
}

좌표가 조금만 틀려도 사진이 프레임 밖으로 삐져나오거나, 투명 구멍과 사진이 맞지 않았다. 그래서 새 프레임을 추가할 때마다 포토샵 기준 좌표와 Canvas 좌표를 맞추는 작업이 필요했다.

온라인 포토부스 초기 기획 이미지
수치를 하나하나 정리하기 귀찮으니, 이럴 때 캡쳐 슉슉 해서 ai한테 값들 정리하라고 하면 된다.

7. 새 프레임을 추가할 때는 두 파일을 수정했다

프레임 추가는 크게 두 단계였다.

수정 위치수정 내용
src/utils/canvas.js프레임 이미지 경로와 사진 슬롯 좌표 등록
src/App.jsx사용자가 선택할 수 있는 프레임 버튼 등록
public/frames실제 프레임 PNG 파일 저장

예를 들어 App.jsx에는 다음처럼 버튼용 프레임 옵션을 추가했다.

{ key: "whitestar", label: "White Star", color: "#fffaf0" },
{ key: "blackdot", label: "Black Dot", color: "#111111" },
{ key: "kimdokrae", label: "Kimdokrae", color: "#f5a623" },
{ key: "catofqueen", label: "Cat Queen", color: "#ffffff" }

이 구조로 정리한 뒤에는 새 프레임을 추가하는 과정이 훨씬 명확해졌다.

8. 편집 화면은 모바일 UX가 가장 어려웠다

편집 화면에서는 사용자가 필터, 위치 조정, 줌, 스티커, 날짜 스탬프를 조작해야 했다. 데스크탑에서는 괜찮았지만 모바일에서는 버튼과 슬라이더가 너무 많아 화면이 답답해졌다.

특히 필터 버튼이 한 줄로 길게 배치되면 다른 기능을 조작하기 어려웠다. 그래서 모바일에서는 필터 버튼을 2열 그리드로 바꿨다.

@media (max-width: 768px) {
  .booth-filter-tabs,
  .chip-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
  }
}

또한 편집창 안에서 프레임이 너무 길면 스크롤이 되지 않는 문제가 있었다. 이 문제는 overflow-y, touch-action, max-height 조합을 조정하면서 해결했다.

확실히 모바일 환경에서 사용성이 편하면서도 예쁘게 가꾸는 건 UX/UI 디자이너의 역량이 많이 중요했다는 것을 느낀다..
콘텐츠 디자인 정도의 역량인 나에게는 이 불편함을 해소하는 게 아직도 너무 많이 부족하다..
버튼과 결과물 화면이 너무 멀지 않으면서 손에 거슬리지 않게 배치하는 게 머리로는 상상이 되는데 실현이 잘 안 되는 게 가장 답답했다!

9. 인트로 애니메이션은 GIF로 결정했다

메인 화면의 핫핑크 포토부스 이미지를 움직이게 만들고 싶었다. 처음에는 GIF를 넣었지만 파일이 약 89MB까지 커져 Cloudflare Pages 배포 제한에 걸렸다.

Pages only supports files up to 25 MiB in size
images/photobooth-box.gif is 89.6 MiB in size

MP4로 바꾸면 용량은 1MB 이하로 줄었지만, 투명 배경이 유지되지 않아 검정 박스가 생겼다. 이 포토부스 이미지는 배경 위에 자연스럽게 떠 있어야 했기 때문에 투명 배경이 중요했다.

형식장점문제
GIF투명 배경 유지, img 태그로 간단용량이 커질 수 있음
MP4용량이 매우 작음투명 배경 미지원
WebM투명 영상 가능브라우저 호환성 확인 필요
Animated WebP가볍고 투명 가능제작 과정이 번거로움

최종적으로는 GIF를 유지하되, 프레임 수와 해상도를 줄여 약 12.7MB까지 압축했다. Cloudflare Pages의 파일 1개당 25MB 제한보다 낮아졌기 때문에 정상 배포가 가능했다.

10. SEO는 웹사이트 운영 시 필수다

처음 사이트는 기능 중심이라 검색엔진이 읽을 텍스트가 거의 없었다. 그래서 메인 화면 아래에 SEO 설명 섹션을 추가했다. 디자인을 해치지 않도록 반투명 카드 형태로 넣고, 한국어와 영어 키워드를 함께 배치했다.

포토부스는 어쩌면 국내보다 해외에서 조금 더 핫한 키워드 같았다. 핀터레스트에도 바이브코딩으로 만든 외국인 분들 영상이 많이 보여서 영어로 구성하는 것은 필수였다.

index.html에는 title, description, canonical, OG 태그, Twitter Card, JSON-LD까지 추가했다.

<title>무료 온라인 포토부스 Hapqy Photobooth|Y2K 감성 레트로 네컷사진 만들기</title>

<link rel="canonical" href="https://hapqy-photobooth.luckymoum.com/" />

<meta
  name="description"
  content="Hapqy Photobooth는 설치 없이 웹에서 바로 사용하는 무료 온라인 포토부스입니다. 웹캠 촬영과 이미지 업로드로 Y2K 감성 레트로 네컷사진, 포토스트립을 만들고 다운로드할 수 있습니다."
/>

특히 JSON-LD는 화면에 보이는 기능은 아니지만, 검색엔진에게 이 사이트가 웹앱이라는 점을 구조적으로 알려주는 역할을 한다.

뭔가 독보적인 키워드를 정하고 싶었는데, 사실 ‘온라인 포토부스’라는 키워드 자체가 검색량이 많지 않아서 그 중에서 그나마 잠재력 있는 단어들로 구성해서 문장을 만든 것 같다.

11. 네이버 서치어드바이저 등록에서 삽질했다

네이버 서치어드바이저 등록 과정도 간단하지 않았다. 메타태그를 넣었는데도 네이버가 찾지 못했고, HTML 확인 파일을 넣었는데도 404가 뜨는 문제가 있었다.

대체 왜!? 지금까지 10개가 넘는 많은 사이트들의 등록이 수월했는데 왜 이 것만 안 되는 것인가…

Vite 프로젝트에서는 public 폴더 안의 파일이 빌드 후 dist 루트로 복사된다. 따라서 네이버 확인 파일, robots.txt, sitemap.xml은 public 폴더에 넣어야 했다.

public/
├─ naver9d2544d8f04f36abee383d850bcd884a.html
├─ robots.txt
└─ sitemap.xml

Cloudflare 로그에서 네이버 Yeti 봇이 실제로 접근한 것도 확인했다. 결국 설정 문제라기보다 네이버 인증 서버 반영 지연에 가까웠고, 시간이 지난 뒤 인증이 성공했다.

아직도 네이버에 등록은 실패한 상태다…
분명 ssl도 정상적으로 발급 받았는데 보안인증서 부분이 확인중이라고 한다..
아무래도 트래픽이 없어서일까…?
나 SEO 귀신인데 완전 실패바리..
자랑스럽게 ‘네이버에 검색하세요!’ 하고 싶은데 안 뜬다 엉엉

12. sitemap.xml도 네이버 기준에 맞춰야 했다

처음 sitemap.xml을 만들었을 때는 네이버에서 형식 오류가 났다. 네이버는 사이트맵 형식에 민감하기 때문에 XML namespace도 정확히 맞춰야 했다.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://hapqy-photobooth.luckymoum.com/</loc>
    <changefreq>weekly</changefreq>
    <priority>1.0</priority>
  </url>
</urlset>

단순한 한 페이지 웹앱이라도 robots.txt와 sitemap.xml을 갖춰두면 검색엔진 등록 과정에서 훨씬 안정적이다.

13. 광고는 React 방식으로 적용했다

애드센스를 사용해서 조금의 서버비라도 벌어보자는 마음에 광고 위치를 정말 많이 고심했던 것 같다. 하지만 디자인이 된 사이트이기도 하고, 사진 찍을 때 광고로 인해 방해되는 것은 원치 않아서 최대한 사용자에게 거슬리지 않을 위치를 계속 테스트했던 것 같다.

결과 화면에 수동 디스플레이 광고를 넣었다. 하지만 애드센스에서 복사한 코드를 App.jsx 안에 그대로 넣으면 React 문법과 충돌한다.

예를 들어 HTML에서는 class를 쓰지만 React에서는 className을 써야 한다. 또한 style="display:block"도 React에서는 객체 형태로 바꿔야 한다.

<div className="ad-slot">
  <ins
    className="adsbygoogle"
    style={{
      display: "block",
      width: "100%",
      minWidth: "250px",
      minHeight: "90px",
    }}
    data-ad-client="ca-pub-xxxxxxxxxxxxxxxx"
    data-ad-slot="xxxxxxxxxx"
    data-ad-format="auto"
    data-full-width-responsive="true"
  />
</div>

광고 호출도 script 태그를 직접 넣는 대신, 결과 화면이 렌더링된 뒤 useEffect에서 실행했다.

useEffect(() => {
  if (screen !== "result" || !resultUrl) return;

  const timer = setTimeout(() => {
    requestAnimationFrame(() => {
      try {
        const ad = document.querySelector(".ad-slot .adsbygoogle");
        if (!ad) return;

        const width = ad.getBoundingClientRect().width;
        if (width <= 0) return;

        (window.adsbygoogle = window.adsbygoogle || []).push({});
      } catch (error) {
        console.error("AdSense push error:", error);
      }
    });
  }, 800);

  return () => clearTimeout(timer);
}, [screen, resultUrl]);

중간에 No slot size for availableWidth=0 오류가 있었는데, 이는 광고가 렌더링되는 순간 광고 슬롯의 너비가 0으로 잡혔다는 뜻이었다. 그래서 최소 너비와 높이를 명시하고, 화면 렌더링 이후 광고를 push하도록 수정했다.

14. 자동 광고는 제외하고 수동 광고만 남겼다

포토부스는 사용자가 촬영하고 편집하는 도구형 웹앱이다. 자동 광고가 촬영 화면이나 편집 화면 중간에 끼어들면 사용성이 크게 떨어질 수 있다.

그래서 애드센스 페이지 제외에 포토부스 도메인을 추가했다.

https://hapqy-photobooth.luckymoum.com/*

이 설정은 자동 광고를 제외하기 위한 것이고, 결과 화면에 직접 넣은 수동 디스플레이 광고는 그대로 노출되도록 구성했다. 최종적으로는 결과 이미지 아래에만 광고가 보이는 구조가 되었다.

웹에서 특정 페이지에 광고를 숨기게 할 수 있다는 것은 이번 프로젝트를 하면서 처음 알았다!

꽤 좋은 인사이트였고, 사용성을 고려하며 ‘광고를 넣지 말아야 하나’ 천만 번 고민했지만.. 내 서버비를 위해 어느 정도는 노출시켜야만 했는데 합의점을 찾을 수 있었다.

광고 너무 미워하지 말아줘요..ㅠ

15. 배포는 GitHub + Cloudflare Pages로 구성했다

배포는 GitHub에 push하면 Cloudflare Pages가 자동으로 빌드하는 방식으로 구성했다.

git add .
git commit -m "update photo booth"
git push
항목설정
빌드 명령어npm run build
빌드 출력 디렉터리dist
배포 플랫폼Cloudflare Pages
도메인hapqy-photobooth.luckymoum.com

배포 과정에서 가장 중요한 것은 public 폴더와 dist 폴더의 역할을 이해하는 것이었다. Vite에서는 public 폴더에 넣은 정적 파일이 빌드 후 dist에 복사된다. 따라서 robots.txt, sitemap.xml, 네이버 인증 파일, 프레임 이미지, GIF는 public 안에 넣어야 했다.

보통 너무 무겁지 않은 사이트들은 클라우드 플레어 페이지 배포로 자주 하는 것 같다!

16. 이 프로젝트에서 얻은 가장 큰 인사이트

이번 프로젝트를 만들면서 가장 크게 느낀 것은 AI가 코드를 만들어줄 수는 있지만, 제품을 완성하는 것은 결국 직접 테스트하고 수정하는 과정이라는 점이다.

초기 코드는 빠르게 만들 수 있었다. 하지만 실제 사용자가 쓸 수 있는 웹앱으로 만들기 위해서는 모바일 촬영, Safari 필터, 프레임 좌표, 이미지 품질, 광고 렌더링, 검색엔진 등록 같은 세부 문제를 하나씩 해결해야 했다.

정리하자면..

AI는 개발의 출발 속도를 크게 높여준다. 하지만 최종 품질은 문제를 발견하고, 원인을 좁히고, 직접 수정하는 과정에서 결정된다. Hapqy Photobooth는 AI가 대신 만든 서비스라기보다, AI를 개발 파트너로 활용해 수작업으로 완성한 웹에 가깝다.

17. codex로 자동 개발하지 않은 이유?

사실 바이브코딩 하면 클로드 코드(claude code)나 코덱스(codex)로 많이 하게 되는데, 이건 성격 탓일 수도 있음..

내가 모르는 영역에서 뭐가 바뀌는지 모르는 채로 만드는 것이 너무 답답했다. 중간에 뭐가 망가져도 원인을 파악하기도 어렵고 길을 잃어 포기하게 될 때가 더 많았다.

그래서 이렇게 1억 번의 수정이 발생하더라도 폴더 구조와 코드의 위치를 내가 인지하면서 수정하는 게 훨씬 컨트롤하기 쉽고 마음이 놓였다.

사실 코덱스한테 명령하면 짠!하고 만들어주긴 한다. 근데 원치 않는 영역까지 바꿔버리는 날에는 복구하는 것마저 짜증나기도 하더라..

개개인의 방식이 있겠지만 난 아무래도 반자동이 조금 더 편한 사람 같다.(강력한 통제형 인간..?)

내 손아귀에서 벗어난 코드 다 죽어..

18. 앞으로 개선할 부분

  • 모바일 광각 촬영 옵션 개선
  • 프레임별 좌표 관리 자동화
  • 사용자 직접 프레임 업로드 기능
  • QR 코드 공유 기능
  • 완성 이미지 SNS 공유 기능
  • 텍스트 꾸미기 기능
  • 프레임 카테고리 정리
  • SEO용 하위 랜딩 페이지 추가

Hapqy Photobooth는 아직 완성형이라기보다 계속 업데이트되는 사이드 프로젝트다. 하지만 아이디어, 디자인, 개발, 배포, 광고, 검색 등록까지 한 사이클을 직접 통째로 경험했다는 점에서 의미가 있다. 앞으로도 기능을 추가하면서 더 완성도 높은 온라인 포토부스 웹앱으로 발전시켜볼 예정이다.

이 게시물이 얼마나 유용했습니까?

별을 눌러주세요!

평균 평점 5 / 5. 투표수: 1022

이 게시물은 구독자의 반응이 좋은 글입니다. 함께 평가해 보세요.