디자인 시스템 네이밍 서포트 툴 개발기 - BE편 (개발부터 배포까지)

예리에르·2023년 8월 26일
1

Backend

목록 보기
3/3
post-thumbnail

프로젝트의 시작

프로젝트의 시작은 디자이너의 제안에서 시작하였다. 프론트에 디자인 가이드를 전해줄 때 색깔과 사이즈 등 디자인 리소스(CSS)의 이름을 전해주는 방법에 대한 많은 고민이 있으셨다고 했다.
좀 더 직관적이면서 구조적인 네이밍 전달에 대해 고민하셨다. 원래는 스프레드 시트의 함수를 이용해 원하는 기능을 구현해볼려고 했으나. 생각보다 다양한 경우의 수와 복잡한 힘수로 개발을 제안하셨다!

사진과 같이 처음에 color를 선택했지만 다음 선택한 속성에 따라 연결 되어 나올수 있는 속성들이 달라진다.
그래서 개발을 시작전에 기술 스택을 고민했는데 마침 팀장님께서 MySQL개발을 통해 RDBMS에 대한 지식도 학습하길 원하셨기 때문에 Node.jsMySQL을 활용하여 백엔드 기술 스택을 정했다.

또 다른 각오 및 목표

이번 프로젝트는 사용자 수가 많지 않아 서비스적인 면이 부족하지만 나만의 목표를 설저하였다. 지금까지는 BE,FE 하나만 선택해서 개발하고 협업을 통해 내가 관리하지 못하는 아쉬운 점이 있었다.

그런 아쉬움을 이번 프로젝트를 통한다면 해소하기 좋을 것 같다는 생각이 들었다.

  1. FE,BE 모두 내가 스스로 직접 개발하자.
  2. 둘다 직접 배포해보자 (AWS, Docker의 지식 고도화!)
  3. 도메인을 직접 만들면서 웹 개발 전반적인 시스템 경험

이렇게 3가지를 생각하면서 개발을 시작하였다. 만 2년을 채운 시점에서 성장에 대한 불안감이 있었는데, 목표를 이룬다면 불안감을 어느정도 해소할 수 있을꺼 같아서 설렘과 함께 시작하였다.

개발의 시작!

MySQL 설계

먼저 서버를 만들기 전에 MySQL을 설정하고 database를 설계해야했다. 지금까지 DB 개발은 MongoDB를 활용한 NoSQL로 기존의 사고 방식에서 벗어나 RDBMS를 이해하는 시간이 필요했다.

그러다 공부를 하다보니 FE개발자로 MySQL을 공부하는 관점이 궁금하였다. (N은 망상을 벗어나지 못해... 🙄🙄 )
팀장님이 답해주신 답변은 2가지로 정리할 수있는데,

  1. 개발을 하다 백엔드로 부터 데이터를 받아올 때 구조를 어느정도 이해하고 의사소통을 원할하게 할 수있다.
    • 필요한 데이터를 요청할 때 금방 나오지 않는 경우 이해가능. -> FE 처리 vs BE에게 요청
    • 백엔드 개발자가 설명해줄 때 이해가 바로바로 가능.
  2. 서비스 기획 회의 참석 시 FE 입장에서 개발일정이나 개발 가능 여부에 대해 설명가능. (FE로써 의견 표출 가능)
    • 최근 기획자 분들도 DB를 하시는 분들이 많이 계신다. 기획서 회의 단계에서 선제적으로 FE 의견 이야기할수있다.

미래 팀을 리딩하고 누군가의 사수가 돼었을때 모습을 상상하면 아직은 몸에 와 닿지 않지만 공부를 해야하는 이유를 확실히 알수 있었고 학습의 의지를 불태웠다!! (하지만 아직도 많은 양의 테이블을 설계하지 못한다... ㅎㅎ 👻👻)

팀장님과 사수분께 모르는 부분을 물어보면서 DB설계했다. 생각한 아이디어는

feature이라는 큰 카테고리와 그 안에 속하는 속성을 attribute라는 필드명을 설정하였고 2개의 key를 합쳐서 primary key로 설정하였다. primary key마다 숨길 feature와 attribute를 적는 열을 추가하였다.

  • feature : category, property, component, variant, state...
  • attribute : color, pading,space ...

