[Next.js] ChatGPT가 분석해준 나의 전생 알아보기 토이 프로젝트 (feat. 서버 액션)

딱히 만들게된 이유는 없습니다. Next.js의 서버액션 기능을 사용해 보고 싶기도 하고 재미삼아 아주 간단한 토이프로젝트를 제작 해보게 되었습니다😂
Next 15 버전 (app router) 기준으로 만들었습니다.

1. OpenAI API 키 발급, OpenAI 라이브러리 설치

우선 OpenAI 플랫폼에서 API 키를 생성해야 합니다.
https://platform.openai.com/settings/organization/api-keys
약간의 과금이 발생 할 수도 있습니다. 저도 이것저것 해보다가 충전한 5달러를 전부 써버렸습니다ㅠㅠ (무료 크레딧을 제공했으나 이제 더는 제공하지 않는다고 합니다...)

발급 받는 과정은 생략하겠습니다.

OpenAI 라이브러리 사용을 위해 라이브러리도 설치 해주었습니다.

npm i openai

2. env에 환경 변수 등록

프로젝트 최상단 위치에 env 파일을 생성 했습니다.

// .env.local
OPEN_API_KEY = 발급 받은 키

서버 액션을 통해 접근할 것이기 때문에 NEXT_PUBLIC 접두사를 붙이지 않았습니다. 추가로 API 키 같은 민감한 환경변수는 해당 접두사를 붙이지 않는것이 좋다고 합니다. (클라이언트에서 접근이 가능하기 때문에 유출 될 위험)

3. 화면 구현 및 서버 액션 실행 함수 작성

// @/app/page.tsx
"use client";
import { createPastAction } from "@/action/createPastAction";
import LoadingSpinner from "@/components/loading-spinner";
import MyResult from "@/components/my-result";
import UserForm from "@/components/user-form";
import { useActionState } from "react";
 
export default function Home() {
  const [state, formAction, isPending] = useActionState(createPastAction, null);
 
  return (
    <div className="flex flex-col items-center justify-center">
      <div className="text-xl font-bold m-3 text-black">
        ChatGPT가 분석해준 나의 전생 알아보기⚡️
      </div>
      <UserForm
        formAction={formAction}
        isPending={isPending}
        error={state?.error}
      />
      {isPending && <LoadingSpinner />}
      {state?.desc && !isPending && (
        <MyResult desc={state.desc} url={state.url as string} />
      )}
    </div>
  );
}

크게 유저 Form 영역, 로딩 상태 보여주는 스피너 컴포넌트, 결과를 보여주는 컴포넌트로 구성 되있습니다.

리액트가 제공하는 useActionState라는 훅을 통해 서버 액션 결과물 상태를 관리할 예정입니다.

✅useActionState란?

리액트 서버 액션을 사용할 때 상태 관리와 함께 요청을 실행하는 훅입니다.
Form과 같은 UI에서 서버 액션을 실행하고 그 결과를 상태로 저장할 수 있게 해준다고 합니다.

사용법

const [state, formAction, isPending] = useActionState(action, initialState);
  • action: 내가 작성한 서버 액션 실행 함수
  • initialState: state의 초기값, useState 훅의 초기값 지정과 비슷하다고 생각하면 될 것 같습니다.

나의 코드

const [state, formAction, isPending] = useActionState(createPastAction, null);
  • state: 서버 액션의 결과물 (따로 작성해둔 createPastAction 서버 액션 함수의 결과물이 담깁니다.)
  • formAction: 폼의 action 속성에 넣어서 서버 액션을 실행하는 함수 = createPastAction
  • isPending: 요청 중인지 여부 (boolean), 로딩 상태

서버 액션 함수

// /@/actions/createPastAction.ts
"use server";
import OpenAI from "openai";
 
const openai = new OpenAI({
  apiKey: process.env.OPEN_API_KEY,
  dangerouslyAllowBrowser: true,
});
 
