project BPM - server

Enzo·2022년 6월 25일
0

project BPM

목록 보기
2/4

이번 프로젝트에서는 백엔드를 맡기로 하였다.
프론트엔드와 백엔드를 모두 경험해본 결과 프론트엔드 보다는 백엔드가 좀 더 적성에 잘 맞는 것 같다.

팀원은 총 4명으로 기획 1명, 프론트엔드 1명, 백엔드 2명으로 프로젝트를 진행하게 되었다.


프로젝트 기획

기획을 하면서 전체적인 틀은 잡았지만 세세한 사항이 정해지지 않아서 개발을 하면서 계속 바뀌는 부분이 있었다.
우리는 음원을 평가할 수 있는 시스템을 만들어주고 커뮤니티를 제공해주는 것이 가장 중요한 기능이었고 이를 위해서 api설계를 했다.
가장 먼저 필요한 부분은 음원 api를 통해 음원을 가져오는 것이었고, 이를 평가하는 시스템을 설계하는 것이었다.
커뮤니티 빌딩에 경우 프로젝트2에서 했던 내용을 좀 더 고도화시키면 되는 것이기 때문에 수월하게 할 수 있을 것으로 예상 했다.
초기 계획은 마켓플레이스까지 설계해서 NFT로 음원사용권 거래까지 가능하게 만들려고 했으나 한달의 시간안에 모든 것을 다 만들기에는 무리가 있을 것 같아서 일단 가장 중요한 기능부터 bare minimum으로 설정하고 마켓플레이스를 advanced로 잡아서 계획을 세웠다.

기술 스택

MongoDB, Express, Node.js, solidity

DB 설계

이번 프로젝트에서는 NoSQL을 사용해보자고 의견이 나와서 mongoDB를 사용하기로 하였다.
mongoDB는 스키마 설계없이 하나의 Document에 넣으면 된다고 생각하고 프로젝트를 시작했는데 mongoose를 사용하려고 보니 스키마 설계가 필요하다는 결론을 얻었다.

위 이미지는 SQL식 스키마 디자인이기 때문에 모든 테이블이 분리되어있지만 실제로는 댓글과 좋아요는 임베딩시켜서 설계하였다.
프로젝트2때는 간단하게 설계하였지만 이번에는 고도화시켜서 실제로 동작하는데 문제가 없을 정도의 커뮤니티를 제작하려고 했기 때문에 DB 구조가 복잡해지게 되었다.
NoSQL은 id값이 무조건 들어가는데 이게 index처럼 증가하는게 아닌 uuid 형태로 들어가서 편의를 위해 index를 만드는게 좋을지 고민하다가 그냥 사용하기로 했다.

음원 API

멜론 API를 가져올 수 있으면 가장 좋았을 것 같지만 지금은 무료로 사용할 수 있는 API가 없었고 해외 API들도 여러개 고려해보았지만 우리가 필요로하는 형태의 데이터를 제공하는 API를 찾을 수가 없었다.
우리는 대안으로 멜론차트를 크롤링하는 방식을 사용하기로 합의하였다.