MySQL Docker 띄우기

MySQL의 개발 환경은 Docker를 활용하였다.

  1. Docker의 이미지를 Pull 받는다.
$ docker pull mysql
  1. docker 실행하기
$ docker run --name naming-mysql-container -e MYSQL_ROOT_PASSWORD=/*password*/ -d -p 3307:3306 mysql:latest

여기서 알게된 사실은 docker 이미지 마다 사용하는 포트가 있는 경우가 있는데
MySQL(3306), MongoDB(27017), redis(6379), node(3000)등 특정 이미지는 포트가 정해진 경우가 있기 때문에 참고하면서 설정해야한다.

  1. docker desktop 을 통해 실행 확인 완료

터미널에서

$ docker ps -a

을 통해서도 컨테이너가 샐행된걸 확인할수있다. 앞으로 aws를 사용하다보면 shell 에 접근할 경우가 많은데 이런 명령어를 알아두는 것도 추천한다!! 👍👍👍

  1. DBeaver을 통해 DB, 테이블, 데이터 CRUD 해보기
    직접 다 명령어로 만들어도 좋지만 DBeaver를 통해 손 쉽게 DB를 다룰 수 있다.


만든 3307이라는 외부 포트를 가진 MySQL을 연결하면 손쉽게 컨테이너 안을 이용할 수있다.

프로그램을 통해 실행한 작업들에 대해 스크립트도 볼 수 있는데 이러한 기능들을 활용해 개발과 동시 학습하였다. ㅎㅎ

Node.js express 에 연결하기

// backend/lib/db.js
const mysql = require('mysql2');

const { MYSQL_HOST = "", MYSQL_USER = "", MYSQL_PASSWORD = "" ,MYSQL_PORT} = process.env;

const connection = mysql.createConnection({
    host:MYSQL_HOST,
    port:MYSQL_PORT,
    user:MYSQL_USER,
    password:MYSQL_PASSWORD,
    database:'naming_db'
});


module.exports = connection;

mysql2라는 모듈을 사용하여 컨테이너를 만들 때 설정한 port, host, user, password등 필요한 정보들을 넣어주었고,


const connection = require('./lib/db');

...

const connectToDatabase = () => {
    return new Promise((resolve, reject) => {
        connection.connect((err) => {
            if (err) {
                reject(err);
            } else {
                resolve();
            }
        });
    });
};

app.listen(3000, async () => {
    console.log('3000번 포트에서 대기중 ');
    try {
        await connectToDatabase();
        console.log('DB connection successful');
    } catch (err) {
        console.error('DB connection error:', err);
        process.exit(1); // 애플리케이션 종료
    }
});

[AWS] MySQL와 node.js의 실행순서 - 해결

위의 코드를 처음부터 적성하지 않았다.

// backend/lib/db.js
const mysql = require('mysql2');

const { MYSQL_HOST = "", MYSQL_USER = "", MYSQL_PASSWORD = "" } = process.env;

const connection = mysql.createConnection({
    host:MYSQL_HOST,
    port:'3307',
    user:MYSQL_USER,
    password:MYSQL_PASSWORD,
    database:'naming_db'
});

connection.connect((err)=>{
    if(err) throw err;
    console.log('db connection::')
});

module.exports = connection;

// apps.js
app.listen(3000,()=>console.log('3000번 포트에서 대기중 '))

비동기적으로 각각 연결 및 실행을 시켰었다. 개발환경에서는 연결이 안되면 직접 서버를 실행하거나 DB를 실행하면됐지만 AWS상에서 연결 서점이 달라지면서 아래와 같은 알수 없는 에러를 보게 되었다.

해결한 방법은 첨에 올린 코드처럼 비동기처리를 하고 문제가 되는 시점 확인 및 동기적으로 실행을 구현하였다.

이를 통해 나중에 다른 이유로 서버가 멈추더라도 log를 좀더 쉽게 확인 할 수 있게 되었다.

프로젝트 개발 구조

구조는 MVC 구조를 사용하였다. (Modle,View, Controller)

View

// app.js
app.get('/naming',NamingController.getAllNamings);

사용자의 요청을 받아 전달해 줍니다.

