Skip to content

📑북마크 기능 개발기

김도현 edited this page Dec 7, 2022 · 4 revisions

북마크 기능은 knoticle 서비스의 차별화된 기능 중 하나다. 북마크 아이콘을 클릭하면 손쉽게 책을 북마크하거나 북마크를 해제할 수 있으며, 이렇게 북마크한 책을 서재 페이지의 ‘북마크 한 책’ 탭에서 모아볼 수 있다.

북마크 기능을 개발하면서 고민했던 부분은 다음과 같다.

  1. 북마크 api를 어떻게 설계할 것인가

  2. 북마크 기능이 책 컴포넌트와 뷰어 페이지에서 모두 사용되는데 어떻게 훅으로 분리할 수 있을까

1. 북마크 api 설계하기

초기에 설계한 api

🔶 북마크 생성
POST `http://{{host}}/bookmarks`
Body: `{ user_id: 1, book_id: 4 }`

북마크 테이블이 user_idbook_id를 column으로 갖고 있으므로, 설계할 당시에는 위와 같이 보내면 될 것 같다고 생각했다. 하지만 북마크 생성 api를 위와 같이 설계하고 나니 북마크 삭제 api를 설계하는 게 문제가 생겼다.

🔶 북마크 삭제
DELETE `http://{{host}}/bookmarks/:bookId/user/:userId`

문제점

  • DELETE 요청에는 body가 없으므로 book_iduser_id를 어떻게든 넣어서 보내야됐는데, 그러다보니 bookmarks/ 뒤에 bookmark_id가 아닌 book_id가 붙게 되었다. 북마크의 id가 아닌 다른 자원의 id가 붙는 게 맞지 않다고 생각했다.
  • 북마크 삭제는 논리 삭제가 아닌 물리 삭제인데 userid를 URL에 담아보내야해서 보안상으로도 좋지 않을 것 같았다.

논의 결과 다음과 같은 결론을 내렸다.

수정 방향

  • POSTDELETE처럼 자원에 변화가 생기는 경우에는 권한 검사를 수행하자

    → 토큰을 검증하고, 토큰을 해독해 user_id를 넘겨주는 guard 함수를 만들자.

  • bookmarks/:bookmarksId 처럼 자원 명 뒤에는 해당 자원의 정보가 들어가도록 설계하자

    → 클라이언트 측에서 bookmark_id를 알고 있어야 하는데, 책 정보를 가져올 때 해당 책에 대한 유저의 bookmark_id도 같이 보내자.

최종 api

🔶 북마크 생성
POST `http://{{host}}/bookmarks`
Body: `{ book_id: 4 }`
  • user_id는 토큰에서 가져온다.
  • 생성 후 생성된 bookmark_id를 반환한다. 클라이언트 측에서 bookmark_id 정보를 저장하고 있으며 새로 생성할 경우 정보를 업데이트한다.
🔶 북마크 삭제
DELETE `http://{{host}}/bookmarks/:bookmarkId`
  • user_id를 검증한다.

2. guard 미들웨어 작성

다음과 같이 guard 미들웨어를 작성했다.

const guard = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { id } = token.verifyJWT(req.cookies.access_token);
    res.locals.user = { id };
    next();
  } catch (err) {
    if (err.message === 'jwt expired') {
      const { id } = token.decodeJWT(req.cookies.access_token);

      await token.checkRefreshTokenValid(req.cookies.refresh_token);

      const { accessToken, refreshToken } = token.getTokens(id);

      await token.saveRefreshToken(id, refreshToken);

      res.cookie('access_token', accessToken, { httpOnly: true });
      res.cookie('refresh_token', refreshToken, { httpOnly: true });

      res.locals.user = { id };

      next();
    } else throw new Unauthorized(Message.TOKEN_MALFORMED);
  }
};
  • 우선 토큰을 검증하고, 검증된 토큰이면 res.locals.user에 id를 담아보낸다.
  • 만약 토큰의 유효기간이 지난 에러라면, 토큰을 해독해서 id를 가져오고 유저가 보낸 refresh token이 유효한 지 확인한다.
    • 유효하다면 새 토큰을 발급받고 cookie에 담아 보내주고, res.locals.user에 동일하게 id를 담아서 다음 미들웨어에게 넘긴다.
  • access token이 변형되었거나, refresh token이 유효하지 않다면 TOKEN_MALFORMED 에러를 보낸다. 클라이언트 측에서는 재로그인해야 한다.

다음 미들웨어로 변수 넘기기: res.locals

이전에 타입스크립트를 사용하지 않고 구현할 때에는 request에 인증정보를 담아 보냈었는데, 이번에 req.user와 같이 설정할 경우 타입스크립트에서 에러를 발생시켰다. 방법은 있었으나, request의 타입을 확장시키는 방식이었다. 다른 방법을 찾아보니 res.locals 객체가 있었다.

