arch-spatula / arch-spatula.github.io

Arch-Spatula의 레시피
https://arch-spatula.github.io/
3 stars 0 forks source link

[Draft] 22주차 #56

Closed arch-spatula closed 1 year ago

arch-spatula commented 1 year ago

글쓰기 주제

blog/2023-06-04

참고할 자료

arch-spatula commented 1 year ago
image
arch-spatula commented 1 year ago

2023 05 29 테스트 설정

플래시 카드 설치 & 설정

저는 패키지 매니저는 pnpm을 사용했고 리액트는 vite로 설치했습니다. 또 우리나라의 자부심 swr을 활용 버전으로 설치했습니다.

chore/init

설치만 하기 위해 브랜치 생성하기

open https://github.com/arch-spatula/flash-card-frontend/pull/new/chore/init

vitest

공식 홈페이지 예시 레포 react-testing-lib-msw를 활용합니다.

pnpm add -D vitest

일단 설치합니다.

Setting up vite, React, TypeScript, eslint, prettier, vitest, testing-library and react-router

위 영상이 제가 이번 프로젝트에 사용할 기술스택과 유사해서 활용하겠습니다.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
});

vite으로 설치한 리액트의 기본 설정이었습니다.

/// <reference types="vitest" />
/// <reference types="vite/client" />

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/setup.ts"],
  },
});

공식문서와 관련된 레포에서 알려준 코드입니다.

둘을 잘 조합하면 다음과 같이 작성하면 됩니다.

/// <reference types="vitest" />
/// <reference types="vite/client" />

import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/setup.ts"],
  },
});

이렇게 모듈을 편집하고나서 ./srcsetup.ts 파일을 만들도록 합니다. 모든 테스트 실행전에 실행될 파일입니다.

여기까지 되면 테스트 러너가 설치 된 것입니다.

RTL 설치

pnpm i -D @testing-library/react
pnpm i -D @testing-library/jest-dom

이 명령으로 설치하면 됩니다.

{
  "name": "@vitest/example-react-testing-lib-msw",
  "private": true,
  "scripts": {
    "build": "tsc && vite build",
    "coverage": "vitest run --coverage",
    "dev": "vite",
    "preview": "vite preview",
    "test": "vitest",
    "test:ui": "vitest --ui"
  },
  "dependencies": {
    "@apollo/client": "^3.6.6",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@testing-library/react": "^12.1.5",
    "@testing-library/user-event": "^13.5.0",
    "@types/react": "^17.0.45",
    "@types/react-dom": "^17.0.17",
    "@vitejs/plugin-react": "^1.3.2",
    "@vitest/ui": "latest",
    "cross-fetch": "^3.1.5",
    "jsdom": "latest",
    "msw": "^1.2.1",
    "vite": "latest",
    "vitest": "latest"
  },
  "stackblitz": {
    "startCommand": "npm run test:ui"
  }
}

공식 문서에서는 jsdom을 설치하고 있습니다. 하지만 저는 jsdom을 잘 모르기 때문에 일단 jest-dom을 설치했습니다.

여기까지는 설치입니다. 다음은 설정입니다.

Using Testing Library jest-dom with Vitest

위 아티클이 고전인듯합니다. 이 고전아티클을 활용하면 설정할 수 있습니다.

// ./src/setup.ts
import matchers from "@testing-library/jest-dom/matchers";
import { expect } from "vitest";

expect.extend(matchers);

설치 직후에 matchers에는 이런 에러가 있을 것입니다.

Could not find a declaration file for module '@testing-library/jest-dom/matchers'.

/* eslint-disable import/no-extraneous-dependencies */
import matchers from "@testing-library/jest-dom/matchers";
import { expect } from "vitest";

expect.extend(matchers);

튜토리얼에서 알려준 그대로 하면 여전히 에러가 발생합니다.

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import matchers from "@testing-library/jest-dom/matchers";
import { expect } from "vitest";

expect.extend(matchers);

이렇게 설정하면 에러가 발생하지 않습니다. 하지만 단기적인 해결책입니다.

하지만 pnpm으로 설치했으면 아래 설치하면 문제가 해결됩니다.

pnpm add -D @types/testing-library__jest-dom

그리고 setup.ts에 lint 무시도 필요 없어집니다.

Property 'toBeInTheDocument' does not exist on type 'Matchers'

구글링 한방에 찾아서 다행입니다.

import matchers from "@testing-library/jest-dom/matchers";
import { expect } from "vitest";

expect.extend(matchers);

test script를 돌렸을 때 결국 필요했습니다. 다음 jsdom을 설치해주시기 바랍니다.

pnpm i -D jsdom

tsx 테스트 코드

import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import App from "./App";

describe("App", () => {
  it("should renders hello world!", () => {
    // Arrange
    render(<App />);

    // Act(선택적)
    // 현재는 생략

    // Expect
    expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
      "hello world!"
    );
  });
});

이렇게 하면 통과하는 테스트코드가 작성된 것입니다.

import { useState } from "react";
import "./App.css";

function App() {
  return <h1>hello world!</h1>;
}

export default App;

이렇게 작성하면 테스트가 통과 될 것입니다. 이제 설치 후 설정까지 되어서 커밋 한 번 합니다.

About Queries - Priority - testing-library

리액트 테스트 코드 베스트 프렉티스

테스팅 라이브러리는 테스트 코드 작성에 대한 의견이 있습니다. DOM 쿼리하는 우선순위를 준수할 것을 베스트 프렉티스라고 합니다. 이유는 접근성 테스트도 동시하기 때문입니다.

RTL 메인테이너 Kent.C Dodds 선생님은 다음 아티클에서 테스트 코드 작성에서 자주 하는 실수를 다룹니다.

Common mistakes with React Testing Library - 원문

Common mistakes with React Testing Library - 국문

@testing-library/user-event

유저의 동작도 테스트할 것이기 때문에 user-event도 설치해야 합니다.

pnpm i -D @testing-library/user-event @testing-library/dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import user from "@testing-library/user-event";
import App from "./App";

describe("App", () => {
  it("should renders hello world!", async () => {
    // Arrange
    user.setup();
    render(<App />);

    // Act(선택적)
    // 현재는 생략

    // Expect
    expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
      "hello world!"
    );
  });
});

불러오고 사용하면 됩니다.

msw 설치

통신을 mocking 하는 것도 이번 프로젝트의 의의입니다. 만든 jsx, hook 즉 view, controller가 통신에 실패하면 어떻게 반응하는지 통합테스트 스러운 테스트 코드를 작성하는 것이 중요합니다.

또 JSON 서버만 주구장창 쓰기에는 아쉬운 점이 많습니다. 물론 좋은 유사 백엔드이기는 합니다. 머나먼 미래에는 유사 BaaS 같은 라이브러리로 JSON base를 만들어 보는 것이 꿈입니다. pocket base도 부족해서 더 간소한 라이브러리를 만들어보고 싶어졌습니다.

각설하고 설치합니다.

pnpm i -D msw

일단 이렇게 되면 설치된 것입니다.

npx msw init public/ — save

실제 서버에 요청이 보내기 전에 위 명령을 하면 생성됩니다. Y 선택하는 것 잊지말도록 합니다.

