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

북마크 기능을 개발하면서 고민했던 부분은 다음과 같다.
이러한 고민을 다음과 같은 과정을 거쳐 풀어낼 수 있었다.
🔶 북마크 생성
POST http://{{host}}/bookmarks
Body: { user_id: 1, book_id: 4 }
북마크 테이블이 user_id와 book_id를 column으로 갖고 있으므로, 설계할 당시에는 위와 같이 보내면 될 것 같다고 생각했다. 하지만 북마크 생성 api를 위와 같이 설계하고 나니 북마크 삭제 api를 설계하는 게 문제가 생겼다.
🔶 북마크 삭제
DELETE http://{{host}}/bookmarks/:bookId/user/:userId
book_id와 user_id를 어떻게든 넣어서 보내야됐는데, 그러다보니 bookmarks/ 뒤에 bookmark_id가 아닌 book_id가 붙게 되었다. 북마크의 id가 아닌 다른 자원의 id가 붙는 게 맞지 않다고 생각했다.논의 결과 다음과 같은 결론을 내렸다.
POST와 DELETE처럼 자원에 변화가 생기는 경우에는 권한 검사를 수행하자 → 토큰을 검증하고, 토큰을 해독해 user_id를 넘겨주는 guard 함수를 만들자.bookmarks/:bookmarksId 처럼 자원 명 뒤에는 해당 자원의 정보가 들어가도록 설계하자 → 클라이언트 측에서 bookmark_id를 알고 있어야 하는데, 책 정보를 가져올 때 해당 책에 대한 유저의 bookmark_id도 같이 보내자.user_id는 토큰에서 가져온다.bookmark_id를 반환한다. 클라이언트 측에서 bookmark_id 정보를 저장하고 있으며 새로 생성할 경우 정보를 업데이트한다.user_id를 검증한다.다음과 같이 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를 담아보낸다.res.locals.user에 동일하게 id를 담아서 다음 미들웨어에게 넘긴다.이전에 타입스크립트를 사용하지 않고 구현할 때에는 request에 인증정보를 담아 보냈었는데, 이번에 req.user와 같이 설정할 경우 타입스크립트에서 에러를 발생시켰다. 방법은 있었으나, request의 타입을 확장시키는 방식이었다. 다른 방법을 찾아보니 res.locals 객체가 있었다.
res.locals는 로컬 변수를 포함한 객체로, 하나의 request-response 사이클에만 유효하다. 따라서 request pathname이나, 유저의 인증정보 등을 담는데에 이용된다.
res.locals는 response의 프로퍼티이므로 클라이언트에서 참조가 가능할 것 같은데, 인증 정보를 담는데 적합할까?
guard 함수에 대한 PR 리뷰 과정에서 위 사항에 대해 논의가 있었는데, 찾아보니 res.locals객체는 서버 측에서만 참조 가능하다고 한다. 해당 객체에 인증 정보를 담는 것이 차선책이라기 보다는 오히려 더 적합한 방법이었다.
북마크 기능은 책 컴포넌트 뿐만 아니라 뷰어 페이지에서도 사용 중이기 때문에, 훅으로 만들어 재사용하고자 했다. 훅을 설계할 때 컴포넌트에서 어떤 걸 필요로 하는지, 즉 무엇을 반환할 지 먼저 생각해보았다.
새로 생성된 북마크 id
북마크 클릭 시 실행될 함수
현재 북마크 수
이에 따라 다음을 인수로 받아야 했다. 여기서 최초는 불러온 책 정보에 담겨있는 값을 의미한다.
최초 북마크 id
책 id
최초 북마크 수
구현된 코드는 다음과 같다.
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 };
};
이렇게 훅으로 작성하니 다음과 같은 장점이 있었다.
[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?
.