2일차에서는 post에 대한 CRUD 기능을 구현하고, 로그인한 사용자만 게시글 기능을 사용할 수 있도록 연결한다.
post 기능은 크게 세 파일로 나뉜다.
router/posts.mjs
-> API 주소와 HTTP 메서드를 컨트롤러에 연결
controller/posts.mjs
-> 요청값을 꺼내고, 응답 상태 코드와 JSON 응답을 처리
data/posts.mjs
-> MongoDB posts 컬렉션에 직접 접근
전체 흐름은 다음과 같다.
Client
-> /post API 요청
-> isAuth 미들웨어에서 JWT 인증
-> posts router
-> posts controller
-> posts repository
-> MongoDB posts 컬렉션
-> response
router/posts.mjs는 게시글 관련 API 주소를 정의한다.
router.get("/", isAuth, postController.getPosts)
router.get("/:id", isAuth, postController.getPost)
router.post("/", isAuth, postController.createPost)
router.put("/:id", isAuth, postController.updatePost)
router.delete("/:id", isAuth, postController.deletePost)
API를 표로 정리하면 다음과 같다.
| 메서드 | 주소 | 기능 |
|---|---|---|
GET | /post | 전체 게시글 조회 |
GET | /post?userid=apple | 특정 사용자의 게시글 조회 |
GET | /post/:id | 특정 게시글 1개 조회 |
POST | /post | 게시글 작성 |
PUT | /post/:id | 게시글 수정 |
DELETE | /post/:id | 게시글 삭제 |
모든 라우터에 isAuth가 들어가 있다.
router.post("/", isAuth, postController.createPost)
즉 게시글 API는 로그인한 사용자만 사용할 수 있다. 클라이언트는 요청할 때 JWT 토큰을 Authorization 헤더에 담아서 보내야 한다.
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
게시글 작성 API는 다음과 같다.
POST /post
컨트롤러 함수는 createPost이다.
export async function createPost(req, res) {
const { text } = req.body
const post = await postRepository.create(text, req.id)
res.status(201).json(post)
}
여기서 text는 클라이언트가 보낸 게시글 내용이다.
const { text } = req.body
req.id는 현재 로그인한 사용자의 id이다. 이 값은 isAuth 미들웨어에서 JWT를 검증한 뒤 넣어준다.
req.id = user.id
데이터 계층에서는 글을 저장하기 전에 UserRepository.findById(id)로 사용자 정보를 가져온다.
export async function create(text, id) {
return UserRepository.findById(id).then((user) => getPosts().insertOne({
text,
createdAt: new Date(),
idx: user.id,
name: user.name,
userid: user.userid
})).then((result) => {
return getPosts().findOne({ _id: result.insertedId })
})
}
게시글에는 글 내용뿐 아니라 작성자 정보도 함께 저장된다.
{
text,
createdAt: new Date(),
idx: user.id,
name: user.name,
userid: user.userid
}
각 필드의 의미는 다음과 같다.
| 필드 | 의미 |
|---|---|
text | 게시글 내용 |
createdAt | 작성 시간 |
idx | 작성자의 사용자 id |
name | 작성자 이름 |
userid | 작성자 로그인 아이디 |
idx는 나중에 수정/삭제할 때 로그인한 사용자와 게시글 작성자가 같은지 확인하는 데 사용된다.
전체 게시글 조회 API는 다음과 같다.
GET /post
컨트롤러 함수는 getPosts이다.
export async function getPosts(req, res) {
const userid = req.query.userid
const data = await (userid ? postRepository.getAllByUserid(userid) : postRepository.getAll())
res.status(200).json(data)
}
이 함수는 userid 쿼리스트링이 있는지에 따라 다르게 동작한다.
요청 주소가 다음과 같으면 전체 게시글을 가져온다.
GET /post
실행되는 Repository 함수는 getAll()이다.
export async function getAll() {
return getPosts().find().sort({ createdAt: -1 }).toArray()
}
sort({ createdAt: -1 })은 최신 글이 먼저 오도록 정렬한다는 뜻이다.
createdAt 기준 내림차순 정렬
-> 최신 게시글이 위로 옴
특정 사용자의 글만 가져오고 싶으면 쿼리스트링을 사용한다.
GET /post?userid=apple
컨트롤러에서는 req.query.userid로 값을 꺼낸다.
const userid = req.query.userid
userid 값이 있으면 getAllByUserid(userid)가 실행된다.
export async function getAllByUserid(userid) {
return getPosts().find({ userid }).sort({ createdAt: -1 }).toArray()
}
이 코드는 posts 컬렉션에서 userid가 일치하는 게시글만 찾는다.
getPosts().find({ userid })
따라서 userid=apple이면 apple 사용자가 작성한 글만 가져온다.
특정 게시글 하나를 가져오는 API는 다음과 같다.
GET /post/:id
예시는 다음과 같다.
GET /post/64abc123...
컨트롤러에서는 URL의 :id 값을 req.params.id로 꺼낸다.
export async function getPost(req, res){
const id = req.params.id
const post = await postRepository.getById(id)
if(post){
res.status(200).json(post)
}else{
res.ststus(404).json({ message: `${id}의 포스트가 없습니다.`})
}
}
MongoDB의 _id는 문자열이 아니라 ObjectId 타입이다. 그래서 DB에서 찾을 때 new ObjectId(id)로 변환해야 한다.
export async function getById(id) {
return getPosts().find({ _id: new ObjectId(id) }).next().then(mapOptionalPost)
}
조회된 게시글은 mapOptionalPost를 거친다.
function mapOptionalPost(post) {
return post ? { ...post, id: post._id.toString() } : post
}
이 함수는 MongoDB의 _id를 문자열 id로 한 번 더 만들어준다.
_id: ObjectId(...)
-> id: "문자열 id"
프론트에서는 ObjectId 객체보다 문자열 id를 다루는 편이 편하다.
주의할 점은 현재 컨트롤러 코드에 오타가 있다는 것이다.
res.ststus(404)
ststus가 아니라 status가 맞다.
res.status(404)
게시글 수정 API는 다음과 같다.
PUT /post/:id
컨트롤러 함수는 updatePost이다.
export async function updatePost(req, res){
const id = req.params.id
const text = req.body.text
const post = await postRepository.getById(id)
if(!post){
return res.status(4040).json({ message: `${id}의 포스트가 없습니다.`})
}
if (post.idx !== req.id){
return res.sendStatus(403)
}
const updated = await postRepository.update(id, text)
res.status(200).json(updated)
}
수정 흐름은 다음과 같다.
1. URL에서 게시글 id를 꺼낸다.
2. body에서 수정할 text를 꺼낸다.
3. id로 기존 게시글을 조회한다.
4. 게시글이 없으면 404를 응답한다.
5. 게시글 작성자와 로그인 사용자가 같은지 확인한다.
6. 다르면 403을 응답한다.
7. 같으면 게시글을 수정한다.
8. 수정된 게시글을 응답한다.
여기서 가장 중요한 코드는 작성자 확인이다.
if (post.idx !== req.id){
return res.sendStatus(403)
}
post.idx는 게시글 작성자의 id이고, req.id는 현재 로그인한 사용자의 id이다.
post.idx
-> 게시글 작성자 id
req.id
-> 현재 로그인한 사용자 id
두 값이 다르다는 것은 내가 쓴 글이 아니라는 뜻이다. 그래서 403 Forbidden을 응답한다.
DB 수정은 findOneAndUpdate()를 사용한다.
export async function update(id, text) {
return await getPosts().findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: { text } },
{ returnDocument: "after" }
).then((result) => result)
}
$set은 특정 필드만 수정할 때 사용한다.
{ $set: { text } }
returnDocument: "after"는 수정 후의 문서를 반환하라는 뜻이다.
현재 코드에서 주의할 점은 상태 코드 오타이다.
res.status(4040)
HTTP 상태 코드는 4040이 아니라 404가 맞다.
res.status(404)
게시글 삭제 API는 다음과 같다.
DELETE /post/:id
컨트롤러 함수는 deletePost이다.
export async function deletePost(req, res){
const id = req.params.id
const post = await postRepository.getById(id)
if(!post){
res.status(404).json({ message: `해당 ${id}의 포스트가 없습니다.`})
}
if(post.idx !== req.id){
return res.sendStatus(403)
}
await postRepository.remove(id)
res.sendStatus(204)
}
삭제 흐름은 수정과 거의 같다.
1. URL에서 게시글 id를 꺼낸다.
2. id로 기존 게시글을 조회한다.
3. 게시글이 없으면 404를 응답한다.
4. 게시글 작성자와 로그인 사용자가 같은지 확인한다.
5. 다르면 403을 응답한다.
6. 같으면 게시글을 삭제한다.
7. 삭제 성공 시 204를 응답한다.
DB 삭제는 deleteOne()을 사용한다.
export async function remove(id) {
return getPosts().deleteOne({ _id: new ObjectId(id) })
}
삭제 성공 시에는 204 No Content를 응답한다.
res.sendStatus(204)
204는 요청은 성공했지만 응답 body는 없다는 의미이다.
현재 코드에서 조심할 점은 게시글이 없을 때 return이 없다는 것이다.
if(!post){
res.status(404).json({ message: `해당 ${id}의 포스트가 없습니다.`})
}
응답을 보낸 뒤 함수가 계속 실행될 수 있으므로 다음처럼 return을 붙이는 것이 안전하다.
if(!post){
return res.status(404).json({ message: `해당 ${id}의 포스트가 없습니다.`})
}
게시글 기능은 모두 isAuth 미들웨어를 거친다.
router.put("/:id", isAuth, postController.updatePost)
router.delete("/:id", isAuth, postController.deletePost)
isAuth는 JWT를 검증한 뒤 로그인한 사용자 id를 req.id에 넣어준다.
req.id = user.id
이 req.id는 게시글 작성, 수정, 삭제에서 계속 사용된다.
게시글 작성
-> req.id로 작성자 정보 저장
게시글 수정
-> req.id와 post.idx 비교
게시글 삭제
-> req.id와 post.idx 비교
즉 post 기능에서 req.id는 단순한 값이 아니라, 권한 검사의 기준이 된다.
이번 post 파트에서 사용한 상태 코드는 다음과 같다.
| 상태 코드 | 의미 | 사용 위치 |
|---|---|---|
200 OK | 조회 또는 수정 성공 | 목록 조회, 상세 조회, 수정 성공 |
201 Created | 생성 성공 | 게시글 작성 성공 |
204 No Content | 성공했지만 응답 body 없음 | 게시글 삭제 성공 |
403 Forbidden | 권한 없음 | 남의 글 수정/삭제 시도 |
404 Not Found | 리소스 없음 | 존재하지 않는 게시글 조회 |
상태 코드는 클라이언트가 응답 결과를 판단하는 기준이 되므로 정확히 사용하는 것이 중요하다.
이번 강의에서 배운 post 기능은 REST API의 기본 구조를 따른다.
Create
-> POST /post
Read
-> GET /post
-> GET /post/:id
-> GET /post?userid=apple
Update
-> PUT /post/:id
Delete
-> DELETE /post/:id
게시글 작성, 수정, 삭제는 로그인한 사용자 기준으로 처리된다.
JWT 인증
-> req.id 생성
-> 게시글 작성자 정보 저장
-> 수정/삭제 권한 검사
가장 중요한 코드는 아래 두 줄이다.
const post = await postRepository.getById(id)
if (post.idx !== req.id){
return res.sendStatus(403)
}
첫 번째 코드는 수정/삭제할 게시글이 실제로 존재하는지 확인한다. 두 번째 코드는 현재 로그인한 사용자가 그 글의 작성자인지 확인한다.
결국 이번 post 파트의 핵심은 다음과 같다.
게시글 CRUD를 만들고,
JWT 인증으로 로그인 사용자를 확인하며,
작성자 본인만 수정/삭제할 수 있도록 권한 검사를 추가했다.