해당 토이 프로젝트는 아래와 같은 기술을 사용할 것이다.

- Reactjs, Typescript, Tailwindcss
- Opentdb API(퀴즈 API)
- Google Cloud Translation API

먼저 프로젝트를 구성해보자.

 

1. 아래 명령어를 입력한다.

 

1
2
3
4
# 리액트 앱 설치
$ npx create-react-app react-ts-quiz-app --template typescript
$ cd react-ts-quiz-app
$ npm start
cs

 

 

2. 서버가 정상적으로 뜨는지 확인한다.

 

 

3. tailwind css를 적용해보자.

 

https://tailwindcss.com/docs/guides/create-react-app

 

Install Tailwind CSS with Create React App - Tailwind CSS

Setting up Tailwind CSS in a Create React App project.

tailwindcss.com

위 설명대로 따라하면 된다.

 

 

1
2
3
# tailwindcss 설치
$ npm install -D tailwindcss
$ npx tailwindcss init
cs

 

제대로 설치 되었다면 아래와 같이 css가 적용된 화면이 나타날것이다.

 

 

4. 퀴즈 API 데이터를 가져오자.

 

퀴즈 데이터는 오픈 API인 Open Trivia DB 에서 가져올 것이다.

 

https://opentdb.com/

 

Open Trivia DB

Free to use, user-contributed trivia questions!

opentdb.com

 

퀴즈 카테고리, 타입, 난이도, 정답, 오답 등의 json 데이터를 확인가능하다.

 

https://opentdb.com/api.php?amount=10 

 

그런데 문제가 생겼다. json 데이터가 모두 영어 데이터이다.

영어 json 데이터 를 한글로 번역하면 좋을 것 같다.

구글 번역 API를 사용할 것이다.

 

 

5. 구글 클라우드 콘솔에 접속한다.

 

 

프로젝트를 하나 생성 후 Cloud Translation API 를 사용설정한다.

사용자 인증 정보 > 사용자 인증 정보 만들기 > API 키 생성한다.

 

 

API Key 값을 복사해서 갖고 있는다.

API 키 수정을 통해 호출 제한 사항을 적절하게 설정한다.

 

 

 

 

6. 구글 번역 API를 사용해 데이터를 번역해서 가져오자.

 

1
2
# tailwindcss 설치
$ npm i @google-cloud/translate
cs

 

 

6.1 먼저 퀴즈 객체 생성 후 데이터를 가져오자.

 

export interface Quiz {
    category: string;
    type: string;
    difficulty: string;
    question: string;
    correct_answer: string;
    incorrect_answers: string[];
    all_answers: string[];
}
  const getQuizData = async() => {
    let problemCnt = '1'
    let url = `https://opentdb.com/api.php?amount=${problemCnt}&difficulty=easy&type=multiple`
    let response = await fetch(url, {
      method: 'GET',
    }); 
    let data = await response.json()
    return data;
  }

 

 

6.2 가져온 퀴즈 데이터를 인자로 받아 구글 번역 API를 불러 번역시킨다.

 

  const translate = async (quizzes: Quiz[]) => {
    let url = `https://translation.googleapis.com/language/translate/v2?key=${apiKey}`

    multiplePreTranslate = '';
    let preTranslate = '';

    for(let quizObj of quizzes){
      let icaListStr = '';
      for (let ica of quizObj.incorrect_answers) {
        if(ica === quizObj.incorrect_answers[quizObj.incorrect_answers.length - 1]) {
          icaListStr += `${ica}`;
        }else{
          icaListStr += `${ica}\n`;
        }
      }
      if(quizObj === quizzes[quizzes.length - 1]) {
        preTranslate += `${quizObj.category}\n${quizObj.difficulty}\n${quizObj.question}\n${quizObj.correct_answer}\n${icaListStr}`;      
      }else{
        preTranslate += `${quizObj.category}\n${quizObj.difficulty}\n${quizObj.question}\n${quizObj.correct_answer}\n${icaListStr}\n\n`;      
      }
    }

    multiplePreTranslate = dataStrReplace(preTranslate);

    let response = await fetch(url, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
       },
       body: JSON.stringify({
         target: 'ko',
         format: "text",
         q: multiplePreTranslate
       }),
    }); 
    let data = await response.json()
    return data;
  }

 

원활한 번역을 위해 문자열과 구분은 한줄내림(\n) 으로 구분해준다.

 

퀴즈데이터를 받아와 구글 번역 API를 통해 아래와 같이 데이터가 적절하게 번역된것을 확인할수 있다.

 

// 번역 전 퀴즈 데이터
`
Entertainment: Musicals & Theatres
medium
When was the play "Macbeth" written?
1606
1605
1723
1628

Entertainment: Musicals & Theatres
easy
Which Shakespeare play inspired the musical 'West Side Story'?
Romeo  Juliet
Hamlet
Macbeth
Othello
`;

// 번역 후 퀴즈 데이터
`
엔터테인먼트: 뮤지컬 및 극장
중간
연극 "맥베스"는 언제 쓰여졌습니까?
1606년
1605년
1723년
1628년

엔터테인먼트: 뮤지컬 및 극장
쉬움
어떤 셰익스피어 연극이 뮤지컬 '웨스트 사이드 스토리'에 영감을 주었습니까?
로미오  줄리엣
작은 촌락
맥베스
오셀로
`;

 

7. 화면을 꾸며보자. 

 

App.tsx

import React, { useLayoutEffect, useState } from 'react';
import './App.css'; 
import { Quiz } from './quiz.interface';

