원본 글 링크: 북마크 기능 개발기
북마크 기능은 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?
.