Controller

    getAllNamings: async function (req, res) {
        const sqlFeatures = await Namings.getNamingFeatures().catch((err)=>{
            console.log("errorCode",err.sqlMessage,err);
            res.status(400).json({message: err.sqlMessage});
        });
        const features = sqlFeatures.map(r => r['feature']);
        const r = await Promise.all(features.map(async (curr) => {
            const feature = curr;
            const attributes = await Namings.getNamingAttributes(curr);
            return {
                feature,
                attributes: attributes.map(a => a['attribute']),
            };
        }));
        res.status(200).json(r);
    },

Model 에서 받아온 데이터를 원하는 응답 형태로 로직을 처리하고 View를 업데이트합니다.

Model

    getNamingFeatures: function () {
        return new Promise((resolve, reject) => {
            const sql = `SELECT feature FROM Naming GROUP BY feature`
            db.query(sql, (err, result) => {
                if (err) reject(err);
                resolve(result)
            })
        })
    },

SQL 쿼리를 통해 데이터를 조회하고 Controller에게 전달해줍니다.

여기까지가 간단하게 구조 설명 및 한가지 요청에 대한 코드 예시를 살펴보았습니다. 이러한 현식으로 RESTFul 하게 CRUD 요청을 수행하면 원하는 개발을 할 수있습니다!! ㅎㅎ

Model - 숨길 속성들의 합집합 Query

    getHideNaming: function (body) {
        return new Promise((resolve, reject) => {
            let whereSQL = [];
            if (typeof body.feature === typeof "") {
                whereSQL = [`(feature = '${body.feature}' AND attribute='${body.attribute}')`]
            } else {
                whereSQL = body.feature.reduce((acc, curr, index) => {
                    const feature = curr;
                    const attribute = body.attribute[index];
                    const sql = `(feature='${feature}' AND attribute='${attribute}')`;
                    acc.push(sql);
                    return acc
                }, []);
            }
            const sql = `SELECT
    GROUP_CONCAT(DISTINCT all_feature_hide) AS features,
   GROUP_CONCAT(DISTINCT all_attribute_hide) AS attributes
FROM (
    SELECT
        GROUP_CONCAT(DISTINCT feature_hide) AS all_feature_hide,
        GROUP_CONCAT(DISTINCT attribute_hide) AS all_attribute_hide
    FROM naming_db.Naming 
    WHERE ${whereSQL.join(" OR ")}
    GROUP BY feature, attribute
) AS subquery;`.replace("\n", ' ');
            db.query(sql, function (err, result) {
                if (err) reject(err);
                else resolve(result)
            })
        })
    },

핵심 기능인 단계가 지나갈수록 보이지 않아야하는 속성들이 추가되어야 하는것이다. 그리기 위해서는 이전단계에서 보이지 않을 속성들을 합집합으로 구성해야합니다.

  • group by : primary key로 설정했던 feature, attrivute를 기준을 그룹화 합니다.
  • GROUP_CONCAT(DISTINCT feature_hide) AS all_feature_hide : attribute_hide 열의 고유한 값을 그룹화하여 하나의 문자열로 만듭니다. 이 결과는 all_attribute_hide라는 이름의 열로 출력합니다.
  • GROUP_CONCAT(DISTINCT all_feature_hide) AS features : sub query 로 얻은 결과를 다시 그룹화하여 원하는 형태의 문자열로 바꿉니다.

Controller - feature 삭제, attribute 삭제

    deleteNaming: async function (req, res) {
        let deleteList = [];
        let exactlyFindList = [];
        try {
            const result = await Namings.selectDeletedNaming(req.params);
            if (req.params.attribute) {
                deleteList = result.reduce((acc,curr)=>{
                    const {feature,attribute,feature_hide,attribute_hide} = curr;
                    const arr = attribute_hide.split(",");
                    acc.push({
                        feature,attribute,feature_hide,attribute_hide : arr
                    });
                    return acc
                },[]);
                exactlyFindList = deleteList.filter(d=>d.attribute_hide.find(a=>a === req.params.attribute));
            } else {
                deleteList = result.reduce((acc,curr)=>{
                    const {feature,attribute,feature_hide,attribute_hide} = curr;
                    const arr =feature_hide.split(",");
                    acc.push({
                        feature,attribute,feature_hide:arr,attribute_hide
                    });
                    return acc
                },[]);
                exactlyFindList = deleteList.filter(d=>d.feature_hide.find(a=>a === req.params.feature));
            }
            const result2 = await Namings.deletedNaming(exactlyFindList,req.params);
            if (result2) res.status(200).json(result2);
        } catch (err) {
            console.log("errorCode",err.sqlMessage);
            res.status(400).json({message: err.sqlMessage});
        }

    }