function App() {
  const [translateQuizzArr, setTranslateQuizzArr] = useState<Quiz[]>([]);
  const [currentQuizIndex, setCurrentQuizIndex] = useState<number>(0);
  const [score, setScore] = useState<number>(0);
  const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
  const [showAnswer, setShowAnswer] = useState<boolean>(false);
  const [hasIncorrectAttempt, setHasIncorrectAttempt] = useState(false);
  const [isCorrect, setIsCorrect] = useState(false);
  const [quizTrigger, setQuizTrigger] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  const { REACT_APP_GOOGLE_TRANSLATE_API_KEY } = process.env;
  const apiKey: string | undefined = REACT_APP_GOOGLE_TRANSLATE_API_KEY;
  if (!apiKey) {
    throw new Error('Missing Google API Key');
  }
  let multiplePreTranslate = '';

  useLayoutEffect(() => {
    getQuizData().then(response => {

      console.log(response.data);
      let quizzes: Quiz[] = response.results;
      let quizArr: Quiz[] = [];
  
      const translatePromises = translate(quizzes)
        .then(response => {
          let translatedData = response?.data?.translations[0]?.translatedText;
          let multipleArrPreTranslate = translatedData.split('\n\n');
          let multipleEngPreTranslate = multiplePreTranslate.split('\n\n');
          for(let i=0; i < multipleArrPreTranslate.length; i++){
            let data = multipleArrPreTranslate[i];
            let engData = multipleEngPreTranslate[i];

            let arrPreTranslate = data.split('\n');
            let engArrPreTranslate = engData.split('\n');
            let engRightAnswerStr = dataStrReplace(engArrPreTranslate[3]);
            let engWrongAnswerStr1 = dataStrReplace(engArrPreTranslate[4]);
            let engWrongAnswerStr2 = dataStrReplace(engArrPreTranslate[5]);
            let engWrongAnswerStr3 = dataStrReplace(engArrPreTranslate[6]);

            let incorrectStrArr: string[] = [];
            let questionStr = `${dataStrReplace(arrPreTranslate[2])}`;
            let rightAnswerStr = `${dataStrReplace(arrPreTranslate[3])} ( ${engRightAnswerStr} )`; 
            let wrongAnswerStr1 = `${dataStrReplace(arrPreTranslate[4])} ( ${engWrongAnswerStr1} )`;
            let wrongAnswerStr2 = `${dataStrReplace(arrPreTranslate[5])} ( ${engWrongAnswerStr2} )`;
            let wrongAnswerStr3 = `${dataStrReplace(arrPreTranslate[6])} ( ${engWrongAnswerStr3} )`;

            incorrectStrArr.push(wrongAnswerStr1, wrongAnswerStr2, wrongAnswerStr3);
            let allAnswersStrArr: string[] = [];
            allAnswersStrArr.push(...incorrectStrArr, rightAnswerStr);
            shuffleArray(allAnswersStrArr);
  
            let translateQuizObj: Quiz = {
              category: arrPreTranslate[0],
              difficulty: arrPreTranslate[1], 
              question: questionStr,
              correct_answer: rightAnswerStr,
              incorrect_answers: incorrectStrArr,
              all_answers: allAnswersStrArr,
              type: ''
            };
            quizArr.push(translateQuizObj);
          }
        })
        .catch(reason => {
          console.log(reason.message);
          return {
            category: '',
            difficulty: '',
            question: '',
            correct_answer: '',
            incorrect_answers: [''],
            type: ''
          } as Quiz;
        });
  
      Promise.all([translatePromises]).then(() => {
        setTranslateQuizzArr(quizArr);
        setIsLoading(false);
      });
  
    });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [quizTrigger]);
  
  const handleSolveMore = () => {
    let quizArr: Quiz[] = [];
    setTranslateQuizzArr(quizArr);
    setIsLoading(true);
    setQuizTrigger(!quizTrigger); 
    setScore(0);
    setCurrentQuizIndex(0);
  };
  
  const handleAnswer = (answer: string) => {
    setSelectedAnswer(answer);
    const isAnswerCorrect = answer === translateQuizzArr[currentQuizIndex].correct_answer;
  
    if (isAnswerCorrect) {
      setIsCorrect(true);
      if (!hasIncorrectAttempt) {
        setScore(score + 1);
      }
      setShowAnswer(true);
  
      setTimeout(() => {
        setIsCorrect(false);
        setShowAnswer(false);
        setHasIncorrectAttempt(false);
        setCurrentQuizIndex(currentQuizIndex + 1);
      }, 800);  
    } else {
      setShowAnswer(true);
      setHasIncorrectAttempt(true);
    }
  };
  


  function dataStrReplace(arrPreTranslate: any) {
    return arrPreTranslate.replace(/&quot;/g, '"')
                     .replace(/&#039;/g, "'")
                     .replace(/&ldquo;/g, '"')
                     .replace(/&rdquo;/g, '"');
  }

  function shuffleArray(array: any[]) {
    for (let i = array.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [array[i], array[j]] = [array[j], array[i]];
    }
  }
  
  async function getQuizData() {
    let problemCnt = '3'
    let url = `https://opentdb.com/api.php?amount=${problemCnt}&difficulty=easy&type=multiple`
    let response = await fetch(url, {
      method: 'GET',
    }); 
    let data = await response.json()
    return data;
  }

  async function translate(quizzes: Quiz[]) {
    let url = `https://translation.googleapis.com/language/translate/v2?key=${apiKey}`

    multiplePreTranslate = '';
    let preTranslate = '';

    for(let quizObj of quizzes){
      let icaListStr = '';
      for (let ica of quizObj.incorrect_answers) {
        if(ica === quizObj.incorrect_answers[quizObj.incorrect_answers.length - 1]) {
          icaListStr += `${ica}`;
        }else{
          icaListStr += `${ica}\n`;
        }
      }
      if(quizObj === quizzes[quizzes.length - 1]) {
        preTranslate += `${quizObj.category}\n${quizObj.difficulty}\n${quizObj.question}\n${quizObj.correct_answer}\n${icaListStr}`;      
      }else{
        preTranslate += `${quizObj.category}\n${quizObj.difficulty}\n${quizObj.question}\n${quizObj.correct_answer}\n${icaListStr}\n\n`;      
      }
    }

    multiplePreTranslate = dataStrReplace(preTranslate);

    let response = await fetch(url, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
       },
       body: JSON.stringify({
         target: 'ko',
         format: "text",
         q: multiplePreTranslate
       }),
    }); 
    let data = await response.json()
    return data;
  }

  return (
    <div className="App bg-blue-50 min-h-screen flex flex-col items-center justify-center font-nanum-gothic font-bold">
      <h1 className="text-4xl mb-4 font-bold">랜덤퀴즈 앱</h1>
      {
        isLoading ? 
        <div>Loading...</div> : 
        (
          <h2 className="text-2xl mb-8">점수 : {score}</h2>
        )
      }
      {translateQuizzArr.length > 0 && currentQuizIndex <= translateQuizzArr.length - 1 ? (
        <div className="w-full bg-white p-8 rounded shadow flex flex-col">
          <h2 className="text-xl mb-4">{translateQuizzArr[currentQuizIndex].question}</h2>

          {translateQuizzArr[currentQuizIndex].all_answers.map((answer, index) => (

          <button
            key={index}
            onClick={() => handleAnswer(answer)}
            className={`my-2 p-4 text-white 
              ${showAnswer && answer === translateQuizzArr[currentQuizIndex].correct_answer 
                ? 'bg-green-500 text-white animate-pulse'  
                : showAnswer && answer === selectedAnswer
                ? 'bg-red-500 text-white'
                : 'bg-sky-500/100 text-white'}`}
          >
            {answer}
          </button>

          ))}

          {isCorrect && (
            <div className="bg-green-500 text-white p-4 mt-4 rounded">
              정답입니다. 다음 문제로 이동 중...
            </div>
          )}
            
          {showAnswer && !isCorrect && (
            <div className="text-red-500 p-4 mt-4 rounded">
              정답은 : {translateQuizzArr[currentQuizIndex].correct_answer} 입니다.
            </div>
          )}
        </div>
      ) : isLoading ? <div>      
        <h2 className="text-2xl">랜덤 퀴즈 로딩 중입니다.</h2>
      </div> : 
      (
        <>
        <h2 className="text-2xl">퀴즈가 끝났습니다. <br/> 최종 점수는 {score} 점 입니다.</h2>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-4" onClick={handleSolveMore}>
          퀴즈 더 풀기
        </button>
      </>
      )
    }
    </div>
  );

}

