두 번째 협업 프로젝트!

J.A.Y·2024년 3월 11일
0

프로젝트

목록 보기
3/5

GAE COLL (개린이 콜라보레이션)

링크: http://192.168.55.176:5502/index.html
위 URL에서 확인하실 수 있습니다.

개린이(개발자 어린이) 콜라보레이션을 만들게 된 이유

두 번째 프로젝트의 주제를 정하기 위해 팀원들과 Notion의 프로젝트 협업 템플릿과 slack을 이용해서 아이디어 회의를 진행하던 도중, Notion의 템플릿을 사용하기가 너무 어렵다는 팀원, slack에는 노트 보관 기한이 짧아 기록용으로는 적절하지 않은 것 같다는 팀원.... 등등 기존에 사용하던 툴들의 불편함들을 하나 둘씩 이야기하게 됐습니다. 그러고 모두 한 마음으로 'Notion의 일정 관리 기능, slack의 DM 기능을 합쳐 좀 더 심플한 협업 툴을 만들면 좋겠다'는 생각을 떠올리면서 '복잡한 것을 싫어하는 개발자들을 위한 심플한 협업 툴'이라는 주제를 1차로 결정하였습니다.

여기에 개발 시작 전에 api 명세서와 erd, 그리고 기획서를 작성해야 함을 잘 몰랐던 저희처럼 이제 막 개발에 발을 들이기 시작한 개발자들에게 마치 가이드라인을 제시하듯 api 명세서와 erd, 기획 이 세가지 파일 보관함을 만들어주면 주자는 의견이 나오게 되면서, 개발을 이제 막 시작한 어린(능력치가 낮은) 개발자, 즉 "'개린이'를 위한 협업 툴"로 주제를 최종적으로 정하게 됐습니다.