Model - feature 삭제, attribute 삭제

    selectDeletedNaming: function (params) {
        return new Promise(async (resolve, reject) => {
            const deletedData = params["attribute"] ? params.attribute : params.feature;
            const sql = `SELECT * FROM naming_db.Naming WHERE ${params["attribute"]?'attribute_hide':'feature_hide'} LIKE '%${deletedData}%'`
            const deleteSQL = `DELETE FROM naming_db.Naming WHERE feature='${params["feature"]}' ${params["attribute"]?`AND attribute='${params['attribute']}'`:""}`;

            await dbQueryPromise.call(db,sql).then(re=> {
                resolve(re);
            }).catch(e =>reject(e))
            await dbQueryPromise.call(db,deleteSQL).then((r)=>console.log(("deleteData",r))).catch(err=>console.log("second",err));

        })
    },

   deletedNaming: function (namingList, params) {
        return new Promise((resolve, reject) => {
            const paramsKeyword = params["attribute"] ? params.attribute : params.feature;
            const result = namingList.map((d) => {
                const {feature, attribute, feature_hide, attribute_hide} = d;
                const originalList = params["attribute"] ? attribute_hide : feature_hide;
                const findIndex = originalList.indexOf(paramsKeyword);
                originalList.splice(findIndex, 1);

                return {
                    feature, attribute,
                    feature_hide: params["attribute"] ? feature_hide : originalList.join(","),
                    attribute_hide: params["attribute"] ? originalList.join(",") : attribute_hide
                }
            });

            const updateSQL = result.map((curr) => {
                if (params["attribute"]) {
                    return `UPDATE Naming SET attribute_hide='${curr["attribute_hide"]}' WHERE feature='${curr.feature}' AND attribute='${curr.attribute}'`;
                } else {
                    return `UPDATE Naming SET feature_hide='${curr["feature_hide"]}' WHERE feature='${curr.feature}' AND attribute='${curr.attribute}'`;
                }
            });

            Promise.all(updateSQL.map(query => dbQueryPromise.call(db, query)))
                .then(results => {
                    console.log(results)
                    resolve(results);
                })
                .catch(error => {
                    reject(error);
                });
        });
    },

삭제를 할때 헤맸던 부분은 MySQL로 문자열 삭제는 간편했지만 숫자가 들어갈 때 원하는 디테일이 나오지 않는 부분이었습니다. 예를들어 12를 삭제하고 '12' 문자열을 빈칸으로 바꾼뒤 update를 하고싶었지만 '112'를 '2'로 Replace가 되는 문제였습니다. LIKE, THEN, REPLACE등 다양한 MySQL를 시도해보았지만 복잡한 부족한 지식으로 시간이 너무 오래걸리면서 작업에 병목현상이 생겼고 다른 방법으로 변경했스빈다.

시도한 방법,

  • (controller) Controller에서 삭제하고 싶은 Delete요소들의 정보를 받아옵니다.
    • (model) selectDeletedNaming : params에 attribute 유무에 따라 attribute_hide, feature_hide들의 요소를 가져오고, 삭제합니다. feature은 attribute의 상위?개념으로 feature가 삭제되면 attribute도 삭제되는 특성을 고려해야합니다.
      선택한 리스의 결과는 contorller에 전달(resolve)해줍니다.
    • 원하는 요소가 잘 삭제되었지만 삭제된 특징이 연결된 feature_hide, hide_attribute_hide 특성도 update 해줘야합니다.
  • (controller) 전달 받은 리스트 들 중에 redeuce를 통해 , 를 통해 분리하고 변경하고 싶은 정확한 문자열만 filter로 처리한뒤 feature,attribute,feature_hide,attribute_hide 로 구성된 object를 model로 넘깁니다.

  • (model) attribute 유무에 따라 WHERE조건에 맞는 열의 값들을 변경합니다. 이렇게 해서 REPLACE에 격었던 어려움을 극복할 수있었습니다

