8. 좋은 리액트 코드 작성을 위한 환경 구축하기
좋은 코드를 작성하기 위한 ESLint와 리액트 테스트 라이브러리를 알아보자
8.1 ESLint를 활용한 정적 코드 분석
✨ JS 생태계에서 가장 많이 사용되는 정적 코드 분석 도구 ESLint에 대해 알아봅세~
8.1.1 ESLint 살펴보기
🔖 ESLint 어떻게 이렇게 쌈@뽕하게 JS 코드를 분석하는 걸까?
JS코드를 문자열로 읽는다.
JS 코드를 분석 가능한 파서(parser)로 코드를 구조화한다.
구조화한 AST(Abstract Syntax Tree)라 하고, 이를 각종 규칙과 대조한다.
규칙 위반 코드를 알리거나 수정한다.
🖥️ 코드 변환 톺아보기
const handleCancelClick = () => {
setIsEditing(false);
setRenewalTime(!renewalTime);
cancelModalOff();
updateFormData();
};
{
"type": "Program",
"start": 0,
"end": 322, // 코드 시작
"body": [
{
"type": "VariableDeclaration",
"start": 181,
"end": 322,
"declarations": [
{
"type": "VariableDeclarator",
"start": 187, // const 뺀 나머지
"end": 321,
"id": {
"type": "Identifier",
"start": 187,
"end": 204, // 함수 이름
"name": "handleCancelClick"
},
"init": {
"type": "ArrowFunctionExpression",
"start": 207, // 화살표 함수임을 의미
"end": 321,
"id": null,
"expression": false,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"start": 213,
"end": 321, // {} 블록 스테이트 시작
"body": [
{
"type": "ExpressionStatement",
"start": 219,
"end": 239,
"expression": {
"type": "CallExpression",
"start": 219,
"end": 238,
"callee": {
"type": "Identifier",
"start": 219,
"end": 231,
"name": "setIsEditing" // setIsEditing 실행 부분
},
"arguments": [
{
"type": "Literal",
"start": 232,
"end": 237,
"value": false,
"raw": "false"
}
],
"optional": false
}
},
{
"type": "ExpressionStatement",
"start": 244,
"end": 273,
"expression": {
"type": "CallExpression",
"start": 244,
"end": 272,
"callee": {
"type": "Identifier",
"start": 244,
"end": 258,
"name": "setRenewalTime" // setRenewalTime 실행 부분
},
"arguments": [
{
"type": "UnaryExpression",
"start": 259,
"end": 271,
"operator": "!",
"prefix": true,
"argument": {
"type": "Identifier",
"start": 260,
"end": 271,
"name": "renewalTime" // setRenewalTime에 들어간 인수
}
}
],
"optional": false
}
},
{
"type": "ExpressionStatement",
"start": 278,
"end": 295,
"expression": {
"type": "CallExpression",
"start": 278,
"end": 294,
"callee": {
"type": "Identifier",
"start": 278,
"end": 292,
"name": "cancelModalOff" // cancelModalOff 실행 부분
},
"arguments": [],
"optional": false
}
},
{
"type": "ExpressionStatement",
"start": 300,
"end": 317,
"expression": {
"type": "CallExpression",
"start": 300,
"end": 316,
"callee": {
"type": "Identifier",
"start": 300,
"end": 314,
"name": "updateFormData" // updateFormData 실행 부분
},
"arguments": [],
"optional": false
}
}
]
}
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
📚 espree
코드 분석 결과를 바탕으로 ESLint가 뭘 수정하고 변경할지 결정
module.export = {
meta: {
type: "problem",
docs: {
description: "Disallow the use of `debugger`",
recommended: true,
},
fixable: null, // 코드 수정 불가
schema: [], // 옵션 미설정
},
create(context) {
// 실제 코드에서 문제점 확인
return {
DebuggerStatement(node) {
context.report({
// debuggerStatement 확인 후 리폿
node,
message: "Unexpected 'debugger' statement.",
fix: function (fixer) {
return fixer.remove(node);
},
});
},
};
},
};
8.1.2 eslint-plugin과 eslint-config
eslint-plugin
은 언급했던 규칙들을 모아놓은 패키지이며, 대시(-)를 뒤에 붙여 다양한 상황에 해당하는 규칙들을 제공한다.
eslintrc
├─ conf
│ ├─ config-schema.js
│ └─ environments.js
├─ dist
│ ├─ eslintrc-universal.cjs
│ ├─ eslintrc-universal.cjs.map
│ ├─ eslintrc.cjs
│ └─ eslintrc.cjs.map
├─ lib
│ ├─ config-array
│ │ ├─ config-array.js
│ │ ├─ config-dependency.js
│ │ ├─ extracted-config.js
│ │ ├─ ignore-pattern.js
│ │ ├─ index.js
│ │ └─ override-tester.js
│ ├─ shared
│ │ ├─ ajv.js
│ │ ├─ config-ops.js
│ │ ├─ config-validator.js
│ │ ├─ deprecation-warnings.js
│ │ ├─ naming.js
│ │ ├─ relative-module-resolver.js
│ │ └─ types.js
│ ├─ cascading-config-array-factory.js
│ ├─ config-array-factory.js
│ ├─ flat-compat.js
│ ├─ index-universal.js
│ └─ index.js
├─ LICENSE
├─ package.json
├─ README.md
└─ universal.js
eslint-config
는 위의eslint-plugin
의 묶음 판매 패키지라고 볼 수 있으며, 이는 여러 프로젝트에 걸쳐 동일하게 사용 가능한 ESLint 관련 설정을 제공한다.
{
"parser": "@typescript-eslint/parser",
"extends": [
"next/core-web-vitals",
"next",
"eslint:recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"plugin:tailwindcss/recommended",
"prettier"
],
"plugins": ["import", "@typescript-eslint", "react-hooks", "tailwindcss", "prefer-arrow"],
"rules": {
"import/order": [
"error",
{
"groups": [
"external", // 외부 라이브러리
"builtin", // Node.js 내장 모듈
"internal", // 경로 별칭 (@)
"parent", // 부모 경로 (..)
"sibling", // 같은 경로 (./)
"index", // 현재 디렉토리 (./)
"object"
],
"pathGroups": [
{
"pattern": "react*",
"group": "external",
"position": "before"
}
],
"pathGroupsExcludedImportTypes": ["react*"]
}
],
"arrow-body-style": ["error", "always"],
"curly": ["error"],
"max-depth": ["error", 4],
"import/export": "warn",
"react/react-in-jsx-scope": "off",
"prefer-arrow/prefer-arrow-functions": [
"error",
{
"disallowPrototype": true,
"singleReturnOnly": false,
"classPropertiesAllowed": false
}
]
},
"settings": {
"import/resolver": {
"typescript": true
}
}
}
👍 원래 직접 만드는 것보다 남이 해준 요리가 더 맛있다.
- 개발자가 직접 패키지를 짜는 경우는 거의 없고, 일부 IT 기업에서 만들어둔 템플릿들을 활용하는 느낌으로 많이 사용한다.
개추! (개발자 추천)
eslint-config-airbnb
- 가장 대중적인
eslint-config
템플릿이다.
- 가장 대중적인
titicaca/tripole-config-kit
유지보수가 활발한 편에 속하는
config
이며, airbnb의config
를 기반으로 하지 않는데도 불구하고 왠만한 규칙을 제공하여 사용 시 큰 지장이 없다.또한 외부 제공 규칙에 대한 테스트 코드가 존재하여 각 환경에 맞게 사용할 수 있다.
eslint-config-next
Next.js
를 이용한 프로젝트는 그냥 이놈 쓰자.
8.1.3 나만의 ESLint 규칙 만들기
🔖 나만의 ESLint 규칙을 만들어 나에게 가장 익숙한 규칙으로 코드를 작성하자
- 이미 존재하는 규칙을 커스터마이징하여 적용하는 것은 매우 유용하다.
// no improt react
module.exports = {
rules: {
'no-restricted-imports' : [
'error',
{
path: [
{
name: 'react',
importNames: ['default'] // import react만 제외하기 위함
message: '이제부턴 import react는 안해도 돼!'
}
]
}
]
}
}
- 아예 새로운 나만의 규칙을 만들 수도 있다.
// new Date 금지하기
module.exports = {
meta: {
type: "problem",
docs: {
description: "Disallow use of `new Date()` without arguments",
category: "Possible Errors",
recommended: false,
},
fixable: "code", // 코드 수정 가능
schema: [], // 옵션 미설정
},
create(context) {
return {
NewExpression(node) {
if (node.callee.name === "Date" && node.arguments.length === 0) {
// 코드 객체의 이름이 Date임과 동시에 인수의 길이가 0일 경우
context.report({
node,
message: "Use of `new Date()` without arguments is not allowed.",
fix: function (fixer) {
// 자동 수정: `new Date()`를 `new Date(/* specify arguments */)`로 변경
return fixer.replaceText(node, "new Date(/* specify arguments */)");
},
});
}
},
};
},
};
📂 만든 규칙들을 배포해보자!
먼저
yo
와generate-eslint
를 활용해eslint-plugin
구성 환경을 만든다.구성된 디렉토리 및 파일에서
rules/no-new-date.js
파일을 열고 앞에서 작성한 규칙을 붙여넣는다.npm publish
로 배포하고 원하는 프로젝트에 설치 및 사용한다.
8.1.4 주의할 점
🔖 잘못 설정하면 원치 않는 결과가 나온다.
코드의 포메팅을 돕는
Prettier
과의 충돌이 일어날 수 있으므로 항상 주의해야 한다.서로 충돌하지 않게끔 규칙을 잘 설정한다.
JS, TS는 ESLint에 맡기고, 그 외는
Prettier
에 맡긴다.
특정 규칙을 임시로 제외하고 싶은 경우
react-hooks/no-exhaustive-deps
주석을 사용한다.- 모든 규칙은 존재하는 이유가 있으니까 핑계대지 말고 잘 점검하고 사용하자
ESLint 버전이 충돌하여 문제가 생길 수 있다.
- 설치하고자 하는
config
밑plugin
및 프로젝트가 지원하는 버전을 잘 확인해야한다.
- 설치하고자 하는
8.1.5 책 정리 + 주관적인 정리
🔖 책 정리
ESLint는 개발자의 숟가락과도 같다.
잘 정리된 규칙을 사용하거나 직접 커스텀하는 등 자유도가 높지만 그만큼 알맞게 사용해야 한다.
익숙해진다면 본인만의
eslint-config
를 만들어보는 것도 큰 도움이 될 것이다.
🏷️ 주관적인 정리
ESLint는 항상 가까이 있으나 막상 보면 어색한 친구라고 생각한다.
ESLint의 규칙들은 모두 존재하는 이유가 있으므로, 항상 신경쓰며 잘 사용하자.
8.2 리액트 팀이 권장하는 리액트 테스트 라이브러리
✨ 프론트엔드는 퍼블리싱만이 다가 아니므로, 각각의 개발 환경에서 항상 테스트해야 한다.
8.2.1 React Testing Library란?
🔖 DOM Testing Library를 기반으로 만들어진 HTML이 없는 환경에서 HTML 및 DOM을 사용하여 테스팅이 가능하도록 설계
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const dom = new JSDOM("<!DOCTYPE html><p>Hello world</p>");
console.log(dom.window.document.querySelector("p").textContext);
jsdom
을 사용해 HTML이 있는 것처럼 DOM을 불러와 조작 가능하다.실제로 리액트 컴포넌트를 렌더링하지 않아도 원하는대로 작동하는지 검사할 수 있다.
8.2.2 자바스크립트 테스트의 기초
🔖 기본적인 테스트 코드 작성법을 알아보자
// 테스트할 함수
function sum(a, b) {
return a + b;
}
// 테스트할 코드
let actual = sum(1, 2);
let expected = 3;
if (expected !== actual) {
throw new Error(`${expectred}는 기대와 다르다!`);
}
actual = sum(2, 3);
expected = 4;
if (expected !== actual) {
throw new Error(`${expectred}는 기대와 다르다!`);
}
- 기본적으로 테스트할 함수의 리턴값이 예상하는 답과 같다면 이것에 대해 성공/실패 여부를 판단하도록 코드를 구성하는 것이 기본이다.
// Node.js의 assert
const assert = require("assert");
function sum(a, b) {
return a + b;
}
assert.equal(sum(1, 2), 3);
assert.equal(sum(2, 2), 5); // AssertionError ...
Node.js
에서 기본적으로assert
메서드를 제공한다.또한
equal
뿐만 아니라 다양한 결과값을 상황에 맞게 보여주는 키들이 존재한다.
❗하지만 테스트 코드는 단순히 값뿐만 아니라 테스트의 관한 실제 정보 또한 반환해야 한다.
이는 테스팅 프레임워크를 통해 볼 수 있다.
Jest
,Mocha
,Karma
,Jasmine
등의 다양한 프레임워크가 존재한다.그중에서도
Jest
의expect
패키지를 알아보자
// add.js
function add(a, b) {
return a + b + 1; // 의도적으로 오류를 넣음
}
module.exports = add;
const add = require("./add");
test("adds 1 + 2 to equal 3", () => {
expect(add(1, 2)).toBe(3);
});
FAIL ./add.test.js
✕ adds 1 + 2 to equal 3 (5ms)
● adds 1 + 2 to equal 3
expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: 4
3 | test('adds 1 + 2 to equal 3', () => {
4 | expect(add(1, 2)).toBe(3);
5 | });
| ^
at Object.<anonymous> (add.test.js:4:24)
Jest
가 제공하는 자체적인 '글로벌' 값들을 이용해서 테스트 관련 정보를 임포트 하지 않고 효율적으로 정보를 볼 수 있다.
8.2.3 리액트 컴포넌트 테스트 코드 작성하기
🔖 컴포넌트를 렌더링하고 컴포넌트에서 필요 시 특정 액션을 수행하며, 두 과정을 통해 기대와 실제를 비교한다.
create-react-app
에는 이미 테스팅 라이브러리가 포함되어 있다.
npx create-react-app react-test --templete typescript
// src/App.tsx
function App() {
return (
<div className="App">
<p>learn react</p>
</div>
);
}
// src/App.test.tsx
import React from "react";
import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
App
컴포넌트를 렌더링하고 'learn react' 라는 문자열을 가진 DOM 요소를 찾는다.expect(linkElement).toBeInTheDocument()
어설션을 활용해 해당 요소가 document 내부 요소인지 확인한다.getBy
,findBy
,queryBy
등 다양한 조건의 테스팅 어설션이 존재한다.
🏷️ 정적 컴포넌트를 테스트하는 방법은 상태가 존재하지 않아 항상 결과가 같으므로 어렵지 않다.
const InteractiveComponent: React.FC = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>정적 컴포넌트</h1>
<p>현재 값: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
};
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import InteractiveComponent from "./InteractiveComponent";
describe("InteractiveComponent", () => {
beforeEach(() => {
render(<InteractiveComponent />);
});
it("모든 요소가 올바르게 존재합니다.", () => {
const headingElement = screen.getByText(/정적 컴포넌트/i);
const paragraphElement = screen.getByText(/현재 값: 0/i);
const buttonElement = screen.getByText(/증가/i);
expect(headingElement).toBeInTheDocument();
expect(paragraphElement).toBeInTheDocument();
expect(buttonElement).toBeInTheDocument();
});
it("버튼을 클릭하면 count가 증가합니다.", () => {
const buttonElement = screen.getByText(/증가/i);
fireEvent.click(buttonElement);
const updatedParagraphElement = screen.getByText(/현재 값: 1/i);
expect(updatedParagraphElement).toBeInTheDocument();
});
});
- 여기서도
beforeEach
,describe
,it
등 다양한 메서드를 상황에 맞게 사용 가능하다.
🏷️ 동적 컴포넌트는 기본적으로 신경쓸 부분이 많아져 훨씬 어렵다.
- 정적 컴포넌트와 같은 순수한 '무상태' 컴포넌트는 상황에 따라 달라지는 여러 값들을 신경쓸 필요가 없지만, 동적은 다르다.
// src/DynamicComponent.tsx
const DynamicComponent: React.FC = () => {
const [text, setText] = useState("");
const handleClick = () => {
alert(`Button clicked with text: ${text}`);
};
return (
<div>
<h1>Dynamic Component</h1>
<input type="text" placeholder="Type something..." value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleClick}>Click Me</button>
<p>You typed: {text}</p>
</div>
);
};
// src/DynamicComponent.test.tsx
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import DynamicComponent from "./DynamicComponent";
const setup = () => {
const utils = render(<DynamicComponent />);
const input = screen.getByPlaceholderText(/Type something.../i);
const button = screen.getByText(/Click Me/i);
return {
input,
button,
...utils,
};
};
describe("DynamicComponent", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders the initial state correctly", () => {
const { input, button } = setup(); // 컴포넌트 렌더링 및 필요 요소 반환
const headingElement = screen.getByText(/Dynamic Component/i);
const paragraphElement = screen.getByText(/You typed:/i);
expect(headingElement).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(button).toBeInTheDocument();
expect(paragraphElement).toHaveTextContent("You typed:");
});
it("updates the text as the user types", async () => {
const { input } = setup();
await userEvent.type(input, "Hello, world!"); // 유저가 타이핑하는 것을 흉내
const updatedParagraphElement = screen.getByText(/You typed: Hello, world!/i);
expect(updatedParagraphElement).toBeInTheDocument();
});
it("calls handleClick when button is clicked", () => {
const { button } = setup();
const handleClickSpy = jest.spyOn(window, "alert").mockImplementation(() => {}); // alert 함수를 모킹하여 실제로 동작하진 않도록 구현
fireEvent.click(button); // 버튼 클릭 이벤트 시뮬레이션
expect(handleClickSpy).toHaveBeenCalledWith("Button clicked with text: ");
});
it("calls handleClick with the correct text", async () => {
const { input, button } = setup();
await userEvent.type(input, "Hello, world!");
const handleClickSpy = jest.spyOn(window, "alert").mockImplementation(() => {});
fireEvent.click(button);
expect(handleClickSpy).toHaveBeenCalledWith("Button clicked with text: Hello, world!");
});
});
- 여기서도 마찬가지로
userEvent.type
,jest.spyOn(window, 'alert').mockImplementation()
등 상황에 맞는 다양한 메서드를 사용 가능하다.
🏷️ 비동기 이벤트 발생 컴포넌트도 테스트 해보자
fetch
가 추가됨으로서 신경써야할 사항이 더욱 많아졌다...
// src/mocks/handlers.ts
import { rest } from "msw";
export const handlers = [
rest.get("/api/data", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ message: "Hello from the mocked API!" }));
}),
];
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// src/FetchComponent.test.tsx
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { server } from "./mocks/server";
import { rest } from "msw";
import FetchComponent from "./FetchComponent";
// MSW 서버 시작 및 종료 설정
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 서버를 기본 설정으로 되돌림
afterAll(() => server.close());
test("renders fetched data correctly", async () => {
render(<FetchComponent />);
// 로딩 텍스트가 표시되는지 확인
expect(screen.getByText("Loading...")).toBeInTheDocument();
// 데이터가 표시될 때까지 기다림
await waitFor(() => {
expect(screen.getByText("Message: Hello from the mocked API!")).toBeInTheDocument();
});
});
test("handles server error", async () => {
// API 요청을 실패하도록 모킹
server.use(
rest.get("/api/data", (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: "Internal Server Error" }));
})
);
render(<FetchComponent />);
// 로딩 텍스트가 표시되는지 확인
expect(screen.getByText("Loading...")).toBeInTheDocument();
// 오류 메시지가 표시될 때까지 기다림
await waitFor(() => {
expect(screen.getByText("Error: Internal Server Error")).toBeInTheDocument();
});
});
MSW는 서비스 워커를 활용해 실제 네트워크 요청을 가로채는 방식으로 모킹을 구현하여 간편하고, 다양한 환경에서 수월하게 동작한다.
데이터 모킹을 활용해 외부에서 데이터를 받아와야 하는 로직 특성 상 의존해야 하는 데이터에 대한 분리를 진행하고 코드 자체 테스트에 더욱 집중할 수 있도록 한다.
모킹에 대해 알아보자
8.2.4 사용자 정의 훅 테스트하기
🔖 사용자 정의 훅을 테스트하기 위해 react-hooks-testing-library 를 활용해보자
useEffectDebugger
훅을 활용해 어떤props
의 변경으로 리렌더링 됐는지 확인한다.
// src/hooks/useEffectDebugger.ts
import { useEffect, useRef } from 'react';
const useEffectDebugger = (effect: React.EffectCallback, dependencies: any[], dependencyNames: string[]) => {
const previousDepsRef = useRef<any[]>();
useEffect(() => {
const changes: { [key: string]: { from: any; to: any } } = {};
const previousDeps = previousDepsRef.current;
dependencies.forEach((dep, i) => {
if (previousDeps && previousDeps[i] !== dep) {
changes[dependencyNames[i]] = {
from: previousDeps[i],
to: dep,
};
}
}); // 뭐 때문에 리렌더링 된건지 비교
if (Object.keys(changes).length) {
console.log('[useEffectDebugger] Dependency changes:', changes);
} // 변경 사항이 있다면 체크
previousDepsRef.current = dependencies;
effect();
}, dependencies);
};
export default useEffectDebugger;
8.2.5 테스트를 작성하기에 앞서 고려해야 할 점
🔖 테스트 커버리지는 소프트웨어가 얼마나 잘 테스트 됐는지를 나타내는 지표지만, 맹신해선 안된다.
테스트 코드 작성 전 우선과제는 앱에서 가장 취약하거나 중요한 부분을 파악하는 것이다.
앱의 가장 핵심적인 부분부터 하나하나 테스트코드를 작성하고, 항상 소프트웨어 품질에 대한 확신을 얻기 위해 노력하자
8.2.6 그 밖에 해볼 만한 여러 가지 테스트
유닛 테스트 : 각각의 코드나 컴포넌트가 독립적으로 분리된 경우 의도대로 작동하는지 검증
통합 테스트 : 유닛 테스트를 통과한 여러 컴포넌트가 하나의 기능으로 정상 동작하는지 검증
엔드 투 엔드 : E2E 테스트라 지칭하며 실제 사용자와 같은 로봇을 활용해 앱 기능을 전체적으로 테스트
👍 E2E까지 가는건 어렵지만, 그만큼 자신감이 생길 것이다.
8.2.7 책 정리 + 주관적인 정리
🔖 책 정리
테스트는 결과물이 정해져있는 앱과 다르게 다양하게 코드를 시도할 수 있다.
다양한 방법들이 궁극적으로 추구하는 목표는 결국 앱이 비즈니스 요구사항을 충족하는지 확인하는 것이다.
따라서 테스트 코드의 대한 작성과 이해, 확신은 결국 소프트웨어의 품질과 직결된다.
🏷️ 주관적인 정리
리액트 및 브라우저 개발자 도구만으로도 벅차다고 생각했는데 정말로 쉽지 않다.
테스트 코드 및 라이브러리의 활용은 먼 미래겠지만, 지금부터 조금씩 익혀놓는다면 분명 나중에 큰 도움이 될 것이라는 생각이 들었다.
'Front-End Study > 모던 리액트 딥다이브 스터디' 카테고리의 다른 글
모던 리액트 딥다이브 15회차 - [10-1, 10-2] (0) | 2024.08.07 |
---|---|
모던 리액트 딥다이브 - 14회차 [9-1 ~ 9-4] (0) | 2024.08.07 |
모던 리액트 딥다이브 - 12회차 [7-1 ~ 7-7] (0) | 2024.07.11 |
모던 리액트 딥다이브 - 11회차 [6-1, 6-2, 6-3, 6-4] (0) | 2024.07.11 |
모던 리액트 딥다이브 - 10회차 [5-1, 5-2] (1) | 2024.06.30 |