다음은 설정입니다. 공식문서는 CRA로 설정하는 법만 알려줍니다. vite도 동일하게 설정해도 동작합니다.

worker 설정

// mocks/handlers.ts
import { rest } from "msw";

const todos = ["먹기", "자기", "놀기"];

export const handlers = [
  // 할일 목록
  rest.get("/todos", (req, res, ctx) => {
    return res(ctx.status(200), ctx.json(todos));
  }),
];

handlers에서 mocking할 API를 임시로 만듭니다. 여기서는 여러 모의 API를 묶어둡니다.

// mocks/worker.ts
import { setupWorker } from "msw";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

이렇게 만들어진 API는 worker로 전달합니다.

// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";

import { worker } from "./mocks/worker";
if (process.env.NODE_ENV === "development") {
  worker.start();
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

이 설정을 하면 개발환경에서만 동작하도록 합니다. process.env.NODE_ENV에 따라 조건부로 worker가 동작하기 때문에 그렇습니다.

여기까지 설정 중간입니다. 여기서 모킹 확인을 한번 해보겠습니다.

// src/App.tsx
import { useState } from "react";
import "./App.css";

function App() {
  const [count, setCount] = useState(0);
  fetch("http://localhost:5173/todos")
    .then((response) => response.json())
    .then((data) => setCount(data));

  return <h1>hello world! {count}</h1>;
}

export default App;

모의 통신이 성공한 것을 볼 수 있을 것입니다.

server 설정하기

server는 테스트 서버를 위해서 설정합니다.

worker는 브라우저 통신과 반영을 위해서 설정하는 서버입니다.

// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

msw/nodesetupServer가 다르다는 점을 주의깊게 살피도록 합니다.

다음은 setup.ts 설정하는 방법입니다.

// src/setup.ts
import { server } from "./mocks/server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

위는 공식문서에 제공하는 레포에서 가져온 것입니다. 하지만 우리에게 맞게 커스터마이징 하면 다음과 같습니다.

// src/setup.ts
import matchers from "@testing-library/jest-dom/matchers";
import { server } from "./mocks/server";
import { expect } from "vitest";

expect.extend(matchers);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

MSW로 백앤드 API 모킹하기 - DaleSeo 아티클을 참고했습니다.

이렇게 되면 통신과 JSX 모두 테스트할 수 있게 됩니다.

여기까지 작성하고 커밋합시다.

{
  "name": "flash-card-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "vitest watch"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@testing-library/dom": "^9.3.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/react": "^18.2.7",
    "@types/react-dom": "^18.2.4",
    "@types/testing-library__jest-dom": "^5.14.6",
    "@typescript-eslint/eslint-plugin": "^5.59.7",
    "@typescript-eslint/parser": "^5.59.7",
    "@vitejs/plugin-react-swc": "^3.3.1",
    "eslint": "^8.41.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.3.5",
    "jsdom": "^22.1.0",
    "msw": "^1.2.1",
    "typescript": "^5.0.4",
    "vite": "^4.3.9",
    "vitest": "^0.31.1"
  },
  "msw": {
    "workerDirectory": "public"
  }
}

프로젝트의 package.json은 위와 비슷할 것입니다.

test provider

이제 4개의 라이브러리만 설치하고 설정하면 기능을 개발에 착수할 수 있습니다.

Jotai, React-Query, Emotion, React-Router-Dom

하지만 여기서 문제가 있습니다. 모두 provider로 감싸야 합니다. 그것도 4개나 처리해야 합니다. provider의 문제는 리액트 root에서 provider로 감싸고 트리에서 해당 노드를 접근하는 방식으로 동작합니다. 하지만 테스트 환경은 트리 외부에 존재하기 때문에 provider를 통해 접근하는 테스트는 모두 감싸줘야 합니다.

hook을 테스트할 때는 문제가 없지만 컴포넌트를 호출 했을 때는 문제가 될 것입니다.

일단 테스트는 각각의 독립적인 공식문서를 활용해서 테스트 해야 합니다.

가장 단순하게 접근해서 일단 설치하고 다음에 대응하도록 하겠습니다.

pnpm i jotai
pnpm i jotai-devtools --save # 선택
pnpm add @tanstack/react-query
pnpm add @tanstack/react-query-devtools
pnpm add -D @tanstack/eslint-plugin-query
pnpm i @emotion/styled @emotion/react
pnpm i react-router-dom

axios

pnpm i axios
{
  "name": "flash-card-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "vitest watch"
  },
  "dependencies": {
    "@emotion/react": "^11.11.0",
    "@emotion/styled": "^11.11.0",
    "@tanstack/react-query": "^4.29.12",
    "axios": "^1.4.0",
    "jotai": "^2.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.11.2"
  },
  "devDependencies": {
    "@tanstack/eslint-plugin-query": "^4.29.9",
    "@testing-library/dom": "^9.3.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/react": "^18.2.7",
    "@types/react-dom": "^18.2.4",
    "@types/testing-library__jest-dom": "^5.14.6",
    "@typescript-eslint/eslint-plugin": "^5.59.7",
    "@typescript-eslint/parser": "^5.59.7",
    "@vitejs/plugin-react-swc": "^3.3.1",
    "eslint": "^8.41.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.3.5",
    "jsdom": "^22.1.0",
    "msw": "^1.2.1",
    "typescript": "^5.0.4",
    "vite": "^4.3.9",
    "vitest": "^0.31.1"
  },
  "msw": {
    "workerDirectory": "public"
  }
}

일단 단순설치해서 여기까지 되었습니다.

다음은 설정하겠습니다.

arch-spatula commented 1 year ago

05 30

vercel Build Failed

ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY  Broken lockfile: no entry for '/react/18.2.0' in pnpm-lock.yaml

해결: pnpm을 yarn으로 전환

학습: pnpm은 래퍼런스가 더 쌓였을 때 학습하고 활용합시다.

관심사 분리하기

스타일링

인덱스 엔트리는 이렇게 작성할 수 있습니다.

export * from './flex';
export * from './position';
export * from './textStyle';

라우팅 처리

// App.tsx
import { useRoutes } from 'react-router-dom';
import { routes } from '@/routes/Routes';

export default function App() {
  const routedElements = useRoutes(routes);
  return <div>{routedElements}</div>;
}
// src/routes/Routes.tsx
import { lazy } from 'react';
import { ROUTE_PATHS } from '@/constants/config';
import GlobalLayout from './_globalLayout';

const SignUpPage = lazy(() => import('@/pages/SignUp'));
const SignInPage = lazy(() => import('@/pages/SignIn'));
const TodoPage = lazy(() => import('@/pages/Todo'));
const WelcomePage = lazy(() => import('@/pages/Welcome'));

export const routes = [
  {
    path: '/',
    element: <GlobalLayout />,
    children: [
      { index: true, element: <WelcomePage /> },
      { path: ROUTE_PATHS.signUp, element: <SignUpPage /> },
      { path: ROUTE_PATHS.signIn, element: <SignInPage /> },
      { path: ROUTE_PATHS.todo, element: <TodoPage /> },
    ],
  },
];
// src/routes/_globalLayout.tsx
import { Suspense } from 'react';
import { Outlet } from 'react-router-dom';
import Loading from '@/components/Loading';
import { Navbar } from '@/components/Navbar';
import useCheckAuth from '@/hooks/useCheckAuth';

