<

/>

imagenext

NEXT.JS 서버 액션으로 AI TEXT-TO-IMAGE 구현하기

Replicate + next.js 서버 액션 프로젝트 구현 과정과 후기

2024년 04월 11일

개요

디시인사이드 AI 이미지

디시인사이드 AI 이미지

모니터를 비교하고 구매하기 위해, 관련 정보를 디시인사이드 모니터갤러리 에서 찾다가 흥미로운것을 발견했습니다.
예전에는 없던 AI 이미지 생성 기능이 생긴것입니다.
그렇게, 호기심이 생겨서 next.js 프로젝트로 직접 구현한 과정과 후기를 공유하려고 합니다.

Replicate

디시인사이드에서는 이미지 생성 모델인 stability-ai를 WEB-UI 로 사용할 수 있는
AUTOMATIC1111로 AI 이미지 생성 기능을 구현하였습니다.

하지만 로컬에서 NVIDIA GPU를 통해 생성하는 AUTOMATIC1111은 저의 상황에 어울리지 않았습니다.
그렇게, 저의 상황에 더 적절한 stability-ai를 클라우드로 제공해주는 Replicate 서비스를 찾았습니다.

Replicate 서비스의 다양한 AI 모델들

Replicate 서비스의 다양한 AI 모델들

Replicate 서비스를 사용하면 머신러닝 모델을 클라우드를 통해 쉽게 실행할 수 있습니다.
다양한 오픈 소스 모델을 실행하고, 엄청난 고수라면 자신의 모델을 배포할 수 있습니다.
저는 이미지 생성 모델인 stability-aistable-diffusionsdxl 모델을 사용하여 구현했습니다.

구현

Dependencies

  • next.js@14.1.3
  • replicate@^0.29.1
  • react-hook-form@^7.51.2
  • zod@^3.22.4
  • react-google-recaptcha@^3.1.0

서버 액션 / Replicate

Replicate next.js 공식문서

공식문서에서는 Route Handlers를 통해 백엔드 api 코드를 작성하는 예시를 보여줍니다.
저는 next.js@14 부터 안정화된 서버액션을 통해 직관적이고 알아보기 쉽게 백엔드 코드를 작성할것입니다.

우선, Replicate 사이트에 접속하여 API token을 발급받습니다.
그 뒤 replicate 라이브러리를 통해 간단하게 Replicate 클라우드에 접근할 수 있습니다.

아래는 간단한 서버액션 예제입니다.

app/page.tsx
// 서버 컴포넌트
const ServerComponent = () => {
  // 서버 액션
  const handleSubmit = async (formData) => {
    "use server";
    console.log(formData.get("title")); // 이 console은 서버에서만 보입니다.
  };
 
  return (
    <form action={handleSubmit}>
      <input type="text" name="title" />
      <button type="submit">Submit</button>
    </form>
  );
};
 
export default ServerComponent;

서버 액션으로 사용할 함수 내부 첫번째 줄에 "use server"를 작성해주시면 컴파일할때 서버 api로 만들어집니다.
위 코드를 실행시키면 클라이언트 브라우저에서는 console.log의 내용을 확인할 수 없습니다.

Server Actions are not limited to <form> and can be invoked from event handlers, useEffect, third-party libraries, and other form elements like <button>.
Next.js docs

참고로, next.js 공식 문서에서는 서버 액션은 <form> 말고도 useEffect, onClick 이벤트를 통해 서버 액션을 호출할 수 있다고 했지만 현재 제 버전인 next@14.1.3 에서는 원인불명 오류를 많이 일으켰습니다.
<form>action 또는 submit 을 통해 서버 액션을 호출하는것을 추천드립니다.

서버 액션에 대해 파악했으니, replicate 라이브러리를 알아볼 차례입니다.

replicate-example.js
import Replicate from "replicate";
 
const replicate = new Replicate({
  auth: process.env.REPLICATE_API_TOKEN, // 발급받은 API 토큰
});
 
const output = await replicate.run(
  // 실행시킬 ai 모델
  "stability-ai/stable-diffusion:d70beb400d223e6432425a5299910329c6050c6abcf97b8c70537d6a1fcb269a",
  {
    input: {
      prompt: "multicolor hyperspace", // input 프롬프트
    },
  }
);
console.log(output);
replicate-example.js output 결과

replicate-example.js output 결과

위 코드를 node.js 를 통해 실행시킨다면, output이 string 배열의 결과물을 return 하는걸 확인할 수 있습니다.
간단한 코드로 쉽게 Replicate 클라우드에 접근할 수 있는것입니다.

위 코드를 봤을때 필수적인 요소는 실행시킬 ai 모델과 사용자가 입력할 prompt 라는것을 확인할 수 있습니다.
필요한것을 파악했으니, 동적인 props를 받아 서버에서 실행시키는 서버 액션 코드를 작성하겠습니다.

action/replicate-action.ts
"use server";
import Replicate from "replicate";
 