배포의 시작!!

자 이제 로컬환경에서 개발을 끝났습니다. 이 코드를 직접 배포해볼까요? 배포는 AWS의 CodePipeline을 활용할 것입니다.

CodePipeline에 대해 모르신다면
저의 Route53 부터 CodePipeline 까지 CI/CD 이해하기 글에서 기본적인 이해도를 쌓을 수 있습니다 ㅎㅎ🥳🥳

buildspec.yml 부터 차근차근 진행하기 보다는 이전과 달리 달라진 점에 대해 설명하도록 하겠습니다.

docker-compose.yml

도커 컴포즈는 컨테이너 여럿을 띄우는 도커 애플리케이션을 정의하고 실행하는 도구(Tool for defining and running multi-container Docker applications) 이다.

compose를 통해 저희는 2개의 도커를 띄울것입니다. MySQL & Node.js


version: "3.1"
services:
  design-naming-mysql :
    image: mysql:8.0
    container_name: "design-naming-mysql"
    ports:
        - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD : /* PASSWORD */
    command: mysqld --sql_mode="STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION" --character-set-server=utf8mb4 --collation-server=utf8mb4_slovenian_ci --init-connect='SET NAMES utf8mb4;' --innodb-flush-log-at-trx-commit=0
    volumes:
      - /* EC2_Volume_Path */:/var/lib/mysql
    logging:
      options:
        max-size: "100m"
        max-file: "5"
    networks:
      - design_naming


  design-naming-was :
    container_name: "design-naming-was"
    hostname: "design-naming-was"
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "7071:3000"
    depends_on:
      - design-naming-mysql
    env_file:
      - .env
    networks:
      - design_naming

networks:
  design_naming:
    driver: bridge

로컬에서 docker를 실행했던 설정들을 생각하며 작성해줍니다. volumes난 데이터 베이스가 들어갈 장소로 ec2에 넣을 경로를 넣어줍니다.

network를 구성한 이유는 프론트엔드의 요청을 nginx를 통해 요청을 전달하기 위해 프론트와 백엔드 모두가 들어갈 network를 구성하였습니다. (컨테이너간의 연결을 위해~~)

  • restart 옵션을 추가하여 node.js를 재시작하게 해주었습니다. 첫 연결이 실패하더라도 서버가 멈추지 않고 다시 연결을 시도할 수 있게 하기 위해서입니다.

[AWS] 개발 데이터 - EC2로 옮기기

개발환경에서 구성한 데이터를 EC2로 옮겨보겠습니다!
Docker안의 MySQL 데이터 Dump export/import 를 참고하였습니다.

1.로컬 docker DB -> 로컬 PC

  # Docker MySQL의 sh 접속
  docker exec -it [Container-ID] sh
  
  # MySQL 데이터 Dump
  mysqldump -uroot -p [Database-Name] > /tmp/[File-Name].sql
  
  # 해당 경로에 생성 확인
  ls -al /tmp
  
  # Docker Container 밖으로 파일 복사
  docker cp [Container-ID]:/tmp/[File-Name].sql [PC의 저장할 경로]

  1. Filezila로 로컬 PC -> EC2 volume

    터미널을 통해 넣어되 되지만 Filezila를 통해 손쉽게 .sql 파일을 이동시켰습니다.

  2. Docker를 통해 확인해보기

// 컨테이너 접근
sudo docker exec -it [Container-ID] bash

// mysql 접속
mysql -u root -p[password]

무사히 로컬에 있던 데이터가 잘 들어간 것을 확인할 수 있습니다!

마무리

이번 백엔드 개발을 통해 Promise, CROS, AWS, Docker등 다양한 개발 지식과 경험을 늘릴 수 있었습니다. 직접 docker의 log도 확인하고 network도 구성해보면서 서비스가 연결에 대해 고민해보고 설계해보면서 백엔드 개발자를 좀더 잘 이해 할 수 있는 소중한 경험이었습니다.

다음은 FE편으로 돌아오겠습니다 😎

profile
비전공 프론트엔드 개발자의 개발일기😈 ✍️

0개의 댓글