🎨 OpenAI Responses API로 이미지 생성 결과 스트리밍 받기
- OpenAI에서 제공하는 responses API를 이용하면 이제 텍스트 프롬프트로 이미지 생성을 요청할 수 있고, 이를 스트리밍 형식으로 단계별로 수신할 수 있습니다.
- Next.js와 TanStack Query의 streamedQuery 기능을 활용해 실시간으로 이미지를 받아 UI에 바로 반영하는 방식을 정리합니다.
🧩 시스템 구성
✅ 주요 기술 스택
- OpenAI SDK (openai): responses.create를 통해 이미지 스트리밍 요청
- Next.js Route Handler: 서버 측에서 OpenAI 응답을 ReadableStream으로 래핑
- TanStack Query (useQuery + streamedQuery): 클라이언트에서 스트림 수신 및 상태 관리
- Async Generator (getGenTxtToImgStream): 응답 스트림을 비동기 반복(iteration)으로 분할 처리
🛠️ route handler
- OpenAI로부터 받은 이미지 스트리밍 응답을 개별 JSON 라인(jsonl) 형식으로 브라우저에 전달합니다.
- stream: true와 함께 partial_images: 3 설정 시, 최대 4개의 응답 이벤트 수신 가능
- 각 응답은 \n으로 구분된 JSON 문자열로 직렬화되어 전달됨
- X-Content-Type-Options: nosniff로 MIME 스니핑 방지
import { type NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
const openaiApiKey = process.env.OPENAI_API_KEY;
if (!openaiApiKey) {
throw new Error("Missing environment variable OPENAI_API_KEY");
}
const openai = new OpenAI({ apiKey: openaiApiKey });
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const prompt = searchParams.get("prompt"); // 쿼리스트링에서 프롬프트 가져오기
const model = searchParams.get("model"); // 쿼리스트링에서 모델 가져오기
if (!prompt) {
return NextResponse.json({ error: "Prompt is required" }, { status: 400 });
}
try {
const imageStream = await openai.responses.create({
model: model || "gpt-4o-mini",
input: prompt,
stream: true, // 스트림으로 받겠다
tools: [
{
type: "image_generation",
partial_images: 3, // 최대 3회 -> 총 4번에 걸쳐 옴
size: "1024x1024",
quality: "medium",
},
],
});
const encoder = new TextEncoder();
const readableStream = new ReadableStream({
async start(controller) {
for await (const event of imageStream) {
let outputIndex = 0;
// 이미지 생성 중 단계일 때
if (event.type === "response.image_generation_call.partial_image") {
const imageBase64 = event.partial_image_b64;
outputIndex = event.partial_image_index;
const responseObject = {
output_index: event.partial_image_index,
partial_image_b64s: [imageBase64],
usage: null,
status: "partial",
final_model: null,
};
controller.enqueue(
encoder.encode(`${JSON.stringify(responseObject)}\n`)
);
} else if ( // 생성 완료시인데 여기서 처리하기 보다 아래 response.completed 에서 처리
event.type === "response.image_generation_call.completed"
) {
// do nothing
} else if (event.type === "response.completed") { // response 완료
const completedObj = event.response.output.find(
(item) => item.type === "image_generation_call"
);
const imageBase64 = completedObj ? completedObj.result : null;
const responseObject = {
output_index: outputIndex + 1,
partial_image_b64s: imageBase64 ? [imageBase64] : [],
usage: event.response.usage || null,
status: "completed",
final_model: event.response.model,
};
controller.enqueue(
encoder.encode(`${JSON.stringify(responseObject)}\n`)
);
} else if (event.type === "error") {
console.error("OpenAI Stream Error Code:", (event as any).code);
console.error(
"OpenAI Stream Error Message:",
(event as any).message
);
console.error("OpenAI Stream Error Param:", (event as any).param);
const errorObject = {
error: true,
message: (event as any).message || "OpenAI stream error",
code: (event as any).code,
param: (event as any).param,
};
controller.enqueue(
encoder.encode(`${JSON.stringify(errorObject)}\n`)
);
controller.error(
new Error((event as any).message || "OpenAI stream error")
);
return;
}
}
controller.close();
},
cancel() {
console.log("Stream cancelled by client.");
},
});
return new Response(readableStream, {
headers: {
"Content-Type": "application/jsonl; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-cache",
},
});
} catch (error) {
console.error("Error generating image stream:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
return NextResponse.json(
{ error: "Failed to generate image stream", details: errorMessage },
{ status: 500 }
);
}
}
📡 api 함수
- 서버로부터 전달된 ReadableStream을 받아 비동기적으로 yield하여 쪼개진 이미지 데이터를 순차적으로 처리합니다.
- UTF-8 디코딩 후 \n 기준으로 줄 단위 JSON 파싱
- AsyncIterable 형식으로 반환되어 TanStack Query의 streamedQuery에서 바로 사용 가능
import type {
IGenTxtImgToTxtStreamData,
IGenTxtToImgStreamData,
} from "../_hooks/query.hooks";
export async function* getGenTxtToImgStream(
model: string,
prompt: string
): AsyncIterable<IGenTxtToImgStreamData> {
if (!prompt) return;
const response = await fetch(
`/api/gen/txttoimg?model=${model}&prompt=${encodeURIComponent(prompt)}`
);
if (!response.ok) {
let errorData = { error: `HTTP error! status: ${response.status}` };
try {
const text = await response.text();
errorData = JSON.parse(text);
} catch (e) {
console.error("Failed to parse error JSON:", e);
}
throw new Error(
(errorData as any).message ||
errorData.error ||
`HTTP error! status: ${response.status}`
);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let eolIndex = buffer.indexOf("\n");
while (eolIndex !== -1) {
const line = buffer.substring(0, eolIndex);
buffer = buffer.substring(eolIndex + 1);
if (line.trim().length > 0) {
try {
yield JSON.parse(line.trim());
} catch (e) {
console.error("Failed to parse JSON line:", line, e);
}
}
eolIndex = buffer.indexOf("\n");
}
}
if (buffer.trim().length > 0) {
try {
yield JSON.parse(buffer.trim());
} catch (e) {
console.error(
"Failed to parse JSON line (remaining buffer):",
buffer,
e
);
}
}
} finally {
reader.releaseLock();
}
}
🚀 steamedQuery
- streamedQuery는 내부적으로 for await...of로 데이터를 수신
- 수신된 이미지 데이터는 React state나 UI에서 사용 가능
export interface IGenTxtToImgStreamData {
output_index: number;
partial_image_b64s: string[];
usage: IOpenAIResponseUsage | null;
status: "partial" | "completed";
final_model: string | null;
}
interface IGenTxtToImgQueryProps {
prompt: string | null;
model: string;
mode: Mode;
}
type TQueryKey = readonly (string | null)[];
export const useGenTxtToImgQuery = ({
prompt,
model,
mode,
}: IGenTxtToImgQueryProps) => {
return useQuery<
IGenTxtToImgStreamData[],
Error,
IGenTxtToImgStreamData[],
TQueryKey
>({
queryKey: [QUERY_KEY_GEN_TXT_TO_IMG, prompt, model],
// streamedQuery의 queryFn은 QueryFunctionContext를 인자로 받고,
// AsyncIterable을 반환해야 합니다.
// streamedQuery 자체가 useQuery의 queryFn으로 사용될 수 있는 함수를 반환합니다.
queryFn: streamedQuery({
queryFn: (context: QueryFunctionContext<TQueryKey>) => {
const [, currentPrompt, currentModel] = context.queryKey;
if (typeof currentPrompt === "string" && currentPrompt) {
return getGenTxtToImgStream(
currentModel || "gpt-4o-mini",
currentPrompt
) as AsyncIterable<IGenTxtToImgStreamData>;
}
// currentPrompt가 유효하지 않으면 빈 AsyncIterable을 반환합니다.
// 즉시 완료되는 비동기 제너레이터 함수를 반환합니다.
return (async function* () {})();
},
}),
enabled: !!prompt && !!model && mode === "txt-to-image",
});
};
🖼️ 4. UI에서 실시간 이미지 업데이트
- status: "partial"이면 순차적으로 이미지 업데이트
- status: "completed"일 때 최종 이미지 + usage 정보 수신
"use client";
interface MarkdownProps {
children: React.ReactNode;
node: React.ReactNode;
}
const formSchema = z.object({
message: z.string(),
});
function Chat() {
const [prompt, setPrompt] = useState<string | null>(null);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const { user } = useAuth();
const { mode, model, base, setUsage } = useUsageCalculatorStore(
useShallow((state) => ({
base: state.base,
mode: state.mode,
model: state.model,
setUsage: state.setUsage,
}))
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
message: "",
},
});
const {
messages,
input,
handleInputChange,
handleSubmit: handleChatSubmit,
setInput,
} = useChat({
onFinish: (message, options) => {
setUsage(options.usage);
},
});
const {
data: txtToImgData,
status: txtToImgImagesStatus,
fetchStatus: txtToImgImagesFetchStatus,
error: txtToImgImagesError,
} = useGenTxtToImgQuery({
prompt,
model,
mode,
});
const messagesEndRef = useRef<HTMLDivElement>(null);
const countRef = useRef<number>(0);
const resetAll = () => {
setInput("");
form.reset({ message: "" });
form.clearErrors("message");
};
const handleSubmit = (data: z.infer<typeof formSchema>) => {
if (data.message.length < 5) {
toast.error("메시지는 5자 이상이어야 합니다.");
return;
}
if (!user) {
toast.error("Please login to use the chat");
return;
}
const selectedModel: Model = base.input_txt_base.model
? base.input_txt_base.model
: base.input_image_base.model!;
if (mode === "txt-to-image") {
setPrompt(data.message);
resetAll();
return;
} else {
handleChatSubmit(
{},
{
body: {
model: selectedModel,
},
}
);
}
resetAll();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || isComposing) return;
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
useEffect(() => {
if (!txtToImgData || txtToImgData.length === 0) return;
if (countRef.current < txtToImgData.length) {
setGeneratedImage(txtToImgData[countRef.current].partial_image_b64s[0]);
if (txtToImgData[countRef.current].status === "partial") {
countRef.current++;
return;
}
if (txtToImgData[countRef.current].status === "completed") {
setUsage(txtToImgData[txtToImgData.length - 1].usage ?? null);
countRef.current = 0;
return;
}
}
}, [txtToImgData, setUsage]);
return (
<div className="w-full h-full flex flex-col gap-2">
<div className="w-full h-auto min-h-[calc(100%-74px)] flex flex-col gap-2 text-xs overflow-y-auto justify-center items-center">
{txtToImgData && txtToImgData.length > 0 && (
<div className="w-[256px] h-[256px] rounded-lg overflow-hidden transition-shadow duration-300">
<img
src={`data:image/png;base64,${generatedImage}`}
alt={"Generated content"}
className="object-cover aspect-square"
/>
</div>
)}
<div ref={messagesEndRef} />
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="min-w-[680px] w-[90svw] h-[60px] lg:w-1/2 relative left-1/2 -translate-x-1/2"
>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem className="w-full space-y-0">
<FormLabel className="hidden">Message</FormLabel>
<FormControl>
<ChatTextArea
value={input}
onKeyDown={handleKeyDown}
onCompositionStart={handleComposition}
onCompositionEnd={handleComposition}
onChange={(e) => {
handleInputChange(e);
field.onChange(e);
}}
/>
</FormControl>
<FormDescription className="hidden">
This is your message.
</FormDescription>
<FormMessage className="absolute bottom-0 left-2 text-[10px] text-red-400" />
</FormItem>
)}
/>
<div className="absolute top-0 right-2 w-fll h-[60px] flex justify-center items-center">
<div className="w-fit h-9 flex justify-center items-center">
<Button
variant="secondary"
type="submit"
className="h-full w-9 p-0 hover:bg-neutral-900"
>
<Send className="!size-5" />
</Button>
</div>
</div>
</form>
</Form>
</div>
);
}
export default Chat;
'TIL' 카테고리의 다른 글
[250520 TIL] useDebounce 훅 개선 (0) | 2025.05.21 |
---|---|
[250519 TIL] FSD (1) | 2025.05.19 |
[250512 TIL] use server, use client (1) | 2025.05.12 |
[250505 TIL] polling 적용기 (0) | 2025.05.05 |
[250429 TIL] 컴포넌트 리셋에 key써먹기 (0) | 2025.04.29 |