export default App;

 

App.css

/* App.css */
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800;900&display=swap');

body {
  @apply font-sans m-0 p-0 bg-gray-200;
}

.App {
  @apply max-w-md mx-auto p-5 bg-white rounded-lg shadow-md;
}

h1 {
  @apply text-blue-600 text-center;
}

.input-container {
  @apply bg-blue-100 p-2 rounded mb-4;
  @apply w-full mx-2 box-border;
}

input {
  @apply w-full py-2 px-3 border-none outline-none text-lg rounded bg-blue-200 box-border;
}

.translated-text {
  @apply text-lg leading-loose;
}

@media screen and (max-width: 600px) {
  .App {
    @apply p-2;
  }
}

@media screen and (max-width: 400px) {
  .App {
    @apply p-1;
  }
}

 

 

7.1 결과 화면

 

- 퀴즈는 한 사이클당 30문제로 구성된다.

- 퀴즈를 푸는 사용자는 사지선다로 문제를 풀수 있다

- 최초 정답 선택 시 점수가 올라간다.

- 최초 틀린 문제를 선택 시 점수가 올라가지 않고 정답을 알려준다.

- 정답을 선택 시 다음 문제로 진입할 수 있다.

- 퀴즈 주제 바꾸기로 퀴즈 카테고리를 바꿀수 있다.

 

 

8. 문제가 생겼다. 구글 클라우드 번역 API는 공짜가 아니다.

 

하루에 요청가능한 글자수와 건수가 정해져있다.  ( 프로젝트가 결제설정 되어있다면 사용한 만큼 지불된다)

 

 

https://cloud.google.com/translate/pricing?hl=ko 

 

가격 책정  |  Cloud Translation  |  Google Cloud

Cloud Translation 가격 책정 검토

cloud.google.com

 

 

https://cloud.google.com/billing/docs/how-to/modify-project?hl=ko#how-to-disable-billing 

 

프로젝트의 결제 사용 설정, 사용 중지, 변경  |  Cloud Billing  |  Google Cloud

의견 보내기 프로젝트의 결제 사용 설정, 사용 중지, 변경 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 각 Google Cloud 프로젝트와 Google Maps

cloud.google.com

 

 

사용량을 초과하면 아래와 같이 에러가 발생될 것이다.

 

User Rate Limit Exeeded !!!! (허허 ... 흙발자 무시하는겁니가... ㅠㅠ)

 

퀴즈 데이터는 카테고리가 약  24개이고 40문제씩 호출할 수 있었다.

그럼 총 문제수만 8~900 개 일 것이다.

 

퀴즈 오픈 API도 분명 데이터가 추가되거나 업데이트 될텐데...

 

1. 매번 앱 실행 시 구글 번역 API를 실행해야한다?

-> 번역 API 트래픽 다량 발생가능성 높음. 얼마나 번역할것인가?

새로운 퀴즈 데이터에 대한 업데이트나 추가 갱신이 빠름  

 

2. 처음 불러올때 데이터를 조금만 불러온다 ?

