🎯 λ‹¨μœ„ ν…ŒμŠ€νŠΈλΆ€ν„° μ»¨ν…Œμ΄λ„ˆν™”, 둜컬 ν΄λŸ¬μŠ€ν„° λ°°ν¬κΉŒμ§€ μ „ 과정을 μžλ™ν™”ν•©λ‹ˆλ‹€.


πŸ“— Today I Learned

λ‹¨μœ„ ν…ŒμŠ€νŠΈ

λ‹¨μœ„ ν…ŒμŠ€νŠΈλŠ” 각각의 ν•¨μˆ˜λ‚˜ λ©”μ„œλ“œ λ“± μ΅œμ†Œ λ‹¨μœ„μ˜ μ½”λ“œ 쑰각이 μ˜λ„ν•œ λŒ€λ‘œ μž‘λ™ν•˜λŠ”μ§€ λ…λ¦½μ μœΌλ‘œ ν™•μΈν•˜λŠ” ν…ŒμŠ€νŠΈμž…λ‹ˆλ‹€.

λ°±μ—”λ“œμ˜ 경우 DB, μΏ ν‚€, 인증 λ“± μ™ΈλΆ€ μƒνƒœμ™€ μ—°κ²°λ˜μ–΄ μžˆλŠ” κ²½μš°κ°€ λ§Žμ•„ Mocking(μ§„μ§œ 데이터 λŒ€μ‹ , ν…ŒμŠ€νŠΈμš©μœΌλ‘œ λ§Œλ“  κ°€μ§œ λ°μ΄ν„°λ‚˜ λ™μž‘μ„ μ‚¬μš©ν•˜λŠ” 것)이 μ€‘μš”ν•©λ‹ˆλ‹€.


BE λ‹¨μœ„ ν…ŒμŠ€νŠΈ


라이브러리 μ„€μΉ˜

npm install cookie-parser jsonwebtoken
npm install --save-dev jest ts-jest supertest @types/jest @types/supertest @types/jsonwebtoken @types/cookie-parser

BE 파일 ꡬ성

πŸ“ 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) νŒŒμΌμ€ μ œμ™Έ
}

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);
  });
});



FE λ‹¨μœ„ ν…ŒμŠ€νŠΈ


라이브러리 μ„€μΉ˜

npm install --save-dev @testing-library/jest-dom @testing-library/react @testing-library/user-event

μ„€μ • 파일

  • package.json
(...)
"jest": {
 "moduleNameMapper": {
 "^@/(.+)": "<rootDir>/src/$1"
 }
}
(...)

HTML λ Œλ”λ§

  • src/utils/test/renderWithRouter.ts
import { 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 });
}

Browser Mocking

  • 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(),
});

Test Suite κ΅¬ν˜„

  • src/components/JoinForm.test.tsx
import { 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 콜백이 ν˜ΈμΆœλ˜μ§€ μ•ŠλŠ”λ‹€.", () => {
 });
});

Case 1: λ Œλ”λ§

  • 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();
});

Case 2: λ²„νŠΌ 콜백

  • 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" });
});

Case 3: 링크에 μ˜ν•œ 이동

  • src/components/JoinForm.test.tsx
test("λ‘œκ·ΈμΈν•˜κΈ° λ²„νŠΌμ„ λˆ„λ₯΄λ©΄ 둜그인 URL둜 μ΄λ™ν•œλ‹€.", () => {
 renderWithRouter(<JoinForm />);
 fireEvent.click(screen.getByText("λ‘œκ·ΈμΈν•˜κΈ°"));
 expect(window.location.pathname).toBe("/login");
});

Case 4: μž…λ ₯ 일치 검사

  • 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/Makefile
all: 
	  cat ./Makefile
test: 
	  CI=true npm test



μ»¨ν…Œμ΄λ„ˆν™”

BE μ»¨ν…Œμ΄λ„ˆν™”


Dockerfile μž‘μ„±

  • 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.yaml
version: "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
(...)



FE μ»¨ν…Œμ΄λ„ˆν™”


ν™˜κ²½ λ³€μˆ˜μ˜ 적용

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>

Dockerfile μž‘μ„±

  • frontend/Dockerfile
FROM 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"]

Makefile νƒ€κ²Ÿ μΆ”κ°€

  • frontend/Makefile
ARCH=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.yaml
services:
 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



둜컬 ν΄λŸ¬μŠ€ν„°μ— μ‹œν—˜ 배포

ν”„λ‘œλ•μ…˜ 배포 ν™˜κ²½


둜컬 ν…ŒμŠ€νŠΈ 배포 ν™˜κ²½


ν™˜κ²½ ꡬ성 및 배포 방식

κ΅¬λΆ„ν”„λ‘œλ•μ…˜λ‘œμ»¬ ν…ŒμŠ€νŠΈ
DB μ ‘κ·Ό 방식DNS (인터넷 μ ‘κ·Ό)k8s service discovery
JWT μΏ ν‚€ μ„€μ •SSL 있음(도메인 λ„€μž„κ³Ό μΈμ¦μ„œ 있음), sameSite: "lax", secure: falseSSL μ—†μŒ(λ™μΌν•œ localhostμ—μ„œ μ ‘κ·Ό), sameSite: "none", secure: true
배포 적용 방법Terraform IaCkubectl + manifest

BE μ‹€ν–‰ ν™˜κ²½ 적용

  • k8s의 ConfigMap μ‚¬μš© : DB 접속 μ •λ³΄λ‚˜ ν”„λ‘ νŠΈ 도메인 μ„€μ •(CORS)은 μ½”λ“œμ— 직접 넣기보닀 μ™ΈλΆ€ μ„€μ •(ConfigMap)으둜 κ΄€λ¦¬ν•˜λŠ” 게 λ³΄μ•ˆκ³Ό μœ μ§€λ³΄μˆ˜ μΈ‘λ©΄μ—μ„œ μœ λ¦¬ν•¨
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.yaml
apiVersion: 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



FE μ‹€ν–‰ ν™˜κ²½ 적용

  • frontend/notes-fe.yaml
apiVersion: 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/Makefile
IMG_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}



✏️ 회고

κΈ°μ‘΄μ—λŠ” μˆ˜λ™μœΌλ‘œ ν•˜λŠ” λΉŒλ“œ, 이미지 ν‘Έμ‹œ, μΏ λ²„λ„€ν‹°μŠ€ 배포 μž‘μ—…λ“€μ„ ν…ŒμŠ€νŠΈμ™€ 배포 κ³Όμ •κΉŒμ§€ μžλ™ν™”ν•˜λ©΄μ„œ μž‘μ—… 흐름이 훨씬 μˆ˜μ›”ν•œ κ±° κ°™λ‹€. μ•žμœΌλ‘œ 더 λ§Žμ€ μž‘μ—…λ“€μ„ μžλ™ν™”ν•΄λ³΄κ³  μ‹Άλ‹€.

profile
🌱개발 기둝μž₯

0개의 λŒ“κΈ€