moonstruck 개발 일지: 화면 구현, 페이지 라우팅, 프롬프트 조정, api 요청 및 응답 처리

그른손·2024년 10월 28일
0

🌙 Moonstruck 개발 일지: 실제로 띄우기, 페이지 라우팅, 그리고 개발 중 느낀 점

지난 포스팅에서 AI 모델로부터 응답을 받는 데 성공했으니, 이제 이를 실제 기능으로 구현해 페이지에 띄워보려 합니다.

💭 생각해본 것들

App.tsx 파일에 모든 로직을 넣어두었던 이전과 달리, 이번에는 페이지를 분리해 각 페이지가 단일 책임을 가지도록 해야겠다고 생각했습니다.

  • 처음에는 Query 페이지Result 페이지를 구성하고, 두 페이지 중 어느 한쪽에서 ‘카드 뽑기 + API 요청’을 처리하도록 할 계획이었음
  • 하지만 이를 그대로 구현하면 Query 페이지와 Result 페이지 중 어느 쪽이든 추가적인 역할을 떠맡게 되는 문제가 생겨서, 이 둘과 별개로 랜덤 카드 뽑기요청 보내기 기능을 위한 별도 페이지를 두기로 했음
  • 임시로 LoadingPage라는 이름을 붙였지만, 이 페이지가 사용자와 상호작용은 전혀 없이 단순히 백엔드에서 로직이 끝날 때까지 대기만 하게 하니, 굳이 별도 페이지로 둘 필요가 있는지 고민이 들었음...

✨ 사용자에게 무의미하지만 의미 있어 보이는 동작 제공하기

LoadingPage 역할을 유지하면서 새로운 동작을 추가하여 사용자 경험을 높이는 방법을 생각해봤습니다👇

  • LoadingPage 진입 시 랜덤 카드 선정과 API 요청이 자동으로 실행되게 하고,
  • 사용자에게는 카드 덱 셔플과 카드 직접 선택 UI를 제공하여, 자신이 직접 카드를 선택하는 것처럼 느끼게 합니다.
  • 실제로는 이미 뽑힌 랜덤 카드들로 API 요청이 전송되어 응답을 기다리는 단계지만, 사용자는 그 동안 뭔가 의미 있는 활동을 하는 듯한 경험을 하게 되어 체감 대기시간이 줄어들게 됩니다.
  • 사용자에게 이 단계가 유의미하다는 느낌을 더 주기 위해 LoadingPage => DrawPage로 이름을 변경!

🧐 추가로 고려할 사항들

  • 사용자가 대기 시간이 지나치게 짧다고 느끼면 결과가 어색하게 보일 수 있다

    • 실제 상용 서비스에서도 요청이 아주 짧은 시간에 완료될 경우, 의도적으로 로딩 지연을 주어 사용자가 의미 있는 과정이 진행 중이라는 느낌을 받게 합니다.
    • 이 경우, 직접 뽑기 로직을 유지하면서도 의도적 지연을 추가할지 고민이 필요합니다.
    • 단, ‘직접 뽑기’가 오래 걸리지 않아서 사용자가 카드 뽑기를 완료한 후에도 API 응답을 기다려야 하는 상황이 발생할 수 있으며, 그 상황에서 추가 지연을 주면 사용자 경험에 오히려 부정적 영향을 줄 수 있습니다.
    • 따라서 사용자의 카드 선택이 마무리되기 전에 이미 API 응답이 도착한 상황에만 의도적 지연을 주는 처리가 필요
    • 하지만 이걸 어떻게 자연스럽게 구현할 수 있지? 카드 선택이 마무리되고 0.2초 후에 API 응답이 도착한다면? 그런 상황에도 유연하게 대처할 수 있는 로직을 어떻게 짜지?
  • API 응답이 오지 않는 경우 사용자가 카드를 직접 뽑는 과정이 무의미해짐

    • 이 경우에는 어떻게 처리해야 할까? 사용자가 카드를 뽑는 중이라도 오류 메세지를 보여주고 리다이렉트해야 할까?
    • 아니면 카드 뽑기 + 의도적 지연이 마무리된 후에 오류 메세지를 보여줘야 할까?
    • 사용자가 ‘카드 선택 후 요청이 전송된다’고 생각하는 것을 의도하고 있으니 전자보다는 후자가 낫다고 생각함
    • 다만, 이 경우 사용자 경험을 개선하려다가 오히려 사용자 불편을 초래하는 점이 찜찜함
    • 물론 사용자는 자신이 카드를 선택하고, 로딩 인디케이터가 돌아가는 동안 실제 API 요청이 이루어진다고 생각하니 실제로 별로 불편을 느끼지 않을지도 모름
    • 그렇다면 사용자가 인식하지 못하는 불편함은 존재하지 않는 것인가?
    • 존재하지 않는 불편을 개선할 필요가 있나?
    • 만약 위와 같이 생각한다면, 모든 문제는 사용자가 불편을 느끼지 않는 상황이라면 해결할 필요가 없는가?