let url = "https://www.melon.com/chart/";
		let title = new Array(),
			artist = new Array(),
			image = new Array(),
			up_date,
			up_time;
		let rank = 100;

		request(url, function (error, response, html) {
			if (!error) {
				let $ = cheerio.load(html);
				1;

				// 곡명 파싱
				for (let i = 0; i < rank; i++) {
					$(".ellipsis.rank01 > span > a").each(function () {
						let title_info = $(this);
						let title_info_text = title_info.text();
						title[i] = title_info_text;
						i++;
					});
				}

				// 아티스트명 파싱
				for (let i = 0; i < rank; i++) {
					$(".checkEllipsis").each(function () {
						let artist_info = $(this);
						let artist_info_text = artist_info.text();
						artist[i] = artist_info_text;
						i++;
					});
				}

				// 앨범커버 파싱
				for (let i = 0; i < rank; i++) {
					$(".image_typeAll > img").each(function () {
						let image_info = $(this);
						let image_info_attr = image_info.attr("src");
						image[i] = image_info_attr;
						i++;
					});
				}

				// 업데이트 날짜
				$(".year").each(function () {
					let date_info = $(this);
					let date_info_text = date_info.text();
					up_date = date_info_text;
				});

				// 업데이트 시간
				$(".hhmm > span").each(function () {
					let time_info = $(this);
					let time_info_text = time_info.text();
					up_time = time_info_text;
				});

				//xxxx년 xx월 xx일 오후/오전 xx시 format
				let up_date_arr = new Array();
				up_date_arr = up_date.split(".");
				let up_time_arr = new Array();
				up_time_arr = up_time.split(":");
				let newtime;

				// 오후 오전 삽입
				if (up_time_arr[0] > 12) {
					up_time_arr[0] = up_time_arr[0] - 12;
					newtime = "오후 " + up_time_arr[0];
				} else {
					newtime = "오전 " + up_time_arr[0];
				}

				// 콘솔창 출력
				console.log("< 멜론 차트 1 ~ " + rank + "위 >");

				// 순위 앨범커버 - 제목 - 아티스트명
				for (let i = 1; i < rank + 1; i++) {
					let object = {
						image: image[i - 1],
						title: title[i - 1],
						artist: artist[i - 1],
					};
					Charts.create(object);
				}
			}
		});

데몬을 설계해서 일정기간을 간격으로 자동 업데이트를 하려고 했으나 DB설계에서 이전에 있던 데이터와 새로 추가된 데이터를 관리하는 방법을 찾지 못하여 기간을 정해두고 리뷰를 받고 집계한 후에 사용자들에게 보여주기로 하였다. 이후 업데이트 시 운영진 측에서 직접 업데이트를 해주는 방식을 택했다.


개발

로그인

로그인과 인증관련한 부분은 jwt를 사용하였고 쿠키에 실어보내는 방식을 채택했다. 로그인 시 jwt를 발급하고 쿠키에 실어서 보내주었다. 이후 유저가 인증이 필요한 행위시에 jwt가 담긴 쿠키를 다시 서버로 보내주면 서버에서 확인해서 처리해난 식으로 설계하였다.
문제는 수많은 API들을 이런 방식으로 짯는데 다 완성되고 나서 반복되는 코드가 너무 많다는 것을 인지하였다.
미들웨어로 만들어서 처리했다면 코드가 간결하고 유지보수 측면에서도 좋았을 것으로 보여서 아쉬운 점으로 남았다. 리팩토링을 하려고 했으나 마감기한이 다가와서 포기하고 다른 부분에서 완성도를 높이기로 하였다.

CRUD

CRUD는 미리 설계해둔 restAPI에 맞춰서 코딩하였다.
하지만 임베딩되어있는 DB에서 원하는 데이터를 뽑아오는 쿼리를 찾지를 못해서 시간을 많이 잡아먹었다. 공식문서를 뒤져보고 검색해서 여러 글들을 확인했는데도 찾지 못하였다.
그래서 결국은 한개의 요청에 여러개의 쿼리를 하는 상황을 만들었고 정확하게는 모르지만 효율성이 떨어질 것으로 생각된다.
실제 서비스였다면 많은 트래픽이 들어오는 순간 서버에 부하가 과도하게 증가하여 뻗어버리는 상황이 발생했을 수도 있을 것 같다. 정말 아쉬운 부분이다.

인센티브 기반 커뮤니티이기때문에 계속 글을 작성하고 토큰을 무한으로 받을 수 있다면 악용할 여지가 있기 때문에 처리를 해줄 필요가 있었고 우리는 하루에 글은 무한히 적을 수 있지만 토큰은 최대 3회까지만 받을 수 있게 제한하였다.

다계정으로 어뷰징을 시도하면 그런 부분을 막을 수는 없지만 지속적인 관리를 통해 악의적인 행동을 하는 유저를 파악하고 벤을 시키는 방식을 고려하고 설계하였다.

집계

