튜토리얼
hiapi API를 사용하여 Next.js 앱에 AI 이미지 생성 기능 추가하기
라우트 핸들러 프록시, 비동기 작업 폴링, 영구 스토리지, 그리고 제로 CLS—약 150줄의 TypeScript로 프로덕션에 가까운 통합을 구현합니다.
이미지 생성 모델을 Next.js 14 App Router 앱에 연결하는 것은 작은 프로젝트이지만, 거의 모든 사람이 동일한 네 가지 핵심 난관에서 실수를 합니다:
- API는 60–120초가 걸립니다—단일 클라이언트 요청으로는 너무 길고, 대부분의 엣지 런타임에서도 너무 깁니다.
- API 키는 브라우저로 전송할 수 없으므로 라우트 핸들러를 프록시로 사용해야 합니다.
- 결과 URL의 유효 기간이 짧으므로 사용자가 새로고침하기 전에 영구 스토리지에 저장해야 합니다.
- 이미지가 반환될 때 크기를 알 수 없어, 그대로 렌더링하면 누적 레이아웃 이동(Cumulative Layout Shift)이 심각하게 깨집니다.
이 튜토리얼에서는 프로덕션에 가까운 hiapi 이미지 API 통합—작업 생성, 폴링, 영구 업로드, CLS 안전 렌더링—을 약 150줄의 TypeScript로 완성합니다. 모든 코드는 실제 app/ 디렉터리에 바로 넣을 수 있습니다.
비동기 작업 규약
hiapi는 모든 이미지 및 비디오 모델에 대해 단일 엔드포인트를 제공합니다: POST https://api.hiapi.ai/v1/tasks. 완전히 비동기 방식입니다—작업을 제출하고, 종료 상태에 도달할 때까지 폴링한 다음, 출력을 다운로드합니다. 롱커넥션 HTTP도, 100초 프록시 타임아웃도, Edge 런타임의 함정도 없습니다.

작업에는 네 가지 가능한 상태가 있습니다:
pending—접수되었지만 아직 가져가지 않음running—생성 중success—output[0].url준비됨 (서명됨, 제한 시간 유효)fail—error.code와error.message가 채워짐
1K 이미지 한 장은 보통 30–120초가 걸립니다. 대부분의 시간은 모델 실행에 쓰이며, API 자체는 측정 가능한 오버헤드를 추가하지 않습니다.
1단계: 프로젝트 설정
Next.js 14+ App Router 프로젝트와 환경 변수 하나가 필요합니다:
# .env.local
HIAPI_API_KEY=sk-... # 절대 브라우저에 노출하지 마세요
이 키를 서버 측 비밀로 배포 플랫폼(Vercel, Cloudflare Pages, Railway)에 추가하세요. 하지 말아야 할 것: NEXT_PUBLIC_ 접두사를 붙이는 것.
영구 스토리지를 원한다면(권장—5단계에서 다룹니다) 다음도 추가하세요:
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=...
R2_PUBLIC_BASE=https://cdn.your-domain.com # 버킷의 커스텀 도메인
2단계: 라우트 핸들러—왜 필요한가

