THE DEVLOG

scribbly.

Next.js+형태소분석 CS퀴즈앱 만들기

2023.03.02 15:24:14

문제를 만들기 전에, 데이터가 있어야 한다.

데이터 설계

데이터는 quiz.json 파일과 keyword.json 파일로 나눈다.

  1. quiz.json 파일은 Array<Object> 타입이며 아래와 같다.
[
  {
   id: number,
   originalText: string,
   keywordArray: Array<string>
  },
]

여기서 id는 배열 내에서의 인덱스 값이다.

  1. keyword.json 파일은 Array<string> 타입이다.
[
  "CSR","브라우저","서버","JS 파일","사용자","상호 작용","JS","동적","렌더링","동적","렌더링","UX","서버","횟수","서버","부담","스크립트 파일","리소스","단위","방식","검색 엔진","검색 봇","크롤 링","어려움","Search Engine Optimization","문제","구글 봇","JS","검색 엔진","문제"
]

비즈니스 로직 설계

  1. App을 시작하면 quiz.json파일과 keyword.json을 읽어 store에 저장한다.

  2. 문제의 원본 데이터를 읽을 때, store에 저장된 quiz 배열에서 id로 읽는다.

  3. 문제를 만들 때, 해당 quiz 데이터로 퀴즈앱 3을 참조하여 문제를 만들고 렌더링 한다.

  4. 문제의 보기는 문제에서 추출된 보기 5개와 keyword.json 파일로부터 추출된 무작위 키워드 5개, 총 10개의 데이터를 가진 set이다.

  5. 문제를 추가하거나 삭제할 때에는 store를 사용하지 않고 곧바로 json파일을 읽고, 수정하고, 저장한다.

Create

위의 데이터 설계대로 Create 부터 작성해보자.

영화앱 2 API 라우터 사용해보기에서 이어서 진행했다.

먼저 pages/quiz/create 컴포넌트이다.

import axios from "axios";
import React, { useState } from "react";

const QuizCreate = () => {
  const [originalText, setOriginalText] = useState("");
  const [title, setTitle] = useState("");
  const [category, setCategory] = useState("리액트");
  const onSubmit = async (e) => {
    e.preventDefault();
    try {
      const res = await axios.post("/api/quiz", {
        category,
        title,
        originalText,
      });
      if (res.status === 200) {
        alert(res.data.message);
      }
    } catch (e) {
      alert(e.message);
    }
  };
  return (
    <div>
      <form method="post">
        <select value={category} onChange={(e) => setCategory(e.target.value)}>
          <option value="리액트">리액트</option>
          <option value="프론트엔드 전반">프론트엔드 전반</option>
          <option value="HTML">HTML</option>
          <option value="CSS">CSS</option>
          <option value="자바스크립트">자바스크립트</option>
          <option value="네트워크">네트워크</option>
          <option value="운영체제">운영체제</option>
        </select>
        <input value={title} onChange={(e) => setTitle(e.target.value)} />
        <textarea
          value={originalText}
          onChange={(e) => setOriginalText(e.target.value)}
          placeholder="무슨 일이 일어나고 있나요?"
        />
        <button type="submit" onClick={onSubmit}>
          전송하기
        </button>
      </form>
    </div>
  );
};

export default QuizCreate;

제목과 카테고리, textarea를 입력 받아 API로 발송한다.

pages/api/quiz.tsx 컴포넌트이다.

import { NextApiHandler } from "next";
import fs from "fs/promises";
import path from "path";
import axios from "axios";

interface Quiz {
  id: number;
  originalText: string;
  keywordArray: Array<string>;
}

interface Morp {
  id: number;
  lemma: string;
  type: string;
  position: number;
  weight: number;
}