export default function Layout() {
  const isLoggedIn = useCheckAuth();
  return (
    <Suspense fallback={<Loading />}>
      <Navbar isLoggedIn={isLoggedIn} />
      <Outlet context={isLoggedIn} />
    </Suspense>
  );
}

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-1-5/blob/main/src/router/Router.jsx

https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/blob/main/src/routes/Routes.tsx

2개의 레포를 비교해보니까 createBrowserRouter를 사용하는 것이 베스트 프렉티스 같습니다.

스타일링은 개별 모듈로 분리하는 것이 베스트 프렉티스입니다.

스타일링 모듈

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-2-5/blob/main/src/components/Title/Title.styles.js

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-2-5/tree/main/src/styles/utils

/Title
  Title.tsx
  Title.styles.tsx
  index.ts
export * from './Title';
// src/components/layouts/AppLayout/AppLayout.styles.js
import styled from 'styled-components';
import { APP_MAX_WIDTH } from 'styles/constants/dimensions';
import { flex } from 'styles/utils';

export const AppLayout = styled.div`
  ${flex({ justifyContent: 'center' })}
  min-height: 100vh;

  padding: 16px;
`;

export const Main = styled.main`
  width: 100%;
  max-width: ${APP_MAX_WIDTH}px;
`;
// src/components/layouts/AppLayout/AppLayout.jsx
import * as Styled from './AppLayout.styles';

export const AppLayout = ({ children }) => {
  return (
    <Styled.AppLayout>
      <Styled.Main>{children}</Styled.Main>
    </Styled.AppLayout>
  );
};

이런 패턴으로 사용합니다.

라우팅 constants

라우팅은 관심사를 묶어주시기 바랍니다.

export const BASE_URL = 'https://www.pre-onboarding-selection-task.shop';

export const API_URLS = {
  todos: '/todos',
  signIn: '/auth/signin',
  signUp: '/auth/signup',
};

export const ROUTE_PATHS = {
  welcome: '/',
  signIn: '/signin',
  signUp: '/signup',
  todo: '/todo',
};

404 페이지

https://poylib.tistory.com/85

 "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }]

https://vercel.com/docs/concepts/projects/project-configuration#rewrites

인터셉터 활용

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-1-5/blob/main/src/api/axiosInstance.js

import axios from 'axios';
import { BASE_URL } from 'constant/config';
import { getUserTokenInLocalStorage } from 'utils/localTokenUtils';

export const axiosInstance = axios.create({
  baseURL: BASE_URL,
  headers: { 'Content-Type': 'application/json' },
});

export const axiosAuthInstance = axios.create({
  baseURL: BASE_URL,
  headers: { 'Content-Type': 'application/json' },
});

axiosAuthInstance.interceptors.request.use(
  (config) => {
    const token = getUserTokenInLocalStorage();
    const configCopy = { ...config };
    configCopy.headers = { ...config.headers };
    configCopy.headers.Authorization = `Bearer ${token}`;
    return configCopy;
  },
  (error) => Promise.reject(error)
);

interceptors의 역할이 요청을 보내기 전에 전처리를 합니다. 토큰을 접근하고 기존 header, config 설정은 복사합니다.

axios 에러 객체

import { AxiosError } from 'axios';
import { axiosInstance } from './axiosInstance';

const signIn = async (signInData) => {
  try {
    const response = await axiosInstance.post('/auth/signin', signInData);
    return response;
  } catch (error) {
    if (error instanceof AxiosError) {
      return error.response;
    }
  }
};

const signUp = async (signUpData) => {
  try {
    const response = await axiosInstance.post('/auth/signup', signUpData);
    return response;
  } catch (error) {
    if (error instanceof AxiosError) {
      return error.response;
    }
  }
};

const authApi = {
  signIn,
  signUp,
};

export default authApi;

타입가드 방식으로 해당하면 반환하도록 설정합니다.

https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/tree/main/src/services

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-1-5/tree/main/src/api

arch-spatula commented 1 year ago

배운 것이 도둑질? 배울 것이 도둑질!

arch-spatula commented 1 year ago

Queue Map

Map으로 Queue를 구현할 수 있습니다.

gopinav / JavaScript-Data-Structures-Tutorial

class Queue {
  #item;
  #head;
  #tail;
  constructor() {
    this.#item = new Map();
    this.#head = 0;
    this.#tail = 0;
  }

  enqueue(val) {
    this.#item.set(this.#tail, val);
    this.#tail += 1;
    return this.length;
  }

  dequeue() {
    if (this.length === 0) return null;

    if (this.length > 0) {
      const result = this.#item.get(this.#head);
      this.#item.delete(this.#head);
      this.#head += 1;
      return result;
    }

    return result;
  }

  get length() {
    return this.#tail - this.#head;
  }
}

CORS 에러

이미 자주 겪어본 에러인데 이번에는 케이스가 조금 특이합니다. 이번에는 제가 백엔드입니다.

Learn CORS In 6 Minutes

해결은 단순합니다. 하지만 express 버전 말고 deno oak에 맞게 변경하겠습니다.

// main.ts
import { Application } from 'https://deno.land/x/oak@v12.4.0/mod.ts';
import router from './routes/index.ts';
import { oakCors } from 'https://deno.land/x/cors@v1.2.2/mod.ts';

const app = new Application();

app.use(
  oakCors({
    origin: /^.+localhost:(1234|비밀)|https:\/\/비밀\.vercel\.app$/,
  })
);
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

생각보다 단순하게 해결했습니다. 이렇게 하면 끝입니다.

문제는 .env에 또 추가해줘야 합니다.

chatGPT에게 정규표현식처럼 귀찮은 작업을 시키려고 했는데 생각과 다르게 잘 안 풀렸습니다.

하지만 console.log를 원시인처럼 활용해서 다음처럼 처리했습니다.

# .env
REGEX_ORIGIN=^.+localhost:(1234|비밀)|https:\/\/비밀\.vercel\.app$

양 끝에 슬래시(/)만 지우면 끝이었습니다. 단순하게 생각하면 금방 해결하고 chatGPT 선생님에게 고통받지 않았을 것 같습니다.

import { Application } from 'https://deno.land/x/oak@v12.4.0/mod.ts';
import router from './routes/index.ts';
import { oakCors } from 'https://deno.land/x/cors@v1.2.2/mod.ts';
import { config } from 'https://deno.land/x/dotenv@v3.2.2/mod.ts';

const app = new Application();

const REGEX_ORIGIN = Deno.env.get('REGEX_ORIGIN') || config()['REGEX_ORIGIN'];

app.use(
  oakCors({
    origin: new RegExp(REGEX_ORIGIN),
  })
);
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

이렇게 처리하니까 동작합니다. 상당히 뿌듯해졌습니다.

다음 의문 관심사는 여기가 맞는가?

