서버 개발 시작!

SteadySlower·2021년 12월 22일
0
post-thumbnail

서버 소개

컴동쌤은 iOS 개발자 지망생입니다. 여태까지한 공부는 대부분 iOS였습니다. 하지만 예전에 잠깐 서버를 배운 적이 있습니다. 그래서 nodejs로 간단한 서버를 만들어서 지금까지 개발한 앱에 적용해봅시다. 물론 아주 간단한 서버이고 서버에 대해서는 잘 모르기 때문에 코드도 iOS 만큼 자세하게 소개해드리지는 못할 것 같고요. 기능 위주로 간단하게만 소개해드릴 생각입니다.

단순화 계획

TypeScript?

컴동쌤은 타입스크립트를 배웠습니다만... 애초에 공부한 이유가 OOP에 대한 개념을 익혀서 Swift를 더 잘하기 위함이었고 (Swift로 OOP의 기초부터 가르치는 수업을 찾는 것이 너무 힘들더군요ㅠㅠ) 서버를 만들 때 적용해본 적은 없습니다.

그래서 이번에는 과감하게 TypeScript를 배제하고 서버를 만들어보겠습니다. 고민을 많이 해보았는데 문법과 개념을 알아도 서버에 적용하기는 쉽지 않을 것 같더라고요.

그래서 이번에는 순수 js로만 만들고 나중에 별도의 프로젝트를 통해 차근차근 고쳐서 써보겠습니다.

AWS?

컴동쌤은 서버를 배우면서 AWS도 배웠었습니다. 도메인을 구매해서 연결하고 ec2 인스턴스를 만들고 RDS를 통해 DB를 만들어서 서버를 만들었습니다. 하지만 이번 프로젝트에서는 과감하게 생략하겠습니다. 서버는 로컬 호스트를 통해서 돌리고 DB도 제 노트북에 설치할 예정입니다.

보안과 로그

CORS이나 Header에 관련된 보안과 서버에 로그를 남기는 것도 과감히 생략하겠습니다. 기능에 집중할 예정입니다.

계획

  1. 1번 API로 구현할 것은 학년별 반수, 그리고 반별 학생 수를 반환하는 함수입니다. 학생 조회 화면에서 버튼을 구성하는데 사용합니다.
  2. 2번 API로 생각한 것은 바로 학생 조회 API입니다. 학년-반-번호를 가지고 학생의 이름과 사진을 조회하는 API입니다.
  3. 조회 API를 만들기 위해서는 조회하기 위한 DB가 필요합니다. MYSQL로 DB 구현하도록 하겠습니다.
  4. 1번, 2번 API를 만들기 위해서는 추가적으로 필요한 것이 학생들의 더미데이터입니다. 간단한 스크립트를 사용해서 DB에 더미데이터를 만들어 주도록 하겠습니다.

DB 구현

테이블 설계

  1. id는 고민 끝에 학년도+학년+반+번호를 나란히 붙인 값으로 하기로 했습니다. 2021학년도에 1학년 1반 1번인 학생은 202110101이 되는 식입니다. 이유는 아래와 같습니다.
    1. 2021학년도에 1학년 1반 1번인 학생인 학생은 단 한 사람 밖에 없습니다. 유일무이한 값을 가져야 하는 primary key로서의 역할을 충분히 할 수 있습니다.
    2. 쿼리 조회의 편의성과 성능이 증가합니다. 조회하는 사람 입장에서는 학년, 반, 번호로 조회하는 것 보다 더 간단한 쿼리문으로 조회할 수 있고 DB입장에서는 id가 index의 역할을 하므로 학년, 반, 번호를 일일히 조회하는 것보다 더 높은 성능으로 쿼리를 실행합니다.
    3. 아무런 의미 없는 auto increment를 통해 만들어진 무의미한 id와는 다르게 id만 보고도 개발자는 어떤 학생을 가리키는 것인지 알 수 있습니다.
  2. 학년, 반, 번호는 Int, 이름은 VARCHAR입니다.
  3. profileImageURL은 Null값을 허용합니다.
  4. status는 학생 데이터의 상태, createdAt, updatedAt은 각각 생성된 날짜, 수정된 날짜를 의미합니다.
  5. 프로필 이미지는 랜덤 이미지를 제공하는 서비스를 사용했습니다.

더미데이터 만들기

스크립트를 통해서 더미 데이터를 만들어서 DB에 넣어줍시다. 랜덤 이름 생성하는 함수를 만들었고 각 반 별로 학생 수가 다르도록 설계했습니다. 아래 스크립트를 grade를 바꾸어서 3번 실행하여 더미 데이터를 만듭시다.

 const { pool } = require('../config/database.js')

