포스트 검색

포스트 제목으로 검색합니다

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

84
ReactNext.jsSSRServer Action

딱히 만들게된 이유는 없습니다. 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를 쉽게 연결 가능하다는 점도 장점이 있는것 같습니다.
잘못 전달한 내용이 있다면 지적 감사하겠습니다.🫡