진짜 몰라서 하는 질문입니다. 어플리케이션 전체적으로 설정해야 할 것이 여기 위치해야 하는 것은 맞습니다. 하지만 CORS를 설정한다는 코드가 여기 위치해야 하는 것이 독자 입장에서 컨텍스트에 맞는가?

CORS 설정은 웹 애플리케이션의 보안 및 권한 관련 중요한 부분이기 때문에 독자 입장에서는 컨텍스트에 맞는 관심사입니다. 일반적으로 백엔드 애플리케이션에서 CORS 설정을 처리하는 것은 올바른 접근 방식입니다.

- chatGPT

Hallucination인지 검증할 수준의 지식이 없습니다. 하지만 지금 시점에서는 저보다 백엔드 지식이 더 많기 때문에 받아들이겠습니다.

왜 Deno를 활용했는가?

빠르게 학습하고 무료 배포하고자 Deno를 선택했습니다. AWS, Github Action, Docker를 몰라도 deno deploy 서비스로 백엔드 배포가 가능해서 활용했습니다.

작년 10월 이후 백엔드 코드를 처음 작성하기 때문에 가장 쉽게 백엔드를 API를 만들수 있을 것 같아서 선택했습니다.

가장 쉬울 것이라는 가설은 v1.0.0 버전을 배포한 시점에는 적어도 맞습니다.

다른 이유도 있습니다. API 서버를 만들어 배포할 때 무료로 사용하고 싶었습니다.

참고로 AWS, Github Action, Docker를 모르고 사용해도 괜찮지만 알고 사용하면 더욱더 효율적인 백엔드 엔지니어링을 할 수 있습니다.

Deno Deploy 단점

Deno Deploy에서 있는 단점입니다. Worker를 지원하지 않아 일부 API를 활용할 수 없습니다.

이점은 Deno Deploy의 확실한 단점이었습니다.

타입스크립트 설정이 Deno가 기본적으로 제공해주는 것에서 추상화 되어 있습니다. 대부분의 상황에서 장점입니다. 하지만 기본적으로 제공해주는 추상화가 잘 못 설정된 경우도 있었습니다. 대소문자 변경하고 이어서 작업하면 큰 문제는 아니지만 분명 아쉬운 점이었습니다.

Deno의 잠재력

Deno의 잠재력은 엄청납니다. 아직 덜 익숙해서 벤치마크 측정, 테스트 코드 작성 기능을 완전히 활용하지 못했습니다. 테스트 방법을 다시 학습하기는 해야 하지만 기본 설정으로 제공해준다는 점이 상당히 좋았습니다.

테스트 코드를 위해 다른 라이브러리에 의존할 필요가 없습니다.

Deno의 장점

장점은 처음 사용하는 입장에서도 확실히 있었습니다.

url 기반으로 import하기는 엄청난 장점입니다. 패키지 호환성에 대해서 고민은 크게 할 필요가 없었습니다. 이미 Deno 생태계에 추상화 되어 있습니다. Node_module과 패키지 매니저로 고통받는 일은 별로 없었습니다.

왜 백엔드가 필요했는가?

교육과정 이후 지금까지 BaaS만 활용해서 프로젝트를 진행했습니다. 백엔드 엔지니어랑 사이드 프로젝트를 같이 진행해 보는 것도 좋지만 간단한 것은 직접 할 줄 알아야 한다는 생각에 만들었습니다. 엄청나게 복잡한 비즈니스 로직을 구현할 필요는 없지만 백엔드 엔지니어에게 무엇을 요구하고 무엇을 기대해야 하는지 가늠이라도 하고자 직접 했습니다.

BaaS를 사용하면 통신에 대해서 BaaS에서 보통 다 정해줍니다. 하지만 BaaS가 제공하는 통신을 활용해서 테스트 코드를 작성하는 케이스를 못 봤습니다. 보통은 백엔드 엔지니어가 API를 제공하기 전까지 프론트엔드는 Mocking으로 유사 API를 만들고 테스트 케이스도 통신의 성공과 실패까지 포함해서 작성한다고 합니다. 저도 이런 접근으로 백엔드를 직접 구현하기로 했습니다.

레버리지

레버리지는 시간대비 효과입니다.

$$ \text{레버리지} = \frac{\text{생산한 효과}}{\text{투자한 시간}} $$

일을 잘 하는 전략은 레버리지 비율을 높이는 것입니다.

3가지 방법에 집중합니다.

  1. 특정 활동을 완료하는 데 드는 시간 줄이기
  2. 특정 활동의 생산량 늘리기
  3. 레버리지가 높은 활동으로 전환하기

참고로 쉬운 업무가 레버리지 비중이 높은 업무는 아닙니다. 난이도가 높은데 더 높은 수준의 가치를 창출하는 업무도 있습니다.

또 컨텍스트를 자주 바꾸거나 아주 오래 유지하는 것이 레버리지를 높이는 것은 아닙니다.

제가 잘 못하는 것이 있습니다. 뭐 일을 단순하게 생각하는 것도 못하지만 효율적으로 일을 못한다고 스스로 생각합니다. 하루를 반나절처럼 살고 있습니다. 하루를 4일처럼 살고 1주일을 1달처럼 살아도 개발자로서 커리어릴 못하는데 태만하게 취준생 생활을 임하고 있습니다.

래버리지 비중을 높이는 결과를 만들어야 합니다.

테크 트렌드 줍줍

이번주 일요일에 이 내용들 정리하면 될 것 같습니다.

21 Awesome Web Features you’re not using yet

I Waited 15 Years For These New Array Methods

웹사이트 로딩이 더 빨라지는 매직? 🍯 꿀팁 공유 10분컷!

mattpocock / zod-fetch

arch-spatula commented 1 year ago

[MSW] Warning: captured a request without a matching request handler:

[MSW] Warning: captured a request without a matching request handler:

MSW를 사용하는데 이런 경고 메시지를 받았습니다.

일단 해결은 상당히 단순했습니다.

if (process.env.NODE_ENV === 'development') {
  worker.start({ onUnhandledRequest: 'bypass' });
}

MSW logging warnings for unhandled Supertest requests

그래서 일단 Stack overflow에서 검색하고 해결책을 바로 적용했습니다.

하지만 의문이 있습니다. 그래서 일단 chatGPT에 질문했습니다.

[MSW] Warning: captured a request without a matching request handler:

이런 경고 메시지를 받았습니다.

일치하는 해더 없이 요청을 포착했다고 의미하는가?

만약 일치하는 해더 없이 요청을 포착했다면 MSW로 mocking하는데 무슨 문제가 있는 것인가? 아니면 무슨 문제가 생길 수 있는가?

"MSW"는 Mock Service Worker의 약어로, 개발 및 테스트 목적으로 실제 서버 요청을 가로채고 가짜 응답을 제공하는 도구입니다. 경고 메시지 "Warning: captured a request without a matching request handler"는 MSW에서 발생하는 것으로, 요청을 가로챘지만 해당 요청에 대한 처리기(handler)가 없다는 것을 의미합니다.

