티스토리 뷰
우아한테크코스 레벨 3 (VoTogether팀) 2주차 - Webpack을 이용해 React 프로젝트 환경 설정하기
YG - 96년생 , 강아지 있음, 개발자 희망 2023. 7. 23. 17:46Webpack을 이용해 React 프로젝트를 설정하게 되었습니다. 생각보다 간단하지만은 않아서 환경 설정하는 과정을 기록해두려고 합니다.
모든 세팅이 끝난 Wepack-React 보일러플레이트 저장소
GitHub - Gilpop8663/webpack-react-boilerplate: react-18, webpack5, typescript, storybook, jest, msw, @tanstack/react-query_v4, s
react-18, webpack5, typescript, storybook, jest, msw, @tanstack/react-query_v4, styled-components, eslint, dotenv, whatwg-fetch - GitHub - Gilpop8663/webpack-react-boilerplate: react-18, webpack5, ...
github.com
1. package.json파일 생성
npm init -y
2. 기본 패키지 설치 및 설정
.gitignore 설정
/node_modules
.env
/dist
React 필수 패키지 설치
npm i react react-dom react-router-dom
타입스크립트 및 타입 패키지 설치
npm i -D typescript @types/react @types/react-dom
tsconfig.json 파일 생성
절대 경로가 설정되어 있고, 테스트 폴더 및 styled-components.d.ts가 include 되어 있는 파일이니 사용하시는 용도에 따라 수정이 필요합니다.
{
"compilerOptions": {
"target": "es2021",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noEmit": false,
"baseUrl": "src",
"paths": {
"@assets/*": ["assets/*"],
"@pages/*": ["pages/*"],
"@components/*": ["components/*"],
"@hooks/*": ["hooks/*"],
"@styles/*": ["styles/*"],
"@utils/*": ["utils/*"],
"@constants/*": ["constants/*"],
"@type/*": ["types/*"],
"@atoms/*": ["atoms/*"],
"@selectors/*": ["selectors/*"],
"@routes/*": ["routes/*"],
"@api/*": ["api/*"],
"@mocks/*": ["mocks/*"]
},
"outDir": "./dist"
},
"include": ["src", "src/custom.d.ts", "__tests__", "styled-components.d.ts"],
"exclude": ["node_modules"]
}
.prettierrc.js 설정
module.exports = {
printWidth: 100, // 한줄당 문자 100개로 제한
singleQuote: true, // "" => ''
arrowParens: 'avoid', // arrow function parameter가 하나일 경우 괄호 생략
};
Webpack 설정
npm install --save-dev webpack
npm install --save-dev webpack-cli
npm install webpack-dev-server --save-dev
npm i --save-dev html-webpack-plugin
npm install --save-dev clean-webpack-plugin
npm install dotenv-webpack --save-dev
npm install ts-loader --save-dev
npm install --save-dev css-loader
npm install --save-dev style-loader
webpack.common.js
빌드 시 dist 폴더에 결과물이 생깁니다. 스토리북에서 msw를 설정하기 위해 devServer의 static을 public으로 해주었습니다. dotenv를 사용하기 위한 설정이 있습니다.
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const DotenvWebpack = require('dotenv-webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
publicPath: '/',
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {
'@assets': path.resolve(__dirname, 'src/assets'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@components': path.resolve(__dirname, 'src/components'),
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@styles': path.resolve(__dirname, 'src/styles'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@constants': path.resolve(__dirname, 'src/constants'),
'@type': path.resolve(__dirname, 'src/types'),
'@atoms': path.resolve(__dirname, 'src/atoms'),
'@selectors': path.resolve(__dirname, 'src/selectors'),
'@routes': path.resolve(__dirname, 'src/routes'),
'@api': path.resolve(__dirname, 'src/api'),
'@mocks': path.resolve(__dirname, 'src/mocks'),
},
},
module: {
rules: [
{
test: /\.(js|ts|tsx)$/i,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.svg/,
type: 'asset/inline',
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new CleanWebpackPlugin(),
new DotenvWebpack(),
],
devtool: 'inline-source-map',
devServer: {
static: 'public',
hot: true,
open: true,
},
};
webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development', // 현재 개발 모드
devtool: 'eval', // 최대성능, 개발환경에 추천
devServer: {
historyApiFallback: true,
port: 3000,
hot: true,
},
});
webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'production', // 현재 배포 모드
devtool: 'hidden-source-map', // 느리지만 안전 배포에 추천
});
webpack 명령어 설정
package.json
"scripts": {
"dev": "webpack-dev-server --config webpack.dev.js --open --hot",
"build": "webpack --config webpack.prod.js",
"start": "webpack --config webpack.dev.js",
}
public/index.html , public 폴더에 index.html 생성 (react code를 집어넣을 html)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
src/index.tsx, src 폴더에 index.tsx 생성 (html과 App 컴포넌트를 연결해 주는 파일)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
src/App.tsx 생성
import React from 'react';
const App = () => (
<>
<h1>Hi!</h1>
</>
);
export default App;
추가적인 패키지 설치 및 설정
MSW
npx msw init public/ save을 하면 public 폴더에 mockServiceWorker 파일이 생기게 됩니다.
npm install msw --save-dev
npx msw init public/ --save
src/mocks/worker.ts
import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
src/mocks/handlers.ts
import { mockPostList } from './postList';
export const handlers = [...mockPostList];
src/mocks/postList.ts
예제 파일입니다.
import { rest } from 'msw';
export const MOCK_POST_LIST = [
{
id: 1,
text: 'hi',
},
{
id: 2,
text: 'hi2',
},
{
id: 3,
text: 'hi3',
},
];
export const mockPostList = [
rest.get('/posts', (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MOCK_POST_LIST));
}),
];
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
if (process.env.NODE_ENV === 'development') {
const { worker } = require('./mocks/worker');
worker.start();
}
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Storybook
npx storybook@latest init
npm install --save-dev tsconfig-paths-webpack-plugin
npm i msw msw-storybook-addon -D
스토리북 MSW 설정
.storybook/public/mockServiceWorker.js 파일 생성 후 public 폴더에 있는 mockServiceWorker.js를 그대로 복사 붙여 넣기 해줍니다.
.storybook/preview.tsx
import type { Preview } from '@storybook/react';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { handlers } from '../src/mocks/handlers';
initialize();
const preview: Preview = {
parameters: {
msw: handlers,
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
decorators: [
mswDecorator,
Story => (
<BrowserRouter>
<Story />
</BrowserRouter>
),
],
};
if (typeof global.process === 'undefined') {
const { worker } = require('../src/mocks/worker');
worker.start();
}
export default preview;
스토리북 절대경로 설정
.storybook/main.ts
import path from 'path';
import type { StorybookConfig } from '@storybook/react-webpack5';
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {
autodocs: 'tag',
},
webpackFinal: async config => {
if (!config.resolve) {
config.resolve = {};
}
if (!config.resolve.plugins) {
config.resolve.plugins = [];
}
config.resolve.plugins.push(
new TsconfigPathsPlugin({
configFile: path.resolve(__dirname, '../tsconfig.json'),
})
);
return config;
},
staticDirs: ['./public'],
};
export default config;
Jest
npm install --save-dev jest jest-environment-jsdom @types/jest
// 리엑트 컴포넌트, 훅 테스팅 패키지
npm install --save-dev @testing-library/react
// node 환경에서 fetch를 테스트할 수 있도록 하기 위해 설치하는 패키지
npm install --save-dev whatwg-fetch
jest.config.js
절대경로 설정이 되어있는 세팅입니다. 따로 수정이 필요해요
module.exports = {
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@assets/(.*)$': '<rootDir>/src/assets/$1',
'^@pages/(.*)$': '<rootDir>/src/pages/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@styles/(.*)$': '<rootDir>/src/styles/$1',
'^@api/(.*)$': '<rootDir>/src/api/$1',
'^@type/(.*)$': '<rootDir>/src/types/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@constants/(.*)$': '<rootDir>/src/constants/$1',
'^@hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^@mocks/(.*)$': '<rootDir>/src/mocks/$1',
},
setupFilesAfterEnv: ['./jest.setup.js'],
transformIgnorePatterns: ['<rootDir>/node_modules/'],
};
jest.setup.js
테스트에 필요한 환경설정을 하는 파일입니다. msw 테스트에 필요한 세팅이 적용된 파일입니다.
import 'whatwg-fetch';
import { setupServer } from 'msw/node';
import { handlers } from './src/mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
루트에 __tests__ 폴더 생성 후 테스트 파일 생성
__tests__/unit.test.ts
describe('테스트 설정한다.', () => {
test('1 + 1 = 2', () => {
expect(1 + 1).toBe(2);
});
});
__tests_/hook.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCount } from '@hooks/useCount';
test('useCount hook을 테스트한다.', () => {
const { result } = renderHook(() => useCount());
act(() => {
result.current.increase();
});
expect(result.current.count).toBe(1);
});
src/hooks/useCount.ts
예제 파일입니다.
import { useState } from 'react';
export const useCount = () => {
const [count, setCount] = useState(0);
const increase = () => {
setCount(count + 1);
};
return { count, increase };
};
__tests__/postList.ts
import { MOCK_POST_LIST } from '@mocks/postList';
describe('게시글 목록을 통신하여 불러올 수 있다.', () => {
test('게시글 목록을 볼러올 수 있다.', async () => {
const data = await fetch('/posts').then(response => response.json());
expect(data).toEqual(MOCK_POST_LIST);
});
});
styled-component
npm install styled-components -D
src/styles/reset.ts
export const reset = /*css*/ `
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
html,
body,
p,
ol,
ul,
li,
dl,
dt,
dd,
blockquote,
figure,
fieldset,
legend,
textarea,
pre,
iframe,
hr,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
padding: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 100%;
font-weight: normal;
}
ul {
list-style: none;
}
button,
input,
select {
margin: 0;
}
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
img,
video {
height: auto;
max-width: 100%;
}
iframe {
border: 0;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0;
}
button{
background: none;
}
a{
color: inherit;
text-decoration: none;
}
`;
src/styles/globalStyle.ts
import { createGlobalStyle } from 'styled-components';
import { reset } from './reset';
export const GlobalStyle = createGlobalStyle`
${reset}
* {
padding: 0;
margin: 0;
box-sizing: border-box;
border:none
}
ul,
li {
list-style: none;
}
html,
body {
font-family: sans-serif;
font-size: 62.5%;
}
:root {
/* Colors *****************************************/
--primary-color: #FA7D7C;
--white: #ffffff;
--slate: #94A3B8;
--gray: #F4F4F4;
--red: #F51A18;
--dark-gray: #929292;
--header: #1f1f1f;
--graph-color-purple:#853DE1;
--graph-color-green:#5AEAA5;
/* Fonts *****************************************/
--text-title: 600 2rem/2.4rem san-serif;
--text-subtitle: 600 1.8rem/2.8rem san-serif;
--text-body: 400 1.6rem/2.4rem san-serif;
--text-caption: 400 1.4rem/2rem san-serif;
--text-small: 400 1.2rem/1.8rem san-serif;
}
`;
src/styles/theme.ts
import { DefaultTheme } from 'styled-components';
const breakpoint = {
/** @media (min-width: 576px) { ... } */
sm: '576px',
/** @media (min-width: 960px) { ... } */
md: '960px',
/** @media (min-width: 1440px) { ... }*/
lg: '1440px',
};
const zIndex = {
header: 100,
modal: 200,
};
export type ZIndex = typeof zIndex;
export type Breakpoint = typeof breakpoint;
export const theme: DefaultTheme = {
breakpoint,
zIndex,
};
src/styles/styled-components.d.ts 파일 생성
이 파일에 theme에 사용되는 CSS 속성들을 선언함으로써 theme을 사용할 때 자동 완성이 가능하게 됩니다.
import { Breakpoint, ZIndex } from '@styles/theme';
import 'styled-components';
declare module 'styled-components' {
export interface DefaultTheme {
breakpoint: Breakpoint;
zIndex: ZIndex;
}
}
src/App.tsx
App.tsx에 글로벌 스타일 적용 및 theme 적용
import React from 'react';
import { GlobalStyle } from '@styles/globalStyle';
import { theme } from '@styles/theme';
import { ThemeProvider } from 'styled-components';
const App = () => (
<ThemeProvider theme={theme}>
<GlobalStyle />
</ThemeProvider>
);
export default App;
.storybook/preview.tsx
GlobalStyle을 추가해 줍니다.
import type { Preview } from '@storybook/react';
import { GlobalStyle } from '../src/styles/globalStyle';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { handlers } from '../src/mocks/handlers';
initialize();
const preview: Preview = {
parameters: {
msw: handlers,
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
decorators: [
mswDecorator,
Story => (
<BrowserRouter>
<GlobalStyle />
<Story />
</BrowserRouter>
),
],
};
if (typeof global.process === 'undefined') {
const { worker } = require('../src/mocks/worker');
worker.start();
}
export default preview;
SVG 파일 인식 못하는 문제
svg를 사용하려고 하면 선언해 달라는 에러가 나옵니다.
src/svg.d.ts 생성
declare module '*.svg' {
const content: any;
export default content;
}
@tanstack-react-query 설치
npm i @tanstack/react-query
src/App.tsx 앱에 React-Query 적용
QueryClientProvider를 추가해 줍니다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'styled-components';
import { GlobalStyle } from '@styles/globalStyle';
import { theme } from '@styles/theme';
const queryClient = new QueryClient();
const App = () => (
<ThemeProvider theme={theme}>
<GlobalStyle />
<QueryClientProvider client={queryClient}></QueryClientProvider>
</ThemeProvider>
);
export default App;
. storybook/preview.tsx 스토리북에 React-Query 적용
QueryClient, QueryClientProvider과 같은 설정을 해줍니다.
import type { Preview } from '@storybook/react';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { GlobalStyle } from '../src/styles/globalStyle';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { handlers } from '../src/mocks/handlers';
const queryClient = new QueryClient();
initialize();
const preview: Preview = {
parameters: {
msw: handlers,
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
decorators: [
mswDecorator,
Story => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<GlobalStyle />
<Story />
</BrowserRouter>
</QueryClientProvider>
),
],
};
if (typeof global.process === 'undefined') {
const { worker } = require('../src/mocks/worker');
worker.start();
}
export default preview;
router 설정
src/App.tsx
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'styled-components';
import router from '@routes/router';
import { GlobalStyle } from '@styles/globalStyle';
import { theme } from '@styles/theme';
const queryClient = new QueryClient();
const App = () => (
<ThemeProvider theme={theme}>
<GlobalStyle />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ThemeProvider>
);
export default App;
src/routes/router.tsx
import { createBrowserRouter } from 'react-router-dom';
import Home from '@pages/Home';
import Post from '@pages/Post';
const router = createBrowserRouter([
{
path: '/',
children: [
{ path: '', element: <Home /> },
{
path: 'posts',
element: <Post />,
},
],
},
]);
export default router;
eslint, eslint 순서 정렬 설정
npm i eslint -D
npm i eslint-config-prettier -D
npm i eslint-config-react-app -D
npm i eslint-plugin-import -D
npm install eslint-plugin-storybook --save-dev
.eslintrc
import 순서가 설정되어 있습니다. 임의대로 수정하여 사용하시면 됩니다.
{
"extends": ["react-app", "eslint:recommended", "react-app/jest", "plugin:storybook/recommended"],
"rules": {
"no-var": "error", // var 금지
"no-multiple-empty-lines": "error", // 여러 줄 공백 금지
"no-console": ["error", { "allow": ["warn", "error", "info"] }],
"eqeqeq": "error", // 일치 연산자 사용 필수
"dot-notation": "error", // 가능하다면 dot notation 사용
"no-unused-vars": "error", // 사용하지 않는 변수 금지
"import/order": [
"error",
{
"groups": [
"type",
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"unknown"
],
"newlines-between": "always",
"pathGroups": [
{
"pattern": "react*",
"group": "external",
"position": "before"
},
{
"pattern": "@type/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@hooks/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@atoms/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@selectors/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@routes/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@api/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@pages/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@components/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@constants/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@utils/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@styles/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@assets/**",
"group": "internal",
"position": "after"
},
{
"pattern": "@mocks/**",
"group": "internal",
"position": "after"
}
],
"alphabetize": {
"caseInsensitive": true,
"order": "asc"
},
"pathGroupsExcludedImportTypes": []
}
]
}
}
설정 끝!
참고 자료
Install Storybook
Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It’s open source and free.
storybook.js.org
CRA 없이 리액트 시작하기 with webpack
1. CRA 없이 리액트를 시작해야하는 이유 create-react-app을 이용하면 정말 편하게 리액트 프로젝트를 시작할 수 있다. 웹팩과 바벨, 타입스크립트 설정까지 제공해준다. 하지만 내 방식대로 설정을
yogjin.tistory.com
CRA없이 TypeScript, Eslint, Prettier, Husky, Jest, StoryBook, Styled-Components 환경 구축하기
CRA없이 React환경을 구축합니다.
kagrin97-blog.vercel.app
Eslint Import/Order (import 순서) 설정하기
React/Next/Javscript Eslint Import/Order (import 순서) 설정하는 방법 0. 이 글을 쓰게 된 계기 과거부터 지금까지 import를 순서를 관리한다면 일일이 수동으로 알파벳 순으로 맞춰주었었는데 리엑트에서 폴
hell-of-company-builder.tistory.com
Install - Getting Started
Mock Service Worker Docs
mswjs.io
Installation | TanStack Query Docs
You can install React Query via NPM, or a good ol' `` via unpkg.com.
tanstack.com
Mock Service Worker Addon | Storybook: Frontend workshop for UI development
Mock API requests in Storybook with Mock Service Worker.
storybook.js.org
styled-components: Basics
Get Started with styled-components basics.
styled-components.com
'우아한테크코스' 카테고리의 다른 글
우아한테크코스 레벨 3 (VoTogether팀) 2주차 - 깃 PR, 이슈 템플릿 등록하는 방법 ,PR이 merge 되었을 때 관련 이슈를 자동으로 closed 하는 방법 , PR을 팀원 몇명 이상이 Approve 해줘야 머지할 수 있도록.. (0) | 2023.07.06 |
---|---|
우아한테크코스 레벨 3 (VoTogether팀) 1주차 - 팀 프로젝트 시작하는 방법 (0) | 2023.07.04 |
우아한테크코스 프론트앤드 5기 합격 후기 (일기장) (4) | 2022.12.30 |
우아한테크코스 프리코스 5기 1~4주 하면서 성장한 점 (0) | 2022.11.23 |
- Total
- Today
- Yesterday
- 스토리 북
- NextRequest
- 프론트앤드
- 우아한테크코스
- C언어
- javascript
- env
- error
- import/order
- React
- 노개북
- 위코드
- Storybook
- nodejs
- nextjs
- 윤성우 열혈C프로그래밍
- jest
- 원티드
- 북클럽
- 아차산
- WSL2
- TopLayer
- 초보
- CLASS
- 프리온보딩
- electron
- createPortal
- 노마드코더
- NextApiRequest
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |