-
Notifications
You must be signed in to change notification settings - Fork 5
📑북마크 기능 개발기
북마크 기능은 knoticle 서비스의 차별화된 기능 중 하나다. 북마크 아이콘을 클릭하면 손쉽게 책을 북마크하거나 북마크를 해제할 수 있으며, 이렇게 북마크한 책을 서재 페이지의 ‘북마크 한 책’ 탭에서 모아볼 수 있다.
북마크 기능을 개발하면서 고민했던 부분은 다음과 같다.
-
북마크 api를 어떻게 설계할 것인가
-
북마크 기능이 책 컴포넌트와 뷰어 페이지에서 모두 사용되는데 어떻게 훅으로 분리할 수 있을까
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`
-
DELETE 요청에는 body가 없으므로
book_id
와user_id
를 어떻게든 넣어서 보내야됐는데, 그러다보니 bookmarks/ 뒤에bookmark_id
가 아닌book_id
가 붙게 되었다. 북마크의 id가 아닌 다른 자원의 id가 붙는 게 맞지 않다고 생각했다. - 북마크 삭제는 논리 삭제가 아닌 물리 삭제인데 userid를 URL에 담아보내야해서 보안상으로도 좋지 않을 것 같았다.
논의 결과 다음과 같은 결론을 내렸다.
-
POST
와DELETE
처럼 자원에 변화가 생기는 경우에는 권한 검사를 수행하자→ 토큰을 검증하고, 토큰을 해독해 user_id를 넘겨주는 guard 함수를 만들자.
-
bookmarks/:bookmarksId
처럼 자원 명 뒤에는 해당 자원의 정보가 들어가도록 설계하자→ 클라이언트 측에서
bookmark_id
를 알고 있어야 하는데, 책 정보를 가져올 때 해당 책에 대한 유저의bookmark_id
도 같이 보내자.
POST `http://{{host}}/bookmarks`
Body: `{ book_id: 4 }`
-
user_id
는 토큰에서 가져온다. - 생성 후 생성된
bookmark_id
를 반환한다. 클라이언트 측에서bookmark_id
정보를 저장하고 있으며 새로 생성할 경우 정보를 업데이트한다.
DELETE `http://{{host}}/bookmarks/:bookmarkId`
-
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를 담아보낸다. - 만약 토큰의 유효기간이 지난 에러라면, 토큰을 해독해서 id를 가져오고 유저가 보낸 refresh token이 유효한 지 확인한다.
- 유효하다면 새 토큰을 발급받고 cookie에 담아 보내주고,
res.locals.user
에 동일하게 id를 담아서 다음 미들웨어에게 넘긴다.
- 유효하다면 새 토큰을 발급받고 cookie에 담아 보내주고,
- access token이 변형되었거나, refresh token이 유효하지 않다면 TOKEN_MALFORMED 에러를 보낸다. 클라이언트 측에서는 재로그인해야 한다.
이전에 타입스크립트를 사용하지 않고 구현할 때에는 request에 인증정보를 담아 보냈었는데, 이번에 req.user
와 같이 설정할 경우 타입스크립트에서 에러를 발생시켰다. 방법은 있었으나, request의 타입을 확장시키는 방식이었다. 다른 방법을 찾아보니 res.locals
객체가 있었다.
res.locals는 로컬 변수를 포함한 객체로, 하나의 request-response 사이클에만 유효하다. 따라서 request pathname이나, 유저의 인증정보 등을 담는데에 이용된다.
-
res.locals는 response의 프로퍼티이므로 클라이언트에서 참조가 가능할 것 같은데, 인증 정보를 담는데 적합할까?
guard 함수에 대한 PR 리뷰 과정에서 위 사항에 대해 논의가 있었는데, 찾아보니
res.locals
객체는 서버 측에서만 참조 가능하다고 한다. 해당 객체에 인증 정보를 담는 것이 차선책이라기 보다는 오히려 더 적합한 방법이었다.
북마크 기능은 책 컴포넌트 뿐만 아니라 뷰어 페이지에서도 사용 중이기 때문에, 훅으로 만들어 재사용하고자 했다. 훅을 설계할 때 컴포넌트에서 어떤 걸 필요로 하는지, 즉 무엇을 반환할 지 먼저 생각해보았다.
-
북마크 훅 설계
반환해야 하는 값들
- 새로 생성된 북마크 id
- 북마크 클릭 시 실행될 함수
- 현재 북마크 수
이에 따라 다음을 인수로 받아야 했다. 여기서 최초는 불러온 책 정보에 담겨있는 값을 의미한다.
- 최초 북마크 id
- 책 id
- 최초 북마크 수
-
POST와 DELETE 요청 구분
현재 클라이언트에서 저장하고 있는 북마크 id가 있을 경우 DELETE 요청, 없을 경우 POST 요청이 가게끔 했다. POST 요청 후에는 북마크 id를 반환해서 현재 북마크 id를 업데이트했다.
-
북마크 수 계산
북마크 수는 최초로 받아온 책의 북마크 수에서 유저가 북마크 할 시 1이 추가되는 방식으로 설계했다. 그 동안 다른 유저들이 북마크를 변경한다면 그 수가 정확히 일치하지 않을 순 있지만, 매번 갱신해야 할 만큼 중요한 정보는 아니라고 생각했기에 불필요한 DB 접근을 줄이는 방법을 택했다.
구현된 코드는 다음과 같다.
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 훅에서만 수정하면 되니 코드 관리가 편하다.
- 유저가 북마크를 계속 눌렀다가 취소하는 행동을 반복할 경우 어떻게 대응할 것인가? 북마크 생성, 삭제 모두 db에 접근하기 때문에 서버에 부담이 될 수 있다.
- 현재 책을 받아오는 api에서 북마크 id도 한꺼번에 넘겨서 주고 있다. 책 정보 자체는 어떤 클라이언트가 요청하든 동일하지만, 책에 대한 북마크 id는 클라이언트마다 다르므로 이 로직을 분리하는 게 좋을 것 같다.
[API 설계] DELETE request 요청/처리/응답에 관한 소소한 고민
Passing variables to the next middleware using next() in Express.js