-> 퀴즈데이터 번역하는 시간 또한 고려해야하니 유저입장에서 다음화면으로 가기전까지 피곤할것이다.

번역 API 트래픽은 줄었지만 발생한다.

 

3. 대량의 데이터를 한번에 번역해서 파일형태의 static으로 갖고있는다? 

-> 퀴즈 데이터 최신화 업데이트에 대한 문제 

 

일단 비용이 발생하면 부담이 발생될것 같아 3번으로 고치겠다.

 

 

8.1 퀴즈 데이터 static 파일 만들기

 

quizData.tsx

export const ENG_QUIZ_CATE9 = 
`
General Knowledge
easy
What is the official language of Brazil?
Portugese
Brazilian
Spanish
English
`;

export const KOR_QUIZ_CATE9 = 
`
일반 지식
쉬움
브라질의 공식 언어는 무엇입니까?
포르투갈어
브라질
스페인의
영어
`;

 

영어 원문 데이터, 한글 번역된 데이터 2개를 갖고 static 데이터를 불러와 활용하고 있다.

 

App.tsx

  const getStaticQuizData = () => {
    let quizArr: Quiz[] = [];
    let quizCategory = [
        {"eng": ENG_QUIZ_CATE9, "kor" : KOR_QUIZ_CATE9}
      , {"eng": ENG_QUIZ_CATE10, "kor": KOR_QUIZ_CATE10}  
    ]

    shuffleArray(quizCategory);

    for(let data=0; data < quizCategory.length; data++){
      let quizCate = quizCategory[data];
      let multipleArrPreTranslate = quizCate.kor.split('\n\n');
      let multipleEngPreTranslate = quizCate.eng.split('\n\n');

      for(let i=0; i < multipleArrPreTranslate.length; i++){
        let data = multipleArrPreTranslate[i];
        let engData = multipleEngPreTranslate[i];
  
        let arrPreTranslate = data.split('\n');
        let engArrPreTranslate = engData.split('\n');
        let engRightAnswerStr = dataStrReplace(engArrPreTranslate[3]);
        let engWrongAnswerStr1 = dataStrReplace(engArrPreTranslate[4]);
        let engWrongAnswerStr2 = dataStrReplace(engArrPreTranslate[5]);
        let engWrongAnswerStr3 = dataStrReplace(engArrPreTranslate[6]);
  
        let incorrectStrArr: string[] = [];
        let questionStr = `${dataStrReplace(arrPreTranslate[2])}`;
        let rightAnswerStr = `${dataStrReplace(arrPreTranslate[3])} ( ${engRightAnswerStr} )`; 
        let wrongAnswerStr1 = `${dataStrReplace(arrPreTranslate[4])} ( ${engWrongAnswerStr1} )`;
        let wrongAnswerStr2 = `${dataStrReplace(arrPreTranslate[5])} ( ${engWrongAnswerStr2} )`;
        let wrongAnswerStr3 = `${dataStrReplace(arrPreTranslate[6])} ( ${engWrongAnswerStr3} )`;
  
        incorrectStrArr.push(wrongAnswerStr1, wrongAnswerStr2, wrongAnswerStr3);
        let allAnswersStrArr: string[] = [];
        allAnswersStrArr.push(...incorrectStrArr, rightAnswerStr);
        shuffleArray(allAnswersStrArr);
  
        let translateQuizObj: Quiz = {
          category: arrPreTranslate[0],
          difficulty: arrPreTranslate[1], 
          question: questionStr,
          correct_answer: rightAnswerStr,
          incorrect_answers: incorrectStrArr,
          all_answers: allAnswersStrArr,
          type: ''
        };
        quizArr.push(translateQuizObj);
      }
    }
    setTranslateQuizzArr(quizArr);
    setIsLoading(false);
  }

 

 

결국 API를 활용하지 않았으니 속도는 빨라졌다.

최신 데이터에 대한 업데이트는 주기적으로 API를 호출하여

기존 데이터와 비교하여 변경사항에 대한 정보를 받아올지 생각해봐야한다.

 

마지막으로 배포된 url과 github 주소이다.

 

https://shlee0882.github.io/react-ts-quiz-app/

 

랜덤퀴즈 앱

 

shlee0882.github.io

 

https://github.com/shlee0882/react-ts-quiz-app

 

GitHub - shlee0882/react-ts-quiz-app: :question: ReactJs, Ts, Tailwindcss 랜덤 퀴즈 앱 토이 프로젝트

:question: ReactJs, Ts, Tailwindcss 랜덤 퀴즈 앱 토이 프로젝트 - GitHub - shlee0882/react-ts-quiz-app: :question: ReactJs, Ts, Tailwindcss 랜덤 퀴즈 앱 토이 프로젝트

github.com

 

react-ts-quiz-app의

dev 브랜치에 퀴즈 데이터가 static으로 변경된 소스가 올라가있다.

master 브랜치에는 구글 번역 api가 적용된 소스가 올라가있다.

 

https://github1s.com/shlee0882/react-ts-quiz-app

 

GitHub1s

 

github1s.com

 

이상으로 포스팅을 마치겠다.

 

 

Swagger 설정, Google Cloud MySQL 설정을 완료했으니

 

1. 구글 로그인 사용을 위해 아래 노드 패키지를 설치해준다.

 

1
2
3
# passport 설치
$ npm install @nestjs/passport
$ npm install passport-google-oauth20
cs

 

2. src 하위에 auth 폴더를 만들어준다.

 

 auth 폴더 하위에 cotroller, service, module 을 만들어준다.

 

2.1. auth.contoller.ts 생성

// auth.controller.ts
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

@ApiTags('auth API')
@Controller('auth')
export class AuthController {