이러한 경고 메시지가 나타나는 이유는 다음과 같습니다:

  1. 요청을 가로채기 위해 등록한 모의(request)와 실제로 수신된 요청이 일치하지 않을 때 발생할 수 있습니다. 예를 들어, 등록한 모의 요청과 메서드, 경로 또는 헤더 등이 일치하지 않는 요청이 발생하면 이 경고가 표시됩니다.

  2. MSW에서 등록한 요청 처리기(handler)가 설정되지 않은 경우에도 이 경고가 나타날 수 있습니다. 요청을 가로챌 때 해당 요청에 대한 처리 방법을 정의하는 요청 처리기가 등록되어 있어야 합니다.

이러한 문제가 발생할 경우 MSW mocking에 문제가 있는 것입니다. 이는 실제 서버 응답을 제공하는 대신 가짜 응답을 반환하도록 구성되어 있으므로, 등록된 요청 처리기가 없으면 원하는 응답을 제공할 수 없게 됩니다. 따라서 응답을 처리할 수 있는 적절한 요청 처리기를 등록하는 것이 중요합니다.

문제가 생길 수 있는 가능성은 다음과 같습니다:

  1. 실제 서버와의 통신이 필요한 기능을 모킹하고 있을 때, 해당 요청을 처리하기 위해 등록된 처리기가 없으면 올바른 응답을 받을 수 없게 됩니다.

  2. 요청 처리기가 부적절하게 구성되어 있다면, 가짜 응답이 실제 서버의 동작과 일치하지 않을 수 있습니다. 이는 개발자가 응답을 테스트하거나 디버깅할 때 문제를 일으킬 수 있습니다.

따라서 MSW를 사용할 때는 등록된 요청 처리기가 요청과 일치하도록 확인하고, 필요한 요청에 대해 적절한 가짜 응답을 제공할 수 있도록 설정해야 합니다.

- chatGPT

MSW로 서버를 흉내내는데 잘못 흉내내는 문제라고 생각할 수 있습니다. 다양한 대응 방침이 있습니다. 일단 단순하게 기능을 구현하고 통신 동작에 익숙해지면 추가하는 전략이 있습니다.

사족으로 MSW로 조기에 설정할 필요는 없습니다. 프로젝트가 성숙해졌을 때 도입해도 늦지 않을 것 같습니다.

handler 관심사 분리

handler 관심사 분리는 중요합니다. 저는 개인프로젝트이지만 현업에서 mocking할 백엔드 코드의 숫자는 엄청나게 많습니다. 그래서 관심사를 분리하는 것이 중요합니다.

// handlers/index.ts
import type { DefaultBodyType, MockedRequest, RestHandler } from 'msw';
import * as cardHandlers from './cardHandlers';
import * as authHandlers from './authHandlers';

export const handlers: RestHandler<MockedRequest<DefaultBodyType>>[] = [
  ...Object.values(cardHandlers),
  ...Object.values(authHandlers),
];

여기서도 인덱스 엔트리를 잘 활용하는 것이 중요합니다.

MSW로 API 모킹하기

위 아티클을 보고 따라했습니다.

인덱스 엔트리

// component/Navbar/index.tsx
function Navbar() {
  return <nav>home</nav>;
}

export { Navbar };
// component/index.ts
export * from './Navbar';
// Layout.tsx
import { Global } from '@emotion/react';
import GlobalStyle from '../styles/Reset';
import { Navbar } from '../Components';

function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Global styles={GlobalStyle} />
      <Navbar />
      {children}
    </>
  );
}

export default Layout;

기존보다 index 엔트리를 더 간결하게 작성하는 방법을 적용했습니다.

스타일링 관심사 분리

Styled에서 접근하지 말고 바로 컴포넌트명 그래도 활용하는 것이 좋겠습니다. 가독성을 너무 저해합니다. 또 인덱스 엔트리 배웠다고 바로 활용하는데 꼭 좋은 것 같지는 않습니다.

스타일링과 관련된 코드를 다른 모듈로 분리해서 마크업 정보를 담는 관심사와 스타일링을 담는 관심사를 분리하는 것은 좋습니다.

의도는 좋은데 막상 해보니까 가독성이 너무 없습니다. 모든 것을 인덱스 엔트리로 처리할 필요는 없습니다.

// /Components/Navbar/index.ts
import * as Styled from './Navbar.style';

function Navbar() {
  return (
    <Styled.Navbar>
      <Styled.Container>
        <Styled.LeftList>
          <Styled.ListItem>home</Styled.ListItem>
          <Styled.ListItem>Deck</Styled.ListItem>
        </Styled.LeftList>
        <Styled.RightList>
          <Styled.ListItem>Setting</Styled.ListItem>
        </Styled.RightList>
      </Styled.Container>
    </Styled.Navbar>
  );
}

export { Navbar };
// /Components/Navbar/Navbar.style.tsx
import styled from '@emotion/styled';

export const Navbar = styled.nav`
  height: 4rem;
  width: 100%;
  background-color: #f8f8f8;
  display: flex;
  align-items: center;
  padding: 0 2rem;
`;

export const Container = styled.div`
  display: flex;
  justify-content: space-between;
  width: 100%;
`;

export const LeftList = styled.ul`
  display: flex;
`;

export const RightList = styled.ul``;

export const ListItem = styled.li``;

Styled를 접두어를 붙이는 코드를 봤습니다. 가독성을 해칩니다.

일단 익숙한 JSX 문법으로 스타일을 작성하고 활용한다는 점은 좋습니다.

https://github.com/wanted-frontedend-team5/pre-onboarding-10th-2-5/tree/main/src/components/SearchBar

RRD 라우팅

너무 오래간만에 해서 조금 당황스럽습니다.

Link 태그 사용하면 해결되는 문제였습니다.

import { Link } from 'react-router-dom';

function Navbar() {
  return <Link to={'/cards'}>Home</Link>;
}

export { Navbar };

생각보다 단순하게 해결할 수 있었습니다.

조건부 랜더링 관심사 분리

import { Link } from 'react-router-dom';
import { Nav, Container, List, ListItem } from './Navbar.style';
import { useLogin } from '../../hooks';

function Navbar() {
  const { isLoggedIn } = useLogin();

  return (
    <Nav>
      <Container>
        {isLoggedIn ? (
          <>
            <List>
              <ListItem>
                <Link to={'/cards'}>Home</Link>
              </ListItem>
              <ListItem>
                <Link to={'/deck'}>Deck</Link>
              </ListItem>
            </List>
            <List>
              <ListItem>
                <Link to={'/setting'}>Setting</Link>
              </ListItem>
            </List>
          </>
        ) : (
          <>
            <List>
              <ListItem>
                <Link to={'/'}>Home</Link>
              </ListItem>
            </List>
            <List>
              <ListItem>
                <Link to={'/signup'}>Sign Up</Link>
                <Link to={'/signin'}>Sign In</Link>
              </ListItem>
            </List>
          </>
        )}
      </Container>
    </Nav>
  );
}

export { Navbar };

이런 코드가 있습니다. 여기서 고민은 조건부 랜더링이 나오면 바로 리팩토링 대상이라고 했습니다. 여기서 고민입니다.

import { Link } from 'react-router-dom';
import { Nav, Container, List, ListItem } from './Navbar.style';
import { useLogin } from '../../hooks';