const CMD_TEXT =
  "너는 전생을 봐주는 역할이야. 유저가 생년월일을 입력하면 한국의 역사상에서 실존했던 인물 중 한 명과 연관지어 전생을 알려줘. 응답에 내 생년월일을 언급은 안해도 되. 성별이 여성인 경우는 남자인 인물이 나오면 안되도록 고려해줘야해. 다양한 인물이 나오면 좋겠어. 이모지도 넣어도 되";
 
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function createPastAction(_: any, formData: FormData) {
  const gender = formData.get("gender")?.toString();
  const date = formData.get("date")?.toString();
 
  if (!date || date.trim() === "" || date.length < 8) {
    return { error: "정확한 생년월일을 입력 해주세요!" };
  }
 
  const response = await openai.chat.completions.create({
    model: "gpt-4o",
    temperature: 1,
    messages: [
      {
        role: "system",
        content: CMD_TEXT,
      },
      {
        role: "user",
        content: `저는 전생에 어떤 한국의 역사 인물이었을까요? 생년월일은 ${date}, 성별은 ${gender}입니다.`,
      },
    ],
  });
 
  const characterDescription = response.choices[0].message.content;
 
  const imageResponse = await openai.images.generate({
    model: "dall-e-3",
    prompt: `한국의 역사 인물과 관련된 이미지 생성해줘. ${characterDescription}.`,
    n: 1,
    quality: "hd",
    size: "1024x1024",
  });
 
  return {
    desc: characterDescription,
    url: imageResponse.data[0].url,
  };
}

전체 서버 액션 함수 코드 입니다.

"use server";
import OpenAI from "openai";
 
const openai = new OpenAI({
  apiKey: process.env.OPEN_API_KEY, // 서버에서만 접근 가능
  dangerouslyAllowBrowser: true, // 브라우저에서 사용 위함 (서버 액션 "use server" 내부에서 API를 호출하기 때문에 안전)
});
 
const CMD_TEXT =
  "너는 전생을 봐주는 역할이야. 유저가 생년월일을 입력하면 한국의 역사상에서 실존했던 인물 중 한 명과 연관지어 전생을 알려줘. 응답에 내 생년월일을 언급은 안해도 되. 성별이 여성인 경우는 남자인 인물이 나오면 안되도록 고려해줘야해. 다양한 인물이 나오면 좋겠어. 이모지도 넣어도 되";

우선 서버 액션 사용을 위해 "use server" 선언을 해주어야 합니다.

일반적인 클라이언트 https 요청 (fetch) 사용 대신 OpenAI의 공식 라이브러리를 통해 API 요청을 할 것입니다!!

CMD_TEXT는 ChatGPT에게 역할을 부여하기 위한 텍스트입니다. 최대한 구체적으로 적어야 만족할만한 답변이 날아왔습니다.

export async function createPastAction(_: any, formData: FormData) {
  const gender = formData.get("gender")?.toString();
  const date = formData.get("date")?.toString();

서버 액션으로 클라이언트에서 전달한 성별, 생년월일 데이터를 추출 할 수 있습니다.

추가로 함수에 전달된 첫번째 인자는 원래 useActionState가 내부적으로 넘겨주는 값 (직전 state 값)이 전달 됩니다. 사용할 필요가 없어 _ 처리를 해두었습니다.

유저 Form 컴포넌트

// /@/components/user-form.tsx
interface UserFormProps {
  formAction: (formData: FormData) => void;
  isPending: boolean;
  error: string | undefined;
}
 
export default function UserForm({
  formAction,
  isPending,
  error,
}: UserFormProps) {
  return (
    <form
      action={formAction}
      className="w-[100%] h-auto flex flex-col p-4 gap-8 justify-self-center shadow-lg rounded-lg"
    >
      <div className="text-xl">
        <label htmlFor="gender" className="text-gray-600 font-semibold">
          성별
        </label>
        <div className="flex gap-2 items-center mt-2">
          {["남성", "여성"].map((text) => (
            <label key={text} className="flex items-center gap-1 text-gray-700">
              <input
                type="radio"
                name="gender"
                value={text}
                defaultChecked={text === "남성"}
                className="size-4"
              />
              {text}
            </label>
          ))}
        </div>
      </div>
      <div className="flex flex-col text-xl">
        <label htmlFor="date" className="text-gray-600 font-semibold">
          생년월일
        </label>
        <input
          placeholder="YYYY/MM/DD"
          name="date"
          className="border border-gray-300 rounded-md px-4 py-2 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
        />
      </div>
      // 에러 처리
      {error && (
        <span className="text-red-500 font-semibold text-sm">{error}</span>
      )}
      <button
        disabled={isPending}
        type="submit"
        className="w-full py-2 text-lg font-semibold text-white bg-blue-500 rounded-md hover:bg-blue-600 active:bg-blue-700 transition-colors"
      >
        {isPending ? "분석 중..." : "분석하기"}
      </button>
    </form>
  );
}

props로 넘겨 받은 formAction 함수가 실행되면 form 내부의 name="gender", name="date" 속성을 가진 입력값들이 FormData 객체를 통해 서버 함수로 전달된다고 합니다.

에러 처리

if (!date || date.trim() === "" || date.length !== 8) {
  return { error: "정확한 생년월일을 입력 해주세요!" };
}

간단한 에러처리도 해주었습니다. 유효하지 않은 값을 입력시 해당 객체를 리턴해 state 값에 담깁니다.

전생 정보 요청, 이미지 생성

입력 값이 유효하다면 OpenAI API로 전생 정보를 요청하고 응답을 받아옵니다.

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  temperature: 1,
  messages: [
    {
      role: "system",
      content: CMD_TEXT,
    },
    {
      role: "user",
      content: `저는 전생에 어떤 한국의 역사 인물이었을까요? 생년월일은 ${date}, 성별은 ${gender}입니다.`,
    },
  ],
});
 