  @ApiOperation({ summary: 'google login auth' })
  @ApiResponse({ status: 200, description: 'google login auth' })
  @Get('google')
  @UseGuards(AuthGuard('google'))
  googleLogin() {
  }

  @ApiOperation({ summary: 'google login auth callback' })
  @ApiResponse({ status: 200, description: 'google login auth callback' })
  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  googleLoginCallback(@Req() req) {
    return {
      user: req.user,
    };
  }
}

 

2.2. auth.service.ts 생성

// auth.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {
}

2.3. auth.module.ts 생성

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/user.entity';
import { GoogleStrategy } from 'src/strategies/google.strategy';
import { AuthController } from './auth.contoller';
@Module({
  // eslint-disable-next-line prettier/prettier
  imports: [
    PassportModule.register({ defaultStrategy: 'google' }),
    TypeOrmModule.forFeature([User]),
  ],
  providers: [AuthService, GoogleStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

 

 

2.4. Passport의 Google 전략 사용을 위해 src/strategies/google.strategy.ts 파일을 생성 후 작성해준다.

// google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { Strategy } from 'passport-google-oauth20';
import { User } from 'src/user/user.entity';
import { Repository } from 'typeorm';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {
    super({
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: 'http://localhost:3000/auth/google/callback',
      scope: ['email', 'profile'],
    });
  }

  // eslint-disable-next-line @typescript-eslint/ban-types
  async validate(accessToken: string, refreshToken: string, profile: any, done: Function): Promise<any> {
    const { name, emails, photos } = profile;

    const user = await this.userRepository.findOne({ where: { email: emails[0].value } });

    if (user) {
      return done(null, user);
    }

    const newUser = new User();
    newUser.email = emails[0].value;
    newUser.firstName = name.givenName;
    newUser.lastName = name.familyName;
    newUser.picture = photos[0].value;

    const result = await this.userRepository.save(newUser);

    return done(null, result);
  }
}

 

2.5.  User 정보 데이터 바인딩 및 저장을 위해 src/user/user.entity.ts 파일을 생성해준다.

// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column({ nullable: true })
  picture: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

 

2.6 서버를 실행 후 아래 주소로 접속한다.

 

http://localhost:3000/auth/google

 

구글 계정 목록이 나타나고 로그인 시 계정에 대한 정보를 확인할 수 있다.

 

이제 해당 데이터를 가지고 service 단에서 User 정보를 DB에 저장할수 있고

필요한 로직을 추가할수 있다.

 

기본적인 셋팅이 완료되면 localhost로 갖고있는것보단

Google App Engine을 활용해 NestJS 프로젝트를 바로 올려 DEV(개발용)으로 사용할수 있다.

해당 과정은 이지하므로 포스팅 작성하는것을 생략하려고 한다.

 

 

위 이미지를 통해 DEV로 올라간 NestJS 프로젝트 를 확인할수 있다.

 

마지막으로 github 코드를 첨부한다.

 

https://github.com/shlee0882/nestjs-study

 

GitHub - shlee0882/nestjs-study: :cat: Nest JS 스터디

:cat: Nest JS 스터디. Contribute to shlee0882/nestjs-study development by creating an account on GitHub.

github.com

 

 

이번 토이 프로젝트는 NetJS에 Google Cloud MySQL , Google App Engine을 붙여사용했는데

개발에 대한 편의성은 높아졌지만 서비스 이용에 대한 비용부담이 커졌다.

 

15일 정도 혼자 사용했는데 7만원이라니...  굉장한 과금이다...

개발비용을 고려하지 못한 내 잘못이긴한데  흙수저인 나에게 부담이 되긴하다.

 

 

'전체 > NestJS' 카테고리의 다른 글

NestJs 토이 프로젝트 구성해보기 - 1  (2) 2023.07.16

NestJS는 백엔드 서버 사이드 애플리케이션을 구축하기 위한 프레임워크이다.

고양이 그림이 귀여워서 공부해보았다.

 

 

 

1. nestjs/cli 를 전역으로 설치해준다.

 

1
2
3
# nest 프로젝트 설치
$ npm i -g @nestjs/cli
$ nest new nestjs-study
cs

 

2. 적절한 위치에서 nestjs 프로젝트를 생성해준다.

 

3. vsc 를 실행하면 다음과 같은 구조가 보일것이다.

NestJS는 모듈, 서비스, 컨트롤러라는 개념을 사용해 코드를 구조화한다.

 

4. 서버를 실행하면 Hello World 가 나오게 된다.

5. 모듈, 서비스, 컨트롤러라는 개념을 적용해 확장 구현해보자.

 

아래 cmd를 이용해 task subject에 대한 service, controller, module을 생성해준다.

 

 

1
2
3
4
# controller, service, module 생성
$ nest generate controller task
$ nest generate service task
$ nest generate module task
cs

src 하위로 디렉토리가 생성된다. 

 

6. TypeORM을 사용하여 Google Cloud SQL의 MySQL 데이터베이스에 연결하기

 

1
2
3
4
5
6
7
8
9
10
11
# type orm mysql 설치
$ npm install --save @nestjs/typeorm typeorm mysql
 
# 환경변수 설정파일 
$ npm install dotenv
 
# config env파일 불러오기 위한 설치
$ npm install @nestjs/config
 
# swagger 설치
$ npm install --save @nestjs/swagger swagger-ui-express
cs

 

필요한 노드 패키지를 설치해준다.

.env 파일에 민감한 정보를 작성해주고 저장한다.

 

 

6-1. app.module.ts 파일을 수정해준다.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskModule } from './task/task.module';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // makes the config global
      envFilePath: '.env', // point to .env in root directory
    }),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: process.env.DB_HOST,
      port: +process.env.DB_PORT,
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
      extra: process.env.INSTANCE_CONNECTION_NAME && process.env.NODE_ENV === 'dev' 
      ? { socketPath: `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` } 
      : undefined,
    }),
    TaskModule,
  ],
})
export class AppModule {}

 