사용한 기술과 어플리케이션

  • 회의록: Notion(👉회의록 바로가기)
  • 역할

  • Backend1: 본인
    1. DB 구조화
    2. API 설계
    3. Backend Main 페이지 로직
    4. Backend 이슈 게시판 로직
    5. Frontend 이슈 게시판 로직
    6. Frontend 작업 일정 추가, 편집 로직
  • Backend2: 팀원1
  • Frontend: 팀원2, 팀원3

  • 개발 과정


    1. DB 구조

    티원1과 같이 DB 구조화하는 작업을 했는데, 한 번은 서로 의견이 달라 충돌이 일어난 적이 있었습니다.

    나: "🤔굳이 테이블B가 필요한지 모르겠습니다."
    
    팀원1: "그러면 이 데이터들을 어디에서 가져올 것인가요?"
    
    나: "테이블A에 있으니 가져오면 되지 않나요?"
    
    팀원1: "아니요. 따로 저장해야 합니다."

    DB 구조화 방법에 꼭 정해진 것은 없겠지만, 결론적으로는 둘다 제가 했던 주장이 맞다고 판단하여 중복되는 값은 다른 테이블의 index값을 외래키로 참조해 join하는 방법으로 테이블들을 구조화해 나갔습니다.

    지금 생각해보면 평소에 mysql의 select와 join 연습 문제를 풀어왔던 것이 이번 프로젝트 때 도움이 됐던 것 같습니다.

    이번에 DB를 직접 구조화하면서 깨달은 것들을 바탕으로 저만의 규칙을 만들어봤습니다. 다음 DB 구조화때 이를 참고하고자 합니다.

    📌규칙1 : "서로 관련 있는 데이터들이더라도 자주 업데이트 되는 데이터는 왜래키 참조키를 사용해 다른 테이블에 별도로 저장한다."

    📌규칙2 : "왜래키를 이용할 경우, 왜래값이 삭제하면 참조값도 삭제할 것인가에 대해 고민을 하고, 왜래키 옵션을 설정한다."

    📌규칙3 : "자주 업데이트 되는 내용이 아니라면 배열 형태로 저장해 DB 사용량을 줄인다." (mysql에서는 배열을 자동으로 문자열로 변환해 저장한다.)

    📌규칙4 : "이미지나 파일은 이름으로 저장한다."

    2. API 설계

    앞에서 DB 구조화 작업을 끝내 놓으니 프론트에서는 어떤 데이터를 백으로 보내줘야 하고 받아야하는지, 반대로 백에서는 어떤 데이터를 받아서 보내줘야 하는지 전체적으로 머릿속에 그려져서 작성하는데 그리 오래 걸리지 않았습니다.

    😓그렇다고 '완벽하게' 작성했다는 의미는 아닙니다.

    프론트로 넘어와서 직접 작업해보니 저희가 예상하지 못한 부분들이 발생했었습니다. 예를 들면, 현재 웹을 이용하고 있는 사용자의 정보를 조회하는 api는 있지만, 다른 사용자의 정보를 조회할 수 있는 api가 없다거나, 화면에 보여줘야하는 컨텐츠 개수가 많아 페이지네이션으로 구현해야하는데 컨텐츠 조회하는 api의 url에는 페이지네이션 구현에 필요한 값이 들어있지 않는다거나 하는 등의 에러들이 있었습니다.

    "예측 못한 부분을 발견할 때마다 API명세서 수정"

    점점 길어지는 API명세서를 보니 뿌듯하면서도 빠트린 내용들이 참 많았구나... 싶은 생각도 들었습니다. 비록 부족한 부분들이 많았지만, 배운 것도 많았기에 이번 경험을 바탕으로 다음 프로젝트때 더 꼼꼼하게 작성할 수 있지 않을까 싶습니다.

    3. 기능 구현 & 문제 해결 과정

    DB의 ERD 작업과 API 명세서 작성이 끝난 후, 팀원1과 저는 각자 controller 작업을 하기 위해 페이지를 나눴습니다. 저의 경우 백엔드는 Main과 프로젝트 이슈 전체를, 프론트는 프로젝트 이슈 게시판 전체와 프로젝트 보드 일부분을 맡아서 했습니다.

    (1) BE : 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);

    1. controller.getMyProject

    우선, 사용자가 참여중인 모든 프로젝트의 정보를 담아오는 기능을 구현하기 위해선 '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:

    idprojectIduserId
    111
    212
    323
    424
    525

    프로젝트 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 } });

    2. controller.getMyBoard

    사용자가 참여중인 모든 프로젝트들 안에서 사용자의 작업 내용을 불러오는 것 역시 사용자의 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: "내 보드 조회 실패" });
        }
    };

    3. controller.getMyTeamBoard

    사용자가 참여중인 모든 프로젝트 안에서 팀원들의 작업 로그를 조회하는 기능을 구현하려면 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 });

    (2) BE : 이슈 게시판

    이슈 게시판에는 크게 다섯가지 기능들이 있습니다.
    1. 프로젝트 이슈 작성, 수정, 삭제
    2. 프로젝트 전체 이슈 조회
    3. 프로젝트 이슈 검색
    4. 프로젝트 이슈 1개 상세 조회
    5. 프로젝트 이슈 댓글 작성 및 삭제

    위의 기능들을 구현하고자 아래와 같이 라우팅을 해주었습니다.

    //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);

    이슈 게시판 페이지에서 핵심 기능은 게시판 작성 기능과 조회 기능입니다. 작성된 게시물이 있고, 작성된 게시물을 읽을 수 있어야 비로소 '게시판'이라는 공간이 활성화되기 때문입니다. 이런 관점에 입각해서 보면 수정, 삭제, 댓글 작성, 검색 등의 기능도 물론 중요하지만 상대적으로 부가적인 기능에 속한다고 볼 수 있지요.

    1. 이슈 작성 controller.createProjectIssue

    이슈 작성은 사실 프론트에서 처리해줘야 하는 것들이 대두분입니다. (물론 클라이언트에서 해야 하는 작업을 서버에서 대신 해줄 수도 있지만, 제 프로젝트에서는 작성해야할 필수 항목이라던지, 프로젝트 멤버만 작성할 수 있게 작성 권한을 제어한다던지 와 같은 기능들은 클라이언트에서 처리하기로 했습니다.)

    다만, 사용자가 게시판을 작성할 때 파일을 첨부할 수도 있고 안 할 수도 있는데, 이를 매번 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 });
        }
    };

    2. 이슈 상세 조회 controller.getProjectIssueDetail

    이제 이슈 상세 조회 기능을 구현하기 위해선 프로젝트의 이슈 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: "해당 프로젝트 이슈를 찾을 수 없습니다." });
    	}

    3. 이슈 전체 조회 pagination(1) - BE

    게시판 페이지에서 모든 이슈를 한번에 볼 수 있는 '이슈 전체 조회' 기능은 페지네이션으로 구현했습니다.

    페지네이션 구현에 필요한 값

    프론트에서 페지네이션을 온전히 구현하기 위해 필요한 값인 '전체 이슈 개수', '현재 페이지', '한 화면에 보여질 이슈 개수', '전체 페이지 수'를 정리해서 보내주면 됩니다.

    우선, 현재 페이지와 한 화면에 보여질 이슈 개수는 클라이언트가 정해서 서버로 보내주기에 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,
       },
    });

    (3) FE : 이슈 게시판

    1. 이슈 작성

    이슈 게시판 페이지에서 글 작성 버튼을 만들어줍니다. 이때 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);
        }
    }

    2. 이슈 전체 조회 pagination(2) - FE

    프론트에서 페지네이션을 구현하기 위해선 우선 페이지를 이동할 수 있는 이전, 이후, 페이지 번호 버튼이 있어야 합니다.

    <!--페이지네이션-->
    <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'함수로 이벤트를 처리해줍니다.

    • goToPage()
        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);
            }
        }
    • goToPrev()
        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);
        };
    • goToNext()
        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);
        };

    3. 이슈 상세 조회

    이슈 정보 조회

     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);

    (4) FE : 작업 일정 추가, 편집


    아쉬운 점과 앞으로의 방향

    "항상 시작은 대범하게!"

    기획자와 개발자 사이에 갈등이 생기는 이유를 알게 된 이번 프로젝트....

    아쉬운 점

    1. UI, UX

    2. 팀원들의 역량을 고려하지 못한 기획 (실패한 기획)

    저와 팀장님도 이제 막 개발을 배우기 시작한 초보들이지만, 다른 두 팀원들보다 역량이 높은 편이었습니다. 이를 인지하고 나름 두 팀원을 배려하여 백엔드를 팀장님과 제가 맡아서 한 것이었지만, JS는 물론이고 html과 css 조차 힘겨워할 줄은 예측하지 못했습니다.

    API 명세서도 작성했지만, 프론트 분들한테 '읽어보시고 궁금한 점 말해주세요'라고만 말하고, 이에 대한 설명을 생략했던 것. 이들이 훈련된 사람들이 아니기에 ( 저 역시도 그렇지만) 설명을 충분히 했었어야 했음을 반성함.

    그래서 마지막

    앞으로의 방향

    profile
    Done is better than perfect🏃‍♀️

    0개의 댓글