프로젝트의 시작은 디자이너의 제안에서 시작하였다. 프론트에 디자인 가이드를 전해줄 때 색깔과 사이즈 등 디자인 리소스(CSS)의 이름을 전해주는 방법에 대한 많은 고민이 있으셨다고 했다.
좀 더 직관적이면서 구조적인 네이밍 전달에 대해 고민하셨다. 원래는 스프레드 시트의 함수를 이용해 원하는 기능을 구현해볼려고 했으나. 생각보다 다양한 경우의 수와 복잡한 힘수로 개발을 제안하셨다!
사진과 같이 처음에 color를 선택했지만 다음 선택한 속성에 따라 연결 되어 나올수 있는 속성들이 달라진다.
그래서 개발을 시작전에 기술 스택을 고민했는데 마침 팀장님께서 MySQL개발을 통해 RDBMS에 대한 지식도 학습하길 원하셨기 때문에 Node.js
와 MySQL
을 활용하여 백엔드 기술 스택을 정했다.
이번 프로젝트는 사용자 수가 많지 않아 서비스적인 면이 부족하지만 나만의 목표를 설저하였다. 지금까지는 BE,FE 하나만 선택해서 개발하고 협업을 통해 내가 관리하지 못하는 아쉬운 점이 있었다.
그런 아쉬움을 이번 프로젝트를 통한다면 해소하기 좋을 것 같다는 생각이 들었다.
- FE,BE 모두 내가 스스로 직접 개발하자.
- 둘다 직접 배포해보자 (AWS, Docker의 지식 고도화!)
- 도메인을 직접 만들면서 웹 개발 전반적인 시스템 경험
이렇게 3가지를 생각하면서 개발을 시작하였다. 만 2년을 채운 시점에서 성장에 대한 불안감이 있었는데, 목표를 이룬다면 불안감을 어느정도 해소할 수 있을꺼 같아서 설렘과 함께 시작하였다.
먼저 서버를 만들기 전에 MySQL을 설정하고 database를 설계해야했다. 지금까지 DB 개발은 MongoDB를 활용한 NoSQL로 기존의 사고 방식에서 벗어나 RDBMS를 이해하는 시간이 필요했다.
그러다 공부를 하다보니 FE개발자로 MySQL을 공부하는 관점이 궁금하였다. (N은 망상을 벗어나지 못해... 🙄🙄 )
팀장님이 답해주신 답변은 2가지로 정리할 수있는데,
미래 팀을 리딩하고 누군가의 사수가 돼었을때 모습을 상상하면 아직은 몸에 와 닿지 않지만 공부를 해야하는 이유를 확실히 알수 있었고 학습의 의지를 불태웠다!! (하지만 아직도 많은 양의 테이블을 설계하지 못한다... ㅎㅎ 👻👻)
팀장님과 사수분께 모르는 부분을 물어보면서 DB설계했다. 생각한 아이디어는
feature이라는 큰 카테고리와 그 안에 속하는 속성을 attribute라는 필드명을 설정하였고 2개의 key를 합쳐서 primary key로 설정하였다. primary key마다 숨길 feature와 attribute를 적는 열을 추가하였다.
MySQL의 개발 환경은 Docker를 활용하였다.
$ docker pull mysql
$ 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)등 특정 이미지는 포트가 정해진 경우가 있기 때문에 참고하면서 설정해야한다.
터미널에서
$ docker ps -a
을 통해서도 컨테이너가 샐행된걸 확인할수있다. 앞으로 aws를 사용하다보면 shell 에 접근할 경우가 많은데 이런 명령어를 알아두는 것도 추천한다!! 👍👍👍
만든 3307이라는 외부 포트를 가진 MySQL을 연결하면 손쉽게 컨테이너 안을 이용할 수있다.
프로그램을 통해 실행한 작업들에 대해 스크립트도 볼 수 있는데 이러한 기능들을 활용해 개발과 동시 학습하였다. ㅎㅎ
// 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); // 애플리케이션 종료
}
});
위의 코드를 처음부터 적성하지 않았다.
// 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)
// app.js
app.get('/naming',NamingController.getAllNamings);
사용자의 요청을 받아 전달해 줍니다.
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를 업데이트합니다.
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 요청을 수행하면 원하는 개발을 할 수있습니다!! ㅎㅎ
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)
})
})
},
핵심 기능인 단계가 지나갈수록 보이지 않아야하는 속성들이 추가되어야 하는것이다. 그리기 위해서는 이전단계에서 보이지 않을 속성들을 합집합으로 구성해야합니다.
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});
}
}
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) 전달 받은 리스트 들 중에 redeuce를 통해 , 를 통해 분리하고 변경하고 싶은 정확한 문자열만 filter로 처리한뒤 feature,attribute,feature_hide,attribute_hide 로 구성된 object를 model로 넘깁니다.
(model) attribute 유무에 따라 WHERE조건에 맞는 열의 값들을 변경합니다. 이렇게 해서 REPLACE에 격었던 어려움을 극복할 수있었습니다
자 이제 로컬환경에서 개발을 끝났습니다. 이 코드를 직접 배포해볼까요? 배포는 AWS의 CodePipeline을 활용할 것입니다.
CodePipeline에 대해 모르신다면
저의 Route53 부터 CodePipeline 까지 CI/CD 이해하기 글에서 기본적인 이해도를 쌓을 수 있습니다 ㅎㅎ🥳🥳
buildspec.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를 구성하였습니다. (컨테이너간의 연결을 위해~~)
개발환경에서 구성한 데이터를 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의 저장할 경로]
Filezila로 로컬 PC -> EC2 volume
터미널을 통해 넣어되 되지만 Filezila를 통해 손쉽게 .sql 파일을 이동시켰습니다.
Docker를 통해 확인해보기
// 컨테이너 접근
sudo docker exec -it [Container-ID] bash
// mysql 접속
mysql -u root -p[password]
무사히 로컬에 있던 데이터가 잘 들어간 것을 확인할 수 있습니다!
이번 백엔드 개발을 통해 Promise, CROS, AWS, Docker등 다양한 개발 지식과 경험을 늘릴 수 있었습니다. 직접 docker의 log도 확인하고 network도 구성해보면서 서비스가 연결에 대해 고민해보고 설계해보면서 백엔드 개발자를 좀더 잘 이해 할 수 있는 소중한 경험이었습니다.
다음은 FE편으로 돌아오겠습니다 😎