집계가 가장 어려운 부분 중에 하나였다.
정량적인 데이터로 만들어야 순위를 반영할 수 있기 때문에 정량적인 평가를 할 수 있는 알고리즘을 만드는 작업부터 하였다.
간단하게 5개의 항목에 점수를 매기고 이를 평균을 내서 순위에 반영하는 방식을 택했다.
이때 리뷰 수에 따라 가중치를 다르게 줘서 최대한 공정한 순위가 나올 수 있게 설계하였다.

aggregate: async (req, res) => {
		const chartid = await Charts.find(
			{},
			{
				image: false,
				title: false,
				artist: false,
				createdAt: false,
				updatedAt: false,
				__v: false,
			}
		);
		// evaluation 필드
		let popularity = 0;
		let lyrics = 0;
		let individuality = 0;
		let Addictive = 0;
		let artistry = 0;
		let popularityAvg = 0;
		let lyricsAvg = 0;
		let individualityAvg = 0;
		let AddictiveAvg = 0;
		let artistryAvg = 0;
		let total = 0;

		// evaluation 순회, 필드 별 값 합산
		for (let i = 0; i < chartid.length; i++) {
			// 곡 당 evaluation이 배열로 반환됨
			const evaluation = await Evaluations.find({
				charts_id: String(chartid[i]._id),
			});
			for (let j = 0; j < evaluation.length; j++) {
				if (evaluation[j] === undefined) {
					continue;
				} else {
					popularity += Number(evaluation[j].popularity);
					artistry += Number(evaluation[j].artistry);
					lyrics += Number(evaluation[j].lyrics);
					individuality += Number(evaluation[j].individuality);
					Addictive += Number(evaluation[j].Addictive);
				}
			}
			// chart_id 당 필드 평균
			if (popularity === 0) {
				popularityAvg = 0;
				lyricsAvg = 0;
				individualityAvg = 0;
				AddictiveAvg = 0;
				artistryAvg = 0;
				total = 0;
			} else {
				popularityAvg = popularity / evaluation.length;
				lyricsAvg = artistry / evaluation.length;
				individualityAvg = lyrics / evaluation.length;
				AddictiveAvg = individuality / evaluation.length;
				artistryAvg = Addictive / evaluation.length;
				if (evaluation.length < 2) {
					total =
						((popularityAvg +
							lyricsAvg +
							individualityAvg +
							AddictiveAvg +
							artistryAvg) /
							5) *
						0.2;
				} else if (evaluation.length >= 2 && evaluation.length < 5) {
					total =
						((popularityAvg +
							lyricsAvg +
							individualityAvg +
							AddictiveAvg +
							artistryAvg) /
							5) *
						0.4;
				} else if (evaluation.length >= 5 && evaluation.length < 7) {
					total =
						((popularityAvg +
							lyricsAvg +
							individualityAvg +
							AddictiveAvg +
							artistryAvg) /
							5) *
						0.6;
				} else if (evaluation.length >= 7 && evaluation.length < 10) {
					total =
						((popularityAvg +
							lyricsAvg +
							individualityAvg +
							AddictiveAvg +
							artistryAvg) /
							5) *
						0.9;
				} else if (evaluation.length >= 10) {
					total =
						(popularityAvg +
							lyricsAvg +
							individualityAvg +
							AddictiveAvg +
							artistryAvg) /
						5;
				}
			}
			// 필드 별 초기화
			popularity = 0;
			lyrics = 0;
			individuality = 0;
			Addictive = 0;
			artistry = 0;
			// Chart id에 맞는 total 저장
			const chart = await Charts.findOne({ _id: String(chartid[i]._id) });
			chart.popularityAvg = parseFloat(popularityAvg).toFixed(2);
			chart.lyricsAvg = parseFloat(lyricsAvg).toFixed(2);
			chart.individualityAvg = parseFloat(individualityAvg).toFixed(2);
			chart.AddictiveAvg = parseFloat(AddictiveAvg).toFixed(2);
			chart.artistryAvg = parseFloat(artistryAvg).toFixed(2);
			chart.total = parseFloat(total).toFixed(2);
			await chart.save();
		}
		res.status(201).send({ success: true, data: null, message: "ok" });
	},

checklike

