ip 주소를 이용하여 익명 좋아요 기능 만들기

kidow
@kidow
태그
Vercel
Supabase
TailwindCSS
설명
vercel이 제공하는 ip 추출 기능으로 좋아요 컴포넌트를 만들어 봅니다.
배포
배포
수정일
Nov 2, 2023 06:22 AM
생성일
Nov 2, 2023 01:38 AM
이 글은 Next.js 13 App Router 버전을 기준으로 작성하였습니다.
Vercel이 자체적으로 ip를 헤더에 저장한다는 사실을 알고 계셨나요? 저는 이 걸로 무엇을 할 수 있을까 생각해보다가 좋아요 기능을 만들면 괜찮겠다! 하고 생각이 들어서 만들어 보게 되었습니다.

만들기로 한 프로젝트는 제가 운영하고 있는 ‘일간 ProductHunt’입니다. 상세 페이지에 만들어두면 괜찮을 것 같습니다.
Vercel에 저장소 연결
ip 주소는 로컬에서 개발할 때는 추출되지 않습니다. 따라서 Vercel이 제공하는 CLI를 통해 Vercel의 배포 환경을 로컬로 복제하는 과정이 필요합니다.
npm install -g vercel@latest
터미널에 vercel dev를 치면 저장소를 연결할 것이 물어봅니다. 연결하면 이렇게 vercel 배포 환경이 로컬로 복제되면서 개발 서버를 열게 됩니다.

ip 주소 추출
ip 주소는 기본적으로 Vercel Proxy에 의해 자동으로 헤더에 저장되어 있습니다. 이 헤더는 서버 측에서만 가져올 수 있습니다. 한 번 확인해 볼까요?
import { headers } from 'next/headers' export const runtime = 'edge' export default async function Page() { const ip = headers().get('x-real-ip') console.log('ip', ip) }
다음과 같이 ip가
::1
로 고정되어 나오는 것을 볼 수 있습니다. 배포 환경에서는 ipv4로 나올 겁니다.
위에서 runtime을 ‘edge’로 지정하는 것은 꼭 해주셔야 합니다. 그래야 ip를 가져올 수 있습니다.
x-real-ip라는 이름은 vercel이 직접 명시한 기본 명칭입니다. 참고

Supabase 테이블 생성
좋아요 데이터를 저장하기 위해 likes라는 이름으로 테이블을 생성해줍니다.

RLS(Row Level Security)도 간단하게 설정해 줍니다.

버튼 꾸미기
이제 ui를 꾸밀 차례입니다. 이왕 하는거 애니메이션도 넣어보고 싶었는데, 마침 괜찮은 예제가 하나 있더군요.
애니메이션을 만들기 위해 tailwind config를 손봐줍니다.
// tailwind.config.js /** @type {import('tailwindcss').Config} */ module.exports = { theme: { extend: { keyframes: { 'heart-burst': { from: { backgroundPosition: 'left' }, to: { backgroundPosition: 'right' } } }, animation: { 'heart-burst': 'heart-burst 0.8s steps(28) 1' } } } }
Like 클라이언트 컴포넌트를 꾸며줍니다.
// app/(product)/product/[id]/like.tsx 'use client' import { createClientComponentClient } from '@supabase/auth-helpers-nextjs' import { useParams } from 'next/navigation' import { useMemo, useState } from 'react' import { cn } from 'services' interface Props { list: string[] ip: string | null } export default function Like({ ip, ...props }: Props): JSX.Element { const supabase = createClientComponentClient<Database>() const [list, setList] = useState<string[]>(props.list || []) const [isAnimated, setIsAnimated] = useState(false) const params = useParams() const onClick = async () => { if (!ip) return if (list.indexOf(ip || '') === -1) { const { error } = await supabase .from('likes') .insert({ product_id: params.id as string, ip_address: ip }) .select(`*`) if (error) { console.error(error) return } setIsAnimated(true) } else { const { error } = await supabase .from('likes') .delete() .eq('ip_address', ip) if (error) { console.error(error) return } } const { data } = await supabase .from('likes') .select(`*`) .eq('product_id', params.id) setList(data?.map((item) => item.ip_address) || []) } const isLiked: boolean = useMemo( () => list.indexOf(ip || '') !== -1, [list, ip] ) return ( <button onClick={onClick} className={cn( 'inline-flex relative rounded-full border w-20 py-2 justify-end items-center gap-2', isLiked ? 'border-red-500' : 'border-neutral-700' )} > <div className={cn( "w-[50px] absolute left-0 top-1/2 -translate-y-1/2 h-[50px] bg-[url('https://abs.twimg.com/a/1446542199/img/t1/web_heart_animation.png')] bg-no-repeat bg-[length:2900%]", { 'animate-heart-burst': isAnimated }, isLiked ? 'bg-right' : 'bg-left' )} onAnimationEnd={() => setIsAnimated(false)} /> <div className={cn('w-[50px]', { 'text-red-500': isLiked })}> {list.length} </div> </button> ) }
ip와 기존 좋아요 목록들을 page.tsx에서 가져와서 자식 컴포넌트로 내려줍니다.
// app/(product)/product/[id]/page.tsx import { headers } from 'next/headers' interface Props { params: { id: string } } export default async function Page({}: Props) { const ip = headers().get('x-real-ip') const { data } = await supabase .from('histories') .select(` *, likes (*) `) .eq('id', params.id) .single() return ( ... <Like list={data.likes.map(item = item.ip_address)} ip={ip} /> ) }
결과

만약 로그인 없이 익명으로 사용할 수 있는 프로덕트를 만들어 보고 싶다면, vercel이 쉽게 제공하는 ip 주소를 활용해 보면 좋을 것 같네요. 👍
참고 자료
- 일간 ProductHunt: https://daily-producthunt.kidow.me
- vercel dev: https://vercel.com/docs/cli/dev
- Nextjs Edge runtime: https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes#edge-runtime
- 좋아요 애니메이션: https://codepen.io/chrismabry/pen/ZbjZEj
- Vercel
- Supabase
- TailwindCSS