2025 . 오은

repo

이미지 생성 결과 스트리밍 받기

🎨 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;