type ReplicateOutPut = (
  prompt: string,
  model: `${string}/${string}` | `${string}/${string}:${string}`
) => Promise<string | string[]>;
 
export const getReplicateOutput: ReplicateOutPut = async (prompt, model) => {
  try {
    const replicate = new Replicate({
      auth: process.env.NEXT_PUBLIC_REPLICATE_API_TOKEN,
    });
 
    const input = {
      prompt: prompt,
      // width,height,negative prompt 등 ai 모델에 따라 다양한 옵션 넣기가능
    };
 
    const output = await replicate.run(model, {
      input,
    });
 
    return output;
  } catch (err) {
    console.log(err);
    return String(err);
  }
};

서버 액션을 모듈화하여 사용하면 클라이언트 컴포넌트에서도 사용 가능합니다.

prompt 와 model을 동적으로 받는 코드를 작성했습니다.
또한, AI 모델에따라 다양한 input option을 제공하니, 해당 모델 문서를 참조해주시길 바랍니다.

백엔드 코드는 작성됐으니, 이제는 프론트엔드 코드를 작성할것입니다.

프론트엔드 코드

위에서 설명했듯이, 서버 액션은 form을 통해 호출해야합니다.
간결하고 쉬운 통합을 위해 react-hook-form 을 사용하여 코드를 작성할것입니다.

components/replicate-form.tsx
import { useForm } from "react-hook-form";
 
const ReplicateForm = ({ submitFn }: ReplicateFormProps) => {
  const form = useForm();
  const onSubmit = form.handleSubmit(submitFn);
 
  return (
    <Form {...form}>
      <form onSubmit={onSubmit}>
        <Input name="prompt" placeholder="robot, cat, rainbow" />
        <Select name="select" placeholder="AI 모델을 선택해주세요." />
        <Button type="submit">실행</Button>
      </form>
    </Form>
  );
};
 
export default ReplicateForm;

prompt를 입력받를 text input과, 모델을 선택할 select input을 사용합니다.
버튼의 타입을 submit으로 하여야 onSubmit 함수가 작동합니다.

app/page.tsx
"use client";
import { getReplicateOutput } from "@/action/replicate-action";
 
const ReplicatePage = () => {
  const [imgSrc, setImgSrc] = useState<string[] | null>(null);
 
  const getReplicateData = async (formData: FormCustomData) => {
    const promptValue = formData.prompt;
    const model = formData.select;
 
    // 서버 액션 prompt와 model을 받음
    const output = await getReplicateOutput(promptValue, model);
    setImgSrc(output);
  };
 
  return (
    <GridBox>
      <ReplicateForm submitFn={getReplicateData} />
      {imgSrc && <Image alt="ai-image" src={imgSrc[0]} />}
    </GridBox>
  );
};
 
export default ReplicatePage;

서버 액션으로 output을 받으면 imgSrc 상태에 set 합니다.

form을 통한 출력 결과

form을 통한 출력 결과

form을 통해 입력한 결과, 정상적으로 사진이 나오는것을 확인할 수 있습니다.

클라이언트 에러 처리

클라이언트에서 에러를 잡지 않고 요청을 보낸 결과

클라이언트에서 에러를 잡지 않고 요청을 보낸 결과

TEXT-TO-IMAGE 기능이 정상적으로 작동하지만 아직 추가적으로 세팅해야할것이 많습니다.
Replicate api는 유료입니다. 봇으로 단기간에 너무 많은 요청을 시도한다던가, 부적절한 프롬프트를 넣는
의도치 않은 요청을 클라이언트에서 막아야 서버의 부담을 줄일 수 있습니다.

우선, api에 접근하기전에 zod를 통해 form에서 의도치 않은 요청을 막을것입니다.

components/replicate-form.tsx
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
 
const FormSchema = z.object({
  prompt: z
    .string({
      required_error: "프롬프트는 빈칸일 수 없습니다.", // 프롬프트 작성 필수
    })
    .min(1, { message: "프롬프트는 빈칸일 수 없습니다." }) // 빈 프롬프트 방지
    .regex(/^[a-zA-Z\s,]*$/, {
      message: "영단어, 공백, 쉼표만 사용 가능합니다.", // 영단어, 공백, 쉼표만 가능
    }),
  select: z.string({
    required_error: "ai 모델을 선택해야 합니다.", // ai 선택 필수
  }),
});
 
type FormZodType = typeof FormSchema;
 
const ReplicateForm = ({ submitFn }: ReplicateFormProps) => {
  const form = useForm<z.infer<FormZodType>>({
    resolver: zodResolver(FormSchema),
  });
  // ...
};

위 코드는 다음과 같습니다.

  1. 빈 프롬프트를 방지합니다.
  2. 영단어만 사용 가능합니다.
  3. 프롬프트간 구분은 쉼표로 합니다.
  4. AI 모델은 필수로 선택합니다.