좋아요의 경우 로그인 하고 봤을때 내가 눌렀던 좋아요가 체크되어야 하기때문에 checklike API를 만들어주었다.
이 부분 역시 쿼리를 제대로 하지 못해서 for문을 돌면서 찾는 방식을 선택해서 부하가 많이 걸리고 만약 좋아요 수가 많다면 느릴 것으로 예상된다.

  checklike: async (req, res) => {
    const reviewid = req.params.reviewid;
    const accessToken = req.cookies.accessToken;
    if (!accessToken) {
      res.status(404).send({ message: "accessToken not provided" });
    } else if (accessToken === "invalidtoken") {
      res
        .status(400)
        .send({ message: "invalid accesstoken, please login again" });
    } else {
      const userinfo = jwt.verify(accessToken, process.env.ACCESS_SECRET);
      const like = await Reviews.findOne({ _id: reviewid }, { likes: true });
      let flag = false;
      for (let i = 0; i < like.likes.length; i++) {
        if (String(like.likes[i].users_id) === userinfo.id) {
          flag = true;
          break;
        }
      }
      if (flag === true) {
        res.status(200).send({ message: "ok" });
      } else {
        res.status(200).send({ message: "no" });
      }
    }
  },

contract

컨트랙트는 ERC20 코드를 사용하였는데 우리는 토큰을 두가지로 설계하였기 때문에 두개의 컨트랙트가 배포되었고 차후 두 토큰의 스왑기능을 제공하려고 고려하고 있다.

백엔드에서 컨트랙트 관련 코드는 ethers.js를 사용하여 web3.js를 사용할때보다 문법적으로 간결하게 만들었다.
서버 지갑을 총 4개 개설하고 용도에 맞춰서 토큰 물량을 분배하고 관리하게 하였다.
서버 지갑에 개인키는 DB에서 관리하는 방향으로 설계하였다.
서명 관련 문제는 토큰보상이 이루어지는 api요청시 자동으로 서명이 되도록 프로그래밍 하였다.


회고

고도화된 커뮤니티를 만든다는게 생각보다 쉬운일이 아니었다.
정말 많은 문제상황과 에러를 만났다.
MySQL을 사용했을떄는 한번도 생각해본적도 없는 쿼리 문제가 발목을 잡아서 정말 많은 애를 먹었다.
NoSQL의 확장성과 유연성 덕분에 테스트 과정에서 데이터를 지우고 쓰고를 자유롭게 할 수 있어서 편하였고, 초기 설계에서 없던 document를 갑자기 추가하는 등의 작업도 쉽게 되어서 편하였는데 나머지 부분에서는 솔직히 다 불편했다. SQL에 너무 익숙해져서 그런건지 그렇게 강력한 쿼리를 제공하는지도 의문이다. 커뮤니티같이 많은 요청이 오가는 서비스에서는 SQL이 좀 더 적합한 것 같다는게 나의 결론이다.
기획과 개발을 아예 따로 두고 개발프로세스를 진행하다보니 기획자와의 마찰도 있었다. 기술적으로 실현이 어려운 부분을 기획해서 온다던지 기존 기획에 맞춰서 모든 기능을 설계했는데 갑자기 아이디어가 떠올랐다고 설계방향을 바꾸려고 하는 상황도 있었다. 이런 어려움을 겪고 서로 소통의 오류가 있다고 느꼈는데 많은 대화를 통해 합의점을 찾고 개발하는 사람의 입장을 잘 설명하니 금방 합의점을 찾을 수 있었다.

가장 크게 느낀 점은 초기 계획을 완벽하게 세웠다고 생각했지만 생각지도 못한 부분에서 고려하지 못한 부분이 튀어나오고 이를 처리하려고 머리를 싸매는 상황이 생긴다는 것이었다. 또 정해진 기한안에 성과를 내려하니 기술부채가 계속 쌓여가는 듯한 기분을 많이 느꼈다.

아직은 부족한 부분이 참 많은 것 같다. 실제 서비스 할 수 있을 정도의 상태로 개발하는 것이 목표였는데 배우고 공부해야 할 것이 산더미 인 것 같다.

profile
고통수집가

0개의 댓글