지난 포스팅에서 AI 모델로부터 응답을 받는 데 성공했으니, 이제 이를 실제 기능으로 구현해 페이지에 띄워보려 합니다.
App.tsx 파일에 모든 로직을 넣어두었던 이전과 달리, 이번에는 페이지를 분리해 각 페이지가 단일 책임을 가지도록 해야겠다고 생각했습니다.
랜덤 카드 뽑기
와 요청 보내기
기능을 위한 별도 페이지를 두기로 했음LoadingPage 역할을 유지하면서 새로운 동작을 추가하여 사용자 경험을 높이는 방법을 생각해봤습니다👇
사용자가 대기 시간이 지나치게 짧다고 느끼면 결과가 어색하게 보일 수 있다
API 응답이 오지 않는 경우 사용자가 카드를 직접 뽑는 과정이 무의미해짐
이 두 가지 부분은 현재 결론을 내지 못했으며, 좀 더 고민해 볼 계획입니다.
위의 생각을 바탕으로 다음과 같은 순서로 서비스를 구축해 보았습니다.
이 페이지는 간단한 서비스 소개와 나중에 OAuth 로그인 기능을 추가할 LandingPage입니다. 현재는 준비 단계이기 때문에 내용은 간단하게 작성되었습니다.
import { useNavigate } from 'react-router-dom';
export default function LandingPage() {
const navigate = useNavigate();
return (
<div>
<h2>문스트럭</h2>
<h3>Anthropic Claude 3.5 Sonnet을 이용한 AI 타로점 서비스</h3>
<button
onClick={() => {
navigate('/query');
}}
>
시작하기
</button>
</div>
);
}
이 페이지는 사용자 입력과 스프레드 선택만을 담당합니다. 사용자는 드롭다운에서 스프레드를 선택하여 cardCount 상태가 업데이트되며, 텍스트 영역에 물어볼 내용을 입력하고 제출 버튼을 누르면 DrawPage로 입력 내용과 카드 개수가 전달됩니다.
export default function QueryPage() {
const [cardCount, setCardCount] = useState(INITIAL_CARD_COUNT);
const navigate = useNavigate();
function handleCardCountChange(newCardCount: number) {
setCardCount(newCardCount);
}
function handleSubmit(userInput: string) {
navigate('/draw', { state: { userInput, cardCount } });
}
return (
<div>
<SpreadSelector
cardCount={cardCount}
onCardCountChange={handleCardCountChange}
/>
<Input onFormSubmit={handleSubmit} />
</div>
);
}
{ state: ... }
에 담아 다음 페이지인 DrawPage로 넘깁니다.![]() | ![]() |
---|
무작위 카드 뽑기와 API 통신, 그리고 카드 직접 선택 기능의 목업이 포함된 페이지입니다.
useLocation
훅으로 가져옵니다.const { userInput, cardCount } = location.state || {};
useEffect
로 마운트 시, 카드 뽑기, 입력 및 카드 정보 포맷팅 후 API 요청을 실행합니다.cardCount
만큼 카드를 뽑습니다.
const drawnCardsResult = drawRandomCards(cardCount);
setDrawnCards(drawnCardsResult);
drawRandomCards
함수는 es-toolkit
의 shuffle
함수를 이용해 미리 준비된 tarotDeck 배열(78장의 TarotCard가 들어있음)을 섞습니다.
export function drawRandomCards(cardCount: number) {
const shuffledDeck = shuffle([...tarotDeck]);
const result = shuffledDeck
.slice(0, cardCount)
.map((card) => processCardDirection(card));
return result;
}
cardCount
만큼 자른 배열을 생성하며, 각 카드에는 정방향 또는 역방향 정보를 추가하여 DrawnTarotCard
타입으로 변환됩니다.각 카드에 방향 정보를 추가하기 위해 processCardDirection
함수를 사용합니다.
function processCardDirection(card: TarotCard) {
const direction: '정방향' | '역방향' =
random(0, 1) > 0.5 ? '정방향' : '역방향';
return {
id: card.id,
name: card.name,
arcanaType: card.arcanaType,
suit: card.suit,
number: card.number,
direction,
keywords:
direction === '정방향' ? card.upright.keywords : card.reversed.keywords,
description:
direction === '정방향'
? card.upright.description
: card.reversed.description,
};
}
direction
필드를 통해 '정방향' 또는 '역방향'으로 방향 정보를 넣어줍니다.
isReversed: Boolean
이 더 깔끔하지 않나요: 이 경우, 의미 상으로 '뒤집혀있나요: 예/아니오'가 되니까 모델이 '소드 10 카드입니다. 이 카드는 뒤집혀있군요'와 같은 응답을 내놓을 수 있겠다는 생각을 했습니다. 그보다는 '소드 10 역방향 카드는~' 와 같은 형태의 응답을 원했기 때문에 굳이 문자열로 방향 정보를 나타내주었습니다.
processCardDirection은 정방향, 역방향의 정보를 모두 포함하고 있는 TarotCard 데이터를 받아 정해진 방향의 정보만 가지고 있는 DrawnTarotCard 데이터를 반환합니다.
경험 상 텍스트 생성형 AI는 입력받은 정보를 최대한 활용하려 하는 경향이 있다고 생각해서, '이 카드는 정방향일 때에는 ~이고 역방향일 때에는 ~를 의미합니다'처럼 불필요한 정보를 추가로 나열하는 형태의 응답을 방지하기 위한 처리입니다.
유저 입력값과 뽑힌 카드 정보를 합쳐 하나의 문자열로 만들어 줍니다.
export function formatUserInputAndCardInfo(
userInput: string,
drawnCards: DrawnTarotCard[]
) {
const formattedCardInfo = formatDrawnCards(drawnCards);
return `## 사용자 입력값\n\n${userInput}\n\n## 사용자가 뽑은 카드 정보\n\n${formattedCardInfo}`;
}
마크다운의 h2 헤딩으로 타이틀을 표시해주고, 그 아래 내용이 들어갑니다.
formatDrawnCards
는 각각의 DrawnTarotCard
데이터를 JSON 형태의 문자열로 바꾸고 하나로 합칩니다.
API 요청에는 callVertexAPI
함수를 사용합니다.
export async function callVertexAPI(userInputWithCardInfo: string) {
const API_URL = `:rawPredict`;
const requestPayload = {
anthropic_version: 'vertex-2023-10-16',
messages: [
{ role: 'user', content: prompt.system.mockedUserInput },
{
role: 'assistant',
content: prompt.system.mockedAssistantResponse,
},
{
role: 'user',
content: userInputWithCardInfo,
},
],
max_tokens: 1024,
stream: false,
};
try {
const response = await apiClient.post(API_URL, requestPayload);
const messageContent = response.data.content[0].text;
return response.data;
} catch (e: unknown) {
if (axios.isAxiosError(e)) {
if (e.response) {
console.error('ERROR RESPONSE: ', e.response.data);
throw e;
}
console.error('ERROR MESSAGE: ', e.message);
throw e;
}
}
}
max_tokens
필드는 응답 크기를 결정합니다. 1024로 설정했을 때 대략 200단어, 900자 정도의 응답이 오는 것을 확인했습니다.
- 켈틱 크로스처럼 해석해야 할 카드 수가 많은 스프레드의 경우에 한하여 최대 토큰량을 늘리는 처리가 추후에 필요할 것 같습니다.
- stream은 스트리밍 모드를 사용할 지 여부를 나타냅니다. GPT를 써보면 응답이 단어 단위로 뚜두두둑 생성되는 걸 볼 수 있는데, 이게 스트리밍 모드입니다. 응답을 토큰 단위로 조금씩 보내는 것으로, 긴 응답이나 대화형 UI를 위한 애플리케이션에서 실시간 상호작용으로 지연 없이 답변을 확인할 수 있게 도와주는 기능입니다.
- messages 배열은 유저와 어시스턴트의 이전 대화 내역과 유저의 새 입력을 포함하고 있습니다.
- 여기서는 이전 대화 내역으로 유저가 지시 사항을 나열(시스템 세팅 프롬프트)하고, 어시스턴트가 그에 동의하는 내용(
mockedAssistantResponse
)의 가상 입력과 답변을 넣어주었습니다.- 이전에는 첫 요청에서 기본 프롬프트와 유저 입력값, 뽑힌 카드를 한 번에 보냈는데, 이 경우 응답이 '네, 지시하신대로 직접적이고 명확한 해석을 제공하겠습니다'와 같이 올 수 있습니다.
- 따라서 '유저가 이런 지시를 했고, 어시스턴트는 이에 동의하는 내용의 답변을 보냈다' 는 가상의 이전 대화가 이미 있었던 것처럼 모델에게 인식시켜 새 요청에 대한 응답으로는 타로에 대한 해석만 제공하는 방향으로 유도했습니다.
isReversed: Boolean
형태가 아니라 direction: '정방향' | '역방향'
형태로 표시하기DrawPage에서는 useEffect
로 API 요청을 호출해 받은 응답을 apiResponse
상태에 할당합니다.
useEffect(() => {
(async () => {
try {
const fetchedApiResponse = await callVertexAPI(formattedQuery);
const responseText = fetchedApiResponse.content?.[0]?.text || '';
setApiResponse(responseText);
} catch (error) {
console.error('API 요청 오류:', error);
setApiResponse('API 요청에 실패했습니다.');
}
})();
}, [userInput, cardCount, navigate]);
useEffect
문 내에서(async () => {어쩌구})();
형태로 비동기 함수가 실행되는 것을 볼 수 있습니다.- 이는
useEffect
함수 내에서 비동기 함수를 직접 사용할 수 없기 때문입니다.- useEffect는 동기적인 함수로 설계되어있고, React는 useEffect의 반환값으로 정리 함수(clean-up function)를 기대합니다.
- useEffect 안에서 async 키워드를 직접 실행하면 반환값이 Promise가 되며, 이로 인해 React가 useEffect의 반환값을 처리하는 방식과 충돌이 발생할 수 있습니다.
- 이는 예문에서와 같이 IIFE(Immediately Invoked Function Expression, 즉시 실행 함수 표현식)로 비동기 함수의 작동을 동기적으로 선언 후 즉시 실행하는 방식으로 해결할 수 있습니다.
자동 카드 뽑기와 API 요청 후 응답을 받아오는 과정과 별개로, 사용자가 직접 카드를 선택하는 기능을 추가했습니다.
export default function DrawPage() {
const [isAnimating, setIsAnimating] = useState(true);
const handleButtonClick = () => {
setIsAnimating(false);
};
useEffect(() => {
if (!isAnimating && apiResponse) {
navigate('/result', { state: { apiResponse, drawnCards } });
}
}, [isAnimating, apiResponse, navigate, drawnCards]);
return (
<div>
<h2>카드를 선택해 주세요</h2>
{isAnimating ? (
<>
<p>카드를 섞고 있습니다...</p>
<button onClick={handleButtonClick}>카드 선택하기</button>
</>
) : (
<p>카드가 선택되었습니다. 결과를 해석 중입니다...</p>
)}
</div>
);
}
isAnimating
상태가 업데이트됩니다.useEffect
문으로 애니메이팅이 끝났고, api 응답을 받았는지를 확인하여 둘 다 true이면 받아온 응답과 뽑힌 카드 상태를 묶어서 ResultPage로 전달합니다.이 페이지는 답변과 뽑힌 카드 정보를 표시합니다.
import { useLocation } from 'react-router-dom';
import { DrawnTarotCard } from '../Types/types';
export default function ResultPage() {
const location = useLocation();
const { apiResponse, drawnCards } = location.state || {};
if (!apiResponse || !drawnCards) {
return <p>잘못된 접근입니다.</p>;
}
return (
<div>
<h2>타로 리딩 결과</h2>
<div>
<h3>API 응답 결과</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{apiResponse}</p>
</div>
<div>
<h3>뽑힌 카드</h3>
<ul>
{drawnCards.map((card: DrawnTarotCard, index: number) => (
<li key={index}>
<h4>
{card.name.en} ({card.name.ko})
</h4>
<p>
{card.direction}: {card.description}
</p>
</li>
))}
</ul>
</div>
</div>
);
}
API 응답 결과 부분에서 줄바꿈과 공백 처리를 위해 whiteSpace: 'pre-wrap'
스타일을 사용했습니다.
유저가 뽑은 카드 정보를 텍스트로 표시했지만, 향후 카드 컴포넌트로 나열하여 시각적 효과를 추가할 예정입니다.
또한 카드 스프레드 중에는 켈틱 크로스나 말발굽 스프레드처럼 카드를 놓는 형식이 정해져있는 스프레드들이 있으므로, 선택된 스프레드에 맞는 형식으로 표시되도록 별도의 컴포넌트도 추가 구현이 필요합니다.
![]() |
---|
useEffect
가 두 번 실행되면서 카드 뽑기 + API 요청이 중복되는 현상이 발생했습니다.useRef
로 hasFetched
변수를 만들어, useEffect
실행 시 처음 실행 여부를 판단하게 했습니다.useEffect
가 두 번 실행되더라도, 카드 뽑기 + API 요청은 첫 실행에만 작동하게끔 처리할 수 있었습니다.useEffect
문에서 비동기 함수를 바로 사용할 수 없는 이유 및 IIFE를 활용한 해결 방법 추가 학습하기