6-2. task.entity.ts 파일을 만든다.

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('tasks') // Specifies the name of the table in the database
export class Task {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  description: string;
}

 

6-3. task.controller.ts 파일을 수정해준다.

import { Controller, Get, Post, Body } from '@nestjs/common';
import { TaskService } from './task.service';
import { Task } from './task.entity';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

@ApiTags('task API')
@Controller('tasks')
export class TaskController {
  constructor(private taskService: TaskService) {}

  @ApiOperation({ summary: 'Retrieve a list of task.' })
  @ApiResponse({ status: 200, description: 'List of task.' })
  @Get()
  findAll(): Promise<Task[]> {
    return this.taskService.findAll();
  }

  @ApiOperation({ summary: 'Create an task.' })
  @ApiResponse({ status: 201, description: 'The task has been successfully created.' })
  @Post()
  create(@Body() task: Task): Promise<Task> {
    return this.taskService.create(task);
  }
}

 

6-4. task.service.ts 파일을 수정해준다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task } from './task.entity';

@Injectable()
export class TaskService {
  constructor(
    @InjectRepository(Task)
    private taskRepository: Repository<Task>,
  ) {}

  findAll(): Promise<Task[]> {
    return this.taskRepository.find();
  }

  create(task: Task): Promise<Task> {
    return this.taskRepository.save(task);
  }
}

 

6-5. task.module.ts 파일을 수정해준다.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
import { Task } from './task.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Task])],
  controllers: [TaskController],
  providers: [TaskService],
})
export class TaskModule {}

 

6-6 main.ts 파일을 수정해준다.

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { INestApplication } from '@nestjs/common';

export function setupSwagger(app: INestApplication) {
  const options = new DocumentBuilder()
    .setTitle('NestJS Swagger')
    .setDescription('API documentation')
    .setVersion('1.0')
    .addTag('NestJS API LIST')
    .build();
  
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('api-docs', app, document);
}

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  setupSwagger(app);
  await app.listen(3000);
}
bootstrap();

 

구조는 아래와 같을 것이다.

서버를 켜고 DB접속이 정상적이라면

아래와 같은 결과를 확인할수 있다.

 

http://localhost:3000/api-docs#/ 로 접속시

swagger도 정상적으로 접근되는것을 확인할수 있다.

 

 

테스트는 Talend API Tester 크롬 확장프로그램을 사용하겠다.

 

GET 데이터 조회

 

POST 데이터 조회

 

데이터의 영속성이 보장되니 db tool (dbeaver) 을 이용하여 데이터를 확인할 수 있다.

 

다음편은 Google Auth Login을 활용해 보겠다.

 

 

 

'전체 > NestJS' 카테고리의 다른 글

NestJs 토이 프로젝트 구성해보기 - 2  (0) 2023.07.16

ReactJs, TypeScript, Vite를 사용해휴가(연차)를 효율적으로 사용할 수 있게 
추천해주는 [휴가 추천 토이 프로젝트]를 만들어보았다.

 

이번 프로젝트는 TypeScript와 React를 학습하는 것에 포커스를 두었다.

 

TypeScript는 (변수 : 타입 ) 형태로 구성되어 있다.

매개변수 Object의 타입을 알고있다면 Object안에 속한 속성을 빠르게 찾아 접근가능하며

타입을 제한하여 메소드의 진입을 막을수 있다.

 

문제는 API를 만드는 백엔드가 주도권을 갖고 있으므로

프론트는 받아서 사용하는 주체이므로 API가 구조적으로 변경되거나 

타입을 모를때 지정하고 싶지 않을때가 발생된다.

이때는 any로 받아버리면 된다.

 

아래는 DateType이라는 Object를 정의한것이다.

Java에서는 String year; String Month; ... 로 정의가 될것이다.

 

