본 게시물은 성균관대학교 20년도 1학기 남범석 교수님의 데이터베이스 개론 수업의 과제를 참고하였음을 밝힙니다.
개요
2020년 데이터베이스 개론 수업의 과제로 파일 공유 웹사이트를 만들었다. 프론트엔드와 백엔드를 모두 구현해야 했는데 당시에 다뤄본 언어가 JSP밖에 없어서 JSP로 웹사이트를 개발하였다. 나동빈 님의 JSP 게시판 만들기 강좌를 보면서 성공적으로 과제를 마쳤지만 직접 만든 것이 아니라 아쉬움이 남은 과제였다. 작년에 군장병 온라인 해커톤을 진행하면서 백엔드를 공부해보고 싶다는 생각이 들었고 예전에 한 과제를 토대로 백엔드 구축 프로젝트를 진행하면 어떨까라는 생각으로 시작하였다. 프로젝트 파일 쉐어링은 문서, 동영상, 프로그램 등의 파일을 판매하는 셀러와 그런 제품을 구입하는 고객을 연결해주는 C2C 플랫폼이다.
개발 기간 : 22.03.17 ~ 22.04.07
구조
클라이언트가 웹 사이트에 접속하면 웹 서버는 html, css, javascript와 같은 정적인 데이터를 전달한다. 특정 고객의 정보와 같이 DB에 저장되어 있는 정보가 필요할 수 있는데 이는 동적인 데이터로 웹 서버가 API 서버로부터 동적인 데이터를 요청하여 데이터를 받아온다. 이 글에서는 빨간색으로 표시되어 있는 부분을 다루고 있다. 프론트 쪽(웹 사이트 + 웹 서버)은 전역 후에 개발하려고 한다.
대부분의 경우 보안상의 이유로 API 서버와 DB 서버를 분리한다. Google Cloud SQL(관계형 데이터베이스 서비스)를 이용하면 데이터베이스 서버를 구글에서 관리해주므로 직접 운영하는 것보다 백업, 복구, 안정성 등의 차원에서 수월한 관리가 가능하다. 하지만 따로 데이터베이스 서버를 두면 하루에 늘어나는 지출이 늘어나고 간단한 개인 프로젝트임으로 API 서버 내에서 데이터베이스를 관리하였다.
스토리지로는 Amazon S3, GCS(Google Cloud Storage) 등이 있는데 Google VM 인스턴스를 사용하므로 익숙한 GCS를 선택하였다. DB에는 이미지, 동영상과 같은 파일을 저장할 수 없다. API 서버의 디스크에 저장할 수도 있지만 클라이언트에서 파일의 URL을 통해 파일을 다운로드할 것이므로 GCS를 이용하는 것으로 결정하였다.
개발 환경
로컬 환경에서 개발을 마치고 배포할 때 코드를 AWS나 GCP에 올리는 게 정석이지만 군대에서 개발을 진행하였으므로 로컬에서는 개발을 할 수가 없었다. GCP의 VM 인스턴스를 생성하여 code-server을 실행하면 어떤 컴퓨터에서든 같은 환경으로 개발을 진행할 수 있다! AWS도 고려해봤지만 예전부터 사용해온 GCP를 이용하는 게 낫다고 생각했다. e2-small(vCPU 2개, 2GB 메모리) + 선점형(preemptible)으로 인스턴스를 구성하면 code-server을 이용하는데 아무런 문제가 없고 하루에 60원 정도의 비용만 지불하면 된다.
언어 + 프레임워크
Django(Python) vs Flask(Python) vs Express(Node.js)
Django는 스케일이 큰 프로젝트에 적합하고 개발 자유도가 낮은 프레임 워크라서 배우는 단계인 초보자에게는 맞지 않는다고 생각하여 제외하였다. 나머지 두 프레임워크는 개발 자유도가 높다. 언어의 차이만이 존재하였는데 결국 이전에 해커톤 진행하면서 다뤄본 경험이 있는 Node.js와 Express를 선택하였다. 이렇게 Express를 선택한 이유를 나열했지만 사실 예전부터 Python보다는 Node.js로 프로젝트를 진행해보고 싶었다. 패키지도 많고 문서도 잘 정리되어 있어서 끌렸기 때문이다. 무엇보다 하나의 언어(Node.js)로 백엔드 서버를 구축한 경험은 후에 다른 언어(Python, Java)로 백엔드 서버를 구축할 때 도움이 될 것이라 생각한다.
DB 설계
🌈 E-R 모델과 Relation 변환 규칙을 이용한 설계 과정
요구사항 분석
- 파일 쉐어링 사이트에 가입하려면 유저는 유저 아이디, 비밀번호, 이름, 나이를 입력해야 한다. 이후 고객으로 가입할 것인지 셀러로 가입할 것인지 확인한다. 고객으로 가입하는 경우 별명을, 셀러로 가입하는 경우 계좌 번호를 추가로 입력해야 한다.
- 유저(고객, 셀러)는 UUID로 식별한다. (random + sequential)
- 유저는 type_id컬럼으로 고객 혹은 셀러를 판단한다.
- 유저의 비밀번호는 해쉬 알고리즘(MD5/SHA1)을 사용하여 저장한다.
- 파일에 대한 파일번호, 파일명, 파일 유형, 카테고리, 파일 설명, 파일 경로, 단가정보를 유지해야 한다.
- 파일은 파일번호로 식별한다. (auto increment)
- 파일은 카테고리별로 분류할 수 있다.
- 카테고리에는 디자인, 음악, IT, 글쓰기, 취업, 노하우, 취미가 있다.
- 각 파일은 한 셀러가 공급하고, 셀러 한 명은 여러 파일을 공급할 수 있다.
- 고객이 파일을 구매하면 구매에 대한 구매번호, 구매일자를 유지해야 한다.
- 고객은 구매한 파일에 리뷰를 남길 수 있고 리뷰는 리뷰 번호, 별점, 내용을 저장해야 한다.
- 리뷰는 리뷰 번호로 식별한다. (auto increment)
- 각 리뷰는 한 고객이 작성하고, 고객 한 명은 여러 리뷰를 작성할 수 있다.
- 관리자는 모든 거래 내역, 수입, 지출 등을 볼 수 있다.
- 고객은 자신이 구매한 파일들을 마이페이지에서 관리할 수 있다.
- 셀러는 마이페이지에서 자신이 판매하는 파일 및 매출액을 관리할 수 있다.
개념적 설계
요구사항 중 데이터베이스에 저장해둘 필요가 있다고 판단되는 데이터 요소를 추출하고 데이터 요소 간 관계를 파악한다. 명사 위주로 추출하는 게 좋다고 한다! 다음은 명사 위주로 추출한 데이터 요소와 데이터 요소 간의 관계를 보여주고 있다.
관계 추출
셀러 : 파일 = 1:N
→ 각각의 셀러는 여러 개의 파일을 판매할 수 있고, 하나의 파일은 한 명의 셀러에게 속한다.
고객 : 파일 = N:M
→ 각각의 고객은 여러 개의 파일을 구매할 수 있고, 각각의 파일은 여러 명의 고객을 구매자로 보유할 수 있다.
카테고리 : 파일 = 1 : N
유저 : 고객 = 1 : 1
유저 : 셀러 = 1 : 1
유저 타입 : 유저 = 1 : N
논리적 설계
개념적 설계에서 도출된 E-R 모델을 통해 논리적 스키마를 설계하는 단계이다.
- 도출된 E-R 모델을 relation으로 변환하라.
- N:M 관계는 relation으로 변환한다.
→ 고객과 파일은 모두 구매 개체에 속할 수 있다. - 1:N 관계는 외래 키로 표현한다.
→ 파일 개체는 셀러 개체를 나타내는 seller_id 속성을 외래 키로 갖는다. - 1:1 관계는 외래 키로 표현한다.
네이밍 룰
- 소문자를 사용한다.
- 테이블명은 복수형으로 한다.
- 이름은 snake_case 를 따른다.
- 테이블이 하나의 Primary Key를 가진다면 그 속성의 이름은 id 로 한다.
ex) 유저 타입 테이블명: user_types
고려사항
유저의 유형은 고객과 셀러 2가지다. 고객 테이블과 셀러 테이블만 있는 경우 어떤 문제가 발생할 수 있을까? 고객 테이블과 셀러 테이블에서 각각 이메일을 따로 관리한다면 고객 테이블과 셀러 테이블에 같은 이메일을 등록하는 경우가 발생할 수 있다. 또한 고객과 셀러가 공통으로 갖는 속성(생일 등)을 추가한다고 할 때 각각의 테이블에 접근해서 같은 SQL 구문을 실행할 것이다. 따라서 지속적인 업데이트가 이뤄진다고 가정할 때, 공통의 속성을 갖는 유저 테이블과 고객의 속성만을 갖는 고객 테이블, 셀러의 속성만을 갖는 셀러 테이블로 유저를 관리하는 것이 좋다.
데이터베이스
위의 E-R 다이어그램을 실제로 구현할 데이터베이스를 선택한다. NoSQL 기반 데이터베이스인 MongoDB와 가장 친숙한 데이터베이스인 MySQL 중 무엇을 선택할까? 데이터 간 관계가 뚜렷하고 데이터 값을 변경하는 일이 잦으므로 MySQL을 선택하였다.
API 설계
파일 전송과 같이 텍스트로 처리할 수 없는 요청이 있고 요청의 구조가 정해져 있으므로 GraphQL보다는 RESTful을 선택하였다. HTTP API는 HTTP URI를 통해 자원을 명시하고, HTTP Method(POST, GET, PUT, DELETE)를 통해 해당 자원에 대한 CRUD Operation을 적용하는 것을 의미한다. REST API는 해당 개념에 추가적으로 제약조건이 붙지만 일반적으로 HTTP API와 REST API를 구분하지 않고 사용한다고 한다. (김영한 님의 Q&A)
규칙
- URI는 정보의 자원을 표현해야 한다. (리소스명은 동사보다는 명사를 활용)
Collection(sports)과 Document(soccer)로 자원을 표현하자. - 자원에 대한 행위는 HTTP Method로 표현
- URI 마지막 문자로 슬래쉬 포함 X
- 페이지(view)가 중심이 되어 받는 것이 아닌, 자원(데이터)이 중심이 되어야 한다.
POST /file-reviews (X) → POST /files/1/reviews (O) - 올바른 상태 코드를 반환하라(200, 201, 400...)
네이밍 룰
- URL에 kecab-case 를 사용하자.
ex) /user-types - Path Variable에는 camelCase 를 사용하자.
ex) /users/:userId - JSON property에는 camelCase 를 사용하자.
ex) { fileId : 1 }
위의 규칙과 네이밍 룰, 이전에 설계한 DB를 바탕으로 REST API를 설계하였다. 각각의 개체에 대해 GET / POST / PUT / DELETE 메소드를 기본으로 잡고 필요 없는 메소드는 삭제, 추가로 필요한 메소드를 작성하였다. API 설계 툴로는 gitbook을 추천한다. 각각의 API 메소드에 대한 설명, path variable, query parameter, body parameter, 응답 코드 등을 작성할 수 있어 상세한 설계가 가능하다. 여기에서 내가 설계한 REST API를 확인할 수 있다.
API 구현
디렉토리 구조
내가 구축한 API 서버의 디렉토리 구조이다. 라우터들의 집합인 routes, 해당 URL이 요청됐을 때의 request와 response를 기술한 controllers, JWT 토큰 인증 · 권한 인증 · 파일 업로드의 역할을 수행하는 미들웨어들의 집합인 middlewares, 에러 처리, GCS 연동 처리, JWT 토큰 처리 등 다양한 기능을 가진 모듈들의 집합인 utils, MySQL DB 연동과 같이 환경설정에 사용되는 모듈들의 집합인 config 등의 폴더들과 환경변수를 관리하는 .env, 필요 없는 파일을 제외하는 .gitignore 등의 파일들로 구성되어 있다.
routes/user/users.js
라우터와 컨트롤러, 미들웨어 간의 관계를 자세히 알아보자.
라우터는 어떤 HTTP URI와 HTTP Method 요청을 처리할지 결정한다.
컨트롤러는 해당 요청에 대해 SQL문을 실행하고 그 결과를 클라이언트에게 JSON 형태로 응답한다.
미들웨어는 컨트롤러 실행 전에 거치는 모듈로서, 주어진 로직을 수행하고 에러가 없으면 next( )를 호출해 다음 미들웨어 혹은 컨트롤러로 이동시킨다.
var express = require('express');
var router = express.Router();
const userController = require('../../controllers/user-controller');
const tokenChecker = require('../../middlewares/token-checker');
const authorityChecker = require('../../middlewares/authority-checker');
/* GET users */
router.get('/', tokenChecker, authorityChecker, userController.getUsers);
/* POST A User */
router.post('/', userController.postUser);
/* Check Whether same email exists */
router.post('/check-email', userController.checkUserEmail);
/* PUT A User */
router.put('/:userId', tokenChecker, authorityChecker, userController.putUser);
/* DELETE A User */
router.delete('/:userId', tokenChecker, authorityChecker, userController.deleteUser);
module.exports = router;
코드 관리
개인 프로젝트여서 main 브랜치에서 commit & push를 해도 상관없었지만 훗날을 생각하며 구현하려는 기능 별로 브랜치를 따고 pull request를 하는 방식으로 개발을 진행하였다. 프로젝트 관리는 프로젝트의 진행상황, 해야 할 일 등을 관리할 수 있는 github Projects, 각각의 기능 구현 관리는 github Issues 탭을 이용하는 것을 추천한다. 하나의 Issue를 생성하여 해당 Issue에 해당하는 브랜치(ex. issues/1)를 따고 pull request를 날리면 commit과 Issue가 연결된다. 또한 해당 기능을 구현하는데 참고한 자료의 경우 Notion에 정리하면서 진행한 것이 큰 도움이 되었다.
파일 API
구현하는데 시간이 가장 많이 걸린 API이다. 서버에서 클라이언트가 전송한 텍스트를 처리하는 것은 간단하다. req 객체에 저장된 데이터(파일 이름, 가격, 설명 등)를 MySQL DB에 저장하면 된다. 하지만 클라이언트가 전송한 파일을 처리하고 서버에서 클라이언트로 요청한 파일을 전달하는 작업은 처음이었다.
구글링 결과 multer 모듈(미들웨어)을 이용하면 쉽게 파일 업로드를 할 수 있었다. multer.single( ) 을 사용하면 req.file에 하나의 파일 정보가, multer.array( ) 을 사용하면 req.files에 여러 개의 파일 정보가 저장된다. 파일은 디스크가 아닌 메모리에 버퍼 객체로 저장한 후 GCS에 버퍼 객체를 전송하여 저장하였다.
그렇다면 클라이언트가 요청한 파일을 전달할 때는 어떻게 해야 할까? 파일을 직접 전송하는 방법과 GCS에 파일이 저장된 위치(URL)를 전송하는 방법이 있다. 전송 속도 차원에서 후자가 유리하다. 이를 위해 GCS에 업로드한 파일의 위치를 MySQL DB에 추가로 저장해야 한다. 업로드 모듈은 미들웨어로 구현하여 컨트롤러는 DB 관련 로직만 처리하게 하였다.
middlewares/upload.js
클라이언트가 서버로 전송한 파일을 GCS에 업로드하는 모듈이다. 파일의 originalname으로 파일을 저장할 수도 있지만 파일명의 보안을 위해 시간을 기반으로 파일명을 설정하였다. GCS에 저장되는 파일의 경로는 req.file.path에 저장하여 컨트롤러에서 파일이 저장된 위치를 DB에 저장할 수 있게 하였다. Node.js와 GCS를 연동하는 코드는 여기서 자세히 볼 수 있다.
const dotenv = require('dotenv').config();
const path = require('path');
const { Storage } = require('@google-cloud/storage');
const storage = new Storage({ keyFilename: process.env.GCS_KEYFILE });
/**
* GCS에 파일을 업로드한다
*/
const uploadToGCS = async (req, res, next) => {
if(req.file === undefined) {
next();
}
const fileData = req.file;
const fileName = new Date().valueOf() + path.extname(fileData.originalname);
const filePath = `https://storage.cloud.google.com/${process.env.GCS_BUCKET}/${fileName}`;
await storage
.bucket(process.env.GCS_BUCKET)
.file(fileName)
.save(fileData.buffer, {
resumable: false,
});
req.file.path = filePath;
next();
};
module.exports = uploadToGCS;
controllers/file-controller.js
클라이언트가 서버로 전송한 파일에 대한 데이터를 MySQL DB에 저장하는 컨트롤러이다. GET, PUT, DELETE 쿼리를 실행하는 함수가 각각 존재하는데 편의상 POST 요청을 처리하는 함수만 가져왔다. Body Parameter에 파일 이름, 가격, 설명 등의 데이터가 있는지, 클라이언트가 전송한 파일의 존재 유무를 체크해서 이상이 없으면 INSERT 쿼리를 실행한다.
// 데이터베이스 연결을 위한 모듈
const mysqlObj = require('../config/mysql.js');
const conn = mysqlObj.init();
// 에러 핸들러
const errorHandlers = require('../utils/error-handler.js');
const postFile = async (req, res, next) => {
const data = {};
try {
if (
!req.body.name ||
!req.body.description ||
!req.body.price ||
!req.body.seller_id ||
!req.body.category_id
) {
throw Error('BodyFormatError');
}
if (!req.file) {
throw Error('NoFileError');
}
// 클라이언트가 전송한 파일 데이터
const fileData = req.file;
// YYYY-MM-DD HH:MM:SS 형식으로 현재 시각을 저장
const nowTime = new Date(+new Date() + 3240 * 10000)
.toISOString()
.replace(/T/, ' ')
.replace(/\..+/, '');
// INSERT 쿼리
const sql = `INSERT INTO files(seller_id, category_id, name, extension, description, price, path, date)
VALUES('${req.body.seller_id}', ${req.body.category_id}, '${req.body.name}', '${fileData['mimetype']}',
'${req.body.description}', '${req.body.price}', '${fileData['path']}', '${nowTime}')`;
// 쿼리 실행 결과가 저장된다
const [result, fields] = await conn.query(sql);
// 응답에 필요한 데이터를 추가
req.body.id = result['insertId'];
req.body.extension = fileData['mimetype'];
req.body.path = fileData['path'];
req.body.date = nowTime;
// JSON 형식으로 응답
data['success'] = true;
data['data'] = req.body;
res.status(201).json(data);
} catch (e) {
// 에러가 발생하면 errorHandlers에서 처리
errorHandlers(e, res);
}
};
인증
인증 방식
클라이언트 인증정보를 저장하기 위해 별도의 저장소를 필요로 하는 세션과 달리 JWT를 이용하면 별도의 I/O 작업 없이 토큰을 검증할 수 있다는 장점이 있다. 다만 쿠키 혹은 로컬 스토리지에 저장된 토큰을 탈취당하면 토큰의 유효기간 동안은 계속 사용할 수 있으므로 보안에 취약할 수 있다.
access 토큰과 refresh 토큰을 같이 사용하면 보안 수준을 높일 수 있다. access 토큰의 유효기간은 짧게, refresh 토큰의 유효기간은 길게 설정하여 access 토큰은 대부분의 요청에, refresh 토큰은 토큰을 재발급할 때에만 사용한다. 이렇게 되면 access 토큰을 탈취당해도 토큰의 유효기간이 길지 않기 때문에 상대적으로 덜 위험하다.
refresh 토큰을 사용하면 보안성이 높아지지만 DB에 접근하므로 별도의 I/O 작업이 필요 없는 JWT의 장점을 살리기 힘들다. 하지만 토큰 재발급 요청 시에만 I/O 작업이 필요하므로 세션보다 부하가 적다!
왜 refresh 토큰이 필요한지 더 자세히 알고 싶다면 아래 블로그를 참고하길 바란다.
인증 로직
1. POST /auth/login
서버는 클라이언트가 body parameter에 담아 보낸 아이디, 비밀번호 정보를 바탕으로 로그인 성공 여부를 판단한 후 access 토큰과 refresh 토큰을 생성한다. access 토큰은 JSON 형식으로, refresh 토큰은 DB에 저장한 후 secure http-only 쿠키의 형태로 클라이언트에 전달한다.
/* 로그인 성공 여부를 판단하는 로직 */
/* accessToken과 refreshToken을 생성하는 로직 */
/* refreshToken을 DB에 저장하는 로직 */
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true
}
res.status(200).json({
'accessToken': accessToken
})
2. 토큰 저장 위치
클라이언트는 access 토큰을 지역변수에 저장하고 refresh 토큰은 secure http-only 쿠키에 저장하면 CSRF 공격과 XSS 공격을 방어할 수 있다.
3. middlewares/token-checker.js
클라이언트는 Authorization 헤더에 access 토큰을 설정하고 필요한 자원을 서버에 요청한다.
서버는 토큰 인증 미들웨어에서 클라이언트가 보낸 access 토큰을 검증하고 유효한 토큰이면 필요한 자원을 클라이언트에게 전달한다.
const checkAccessToken = async (req, res, next) => {
const data = {};
// Access 토큰이 없는 경우
if (req.headers.authorization === undefined) {
data['success'] = false;
data['message'] = 'Please set your token';
return res.status(401).json(data);
}
// 'Bearer XXXX'의 형태를 'XXXX'의 형태로 변경
const accessTokenString = (req.headers.authorization).replace('Bearer ', "");
// 토큰을 검증하는 로직
const accessToken = verifyAccessToken(accessTokenString);
// Access 토큰 만료
if(accessToken == null) {
data['success'] = false;
data['message'] = 'Your token is not valid';
return res.status(401).json(data);
}
const userId = accessToken['id'];
const typeId = accessToken['type'];
req.ID = userId;
req.TYPE_ID = typeId;
next();
}
4. POST /auth/refresh
클라이언트에서 access 토큰을 지역변수에 저장했으므로 페이지를 새로고침 하면 access 토큰이 없어져 다시 로그인을 해야 한다. 또한 access 토큰의 유효기간은 1시간 정도로 길지 않은데 유효기간이 지나면 새로운 토큰을 발급받기 위해 로그인을 다시 해야 한다. 이와 같은 불편함을 방지하기 위해 토큰의 유효기간이 지나기 전과 페이지 새로고침 시에 토큰을 재발급받을 필요가 있다.
클라이언트는 쿠키에 담긴 refresh 토큰을 서버로 보내고 서버는 클라이언트가 보낸 refresh 토큰을 검증하고 새로운 access 토큰과 refresh 토큰을 발급한다. 로그인 모듈과 로직이 거의 유사하다.
회고
- 개인 프로젝트여도 github issue로 브랜치를 따서 pull request 하는 방식을 통해 issue별로 프로젝트를 관리할 수 있었다.
- 설계부터 구현까지 조사한 자료를 노션에 정리하여 어떤 생각과 고민을 하며 프로젝트를 진행하였는지 전체적으로 정리할 수 있었다.
- API 테스트를 Postman으로만 테스트한점이 아쉽다. 테스트 코드를 작성해서 테스트를 자동화했으면 모듈을 수정하고나서 한번의 테스트를 통해 문제 여부를 파악할 수 있었을 것이다.
- 단위 테스트와 통합 테스트를 위한 테스트 코드를 작성하여 TDD(Test Driven Development) 방법론으로 다음 프로젝트를 진행해야겠다.
전체 코드는 여기서 확인할 수 있다.
Reference
- 백엔드가 이정도는 해줘야 함
- API 서버를 위해 어떤 웹 프레임워크를 사용해야 할까?(Django, Flask, Express)
- DB 스키마 설계
- REST란? REST API 디자인 가이드
- HTTP API vs REST API
- http-api-design
- Express + Multer, 어렵지 않게 사용하기
- 프론트에서 안전하게 로그인하기 (ft. React)
'백엔드' 카테고리의 다른 글
[React & Express] 파일, 텍스트 데이터 한꺼번에 주고 받기 (0) | 2022.07.31 |
---|
댓글