// id 만들어주는 함수
function idMaker(student) {
    const year = "2021"
    const grade = student.grade
    const classNumber = String(student.classNumber) 
    const number = String(student.number) 

    return `${year}${grade}${classNumber.padStart(2, '0')}${number.padStart(2, '0')}`
}

// 학생 데이터 insert하는 함수
async function insertStudent(connection, student) {
    const id = idMaker(student)
    const url = "https://picsum.photos/200"
    params = [id, student.grade, student.classNumber, student.number, student.name, url]
    const insertStudentQuery = `
            INSERT INTO Student(id, grade, classNumber, number, name, profileImageURL)
            VALUES (?, ?, ?, ?, ?, ?);
        `;
    const insertDealRow = await connection.query(
        insertStudentQuery,
        params
    );
    return insertDealRow;
};

// 랜덤 이름을 만드는 함수
function createRandomName() {
    const lastNames = ["김", "이", "박", "최", "윤", "문", "성", "설", "정", "구", "양", "민", "고", "홍", "장", "강"]
    const firstNames = ["철수", "영수", "민수", "민준", "준혁", "상혁", "정혁", "준서", "문규", "강호", "상민", "철민", "성혁", "민혁", "민규", "영희", "민희", "서아", "시아", "설아", "수아", "지민", "민서", "민지", "유빈", "혜림", "규리", "윤희", "윤지", "예지"]
    const randomLastNameIdx = Math.floor(Math.random() * lastNames.length);
    const randomfirstNameIdx = Math.floor(Math.random() * firstNames.length);
    return `${lastNames[randomLastNameIdx]}${firstNames[randomfirstNameIdx]}`
}

// 더미 데이터 넣는 함수
createDummyStudents = async function (student) {
    const connection = await pool.promise().getConnection(async (conn) => conn);
    const grade = 3
    for (let i = 1; i < 15; i++) {
        let classNumber = i
        for (let j = 1; j < 30 + i; j++) {
            let number = j
            let name = createRandomName()
            let student = { grade: grade, classNumber: classNumber, number: number, name: name}
            const result = await insertStudent(connection, student)
            console.log(result)
        }
    }
    connection.release();
    return
};

createDummyStudents()
console.log("finished")

서버 구현

기본 틀

서버는 router-controller-provider-dao 구조를 가지도록 만들겠습니다.

app.js

실습의 생산성을 위해 최소한의 미들웨어만 사용합니다. 말씀 드렸듯이 helmet 같은 보안 관련 미들웨어, morgan 같은 로그 관련 미들웨어를 생략했습니다.

const express = require('express');
const studentRouter = require('./routes/student.js');

const app = express();

app.use(express.json()); 
app.use(express.urlencoded({ extended: false }));

app.use('/students', studentRouter);

// 404 에러 처리
app.use((req, res, next) => {
    res.status(404).send('Not Found');
});

// 서버 에러 처리
app.use((err, req, res, next) => {
    console.error(err);
    res.sendStatus(500);
});

app.listen(8080);

response 정의

클라이언트 개발자를 위해서 response에 담을 메타 데이터를 미리 정의합니다.

// 성공 및 에러 정의
const response = ({isSuccess, code, message}, result) => {
	return {
	     isSuccess: isSuccess,
	     code: code,
	     message: message,
	     result: result
	}
};

const errResponse = ({isSuccess, code, message}) => {
	 return {
     isSuccess: isSuccess,
     code: code,
     message: message
   }
};

module.exports = { response, errResponse };
// 메시지 모아 놓기
module.exports = {

  // Success
  SUCCESS : { "isSuccess": true, "code": 1000, "message":"성공" },

  // Error
  DB_ERROR : { "isSuccess": false, "code": 4000, "message": "데이터 베이스 에러"},
  SERVER_ERROR : { "isSuccess": false, "code": 4001, "message": "서버 에러"},

}

1번 API (전체 반 갯수, 학생 수 조회)

controller

/**
 * API No. 1
 * API Name : 학생 조회 API
 * [GET] /students/totalNumber
 */

 exports.getTotalNumber = async function (req, res) {

    const result = await studentsProvider.getTotalNumber()

    return res.send(response(responses.SUCCESS, result));

};

provider

먼저 학년 별로 반 갯수를 찾아서 넣어 오브젝트를 만듭니다. 그 데이터를 이용해서 다시 반 갯수만큼 반복해서 반별 학생 수를 찾습니다.