const quizApiHandler: NextApiHandler = async (req, res) => {
  //메서드가 post일 때에,
  if (req.method === "POST") {
    //body에 있는 content 프로퍼티를 읽는다.
    const { category, title, originalText } = req.body as {
      category: string;
      title: string;
      originalText: string;
    };
    let morpData = await axios
      .post(
        "http://aiopen.etri.re.kr:8000/WiseNLU",
        {
          argument: {
            analysis_code: "morp", //"morp" 형태소 분석 코드
            text: originalText,
          },
        },
        {
          headers: {
            Authorization: process.env.NODE_YOUR_ACCESS_KEY,
          },
        }
      )
      .then((res) => {
        const resultArr = res.data.return_object.sentence.map((e) => {
          return { id: e.id, text: e.text, morp: e.morp };
        });
        return resultArr as Array<{
          id: number;
          text: string;
          morp: Array<Morp>;
        }>;
      });

    const keywordArray = [];
    const jointTypeArray = ["JKS", "JKC", "JKG", "JKO", "JKB", "JKQ"];
    const nounTypeArray = ["NNP", "NNG", "SL"];
    const suffixTypeArray = ["XSN", "XSV", "XSA"];

    morpData.forEach((jsonData) => {
      const morp = jsonData.morp;
      let i = 0;
      morp.forEach((element, idx) => {
        if (jointTypeArray.includes(element.type)) {
          let crrIdx = idx - 1;
          let string = "";
          while (crrIdx >= 0) {
            if (morp[crrIdx].type === "NNB") break;
            if (nounTypeArray.includes(morp[crrIdx].type)) {
              let suffix = " ";
              if (
                crrIdx > 0 &&
                suffixTypeArray.includes(morp[crrIdx + 1].type)
              ) {
                suffix = morp[crrIdx + 1].lemma;
              }
              string = morp[crrIdx].lemma + suffix + string;
              if (
                crrIdx > 0 &&
                !nounTypeArray.includes(morp[crrIdx - 1].type) &&
                !suffixTypeArray.includes(morp[crrIdx - 1].type)
              ) {
                break;
              }
            }
            crrIdx -= 1;
          }
          const crrKeyword = string.trim();
          if (crrKeyword) {
            keywordArray.push(crrKeyword);
          }
        }
      });
    });

    //프로세스의 current working directory/db/quiz.json
    const filePath = path.join(process.cwd(), "data", "quizes.json");

    //fs.readFileSync는 동기적으로 처리한다. 반면 readFile는 비동기적으로 작동한다.
    const jsonData = await fs.readFile(filePath, "utf-8");
    const data: Quiz[] = JSON.parse(jsonData);
    const quiz = {
      id: data.at(-1)?.id + 1 || 0,
      category,
      title,
      originalText,
      keywordArray,
    };

    const keywordsFilePath = path.join(process.cwd(), "data", "keywords.json");
    const keywordsData = await fs.readFile(keywordsFilePath, "utf-8");
    const keywords = JSON.parse(keywordsData);
    const newKeywordsSet = new Set([...keywords, ...keywordArray]);
    const newKeywordsData = Array.from(newKeywordsSet);

    // 읽어온 json의 배열에 새로운 객체를 추가한다.
    data.push(quiz);

    // 배열을 다시 문자열로 만들어서 비동기적으로 파일을 작성한다.
    await fs.writeFile(filePath, JSON.stringify(data));
    await fs.writeFile(keywordsFilePath, JSON.stringify(newKeywordsData));

    res.status(200).json({ message: "글이 작성되었습니다.", result: quiz });
  } else {
    res.status(405).json({ message: "메서드가 잘못 되었습니다." });
  }
};

export default quizApiHandler;

퀴즈앱 2와 퀴즈앱 3의 코드를 합쳤다.

들어온 originalText를 OpenAPI에 보내고,
그 결과값을 토대로 quizes.json을 만든다.

또한 keywords.json의 경우 SET 객체로 관리하여 중복되는 키워드가 없도록 한다.

(중복되는 데이터를 마음대로 넣을 수 있다는 점을 제외하면) 잘 작동된다.

리팩토링

위의 API 코드에서
데이터를 읽어들이는 부분과 Open API를 호출하는 부분만 따로 분리했다.

import { NextApiHandler } from "next";
import fs from "fs/promises";
import path from "path";
import axios from "axios";

interface Quiz {
  id: number;
  originalText: string;
  keywordArray: Array<string>;
}

interface Morp {
  id: number;
  lemma: string;
  type: string;
  position: number;
  weight: number;
}

const quizApiHandler: NextApiHandler = async (req, res) => {
  const filePath = path.join(process.cwd(), "data", "quizes.json");
  const jsonData = await fs.readFile(filePath, "utf-8");
  const data: Quiz[] = JSON.parse(jsonData);

  const keywordsFilePath = path.join(process.cwd(), "data", "keywords.json");
  const keywordsData = await fs.readFile(keywordsFilePath, "utf-8");
  const keywords: string[] = JSON.parse(keywordsData);

  if (req.method === "POST") {
    const { category, title, originalText } = req.body as {
      category: string;
      title: string;
      originalText: string;
    };

    const keywordArray = await getKeywordArray(originalText);

    const quiz = {
      id: data.at(-1)?.id + 1 || 0,
      category,
      title,
      originalText,
      keywordArray,
    };

    const newKeywordsSet = new Set([...keywords, ...keywordArray]);
    const newKeywordsData = Array.from(newKeywordsSet);

    data.push(quiz);

    await fs.writeFile(filePath, JSON.stringify(data));
    await fs.writeFile(keywordsFilePath, JSON.stringify(newKeywordsData));

    res.status(200).json({ message: "글이 작성되었습니다.", result: quiz });
  } else {
    res.status(405).json({ message: "메서드가 잘못 되었습니다." });
  }
};

const getMorpData = async (originalText: string) => {
  return axios
    .post(
      "http://aiopen.etri.re.kr:8000/WiseNLU",
      {
        argument: {
          analysis_code: "morp", //"morp" 형태소 분석 코드
          text: originalText,
        },
      },
      {
        headers: {
          Authorization: process.env.NODE_YOUR_ACCESS_KEY,
        },
      }
    )
    .then((res) => {
      const resultArr = res.data.return_object.sentence.map((e) => {
        return { id: e.id, text: e.text, morp: e.morp };
      });

      return resultArr as Array<{
        id: number;
        text: string;
        morp: Array<Morp>;
      }>;
    });
};

const getKeywordArray = async (originalText: string) => {
  const morpData = await getMorpData(originalText);
  const keywordArray = [];
  const jointTypeArray = ["JKS", "JKC", "JKG", "JKO", "JKB", "JKQ"];
  const nounTypeArray = ["NNP", "NNG", "SL"];
  const suffixTypeArray = ["XSN", "XSV", "XSA"];
  morpData.forEach((jsonData) => {
    const morp = jsonData.morp;
    let i = 0;
    morp.forEach((element, idx) => {
      if (jointTypeArray.includes(element.type)) {
        let crrIdx = idx - 1;
        let string = "";
        while (crrIdx >= 0) {
          if (morp[crrIdx].type === "NNB") break;
          if (nounTypeArray.includes(morp[crrIdx].type)) {
            let suffix = " ";
            if (crrIdx > 0 && suffixTypeArray.includes(morp[crrIdx + 1].type)) {
              suffix = morp[crrIdx + 1].lemma;
            }
            string = morp[crrIdx].lemma + suffix + string;
            if (
              crrIdx > 0 &&
              !nounTypeArray.includes(morp[crrIdx - 1].type) &&
              !suffixTypeArray.includes(morp[crrIdx - 1].type)
            ) {
              break;
            }
          }
          crrIdx -= 1;
        }
        const crrKeyword = string.trim();
        if (crrKeyword) {
          keywordArray.push(crrKeyword);
        }
      }
    });
  });

  return keywordArray as Array<string>;
};

export default quizApiHandler;