이 튜토리얼을 시작하기에 앞서 몇 가지 전제 조건이 있습니다. 원활한 진행을 위해 다음 항목들을 미리 준비해 두시기 바랍니다.
- Redis 인스턴스가 생성되어 있는 Upstash 계정
- API 토큰에 접근할 수 있는 Replicate 계정
- 원하는 기능을 구현할 Next.js 프로젝트
- 프로젝트를 배포할 Vercel 계정
이 튜토리얼은 무엇인가요?
Replicate에서 제공하는 다양한 머신러닝 모델을 활용해 이미지를 생성하는 작업을 시작하고 싶으셨나요? 이 튜토리얼에서는 Replicate의 폭넓은 호스팅 모델과 Upstash의 Redis를 함께 살펴봅니다. 단순히 모델을 소개하는 데 그치지 않고, 실제로 하나의 모델을 세팅하는 전 과정을 다루며, 다른 모델로 손쉽게 교체하는 방법까지 안내합니다.
특히 이번 튜토리얼에서는 Microsoft의 "Bringing Old Photos Back to Life" 모델을 사용합니다. 이 모델은 오래된 사진을 입력받아 처리한 뒤, 편집되어 개선된 버전의 사진을 출력해 주는 모델입니다.

앱 아키텍처 살펴보기
React 경험이 있다면 코드베이스만 읽어도 앱의 아키텍처가 어떻게 동작하는지 파악할 수 있습니다. 하지만 이해를 돕기 위해 전체 구조를 한눈에 보여주는 다이어그램도 함께 준비했습니다.