zod로 실시간 에러를 잡았다면, 요청을 보내기전에 체크해야하는 에러도 있습니다.
NSFW(Not Safe For Work) 단어들을 필터링해야합니다.
List of NSFW Words github source 링크에서 부적절한 단어들(en)을 가져와서 요청을 보내기전
프롬프트를 쉼표 단위로 구분하여 부적절한 단어가 포함됐는지 확인합니다.

클라이언트 에러 처리 UI

위와 같이 zod 설정을 통해 클라이언트 측에서 불필요하거나 잘못된 요청을 사전에 차단할 수 있으며,
UI에서 피드백을 쉽게 제공할 수 있습니다.

봇 접근 방지

이제는 봇 접근과 개인이 너무 많이 접근하는걸 막을 차례입니다.
봇 접근은 구글 리캡챠를 이용하여 막을것입니다. 우선, reCAPTCHA 공식문서에서 개인키를 발급받아야 합니다.

reCAPTCHA v2는 클라이언트에서 간단하게 봇 체크를 할때 유용하지만,
그림을 맞추는 형식이기 때문에 사용자 경험이 떨어질수 있습니다.
v3 버전을 사용하면 서비스 워커로 작동하기때문에 사용자 경험이 좋아지지만,
개발자 설정이 복잡해지고 v2에 비하면 보안이 약해질것입니다.

저는 간단한 구현을 위해 reCAPTCHA v2를 사용할 것이고,
react-google-recaptcha 라이브러리로 쉽게 reCAPTCHA v2를 사용할 수 있습니다.

components/re-captcha.tsx
"use client";
import ReCAPTCHA from "react-google-recaptcha";
 
const sitekey = String(process.env.NEXT_PUBLIC_GOOGLE_RECAPTCHA_KEY);
 
interface ReCaptchaProps {
  onChange: () => void;
}
 
const ReCaptcha = ({ onChange }: ReCaptchaProps) => {
  return <ReCAPTCHA theme="dark" sitekey={sitekey} onChange={onChange} />;
};
 
export default ReCaptcha;

발급받은 개인키를 넣어주면 쉽게 사용할 수 있습니다.

app/page.tsx
"use client";
import ReCaptcha from "@/components/re-captcha";
 
const ReplicatePage = () => {
  // 나머지 코드
  const [isCertification, setIsCertification] = useState<boolean>(false);
 
  const setCertificationSuccess = () => {
    setIsCertification(true);
    sessionStorage.setItem("certification", String(true)); // 세션 스토리지 설정
  };
 
  useEffect(() => {
    setIsCertification(
      Boolean(sessionStorage.getItem("certification")) || false // 진입시 세션 스토리지 접근
    );
  }, []);
 
  return (
    <GridBox>
      {/* 나머지 코드 */}
      {!certification && <ReCaptcha onChange={certificationSuccess} />}
    </GridBox>
  );
};
 
export default ReplicatePage;

isCertification 상태를 선언하여 ReCaptcha 인증을 받았는지 파악합니다.
그리고 세션 스토리지에 상태를 저장하여 이미 인증받은 사용자에게 중복해서 인증을 받는것을 방지합니다.

이제는 "짧은시간에 너무 많은 요청을 보내는 상황"을 방지할것입니다.
봇이 아니더라도, 사람이 짧은시간에 반복적으로 많은 요청을 보내는건 문제가 있습니다.

저는 로컬 스토리지를 이용하여 10번이상 요청을 보낸 사람한테 30분동안 요청을 막을것입니다.

짧은시간 반복 요청 막기

짧은시간 반복 요청 막기

로컬 스토리지와 Date 객체로 시간을 비교하여 반복적인 요청을 막는 코드 흐름입니다.

이 코드는 로직은 간단하지만 작성 내용은 길기 때문에 블로그 글에 작성하지 않고 간단한 코드 흐름만 적어두겠습니다.
코드는 github source 링크를 확인바랍니다.

에러 UI

에러 UI

UI 에러 상태 코드를 작성하여 정상적으로 작동하는것을 확인할 수 있습니다.

마치며

https://ou-playground.com

https://ou-playground.com

기능 구현도 중요하지만, 프론트엔드에서의 보안과 에러 처리 설계가 얼마나 중요한지 알게 됐습니다.
왜 회사에서 직접 서비스를 배포하고 운영해본 개발자들을 원하는지 알것같습니다.
직접 서비스를 운영해봐야 (본인의 돈이 걸렸기때문에) 더 효율적이고 더 나은 설계를 추구할것이기 때문입니다.

PLAYGROUND 링크를 통해 결과물을 확인하실 수 있습니다.
사실, AI TEXT-TO-IMAGE 토이 프로젝트를 구현하려고 했지만 하다보니까 너무 재밌어졌기에,
흥미로웠던 UI들이나 프론트 엔지니어링을 직접 구현하는 사이드 프로젝트로 바꿨습니다.

2024﹒©

 OU9999

Powered by Next.js﹒Vercel