export function Navbar() {
  const { isLoggedIn } = useLogin();
  return <Nav>{isLoggedIn ? <LoggedInNav /> : <LoggedOutNav />}</Nav>;
}

function LoggedInNav() {
  return (
    <Container>
      <List>
        <ListItem>
          <Link to={'/cards'}>Home</Link>
        </ListItem>
        <ListItem>
          <Link to={'/deck'}>Deck</Link>
        </ListItem>
      </List>
      <List>
        <ListItem>
          <Link to={'/setting'}>Setting</Link>
        </ListItem>
      </List>
    </Container>
  );
}

function LoggedOutNav() {
  return (
    <Container>
      <List>
        <ListItem>
          <Link to={'/'}>Home</Link>
        </ListItem>
      </List>
      <List>
        <ListItem>
          <Link to={'/signup'}>Sign Up</Link>
        </ListItem>
        <ListItem>
          <Link to={'/signin'}>Sign In</Link>
        </ListItem>
      </List>
    </Container>
  );
}

Nav 컴포넌트의 테스트 가능성

Cannot destructure property 'basename' of 'React__namespace.useContext(...)' as it is null.

Components/Navbar/index.tsx

Nav는 Layout 컴포넌트에 넣고 사용하고 있습니다. 문제는 테스트할 때 발생했습니다.

import { Global } from '@emotion/react';
import GlobalStyle from '../styles/Reset';
import { Navbar } from '../Components';

/**
 * @see https://github.com/WANTED-TEAM03/pre-onboarding-10th-1-3/blob/main/src/routes/_globalLayout.tsx
 */
function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Global styles={GlobalStyle} />
      <Navbar />
      {children}
    </>
  );
}

export default Layout;

Nav가 정상동작하기 위해서는 개별 페이지별로 호출하는 방식으로 사용해야 한다는 것입니다.

import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Layout from './GlobalLayout';
import {
  Cards,
  Deck,
  Landing,
  NotFound,
  Setting,
  SignIn,
  SignUp,
} from '../pages';

function Router() {
  return (
    <BrowserRouter>
      <Layout>
        <Routes>
          <Route path="/" element={<Landing />} />
          <Route path="/cards" element={<Cards />} />
          <Route path="/deck" element={<Deck />} />
          <Route path="/setting" element={<Setting />} />
          <Route path="/signin" element={<SignIn />} />
          <Route path="/signup" element={<SignUp />} />
          <Route path="*" element={<NotFound />} />
        </Routes>
      </Layout>
    </BrowserRouter>
  );
}

export default Router;

이렇게 컨텍스트 밖에 있는 것이 문제로 작용했습니다.

여기서 고민할 점은 당장해결할지 아니면 후순위로 두고 다른 가치가 더 높은 것에 대응할지입니다.

지금 시점에서는 기능 구현이 우선순위에서 높습니다. 따라서 추가 테스트 코드 작성 중에 혹은 리팩토링 진행 직전에 테스트를 추가합니다.

arch-spatula commented 1 year ago

Custom hook에 대한 관심사 분리

import { Button, Input } from '../../Components';
import { useInput } from '../../hooks';

function SignIn() {
  const { inputVal: emailValue, changeInputVal: changeEmail } = useInput();

  return (
    <div>
      <h1>Sign In</h1>
      <Input type="email" onChange={changeEmail} value={emailValue} />
      <Button
        onClick={(e) => {
          console.log(e.target);
        }}
      >
        Sign In
      </Button>
    </div>
  );
}

export { SignIn };

이런 컴포넌트가 있습니다. 로그인 페이지를 담당하고 있습니다. 딜레마는 이것입니다. 페이지에 그대로 작성해도 당연히 동작하는데 마크업과 컨트롤러의 분리가 안 이루어집니다. hook이 컨트롤러 역할을 할 수 있게 해야 하는데 문제는 중복하는 로직이 아니라는 점입니다.

https://martinfowler.com/bliki/PresentationDomainDataLayering.html

이미지의 위치를 보면 전용 로직을 같이 배치해도 괜찮을 것 같습니다. 로그인 기능을 useSignIn으로 같은 page 모듈에 배치하고 해결할 수 있습니다. 그리고 useSignIn에서 전용으로 사용할 Model 클래스도 정의해 둘 수 있습니다.

페이지 단위로 프레젠테이션과 도메인을 불리할 수 있습니다.

refresh token access token

백엔드 엔지니어링 중에 오타를 나중에 발견했습니다.

refresh access token를 구현하는 것이 좋을 것 같습니다.

https://www.youtube.com/watch?v=iD49_NIQ-R4

개념입니다.

사용자가 로그인하면 하나는 쿠키에 저장하고 다른 하나는 일반 메모리에 저장합니다.

JWT Bearer 토큰은 스토리지에서 담고 있다가 Header로 설정해서 요청을 보내면 됩니다. 이것은 access token입니다. 만료 혹은 유효하지 않으면 막으면 됩니다.

refresh 토큰은 cookie로 설정합니다.

이렇게 하면 장점은 새로고침 문제를 해결할 수 있습니다. 새로고침하면 refresh 토큰은 잔존하고 access token은 사라집니다.

사용자는 refresh 토큰을 서버에 보내면 서버는 access 토큰을 응답합니다. 그리고 access 토큰으로 서버에 요청을 보내고 데이터를 받는 방식입니다.

refresh 토큰 자체로 요청을 활용하면 의미는 크게 없습니다. 해커는 refresh의 응답을 활용할 수 없기 때문입니다.

https://www.youtube.com/watch?v=9eKIYjcPXp4

여기서는 access이 만료됩니다. 그리고 refresh 토큰을 받아서 갱신합니다.

https://www.rfc-editor.org/rfc/rfc6749

  +--------+                                           +---------------+
  |        |--(A)------- Authorization Grant --------->|               |
  |        |                                           |               |
  |        |<-(B)----------- Access Token -------------|               |
  |        |               & Refresh Token             |               |
  |        |                                           |               |
  |        |                            +----------+   |               |
  |        |--(C)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(D)- Protected Resource --| Resource |   | Authorization |
  | Client |                            |  Server  |   |     Server    |
  |        |--(E)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(F)- Invalid Token Error -|          |   |               |
  |        |                            +----------+   |               |
  |        |                                           |               |
  |        |--(G)----------- Refresh Token ----------->|               |
  |        |                                           |               |
  |        |<-(H)----------- Access Token -------------|               |
  +--------+           & Optional Refresh Token        +---------------+

토큰 2개를 저장하고 API 호출할 때는 Access 토큰을 제출합니다. 하지만 만료되는 경우가 있습니다. token error가 발생하면 access 토큰의 수명이 끝났다는 것입니다. 이 때 refresh 토큰을 보내고 Access 토큰을 갱신합니다.

예전 과제를 보니까 Access 토큰으로 body에 응답합니다.

https://github.com/walking-sunset/selection-task

이렇게 보면 set-cookie로 응답은 refresh 토큰에 설정하면 됩니다.

login

https://github.com/gitdagray/mern_stack_course/blob/main/lesson_13-backend/controllers/authController.js