이 두 가지 부분은 현재 결론을 내지 못했으며, 좀 더 고민해 볼 계획입니다.

🛠️ 구현

위의 생각을 바탕으로 다음과 같은 순서로 서비스를 구축해 보았습니다.

🖥️ LandingPage

LandingPage 스크린샷

이 페이지는 간단한 서비스 소개와 나중에 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>
  );
}

🔍 QueryPage

QueryPage 스크린샷

이 페이지는 사용자 입력과 스프레드 선택만을 담당합니다. 사용자는 드롭다운에서 스프레드를 선택하여 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>
  );
}
  • 사용자가 스프레드를 선택하고 제출 버튼을 누르면, cardCount와 userInput 정보를 { state: ... }에 담아 다음 페이지인 DrawPage로 넘깁니다.
  • 현재는 전역 상태 관리 없이 간단히 React Router로 데이터를 넘겨주지만, 향후 전역 상태 관리가 필요하면 수정할 예정입니다.

🎲 DrawPage

진입 시 (이미 api 요청 갔음) '카드 선택하기' 클릭 후, 응답을 대기하는 중의 화면

무작위 카드 뽑기와 API 통신, 그리고 카드 직접 선택 기능의 목업이 포함된 페이지입니다.

동작 방식

  1. QueryPage에서 넘겨받은 정보를 useLocation 훅으로 가져옵니다.
    const { userInput, cardCount } = location.state || {};
  2. useEffect로 마운트 시, 카드 뽑기, 입력 및 카드 정보 포맷팅 후 API 요청을 실행합니다.

🃏 무작위 카드 뽑기

  • cardCount만큼 카드를 뽑습니다.

    const drawnCardsResult = drawRandomCards(cardCount);
    setDrawnCards(drawnCardsResult);
  • drawRandomCards 함수는 es-toolkitshuffle 함수를 이용해 미리 준비된 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 요청 및 응답 처리

  • 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)의 가상 입력과 답변을 넣어주었습니다.
  • 이전에는 첫 요청에서 기본 프롬프트와 유저 입력값, 뽑힌 카드를 한 번에 보냈는데, 이 경우 응답이 '네, 지시하신대로 직접적이고 명확한 해석을 제공하겠습니다'와 같이 올 수 있습니다.
  • 따라서 '유저가 이런 지시를 했고, 어시스턴트는 이에 동의하는 내용의 답변을 보냈다' 는 가상의 이전 대화가 이미 있었던 것처럼 모델에게 인식시켜 새 요청에 대한 응답으로는 타로에 대한 해석만 제공하는 방향으로 유도했습니다.

