계란 요리 성격 유형 테스트

윤태현·2023년 10월 9일
0

PROJECT

목록 보기
1/1
post-thumbnail

1. 기획

깃허브 : https://github.com/yoonth95/EBTI-React
배포 (AWS S3, CloudFront, Route53) : https://egg-mbti.net/

  • 성격 유형 테스트 (MBTI)를 가지고 다양한 계란 요리에 빗대어 보았다.

  • React, styled-components, kakao API 등을 사용했으며, AWS를 사용하여 배포

  • ChatGPT를 사용해 계란 요리 유형, 요리와 맞는 성격, 문제, 가중치 등을 설정을 했다.

  • 다양한 일러스트 이미지 등은 MS Bing Image Creator를 사용하여 다양한 이미지를 생성했다.

참고 사이트

kakao API : https://developers.kakao.com/
ChatGPT : https://chat.openai.com/
Bing Image Creator : https://www.bing.com/images/create


2. 프로젝트 디렉토리 구조

.env 파일에서는 가중치 값과 kakao API 키 값이 들어있습니다.
jsconfig.json 파일은 폴더 및 파일 경로를 무조건 src로 설정되게 하게끔 해놨습니다.


3. 개발 진행

3-1. index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

window.Kakao.init(process.env.REACT_APP_KAKAO_API);
window.Kakao.isInitialized();

root.render(
  <>
    <App />
  </>
);
  • index.css의 경우 브라우저의 스타일 초기화를 해줬습니다.
  • 웹 페이지를 카톡으로 공유 하기 위해 kakao API를 사용했습니다.

3-2. App.js

import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Main from 'components/Main';
import Test from 'components/Test';
import Result from 'components/Result';
import View from 'components/View';

const App = () => {
  return (
    <>
      <Router>
        <Routes>
          <Route path='/' element={<Main />}/>
          <Route path='/test' element={<Test />}/>
          <Route path='/result' element={<Result />}/>
          <Route path='/view' element={<View />}/>
        </Routes>
      </Router>
    </>
  )
}

export default App;
  • react-router-dom을 사용했습니다. 총 보여질 페이지는 메인, 문제, 결과, 전체 유형 등 4개의 페이지로 구성했습니다.

3-3. Main.jsx

import React from 'react';
import { useNavigate } from 'react-router-dom';
import { GlobalStyles, Container, H1, H2, Image, StartButton } from 'styles/StyledComponents'
import img1 from 'assets/images/img1.png';

const Main = () => {
  const navigate = useNavigate();

  const move = () => {
    navigate('/test');
  }

  return (
    <>
      <GlobalStyles bgColor={'#fff2cc'}/>
      <Container justifyContent={'center'} maxWidth={'540px'}>
        <div>
          <H1>성격 유형 테스트</H1>
          <H2>나는 어떤 계란 요리일까?</H2>
          <Image margin='35px 0' src={img1} alt='메인이미지' title='메인이미지' />
        </div>
        <StartButton type='button' onClick={() => move()}>시작하기</StartButton>
      </Container>
    </>
  );
};

export default Main;
  • Style-Components의 경우 하나의 파일에서 전체 스타일을 관리하도록 했습니다.
  • GlobalStyles, Container 등의 경우 모든 페이지의 스타일은 비슷하지만 몇몇 부분만 다르기 때문에 props로 넘겨줬습니다.

3-4. data 값

1. quetion.json

[
  {
    "question": ["faFaceGrinBeamSweat", "주말에 친구들이 갑자기 <br />방문했습니다. 당신의 반응은?"],
    "choice1": "즉석에서 무언가를 요리하여 <br />함께 먹는다.",
    "choice2": "사전에 말이 있었으면 <br />좋았을 텐데..."
  },
  {
    "question": ["faBookOpenReader", "식당에서 메뉴를 선택할 때, <br />당신은?"],
    "choice1": "이미 먹어 본 메뉴를 선택한다.",
    "choice2": "새로운 메뉴를 시도해본다."
  },
  ...
]

2. result.json

{
  "ESTP": 1,
  "ISTJ": 2,
  "ISFP": 3,
  "ESTJ": 4,
  "ISTP": 5,
  ...
}

3. resultInfo.json