react-calendar (https://www.npmjs.com/package/react-calendar) 모듈을 사용했으며

오픈소스 이므로 가이드를 보며 필요한 props를 주입시켜

커스터마이징 한다.

 

export type DateType = {
  year: string,
  month: string,
  holidayArray: Array<RtnArrType>
  holidayRecArray: any,
  selectHoliday: string
}

const CalendarComponents = (dateType: DateType) => {
  const [value, onChange] = useState(new Date());
  return (
    <Calendar onChange={onChange} 
    className={["box"]}
    view="month"
    onClickMonth={(value, event) => (
      event.preventDefault(),
      event.stopPropagation()
    )}
    onClickDay={(value, event) => (
      event.preventDefault(),
      event.stopPropagation()
    )}
    defaultValue={[new Date(Number(dateType.year), Number(dateType.month))]}
    tileContent={({ activeStartDate, date, view }) => view === 'month'? <TitleContent activeStartDate={activeStartDate} date={date} holidayArray={dateType.holidayArray}/> : null}
    tileClassName={({activeStartDate, date, view }) => changeColorDate(activeStartDate, date, view, dateType)}
    calendarType="US" 
    formatDay={(locale, date) => moment(date).format("D")} />
  )
}

 

useState를 사용하여 상태관리가 가능하게 할수 있다.

{{ }} expression을 사용해 데이터 바인딩을 해준다.

 

fetchData를 통해 공공 휴일데이터 정보를 가져와 화면을 렌더링한다.

 

const App = () => {
  const [selectHoliday, setSelectHoliday] = useState("2");
  const [selectedOption, setSelectedOption] = useState(year);
  const [holidayArray, setHolidayArray] = useState<Array<RtnArrType>>([]);
  const [holidayRecArray, setHolidayRecArray] = useState([]);
  const [data, setData] = useState(null);
  const [error, setError] = useState(false);
  const [loading, setLoading] = useState(false);

  const fetchData = async () => {
    setError(false);
    setLoading(true);
    try {
      let reqUri = rtnReqUriAppend(selectedOption)
      const response = await axios.get(reqUri);
      let rtnArray = response?.data?.response?.body?.items?.item
      let holidayArray = rtnArray.filter((arr:RtnArrType) => arr.isHoliday == "Y");
      setHolidayArray(holidayArray)
      console.log(holidayArray)
      const arrDayArr:any = recHolidayCalc(holidayArray, selectHoliday)
      setHolidayRecArray(arrDayArr)
    } catch (err) {
      setError(true);
    }
    setLoading(false);
  };

  useEffect(() => {

  }, []);

  return (
    <div className="App">
        <p aria-hidden="true" style={{textAlign: "center"}}>
          <Placeholder.Button xs={6} aria-hidden="true">
            직장인 연차 추천 애플리케이션 - 연차 효율적으로 쓰고 여행 가기!<br/>
            연차 사용 추천일이 파란색으로 선택됩니다.
          </Placeholder.Button>
        </p>
        &nbsp;&nbsp;연도: <Form.Select aria-label="Default select example2" onChange={e => {
        setSelectedOption(e.target.value);}}
        value={selectedOption} className='selectBox1'>
        <option>휴가 갈 연도를 선택</option>
        {selectOptionData.map((item:number, index:number) =>(
          <option key={item} value={item}>{item}</option>
        ))}
      </Form.Select>
      사용가능 휴가일수: <Form.Select aria-label="Default select example1" onChange={e => {
        setSelectHoliday(e.target.value);}}
        value={selectHoliday} className='selectBox1'>
        <option>사용가능한 휴가 일수를 선택</option>
        {selectHolidayCnt.map((item:number, index:number) =>(
          <option key={item} value={item}>{item}</option>
        ))}
      </Form.Select>
      <Button variant="success" className='holidaySearch1' onClick={() => {
        fetchData();
      }
      }>휴가 검색!!</Button>
      <div>
        {loading ? (
          <div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
            <BeatLoader size={70} color="green"/>
          </div>
        ) : 
          error? (
          <Alert variant="danger" onClose={() => setError(false)} dismissible>
            <Alert.Heading>Error</Alert.Heading>
            <p>휴일데이터를 갖고 오지 못했습니다.</p>
          </Alert>
          ) : (
          <>
            <div style={{ display: 'flex', flexWrap: 'nowrap' }}>
              {items1.map((item1: string, index: number) => (
                <CalendarComponents key={index} year={selectedOption} month={item1} holidayArray={holidayArray} holidayRecArray={holidayRecArray} selectHoliday={selectHoliday}></CalendarComponents>
              ))}
            </div>
            <div style={{ display: 'flex', flexWrap: 'nowrap' }}>
              {items2.map((item2: string, index: number) => (
                <CalendarComponents key={index} year={selectedOption} month={item2} holidayArray={holidayArray} holidayRecArray={holidayRecArray} selectHoliday={selectHoliday}></CalendarComponents>
              ))}
            </div>
            <div style={{ display: 'flex', flexWrap: 'nowrap' }}>
              {items3.map((item3: string, index: number) => (
                <CalendarComponents key={index} year={selectedOption} month={item3} holidayArray={holidayArray} holidayRecArray={holidayRecArray} selectHoliday={selectHoliday}></CalendarComponents>
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  )
}

 

소스를 수정하며 발생되는 불필요한 기본 설정이나 스타일 등을 node_modules 안에 접근해서 적절하게 수정해준다.

오픈소스를 사용하며 발생되는 불가피한 영역인듯하다.

 

Vite 공식 가이드에 나온대로 배포 준비를 하고 (https://vitejs.dev/guide/static-deploy.html)

github 정적 페이지 배포를 통해 배포해준다.

 

배포된 Deploy URL

https://shlee0882.github.io/react-ts-calendar-app/

 

직장인 연차 추천 애플리케이션

 

shlee0882.github.io

 

repo

 

https://github.com/shlee0882/react-ts-calendar-app

 

GitHub - shlee0882/react-ts-calendar-app: TypeScript, ReactJs, Vite 직장인 휴가 추천 토이 프로젝트

TypeScript, ReactJs, Vite 직장인 휴가 추천 토이 프로젝트. Contribute to shlee0882/react-ts-calendar-app development by creating an account on GitHub.

github.com

 

package.json 

{
  "name": "react-ts-calendar-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@reduxjs/toolkit": "^1.9.2",
    "axios": "^1.3.2",
    "bootstrap": "^5.2.3",
    "react": "^18.2.0",
    "react-big-calendar": "^1.6.3",
    "react-bootstrap": "^2.7.0",
    "react-calendar": "^4.0.0",
    "react-dom": "^18.2.0",
    "react-redux": "^8.0.5",
    "react-spinners": "^0.13.8"
  },
  "devDependencies": {
    "@types/moment": "^2.13.0",
    "@types/react": "^18.0.26",
    "@types/react-big-calendar": "^0.38.4",
    "@types/react-calendar": "^3.9.0",
    "@types/react-dom": "^18.0.9",
    "@vitejs/plugin-react": "^3.0.0",
    "typescript": "^4.9.3",
    "vite": "^4.0.0"
  }
}

 

 

'전체 > 개인 프로젝트' 카테고리의 다른 글

Reactjs, Tailwindcss 토이 퀴즈 앱 만들기  (2) 2023.08.02

model에 enum type을 넣고 

mybatis에서 아래 테이블의 data를 받아오려고 한다.

 

 

enum은 여러 형태를 가질 수 있다.

 

아래와 같이 그냥 단건의 데이터만 지정하여 만드는 경우가 있을 수 있고

 

 

db에 쌓이는 데이터유형이 숫자( 1_VERSION, 2_VERSION )

특수문자로 시작할 경우 enum을 아래와 같이 인자를 2개씩 갖게 지정될 수 있다.

 

 

 

그럴경우 여러 형태의 enum 데이터를 바인딩 하지 못해

아래와 같은 오류가 발생 할 수 있다.

 

Caused by: org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column '' from result set.

Caused by: java.lang.IllegalArgumentException: No enum constant

 

 

 

위소스를 보면 상품정보 rest controller에서 service를 거쳐 mapper에 해당하는 쿼리결과를 lists 객체에

담겨야 하는데 데이터를 못받아와 catch절 NOT_FOUND로 빠져  

swagger에서해당 data를 알지 못해 못받아오는 경우도 생긴다.

 

 

 

이럴경우 TypeHandler를 생성하여 해결할 수 있다.

 

 

 

 

그리고 생성한 type handler 위치의 패키지명을 아래와 같이 지정해준다.

type-handlers-package: com.shlee.toy1.common.handler.type

 

 

 

그리고 api를 조회해보면 아래와 같이 enum으로 정의한 키값으로 나온다.

 

 

 

이제 이 반환된 키 값으로 서비스 로직에서 데이터를 가공해서 반환해주면 된다.

 

 

 

 

이제 여러개의 enum type에 대응가능한 mybatis type handler에 대해 작성할 수 있게 되었다. 

프로젝트에서 이미지 api에서 받아온 이미지 url로

 

이미지 파일 N개 혹은

이미지 파일 1개를 같은 width, height 값으로 N개 분할하여

 

여러개의 이미지

이미지 N개를 1개의 연결된 gif 이미지로 보여주려는 작업요건이 필요했다. 

 

그래서 일단 해당 요건을 테스트 해보기 위해 테스트 소스를 작성해보았다.

 

codepen.io/shlee0882/pen/QWGmJbm

 

이미지 gif 효과 만들기

...

codepen.io

위 소스는 캔버스를 이용해 특정영역을 잡고

이미지 데이터 주소를 획득하여 배열에 넣은 다음

마우스 hover시 이미지 정보가 담긴 배열을 0.5초 간격으로 번갈아 가며 보여주며

마우스 out시는 반복을 멈추는 소스코드이다.

 

그럼 이 코드로 기본검증이 끝났으므로

 

응용하여 gif를 생성하는 라이브러리를 사용하지 않고 (not use gif library)

아래와 같은 움직이는 gif이미지를 생성한 것 같은 착각을 일으키는 코드를 작성할 수 있다.

 

 

 

www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png

 

위 이미지는 구글 웹페이지 메인에 로고이다.

위 이미지의 url이 노출된 상태에서 js에서 해당 이미지url로 image 생성자에 담아 

localhost로 해당 이미지를 조작 및 변경 처리를 시도 하면 아래와 같이

CORS policy: No 'Access-Control-Allow-Origin 오류가 발생한다.

 

 

CORS를 통하지 않고, 다른 origin으로 부터 가져온 이미지를

canvas를 통해 이미지를 자르고 조작 시 
원본데이터가 오염 및 안전하지 않다고 판단하여

js에서 origin exception이 발생한 것이다.

 

developer.mozilla.org/ko/docs/Web/HTML/CORS_enabled_image

 

교차 출처 이미지와 캔버스 허용하기 - HTML: Hypertext Markup Language | MDN

교차 출처 이미지와 캔버스 허용하기 Table of contentsTable of contentsHTML은 이미지 처리를 위해 CORS header를 포함하고 있는 crossorigin 속성을 제공합니다. 이는 요소에서 정의된, 외부 origin으로 부터 가

developer.mozilla.org

 

이러한 문제를 회피하기 위해 image url이 완전히 노출된 데이터를 인코딩 해서 처리해야한다.

 

 

- js에서 base64 encoding으로 변환 후 처리를 시도할 수 있지만 잘 처리가 되지 않을 수 있다.

 

 

- 이럴경우 java에서 base64 encoding으로 변환 후 처리하면 쉽게 해결 할 수 있다.

 

 

인코딩 된 base64 data 값을 화면에서 가져와 img 태그안에 src attribute로 넣어주면 된다.

 

 

 

img 태그는 해당 이미지값을 읽어 다음과 같이 출력해준다.

 

 

인코딩된 값으로 canvas 등을 활용해 CORS policy: No 'Access-Control-Allow-Origin을 회피하여

DOM을 조작할 수 있게 되었다. 

component로 등록된 것은 @SpringBootApplication 어노테이션을 통해 componentscan을 한다고 한다.

 

docs.spring.io/spring-boot/docs/current/reference/html/using-spring-boot.html#using-boot-using-springbootapplication-annotation

 

Using Spring Boot

This section goes into more detail about how you should use Spring Boot. It covers topics such as build systems, auto-configuration, and how to run your applications. We also cover some Spring Boot best practices. Although there is nothing particularly spe

docs.spring.io

intellij에서는 component로 등록된 것을 @Autowired하여 사용하려면

Could not autowire. No beans of '' type found. 라는 error가 발생한다.

컴파일 및 서버 실행에 문제는 없지만 해당 오류를 수정 할 수 있다.

 

컴포넌트를 사용하려는 클래스 위에 컴포넌트를 스캔할 패키지명을 적어준다.

@ComponentScan(basePackages={"com.shlee.toy1.common.components"}) 

 

참조 

stackoverflow.com/questions/26889970/intellij-incorrectly-saying-no-beans-of-type-found-for-autowired-repository

+ Recent posts