문제를 만들기 전에, 데이터가 있어야 한다.
데이터 설계
데이터는 quiz.json 파일과 keyword.json 파일로 나눈다.
- quiz.json 파일은
Array<Object>
타입이며 아래와 같다.
[
{
id: number,
originalText: string,
keywordArray: Array<string>
},
]
여기서 id는 배열 내에서의 인덱스 값이다.
- keyword.json 파일은
Array<string>
타입이다.
[
"CSR","브라우저","서버","JS 파일","사용자","상호 작용","JS","동적","렌더링","동적","렌더링","UX","서버","횟수","서버","부담","스크립트 파일","리소스","단위","방식","검색 엔진","검색 봇","크롤 링","어려움","Search Engine Optimization","문제","구글 봇","JS","검색 엔진","문제"
]
비즈니스 로직 설계
-
App을 시작하면 quiz.json파일과 keyword.json을 읽어 store에 저장한다.
-
문제의 원본 데이터를 읽을 때, store에 저장된 quiz 배열에서 id로 읽는다.
-
문제를 만들 때, 해당 quiz 데이터로 퀴즈앱 3을 참조하여 문제를 만들고 렌더링 한다.
-
문제의 보기는 문제에서 추출된 보기 5개와 keyword.json 파일로부터 추출된 무작위 키워드 5개, 총 10개의 데이터를 가진 set이다.
-
문제를 추가하거나 삭제할 때에는 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;