모든 데이터 조회 시 중요하게 생각할 것은 요청의 수를 줄일 수 있다면 최대한 줄이는 게 좋다.
다음 그림을 보자.
이런 구조를 가지고 있는 Blog Collection이 있다고 할 때, 블로그 전체를 조회하는 무식한 경우는 당연히 없겠지만 그런 경우가 있다고 가정해보고 전체 블로그를 가져오게 될 때, 만약 해당 블로그의 유저 정보와 커멘트들도 담아와야 한다면 블로그마다 유저와 커멘트들을 조회하는 비용이 발생한다.
코드의 내용은 대략 블로그 전체를 가져온 후 각 블로그마다 블로그의 유저 아이디를 가져와서 해당 아이디를 통해 유저와 커멘트를 가져온다. 이는 모든 블로그를 가져오는 요청을 한 번, 블로그를 가져오는 요청에서 처리하는 데이터베이스 조회, 조회된 데이터의 유저 및 커멘트를 가져오기 위한 요청 한 번, 각 요청에서 처리하는 데이터베이스 조회 등 상당히 많은 비용이 발생한다.
이것을 해결할 수 있는 방법 중 하나가 MongoDB의 'populate'이다.
populate은 특정 컬렉션의 도큐멘트에서 가져올 다른 객체가 있다면 (블로그의 유저와 커멘트) 해당 객체도 같이 조회하여 조회된 데이터를 추가해주는 방법이다.
이게 나의 블로그 스키마인데 커멘트는 없다. 블로그에는 커멘트라는 데이터는 없고 커멘트에만 블로그라는 데이터를 참조한다. 그렇다면 당연히 blog 전체를 가져오는 find()에서 populate를 사용할 때 comments를 담을 수 없게 된다. 그럼 커멘트를 담고 싶을 때 방법은 두가지인데 첫번째는 스키마에 커멘트를 유저처럼 추가하거나, virtual을 사용하는 것이다. 난 후자를 선택했다.
SyntaxError: Cannot use import statement outside a module
at internalCompileFunction (node:internal/vm:73:18)
at wrapSafe (node:internal/modules/cjs/loader:1178:20)
at Module._compile (node:internal/modules/cjs/loader:1220:27)
at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
at Module.load (node:internal/modules/cjs/loader:1119:32)
at Module._load (node:internal/modules/cjs/loader:960:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:86:12)
at node:internal/main/run_main_module:23:47
뭔지는 당연히 몰랐기에 구글링 시도!
우선 require로 패키지를 가져오지 않고 import를 사용하니 이런 에러를 마주했는데, package.json 파일에서 "type": "module"을 추가하면 해결할 수 있다고 한다. 추가한 후 다시 실행하니 다른 에러가 발생한다.
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /Users/cw.choiit/mongo/tutorial/src/api/client.ts
at new NodeError (node:internal/errors:405:5)
at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:79:11)
at defaultGetFormat (node:internal/modules/esm/get_format:124:36)
at defaultLoad (node:internal/modules/esm/load:89:20)
at nextLoad (node:internal/modules/esm/loader:163:28)
at ESMLoader.load (node:internal/modules/esm/loader:603:26)
at ESMLoader.moduleProvider (node:internal/modules/esm/loader:457:22)
at new ModuleJob (node:internal/modules/esm/module_job:64:26)
at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:480:17)
at ESMLoader.getModuleJob (node:internal/modules/esm/loader:434:34) {
code: 'ERR_UNKNOWN_FILE_EXTENSION'
}
.ts 파일을 못 읽는 것 같은데 역시 뭔지 모르니 다시 서치!
Typescript를 사용하고 있고, ts-node로 typescript 파일을 실행하기 위해서 tsconfig.json 파일을 수정해야 한다.
compilerOptions의 module을 commonjs로 target을 es2016으로 설정하고 다음과 같이 ts-node의 esm을 true로 설정한다.
하고 나서 다시 실행하니 또 다른 에러.. 에러 지옥에 갇혔지만 끝까지 가면 내가 다 이긴다.
ReferenceError: exports is not defined in ES module scope
at file:///Users/cw.choiit/mongo/tutorial/src/api/client.ts:14:23
at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
이건 알 거 같다. 제일 처음 package.json 파일에서 "type": "module"을 추가했는데 저 부분을 다시 지워야 할 것 같다.
그래서 package.json 파일에서 "type": "module"을 지우고 아래와 같은 package.json 파일로 저장했다.
마침내 정상 실행이 됐다.
정리를 하자면
1. Typescript를 사용 중이라면 tsconfig.json에서 다음과 같은 설정이 필요
RDBS를 사용해 개발하던 중에 칼럼 형식으로부터의 자유로움의 필요성을 느끼고 MongoDB를 공부하기 시작했다.
MongoDB와 잘 맞는 Javascript를 사용해 간단한 Blog Service를 구축해 보면서 MongoDB와 친해져 보자.
Getting Started
npm init -y
package.json 파일을 우선 만들고, dependencies를 추가하자.
npm i typescript --save-dev
npm i express --save
이후에 typescript 설정 파일을 생성하기 위해 아래 커맨드를 실행하자.
npx tsc --init
이 커맨드는 tsconfig.json 파일을 작업하고자 하는 프로젝트 경로의 루트에 생성해 주는데, 어떤 식으로 typescript 코드를 javascript 코드로 컴파일할지에 대한 여러 옵션을 지정하고 있다. 언제든지 수정이 가능하지만 기본 옵션으로 우선 진행하자.
Setup Express server
이제 차근차근 하나씩 Express 서버를 구축해 보자. 우선 나는 Typescript를 사용할 거니까, express의 type definition file을 내려받자.
npm i @types/express --save-dev
프로젝트 루트 경로에 src폴더를 만들고 index.ts파일을 하나 생성하자.
src/index.ts
import express from 'express';
const app = express();
app.listen(3000, function () {
console.log('server listening on port 3000');
});
Express server는 3000번 포트로 기동 하게끔 설정했고, 실행 후 callback 함수로 간단한 log를 찍어주었다.
일단 서버가 실행이 정상적으로 되는지 확인해 보자.
Node가 typescript 코드를 다이렉트로 실행하지 못하기 때문에 javascript로 컴파일해주어야 한다는 것을 다시 한번 인지하고 이를 도와주는 ts-node라는 패키지를 내려받자. ts-node는 Node로 Typescript를 다이렉트로 실행하게 도와주는 패키지인데 나는 어떤 코드의 변경사항이 생길 때 서버를 재기동하게끔 ts-node-dev를 내려받을 거다. ts-node-dev는 그냥 nodemon과 같은 녀석이라고 생각하면 된다.
npm i ts-node-dev --save-dev
이제 package.json 파일에서 script 부분을 수정하자.
ts-node-dev로 src/index.ts 파일을 실행하게끔 "dev"라는 커맨드를 추가했고, 이제 서버를 실행해 보자.
실행하면 정상적으로 아래 같은 로그가 출력되는 걸 확인할 수 있다.
server listening on port 3000
Connect to MongoDB
이제, MongoDB를 연동해 보자. MongoDB를 연동하기 위해 mongoose라는 패키지를 내려받자.
import express from 'express';
import mongoose from 'mongoose';
const app = express();
const MONGO_URI =
'mongodb+srv://<username>:<password>@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';
const start = async () => {
try {
await mongoose.connect(MONGO_URI);
mongoose.set('debug', true);
console.log('MongoDB connected !');
app.listen(3000, function () {
console.log('server listening on port 3000');
});
} catch (error) {
console.log(error);
}
};
start();
Mongo Atlas에서 데이터베이스 만들고, Connect > Compass를 누르면 나한테 연결할 URI를 알려준다.
이렇게 연결을 하고 나서, 다시 재실행을 해보면 아래와 같은 로그가 찍히는 걸 확인할 수 있다.
MongoDB connected !
server listening on port 3000
그럼 성공적으로 연결은 다 끝난 상태이다. 이제 라우팅 및 API를 작업해 보자.
Setup routes
라우트를 작업하기 앞서, 우선 한 가지 미들웨어를 설정해줘야 하는데 express의 json이라는 녀석이다. 이 녀석은 쉽게 말해 외부의 요청이 들어올 때 전달되는 payload의 데이터가 Json 형식의 데이터가 들어오면 그것을 파싱 해주는 미들웨어라고 생각하면 된다. 즉, Json 데이터를 받아줄 수 있는 미들웨어.
import express from 'express';
import mongoose from 'mongoose';
const app = express();
const MONGO_URI =
'mongodb+srv://chiwon99881:VM3j4MQVKrMWW6K3@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';
const start = async () => {
try {
await mongoose.connect(MONGO_URI);
mongoose.set('debug', true);
console.log('MongoDB connected !');
app.use(express.json()); // 이 부분
app.listen(3000, function () {
console.log('server listening on port 3000');
});
} catch (error) {
console.log(error);
}
};
start();
이제 유저의 스키마 작업, 라우트 작업, API를 만들어보자.
우선, src/model/user라는 폴더를 만들고 해당 폴더에서 user.ts파일을 만들자.
이제 MongoDB에게 알려줄 User Schema를 만든다. 이 유저 스키마 정보는 곧 나의 데이터베이스의 Collection으로 만들어지게 된다.
username에서는 unique: true라는 옵션을 넣어주어서 같은 username을 가지는 유저를 방지하게 하고, timestamp: true라는 옵션을 넣어서 새로운 user document가 생성될 때마다 createdAt 데이터가 자동으로 추가되고 변경 시 updatedAt 정보를 갱신하게끔 만들어 주었다.
이제 mongoose패키지에서 model이라는 모듈을 import 하여 User모델을 mongoDB에게 알려준다. collection이름을 user라고 명시하였는데 이렇게 하면 실제로는 데이터베이스에 users라는 복수명으로 표시되어 만들어진다.
생성한 User model을 export default로 내보내자.
이렇게 만든다고 바로 MongoDB Compass에 collection이 보이지는 않는다. collection에 데이터가 실제로 추가가 되면 그때 비로소 collection이 나타나게 된다. 이를 확인하기 위해 router도 만들고 실제로 API를 구현해서 데이터를 생성해 보자.
router를 만들기 앞서, /src/model/index.ts파일을 만들고 model의 패키지의 모든 모델들을 한 번에 export 하게끔 해보자.
다른 파일에서 model의 특정 파일을 명시하지 않으면 기본적으로 model패키지의 index파일을 찾게 된다. 이를 이용해서 편리하게 export 해보자.
/src/model/index.ts
import User from './user/user';
export { User };
이제 모델 패키지에 새로운 모델들이 만들어질 때마다 항상 이 파일에서 export 해주면 다른 파일에서 원하는 모델을 불러오고 싶을 때 index.ts파일을 통해 불러올 수 있다. 이는 이후에 새로운 모델이 생성된 후에 더 자세히 알아보자.
이것과 유사하게 routes 패키지도 같은 방식으로 처리해 줄 수 있다.
import { userRouter } from './user/userRoute';
export { userRouter };
이러한 쓰임새는 바로 다음 /src/index.ts 파일에 사용되는 routes 패키지를 import 하는 부분에서 확인할 수 있다.
/src/routes/user/userRoute.ts 파일을 생성하자.
import { Router } from 'express';
import mongoose from 'mongoose';
import { User } from '../../models';
export const userRouter = Router();
// Create user
userRouter.post('/', async function (req, res) {
try {
const { username, name } = req.body;
if (!username)
return res.status(400).send({ error: 'username is required' });
if (!name || !name.first || !name.last)
return res
.status(400)
.send({ err: 'Both first and last name are required' });
const user = new User(req.body);
await user.save();
return res.send({ success: true, user });
} catch (e: any) {
console.log(e);
return res.status(500).send({ error: e.message });
}
});
위 코드는 새로운 유저를 생성하는 API를 구현한 코드이다. 내가 구현할 유저 관련 API는 생성, 읽기, 수정, 삭제이다.
우선 POST 메서드로 REST API를 구현하고 Path는/users이다.
request의 body를 통해 username, name을 받는다. 이 코드에서는 email, age는 따로 검증하지 않았다.
username이 없으면응답 코드를 400,에러 메시지를 "username is required"로 반환하기로 했고 name이 없거나 name의 first, last값이 없으면 역시응답 코드를 400,에러 메시지를 "Both first and last name are required"로 반환하기로 한다.
위 검증을 모두 통과하면 유저를 생성하고 저장한다.user.save()를 실행하면 MongoDB에 해당 데이터가 저장이 된다.
모든 과정을 마치면, res.send()에 생성된 유저 정보를 넣어 반환한다.
위 코드에서는 "/" 이렇게만 설정되어 있지만 Express app을 실행하는 부분에서 (src/index.ts) users라는 context를 설정해 줄 것이다. 아래 코드를 확인해 보자.
/src/index.ts
import express from 'express';
import mongoose from 'mongoose';
import { userRouter } from './routes';
const app = express();
const MONGO_URI =
'mongodb+srv://chiwon99881:VM3j4MQVKrMWW6K3@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';
const start = async () => {
try {
await mongoose.connect(MONGO_URI);
mongoose.set('debug', true);
console.log('MongoDB connected !');
app.use(express.json());
app.use('/users', userRouter); // 이 부분
app.listen(3000, function () {
console.log('server listening on port 3000');
});
} catch (error) {
console.log(error);
}
};
start();
Test create user
이제 생성 관련 API를 만들었으니 테스트해보자. 테스트를 해보기 위해 Postman을 사용해 보자.
Method는 POST, URL은 http://localhost:3000/users로 설정한 후 아래와 같이 body payload를 설정한 후 Request 해보자.
응답의 결과로 아래와 같은 화면을 돌려받을 수 있다.
정상적으로 데이터를 만든 API를 통해 주고받았고 데이터베이스에도 해당 데이터가 저장되었는지 확인하기 위해 MongoDB Compass를 실행해서 확인해 보자.
데이터베이스에도 정상적으로 데이터가 저장된 것을 확인할 수 있다. 저 데이터 형식에서 _id는 MongoDB에서 생성해 주는 고유값이다. 해당 id가 곧 RDBS에서 primary key라고 생각할 수 있는데, 우리는 저 key를 통해 각 유저를 조회하는 API도 만들 것이다.
한 가지 더 해보고 넘어갈 것은 또 다른 collection을 만들고 collection 사이의 관계를 맺어보는 것이다.
Blog collection
이제 새로운 collection하나를 더 만들어보자. 이 blog collection은 데이터로 유저 정보도 가지게 된다. 어떤 유저가 이 블로그의 주인인지를 알기 위해.
위 코드에서 확인할 것은 Blog라는 Schema의 데이터 중 user이다. 이렇게 collection끼리 관계를 갖게 할 수 있는데 그러기 위해선 ref라는 옵션을 사용해서 어떤 collection을 가리키는지 알려주어야 한다. User model을 저장할 때 model이름으로 'user'로 저장한 것과 똑같은 값인 'user'를 ref의 value로 설정해 주면 된다. 그리고 또 한 가지 유저의 type으로 mongoose.Types.ObjectId를 받는다. 이 ObjectId는 아까 MongoDB Compass에서 봤던 것처럼 고유값인 _id를 가리킨다.
이제 Blog collection을 생성했으니 연관된 API도 하나만 만들어보자.
/src/routes/blog/blogRoutes.ts
import { Router } from 'express';
import { Blog, User } from '../../models';
import mongoose from 'mongoose';
export const blogRouter = Router();
// Create blog
blogRouter.post('/', async (req, res) => {
try {
const { title, content, isLive, userId } = req.body;
if (!title) return res.status(400).send({ error: 'title is required' });
if (!content) return res.status(400).send({ error: 'content is required' });
if (isLive && typeof isLive !== 'boolean')
return res.status(400).send({ error: 'isLive must be a boolean value' });
if (!mongoose.isValidObjectId(userId))
return res.status(400).send({ error: 'Invalid user id' });
const user = await User.findOne({ _id: userId });
if (!user) return res.status(400).send({ error: 'User does not exist' });
const blog = new Blog({ ...req.body, user });
await blog.save();
return res.status(201).send({ blog });
} catch (e: any) {
console.log(e);
res.status(500).send({ error: e.message });
}
});