이런 처리가 얼마나 유의미하고 어떻게 동작하나요?

  • 모릅니다!
  • 텍스트 생성형 AI는 기본적으로 글을 한 단어씩 이어쓰는 식으로 동작합니다. 맥락에 따라 다음 단어로 가장 확률이 높은 단어를 뽑아서 나열하는 식인데, 유저의 입력이 이 확률의 변동에 어떻게, 얼마나 영향을 미치는지는 직접 모델을 뜯어보더라도 명확하게 알기 힘듭니다.
  • 모델에게 '이런 방식의 입력이 네 응답에 어떤 영향을 미쳐?'라고 물어보는 것도 마찬가지로 거의 의미가 없습니다. 그럴 듯한 답변을 주기야 하겠지만, 실제로 그런 식으로 동작했는지는 알 방법이 없으니까요.
  • 따라서 기본적인 프롬프트 엔지니어링은 대체로 원하는 형태의 응답에 최대한 가까워지도록 계속 다듬는 식이고, 순전히 경험에 의존하는 경향이 있습니다.
  • 제가 이번 과정에서 적용한 다음과 같은 처리들도 마찬가지의 맥락입니다.
    • 카드 정보를 JSON 형태로 보내기
    • 프롬프트를 마크다운 형식으로 입력하기
    • 카드 정보를 보낼 때 정해진 방향의 정보만 보내기
    • 방향을 isReversed: Boolean형태가 아니라 direction: '정방향' | '역방향'형태로 표시하기
    • 이전 대화 내역에 시스템 프롬프트와 모델의 가상 응답을 넣기
    • 등등, 웹에서 찾은 프롬프트 엔지니어링 정보들(혹은 다른 사용자의 썰😅들)에서 여러 팁과 트릭들을 차용하여 적용해보았습니다.

🔄 응답 받아오기

DrawPage에서는 useEffectAPI 요청을 호출해 받은 응답을 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>
  );
}
  • 이런 식으로 목업만 만들어둔 상태입니다. TBD: 어쩌구 해놓은 부분은 민망하니까 읽지 말아주시길 바랍니다.
  • 사용자가 카드 선택을 마치면 isAnimating 상태가 업데이트됩니다.
  • useEffect문으로 애니메이팅이 끝났고, api 응답을 받았는지를 확인하여 둘 다 true이면 받아온 응답과 뽑힌 카드 상태를 묶어서 ResultPage로 전달합니다.

🧾 ResultPage

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' 스타일을 사용했습니다.

  • 유저가 뽑은 카드 정보를 텍스트로 표시했지만, 향후 카드 컴포넌트로 나열하여 시각적 효과를 추가할 예정입니다.

  • 또한 카드 스프레드 중에는 켈틱 크로스나 말발굽 스프레드처럼 카드를 놓는 형식이 정해져있는 스프레드들이 있으므로, 선택된 스프레드에 맞는 형식으로 표시되도록 별도의 컴포넌트도 추가 구현이 필요합니다.

    켈틱 크로스 스프레드말발굽 스프레드

⏱️ 그 외에 마주친 이슈

  • Strict mode로 인해 useEffect가 두 번 실행되면서 카드 뽑기 + API 요청이 중복되는 현상이 발생했습니다.
    • Strict mode를 끄면 되잖아? 해서 껐다가,
    • 뭔가 이건 아닌 거 같아서 다시 켰습니다.
    • 최종적으로는 useRefhasFetched 변수를 만들어, useEffect 실행 시 처음 실행 여부를 판단하게 했습니다.
    • 따라서 Strict mode로 인해 useEffect가 두 번 실행되더라도, 카드 뽑기 + API 요청은 첫 실행에만 작동하게끔 처리할 수 있었습니다.

🎬 마치며 정리

  • 의도적 로딩 지연의 필요성 고려하기
  • API 오류 발생 시 처리 방안 고민하기
  • 멋진 카드 뽑기 로직 & 애니메이션 만들기
  • 답변 결과를 저장 및 공유할 방법 고려하기
  • useEffect문에서 비동기 함수를 바로 사용할 수 없는 이유 및 IIFE를 활용한 해결 방법 추가 학습하기
  • 프롬프트로 응답 포맷 설정 및 유도하기
  • 카드 설명 부분을 정규식으로 캡처해 각 카드 옆에 설명을 표시하는 방식으로 띄울 수 있는지 알아보기
profile
프론트엔드 개발자

0개의 댓글