Skip to main content

간단한 Mock API 구현

구현 계기

프로젝트를 진행하다 보니 백엔드와 프론트엔드 간 진행 속도가 차이가 났다. 아무래도 백엔드에서는 ERD부터 제대로 설계를 하려고 하고 있었고, 여러 고민이 오가면서 다소 지체가 됐다.

그런데 웹에서는 당장 설문조사 목록 페이지를 보여줘야 했는데, 그러려면 임시 데이터라도 필요한 상황이었다. 프론트를 담당하는 팀원들을 위해, 임시로 구성된 API라도 빠르게 제공할 필요를 느꼈고, 그렇게 구현을 시작했다.


사용한 기술스택

  • 배포: fly.io
    • 가장 먼저, Heroku의 무료 지원이 종료됨에 따라 자연스레 옮기게 된 fly.io를 사용해 배포하면 좋겠다고 판단했다.
  • 서버: Node.js + Express.js + TypeScript
    • 빠르게 만들어야 하다보니, 가장 익숙한 Node.js 환경에서 Express 라이브러리를 활용해 구현하면 가장 빠를 것 같다고 판단했다.
    • JavaScript를 사용하려다 TypeScript가 익숙한 것도 있었고, human-error를 빠르게 잡으면서 구현하는게 좋을 것 같아 선택했다.
  • 무작위 데이터: Python + lorem
    • 단순 GET 메서드로 조회만 가능하게끔 서버를 구성하려 했고, 이러한 무작위 데이터들을 만들려면 가장 빨라보이는 것이 Python이었다.

구현 방향

빠르게 구현하고 배포를 하긴 하지만, 임시로 구성된 ERD 모델의 변화에 빠르게 대응할 수 있도록 구성하고, 기본적인 예외처리를 진행하려 했다.


구현

무작위 데이터 생성

API에서 별다른 데이터베이스 연결 없이, GET 메서드를 통해 json 파일을 불러오는 방식으로 구현하기로 했다. 데이터는 약 100개를 생각했고, 이러한 무작위 데이터를 생성하기위해 Python의 lorem 라이브러리 도움을 받았다.

# lorem_example.py

import lorem

print(lorem.sentence())
print(lorem.paragraph())

위와 같이 아주 간단한 코드로 "lorem ipsum..." 문장을 쉽게 생성할 수 있다.

코드는 다음과 같이 구성했다.

# 1. 필요한 library 불러오기

import os
import json
import random
import lorem
import uuid
from datetime import datetime, timedelta
# 2. 유틸함수 선언

def format_string(num: int) -> str:
"""앞자리에 0을 붙여 숫자의 형태를 변환하는 함수 (최대 3자리)

Parameters
----------
num (int): 변환할 숫자

Returns
-------
str: 수정된 숫자를 문자열 형태로 반환 (최대 3자리)
"""
return str(num).zfill(3)


def generate_random_num(size: int) -> int:
"""size 만큼의 자릿수에 해당하는 무작위 숫자를 반환하는 함수

Parameters
----------
size (int): 자릿수

Returns
-------
int: 자릿수 만큼의 무작위 숫자를 반환
"""
return random.randint(10 ** (size - 1), 10 ** size - 1)


def pick_random_items(items: list, num_of_items: int) -> list:
"""주어진 items 리스트에서 무작위로 num_of_items만큼 선택해 반환하는 함수

Parameters
----------
items (list): 선택할 목록이 담긴 리스트
num_of_items (int): 선택할 개수

Returns
-------
list: 선택할 개수만큼의 선택된 아이템 리스트
"""
return random.sample(items, num_of_items)


def generate_options() -> list:
"""무작위로 옵션 리스트를 반환

Returns
-------
list: 무작위로 생성된 2~5 개의 옵션이 담긴 리스트
"""
num_options = random.randint(2, 5)
return [{"option_number": i+1, "text": lorem.sentence()} for i in range(num_options)]


def generate_questions(num: int) -> list:
"""질문의 종류에 따라 질문을 생성한다. 질문의 종류는 총 네 가지가 있다: LONG_ANSWER, SHORT_ANSWER, SINGLE_CHOICE, MULTIPLE_CHOICES

Parameters
----------
num (int): 질문의 아이디(QuestionBank Entity의 ID)

Returns
-------
list: 무작위로 생성된 질문 리스트를 반환
"""
questions = []
for i in range(random.randint(1, 10)):
question_type = random.choice(["LONG_ANSWER", "SHORT_ANSWER", "SINGLE_CHOICE", "MULTIPLE_CHOICES"])
question = {
"question_id": num,
"type": question_type,
"title": lorem.sentence(),
"description": lorem.paragraph(),
"options": None
}

if question_type == "SINGLE_CHOICE" or question_type == "MULTIPLE_CHOICES":
question["options"] = generate_options()

questions.append(question)
num += 1
return questions

이렇게 무작위 데이터 생성에 필요한 유틸함수를 정의했으면, 이제 본격적으로 파일을 생성하면 된다.

# 3. 설문조사 데이터 생성 (1번 ~ 100번) (설문조사 상세정보 포함)

authentication_methods = ["KAKAO", "GOOGLE", "ID", "MOBILE_PHONE", "DRIVER_LICENSE", "WEBMAIL"]

now = datetime.now()
time_formatter = '%Y-%m-%dT%H:%M:%S'
created_date = now.strftime(time_formatter)

question_id = 1

for i in range(1, 101):
survey_id = str(uuid.uuid4())
ended_date = now + timedelta(days=random.randint(1, 101))
formatted_ended_date = ended_date.strftime(time_formatter)

with open("survey-{}.json".format(survey_id), "w", encoding="utf-8") as file:
temp_dict = {
"survey_id": survey_id,
"author_id": generate_random_num(4),
"title": lorem.sentence(),
"description": lorem.paragraph(),
"created_date": created_date,
"ended_date": formatted_ended_date,
"required_authentications": pick_random_items(authentication_methods, random.randint(0, len(authentication_methods))),
"questions": generate_questions(question_id)
}

question_id += len(temp_dict["questions"])
json.dump(temp_dict, file, indent=2, ensure_ascii = False)
# 4. 설문조사 리스트 목록 데이터 생성 (페이지별로 보여주기 위해)

survey_list = []

folder_path = '.'
file_list = os.listdir(folder_path)
json_files = [file for file in file_list if file.endswith('.json')]

for json_file in json_files:
with open(os.path.join(folder_path, json_file), "r", encoding="utf-8") as file:
data = json.load(file)

del data['questions']
survey_list.append(data)

with open("survey-list.json", "w", encoding="utf-8") as file:
json.dump(survey_list, file, indent=2, ensure_ascii = False)

API 구성

import * as dotenv from "dotenv";
import * as express from "express";
import * as cors from "cors";
import { router } from "./routes";

dotenv.config();

const PORT = process.env.PORT || 5000;

const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());
app.use("/api", router);

app.on("error", (err) => {
console.log(err);
});

app.get("/", (req: express.Request, res: express.Response) => {
res.send({ health_check: true });
});

app.listen(PORT, () => {
console.log(`Server is listening on PORT ${PORT}...`);
});

여기서 router에는 다음과 같이 선언해두었다.

// src/routes/survey/index.ts

import * as express from "express";
import {
getSurvey,
getSurveyList,
getSurveyPageList,
} from "../../controllers/survey.controller";

export const surveyRouter = express.Router();

surveyRouter.get("/all", getSurveyList);
surveyRouter.get("/:id", getSurvey);
surveyRouter.get("/", getSurveyPageList);

그리고 controller 코드는 다음과 같이 작성했다.

// src/controllers/survey.controller.ts

import { Request, Response } from "express";
import { padNumber } from "../utils";
import * as fs from "fs";
import * as path from "path";

/**
*
* Retrieve the list of all surveys.
*
* @group Survey - Operations related to survey management
* @route GET /api/survey/all
* @returns {Array.<Survey>} An array of all surveys.
* @throws {Error} 500 - [ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.
*/
export const getSurveyList = (req: Request, res: Response): void => {
try {
// Read survey list file
const filePath = path.join(__dirname, "..", "data", "survey-list.json");
const rawData = fs.readFileSync(filePath, "utf8");
const surveyListData = JSON.parse(rawData);

res.json(surveyListData);
} catch (error) {
console.log("[ERROR] 설문 리스트를 가져오는 데에 실패하였습니다:", error);
res
.status(500)
.send("[ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.");
}
};

