
'좋아요' 기능, 그냥 숫자 하나 더하는 게 아니었어 (1) - 계획 & 기능 구현
Deep Dive 블로그에 '좋아요' 기능을 추가하기로 했다. 처음엔 간단하게 생각했다. 그냥 하트 버튼 하나 만들고, 숫자(count) 하나 더해주면 끝나는 거 아닌가? 하지만 이 작은 기능 하나가 내 프로젝트의 아키텍처 전체를 되돌아보게 만드는 거대한 나비효과를 일으킬 줄은 꿈에도 몰랐다.
Step 1: 첫 번째 계획 - "가장 이상적인 척"
기능 요구사항은 명확했다.
- 비로그인 사용자도 '좋아요'를 누를 수 있어야 한다.
- 중복 '좋아요'는 안 된다. 두 번째 클릭은 '취소'가 되어야 한다.
처음 세운 계획은 꽤나 그럴듯해 보였다. 백엔드와 프론트엔드의 역할을 명확히 나누고, 최신 기술 스택의 모범 사례를 따르는 것처럼 보였다.
- Backend:
likes.router.ts를 새로 만들어like,unlike,like-statusAPI를 구현한다. - Frontend:
api.ts에 API 함수들을 추가하고,useLike라는 커스텀 훅으로 로직을 캡슐화한다.
완벽해 보였다. 하지만 여기서 의문이 들었다.
"API를 3개나 만들 필요가 있나?
like와unlike는 사실상 하나의 '토글' 동작이고,like-status는 게시물을 불러올 때 함께 내려주면 네트워크 요청을 한 번 줄일 수 있지 않을까?"
생각해 보니 그랬다. 사용자가 페이지에 들어올 때마다 게시물 데이터 따로, '좋아요' 상태 따로 요청하는 건 명백한 낭비였다. 그리고 like와 unlike를 굳이 분리할 필요도 없었다. 서버가 알아서 현재 상태를 보고 뒤집어주면 되는 일이었다.
Step 2: 두 번째 계획 - "효율성을 찾아서"
계획을 수정했다.
- Backend:
POST /posts/:postId/like: '좋아요' 상태를 토글하는 단일 API.GET /posts/:postId: 기존 게시물 조회 API에isLiked(현재 사용자의 '좋아요' 여부) 필드를 추가하여 응답.
- Frontend:
api.ts에toggleLike함수 하나만 추가.useLike훅에서는 **낙관적 업데이트(Optimistic Update)**를 적용하여 UX를 극대화.
여기서 또 다른 현실적인 문제가 고개를 들었다. 내 posts.router.ts 파일은 이미 500줄이 넘는 거대한 파일이 되어 있었다. 여기에 '좋아요' 관련 로직까지 추가하면 유지보수성이 떨어질 게 뻔했다.
결국 하이브리드 방식에 도달했다.
- 라우팅은 통합: API 엔드포인트는 RESTful 원칙에 맞게
/posts/:postId/like를 유지한다. - 로직은 분리: 실제 DB 처리 로직은
likes.service.ts라는 별도의 파일로 분리하여posts.router.ts가 비대해지는 것을 막는다.
이제야 좀 제대로 된 설계가 나온 것 같았다. 이 계획에 따라 가장 먼저 DynamoDB 테이블 구조부터 손 보기 시작했다.
Step 3: 데이터 모델링 - 모든 것은 테이블 설계로부터
'좋아요' 기능을 위해서는 두 가지 정보가 필요했다.
- 게시물 별 총 '좋아요' 수:
Post아이템에likeCount속성을 추가했다. 이건 쉬웠다. - 누가 어떤 글에 '좋아요'를 눌렀는가: 이걸 기록하기 위해 새로운
Like아이템을 설계했다.PK:POST#{postId}SK:LIKED_BY#{anonymousId}
이 구조는 특정 글에 특정 유저가 '좋아요'를 눌렀는지 O(1)로 매우 빠르게 확인할 수 있게 해준다. 비로그인 유저 식별은 localStorage에 UUID를 저장하는 anonymousId 방식으로 해결했다.
그리고 여기서 미래를 위한 설계를 추가했다. "내가 '좋아요' 한 글 목록" 같은 기능을 대비해, anonymousId로 데이터를 조회할 수 있는 새로운 GSI(Global Secondary Index)를 추가했다. 또한, PostCard 목록에서도 likeCount를 보여줘야 했기에, 기존 게시물 목록용 GSI의 projection에도 likeCount를 추가했다.
CDK 코드를 수정하고 cdk deploy를 실행하는 순간, 이제 정말 돌이킬 수 없는 강을 건넜다는 생각이 들었다. 인프라가 변경되었으니, 이제 코드로 증명해 보일 차례였다.
Step 4: 백엔드 구현 - "방어적 확장"의 미학
새로운 기능을 추가할 때, 기존 코드를 망가뜨리지 않는 것이 무엇보다 중요하다. "방어적 기능 확장" 원칙에 따라, 나는 다음과 같은 순서로 백엔드를 구축했다.
tryAnonymousAuthMiddleware: 요청 헤더에서X-Anonymous-Id를 조용히 읽어오는 미들웨어를 먼저 만들었다. 기존 인증 로직과 완전히 분리되어 있어 안전했다.likes.service.ts: '좋아요'와 관련된 모든 DB 로직을 여기에 쏟아부었다. 특히,likeCount증감과Like아이템 생성을 하나의 트랜잭션으로 묶는TransactWriteItems를 사용하여 데이터 정합성을 보장하는 데 가장 큰 공을 들였다. 이 서비스 파일만 따로 떼어 테스트할 수 있을 정도로 완벽하게 독립적으로 만들었다.posts.router.ts: 마지막으로, 라우터 파일에는 최소한의 '접착제' 코드만 추가했다. 새로 만든 미들웨어와 서비스를import하고,GET /:postId와POST /:postId/like핸들러에서 각각의 함수를 호출하는 역할만 부여했다. 라우터는 DB 구조나 트랜잭션에 대해 아무것도 알지 못했다.
백엔드 구현을 마치고 API를 테스트했을 때, 모든 것이 계획대로 완벽하게 동작했다. 이제 진짜 재미있는 부분, 프론트엔드 UI를 만들 차례였다.
'좋아요' 기능, 그냥 숫자 하나 더하는 게 아니었어 (2) - 디자인 고도화
백엔드 API가 완벽하게 준비되었다. GET /posts/:postId는 이제 likeCount와 isLiked를 포함해서 내려주고, POST /posts/:postId/like는 깔끔하게 '좋아요' 상태를 토글해준다. 이제 남은 것은 사용자가 직접 누르고 보게 될 프론트엔드 UI를 만드는 일이었다. 그리고 언제나 그렇듯, 디테일 속에 악마가 숨어 있었다.
Step 5: 프론트엔드 - 뇌(useLike)와 얼굴(Button)을 만들다
백엔드 로직을 서비스 레이어로 분리했던 것처럼, 프론트엔드에서도 UI 렌더링과 상태 관리 로직을 분리하는 것이 정석이다. 이를 위해 useLike라는 커스텀 훅을 만들기로 했다.
이 훅의 역할은 명확했다.
- '좋아요' 상태(
likeCount,isLiked)를useState로 관리한다. - '좋아요' 버튼이 클릭되면, 백엔드 API(
api.toggleLike)를 호출한다. - 서버 응답을 기다리는 동안 UI가 멈칫거리는 것을 막기 위해, **낙관적 업데이트(Optimistic Update)**를 적용한다.
낙관적 업데이트는 이 기능의 사용자 경험을 결정하는 핵심이었다. 서버의 응답을 기다리지 않고, 사용자가 버튼을 클릭하는 즉시 UI(하트 색상, 숫자)를 먼저 바꾸는 것이다. 마치 "당신의 요청은 성공할 겁니다"라고 긍정적으로 예측하고 미리 보여주는 것과 같다. 만약 네트워크 오류 등으로 실패하면, 그때 조용히 원래 상태로 되돌리면 된다.
// apps/frontend/src/hooks/useLike.ts export function useLike(initialPost: Post) { const [likeState, setLikeState] = useState({ likeCount: initialPost.likeCount || 0, isLiked: initialPost.isLiked || false, }); const [isPending, startTransition] = useTransition(); const handleLike = async () => { startTransition(async () => { // 1. 롤백을 위해 이전 상태 저장 const previousState = likeState; // 2. UI 즉시 업데이트 (낙관적 업데이트) setLikeState(prev => ({ likeCount: prev.isLiked ? prev.likeCount - 1 : prev.likeCount + 1, isLiked: !prev.isLiked, })); try { // 3. 실제 API 요청 const result = await api.toggleLike(initialPost.postId); // 4. 서버 최종 값으로 상태 동기화 setLikeState({ ...result }); } catch (error) { // 5. 실패 시 롤백 setLikeState(previousState); } }); }; return { ...likeState, handleLike, isPending }; }
이 useLike 훅 덕분에, 실제 버튼 컴포넌트인 PostUtilButtons.tsx는 매우 깔끔해졌다. 그냥 훅을 호출하고, 반환된 값들을 UI에 연결하기만 하면 끝이었다.
Step 6: 디테일과의 싸움 - UI 고도화
핵심 기능은 완성됐지만, UI는 아직 영혼이 없었다. 그냥 하트 아이콘과 숫자가 덩그러니 있을 뿐이었다. 여기서부터가 진짜 재미있는, 디테일을 조각하는 과정이었다.
6.1. 클릭, 그리고 살아있는 피드백
사용자가 버튼을 눌렀을 때, 아무런 시각적 변화가 없다면 제대로 눌렀는지 확신할 수 없다. tailwindcss의 group과 active: 상태를 이용해, 버튼을 클릭하는 순간 하트 아이콘이 살짝 커졌다가 돌아오는 '펀치' 효과를 추가했다.
/* globals.css */ @keyframes bouncy-heart { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.25); } 75% { transform: scale(0.95); } } .animate-bouncy-heart { animation: bouncy-heart 0.3s ease-in-out; }
그리고 React의 key prop을 이용한 간단한 트릭으로, '좋아요'를 누를 때마다 이 애니메이션이 다시 재생되도록 만들었다. 이제 버튼은 사용자의 클릭에 살아있는 것처럼 반응했다.
6.2. 숫자의 춤, framer-motion
숫자가 9에서 10으로, 99에서 100으로 바뀔 때 UI가 어색하게 느껴졌다. 숫자의 자릿수가 바뀔 때마다 버튼의 전체적인 레이아웃이 울컥거렸기 때문이다.
이 문제를 해결하기 위해 framer-motion을 도입했다. AnimatePresence 컴포넌트를 사용해, 숫자가 바뀔 때마다 이전 숫자는 아래로 사라지고 새 숫자가 위에서 나타나는 '슬롯머신' 같은 애니메이션을 구현했다.
// PostUtilButtons.tsx <div className="relative w-8 h-5 overflow-hidden text-center"> <AnimatePresence> <motion.span key={likeCount} // key가 바뀌면 애니메이션이 트리거된다 variants={slotMachineVariants} initial="enter" animate="center" exit="exit" > {likeCount} </motion.span> </AnimatePresence> </div>
또한, 숫자의 자릿수에 따라 컨테이너의 너비(w-4, w-6, w-8)를 동적으로 변경하고, 이 너비 변화 자체에도 transition을 적용했다. 그 결과, '좋아요'가 9에서 10이 되는 순간, 숫자 컨테이너는 부드럽게 넓어지면서 숫자 '9'는 사라지고 '10'이 나타나는, 매우 만족스러운 시각적 경험을 만들어낼 수 있었다.
결론: 작은 기능, 큰 배움
처음엔 그저 숫자 하나 더하는 일이라고 생각했던 '좋아요' 기능은, 결국 내게 아키텍처 설계의 중요성, 데이터 흐름에 대한 깊은 이해, 그리고 사용자 경험을 결정하는 디테일의 힘을 가르쳐주었다.
- 데이터베이스에는 항상 원본을 저장해야 한다. 가공된 데이터는 언제든 원본으로부터 다시 만들 수 있지만, 한번 잃어버린 원본은 되돌릴 수 없다.
- 역할을 분리하면 코드가 강해진다. 백엔드와 프론트엔드, 그리고 그 안의 서비스와 컴포넌트들이 각자의 역할에만 충실할 때, 시스템 전체는 더 견고하고 유연해진다.
- 사용자는 0.1초의 차이를 느낀다. 낙관적 업데이트와 섬세한 애니메이션은 단순한 '꾸미기'가 아니라, 사용자가 시스템과 편안하게 상호작용하도록 돕는 핵심적인 '기능'이다.
결국 '좋아요' 버튼 하나를 추가하는 것은, 내 블로그와 나 자신을 한 단계 더 성장시키는 값진 경험이 되었다. 이제 내 블로그의 작은 하트는, 그냥 숫자를 보여주는 것이 아니라 수많은 고민과 배움의 흔적을 담고 빛나고 있다.