{
  "1": {
    "typeName": "계란 김밥",
    "typeTitle": "현실적인 모험가",
    "typeTag": "#현실주의자 #즉흥적인결정 <br />#실용주의",
    "typeInfo": "실용적이고 현실적임/융통성 있음/다양한 재료를 활용하는 창의성 있음/감각적이고 실제적인 경험을 선호함/기회를 즉시 포착함/즉흥적으로 행동함/사람들과 함께 있을 때 에너지를 얻음/적응력과 재치 있음/결과 중심적임/빠른 의사결정을 할 수 있음",
    "typeGuide": "장기적인 계획을 세우고 이를 따르는 연습을 해보세요./객관적인 분석과 자료 수집에 의존하려는 경향을 개발하세요./타인의 감정과 입장을 이해하려는 노력을 기울이세요.",
    "typeImage": "1.png",
    "typeUrl": "https://www.youtube.com/results?search_query=%EA%B3%84%EB%9E%80+%EA%B9%80%EB%B0%A5"
  },
  ...
}

3-5. Test.jsx

  • 몇 개의 주요 코드만 작성했습니다.
const navigate = useNavigate();
const timeoutRef = useRef(null);

const [qNum, setQNum] = useState(0);					// 현재 문제 번호 저장 State
const [choices, setChoices] = useState({});				// 문제 답변 저장 State
const [isFinished, setIsFinished] = useState(false);	// 모든 문제 완료 여부 State

useEffect(() => {
  // 문제를 다 풀었을 시
  if (qNum === questionData.length-1) {
    handleResults();
  }

  // 2초의 유예 시간을 주기 위해
  return () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
  }
}, [qNum, choices]);

// 가중치의 값이 base64로 되어 있기 때문에 decode 해야 함
const decodeUnicode = (str) => {
  return decodeURIComponent(
    atob(str)
      .split("")
      .map(function (c) {
        return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
      })
      .join("")
  );
}

// 가중치 계산 후 2초 유예 시간 후에 결과 페이지로 이동 
const handleResults = async () => {
  if (Object.keys(choices).length === questionData.length) {
    const typeIdx = await weightCalc();
    setIsFinished(true);
    
    timeoutRef.current = setTimeout(() => {
      navigate(`/result?idx=${typeIdx}`);
    }, 2000);
  }
}

// 가중치 계산
const weightCalc = async () => {
  let sumList = [0, 0, 0, 0, 0, 0, 0, 0];
  const decodeWeight = JSON.parse(decodeUnicode(process.env.REACT_APP_WEIGHT));

  for (let i in choices) {
    for (let [index, item] of decodeWeight[i][choices[i]].entries()) {
      sumList[index] += item;
    }
  }

  const first = sumList[0] > sumList[1] ? 'E' : 'I';
  const second = sumList[2] > sumList[3] ? 'S' : 'N';
  const fourth = sumList[4] > sumList[5] ? 'J' : 'P';
  const third = sumList[6] > sumList[7] ? 'T' : 'F';
  const typeIdx = resultData[first+second+third+fourth];

  return typeIdx;
}

3-6. Result.jsx

const mainUrl = "https://egg-type.netlify.app/";	// 메인 URL
const currentUrl = window.location.href;			// 현재 URL

const location = useLocation();
const navigate = useNavigate();
const [eggTypeInfo, setEggTypeInfo] = useState({
  typeTitle: '',
  typeImage: '',
  typeName: '',
  typeTag: '',
  typeUrl: '',
  typeInfo: [],
  typeGuide: []
});

// https://egg-type.netlify.app/result?idx=${typeIdx}
// idx의 쿼리 스트링 값을 가져와 resultInfo.json과 매칭
// 이후 이미지를 동적으로 가져옴
useEffect(() => {
  const queryParams = new URLSearchParams(location.search);
  const query = queryParams.get('idx');
  const eggType = resultInfo[query];

  // 동적 import
  import(`assets/images/${eggType['typeImage']}`).then((image) => {
    setEggTypeInfo(prev => ({
      ...prev,
      typeTitle: eggType['typeTitle'],
      typeImage: image.default,
      typeName: eggType['typeName'],
      typeTag: eggType['typeTag'],
      typeUrl: eggType['typeUrl'],
      typeInfo: splitList(eggType['typeInfo']),
      typeGuide: splitList(eggType['typeGuide'])
    }));
  });
}, [location]);