이 예시가 제일 직관적입니다.

const User = require('../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

// @desc Login
// @route POST /auth
// @access Public
const login = async (req, res) => {
  const { username, password } = req.body;

  if (!username || !password) {
    return res.status(400).json({ message: 'All fields are required' });
  }

  const foundUser = await User.findOne({ username }).exec();

  if (!foundUser || !foundUser.active) {
    return res.status(401).json({ message: 'Unauthorized' });
  }

  const match = await bcrypt.compare(password, foundUser.password);

  if (!match) return res.status(401).json({ message: 'Unauthorized' });

  const accessToken = jwt.sign(
    {
      UserInfo: {
        username: foundUser.username,
        roles: foundUser.roles,
      },
    },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { username: foundUser.username },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );

  // Create secure cookie with refresh token
  res.cookie('jwt', refreshToken, {
    httpOnly: true, //accessible only by web server
    secure: true, //https
    sameSite: 'None', //cross-site cookie
    maxAge: 7 * 24 * 60 * 60 * 1000, //cookie expiry: set to match rT
  });

  // Send accessToken containing username and roles
  res.json({ accessToken });
};

부분을 보면 이해가 됩니다. 어느정도 유효성을 검증하고 cookie에 refresh token을 설정합니다. 그리고 response body에 access token을 응답합니다. 응답을 받은 프론트엔드 엔지니어 입장에서 이 token을 header에 설정하면 됩니다.

refresh

// @desc Refresh
// @route GET /auth/refresh
// @access Public - because access token has expired
const refresh = (req, res) => {
  const cookies = req.cookies;

  if (!cookies?.jwt) return res.status(401).json({ message: 'Unauthorized' });

  const refreshToken = cookies.jwt;

  jwt.verify(
    refreshToken,
    process.env.REFRESH_TOKEN_SECRET,
    async (err, decoded) => {
      if (err) return res.status(403).json({ message: 'Forbidden' });

      const foundUser = await User.findOne({
        username: decoded.username,
      }).exec();

      if (!foundUser) return res.status(401).json({ message: 'Unauthorized' });

      const accessToken = jwt.sign(
        {
          UserInfo: {
            username: foundUser.username,
            roles: foundUser.roles,
          },
        },
        process.env.ACCESS_TOKEN_SECRET,
        { expiresIn: '15m' }
      );

      res.json({ accessToken });
    }
  );
};

accessToken 갱신하는 로직입니다.

logout

// @desc Logout
// @route POST /auth/logout
// @access Public - just to clear cookie if exists
const logout = (req, res) => {
  const cookies = req.cookies;
  if (!cookies?.jwt) return res.sendStatus(204); //No content
  res.clearCookie('jwt', { httpOnly: true, sameSite: 'None', secure: true });
  res.json({ message: 'Cookie cleared' });
};

module.exports = {
  login,
  refresh,
  logout,
};

아주 직관적입니다.

이 예시는 Node.js 버전입니다. 저는 Deno 런타임에 맞게 변형해야 합니다.

Deno 버전

한가지 잘 못 알고 있던 지식이 있었습니다. 제가 만들었던 것은 미들웨어입니다. 컨트롤러가 아닙니다.

미들웨어는 요청과 응답 사이 처리를 담당하는 코드입니다.

컨트롤러는 라우팅 요청에 대한 실제 처리결과를 구현합니다.

// util/token.ts
import { create, getNumericDate, verify } from '../deps.ts';

class Token {
  private static instance: Token;
  readonly key: Promise<CryptoKey>;

  private constructor() {
    this.key = (async () => {
      const key = await crypto.subtle.generateKey(
        { name: 'HMAC', hash: { name: 'SHA-512' } },
        true,
        ['sign', 'verify']
      );
      return key;
    })();
  }

  static getInstance(): Token {
    if (!Token.instance) {
      Token.instance = new Token();
    }
    return Token.instance;
  }

  async makeAccessToken(userId: string, expiresInSec = 3600) {
    const jwt = await create(
      { alg: 'HS512' },
      { exp: getNumericDate(expiresInSec), sub: userId },
      await this.key
    );
    return {
      jwt,
      expires: new Date(new Date().getTime() + expiresInSec * 1000),
    };
  }

  async makeRefreshToken(userId: string, expiresInSec = 2592000) {
    const jwt = await create(
      { alg: 'HS512' },
      { exp: getNumericDate(expiresInSec), sub: userId },
      await this.key
    );
    return {
      jwt,
      expires: new Date(new Date().getTime() + expiresInSec * 1000),
    };
  }

  async tokenToUserId(jwt: string) {
    const { sub } = await verify(jwt, await this.key);
    return sub;
  }
}

export default Token;
// controllers/users.ts
async function signin({ request, response, cookies }: Context) {
  try {
    if (!request.hasBody) {
      throw Error('body가 없습니다.');
    }

    const input = await request.body().value;
    if (!input.email || !input.password) {
      throw Error('이메일 혹은 비밀번호가 없습니다.');
    }

    const document = await mongoAPI.getUser(input.email);
    if (document === null) throw Error('이메일이 없습니다.');
    else {
      if (await compare(input.password, document.passwordHash)) {
        const { jwt: refreshToken, expires: refreshExpires } =
          await token.makeRefreshToken(document._id);

        const { jwt: access_token } = await token.makeAccessToken(
          document._id,
          60 * 60
        );

        cookies.set('user', refreshToken, { expires: refreshExpires });
        response.status = 201;
        response.body = { access_token };
      } else {
        throw Error('비밀번호가 일치하지 않습니다.');
      }
    }
  } catch (error) {
    response.status = 400;
    response.body = {
      success: false,
      msg: `${error}`,
    };
  }
}

로그인 기능을 구현했습니다. 다음은 요청과 갱신 기능입니다.

먼저 요청에서 access token의 만료 검증입니다.

deps

https://deno.com/manual@v1.29.2/examples/manage_dependencies

그동안 하고 싶었던 리팩토링을 했습니다. 모든 의존성을 하나의 모듈에 몰아 넣었습니다.

막상 해보니까 별로 어려운 작업이 아니었습니다.

내일 할일

내일은 token에 대한 구현 테스트를 진행합니다.

구현 테스트가 성공하면 API도 업데이트합니다. 응답이 미흡한 것이 너무 많습니다.

내일 모래부터 착수하게 될 작업들입니다. API 명세서도 갱신합니다. 프론트엔드 작업이 어려웠습니다. 테스트코드 추가도 시작합니다. 리팩토링과 기능 추가가 어려워졌습니다. deno에 대한 테스트 방법론을 학습하고 적용합니다.

arch-spatula commented 1 year ago

미들웨어 컨트롤러

따라서 일반적인 흐름은 다음과 같습니다:

  1. 클라이언트가 요청을 보냄.
  2. 요청이 도달하면 미들웨어가 실행되어 전처리 작업 수행.
  3. 미들웨어는 요청을 검사하고 조작한 후, 컨트롤러에게 제어를 전달.
  4. 컨트롤러는 요청을 처리하고 필요한 작업을 수행.
  5. 컨트롤러는 응답을 생성하여 클라이언트에게 반환.
  6. 응답이 도달하면 미들웨어가 실행되어 후처리 작업 수행 및 응답을 클라이언트에게 전달.

ChatGPT에게 질문하고 얻은 답변입니다.

토큰 갱신 프론트엔드

토큰 갱신이 필요하다는 응답을 받으면 프론트엔드는 어떻게 처리하는지 궁금해졌습니다.

https://gusrb3164.github.io/web/2022/08/07/refresh-with-axios-for-client/

잘 다룬 블로그를 발견했습니다.

Deno oak testing

Deno oak를 테스트하는 방법을 찾고 있었습니다.

oak cookie testing

cookie를 서버에서 설정하고 클라이언트가 요청 보낼 때마다 확인해야 하는데 이것을 어떻게 구현하는지 찾아보고 있었습니다.

https://github.com/oakserver/oak/pull/422/files

Deno.test({
  name: 'testing - ctx.cookies.set()',
  async fn() {
    const ctx = createMockContext();
    await ctx.cookies.set(
      'sessionID',
      'S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro',
      { httpOnly: true }
    );
    assertEquals(
      [...ctx.response.headers],
      [
        [
          'set-cookie',
          'sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro; path=/; httponly',
        ],
      ]
    );
  },
});

Deno.test({
  name: 'testing - ctx.cookies.get()',
  async fn() {
    const ctx = createMockContext({
      headers: [
        ['cookie', 'sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro;'],
      ],
    });
    assertEquals(
      await ctx.cookies.get('sessionID'),
      'S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro'
    );
  },
});

안풀리는 미스테리: Cookies 설정 불가

보통 토큰은 2개로 테스트합니다. 하지만 이것에 대한 테스트 코드가 별로 없는 것도 의외입니다.

문제: ???

import type { Context, Middleware } from '../deps.ts';
import Token from '../util/token.ts';

const token = Token.getInstance();

/**
 * @see https://github.com/gitdagray/mern_stack_course/blob/main/lesson_13-backend/middleware/verifyJWT.js
 */
const authMiddleware: Middleware = async (
  { request, response, cookies },
  next
) => {
  const accessToken = request.headers.get('Authorization');
  if (accessToken) {
    await next();
  } else {
    const refreshToken = await cookies.get('user');
    if (!refreshToken) {
      response.status = 400;
      response.body = {
        msg: '로그아웃 되었습니다.',
      };
    } else {
      response.status = 401;
      response.body = {
        msg: '토큰이 만료되었습니다.',
        accessToken: token.refreshAccessToken(refreshToken),
      };
    }
  }
};

export { authMiddleware };

이것에 대한 테스트코드를 작성하고 있는데 문제가 생겼습니다.

Deno.test({
  name: 'access token이 만료 refresh token은 유효',
  async fn() {
    const ctx = testing.createMockContext({
      headers: [['Authorization', '']],
    });
    const refreshCookie = 'refreshToken';
    const next = testing.createMockNext();

    await ctx.cookies.set('user', refreshCookie);
    await authMiddleware(ctx, next);

    assertEquals(
      [...ctx.response.headers],
      [['set-cookie', `user=${refreshCookie}; path=/; httponly`]]
    );

    await authMiddleware(ctx, next);

    console.log(await ctx.cookies.get('user'), ctx.response.headers); // undefined
    assert(ctx.response.status === 401, '401');

    const configPromise = new
  },
});

cookiesset해도 get할 수 없습니다.

시도: ChatGPT 질문

Deno.test({
  name: 'access token이 만료 refresh token은 유효',
  async fn() {
    const ctx = testing.createMockContext({
      headers: [['Authorization', '']],
    });
    const refreshCookie = 'refreshToken';
    const next = testing.createMockNext();

    await authMiddleware(ctx, next); // authMiddleware를 호출하기 전에 쿠키를 설정하지 않습니다.

    // 쿠키 설정 후에 await를 사용하여 Promise가 완료될 때까지 기다립니다.
    await ctx.cookies.set('user', refreshCookie);

    await authMiddleware(ctx, next);

    assertEquals(
      [...ctx.response.headers],
      [['set-cookie', `user=${refreshCookie}; path=/; httponly`]]
    );
    assert(ctx.response.status === 401, '401');
  },
});

표본이 너무 작아서 GPT로 해결할 수 없었습니다.

promise 활용

Deno.test({
  name: 'access token이 만료 refresh token은 유효',
  async fn() {
    const refreshCookie = 'refreshToken';
    const ctx = testing.createMockContext({
      headers: [['Authorization', '']],
    });
    const next = testing.createMockNext();

    await ctx.cookies.set('user', refreshCookie).then(() => {
      authMiddleware(ctx, next);
    });

    assertEquals(
      [...ctx.response.headers],
      [['set-cookie', `user=${refreshCookie}; path=/; httponly`]]
    );

    assert(ctx.response.status === 401, '401');
    assertEquals(
      ctx.response.body,
      {
        msg: '토큰이 만료되었습니다.',
        accessToken: async () => ({ accessToken: null, success: false }),
      },
      'access token 갱신 응답'
    );
  },
});

순서에 맞게 실행했지만 그래도 테스트를 통과하지 않았습니다.

이 테스트 코드를 보면 확실하게 동작합니다. 하지만 set의 동작방식이 다릅니다.

해결: get과 set은 서로 읽고 쓰는 관계가 아닙니다.

Deno.test({
  name: 'testing - ctx.cookies.get()',
  async fn() {
    const ctx = testing.createMockContext({
      headers: [
        ['cookie', 'sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro;'],
      ],
    });
    console.log(ctx.cookies.get('sessionID'));
    assertEquals(
      await ctx.cookies.get('sessionID'),
      'S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro'
    );
  },
});

github에서 이 예시를 보니까 cookies를 set하는 동작이 다릅니다. 즉 set을 하면 get으로 접근이 가능한 것이 아니었습니다.

https://github.com/oakserver/oak/pull/422/files

Deno.test({
  name: 'access token이 만료 refresh token은 유효',
  async fn() {
    const refreshCookie = 'refreshToken';
    const ctx = testing.createMockContext({
      headers: [
        ['Authorization', ''],
        ['cookie', `user=${refreshCookie}`],
      ],
    });
    const next = testing.createMockNext();

    await authMiddleware(ctx, next);

    assert(ctx.response.status === 401, '401');
    assertEquals(
      ctx.response.body,
      {
        msg: '토큰이 만료되었습니다.',
        accessToken: Promise.resolve({ accessToken: null, success: false }),
      },
      'access token 갱신 응답'
    );
  },
});

결국 이렇게 해서 문제를 해결했습니다. 상당히 특이하고 시간을 많이 낭비하면서 겨우 힘을게 테스트를 구현했습니다.

학습: 초기 라이브러리는 손 볼 곳이 많습니다.

set을 하면 당연히 get 메서드로 접근 가능할 것이라는 순진한 생각을 가졌습니다. 저는 자바스크립트 생태계에 대한 높은 신뢰를 갖는 실수를 오늘도 하고 말았습니다.

부조리 맞습니다. 나중에 이거 수정하는 PR올려 컨트리뷰터가 되고 싶네요