두 번째 프로젝트의 주제를 정하기 위해 팀원들과 Notion의 프로젝트 협업 템플릿과 slack을 이용해서 아이디어 회의를 진행하던 도중, Notion의 템플릿을 사용하기가 너무 어렵다는 팀원, slack에는 노트 보관 기한이 짧아 기록용으로는 적절하지 않은 것 같다는 팀원.... 등등 기존에 사용하던 툴들의 불편함들을 하나 둘씩 이야기하게 됐습니다. 그러고 모두 한 마음으로 'Notion의 일정 관리 기능, slack의 DM 기능을 합쳐 좀 더 심플한 협업 툴을 만들면 좋겠다'는 생각을 떠올리면서 '복잡한 것을 싫어하는 개발자들을 위한 심플한 협업 툴'이라는 주제를 1차로 결정하였습니다.
여기에 개발 시작 전에 api 명세서와 erd, 그리고 기획서를 작성해야 함을 잘 몰랐던 저희처럼 이제 막 개발에 발을 들이기 시작한 개발자들에게 마치 가이드라인을 제시하듯 api 명세서와 erd, 기획 이 세가지 파일 보관함을 만들어주면 주자는 의견이 나오게 되면서, 개발을 이제 막 시작한 어린(능력치가 낮은) 개발자, 즉 "'개린이'를 위한 협업 툴"로 주제를 최종적으로 정하게 됐습니다.
- DB 구조화
- API 설계
- Backend Main 페이지 로직
- Backend 이슈 게시판 로직
- Frontend 이슈 게시판 로직
- Frontend 작업 일정 추가, 편집 로직
팀원1과 같이 DB 구조화하는 작업을 했는데, 한 번은 서로 의견이 달라 충돌이 일어난 적이 있었습니다.
나: "🤔굳이 테이블B가 필요한지 모르겠습니다."
팀원1: "그러면 이 데이터들을 어디에서 가져올 것인가요?"
나: "테이블A에 있으니 가져오면 되지 않나요?"
팀원1: "아니요. 따로 저장해야 합니다."
DB 구조화 방법에 꼭 정해진 것은 없겠지만, 결론적으로는 둘다 제가 했던 주장이 맞다고 판단하여 중복되는 값은 다른 테이블의 index값을 외래키로 참조해 join하는 방법으로 테이블들을 구조화해 나갔습니다.
지금 생각해보면 평소에 mysql의 select와 join 연습 문제를 풀어왔던 것이 이번 프로젝트 때 도움이 됐던 것 같습니다.
이번에 DB를 직접 구조화하면서 깨달은 것들을 바탕으로 저만의 규칙을 만들어봤습니다. 다음 DB 구조화때 이를 참고하고자 합니다.
📌규칙1 : "서로 관련 있는 데이터들이더라도 자주 업데이트 되는 데이터는 왜래키 참조키를 사용해 다른 테이블에 별도로 저장한다."
📌규칙2 : "왜래키를 이용할 경우, 왜래값이 삭제하면 참조값도 삭제할 것인가에 대해 고민을 하고, 왜래키 옵션을 설정한다."
📌규칙3 : "자주 업데이트 되는 내용이 아니라면 배열 형태로 저장해 DB 사용량을 줄인다." (mysql에서는 배열을 자동으로 문자열로 변환해 저장한다.)
📌규칙4 : "이미지나 파일은 이름으로 저장한다."
앞에서 DB 구조화 작업을 끝내 놓으니 프론트에서는 어떤 데이터를 백으로 보내줘야 하고 받아야하는지, 반대로 백에서는 어떤 데이터를 받아서 보내줘야 하는지 전체적으로 머릿속에 그려져서 작성하는데 그리 오래 걸리지 않았습니다.
😓그렇다고 '완벽하게' 작성했다는 의미는 아닙니다.
프론트로 넘어와서 직접 작업해보니 저희가 예상하지 못한 부분들이 발생했었습니다. 예를 들면, 현재 웹을 이용하고 있는 사용자의 정보를 조회하는 api는 있지만, 다른 사용자의 정보를 조회할 수 있는 api가 없다거나, 화면에 보여줘야하는 컨텐츠 개수가 많아 페이지네이션으로 구현해야하는데 컨텐츠 조회하는 api의 url에는 페이지네이션 구현에 필요한 값이 들어있지 않는다거나 하는 등의 에러들이 있었습니다.
"예측 못한 부분을 발견할 때마다 API명세서 수정"
점점 길어지는 API명세서를 보니 뿌듯하면서도 빠트린 내용들이 참 많았구나... 싶은 생각도 들었습니다. 비록 부족한 부분들이 많았지만, 배운 것도 많았기에 이번 경험을 바탕으로 다음 프로젝트때 더 꼼꼼하게 작성할 수 있지 않을까 싶습니다.
DB의 ERD 작업과 API 명세서 작성이 끝난 후, 팀원1과 저는 각자 controller 작업을 하기 위해 페이지를 나눴습니다. 저의 경우 백엔드는 Main과 프로젝트 이슈 전체를, 프론트는 프로젝트 이슈 게시판 전체와 프로젝트 보드 일부분을 맡아서 했습니다.
우선 Main페이지에서 구현해야 하는 기능은 다음 3가지였습니다.
1. 내가 참여중인 모든 프로젝트의 리스트
2. 내가 참여중인 모든 프로젝트 안에서 '나의 작업'을 조회하는 기능
3. 내가 참여중인 모든 프로젝트 안에서 '팀원의 작업'을 조회하는 기능
위의 3가지 기능을 구현하기 위해 아래와 같이 먼저 라우팅해줬습니다. 아직 컨트롤러를 작성해주지 않았지만 라우팅을 먼저 한 뒤에 컨트롤러 작업하는 순서가 익숙해서 라우팅부터 작업해줬습니다.
(여기서 'auth'는 jwt.verify()로 JWT 토큰을 확인하고 해독한 뒤, 사용자 아이디가 있는 경우에만 요청을 라우트 핸들러로 전달되도록 해주는 미들웨어입니다.)
//사용자가 참여 중인 모든 프로젝트 조회
router.post("/mine", auth, controller.getMyProject);
//내 작업(보드) 조회 (사용자가 참여 중인 프로젝트에 있는 자신의 보드)
router.post("/board/mine", auth, controller.getMyBoard);
//팀원 활동(보드) 조회 (사용자가 참여 중인 모든 프로젝트 멤버들의 보드)
router.post("/teamboard", auth, controller.getMyTeamBoard);
우선, 사용자가 참여중인 모든 프로젝트의 정보를 담아오는 기능을 구현하기 위해선 'user id'와 'project id'가 필요합니다. 'user id'는 auth라는 미들웨어에서 전달해주고 있으므로 req.userId
작성을 통해 가져올 수 있습니다.
const id = req.ueserId
User DB에서 'user id'값으로 사용자의 개인 정보를 가져올 수 있습니다.
이때, 사용자 이름, 깃헙 주소, 블로그 주소는 메인 페이지 상단에 띄워줄 때 필요하므로 객체 분할을 통해 user_name과 github, blog 정보만 추출해서 별도로 저장해줍니다.
const user = await User.findByPk(id);
const { user_name, github, blog } = user;
프로젝트 Id 값은 '사용자별로 참여중인 프로젝트 아이디를 저장하고 있는, 프로젝트 멤버 테이블'에서 사용자의 id와 일치하는 컬럼을 추출해줍니다. 이때, 마지막에 모든 정보들을 배열에 담아 프론트로 전달해줄 것을 고려해 오름차순으로 정렬해줍니다.
const projectId = await ProjectMember.findAll({
where: { userId: user.id },
attributes: ["projectId"],
order: [["projectId", "ASC"]],
});
projectId 변수에는 사용자 id(현재 사용자 id & 프로젝트 멤버 id)와 프로젝트 id가 같이 담겨옵니다.
예시) ProjectMember:
id | projectId | userId |
---|---|---|
1 | 1 | 1 |
2 | 1 | 2 |
3 | 2 | 3 |
4 | 2 | 4 |
5 | 2 | 5 |
프로젝트 id만 필요하므로 map메소드를 활용해 따로 저장해줍니다.
const project_ids = projectId.map((project_member) => project_member.dataValues.projectId);
이렇게 추출한 프로젝트 Id를 활용해서 프로젝트별로 이름, 진행 상태, 이미지 등에 관한 정보를 저장하고 있는 Project 테이블에서 정보를 가져옵니다.
저는 이때 프론트에서 데이터를 분해하여 사용하기 쉽도록 프로젝트 이름, 상태, 이미지 등을 하나의 배열로 묶고, 이를 또 다시 배열로 저장했습니다. 즉, 2차배열 형태로 데이터들을 저장했습니다. 이렇게 하면 프론트에서 첫 번째 프로젝트에 관한 정보를 불러오고 싶을 때 배열의 첫 번째 인덱스 값만 가져오면 되어서 편리해집니다.
let projectResult = [];
//2차 배열 형태로 각 프로젝트 정보 담기 (프로젝트 이름, 상태, 프로젝트 이미지)
for (let i = 0; i < project_ids.length; i++) {
let id = project_ids[i];
const getProjectInfotResult = await Project.findOne({
where: { id },
});
let project_name = getProjectInfotResult.dataValues.project_name;
let status = getProjectInfotResult.dataValues.status;
let project_img = getProjectInfotResult.dataValues.project_img;
projectResult.push([id, project_name, status, project_img]);
}
마지막으로 이 모든 데이터들을 result 객체에 담아서 전송해줍니다.
(JS 함수를 공부하면서 파라미터로 전달해줘야 하는 값이 많을 경우 이렇게 객체로 묶어서 전달해주는 것이 성능과 코드의 가독성을 높여준다고 합니다.)
res.json({ success: true, result: { user_name, github, blog, projectResult } });
사용자가 참여중인 모든 프로젝트들 안에서 사용자의 작업 내용을 불러오는 것 역시 사용자의 Id값과 프로젝트 Id가 필요합니다.
위와 동일한 방식으로 사용자가 참여중인 모든 프로젝트의 Id를 추출해오고, 이를 이용해 프로젝트별 이름과 사용자가 작업 중인 내용과 제목, 진행 상태, 기한 등을 추출해오면 됩니다.
exports.getMyBoard = async (req, res) => {
try {
const userId = req.userId;
// 사용자가 참여 중인 프로젝트 조회
const project_Id = await ProjectMember.findAll({
where: { userId },
attributes: ["projectId"],
order: [["projectId", "ASC"]],
});
// 프로젝트 별로 보드 가져오기 (프로젝트Name과 보드 정보 합치기)
let getMyBoard = new Map();
for (let i = 0; i < project_Id.length; i++) {
let projectId = project_Id[i].projectId;
//프로젝트 테이블의 프로젝트 이름, 보드 정보(제목, 상태, 기한) 모두 같은 프로젝트Id로 조회
let getProjectName = await Project.findByPk(projectId);
let projectName = getProjectName.dataValues.project_name;
let board = await Board.findAll({
where: { projectId },
attributes: ["id", "title", "status", "deadline"],
});
//project에 projectId가 없으면
if (!getMyBoard.has(projectId)) {
//projectId, proejctName, myBoards 추가
getMyBoard.set(projectId, {
projectName,
board,
});
}
}
// 배열로 반환해서 결과에 저장
const result = Array.from(getMyBoard.values());
res.json({ success: true, result });
} catch (error) {
console.error("내 보드 조회 실패:", error);
res.json({ success: false, result: "내 보드 조회 실패" });
}
};
사용자가 참여중인 모든 프로젝트 안에서 팀원들의 작업 로그를 조회하는 기능을 구현하려면 userId, projectId 뿐만 아니라 프로젝트별로 참여중인 프로젝트 멤버의 id값이 필요합니다.
프로젝트 멤버의 id값은 프로젝트 멤버 테이블에서 추출해올 수 있습니다.
const projects = await ProjectMember.findAll({
where: { userId },
attributes: ["projectId"],
});
프로젝트 멤버의 id값을 활용하여 이름, 프로필 이미지, 현재 참여중인 프로젝트명과 작업 내용들을 가져옵니다.
const projectMembers = await ProjectMember.findAll({
where: { projectId: projects.map((project) => project.projectId) },
include: { model: User, attributes: ["user_name"] }, // 멤버의 이름을 가져오기 위해 User 모델을 include
});
// 각 팀원별로 프로젝트의 보드를 조회한 결과를 담을 배열 초기화
let teamBoards = [];
// 각 팀원별로 프로젝트의 보드 조회
for (let j = 0; j < projects.length; j++) {
for (let i = 0; i < projectMembers.length; i++) {
const member = projectMembers[i];
const getBoardResult = await Board.findAll({
where: { projectId: projects[j].projectId, userId: projectMembers[i].id },
});
for (let boardResult of getBoardResult) {
const projectImg = await Project.findOne({
where: { id: projects[j].projectId },
attributes: ["project_img", "project_name"],
});
const board = {
deadline: boardResult.deadline,
description: boardResult.description,
id: boardResult.id,
projectId: boardResult.projectId,
status: boardResult.status,
title: boardResult.title,
userId: boardResult.userId,
user_name: member.user.user_name,
project_img: projectImg.project_img,
updatedAt: boardResult.updatedAt,
project_name: projectImg.project_name,
};
teamBoards.push(board);
}
}
}
마지막으로 result 객체에 담아 전달합니다.
res.json({ success: true, result: teamBoards });
이슈 게시판에는 크게 다섯가지 기능들이 있습니다.
위의 기능들을 구현하고자 아래와 같이 라우팅을 해주었습니다.
//search = 검색 params
// 프로젝트 이슈 검색
router.get("/search", middleware.auth, controller.searchProjectIssues);
// 프로젝트 이슈 작성 + 조회
router.post("/", middleware.auth, uploadIssueFiles, controller.createProjectIssue);
router.get("/", middleware.auth, controller.getProjectIssues);
//list = 페지네이션 요청 넘버
router.get("/list", middleware.auth, controller.getProjectIssuesPage);
// 프로젝트 이슈 상세 조회 + 수정 + 삭제
//:id = 이슈 id
router.get("/detail/:id", middleware.auth, controller.getProjectIssueDetail);
router.patch("/detail/:id", middleware.auth, uploadIssueFiles, controller.updateProjectIssueDetail);
router.delete("/detail/:id", middleware.auth, controller.deleteProjectIssueDetail);
router.delete("/detail/file/:id", middleware.auth, uploadIssueFiles, controller.deleteProjectIssueFile);
//프로젝트 이슈 댓글 작성 + 조회 + 수정 + 삭제
router.post("/comment/:id", middleware.auth, controller.writeProjectIssueComment);
router.get("/comment/:id", middleware.auth, controller.getProjectIssueComment);
router.patch("/comment/:id", middleware.auth, controller.updateProjectIssueComment);
이슈 게시판 페이지에서 핵심 기능은 게시판 작성 기능과 조회 기능입니다. 작성된 게시물이 있고, 작성된 게시물을 읽을 수 있어야 비로소 '게시판'이라는 공간이 활성화되기 때문입니다. 이런 관점에 입각해서 보면 수정, 삭제, 댓글 작성, 검색 등의 기능도 물론 중요하지만 상대적으로 부가적인 기능에 속한다고 볼 수 있지요.
이슈 작성은 사실 프론트에서 처리해줘야 하는 것들이 대두분입니다. (물론 클라이언트에서 해야 하는 작업을 서버에서 대신 해줄 수도 있지만, 제 프로젝트에서는 작성해야할 필수 항목이라던지, 프로젝트 멤버만 작성할 수 있게 작성 권한을 제어한다던지 와 같은 기능들은 클라이언트에서 처리하기로 했습니다.)
다만, 사용자가 게시판을 작성할 때 파일을 첨부할 수도 있고 안 할 수도 있는데, 이를 매번 req.files로 받아온 files를 DB에 저장하려고 하면 오류가 날 수 있습니다. 그래서 해당 부분만 고려해서 작성해주면 크게 복잡할 건 없습니다.
저의 경우엔 files에 값이 존재할 때만 fileNames(실질적으로 DB에 저장될 파일값)에 files를 할당하고, 그렇지 않을 경우엔 null값이 할당된 fileNames를 files 속성에 insert해, 실제로 파일이 없을 경우 컬럼 안에 파일에 대한 데이터가 아예 저장되지 않도록 했습니다.
exports.createProjectIssue = async (req, res) => {
const files = req.files;
console.log("file", files);
const projectId = req.projectId;
const { title, content, issue_date } = req.body;
const userId = req.userId;
try {
//각 파일들 이름 변경
let fileNames = null;
if (files) {
fileNames = files.map((file) => file.filename).join(", ");
console.log("파일 이름:", fileNames);
}
const newProjectIssue = await Issue.create({ title, content, projectId, userId, issue_date, files: fileNames });
console.log("issue id:", newProjectIssue.id);
res.json({ success: true, result: newProjectIssue.id });
} catch (error) {
console.error("이슈 작성 오류:", error);
res.json({ success: false, result: error });
}
};
이제 이슈 상세 조회 기능을 구현하기 위해선 프로젝트의 이슈 id가 필요합니다.
프로젝트 이슈 id는 api의 url 파라미터로 보내져 오기에 아래와 같이 req.params로 불러와줍니다.
const { id } = req.params;
이렇게 불러온 이슈 id를 활용해서 프로젝트별로 작성된 이슈 데이터를 담고 있는 Issue 테이블에서 프로젝트에 해당하는 모든 이슈들을 추출합니다. 이때, 최초로 생성된 게시판의 경우 작성된 게시글이 없을 수 있으므로 없으면 '해당 프로젝트 이슈를 찾을 수 없습니다.'와 같은 응답을 클라이언트 측에 전송되도록 작성해줍니다.
const projectIssue = await Issue.findByPk(id);
if (projectIssue) {
res.json({ success: true, result: projectIssue });
} else {
res.json({ success: false, result: "해당 프로젝트 이슈를 찾을 수 없습니다." });
}
게시판 페이지에서 모든 이슈를 한번에 볼 수 있는 '이슈 전체 조회' 기능은 페지네이션으로 구현했습니다.
프론트에서 페지네이션을 온전히 구현하기 위해 필요한 값인 '전체 이슈 개수', '현재 페이지', '한 화면에 보여질 이슈 개수', '전체 페이지 수'를 정리해서 보내주면 됩니다.
우선, 현재 페이지와 한 화면에 보여질 이슈 개수는 클라이언트가 정해서 서버로 보내주기에 req.query로 불러와 객체 분할을 통해 각각 저장해줍니다. 그리고 혹시 현재 페이지 값이 1보다 작거나 정수가 아닌 다른 값이 들어올 경우 초기 페이지로 돌아가도록 하여 예외처리를 해줍니다.
let { page, pageSize } = req.query;
page = JSON.parse(page); // offset / pagesize + 1
pageSize = JSON.parse(pageSize); // limit
if (!Number.isInteger(page) || page < 1) {
page = 1;
}
그런 뒤, DB에서 검색할 시작 위치인 offeset을 계산해주고, 계산한 offset과 limit(=pageSize)를 이용해서 클라이언트에서 요청한 이슈 데이터를 불러옵니다.
// offset 계산
const offset = (page - 1) * pageSize;
//
const projectIssues = await Issue.findAndCountAll({
where: { projectId },
limit: pageSize,
offset: offset,
order: [["updatedAt", "DESC"]],
});
이렇게 불러온 이슈 글을 바탕으로 전체 이슈 개수와 전체 페이지 수를 구하고, pagination객체에 totalIssues, page, pageSize, totalPages를 담아서 클라이언트로 전송합니다.
const totalIssues = projectIssues.count; // 전체 이슈 개수
const totalPages = Math.ceil(totalIssues / pageSize); // 전체 페이지 수
res.json({
success: true,
result: projectIssues.rows,
pagination: {
totalIssues,
page,
pageSize,
totalPages,
},
});
이슈 게시판 페이지에서 글 작성 버튼을 만들어줍니다. 이때 div를 사용해줘도 되고, button태그를 사용해줘도 됩니다.
<div id="write">
<a href="../../../project/issue_write" class="issue-write-a">글쓰기</a>
</div>
사용자가 링크를 타고 넘어온 '이슈 작성' 페이지에서 이슈를 작성할 수 있도록 form태그를 활용해 폼을 생성해줍니다.
<div class="bg_white">
<form name="issue_write_form" enctype="multipart/form-data">
<div class="form_top">
<h1 class="txtSmall">프로젝트 이슈</h1>
</div>
<!--제목 출력 위치-->
<div class="title_boxes">
<div contenteditable="true" id="title_box" class="title_box">제목을 작성해주세요.</div>
</div>
<div class="writer_date">
<input type="text" class="userId" style="display: none;"/>
<span>작성자</span>
<div class="writer_box"></div>
<div class="dateWrap_issue">
<label for="issue_date">작성일</label><input type="date" id="issue_date" class="issueDate">
</div>
</div>
<hr />
<!--글 출력 위치-->
<div class="content_box">
<div class="icon coding_icon" id="codingIcon" onclick="codeBlockFunc.bind(null, 2)()"></div>
<div class="content_box_inner" id="content" contenteditable="true">내용을 작성해주세요.</div>
</div>
<div class="uploadBox">
<form id="myForm">
<label for="files">
<span id="filesButt" class="button_white_yellow">파일 선택</span>
</label>
<p id="origin_name"></p>
<input id="files" type="file" name="issue_files" multiple />
</form>
</div>
<button type="button" id="submitButt" class="button_yellow_white" onclick="submitFunc()">글쓰기</button>
</form>
</div>
이때 작성자의 이름과 작성일은 사용자가 작성할 필요 없이 자동으로 값이 입력되도록 서버에 api요청을 통해 사용자 정보를 받아오고, Data()메소드로 현재 시각을 계산해 대입해줍니다.
(function () {
axios({
method: "POST",
url: "/api/user/info",
headers: {
Authorization: `Bearer ${token}`,
},
}).then((res) => {
console.log("res data결과", res.data);
const { user_name, id } = res.data.result;
document.querySelector(".userId").value = id;
document.querySelector(".writer_box").innerHTML = user_name;
document.getElementById("issue_date").value = new Date().toISOString().slice(0, 10);
});
})();
사용자가 버튼을 클릭하면 서버로 입력 데이터를 전송해주는 submitFunc함수를 작성해줍니다. 이때 파일이 있을 수 있기에 formData 안에 데이터들을 담아서 전송해주고, 성공적으로 axios 요청이 완료되면 게시판 홈으로 돌아가도록 설정해줍니다.
async function submitFunc() {
try {
const content = document.getElementById("content").innerHTML;
const issue_date = document.querySelector(".issueDate").value;
const title = document.querySelector(".title_box").textContent;
const userId = document.querySelector(".userId").value;
//파일 불러오기
const fileInput = document.getElementById("files");
const formData = new FormData();
formData.append("content", content);
formData.append("issue_date", issue_date);
formData.append("title", title);
formData.append("projectId", "1"); // 로컬 스토리지 가져오기
formData.append("userId", userId);
for (let i = 0; i < fileInput.files.length; i++) {
formData.append("issue_files", fileInput.files[i]);
}
const res = await axios({
method: "POST",
url: "/api/project/issue",
data: formData,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
});
document.location.href = "issue_main";
} catch (error) {
console.error(error);
}
}
프론트에서 페지네이션을 구현하기 위해선 우선 페이지를 이동할 수 있는 이전, 이후, 페이지 번호 버튼이 있어야 합니다.
<!--페이지네이션-->
<div id="paginationBox">
<!--이전 페이지 그룹 이동 버튼-->
<div id="prevGroupButton" class="prevGroupButton">이전</div>
<!-- 페이지 이동할 수 있는 버튼-->
<div class="pageNumber" id="pageNumber"></div>
<!--다음 페이지 그룹 이동 버튼-->
<div id="nextGroupButton" class="nextGroupButton">다음</div>
</div>
여기서, pageNumber는 사실 이전, 이후 버튼과 달리 이슈 개수에 따라 달라질 수 있는 값이므로 별도의 연산 과정이 필요합니다.
pageNumber는 정확히 말해서 한 화면에 보여질 전체 페이지 수입니다. 이를 구하기 위해서는 앞서 서버에서 전송해준 현재 페이지, 한 화면에 보여질 페이지 수, 전체 페이지 수, 전체 이슈 개수 값이 필요합니다. 이 값들을 받아오기 위해 필요한 page와 pageSize를 서버로 요청하고, 이렇게 요청해서 받은 값들은 변경되어선 안 되는 값이므로 상수로 저장해줍니다.
const res3 = await axios({
method: "get",
url: `/api/project/issue/list?page=1&pageSize=5`,
headers: {
Authorization: `Bearer ${token}`,
},
});
const page = res3.data.pagination.page; // 현재 페이지
const pageSize = res3.data.pagination.pageSize; // limit
const totalPages = res3.data.pagination.totalPages; // 페이지 전체 개수
const totalIssues = res3.data.pagination.totalIssues;
그런 뒤, 이렇게 가져온 값들을 활용해서 총 pageNumber와 첫 페이지 숫자와 마지막 페이지 숫자를 구해줍니다.
let pageGroup = Math.ceil(page / pageSize); // 페이지 그룹
//어떤 한 페이지 그룹의 첫번째 페이지 번호 = ((페이지 그룹 - 1) * 한 화면에 보여질 페이지 개수) + 1
let firstPageOfGroup = (pageGroup - 1) * 5 + 1;
//어떤 한 페이지 그룹의 마지막 페이지 번호 = 페이지 그룹 * 한 화면에 보여질 페이지 개수
let lastPageOfGroup = pageGroup * 5;
if (lastPageOfGroup > totalPages) {
lastPageOfGroup = totalPages;
}
총 pageNumber와 첫번째 페이지 숫자와 마지막 페이지 숫자를 알아내고나면, pageNumber(div)을 JS에서 가져와서 pageNumberBox라는 변수에 저장하고, for 반복문을 돌려서 pageNumber 생성, 클릭 이벤트 추가, 그리고 pageNumberBox에 삽입해줍니다.
(이때, pageNumberBox안에 이전 pageNumber값이 들어가 있을 수 있으므로 초기화를 먼저 해준 뒤 for 반복문이 실행되도록 해줍니다.)
const pageNumberBox = document.getElementById("pageNumber");
pageNumberBox.replaceChildren(); // 페이지 버튼 초기화
for (let i = firstPageOfGroup; i <= lastPageOfGroup; i++) {
const pageNumber = document.createElement("span");
pageNumber.innerText = i;
pageNumber.addEventListener("click", () => {
goToPage(i);
});
pageNumberBox.appendChild(pageNumber);
}
이렇게 이슈 게시판 페이지 하단에 pagination box 안에 페이지 숫자, 이전 & 이후 버튼을 추가해준 뒤, 각각 클릭하면 실행될 이벤트를 추가해줍니다.
페이지 숫자는 'gotToPage'라는 함수로, 페이지 이전과 이후 버튼은 각각 'goToPrev' 와 'goToNext'함수로 이벤트를 처리해줍니다.
async function goToPage(page) {
const res = await axios({
method: "get",
url: `/api/project/issue/list?page=${page}&pageSize=${pageSize}`,
headers: {
Authorization: `Bearer ${token}`,
},
});
tbody.innerHTML = ""; //페이지 번호에 해당하는 issue들 할당해주기 위한 초기 테이블값 초기화
//issue[i].userId = i번째 이슈 작성자
for (let i = 0; i < res.data.result.length; i++) {
const userId = res.data.result[i].userId;
console.log("res. userId값: ", res.data.result[i].userId);
const res2 = await axios({
method: "POST",
url: "/api/user/findInfo",
headers: {
Authorization: `Bearer ${token}`,
},
data: {
userId: userId,
},
});
//issue[i].userId를 가진 작성자
const userName = res2.data.result.user_name;
console.log("res2:", res2.data.result);
//[i]번째 이슈글 출력
const html = `
<tr>
<td>${(page - 1) * 10 + i + 1}</td>
<td><a href="/project/issue_content/${res.data.result[i].id}">${res.data.result[i].title}</a></td>
<td>${userName}님</td>
<td>${res.data.result[i].issue_date}</td>
</tr>`;
tbody.insertAdjacentHTML("beforeend", html);
}
}
const goToPrev = (pageGroup) => {
if (pageGroup > 1) {
pageGroup -= 1;
}
firstPageOfGroup = (pageGroup - 1) * 5 + 1;
lastPageOfGroup = pageGroup * 5;
if (lastPageOfGroup > totalPages) {
lastPageOfGroup = totalPages;
}
pageNumberBox.replaceChildren();
for (let i = firstPageOfGroup; i <= lastPageOfGroup; i++) {
const pageNumber = document.createElement("span");
pageNumber.innerText = i;
pageNumber.addEventListener("click", () => {
goToPage(i);
});
pageNumberBox.appendChild(pageNumber);
}
return goToPage(firstPageOfGroup);
};
const goToNext = (pageGroup) => {
if (pageGroup < totalPages) {
pageGroup += 1;
firstPageOfGroup = (pageGroup - 1) * 5 + 1;
lastPageOfGroup = pageGroup * 5;
if (lastPageOfGroup > totalPages) {
lastPageOfGroup = totalPages;
}
pageNumberBox.replaceChildren();
for (let i = firstPageOfGroup; i <= lastPageOfGroup; i++) {
const pageNumber = document.createElement("span");
pageNumber.innerText = i;
pageNumber.addEventListener("click", () => {
goToPage(i);
});
pageNumberBox.appendChild(pageNumber);
}
}
return goToPage(firstPageOfGroup);
};
이슈 정보 조회
const res1 = await axios({
method: "get",
url: `/api/project/issue/detail/${id}`,
headers: {
Authorization: `Bearer ${token}`,
},
});
const { title, content, issue_date, files, createdAt, updatedAt, userId } = res1.data.result;
document.getElementById("content").innerHTML = content;
document.querySelector(".date_box").textContent = issue_date;
document.querySelector(".title_box").textContent = title;
document.querySelector(".userId").value = userId;
const fileBox = document.querySelector(".file_box");
// 유저 조회해서 이름(이슈 글 작성자) 가져오기
const user_Id = document.querySelector(".userId").value;
const res2 = await axios({
method: "POST",
url: "/api/user/findInfo",
headers: {
Authorization: `Bearer ${token}`,
},
data: {
userId: user_Id,
},
});
document.querySelector(".writer_box").innerHTML = res2.data.result.user_name;
파일이 있는 경우
if (files !== null && files !== "") {
fileBox.innerHTML = "";
console.log("files 받아온 것:", files);
const myfile = files.split(",");
if (myfile && myfile.length > 0) {
myfile.forEach((file) => {
// 파일명
const p = document.createElement("p");
p.textContent = file;
p.style.display = "inline-block";
// 삭제 버튼
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.onclick = function (event) {
deleteFileFunc(event); // 파일 삭제 함수 호출
};
deleteButton.style.display = "inline-block";
// 파일명과 삭제 버튼을 포함하는 div 요소 생성
const newFileBox = document.createElement("div");
newFileBox.classList.add("file");
newFileBox.appendChild(p);
newFileBox.appendChild(deleteButton); // 삭제 버튼 추가
fileBox.appendChild(newFileBox);
});
}
}
const res3 = await axios({
method: "get",
url: `/api/project/issue/comment/${id}`,
headers: {
Authorization: `Bearer ${token}`,
},
});
const comments = res3.data.result;
const commentList = document.querySelector(".comments");
//axios에서 전체 comment 리스트를 가져올 때 중복되는 댓글인지 아니면 똑같은 댓글은 두 번 쓴 건지 아닌지 판별하기가 어려워서 아예 초기화 후 리스트 재출력
commentList.innerHTML = ""; // 댓글 목록 초기화
comments.forEach(async (comment) => {
try {
// 각각의 댓글 작성자, 이미지 가져오기
const commentUserRes = await axios({
method: "post",
url: "/api/user/findInfo",
headers: {
Authorization: `Bearer ${token}`,
},
data: {
userId: comment.userId,
},
});
// 유저 정보에서 이미지와 이름 가져오기
const { user_img, user_name } = commentUserRes.data.result;
// 댓글을 표시할 HTML 요소 생성
const li = document.createElement("li");
// 유저 이미지 표시
// div가 사이즈 관리하기 편리한데 출력이 안 돼서 img태그로 바꿈
const userImage = document.createElement("img");
userImage.classList.add("user_img");
//파일 삭제한 경우엔 액박 뜸
if (user_img === null || user_img === "" || user_img === undefined) {
userImage.src = `../../../public/img/user-solid.svg`; //
} else if (user_img.includes("http:") || user_img.includes("https://")) {
userImage.src = user_img;
} else {
userImage.src = `../../../public/uploads/profile/${user_img}`;
}
// 작성자 이름 표시
const userName = document.createElement("div");
userName.classList.add("userName");
userName.textContent = user_name;
// 댓글 내용 표시
const commentText = document.createElement("div");
commentText.classList.add("comment");
commentText.textContent = comment.comment; // 댓글 내용
// 삭제 버튼
const deleteButton = document.createElement("button");
deleteButton.classList.add("deleteCommentButt");
// 삭제 버튼 함수 로직
deleteButton.onclick = async function () {
try {
const res = await axios({
method: "delete",
url: `/api/project/issue/comment/${id}`, // 댓글 삭제 API 엔드포인트
headers: {
Authorization: `Bearer ${token}`, // 토큰 추가
},
data: { id: comment.id },
});
if (res.status === 200) {
// 성공적으로 삭제되면 화면에서 해당 댓글 제거
li.remove();
console.log("댓글이 성공적으로 삭제되었습니다.");
}
} catch (error) {
console.log(error);
}
};
//css파일에서 flex랑 디자인 처리
const commentContainer = document.createElement("div");
commentContainer.classList.add("commentContainer");
const nameAndText = document.createElement("div"); //이름하고 댓글 묶어줄 div
nameAndText.classList.add("comment_nameAndText");
nameAndText.appendChild(userName);
nameAndText.appendChild(commentText);
commentContainer.appendChild(userImage);
commentContainer.appendChild(nameAndText);
li.appendChild(commentContainer);
li.appendChild(deleteButton); // 삭제 버튼
commentList.appendChild(li);
이번 프로젝트를 통해 웹 어플리케이션의 UI,UX가 생각보다 사람들한테 많은 영향을 끼친다는 사실을 깨달았습니다. 대상을 받은 팀도 물론 잘 했지만, 해당 팀은 프론트엔드에 초점을 맞춰 했기에 '풀스택'이라는 이번 프로젝트 목적에는 다소 아쉽다고 느꼈고, 심지어 해당 팀의 홈 페이지를 접속해보면 작동하지 않는 기능들이 생각보다 많았습니다. 그러나 해당 팀이 수강생들의 투표에 따라 대상을 수상한 것을 보고, 이들이 사람들의 마음을 산 점은 무엇이었을까?하고 고민해보니 'UI/UX'였습니다.
누가봐도 정말 깔끔한 디자인이었고, 겉으로 보기에는 바로 상용화해도 될 정도로 예뻤습니다. 해당 팀에 퍼블리셔를 하고 오셨던 분이 계셔서 그런지, 제것도 손 좀 봐달라고 부탁드리고 싶더군요ㅎㅎ. 아무튼, 겉으로 보여지는 부분을 다루는 프론트엔드 개발자라면 얕게라도 UI/UX에 대한 관심을 가져야 함을 깨달았습니다.
이 프로젝트 이후에 UI/UX를 어떻게 공부하면 좋을지 소스들을 검색해보니, 실제로 많은 프론트 개발자 분들이 간단하게라도 서적을 구매해서 공부하시는 것을 봤습니다. 이를 보고 저 또한 개발 언어와 도구를 공부하다 틈날 때 UI/UX도 공부해야겠다는 생각이 들었습니다.
저와 팀장님도 이제 막 개발을 배우기 시작한 초보들이지만, 다른 두 팀원들보다 역량이 높은 편이었습니다. 이를 인지하고 나름 두 팀원을 배려하여 백엔드를 팀장님과 제가 맡아서 한 것이었지만, 프론트 엔드 팀원분들께서 JS는 물론이고 html과 css 조차 힘겨워할 줄은 예측하지 못했습니다. 그래서 프로젝트 마감 1주일 전에 급하게 코드리뷰를 하며 HTML 코드부터 수정 또는 재작성을 하게 됐고, 이로 인해 프로젝트 마감을 제대로 하지 못하는 불상사가 발생하였습니다.
그리고 초반에 API 명세서를 작성해 프론트 엔드분들과 소통하고자 했으나, 이때 바쁘다는 핑계로 프론트엔드 분들께 '명세서 읽어보시고 궁금한 점 말해주세요'라고만 말하고 설명을 생략했습니다. 지금 생각해보면 얼마나 무지했는지 프로젝트 끝난 후 본 REST API 강의를 보고 깨달았습니다. 'REST' 아키텍처의 설계 원칙 중 하나인 'Self-descriptive'를 제대로 해놓지도 않고, 달랑 명세서만 제공한 뒤 이를 이해하라고 한 것이었습니다. 이는 팀원들의 역량이 아쉬운 점보다 제 자신이 무지해서 생긴 일에 가깝습니다.
조금씩 UI/UX에 관심 가지는 것, 그리고 프로젝트 설계 과정을 공부하는 것입니다. 특히 프로젝트 설계는 현업자인 지인의 말에 의하면 설계만 잘 해놓으면 코드는 금방 짠다고 할만큼 매우 중요한데, 그만큼 어렵고 꼼꼼해야 하며 오래 걸리는 작업입니다. 조금만 서치해봐도 이벤트 로깅 컨벤션 설계, 코딩 컨벤션, 폴더 구조, 계층 설계 등등 설계해야 하는 내용들이 정말 많습니다. 엄연히 따지면 끝이 없는 작업이라고 볼 수 있죠.
그치만 밑 그림이 탄탄해야 이 위에 채색을 했을 때 그림의 완성도가 높아지듯, 프로그래밍 또한 설계가 탄탄히 되어야 클린한 코드를 작성할 수 있습니다. 저는 이러한 사실을 실패를 통해 깨달을 수 있었죠.
다음번 프로젝트를 협업하고자 할 때는 좀 더 설계를 잘 할 수 있도록 공부하는 것이 당분간 저의 목표입니다. (물론, 개발 라이브러리와 도구들도 같이 공부하면서요)