const characterDescription = response.choices[0].message.content;

ChatGPT와 단순 대화하기 위한 API를 요청합니다.

messages 프로퍼티에서 role이 system인 객체의 content 값은 입력한 값에 따라 ChatGPT에 미리 역할을 부여하는것이라고 합니다. 미리 적어둔 텍스트를 넣어주었습니다.

그 다음 role이 user인 객체는 실제 유저가 질문할 내용을 담아 요청해주는 객체입니다.

더 자세한 사용법 > https://platform.openai.com/docs/api-reference/chat/create

요청이 성공적으로 수행되면 choices[0].message.content에 접근해 응답 내용 텍스트를 얻을수 있습니다.

이제 이 값을 기반으로 다시 이미지 생성 모델을 통해 이미지를 요청했습니다.

const imageResponse = await openai.images.generate({
  model: "dall-e-3",
  prompt: `한국의 역사 인물과 관련된 이미지 생성해줘. ${characterDescription}.`,
  n: 1,
  quality: "hd",
  size: "1024x1024",
});
 
return {
  desc: characterDescription,
  url: imageResponse.data[0].url,
};

더 자세한 사용법 > https://platform.openai.com/docs/api-reference/images

처음에는 모델을 디폴트 값인 dall-e-2를 사용했는데 만족할만한 이미지를 생성해 주지않아 dall-e-3를 사용했습니다.

요청이 잘 수행되면 data[0].url에 접근해 이미지 url을 얻을 수 있고 최종적으로 서버 액션함수에서 설명 텍스트와 이미지 url을 리턴 합니다.

데이터 뿌려주기

다시 메인 페이지 컴포넌트로 돌아왔습니다!!

"use client";
import { createPastAction } from "@/action/createPastAction";
import LoadingSpinner from "@/components/loading-spinner";
import MyResult from "@/components/my-result";
import UserForm from "@/components/user-form";
import { useActionState } from "react";
 
export default function Home() {
  const [state, formAction, isPending] = useActionState(createPastAction, null);
 
  return (
    <div className="flex flex-col items-center justify-center">
      <div className="text-xl font-bold m-3 text-black">
        ChatGPT가 분석해준 나의 전생 알아보기⚡️
      </div>
      <UserForm
        formAction={formAction}
        isPending={isPending}
        error={state?.error}
      />
      {isPending && <LoadingSpinner />}
      {state?.desc && !isPending && (
        <MyResult desc={state.desc} url={state.url as string} />
      )}
    </div>
  );
}

요청이 성공적으로 수행된뒤 서버액션 함수에서 리턴한 객체가 state에 담기게 됩니다.

이제 MyResult 컴포넌트에 해당 데이터를 전달해주었습니다.

// @/components/my-result.tsx
import Image from "next/image";
 
export default function MyResult({ desc, url }: { desc: string; url: string }) {
  return (
    <div className="mt-2 p-4 bg-white shadow-md rounded-lg">
      <p className="text-gray-700 text-xl font-medium">{desc}</p>
      <div className="mt-4 flex justify-center">
        <Image
          src={url}
          alt="결과 이미지"
          width={400}
          height={400}
          className="rounded-md"
        />
      </div>
    </div>
  );
}

OpenAI API와 리액트의 서버액션을 활용해 간단하고 재밌는 토이프로젝트를 만들수 있었습니다.
서버액션을 통해 별도의 상태 관리 (useState, useEffect 등,,,,)가 필요없어 코드가 간결해졌다는 점이 마음에 들었습니다. 별도의 API 엔드포인트가 필요 없이 폼 데이터를 서버에서 바로 처리 가능하다는 점도 재미있었습니다.
로딩 상태 또한 useActionState에서 바로 제공해주기에 로딩 UI를 쉽게 연결 가능하다는 점도 장점이 있는것 같습니다.
잘못 전달한 내용이 있다면 지적 감사하겠습니다.🫡

Github: https://github.com/boyfromthewell/my-past-life