// 공유 버튼 (총 4개 : 카카오 공유, 페이스북 공유, 트위터 공유, 현재 URL 복사)
// 카카오 공유 참고 : https://developers.kakao.com/docs/latest/ko/message/js-link
// document.execCommand 함수의 경우 이제는 지원하지 않으므로 다른 방법으로 사용해도 됨
const ShareBtn = (type) => {
  if (type === 'kakao') {
    window.Kakao.Share.sendDefault({
      objectType: 'feed',
      content: {
        title: eggTypeInfo.typeName,
        description: eggTypeInfo.typeTag.replaceAll('<br />', ''),
        imageUrl: `${window.location.origin}${eggTypeInfo.typeImage}`,
        link: {
          mobileWebUrl: currentUrl,
          webUrl: currentUrl,
        },
      },
      buttons: [
        {
          title: '테스트하기',
          link: {
            mobileWebUrl: mainUrl,
            webUrl: mainUrl,
          },
        },
        {
          title: '결과보기',
          link: {
            mobileWebUrl: currentUrl,
            webUrl: currentUrl,
          },
        },
      ],
    });
  } else if (type === 'facebook') {
    window.open("https://www.facebook.com/sharer/sharer.php?u=" + currentUrl);
  } else if (type === 'twitter') {
    const text = '나는 어떤 계란 요리일까?'
    window.open("https://twitter.com/intent/tweet?text=" + text + "&url=" + currentUrl);
  } else {
    const copyToClipboard = (text) => {
      const textarea = document.createElement("textarea");
      textarea.value = text;
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand('copy');
      document.body.removeChild(textarea);
    };

    copyToClipboard(currentUrl);
  }
}

3-7. View.jsx

  • 전체 유형을 보여주고 클릭 시 해당 유형의 결과 페이지로 이동
const gridData = [
  { title: '계란 김밥', alt: '계란 김밥 이미지' },
  { title: '계란 말이', alt: '계란 말이 이미지' },
  { title: '계란 볶음밥', alt: '계란 볶음밥 이미지' },
  ...
]

const View = () => {
  const [imgList, setImgList] = useState([]);

  // 이미지 불러오기
  useEffect(() => {
    const images = Array.from({ length: 16 }, (_, i) => require(`../assets/images/${i+1}.png`))
    setImgList(images);
  }, []);

  return (
    <>
      <GlobalStyles bgColor={'#fff2cc'}/>
      <ViewContainer justifyContent={'center'} maxWidth={'425px'}>
        <div>
          <H1>성격 유형 테스트</H1>
          <H2>나는 어떤 계란 요리일까?</H2>
        </div>
        <GridContainer>
          {imgList.map((img, index) => (
            <GridItem key={index} href={`/result?idx=${index+1}`}>
              <img src={img} alt={gridData[index].alt} />
              <p>{gridData[index].title}</p>
            </GridItem>
          ))}
        </GridContainer>
      </ViewContainer>
    </>
  );
};

4. 마무리

전체적인 기획은 내가 했지만 성격 유형 테스트 문제를 만든다는 것이 꽤 어렵고 가중치 설정하는 방식에서 많이 애를 먹었다.
문제는 GPT를 사용해서 만들었지만, 가중치의 경우 다양하고 MBTI라는 틀에서 벗어나는 방식으로 하고 싶었지만 실패하여 다른 MBTI 테스트 사이트와 비슷하게 진행했다.
이미지의 경우도 원래는 DALL·E를 사용해서 쓰고 싶었지만 너무 그림이 원하는 대로 나오지 않아 찾아보던 중 MS에서 만든 사이트가 가장 적합하다고 판단하여 사용하게 되었다.

현재는 Netlify, Firebase 등을 사용하여 배포 해봤고 도메인을 구매하여 나만의 사이트로 배포를 하고 싶어서 AWS로 배포를 진행했고 S3를 사용해 정적 배포 후 CloudFront CDN 설정 및 Route53을 통한 DNS 관리로 진행했다 추후 SEO 적용을 해서 구글이나 네이버에도 나올 수 있게 할 생각이다.

0개의 댓글