[32일차] X_Project 2일차 정리

송정근·2026년 7월 5일

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

1. Post 라우터 구조

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}`
}

2. 게시글 작성

게시글 작성 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는 나중에 수정/삭제할 때 로그인한 사용자와 게시글 작성자가 같은지 확인하는 데 사용된다.

3. 전체 게시글 조회

전체 게시글 조회 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 기준 내림차순 정렬
  -> 최신 게시글이 위로 옴

4. 특정 사용자의 게시글 조회

특정 사용자의 글만 가져오고 싶으면 쿼리스트링을 사용한다.

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 사용자가 작성한 글만 가져온다.

5. 특정 게시글 1개 조회

특정 게시글 하나를 가져오는 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)

6. 게시글 수정

게시글 수정 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)

7. 게시글 삭제

게시글 삭제 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}의 포스트가 없습니다.`})
}

8. Post 기능에서 isAuth가 중요한 이유

게시글 기능은 모두 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는 단순한 값이 아니라, 권한 검사의 기준이 된다.

9. 상태 코드 정리

이번 post 파트에서 사용한 상태 코드는 다음과 같다.

상태 코드의미사용 위치
200 OK조회 또는 수정 성공목록 조회, 상세 조회, 수정 성공
201 Created생성 성공게시글 작성 성공
204 No Content성공했지만 응답 body 없음게시글 삭제 성공
403 Forbidden권한 없음남의 글 수정/삭제 시도
404 Not Found리소스 없음존재하지 않는 게시글 조회

상태 코드는 클라이언트가 응답 결과를 판단하는 기준이 되므로 정확히 사용하는 것이 중요하다.

10. 전체 흐름 요약

이번 강의에서 배운 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 인증으로 로그인 사용자를 확인하며,
작성자 본인만 수정/삭제할 수 있도록 권한 검사를 추가했다.
profile
기록하며 성장하는 개발자

0개의 댓글