해당 시리즈는 제목 그대로 김민준님의 '리액트를 다루는 기술'을 요약한 글입니다.
목차
21장 백엔드 프로그래밍: Node.js의 Koa 프레임워크
22장 mongoose를 이용한 MongoDB 연동 실습
Koa
Koa는 Express의 기존 개발 팀이 개발한 프레임워크.
Express는 미들웨어, 라우팅, 템플릿, 파일 호스팅 등과 같은 다양한 기능이 자체적으로 내장되어 있는 반면, Koa는 미들웨어 기능만 갖추고 있으며 나머지는 다른 라이브러리를 적용하여 사용함. 그래서 Express 보다 훨씬 가벼움.
// src/index.js
const Koa = require('koa');
const app = new Koa();
app.use((ctx) => {
ctx.body = 'hello world';
}
app.listen(4000, () => {
console.log('Listening to port 4000');
});
app.use 함수는 미들웨어 함수를 애플리케이션에 등록함.
ctx는 Context의 줄임말로 웹 요청과 응답에 관한 정보를 지니고 있음.
next는 다음 미들웨어를 호출하는 함수.
const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {
console.log(ctx.url);
console.log(1);
if (ctx.query.authorized !== '1') {
ctx.status = 401;
return;
}
next();
});
app.use((ctx, next) => {
console.log(2);
next();
});
app.use((ctx) => {
ctx.body = 'hello world';
});
app.listen(4000, () => {
console.log('Listening to port 4000');
});
Express와 달리 Koa는 next 함수를 호출하면 Promise를 반환함. 이 Promise는 다음에 처리해야 할 미들웨어가 끝나야 완료됨.
const Koa = require('koa');
const app = new Koa();
app.use((ctx, next) => {
console.log(ctx.url);
console.log(1);
if (ctx.query.authorized !== '1') {
ctx.status = 401;
return;
}
next().then(() => {
console.log('END');
});
});
app.use((ctx, next) => {
console.log(2);
next();
});
app.use((ctx) => {
ctx.body = 'hello world';
});
app.listen(4000, () => {
console.log('Listening to port 4000');
});
아래와 같이 async/await을 사용해도 됨.
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log(ctx.url);
console.log(1);
if (ctx.query.authorized !== '1') {
ctx.status = 401;
return;
}
await next();
console.log('END');
});
app.use((ctx, next) => {
console.log(2);
next();
});
app.use((ctx) => {
ctx.body = 'hello world';
});
app.listen(4000, () => {
console.log('Listening to port 4000');
});
koa-router 사용하기
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
router.get('/', (ctx) => {
ctx.body = '홈';
});
router.get('/about', (ctx) => {
ctx.body = '소개';
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(4000, () => {
console.log('Listening to port 4000');
});
라우터 파라미터와 쿼리
const Koa = require('koa');
const Router = require('koa-router');
const app = new Koa();
const router = new Router();
router.get('/', (ctx) => {
ctx.body = '홈';
});
router.get('/about/:name?', (ctx) => {
const { name } = ctx.params;
ctx.body = name ? `${name}의 소개` : '소개';
});
router.get('/posts', (ctx) => {
const { id } = ctx.query;
ctx.body = id ? `포스트 #${id}` : '포스트 아이디가 없습니다.';
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(4000, () => {
console.log('Listening to port 4000');
});
REST API
웹 애플리케이션을 만들려면 데이터베이스에서 정보를 입력하고 읽어와야 함. 그런데 웹 브라우저에서 데이터베이스에 직접 접속하여 데이터를 변경한다면 보안상 문제가 되므로 REST API를 사용함.
GET (조회)
POST (등록, 인증 작업)
DELETE (삭제)
PUT (새 정보로 교체)
PATCH (특정 필드 수정)
라우트 모듈화
// src/index.js
const Koa = require('koa');
const Router = require('koa-router');
const api = require('./api');
const app = new Koa();
const router = new Router();
router.use('/api', api.routes());
app.use(router.routes()).use(router.allowedMethods());
app.listen(4000, () => {
console.log('Listening to port 4000');
});
// src/api/index.js
const Router = require('koa-router');
const posts = require('./posts');
const api = new Router();
api.use('/posts', posts.routes());
module.exports = api;
// src/api/posts/index.js
const Router = require('koa-router');
const posts = new Router();
const printInfo = (ctx) => {
ctx.body = {
method: ctx.method,
path: ctx.path,
params: ctx.params,
};
};
posts.get('/', printInfo);
posts.post('/', printInfo);
posts.get('/:id', printInfo);
posts.delete('/:id', printInfo);
posts.put('/:id', printInfo);
posts.patch('/:id', printInfo);
module.exports = posts;
GET 메서드를 사용하는 API는 웹 브라우저에서 주소를 입력하여 테스팅할 수 있지만 POST, DELETE, PUT, PATCH 메서드를 사용하는 API는 자바스크립트로 호출해야 함. (Postman 사용)
라우트를 작성하는 과정에서 특정 경로에 미들웨어를 등록할 때는 다음과 같이 두 번째 인자에 함수를 선언해서 바로 넣어 줄 수 있음.
router.get('/', ctx => {
});
하지만 코드가 길어지면 가독성이 떨어지기 때문에 따로 분리해서 관리할 수 있음. 이 라우트 처리 함수만 모아 놓은 파일을 컨트롤러라고 함.
먼저 koa-bodyparser 미들웨어를 적용해야 함. 이 미들웨어는 POST/PUT/PATCH 같은 메서드의 Request Body에 JSON 형식으로 데이터를 넣어 주면, 파싱하여 서버에서 사용할 수 있음.
yarn add koa-bodyparser
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const api = require('./api');
const app = new Koa();
const router = new Router();
router.use('/api', api.routes());
app.use(bodyParser());
app.use(router.routes()).use(router.allowedMethods());
app.listen(4000, () => {
console.log('Listening to port 4000');
});
컨트롤러 파일 작성하기
// src/api/posts/posts.ctrl.js
let postId = 1; // id의 초깃값입니다.
// posts 배열 초기 데이터
const posts = [
{
id: 1,
title: '제목',
body: '내용',
},
];
/* 포스트 작성
POST /api/posts
{ title, body }
*/
exports.write = ctx => {
// REST API의 request body는 ctx.request.body에서 조회할 수 있습니다.
const { title, body } = ctx.request.body;
postId += 1; // 기존 postId 값에 1을 더합니다.
const post = { id: postId, title, body };
posts.push(post);
ctx.body = post;
};
/* 포스트 목록 조회
GET /api/posts
*/
exports.list = ctx => {
ctx.body = posts;
};
/* 특정 포스트 조회
GET /api/posts/:id
*/
exports.read = ctx => {
const { id } = ctx.params;
// 주어진 id 값으로 포스트를 찾습니다.
// 파라미터로 받아 온 값은 문자열 형식이니 파라미터를 숫자로 변환하거나,
// 비교할 p.id 값을 문자열로 변경해야 합니다.
const post = posts.find(p => p.id.toString() === id);
// 포스트가 없으면 오류를 반환합니다.
if (!post) {
ctx.status = 404;
ctx.body = {
message: '포스트가 존재하지 않습니다.',
};
return;
}
ctx.body = post;
};
/* 특정 포스트 제거
DELETE /api/posts/:id
*/
exports.remove = ctx => {
const { id } = ctx.params;
// 해당 id를 가진 post가 몇 번째인지 확인합니다.
const index = posts.findIndex(p => p.id.toString() === id);
// 포스트가 없으면 오류를 반환합니다.
if (index === -1) {
ctx.status = 404;
ctx.body = {
message: '포스트가 존재하지 않습니다.',
};
return;
}
// index번째 아이템을 제거합니다.
posts.splice(index, 1);
ctx.status = 204; // No Content
};
/* 포스트 수정(교체)
PUT /api/posts/:id
{ title, body }
*/
exports.replace = ctx => {
// PUT 메서드는 전체 포스트 정보를 입력하여 데이터를 통째로 교체할 때 사용합니다.
const { id } = ctx.params;
// 해당 id를 가진 post가 몇 번째인지 확인합니다.
const index = posts.findIndex(p => p.id.toString() === id);
// 포스트가 없으면 오류를 반환합니다.
if (index === -1) {
ctx.status = 404;
ctx.body = {
message: '포스트가 존재하지 않습니다.',
};
return;
}
// 전체 객체를 덮어씌웁니다.
// 따라서 id를 제외한 기존 정보를 날리고, 객체를 새로 만듭니다.
posts[index] = {
id,
...ctx.request.body,
};
ctx.body = posts[index];
};
/* 포스트 수정(특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
exports.update = ctx => {
// PATCH 메서드는 주어진 필드만 교체합니다.
const { id } = ctx.params;
// 해당 id를 가진 post가 몇 번째인지 확인합니다.
const index = posts.findIndex(p => p.id.toString() === id);
// 포스트가 없으면 오류를 반환합니다.
if (index === -1) {
ctx.status = 404;
ctx.body = {
message: '포스트가 존재하지 않습니다.',
};
return;
}
// 기존 값에 정보를 덮어씌웁니다.
posts[index] = {
...posts[index],
...ctx.request.body,
};
ctx.body = posts[index];
};
컨트롤러를 만들 때 exports.이름 = ... 형식으로 함수를 내보내면 아래와 같이 불러올 수 있습니다.
// src/api/posts/index.js
const Router = require('koa-router');
const postsCtrl = require('./posts.ctrl');
const posts = new Router();
posts.get('/', postsCtrl.list);
posts.post('/', postsCtrl.write);
posts.get('/:id', postsCtrl.read);
posts.delete('/:id', postsCtrl.remove);
posts.put('/:id', postsCtrl.replace);
posts.patch('/:id', postsCtrl.update);
module.exports = posts;
정리
먼저 REST API를 살펴본 후 어떻게 작동하는지를 자바스크립트 배열을 사용하여 구현하면서 알아보았습니다. 하지만 자바스크립트 배열을 사용하여 구현하면 서버를 재시작할 때 당연히 데이터가 소멸됩니다. 따라서 MySQL, MongoDB 등의 데이터베이스에 정보를 저장하여 관리해야합니다.
mongoose로 MongoDB 연동하기
흐름
- MongoDB 기본 지식 알아보기
- 작업 환경 설정하기
- mongoose로 데이터베이스 연결하기
- esm으로 ES 모듈 import/export 문법 사용하기
- 스키마와 모델 이해하기
- REST API 구현하기
- 페이지네이션 구현하기
기존에는 MySQL, OracleDB 와 같은 RDBMS (관계형 데이터베이스)를 자주 사용했는데 관계형 데이터베이스에는 몇 가지 한계가 있음.
- 데이터 스키마가 고정적 (예를 들어 새로 등록하는 데이터 형식이 기존에 있던 데이터들과 다르다면 기존 데이터를 모두 수정해야 새 데이터를 등록할 수 있음)
- 확장성 (데이터양이 늘어나면 여러 컴퓨터에 분산시키는 것이 아니라, 해당 데이터베이스 서버의 성능을 업그레이드 해야함)
MongoDB는 이런 한계를 극복한 문서 지향적 NoSQL 데이터베이스임. 유동적인 스키마를 지닐 수 있고 데이터양이 늘어나도 여러 컴퓨터로 분산하여 처리할 수 있음.
하지만, MongoDB가 무조건 기존의 RDBMS보다 좋은 것은 아님. 데이터의 구조가 자주 바뀐다면 MongoDB가 유리하지만 까다로운 조건으로 데이터를 필터링 해야 하거나, ACID 특성을 지켜야 한다면 RDBMS가 더 유리할 수 있음.
RDBMS와 MongoDB 용어
RDBMS | MongoDB |
테이블 | 컬렉션 |
레코드 | 문서 |
스키마를 디자인 하는 방식도 완전히 다름.
RDBMS에서는 각 포스트, 댓글마다 테이블을 만들어야 하지만 MongoDB는 문서하나에 모두 다 넣을 수 있음.
yarn add mongoose dotenv
.env 파일 (root경로에 만들지 않으면 에러 발생함)
PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
여기서 blog는 데이터베이스 이름.
지정한 데이터베이스가 서버에 없다면 자동으로 만들어줌.
// src/index.js
require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
import api from './api';
// 비구조화 할당을 통하여 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;
mongoose
.connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
.then(() => {
console.log('Connected to MongoDB');
})
.catch((e) => {
console.error(e);
});
( ... )
esm으로 ES 모듈 import/export 문법 사용하기 (생략)
데이터베이스의 스키마와 모델
스키마: 문서 내부의 각 필드가 어떤 형식으로 되어 있는지 정의하는 객체.
모델: 스키마를 사용해서 만드는 인스턴스(데이터베이스에서 실제 작업을 처리할 수 있는 함수들을 지니고 있는 객체)
// src/models/post.js
import mongoose from 'mongoose';
const { Schema } = mongoose;
const PostSchema = new Schema({
title: String,
body: String,
tags: [String],
publishedData: {
type: Date,
default: Date.now,
},
});
const Post = mongoose.model('Post', PostSchema);
export default Post;
model() 함수의 첫 번째 파라미터는 스키마 이름, 두 번째 파라미터는 스키마 객체. 데이터베이스는 스키마 이름을 복수 형태로 변경해서 컬렉션 이름을 만듦. (Post -> posts)
MongoDB Compass의 설치 및 사용 (생략)
데이터 생성과 조회
// src/api/posts/posts.ctrl.js
import Post from '../../models/post';
export const write = async (ctx) => {
const { title, body, tags } = ctx.request.body;
const post = new Post({
title,
body,
tags,
});
try {
await post.save();
ctx.body = post;
} catch (e) {
ctx.throw(500, e);
}
};
/* 포스트 목록 조회
GET /api/posts
*/
export const list = async (ctx) => {
try {
const posts = await Post.find().exec();
ctx.body = posts;
} catch (e) {
ctx.throw(500, e);
}
};
/* 특정 포스트 조회
GET /api/posts/:id
*/
export const read = async (ctx) => {
const { id } = ctx.params;
try {
const post = await Post.findById(id).exec();
if (!post) {
ctx.status = 404; // Not Found
return;
}
ctx.body = post;
} catch (e) {
ctx.throw(500, e);
}
};
포스트의 인스턴스를 만들 때는 new 키워드를 사용하고 생성자 함수의 파라미터에 정보를 지닌 객체를 넣음.
save() 함수를 실행시키면 데이터베이스에 저장.
모든 데이터를 조회할 때는 모델 인스턴스인 find() 함수를 사용.
특정 데이터를 조회할 때는 findById() 함수를 사용.
exec()를 붙여 주어야 서버에 쿼리를 요청함.
read에서 id의 문자 중 아무거나 변경해서 요청하면 404 오류가 발생함.
하지만 제거하고 요청하면 500 오류가 발생함. (전달받은 id가 ObjectId 형태가 아니라서 발생하는 오류)
뒤에서 ObjectId 검증 작업을 할 것임.
데이터 삭제와 수정
import Post from '../../models/post';
/* 특정 포스트 제거
DELETE /api/posts/:id
*/
export const remove = async (ctx) => {
const { id } = ctx.params;
try {
await Post.findByIdAndRemove(id).exec();
ctx.status = 204;
} catch (e) {
ctx.throw(500, e);
}
};
/* 포스트 수정(특정 필드 변경)
PATCH /api/posts/:id
{ title, body }
*/
export const update = async (ctx) => {
const { id } = ctx.params;
try {
const post = await Post.findByIdAndUpdate(id, ctx.request.body, {
new: true,
}).exec();
if (!post) {
ctx.status = 404;
return;
}
ctx.body = post;
} catch (e) {
ctx.throw(500, e);
}
};
- remove(): 특정 조건을 만족하는 데이터를 모두 지움.
- findByIdAndRemove(): id를 찾아서 지움.
- findOneAndRemove(): 특정 조건을 만족하는 데이터 하나를 찾아서 제거.
데이터를 업데이트할 때는 findByIdAndUpdate()함수를 사용함. id, 업데이트 내용, 업데이트 옵션을 넣어주어햐 함.
true이면 업데이트된 데이터를 반환하고 false이면 업데이트되기 전의 데이터를 반환함.
요청 검증
id가 올바른 ObjectId 형식이 아니면 500오류가 발생하게 했었는데 500오류는 보통 서버에서 처리하지 않아 내부적으로 문제가 생겼을 때 발생함. 잘못된 id를 전달했다면 클라이언트가 요청을 잘못 보낸 것이니 400 Bad Request 오류를 띄워주는 것이 맞음. id 값이 올바른 ObjectId인지 확인하면 됨.
// src/api/posts/posts.ctrl.js
import Post from '../../models/post';
import mongoose from 'mongoose';
const { ObjectId } = mongoose.Types;
export const checkObjectId = (ctx, next) => {
const { id } = ctx.params;
if (!ObjectId.isValid(id)) {
ctx.status = 400;
return;
}
return next();
};
( ... )
// src/api/posts/index.js
import Router from 'koa-router';
import * as postsCtrl from './posts.ctrl';
const posts = new Router();
posts.get('/', postsCtrl.list);
posts.post('/', postsCtrl.write);
posts.get('/:id', postsCtrl.checkObjectId, postsCtrl.read);
posts.delete('/:id', postsCtrl.checkObjectId, postsCtrl.remove);
posts.patch('/:id', postsCtrl.checkObjectId, postsCtrl.update);
export default posts;
이제 write, update API에서 전달받은 요청 내용을 검증하는 방법을 알아보자. 포스트를 작성할 때 서버는 title, body, tags 값을 모두 전달받아야 함. 그리고 클라이언트가 값을 빼먹었을 때는 400 오류가 발생해야 함.
객체를 검증하기 위해 각 값을 if 문으로 비교하는 방법도 있지만, Joi 라이브러리를 사용하면 더 쉽게 할 수 있음.
yarn add joi
import Post from '../../models/post';
import mongoose from 'mongoose';
import Joi from 'joi';
( ... )
export const write = async (ctx) => {
const schema = Joi.object().keys({
title: Joi.string().required(),
body: Joi.string().required(),
tags: Joi.array().items(Joi.string()).required(),
});
const result = schema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
( ... )
};
페이지네이션 구현
가짜 데이터 생성하기
// src/createFakeData.js
import Post from './models/post';
export default function createFakeData() {
const posts = [...Array(40).keys()].map((i) => ({
title: `포스트 #${i}`,
body: 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Molestiae dicta aspernatur atque facere reprehenderit enim veniam at illo rem perferendis. Adipisci dignissimos excepturi aperiam suscipit? Voluptate aspernatur numquam neque vitae. Lorem ipsum dolor sit, amet consectetur adipisicing elit. Molestiae dicta aspernatur atque facere reprehenderit enim veniam at illo rem perferendis. Adipisci dignissimos excepturi aperiam suscipit? Voluptate aspernatur numquam neque vitae. Lorem ipsum dolor sit, amet consectetur adipisicing elit. Molestiae dicta aspernatur atque facere reprehenderit enim veniam at illo rem perferendis. Adipisci dignissimos excepturi aperiam suscipit? Voluptate aspernatur numquam neque vitae.',
tags: ['tag1', 'tag2'],
}));
Post.insertMany(posts, (err, docs) => {
console.log(docs);
});
}
require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
import api from './api';
import createFakeData from './createFakeData';
// 비구조화 할당을 통하여 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;
mongoose
.connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
.then(() => {
console.log('Connected to MongoDB');
createFakeData();
})
.catch((e) => {
console.error(e);
});
( ... )
블로그에 방문한 사람에게 가장 최근 작성된 포스트부터 보여주고 싶다면 역순으로 정렬하면 됨.
export const list = async (ctx) => {
try {
const posts = await Post.find().sort({ _id: -1 }).exec();
ctx.body = posts;
} catch (e) {
ctx.throw(500, e);
}
};
1로 설정하면 오름차순, -1로 설정하면 내림차순으로 정렬됨.
개수를 제한할 때는 limit() 함수를 사용하고 파라미터에 제한할 숫자를 넣으면 됨.
export const list = async (ctx) => {
try {
const posts = await Post.find().sort({ _id: -1 }).limit(10).exec();
ctx.body = posts;
} catch (e) {
ctx.throw(500, e);
}
};
페이지 기능을 구현하려면 방금 배운 limit과 추가로 skip 함수도 사용해야 함.
skip 함수에 파라미터로 10을 넣으면 처음 10개를 제외하고 그다음 데이터를 불러옴. 따라서 파라미터로 (page - 1) * 10을 넣어주면 페이지마다 10개씩 불러올 것임.
export const list = async (ctx) => {
const page = +(ctx.query.page || '1');
if (page < 1) {
ctx.status = 400;
return;
}
try {
const posts = await Post.find()
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.exec();
ctx.body = posts;
} catch (e) {
ctx.throw(500, e);
}
};
마지막 페이지를 알 수 있다면 클라이언트가 더욱 편함. 새로운 필드를 설정하는 방법, Response 헤더 중 Link를 설정하는 방법, 커스텀 헤더를 설정하는 방법으로 이 정보를 알려 줄 수도 있음.
이 중에서 커스텀 헤더를 설정해보자.
export const list = async (ctx) => {
const page = +(ctx.query.page || '1');
if (page < 1) {
ctx.status = 400;
return;
}
try {
const posts = await Post.find()
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.exec();
const postCount = await Post.countDocuments().exec();
ctx.set('Last-Page', Math.ceil(postCount / 10));
ctx.body = posts;
} catch (e) {
ctx.throw(500, e);
}
};
body의 길이가 200자 이상이면 뒤에 '...'을 붙이고 자르는 기능을 구현해보자.
find()를 통해 조회한 데이터는 mongoose 문서 인스턴스의 형태이므로 데이터를 바로 변형할 수 없음. 그 대신 toJSON() 함수를 실행하여 JSON 형태로 변환한 뒤 필요한 변형을 일으켜야 함.
export const list = async (ctx) => {
const page = +(ctx.query.page || '1');
if (page < 1) {
ctx.status = 400;
return;
}
try {
const posts = await Post.find()
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.exec();
const postCount = await Post.countDocuments().exec();
ctx.set('Last-Page', Math.ceil(postCount / 10));
ctx.body = posts
.map((post) => post.toJSON())
.map((post) => ({
...post,
body:
post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
}));
} catch (e) {
ctx.throw(500, e);
}
};
다른 방법으로 lean() 함수를 사용하면 데이터를 처음부터 JSON 형태로 조회할 수 있음.
export const list = async (ctx) => {
const page = +(ctx.query.page || '1');
if (page < 1) {
ctx.status = 400;
return;
}
try {
const posts = await Post.find()
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.lean()
.exec();
const postCount = await Post.countDocuments().exec();
ctx.set('Last-Page', Math.ceil(postCount / 10));
ctx.body = posts.map((post) => ({
...post,
body:
post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
}));
} catch (e) {
ctx.throw(500, e);
}
};
정리
백엔드는 결국 여러 가지 조건에 따라 클라이언트에서 전달받은 데이터를 등록하고 조회하고 수정하는 것.
'WEB🔨 > React' 카테고리의 다른 글
리액트를 다루는 기술 요약#15 (0) | 2021.07.13 |
---|---|
리액트를 다루는 기술 요약#14 (0) | 2021.07.12 |
리액트를 다루는 기술 요약#12 (0) | 2021.07.11 |
리액트를 다루는 기술 요약#11 (0) | 2021.06.29 |
리액트를 다루는 기술 요약#10 (0) | 2021.06.28 |