/**
*
* Get a list of surveys with pagination.
*
* @group Survey - Operations related to survey management
* @route GET /api/survey
* @param {number} page.query - The page number to get (from 1 to 10)
* @returns {Array.<Object>} 200 - An array of survey objects
* @throws {string} 400 - [ERROR] 1 페이지부터 시작해야 합니다. | [ERROR] 10 페이지를 초과할 수 없습니다.
* @throws {string} 500 - [ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.
*/
export const getSurveyPageList = (req: Request, res: Response): void => {
try {
const page = parseInt(req.query.page?.toString() || "1", 10);
const perPage = 8;

// Extract current page data
const startIndex = (page - 1) * perPage;
const endIndex = Math.min(startIndex + perPage, 100);

if (startIndex > 96) {
res.status(400).send("[ERROR] 13 페이지를 초과할 수 없습니다.");
return;
}
if (startIndex < 0) {
res.status(400).send("[ERROR] 1 페이지부터 시작해야 합니다.");
return;
}

// Read survey list file
const filePath = path.join(__dirname, "..", "data", "survey-list.json");
const rawData = fs.readFileSync(filePath, "utf8");
const surveyListData = JSON.parse(rawData);
const pageData = surveyListData.slice(startIndex, endIndex);

res.json(pageData);
} catch (error) {
console.log("[ERROR] 설문 리스트를 가져오는 데에 실패하였습니다:", error);
res
.status(500)
.send("[ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.");
}
};

/**
*
* Retrieve a survey data by ID
*
* @group Survey - Operations related to survey management
* @route GET /api/survey/{id}
* @param {string} id.path.required - The ID of the survey to retrieve
* @returns {object} 200 - Single survey object
* @throws {string} 400 - [ERROR] 해당 설문조사 파일이 존재하지 않습니다.
* @throws {string} 500 - [ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.
*/
export const getSurvey = (req: Request, res: Response): void => {
try {
const { id } = req.params;

// Read survey file
const filePath = path.join(__dirname, "..", "data", `survey-${id}.json`);

let rawData = "";

try {
rawData = fs.readFileSync(filePath, "utf8");
} catch (error) {
console.log("[ERROR] 해당 설문조사 파일이 존재하지 않습니다:", error);
res.status(400).send("[ERROR] 해당 설문조사 파일이 존재하지 않습니다.");
}
const surveyData = JSON.parse(rawData);

res.json(surveyData);
} catch (error) {
console.log("[ERROR] 설문 조사를 조회하는 데에 실패하였습니다:", error);
res
.status(500)
.send("[ERROR] 내부 서버 오류: 페이지를 표시할 수 없습니다.");
}
};

배포

코드가 정상동작하는 것을 확인했으니, 이제 배포해서 빠르게 프론트 팀원들에게 공유를 해야 했다. Heroku의 무료지원이 2022년 11월 28일부로 종료가 됨에 따라, 대체제로 fly.io를 찾을 수 있었다.

fly.io에 배포하기 위해서는 Dockerfile과 설정파일인 fly.toml 파일이 필요하다. $ fly launch 명령어를 실행하면 이 두 가지 파일을 자동으로 생성해줘서 아주 간편하다. 물론 간혹 설정이 잘못된 경우가 있을 수 있어 다시 한번 확인을 해볼 필요는 있다. (가령 PORT 등)

구성된 파일은 다음과 같다.

# Dockerfile

FROM debian:bullseye as builder

ARG NODE_VERSION=16.18.1

RUN apt-get update; apt install -y curl python-is-python3 pkg-config build-essential
RUN curl https://get.volta.sh | bash
ENV VOLTA_HOME /root/.volta
ENV PATH /root/.volta/bin:$PATH
RUN volta install node@${NODE_VERSION}

#######################################################################

RUN mkdir /app
WORKDIR /app

# NPM will not install any package listed in "devDependencies" when NODE_ENV is set to "production",
# to install all modules: "npm install --production=false".
# Ref: https://docs.npmjs.com/cli/v9/commands/npm-install#description

ENV NODE_ENV production

COPY . .

RUN npm install --production=false && npm run build
FROM debian:bullseye

LABEL fly_launch_runtime="nodejs"

COPY --from=builder /root/.volta /root/.volta
COPY --from=builder /app /app

WORKDIR /app
ENV NODE_ENV production
ENV PATH /root/.volta/bin:$PATH

CMD [ "npm", "run", "start" ]
# fly.toml

app = "capstone-mock-api"
kill_signal = "SIGINT"
kill_timeout = 5
primary_region = "nrt" # `fly launch`를 실행하면 호스팅되는 지역을 선택할 수 있다.
processes = []

[env]
PORT = "5000" # 만약 지정하려는 포트가 있다면 여기서 정할 수 있다.

[experimental]
auto_rollback = true

[[services]]
http_checks = []
internal_port = 5000 # 내부포트번호를 지정할 수 있다.
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"

[[services.ports]]
force_https = true
handlers = ["http"]
port = 80

[[services.ports]]
handlers = ["tls", "http"]
port = 443

[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"

만약 설정이 완료 됐으면 다음의 명령어로 서비스를 배포하면 된다.

$ fly deploy

배포된 코드

https://github.com/SeiwonPark/capstone-mock-api에서 확인할 수 있다.

Related Links