// 학년별 반, 반별 학생수 찾기
exports.getTotalNumber = async function (studentID) {
  const connection = await pool.promise().getConnection(async (conn) => conn);
	
  // 학년별 반 갯수 찾기
  let numOfClassResult = {}
  for (let grade = 1; grade < 4; grade++) {
    const numOfClass = await studentDao.countNumOfClass(connection, grade)
    numOfClassResult[grade] = numOfClass[0].cnt
  }

  // 반별 학생 수 찾기
  let numOfStudentsResult = {1: {}, 2: {}, 3: {}}
  for (let grade = 1; grade < 4; grade++) {
    const numOfClassOfGrade = numOfClassResult[grade]
    for (let classNum = 1; classNum <= numOfClassOfGrade; classNum++) {
      params = [grade, classNum]
      const numOfStudents = await studentDao.countNumOfStudent(connection, params)
      numOfStudentsResult[grade][classNum] = numOfStudents[0].cnt
    }
  }

  // 결과 취합
  result = {
    numOfClasses: numOfClassResult,
    numOfStudents: numOfStudentsResult
  }

  connection.release();
  return result;
};

dao

count를 이용해서 갯수를 셉니다. 특히 반은 여러 같은 값이 있으므로 DISTINCT를 통해 중복된 것을 제거해야 합니다.

exports.countNumOfClass = async function (connection, grade) {
  const query = `
    SELECT COUNT(DISTINCT classNumber) AS cnt
    FROM Student
    WHERE grade = ?;
  `;
  const [rows] = await connection.query(query, grade);
  return rows;
}

exports.countNumOfStudent = async function (connection, params) {
  const query = `
    SELECT COUNT(*) AS cnt
    FROM Student
    WHERE grade = ? AND classNumber = ?;
  `;
  const [rows] = await connection.query(query, params);
  return rows;
}

2번 API (특정 학생 조회)

controller

쿼리 스트링을 통해 받아온 id를 이용합니다. 클라이언트 측에서 공유된 규칙에 따라 id를 포함해서 요청을 보낼 것입니다.

/**
 * API No. 2
 * API Name : 학생 조회 API
 * [GET] /students/search
 */
 exports.getStudentInfo = async function (req, res) {

    /**
     * Query String: studentID
     */

    const studentID = req.query.id;

    // TODO: 학번 형식적 검증

    const result = await studentsProvider.getStudentInfo(studentID)

    return res.send(response(responses.SUCCESS, result));

};

provider

// 학번으로 학생 찾기
exports.getStudentInfo = async function (studentID) {
    const connection = await pool.promise().getConnection(async (conn) => conn);
    const selectStudentResult = await studentDao.selectStudent(connection, studentID);
    connection.release();
    return selectStudentResult[0];
};

dao

exports.selectStudent = async function (connection, studentID) {
  const query = `
    SELECT *
    FROM Student
    WHERE id = ?;
    `;
  const [rows] = await connection.query(query, studentID);
  return rows;
}

결과

1번 API

localhost:8080/students/totalNumber

{
    "isSuccess": true,
    "code": 1000,
    "message": "성공",
    "result": {
        "numOfClasses": {
            "1": 10,
            "2": 11,
            "3": 14
        },
        "numOfStudents": {
            "1": {
                "1": 30,
                "2": 31,
                "3": 32,
                "4": 33,
                "5": 34,
                "6": 35,
                "7": 36,
                "8": 37,
                "9": 38,
                "10": 39
            },
            "2": {
                "1": 30,
                "2": 31,
                "3": 32,
                "4": 33,
                "5": 34,
                "6": 35,
                "7": 36,
                "8": 37,
                "9": 38,
                "10": 39,
                "11": 40
            },
            "3": {
                "1": 30,
                "2": 31,
                "3": 32,
                "4": 33,
                "5": 34,
                "6": 35,
                "7": 36,
                "8": 37,
                "9": 38,
                "10": 39,
                "11": 40,
                "12": 41,
                "13": 42,
                "14": 43
            }
        }
    }
}

2번 API

localhost:8080/students/search?id=202131101

{
    "isSuccess": true,
    "code": 1000,
    "message": "성공",
    "result": {
        "id": 202131102,
        "grade": 3,
        "classNumber": 11,
        "number": 2,
        "name": "구예지",
        "profileImageURL": "https://picsum.photos/200",
        "status": "재학",
        "createdAt": "2021-12-21T02:09:11.000Z",
        "updatedAt": "2021-12-21T02:09:11.000Z"
    }
}

마치며...

  1. 오랜만에 서버를 하는데 옛 기억이 나는듯 마는듯 어렵군요... 하하
  2. 다음에는 위 서버를 iOS 앱에 연결해보겠습니다.
profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글