스마트컨트랙트 부분을 제외한 프런트, 백엔드 전반적인 부분을 담당하였다.
Program Languege
Front-End
Back-End
web3
이번 프로젝트의 핵심이 되는 기능인 퀴즈의 데이터를 가져오기 위해 cheerio 라이브러리를 사용했다.
이번 프로젝트에서 회원가입, 로그인, 퀴즈, 강좌 등 여러부분에서 서버(DB)와 연동해야 했는데, 인증 부분에서 연동 인과관계 파악이 어려워 시간을 많이 할애했다.
로그인 시 해당 아이디, 비밀번호 정보를 서버에서 파악 -> 해당 계정이 존재한다면 유저 강의, 닉네임, 이메일이 담긴 JWT를 localStorage에 저장하는 방식을 택하였다.
src/pages/Singin.tsx
중 일부
const login = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
fetch("http://localhost:3001/user/login", {
method: "post",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ id: email, pwd: password }),
}).then((res) => {
if (res.status >= 200 && res.status <= 204) {
// msg -> 서버에서 보내오는 데이터
res
.json()
.then((msg) =>
localStorage.setItem("accessToken", JSON.stringify(msg["data"]))
);
console.log("클라이언트 로그인성공");
dispatch(userActions.setLoggedIn());
} else if (res.status == 400) {
navigate("/signin");
res.json().then((msg) => alert(msg.message));
}
console.log(res.status);
if (res.status >= 400) {
console.log("해당 입력이 잘못되었습니다.");
}
});
} catch (error) {
console.log(error);
}
// error 메시지 확인 가능 https://krpeppermint100.medium.com/ts-nodejs-express%EC%9D%98-%EC%9A%94%EC%B2%AD-%EC%9D%91%EB%8B%B5-%EC%97%90%EB%9F%AC-%ED%95%B8%EB%93%A4%EB%A7%81-8943ab7bd13b
navigate("/");
};
server.js
중 일부
app.post("/user/login", (req, res) => {
//로그인
const id = req.body.id;
const pwd = req.body.pwd;
const loginInfo = [id, pwd];
connection.query(
"SELECT * FROM users where userName=? and password=?",
loginInfo,
function (err, rows, fields) {
if (rows.length > 0) {
const accessToken = jwt.sign(rows[0]);
console.log("로그인됨");
res.status(200).send({
// client에게 토큰 반환합니다.
ok: true,
data: {
accessToken,
},
});
} else {
res.status(400).send({
ok: false,
message: "해당 유저가 존재하지 않거나, 유효하지 않은 양식입니다.",
});
console.log("로그인 fail");
}
}
);
});
토큰이 정상적으로 저장된 것을 확인할 수 있다.
크롤링한 영단어를 DB에 넣고, 해당 데이터를 퀴즈화 하였다.
(1,1,' 개탄스러운 | 개탄스러운 | 개탄스러운 ','inevitable|comfortable|deplorable','0|0|1',2,'e2k','2022-07-23 16:27:32')
다음과 같은 영어 데이터를 얻을 수 있는데,
해당 문제의 답은 "개탄스러운"이고 답은
inevitable|comfortable|deplorable
- 0|0|1
즉 "deplorable"이 정답이 된다.
Test 페이지 접근시 server.js
에서 실행되는 코드
app.post("/user/testData", (req, res) => {
const token = req.headers.authorization.split("Bearer ")[1];
const result = jwt.verify(token);
if (result.ok) {
const { id, pwd, nickname } = result;
// 유저와 일치하는 데이터를 찾기
connection.query(
"SELECT * FROM users where userName=?",
id,
function (err, rows, fields) {
if (rows[0].taken_lectures == null) {
res.status(201).send({ message: "보유한 강좌가 없습니다." });
} else {
const userLec = rows[0].taken_lectures.split("|");
// [ '1', '2' ]
let cunQuery = "lec_id in (?) ";
for (let i = 1; i < userLec.length; i++) {
if (userLec === 1) {
cunQuery = "lec_id in (?) ";
} else {
cunQuery += "OR lec_id in (?)";
}
}
connection.query(
"select * from lecture where " + cunQuery + "",
userLec,
function (err, rows) {
if (err) {
console.log(err);
} else {
const lecData = rows;
connection.query(
"SELECT lec_name,pass_state FROM lecturestate WHERE userName = ?",
id,
function (err, rows) {
if (err) {
console.log(err);
} else {
console.log(`${id}님이 보유 강좌 데이터를 불러왔습니다.`);
res.status(200).send({ lec: lecData, lecPass: rows });
}
}
);
}
}
);
}
}
);
} else {
// 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
res.status(401).send({
ok: false,
message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
});
}
});
유저가 가지고 있던 토큰을 확인하여 해당 유저가 가진 강좌를 DB에서 찾고
해당 강좌를 Test 페이지에 불러오는 과정이다.
useEffect로 해당 페이지 접속시 데이터를 불러와 redux store에 저장한다.
src/pages/ChoserTest.tsx
중 일부
useEffect(() => {
if (!isLoggedIn) {
navigate("/signin", { replace: true });
dispatch(modalActions.openNeedLoginModalOpen());
} else {
const testData = async () => {
const token: any = localStorage.getItem("accessToken");
const parseToken: any = JSON.parse(token);
try {
fetch("http://localhost:3001/user/testData", {
method: "post",
headers: {
// 강좌를 구매한 유저 정보를 식별하기 위한 토큰 전송(DB에서 해당 유저에게 구매한 강좌를 저장시키기 위함)
authorization: `Bearer ${parseToken.accessToken}`,
},
// server로 클릭한 강좌 정보 전송
body: JSON.stringify({}),
}).then((res) =>
res.json().then((result) => {
// 강좌가 없다면?
if (res.status == 201) {
return alert(result.message);
}
// DB에서 가져온 데이터를 slice에 넣어주는데, 중복저장이 안되게 for문을 먼저 진행.
// 이건 lecturestate 데이터 값을 불러옴
// 추후 퍼센테이지 채울 때 필요함
const passList = result.lecPass.map((el: any) => el);
// 강좌에 따른 통과진행률 데이터를 passSlice에 저장
// 퀴즈를 통과하고 다시 들어올 때 새로고침을 안하면 통과율 저장이 안됨, slice내 추가 action을 만들어야 할 것 같다 - 규현
passList.map((el: any) => {
for (let i = 0; i < passData.length; i++) {
if (el.lec_name == passData[i].lecId) {
console.log("이미 불러온 데이터 입니다.");
return;
}
}
dispatch(
setPassData({
lecId: el.lec_name,
passed: el.pass_state,
})
);
});
// lecture의 데이터를 넣는 lectureSlice에 넣는 과정
result.lec.map((el: any) => {
for (let i = 0; i < lecture.length; i++) {
if (el.lec_id == lecture[i].id) {
console.log("이미 불러온 데이터 입니다.");
return;
}
}
dispatch(
lecData({
id: el.lec_id,
image: el.lec_image_path,
level: el.lec_level,
name: el.lec_name,
source: el.lec_source,
})
);
});
})
);
} catch (error) {
console.error(error);
}
};
testData();
}
}, [isLoggedIn, navigate]);
이 과정을 진행하면 추후 퀴즈 데이터를 불러올 때 redux store에 저장되어 있으므로 새로고침 하지 않는다면 매번 DB에게 받아올 필요없이 프런트에서 해결할 수 있다.
src/store/qzSlice.ts
아래 코드는 store의 퀴즈 슬라이스이다.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type qzState = {
answer: string;
correct: string;
lec_id: number;
question: string;
qz_category: string;
qz_id: number;
qz_num: number;
};
const initialState: qzState[] = [];
export const qzSlice = createSlice({
name: "lecture",
initialState,
reducers: {
qzData: (
state = initialState,
action: PayloadAction<{
answer: string;
correct: string;
lec_id: number;
question: string;
qz_category: string;
qz_id: number;
qz_num: number;
}>
) => {
state.push(action.payload);
},
resetData: (state) => {
state.splice(0);
},
},
});
export const { qzData } = qzSlice.actions;
export const qzActions = { ...qzSlice.actions };
export default qzSlice;
우선 강좌 구매 페이지에 판매 중인 강좌를 표시해주기 위해 useEffect를 사용했다.
src/pages/Coures.tsx
중 일부
const [cards, setCards] = useState([]);
useEffect(() => {
const cardData = async () => {
try {
fetch("http://localhost:3001/selectCard", {
method: "post",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
}).then((res) =>
res.json().then((result) => {
setCards(result);
console.log(result);
})
);
} catch (error) {
console.error(error);
}
};
cardData();
}, []);
이후 map 메서드를 활용하여 CardItem 컴포넌트에 서로 다른 props를 넣어준다.
/CardItem.tsx
// 구매함수
const handleBuyClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!isLoggedIn) {
navigate("/signin", { replace: true });
dispatch(modalActions.openNeedLoginModalOpen());
} else {
const cardData = async () => {
const token: any = localStorage.getItem("accessToken");
const parseToken: any = JSON.parse(token);
try {
fetch("http://localhost:3001/user/payment", {
method: "post",
headers: {
"content-type": "application/json",
// 강좌를 구매한 유저 정보를 식별하기 위한 토큰 전송(DB에서 해당 유저에게 구매한 강좌를 저장시키기 위함)
authorization: `Bearer ${parseToken.accessToken}`,
},
// server로 클릭한 강좌 정보 전송
body: JSON.stringify({ lecInfo: props }),
}).then((res) => {
res.json().then((msg) => {
// 이미 구매한 강좌 유효성 검사
if (!msg.ok) {
alert("이미 구매하신 강좌입니다.");
setGet("complete");
} else {
alert("구매완료!");
setGet("complete");
}
});
});
} catch (error) {
console.error(error);
}
};
cardData();
}
};
해당 컴포넌트에는 각 prop의 정보가 담긴 개별적인 강좌 카드가 있고,
특정 유저가 강좌를 구매한다면, 유저 테이블 "taken_lectures" varchar(255)
칼럼에서 해당 강좌의 id를 추가한다.
구매 순서대로 강좌가 유저 lectures 칼럼에 추가되는 것을 확인할 수 있다.
server.js
중 강좌 구매 부분
// 강좌구매 시 작동(유효성 검사 완)
app.post("/user/payment", (req, res) => {
const token = req.headers.authorization.split("Bearer ")[1];
const result = jwt.verify(token);
const info = req.body.lecInfo;
console.log(info);
const { lec_id, lec_price } = info;
if (result.ok) {
const { id, pwd, nickname } = result;
// 유저와 일치하는 데이터를 찾기
connection.query(
"SELECT * FROM users where userName=?",
id,
function (err, rows, fields) {
// 강의를 처음 구매할 시
if (rows[0].taken_lectures == null) {
connection.query(
"UPDATE users SET taken_lectures = (?) WHERE userName = ?",
[lec_id, id],
function (err, rows) {
connection.query(
"INSERT INTO lecturestate(lec_name,userName,pass_state) values (?,?,?)",
[lec_id, id, "none"]
);
res.status(200).send({ ok: true, message: lec_id });
console.log(
`${id}님이 lec_id : ${lec_id} 강좌를 구매하였습니다. lecturestate에 해당 정보를 저장합니다.`
);
}
);
} else {
// 해당 유저의 강의에 현재 구매한 강의 아이디를 넣기
const preLec = rows[0].taken_lectures;
// 담고있는 lec_id를 새로 들어온 강의 id와 비교할 수 있게 숫자를 담은 배열로 변경하는 과정 -규현
const aryPreLec = preLec.split("|");
const numLec = aryPreLec.join("");
const strLec = String(numLec);
// mapfn : 배열 내 모든 요소를 숫자로 변경 https://hianna.tistory.com/707
const mapfn = (arg) => Number(arg);
const newLec = Array.from(strLec, mapfn);
// 처음 구매하고 바로 다시 처음 강좌를 재구매 할 때
if (preLec == lec_id) {
console.log("이미 구매한 강좌입니다.");
res.status(400).send({ ok: false, message: lec_id });
}
// 이후 중복 구매시
else if (newLec.includes(lec_id)) {
console.log("이미 구매한 강좌입니다.");
res.status(400).send({ ok: false, message: lec_id });
} else {
// 새로운 값 추가할 때 "|"
const ary = [preLec + "|" + lec_id];
connection.query(
"UPDATE users SET taken_lectures = (?) WHERE userName = ?",
[[ary], id],
function (err, rows) {
connection.query(
"INSERT INTO lecturestate(lec_name,userName,pass_state) values (?,?,?)",
[lec_id, id, "none"]
);
res.status(200).send({ ok: true, message: lec_id });
console.log(
`${id}님이 lec_id : ${lec_id} 강좌를 구매하였습니다. lecturestate에 해당 정보를 저장합니다.`
);
}
);
}
}
}
);
} else {
// 검증에 실패하거나 토큰이 만료되었다면 클라이언트에게 메세지를 담아서 응답합니다.
res.status(401).send({
ok: false,
message: result.message, // jwt가 만료되었다면 메세지는 'jwt expired'입니다.
});
}
});
AlertModal.tsx
interface Props extends React.HTMLAttributes<HTMLDivElement> {
message?: string;
}
const AlertModal: React.FC<Props> = ({ message, ...props }) => {
return (
<Base {...props}>
<span className="text">{message}</span>
<div className="loader4"></div>
</Base>
);
};
export default AlertModal;
extends React.HTMLAttributes<HTMLDivElement>
처럼 확장을 해줘야 className
등 여러 옵션을 추가적용할 수 있다.
...props
를 넣어야 다른 곳에서 className
적용가능
지갑연동, 로그인은 오른쪽 / 테스트, 강좌, 커뮤니티를 왼쪽로고 옆에 뒀다.
저번에 진행한 프로젝트를 참고하여 유효성 검사를 통과한 회원가입 예시 영상이다.
강좌를 구매하지 않은채로 Test 페이지로 들어가면 alret가 작동된다.(비로그인시 마찬가지)
강좌 hover 시 강좌카드 스타일 변경, click 시 flip
각 강좌마다 토큰 가격이 명시. 추후 토큰의 지급량과 더불어 시세에 맞게 변동 할 수 있음
해당 계정이 보유한 강좌와 비교하여 중복되는 강좌일 때 구매할 수 없도록 표시
퀴즈 문제 별 선택에 따른 합/불의 결과는 store에 저장되어 결과창에서 맞은 개수 만큼 %되어 나온다. 이 %가 80% 이상이면, 그 다음 퀴즈를 풀수 있게 된다.
추후 특정 시간, 정답률에 따른 토큰 차등보상 지급예정
일차별 통과율이 초기 test 페이지 강좌카드에 표시된다.
이전 퀴즈를 통과(80% 이상)해야 다음 일차 퀴즈를 풀 수 있음
통과하지 못한 상태로 다음 일차를 클릭한다면 해당 화면과 같이 알림이 뜬다.
해당 일차 퀴즈 정답, 오답 표기 및 정답률이 표시됨. 추후 오답노트 기능 구현 예정