res.locals는 로컬 변수를 포함한 객체로, 하나의 request-response 사이클에만 유효하다. 따라서 request pathname이나, 유저의 인증정보 등을 담는데에 이용된다.

  • res.locals는 response의 프로퍼티이므로 클라이언트에서 참조가 가능할 것 같은데, 인증 정보를 담는데 적합할까?

    guard 함수에 대한 PR 리뷰 과정에서 위 사항에 대해 논의가 있었는데, 찾아보니 res.locals객체는 서버 측에서만 참조 가능하다고 한다. 해당 객체에 인증 정보를 담는 것이 차선책이라기 보다는 오히려 더 적합한 방법이었다.

3. useBookmark 훅 만들기

useBookmark 훅 설계하기

북마크 기능은 책 컴포넌트 뿐만 아니라 뷰어 페이지에서도 사용 중이기 때문에, 훅으로 만들어 재사용하고자 했다. 훅을 설계할 때 컴포넌트에서 어떤 걸 필요로 하는지, 즉 무엇을 반환할 지 먼저 생각해보았다.

  • 북마크 훅 설계

    반환해야 하는 값들

    • 새로 생성된 북마크 id
    • 북마크 클릭 시 실행될 함수
    • 현재 북마크 수

    이에 따라 다음을 인수로 받아야 했다. 여기서 최초는 불러온 책 정보에 담겨있는 값을 의미한다.

    • 최초 북마크 id
    • 책 id
    • 최초 북마크 수
  • POST와 DELETE 요청 구분

    현재 클라이언트에서 저장하고 있는 북마크 id가 있을 경우 DELETE 요청, 없을 경우 POST 요청이 가게끔 했다. POST 요청 후에는 북마크 id를 반환해서 현재 북마크 id를 업데이트했다.

  • 북마크 수 계산

    북마크 수는 최초로 받아온 책의 북마크 수에서 유저가 북마크 할 시 1이 추가되는 방식으로 설계했다. 그 동안 다른 유저들이 북마크를 변경한다면 그 수가 정확히 일치하지 않을 순 있지만, 매번 갱신해야 할 만큼 중요한 정보는 아니라고 생각했기에 불필요한 DB 접근을 줄이는 방법을 택했다.

완성된 userBookmark 훅

구현된 코드는 다음과 같다.

const useBookmark = (bookmarkId: number | null, bookmarkCnt: number, bookId: number) => {
  const [curBookmarkId, setCurBookmarkId] = useState<number | null>(bookmarkId);
  const [curBookmarkCnt, setCurBookmarkCnt] = useState(bookmarkCnt);

  const { execute: deleteBookmark } = useFetch(deleteBookmarkApi);
  const { data: postedBookmark, execute: postBookmark } = useFetch(postBookmarkApi);

  useEffect(() => {
    if (!postedBookmark) return;
    setCurBookmarkId(postedBookmark.bookmarkId);
    setCurBookmarkCnt(curBookmarkCnt + 1);
  }, [postedBookmark]);

  const handleBookmarkClick = useCallback(async () => {
    if (curBookmarkId) {
      await deleteBookmark({ bookmarkId: curBookmarkId });
      setCurBookmarkId(null);
      setCurBookmarkCnt(curBookmarkCnt - 1);
    } else {
      await postBookmark({ book_id: bookId });
    }
  }, [curBookmarkId]);

  return { handleBookmarkClick, curBookmarkCnt, curBookmarkId };
};

이렇게 훅으로 작성하니 다음과 같은 장점이 있었다.

  • 책 컴포넌트 및 뷰어 페이지에서 북마크 로직이 분리되어 코드가 깔끔해졌다.
  • 북마크 로직에 문제가 있을 경우 useBookmark 훅에서만 수정하면 되니 코드 관리가 편하다.

4. 남은 고민들

  • 유저가 북마크를 계속 눌렀다가 취소하는 행동을 반복할 경우 어떻게 대응할 것인가? 북마크 생성, 삭제 모두 db에 접근하기 때문에 서버에 부담이 될 수 있다.
  • 현재 책을 받아오는 api에서 북마크 id도 한꺼번에 넘겨서 주고 있다. 책 정보 자체는 어떤 클라이언트가 요청하든 동일하지만, 책에 대한 북마크 id는 클라이언트마다 다르므로 이 로직을 분리하는 게 좋을 것 같다.

참고 자료

[API 설계] DELETE request 요청/처리/응답에 관한 소소한 고민

Passing variables to the next middleware using next() in Express.js

res.locals Property in Express.js

Can res.locals Be Accessed By Clients?

Clone this wiki locally