시작하기 위해 필요한 것
먼저 당연히 Next.js 프로젝트가 필요합니다. 공식 Next.js 셋업 가이드를 따라 새로 생성하거나, 이미 준비된 프로젝트가 있다면 그대로 사용하셔도 좋습니다. 이 튜토리얼에서는 Tailwind CSS를 사용하지만, 선호하는 스타일링 도구가 있다면 무엇이든 대체 가능합니다.
기본적인 Next.js 프로젝트가 준비되었다면, 다음 명령어로 Upstash의 Redis 라이브러리를 설치합니다.
npm install @upstash/redis
다음으로 .env.local 파일에 아래 환경 변수를 채워 넣습니다. Redis 토큰은 Upstash 콘솔에서, Replicate API 토큰은 계정 페이지에서 확인할 수 있으며, 사이트 URL은 배포된 주소, 즉 Vercel 배포 엔드포인트를 입력하면 됩니다.
SITE_URL=https://your-project-url.vercel.app
REPLICATE_API_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
프론트엔드 폼 구성하기
가장 먼저, 이미지 복원 요청을 처리하고 결과를 폴링(polling)하여 완성된 이미지를 화면에 표시하는 폼이 필요합니다.
이미지 복원 폼 만들기
파일: pages/index.tsx
import { MouseEvent, RefObject, useRef, useState } from "react";
import Head from "next/head";
import useInterval from "../hooks/useInterval";
export default function Home() {
const [restoring, setRestoring] = useState<boolean>(false);
const [messageId, setMessageId] = useState<string | null>(null);
const [prediction, setPrediction] = useState<any>({});
const [outputImageUrl, setOutputImageUrl] = useState<string | null>(null);
const imageUrlRef: RefObject<HTMLInputElement> = useRef(null);
const hrRef: RefObject<HTMLInputElement> = useRef(null);
const scratchRef: RefObject<HTMLInputElement> = useRef(null);
useInterval(
async () => {
await fetch(`/api/poll?id=${messageId}`)
.then((res: any) => res.json())
.then((data: any) => {
if (!data.output) {
return;
}
setRestoring(false);
setMessageId(null);
setOutputImageUrl(data.output);
})
.catch((err: any) => console.error(err));
},
messageId ? 1000 : null,
);
async function restoreImage(e: any) {
e.preventDefault();
setRestoring(true);
await fetch("/api/create", {
method: "POST",
body: JSON.stringify({
image_url: imageUrlRef.current?.value,
is_hr: hrRef.current?.value,
has_scratches: scratchRef.current?.value,
}),
headers: { "Content-Type": "application/json" },
})
.then((res: Response) => res.json())
.then((data: any) => {
setMessageId(data.data.id);
setPrediction(data.data);
})
.catch((err: Error) => console.error(err));
}
async function cancel(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault();
await fetch("/api/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cancel_url: prediction.urls.cancel }),
})
.then((res: Response) => res.json())
.then((data: any) => {
setMessageId(null);
setPrediction({});
setRestoring(false);
})
.catch((err: Error) => console.error(err));
}
return (
<>
<Head>
<title>PhotoRescue</title>
<meta
name="description"
content="A simple Next.js application that utilizes Replicate to restore old photos."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="my-16 flex flex-col items-center justify-center md:my-32">
<h1 className="text-5xl font-black">PhotoRescue</h1>
<p className="mt-4">Restore your old photos to their former glory.</p>
{outputImageUrl && (
<div className="flex flex-col items-center justify-center">
<img
src={outputImageUrl}
alt="Restored Image"
className="mt-8 h-auto w-72"
/>
<button
type="button"
onClick={() => setOutputImageUrl(null)}
className="mt-8 inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
Start Again
</button>
</div>
)}
{!outputImageUrl && (
<form
onSubmit={restoreImage}
className="mt-10 flex w-full max-w-lg flex-col items-center"
>
<div className="w-full space-y-4">
<div>
<label htmlFor="image_url" className="text-sm font-semibold">
Image URL
</label>
<input
name="image_url"
id="image_url"
type="text"
defaultValue="https://replicate.delivery/mgxm/b033ff07-1d2e-4768-a137-6c16b5ed4bed/d_1.png"
placeholder="https://example.com/image.png"
className="mt-0.5 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-gray-500 focus:ring-gray-500"
ref={imageUrlRef}
required
/>
</div>
<div className="max-w-lg space-y-4">
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
name="is_hr"
id="is_hr"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
ref={hrRef}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="is_hr"
className="font-medium text-gray-900"
>
Is High Resolution?
</label>
<p className="text-gray-500">
Check this if the input image is a high resolution
photo.
</p>
</div>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
name="is_scratched"
id="is_scratched"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
ref={scratchRef}
defaultChecked={true}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="is_scratched"
className="font-medium text-gray-900"
>
Has Scratches?
</label>
<p className="text-gray-500">
Check this if the input image has visible scratches over
it.
</p>
</div>
</div>
</div>
</div>
<div className="mt-6 flex gap-2">
<button
type="submit"
disabled={restoring}
className="inline-flex items-center rounded-full border border-transparent bg-gray-900 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2 disabled:opacity-50"
>
{restoring ? "Restoring..." : "Restore"}
</button>
{restoring && prediction && (
<button
type="button"
onClick={cancel}
className="inline-flex items-center rounded-full border border-gray-900 bg-white px-6 py-2.5 text-sm font-medium text-gray-900 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-600 focus:ring-offset-2"
>
Cancel
</button>
)}
</div>
</form>
)}
</div>
</main>
</>
);
}
이 컴포넌트는 기본적으로 사용자가 복원하고 싶은 이미지의 URL을 입력할 수 있는 폼을 보여줍니다. 여기에 더해 이미지가 고해상도인지, 제거해야 할 스크래치가 있는지 등의 옵션도 함께 선택할 수 있습니다. 사용자가 정보를 입력하고 폼을 제출하면, 입력 데이터와 함께 /api/create 엔드포인트로 POST 요청이 전송됩니다.
API로 요청이 전송되고 예측(prediction) 정보가 담긴 응답을 받으면, 컴포넌트는 폴링 상태에 들어갑니다. 이후 매초 한 번씩 /api/poll로 GET 요청을 보내 예측이 완료되었는지 확인합니다. 폴링 요청이 성공 응답을 반환하면, 즉 Replicate가 우리의 콜백 엔드포인트로 결과를 전달했다는 의미이므로, 이제 예측 출력값에 접근할 수 있습니다.
폴링이 진행되는 동안에는 예측을 취소할 수 있는 버튼이 폼에 표시됩니다. 이 버튼을 누르면 최초 생성 시 받았던 예측 데이터의 cancel_url과 함께 /api/cancel로 POST 요청이 전송됩니다.
폴링 구현에는 hooks/useInterval.ts에 위치할 커스텀 훅을 활용합니다. 이 훅을 사용하면 React의 컴포넌트 생명주기와 자연스럽게 연동되며, 어떤 React 컴포넌트에서든 콜백 기반 인터벌을 더욱 편리하게 처리할 수 있습니다. 해당 훅에 대해 더 자세히 알고 싶다면 관련 문서를 참고하시기 바랍니다.
import { useEffect, useRef } from "react";
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!delay && delay !== 0) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;
API 설정하기
여러 파일로 구성된 API 설정은 예측 생성 및 취소, 완료 여부를 확인하는 폴링, 그리고 Replicate가 예측 완료 시 호출할 콜백 지정까지 담당합니다.
이미지 예측 생성
파일: pages/api/create.ts
import type { NextApiRequest, NextApiResponse } from "next";
import fetch, { Response } from "node-fetch";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { image_url, is_hr, has_scratches }: any = req.body;
await fetch("https://api.replicate.com/v1/predictions", {
method: "POST",
headers: {
Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
version:
"c75db81db6cbd809d93cc3b7e7a088a351a3349c9fa02b6d393e35e0d51ba799",
input: {
image: image_url,
HR: is_hr,
with_scratch: has_scratches,
},
webhook_completed: `${process.env.SITE_URL}/api/callback`,
}),
})
.then((res: Response) => res.json())
.then(async (data: any) => {
await redis.set(data.id, data);
return res.status(202).json({ data: data });
})
.catch((error: Error) => {
return res.status(500).json({ message: error.message });
});
}
생성(create) API 엔드포인트에서는 먼저 간단한 검사를 통해 들어온 요청이 POST 방식인지 확인하고, 아니라면 400 응답을 반환합니다. 이후 Replicate API 토큰과 함께 Replicate로 POST 요청을 보냅니다. 요청 본문에는 해당 모델의 version 파라미터가 포함되는데, 이 값은 어떤 모델에 요청을 보내는지를 나타냅니다(사용하려는 모델 페이지의 "API" 탭에서 확인할 수 있습니다). 또한 프론트엔드 폼에서 받은 데이터를 모델에 맞는 파라미터로 함께 전달합니다.
요청이 전송되면, 반환된 예측 id를 키로 사용해 Redis에 저장하고, 예측 데이터를 프론트엔드로 되돌려 줍니다. 프론트엔드는 이 데이터를 바탕으로 Redis 항목을 폴링하다가 예측이 완료되었음을 감지하게 됩니다.
콜백(Callback)
파일: pages/api/callback.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { body }: any = req;
try {
await redis.set(body.id, body);
return res.status(200).send(body);
} catch (error) {
return res.status(500).json({ error });
}
}
콜백 엔드포인트는 특정 예측의 처리가 끝났음을 알려 주기 위해 Replicate가 POST 요청을 보내는 곳입니다. 이 요청을 받으면 요청 본문에서 예측 데이터를 꺼내고, 해당 Redis 항목을 완료된 예측 데이터로 갱신합니다.
폴링(Polling)
파일: pages/api/poll.ts
import type { NextApiRequest, NextApiResponse } from "next";
import redis from "../../lib/redis";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { id }: any = req.query;
try {
const data = await redis.get(id);
if (!data) {
return res
.status(404)
.json({ message: "Data for supplied ID not found" });
}
return res.status(200).json(data);
} catch (error: any) {
return res.status(500).json({ message: error.message });
}
}
폴링 설정에서는 요청에서 id를 추출한 뒤, 해당 식별자로 Redis에 저장된 데이터를 조회합니다. 데이터가 없으면 404 응답을 반환하고, 데이터가 있다면 200 응답과 함께 해당 데이터를 반환합니다.
취소(Cancel)
파일: pages/api/cancel.tsx
import type { NextApiRequest, NextApiResponse } from "next";
import fetch, { Response } from "node-fetch";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(400).json({
message: `Invalid request method: ${req.method}.`,
});
}
const { cancel_url }: any = req.body;
await fetch(cancel_url, {
method: "POST",
headers: {
Authorization: `Token ${process.env.REPLICATE_API_TOKEN}`,
"Content-Type": "application/json",
},
})
.then((res: Response) => res.json())
.then((data: any) => {
return res.status(202).json({ data: data });
})
.catch((error: Error) => {
return res.status(500).json({ message: error.message });
});
}
이미 시작된 예측을 취소하는 API 엔드포인트는 비교적 단순합니다. 프론트엔드에서 전달된 cancel_url(생성 요청 시 저장했던 예측 데이터에서 유래한 값)를 추출한 뒤, Replicate API 토큰과 함께 해당 엔드포인트로 POST 요청을 보내기만 하면 됩니다.
라이브러리(Libs)
마지막으로, 데이터 추적에 사용할 Redis 클라이언트를 생성합니다.
파일: lib/redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL as string,
token: process.env.UPSTASH_REDIS_REST_TOKEN as string,
});
export default redis;
이 객체는 애플리케이션 내에서 폴링 중인 데이터를 저장하고 조회하는 데 사용되며, 이를 통해 Replicate의 웹훅(webhook) 완료 시점을 알 수 있습니다.
마무리
Replicate는 API를 통해 사용할 수 있는 다양한 모델을 제공합니다. Vercel과 Upstash를 함께 활용하면 머신러닝 모델을 활용한 실용적인 웹 애플리케이션을 그 어느 때보다 쉽게 구축하고 배포할 수 있습니다.
전체 저장소를 확인하고 싶다면 공개된 리포지토리를 참고하세요.
추가 개발 방향
지금까지 살펴본 내용은 Replicate의 비교적 단순한 모델 하나를 활용한 기본 예제에 불과합니다. 폼 파라미터와 API의 version 값만 교체하면 다른 모델로 손쉽게 전환할 수 있으며, Replicate API 토큰만 연결되어 있다면 제공되는 모든 모델을 자유롭게 사용할 수 있습니다.
Replicate에서 제공하는 모든 모델은 공식 사이트에서 탐색할 수 있습니다. 실험해 보고 싶은 모델을 찾았다면 해당 모델 페이지의 "API" 탭을 클릭해 사용법을 확인하세요. 이곳에는 Python, cURL, Cog, Docker용 코드 버튼이 있어 모델을 바로 테스트해 볼 수 있을 뿐만 아니라, 어떤 파라미터가 필요하고 어떻게 전송되는지 파악하는 데도 유용합니다.