웹으로 만든 4컷 포토부스 (3) - AI를 활용하여 사진을 캐릭터로 바꾸기
들어가며
이전 글에서는 인쇄를 브라우저 밖으로 분리하여 프린트 큐와 워커로 처리하는 과정을 다루었다. 이번 글에서는 어린이날 행사 부스를 준비하며 추가한 AI 사진 스타일 변환 기능에 대해 서술하고자 한다.
기존 포토부스는 촬영한 사진을 그대로 프레임에 넣어 인쇄하는 방식이었다. 어린이날 행사인 만큼 아이들이 흥미를 가질 만한 요소가 필요했고, 촬영한 사진을 캐릭터 스타일로 변환해주는 기능을 넣기로 했다.
모델 선택
이미지 생성 모델은 Gemini의 gemini-2.5-flash-image를 사용하였고, 이 기능에서 요구되는 조건은 명확했다.
- 입력 이미지의 인물을 유지할 것 — 완전히 새로운 이미지를 생성하는 것이 아니라 촬영한 사진을 변환하는 것이므로, 사진 속 사람이 누구인지 알아볼 수 있어야 한다.
- 속도가 빨라야 할 것 — 부스 앞에 대기 인원이 있는 상태에서 빠르게 한 팀당 4장을 변환해야 한다.
이미지 입력을 받아 이미지를 출력하는 모델이면서 응답 속도가 빠른 계열을 선택한 이유이다. 실제로 작업 도중 더 상위 모델로도 테스트해 보았으나, 품질 향상 대비 응답 시간이 길어져 원래 모델로 되돌렸다.
변환 API 구현
백엔드에 이미지 배열과 스타일 값을 받아 변환된 이미지 배열을 반환하는 엔드포인트를 추가하였다.
app.post("/photos/transform", async (request, reply) => {
const { images, style } = request.body;
const prompt = STYLE_PROMPTS[style];
if (!prompt) {
return reply
.code(400)
.send({ status: false, message: "지원하지 않는 스타일입니다." });
}
const transformedImages = await Promise.all(
images.map(async (base64Image, idx) => {
const response = await ai.models.generateContent({
model: GEMINI_MODEL,
contents: [
{ inlineData: { mimeType: "image/jpeg", data: base64Image } },
{ text: prompt },
],
config: { responseModalities: ["TEXT", "IMAGE"] },
});
// ...
}),
);
return reply.send({ transformedImages });
});
여기서 중요한 부분은 프롬프트를 클라이언트가 아닌 서버가 가지고 있다는 점이다. 클라이언트는 watercolor, cartoon, village라는 스타일 식별자만 보내고, 실제 프롬프트는 서버의 상수에서 조회한다. 프롬프트를 수정할 때 프론트엔드를 다시 배포할 필요가 없고, 지원하지 않는 스타일 값이 들어오면 그 시점에서 걸러낼 수 있다.
4장의 사진은 Promise.all로 동시에 요청한다. 순차적으로 처리하면 변환 시간이 그대로 4배가 되기 때문이다. 부스 회전율을 생각하면 이 부분은 양보할 수 없었다.
응답 처리에서는 이미지 파트를 명시적으로 찾아야 한다. 모델이 responseModalities에 따라 텍스트와 이미지를 함께 반환하기 때문이다.
const candidate = response.candidates?.[0];
if (!candidate?.content?.parts) {
throw new Error(
`Gemini 응답 없음 [${idx}]: finishReason=${candidate?.finishReason}`,
);
}
const part = candidate.content.parts.find((p) => p.inlineData);
if (!part) throw new Error(`이미지 파트 없음 [${idx}]`);
에러 메시지에 finishReason을 포함시킨 것은 현장 대응을 염두에 둔 것이다. 이미지가 돌아오지 않는 상황은 안전 필터에 걸린 경우와 그 외의 실패가 원인이 다르고, 대응 방법도 다르다. 로그만 보고 판단할 수 있어야 했다.
프롬프트 설계
제공한 스타일은 수채화, 카툰, 동물 캐릭터 세 가지였다.
프롬프트를 작성하면서 겪은 문제와 대응을 정리하면 다음과 같다.
배경이 통째로 바뀌는 문제
가장 먼저 부딪힌 문제다. 스타일 변환을 요청하면 모델이 배경까지 새로 그려버렸다. 부스에서 촬영한 사진인데 배경이 전혀 다른 장소로 바뀌면 같은 자리에서 찍은 사진이라는 느낌이 사라진다.
이 현상은 프롬프트에 배경을 유지하라는 지시를 명시적으로, 반복해서 넣음으로써 해결할 수 있었다.
Background should preserve the original real-world scene exactly as it appears
— do not replace, reimagine, or significantly alter the background.
Only apply subtle color grading and soft depth-of-field blur to the existing background.
The background must remain recognizable and true to the original photo.
이 프롬프트는 같은 요구를 세 문장으로 나누어 반복한 형태인데, 한 문장으로 짧게 지시했을 때보다 이렇게 긍정 지시와 부정 지시, 허용 범위를 나누어 서술했을 때 결과가 가장 안정적이었다.
스타일이 의도한 방향에서 벗어나는 문제
카툰 스타일을 요청하면 결과가 일본 애니메이션 풍으로 나오는 경우가 잦았다. 원하는 스타일을 서술하는 것만으로는 부족했고, 원하지 않는 방향을 명시적으로 배제해야 했다.
IMPORTANT: Avoid anime style, avoid flat illustration, avoid realistic human proportions.
수채화 스타일에서도 같은 방식이 필요했다. 부드러운 질감을 의도했음에도 외곽선이 진하게 들어가거나 색이 납작하게 처리되는 경우가 있어, 이쪽에도 배제 조건을 덧붙였다.
두 경우 모두 원하는 스타일을 더 자세히 묘사하는 것보다, 원하지 않는 방향을 한 줄 배제하는 편이 결과를 크게 바꿨다. 모델이 이미 알고 있는 여러 스타일 사이에서 어느 쪽으로 기울지를 정해주는 역할에 가까웠다고 생각한다.
사람을 다른 형태로 바꿔야 하는 경우
동물 캐릭터 스타일은 성격이 다른 문제였다. 앞의 두 스타일이 사람의 형태를 유지한 채 화풍만 바꾸는 것이라면, 이쪽은 사람을 아예 동물 캐릭터로 바꿔야 한다.
이 경우 모델이 지시를 절충해서 "사람인데 동물 귀가 달린" 어중간한 결과를 내놓는 경향이 있었다. 변환 대상을 구체적으로 열거하고 강조 문구를 덧붙여 해결했다.
they can be transformed into cute animal villagers such as cats, dogs, bears,
ducks, rabbits, frogs, hamsters, or other Animal Crossing animal types
...
IMPORTANT: Transform people into animal villager characters, not human characters.
선택지를 나열해준 것이 효과가 있었다. 추상적으로 "동물 캐릭터로 바꿔라"라고 지시했을 때보다 구체적인 동물 목록을 제시했을 때 결과가 훨씬 안정적이었다.
정리
프롬프트를 다듬으며 얻은 결론은 다음과 같다.
- 지키고 싶은 것은 긍정문과 부정문을 함께 써서 반복한다.
- 원하는 스타일을 서술하는 것보다 원하지 않는 스타일을 배제하는 문장이 더 강하게 작동한다.
- 추상적인 지시보다 구체적인 예시 나열이 결과를 안정시킨다.
대기 시간 문제
이 기능의 가장 큰 걸림돌은 변환에 걸리는 시간이었다. 4장을 병렬로 요청해도 부스 앞에서 기다리기에는 짧지 않은 시간이 소요된다. 1편에서 다룬 업로드 대기 시간과는 성격이 다른 문제였다. 업로드는 다른 화면 뒤로 숨길 수 있었지만, 변환은 그 결과가 나와야 다음 단계로 넘어갈 수 있다.
숨길 수 없다면 기다리는 동안 볼 것을 제공하는 방향으로 접근했다. 촬영 중인 화면을 영상으로 녹화해두었다가, 변환을 기다리는 동안 이를 재생하기로 했다.
이를 위해 촬영 페이지에서 MediaRecorder로 카메라 스트림을 녹화한다. 이미 getUserMedia로 스트림을 받아두었기 때문에 그대로 넘겨주기만 하면 된다.
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordedChunks.current.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(recordedChunks.current, { type: "video/webm" });
setShootingVideoUrl(URL.createObjectURL(blob));
};
recorder.start();
촬영이 완료되고 변환 작업이 진행 중일 때, 이 영상을 변환 대기 화면에서 4배속으로 재생한다. 촬영에만 40초 남짓이 걸리기 때문에 원래 속도로는 지루하지만, 빠르게 돌리면 타임랩스처럼 보여 그 자체로 볼거리가 된다. 구현 자체는 간단했지만 방금 자신이 촬영하던 모습을 빠르게 돌려 보여준다는 점에서 생각보다 반응이 좋았다.
실패 처리
외부 API에 의존하는 기능이므로 실패를 전제하고 구현해야 했다. 따라서 클라이언트에 재시도 로직을 넣었다.
const TRANSFORM_MAX_RETRIES = 3;
const TRANSFORM_RETRY_DELAY = 2000;
for (let attempt = 0; attempt <= TRANSFORM_MAX_RETRIES; attempt++) {
try {
const { data } = await api.post(API_PHOTOS_TRANSFORM, { images, style });
setTransformedPhotos(data.transformedImages);
return;
} catch (e) {
const status = e?.response?.status;
if (attempt < TRANSFORM_MAX_RETRIES && status !== undefined && status >= 500) {
await new Promise((r) => setTimeout(r, TRANSFORM_RETRY_DELAY * (attempt + 1)));
continue;
}
console.error("Transform failed:", e);
return;
}
}
재시도는 5xx 응답에 대해서만 수행하도록 하였다. 재시도 간격은 시도 횟수에 비례해 늘렸는데, 모델 서버가 일시적으로 과부하 상태일 때 같은 간격으로 계속 요청하는 것은 의미가 없다고 판단했기 때문이다.
2편에서 인쇄 실패에는 자동 재시도를 넣지 않았던 것과 대비되는 선택인데, 인쇄 실패는 용지 소진처럼 사람이 개입해야 풀리는 문제라 자동 재시도가 의미가 없었던 반면, API 호출 실패는 다시 시도하면 성공할 가능성이 있는 문제라고 판단했다.
다만 이 방식에는 한계가 있다. 4장을 Promise.all로 묶어 요청하기 때문에 한 장만 실패해도 나머지 세 장의 결과까지 함께 버려지고, 재시도 역시 네 장 전부를 다시 요청하게 된다. 성공한 이미지를 남겨두고 실패한 것만 다시 요청하는 편이 나았겠지만, 당시에는 여기까지 손대지 못했다.
회전율 조정
AI 변환이 들어가면서 한 팀당 소요 시간이 늘어났고, 이에 맞춰 운영 방식도 조정해야 했다.
먼저 출력 설정 페이지를 건너뛰도록 했다. 출력 매수와 방명록 등록 여부를 선택하는 단계였는데, 운영 조건상 두 값 모두 고정해두기로 하면서 사용자가 결정할 것이 남아 있지 않았다.
또한 최종 이미지 포맷을 PNG에서 JPEG로 변경했다. 인화물에는 변환된 사진만 넣지 않고 원본 4컷과 변환본 4컷을 나란히 배치했는데, 변환된 사진만 남으면 실제로 어떤 표정으로 찍었는지가 사라지기 때문이다. 이 때문에 인화물이 4×6 규격이 되었고, 288 DPI로 캡처하면 PNG로는 용량이 상당히 커졌다. 사진 위주의 이미지이므로 JPEG로도 인쇄 품질에 큰 차이가 없어 포맷을 바꾸는 것으로 해결했다.
현장 운영
어린이날 부스에는 총 300팀 정도가 방문했다. 프린터 두 대가 같은 큐에서 작업을 나눠 가져가도록 구조를 바꾸지 않았다면 감당하기 어려웠을 규모였다.
AI 변환에 걸린 시간은 4장 기준 평균 15초 정도였다. 변환을 기다리는 동안 촬영 영상을 배속으로 재생하도록 한 것이 이 구간을 메워주었다.
스타일 선택은 카툰이 가장 많았다. 다만 어린 아이들은 동물 캐릭터를 가장 좋아했는데, 연령대에 따라 선호가 뚜렷하게 갈렸다. 스타일마다 프롬프트를 다듬느라 공을 들였는데, 선호가 이렇게 갈리는 것을 보니 세 가지를 모두 준비해둔 보람은 있었다.
물론 부스를 운영하면서 여러 문제도 있었다. 가장 먼저 눈에 띈 것은 안전 필터였는데, 특별히 문제될 것 없는 사진인데도 모델이 이미지 반환을 거부하는 경우가 종종 있었다. 이때 앞서 에러 메시지에 finishReason을 넣어둔 것이 도움이 되었다. 로그만 보고 안전 필터에 걸린 것인지 다른 원인의 실패인지 바로 구분할 수 있었기에, 현장에서는 재촬영을 안내하는 것으로 빠르게 대응할 수 있었다.
입력 사진의 품질도 결과에 그대로 영향을 주었다. 부스 조명이 충분하지 않거나 역광이 드는 위치에서 촬영된 사진은 변환 결과도 함께 무너져, 얼굴이 뭉개지거나 원래 사람과 닮지 않은 결과가 나오곤 했다. 프롬프트를 아무리 다듬어도 이 부분만큼은 나아지지 않았는데, 결국 모델이 얼굴을 제대로 인식할 수 있어야 변환도 가능하다는 당연한 사실을 확인한 셈이었다. 돌이켜보면 프롬프트를 손보는 것보다 촬영 환경 자체를 개선하는 편이 훨씬 효과적인 접근이었을 것이다.
마지막으로 이용이 몰리는 시간대에는 API 사용량 문제로 요청이 실패하는 경우가 있었다. 미리 넣어둔 재시도 로직이 대부분을 흡수해주었지만, 재시도가 도는 만큼 해당 팀의 대기 시간은 그대로 길어졌다. 평균 15초라고는 해도 운이 나쁘면 그보다 훨씬 오래 기다린 팀도 있었던 셈이다.
마무리
세 편에 걸쳐 웹으로 만든 4컷 포토부스를 정리해 보았다.
돌아보면 이 프로젝트에서 고민이 길었던 부분은 대부분 웹 애플리케이션 바깥에 있었다. 프린터라는 물리 장치, 응답 시간을 통제할 수 없는 외부 API, 그리고 부스 앞에 줄을 선 사람들이 그렇다. 화면을 만드는 일보다 이 경계를 어떻게 다룰지 정하는 데 훨씬 많은 시간이 들었다.
정리하고 나서야 보인 것도 있다. 세 편 모두 결국 속도를 직접 줄이지 못해 구조를 바꾼 이야기였다. 업로드가 빨라지지 않으니 화면을 먼저 넘겼고, 프린터 한 대가 빨라지지 않으니 인쇄를 브라우저 밖으로 꺼내 여러 대에 나눠 맡겼고, 변환이 빨라지지 않으니 기다리는 동안 볼 것을 넣었다. 손댈 수 없는 부분을 인정하고 그 주변을 다시 짜는 쪽이, 어설프게 최적화를 시도하는 것보다 현장에서는 훨씬 잘 통했다.