
π― λ¨μ ν μ€νΈλΆν° 컨ν μ΄λν, λ‘컬 ν΄λ¬μ€ν° λ°°ν¬κΉμ§ μ κ³Όμ μ μλνν©λλ€.
λ¨μ ν μ€νΈλ κ°κ°μ ν¨μλ λ©μλ λ± μ΅μ λ¨μμ μ½λ μ‘°κ°μ΄ μλν λλ‘ μλνλμ§ λ 립μ μΌλ‘ νμΈνλ ν μ€νΈμ λλ€.
λ°±μλμ κ²½μ° DB, μΏ ν€, μΈμ¦ λ± μΈλΆ μνμ μ°κ²°λμ΄ μλ κ²½μ°κ° λ§μ Mocking(μ§μ§ λ°μ΄ν° λμ , ν μ€νΈμ©μΌλ‘ λ§λ κ°μ§ λ°μ΄ν°λ λμμ μ¬μ©νλ κ²)μ΄ μ€μν©λλ€.

npm install cookie-parser jsonwebtoken
npm install --save-dev jest ts-jest supertest @types/jest @types/supertest @types/jsonwebtoken @types/cookie-parser
π backend
βββ π src # λ©μΈ μμ€μ½λ
β βββ π models # μ€μ λͺ¨λΈ μ μ (DB μ°λ)
β β βββ user.ts
β βββ π models/__mocks__ # ν
μ€νΈμ© mock λͺ¨λΈ
β β βββ user.ts
β βββ π middlewares # μΈμ¦ λ±μ λ―Έλ€μ¨μ΄
β β βββ authentication.ts
β βββ π routes # API λΌμ°νΈ λ° ν
μ€νΈ νμΌ
β β βββ users.ts # μ€μ users λΌμ°ν°
β β βββ users.test.ts # ν΄λΉ λΌμ°ν°μ λν ν
μ€νΈ
β βββ app.ts # express μ± μ€μ
β βββ server.ts # μλ² μ€νλΆ (μ νμ¬ν)
βββ π jest.config.js # Jest μ€μ
βββ π tsconfig.json # TypeScript μ€μ
βββ π package.json # μμ‘΄μ± λ° μ€ν¬λ¦½νΈ
βββ π Makefile # ν
μ€νΈ μλν λͺ
λ Ήμ΄
jest.config.js : Jest ν
μ€νΈ μ€νμ μν μ€μ νμΌ /** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest", // TypeScript κΈ°λ° μ€μ
testEnvironment: "node", // Node.js νκ²½μμ ν
μ€νΈ μ€ν
};
tsconfig.json
{
"compilerOptions": {
// μλ΅
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/__mocks__/*.ts"] // ν
μ€νΈ μ€ν μ μ€μ ν
μ€νΈ λ° λͺ©(mock) νμΌμ μ μΈ
}
src/models/__mocks__/user.ts : DB μμ΄ ν
μ€νΈκ° κ°λ₯νλλ‘ λ§λ user λͺ¨λΈμ λν λͺ©(mock) κ°μ²΄ νμΌexport class User {
constructor(
public readonly id: number,
public email: string,
public encryptedPassword: string
) {}
static async findOne(params: { email: string }) {
return MOCK_USERS.find((user) => user.email === params.email) || null;
}
}
export const MOCK_USERS: User[] = []; // ν
μ€νΈμ© μ μ λ°°μ΄
__mocks__/jsonwebtoken.ts import { jest } from "@jest/globals";
const jwt = {
sign: jest.fn(({ email }: { email: string }) => "mock_jwt_" + email),
verify: jest.fn((token: string) => {
if (!token.startsWith("mock_jwt_")) throw new Error("Invalid token");
}),
decode: jest.fn((token: string) => {
if (!token.startsWith("mock_jwt_")) throw new Error("Invalid token");
return { email: token.replace("mock_jwt_", "") };
}),
};
export default jwt;
src/middlewares/authentication.ts : μΏ ν€μ μ μ₯λ JWT ν ν°μ κ²μ¦νκ³ μ¬μ©μ μ 보λ₯Ό μ‘°ννλ λ―Έλ€μ¨μ΄import jwt from "jsonwebtoken";
export async function authenticateUser(req, res, next) {
const accessToken = req.cookies["access-token"];
if (!accessToken) return res.sendStatus(401);
jwt.verify(accessToken, "secret");
const payload = jwt.decode(accessToken);
const user = await User.findOne({ email: payload.email });
if (!user) return res.sendStatus(401);
req.user = user;
next();
}
src/routes/users.test.ts : JWT μΈμ¦ λ° μ μ μ‘°ν API ν
μ€νΈimport request from "supertest";
import { app } from "../app";
import { User, MOCK_USERS } from "../models/__mocks__/user";
jest.mock("../models/user", () => jest.requireActual("../models/__mocks__/user"));
afterEach(() => {
MOCK_USERS.splice(0);
});
describe("GET /users/me", () => {
test("μ¬λ°λ₯Έ JWT μΏ ν€κ° μ€μ λμ΄μμΌλ©΄ μ μ μ 보μ ν¨κ» 200 μλ΅", async () => {
MOCK_USERS.push(new User(1, "apple@example.com", "mock_pw"));
const response = await request(app)
.get("/users/me")
.set("Cookie", "access-token=mock_jwt_apple@example.com");
expect(response.status).toBe(200);
expect(response.body).toEqual({ email: "apple@example.com" });
});
test("JWTκ° μμΌλ©΄ 401 Unauthorized", async () => {
const response = await request(app).get("/users/me");
expect(response.status).toBe(401);
});
test("JWTμ μ΄λ©μΌμ΄ μ‘΄μ¬νμ§ μλ κ²½μ° 401 Unauthorized", async () => {
const response = await request(app)
.get("/users/me")
.set("Cookie", "access-token=mock_jwt_unknown@example.com");
expect(response.status).toBe(401);
});
});

npm install --save-dev @testing-library/jest-dom @testing-library/react @testing-library/user-event
package.json(...)
"jest": {
"moduleNameMapper": {
"^@/(.+)": "<rootDir>/src/$1"
}
}
(...)
src/utils/test/renderWithRouter.tsimport { render } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
export function renderWithRouter(ui: React.ReactElement, { route = "/" } = {}) {
window.history.pushState({}, "Test page", route);
render(ui, { wrapper: BrowserRouter });
}
src/setupTests.ts// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
// window.alert ν¨μλ₯Ό λͺ¨νΉ
Object.defineProperty(window, "alert", {
writable: true,
value: jest.fn(),
});
src/components/JoinForm.test.tsximport { fireEvent, screen } from "@testing-library/react";
import { renderWithRouter } from "@/utils/test/renderWithRouter";
import { JoinForm } from "./JoinForm";
describe("JoinForm", () => {
test("μ λ λλ§λλ€.", () => {
});
test("νμμ 보λ₯Ό μ
λ ₯νκ³ νμκ°μ
λ²νΌμ λλ₯΄λ©΄ onSubmit μ½λ°±μ΄ νΈμΆλλ€.", () => {
});
test("λ‘κ·ΈμΈνκΈ° λ²νΌμ λλ₯΄λ©΄ λ‘κ·ΈμΈ URLλ‘ μ΄λνλ€.", () => {
});
test("λΉλ°λ²νΈ νμΈμ λ€λ₯΄κ² μ
λ ₯νλ©΄ alert μ°½μ΄ λ¨κ³ onSubmit μ½λ°±μ΄ νΈμΆλμ§ μλλ€.", () => {
});
});
src/components/JoinForm.test.tsx test("μ λ λλ§λλ€.", () => {
renderWithRouter(<JoinForm />);
expect(screen.getByLabelText("μ΄λ©μΌ", { selector: "input" })).toBeInTheDocument();
expect(screen.getByLabelText("λΉλ°λ²νΈ", { selector: "input" })).toBeInTheDocument();
expect(screen.getByLabelText("λΉλ°λ²νΈ νμΈ", { selector: "input" })).toBeInTheDocument();
expect(screen.getByText("νμκ°μ
", { selector: "button" })).toBeInTheDocument();
expect(screen.getByText("λ‘κ·ΈμΈνκΈ°", { selector: "a" })).toBeInTheDocument();
});
src/components/JoinForm.test.tsx test("νμμ 보λ₯Ό μ
λ ₯νκ³ νμκ°μ
λ²νΌμ λλ₯΄λ©΄ onSubmit μ½λ°±μ΄ νΈμΆλλ€.", () => {
const onSubmit = jest.fn();
renderWithRouter(<JoinForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText("μ΄λ©μΌ"), { target: { value: "foo@example.com" } });
fireEvent.change(screen.getByLabelText("λΉλ°λ²νΈ"), { target: { value: "1234" } });
fireEvent.change(screen.getByLabelText("λΉλ°λ²νΈ νμΈ"), { target: { value: "1234" } });
screen.getByText("νμκ°μ
", { selector: "button" }).click();
expect(onSubmit).toBeCalledWith({ email: "foo@example.com", password: "1234" });
});
src/components/JoinForm.test.tsx test("λ‘κ·ΈμΈνκΈ° λ²νΌμ λλ₯΄λ©΄ λ‘κ·ΈμΈ URLλ‘ μ΄λνλ€.", () => {
renderWithRouter(<JoinForm />);
fireEvent.click(screen.getByText("λ‘κ·ΈμΈνκΈ°"));
expect(window.location.pathname).toBe("/login");
});
src/components/JoinForm.test.tsx est("λΉλ°λ²νΈ νμΈμ λ€λ₯΄κ² μ
λ ₯νλ©΄ alert μ°½μ΄ λ¨κ³ onSubmit μ½λ°±μ΄ νΈμΆλμ§ μλλ€.", () => {
const alertSpy = jest.spyOn(window, "alert");
const onSubmit = jest.fn();
renderWithRouter(<JoinForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText("μ΄λ©μΌ"), { target: { value: "foo@example.com" } });
fireEvent.change(screen.getByLabelText("λΉλ°λ²νΈ"), { target: { value: "1234" } });
fireEvent.change(screen.getByLabelText("λΉλ°λ²νΈ νμΈ"), { target: { value: "123456" } });
screen.getByText("νμκ°μ
", { selector: "button" }).click();
expect(alertSpy).toBeCalledWith("λΉλ°λ²νΈκ° μΌμΉνμ§ μμ΅λλ€.");
expect(onSubmit).not.toBeCalled();
});
ν μ€νΈ μ μ°¨μ μ¬μ¬μ©μ± ν보, ν μ€νΈ λ¨κ³μ μ¬νμ±μ λμ
frontend/Makefileall:
cat ./Makefile
test:
CI=true npm test

backend/Dockerfile : Node.js λ°νμμ κΈ°λ°μΌλ‘ μ±μ μ€νν μ μλλ‘ μ€μ FROM node:18
WORKDIR /var/app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY ./build/ ./build/
ENV PORT=3031
CORS_ALLOWED_ORIGIN=(...)
EXPOSE 3031
HEALTHCHECK CMD curl --fail http://localhost:3031/healthcheck || exit 1
ENTRYPOINT ["node", "."]
backend/Makefile : λ컀 μ΄λ―Έμ§ λΉλ/λ°°ν¬, μΏ λ²λ€ν°μ€μ μ μ© λ° μμ μλνπ€ Makefileμ μ νμν κΉ?
λΉλ β νΈμ β λ°°ν¬ β μμ μμ κΉμ§ λͺ λ Ήμ΄ νλλ‘ μΌκ΄ μ²λ¦¬ν μ μμ΄ ν¨μ¨μ μΈ μλνκ° κ°λ₯ν©λλ€.
ARCH=amd64
IMG_TAG="your-image-name:latest"
all:
cat ./Makefile
test:
npm test
node:
npm ci
npm run build
image: Dockerfile node
docker build --platform=linux/${ARCH} --tag ${IMG_TAG} .
up:
docker-compose down
docker-compose build
docker-compose up -d
docker-compose.yamlversion: "3.8"
services:
db:
image: mariadb:11.2.2
environment:
MARIADB_ROOT_PASSWORD:
root
ports:
- 3032:3306
networks:
- notes
volumes:
- ./db:/var/lib/mysql
networks:
notes: {}
services:
backend:
image: (...)
environment:
DB_HOST: db
DB_PORT: 3306
ports:
- 3031:3031
networks:
- notes
(...)

React μ±μ λΉλλ ν .env νμΌμ μ½μ μ μκΈ° λλ¬Έμ, 컨ν
μ΄λ μ€ν μμ μ env.jsλ‘ λ³νν΄ μ¬μ©νλ λ°©μμ΄ νμν©λλ€.
frontend/docker-entrypoint.sh#!/bin/bash
set -e
# REACT_APP_μΌλ‘ μμνλ νκ²½λ³μλ₯Ό window._ENV κ°μ²΄μ μ μ₯νλ env.js νμΌ μμ±
echo -n "" > ./build/env.js
echo "window._ENV={" >> ./build/env.js
for key in $(compgen -v | grep ^REACT_APP_); do
echo "$key:'${!key}'," >> ./build/env.js
done
echo "}" >> ./build/env.js
# μ± μλ² μμ
exec serve -s build
frontend/public/index.html<!DOCTYPE html>
<html lang="en">
<head>
(...)
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="%PUBLIC_URL%/env.js"></script>
frontend/DockerfileFROM node:18
WORKDIR /var/app
RUN npm install -g serve
COPY build ./build
EXPOSE 3000
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod u+x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
frontend/MakefileARCH=amd64
IMG_TAG="..."
all:
cat ./Makefile
test:
CI=true npm test
node:
npm ci
npm run build
image: Dockerfile node
docker build --platform=linux/${ARCH} --tag ${IMG_TAG} .
docker-compose.yamlservices:
frontend:
image: (...)
environment:
REACT_APP_API_BASE_URL: http://localhost:3031
ports:
- 3000:3000
networks:
- notes
(...)
networks:
notes: {}
up:
docker-compose down
docker-compose build
docker-compose up -d
νλ‘ νΈμλ URL: http://localhost:30030
λ°±μλ URL: http://localhost:30031
λ°μ΄ν°λ² μ΄μ€: notes-db.db:3306
| κ΅¬λΆ | νλ‘λμ | λ‘컬 ν μ€νΈ |
|---|---|---|
| DB μ κ·Ό λ°©μ | DNS (μΈν°λ· μ κ·Ό) | k8s service discovery |
| JWT μΏ ν€ μ€μ | SSL μμ(λλ©μΈ λ€μκ³Ό μΈμ¦μ μμ), sameSite: "lax", secure: false | SSL μμ(λμΌν localhostμμ μ κ·Ό), sameSite: "none", secure: true |
| λ°°ν¬ μ μ© λ°©λ² | Terraform IaC | kubectl + manifest |
apiVersion: v1
kind: ConfigMap
metadata:
name: notes-be-config
namespace: prgms-notes
data:
DB_HOST: notes-db.db
DB_USER: prgms
DB_PASSWD: <Database password for user
prgms>
DB_NAME: prgms_notes
CORS_ALLOWED_ORIGIN: http://localhost:30030
backend/notes-be.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: notes-be
namespace: prgms-notes
spec:
replicas: 1
selector:
matchLabels:
run: notes-be
template:
metadata:
labels:
run: notes-be
spec:
containers:
- name: notes-backend
image: <Image to pull>
imagePullPolicy: Always
envFrom:
- configMapRef:
name: notes-be-config # νκ²½λ³μ μ£Όμ
(ConfigMap)
apiVersion: v1
kind: Service
metadata:
name: notes-be
labels:
run: notes-be
namespace: prgms-notes
spec:
type: NodePort
selector:
run: notes-be
ports:
- port: 3031
targetPort: 3031
nodePort: 30031
frontend/notes-fe.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: notes-fe
namespace: prgms-notes
spec:
selector:
matchLabels:
run: notes-fe
replicas: 1
template:
metadata:
labels:
run: notes-fe
spec:
containers:
- name: notes-frontend
image: <Image to pull>
imagePullPolicy: Always
ports:
- containerPort: 3000
env:
- name:
REACT_APP_API_BASE_URL
value: http://localhost:30031
apiVersion: v1
kind: Service
metadata:
name: notes-fe
labels:
run: notes-fe
namespace: prgms-notes
spec:
type: NodePort
selector:
run: notes-fe
ports:
- port: 3000
nodePort: 30030
frontend/MakefileIMG_TAG="..."
(...)
image: Dockerfile node
docker build --platform=linux/${ARCH} --tag ${IMG_TAG} .
push:
docker push ${IMG_TAG}
deploy: notes-fe.yaml
kubectl apply βf notes-fe.yaml
undeploy:
kubectl delete βf notes-fe.yaml
clean:
\rm βrf build
docker rmi ${IMG_TAG}
κΈ°μ‘΄μλ μλμΌλ‘ νλ λΉλ, μ΄λ―Έμ§ νΈμ, μΏ λ²λ€ν°μ€ λ°°ν¬ μμ λ€μ ν μ€νΈμ λ°°ν¬ κ³Όμ κΉμ§ μλννλ©΄μ μμ νλ¦μ΄ ν¨μ¬ μμν κ±° κ°λ€. μμΌλ‘ λ λ§μ μμ λ€μ μλνν΄λ³΄κ³ μΆλ€.