라우트 핸들러가 존재하는 유일한 이유: API 키를 서버 측에 유지하는 것. React 컴포넌트에서 시작된 모든 요청은 사용자의 브라우저에서 실행되며, 클라이언트 코드에 컴파일된 문자열은 DevTools 탭 하나만 열면 탈취될 수 있습니다.
라우트 핸들러는 또한 요청이 유료 엔드포인트에 도달하기 전에 속도 제한, 사용량 기록, 프롬프트 검증을 시행할 수 있는 단일하고 감사 가능한 지점을 제공합니다.
app/api/images/route.ts를 생성하세요:
// app/api/images/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs"; // 장시간 fetch에는 Edge가 아닌 Node 필요
export const maxDuration = 180; // Vercel: 서버리스 타임아웃 연장
const HIAPI = "https://api.hiapi.ai/v1/tasks";
type CreateBody = {
prompt: string;
model?: string;
aspectRatio?: string; // 예: "16:9", "3:2", "1:1"
resolution?: "1K" | "2K" | "4K";
};
export async function POST(req: Request) {
const body = (await req.json()) as CreateBody;
if (!body?.prompt || body.prompt.length > 2000) {
return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
}
const res = await fetch(HIAPI, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.HIAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: body.model ?? "Nano-Banana-2",
input: {
prompt: body.prompt,
aspect_ratio: body.aspectRatio ?? "16:9",
resolution: body.resolution ?? "1K",
},
}),
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text }, { status: res.status });
}
const { data } = await res.json();
// data.taskId는 클라이언트가 폴링에 사용하는 핸들
return NextResponse.json({ taskId: data.taskId });
}
중요하지만 잊기 쉬운 두 가지 설정이 있습니다:
runtime = "nodejs"—많은 환경에서 Edge 런타임은 단일 fetch를 약 25초로 제한하므로 폴링 루프에 적합하지 않습니다.maxDuration = 180—Vercel 서버리스 함수는 Hobby 플랜에서 기본 10초, Pro 플랜에서 60초입니다. 이 값을 수동으로 늘리지 않으면, 서버 측에서 대기하기로 선택한 경우 라우트가 폴링 도중 종료됩니다.
3단계: 폴링 엔드포인트 (또는: 서버 측에서 기다리지 말아야 하는 이유)
클라이언트에 진행 상황을 보여주는 두 가지 선택지가 있습니다:
| 방식 | 장점 | 단점 |
|---|---|---|
| 서버 측에서 대기 후 한 번에 반환 | 클라이언트 요청 한 번만 필요 | 서버리스 호출을 60–120초 점유, 타임아웃 발생 쉬움, 진행 피드백 없음 |
서버가 taskId 반환, 클라이언트가 폴링 |
서버 비용 낮음, 실시간 진행 | 엔드포인트를 하나 더 구축해야 함 |
프로덕션 환경에서는 두 번째 방식이 확실히 더 좋습니다. app/api/images/[id]/route.ts를 추가하세요:
// app/api/images/[id]/route.ts
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const HIAPI = "https://api.hiapi.ai/v1/tasks";
export async function GET(
_req: Request,
{ params }: { params: { id: string } },
) {
const res = await fetch(`${HIAPI}/${params.id}`, {
headers: { "Authorization": `Bearer ${process.env.HIAPI_API_KEY}` },
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status });
}
const { data } = await res.json();
// status ∈ { pending, running, success, fail }
return NextResponse.json({
status: data.status,
url: data.output?.[0]?.url ?? null,
error: data.error ?? null,
});
}
이 엔드포인트가 바로 클라이언트가 3초마다 폴링하는 대상입니다. 각 호출은 빠르고(200ms 미만) 상태를 저장하지 않습니다.
4단계: 클라이언트 컴포넌트
간단한 폼, 로딩 표시기가 있는 로딩 상태, 그리고 결과 캔버스. 폴링은 setInterval로 설정하고 종료 상태에 도달하거나 컴포넌트가 언마운트될 때 해제합니다.
// app/generate/page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Status = "idle" | "queued" | "running" | "done" | "error";
export default function GeneratePage() {
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [url, setUrl] = useState<string | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => () => {
if (pollRef.current) clearInterval(pollRef.current);
}, []);
async function start() {
setStatus("queued");
setUrl(null);
const r = await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, aspectRatio: "16:9" }),
});
if (!r.ok) return setStatus("error");
const { taskId } = await r.json();
setTaskId(taskId);
setStatus("running");
pollRef.current = setInterval(async () => {
const p = await fetch(`/api/images/${taskId}`).then(x => x.json());
if (p.status === "success") {
setUrl(p.url);
setStatus("done");
clearInterval(pollRef.current!);
} else if (p.status === "fail") {
setStatus("error");
clearInterval(pollRef.current!);
}
}, 3000);
}
return (
<main className="mx-auto max-w-2xl p-8 space-y-6">
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
rows={3}
className="w-full border rounded p-2"
placeholder="A sunset over a misty pine forest, cinematic, 16:9"
/>
<button
onClick={start}
disabled={!prompt || status === "queued" || status === "running"}
className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
>
{status === "running" ? "Generating…" : "Generate"}
</button>
{/* 예약 캔버스—이 정확한 종횡비 div가 왜 중요한지는 6단계 참조 */}
<div className="relative w-full" style={{ aspectRatio: "16/9" }}>
{status === "running" && (
<div className="absolute inset-0 grid place-items-center bg-neutral-100 rounded">
<span className="text-neutral-500">Waiting on task {taskId?.slice(0, 8)}…</span>
</div>
)}
{url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={prompt}
className="absolute inset-0 w-full h-full object-cover rounded"
/>
)}
</div>
</main>
);
}
지금부터 주의하고 나중에 점진적으로 추가할 만한 프로덕션 개선 사항이 있습니다:
- 지수 백오프(3초 → 5초 → 8초)는 느린 생성 시 요청 수를 줄입니다.
- 유휴 타임아웃(예: 240초 후 포기)은 이미 죽은 작업에 대한 좀비 폴링을 방지합니다.
- 부분 진행 텍스트—
"running"과"pending"—를 표시하여 사용자에게 무언가 진행 중임을 확신시킵니다.
5단계: 결과 영구화—출력 URL은 만료됩니다
모든 현대 이미지 API를 사용할 때 가장 흔한 실수는 output[0].url을 데이터베이스에 저장하고 모든 것이 끝났다고 생각하는 것입니다. 그 URL은 서명되었고 수명이 짧으며(보통 몇 분에서 몇 시간), 사용자가 새로고침하면 404가 됩니다.
해결책은 바이트를 자체 서버를 통해 영구 스토리지로 파이프하는 것입니다. 동일한 app/api/images/[id]/route.ts 핸들러가 올바른 위치입니다—처음 success 응답을 받았을 때만, URL을 클라이언트에 반환하기 전에 실행하세요.
// `data.status === "success"` 검사 후 app/api/images/[id]/route.ts에 추가
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
async function persist(taskId: string, sourceUrl: string): Promise<string> {
const bytes = new Uint8Array(await (await fetch(sourceUrl)).arrayBuffer());
const key = `generations/${taskId}.jpg`;
await s3.send(new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
Body: bytes,
ContentType: "image/jpeg",
CacheControl: "public, max-age=31536000, immutable",
}));
return `${process.env.R2_PUBLIC_BASE}/${key}`;
}
상태 라우트에서 상태가 success로 바뀌면 persist()를 사용한 다음, 클라이언트에 업스트림의 서명된 URL이 아닌 영구 URL을 반환하세요. 클라이언트는 만료되는 URL을 절대 보지 못하며, 내일 새로고침해도 여전히 유효합니다.
자체 데이터베이스(Postgres, Supabase 등)에 영구화하는 경우, 서명된 URL이 아닌 영구 URL을 저장하세요.
6단계: CLS 제거
누적 레이아웃 이동은 AI 이미지 앱에서 핵심 웹 지표의 조용한 킬러입니다. 이미지는 페이지 로드 후 60초 이상 지나서 도착합니다. 크기가 지정되지 않은 컨테이너에서 <img>로 렌더링하면, 매번 결과가 그 아래의 모든 콘텐츠를 밀어냅니다.
두 가지 신뢰할 수 있는 기법:
CSS로 종횡비를 예약하세요. 위 컴포넌트에서 aspectRatio: "16/9" 스타일이 하는 일입니다. 이미지 로드 여부와 관계없이 컨테이너는 공간을 유지합니다.
요청에서 종횡비를 일치시키세요. hiapi에 aspectRatio: "16:9"를 보내고, 컨테이너에 16/9를 선언하세요. 둘이 일치해야 하며, 그렇지 않으면 레터박스가 생기거나 더 나쁘게는 모양이 예약된 것과 일치하지 않습니다.
Next.js의 <Image> 컴포넌트에도 같은 원칙이 적용됩니다: 요청 비율과 일치하는 width와 height 속성을 전달하세요. 부모 요소가 이미 고정 크기 박스가 아닌 한 fill을 사용하지 마세요.
import Image from "next/image";
<Image
src={persistedUrl}
alt={prompt}
width={1280}
height={720}
className="rounded"
priority={false}
/>
7단계: 모델 선택 및 비용 추정
hiapi의 이미지 모델은 동일한 엔드포인트 구조를 공유합니다—model 필드만 다릅니다. 기본 해상도에서 이미지당 가격 기준의 일반적인 선택:
| 모델 | 가격 / 1K 이미지 | 강점 |
|---|---|---|
qwen-image-2.0 |
$0.025 | 가장 저렴; 기본 2K; 강력한 중일한 텍스트 렌더링 |
gpt-image-2 |
$0.03 | 안정적인 종횡비; 강력한 타이포그래피 |
Nano-Banana |
$0.05 | 매우 빠름(1–2초); 캐릭터 일관성 우수 |
flux-1.1-pro |
$0.05 | 예술적 스타일의 포토리얼리즘 |
Nano-Banana-2 |
$0.085(1K) / $0.076(2K) / $0.114(4K) | 고급 품질, 네이티브 4K |
정직한 기본 선택은 작업에 따라 다릅니다. UI 목업과 타이틀에는 gpt-image-2가 가장 안전합니다. 네이티브 4K 포토리얼리즘에는 Nano-Banana-2를 선택하세요. 이미지 한 장을 버려도 아깝지 않은 배치 작업에는 qwen-image-2.0 또는 Nano-Banana를 선택하세요.
10만 사용자 앱에서 세션당 하루에 이미지 한 장을 생성하는 경우:
qwen-image-2.0: $2,500/일Nano-Banana-21K: $8,500/일
위 아키텍처는 변하지 않습니다. 2단계의 model 필드만 바뀝니다.
8단계: 프로덕션 체크리스트
출시 전:
- 라우트 핸들러에 속도 제한을 적용하세요—그렇지 않으면 IP 하나가 하루 오후에 월 예산을 소진할 수 있습니다. Upstash, Cloudflare Rate Limiting, 또는 간단한 Redis 카운터면 충분합니다.
- 서버에서 전달하기 전에 프롬프트 길이와 내용을 검증하세요. hiapi 플랫폼은 일부 콘텐츠를 거부합니다; 400 오류에 왕복 지연을 지불하고 싶지 않을 것입니다.
- 사용자별 일일 할당량을 설정하세요—
userId와YYYY-MM-DD로 생성 횟수를 저장하고, 그날 11번째 요청에 친절한 메시지로 거부하세요. - 응답 전에 영구화하세요—결과 URL이 영구 스토리지를 거치기 전에 클라이언트에 도달하게 하지 마세요.
- 결과 컨테이너 크기를 요청된 종횡비에 맞게 미리 설정하세요. 이 단계를 건너뛰는 것이 CLS 0과 0.4의 차이입니다.
- 영구화 단계를 백그라운드로 옮겨 클라이언트가 1초 미만으로 응답하게 하세요: 업스트림 URL을 즉시 반환하고, 큐 작업을 시작해 R2에 미러링하고 데이터베이스 행을 패치하세요.
마지막 항목은 한 마디 더 할 가치가 있습니다: 대부분의 앱은 필요하지 않습니다. 인라인 영구화는 코드를 단순하게 유지하며, 추가 1–2초는 60초 대기 이후에 발생하므로 사용자는 이미 로딩 표시기를 보고 있습니다.
다음 단계
이제 약 150줄의 TypeScript로 완전하고 안전하며 CLS 안전한 hiapi 이미지 API의 Next.js 통합을 갖추었습니다. 여기서부터 동일한 POST /v1/tasks 패턴은 비디오 모델(영화적 소재를 위한 Seedance 2.0), 이미지-투-이미지 흐름(input 내에 image_url만 추가), 그리고 다중 턴 편집으로 확장됩니다—모두 폴링 아키텍처를 바꾸지 않고 가능합니다.
작동하는 출발점을 원한다면, hiapi 모델 페이지에 이 엔드포인트에 응답하는 모든 모델이 나열되어 있으며 각 모델의 예제 입력 구조가 제공됩니다. 그중 어느 것이든 위의 model 필드에 넣으면 동일한 컴포넌트가 결과를 렌더링합니다.
관련 글
모든 아티클 보기 →-
튜토리얼
HiAPI API로 hailuo-2.3-fast/image-to-video 호출하는 방법: curl과 Python 실전
빠르고 저렴한 이미지-투-비디오 등급의 첫 실행 요청 — 모델 ID부터 MP4 다운로드까지
-
튜토리얼
HiAPI API로 hailuo-2.3/text-to-video를 호출하는 방법: curl, Python 및 실행 가능한 요청 예시
-
튜토리얼
HiAPI에서 Kandinsky 6.0 Pro 텍스트-투-비디오 사용하기: curl 및 Python 튜토리얼
요청 우선 실전 데모: 프롬프트에서 비디오 클립까지의 호출 구성, 요청 제출, 완료 상태 폴링 및 결과 다운로드