ip 주소를 이용하여 익명 좋아요 기능 만들기
![avatar](/_next/image?url=%2Favatar.png&w=96&q=75)
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를 헤더에 저장한다는 사실을 알고 계셨나요? 저는 이 걸로 무엇을 할 수 있을까 생각해보다가 좋아요 기능을 만들면 괜찮겠다! 하고 생각이 들어서 만들어 보게 되었습니다.
![좋아요 기능을 적용할 곳](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2F2d48ceca-c0e0-4929-bbe4-7432e2836591%2FUntitled.png?table=block&id=d2b642c2-3791-4e43-9076-6f966bc7935f&cache=v2)
만들기로 한 프로젝트는 제가 운영하고 있는 ‘일간 ProductHunt’입니다. 상세 페이지에 만들어두면 괜찮을 것 같습니다.
Vercel에 저장소 연결
ip 주소는 로컬에서 개발할 때는 추출되지 않습니다. 따라서 Vercel이 제공하는 CLI를 통해 Vercel의 배포 환경을 로컬로 복제하는 과정이 필요합니다.
npm install -g vercel@latest
터미널에 vercel dev를 치면 저장소를 연결할 것이 물어봅니다. 연결하면 이렇게 vercel 배포 환경이 로컬로 복제되면서 개발 서버를 열게 됩니다.
![notion image](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2F2841ba99-18f7-46b9-9f0a-65aaa92ebd51%2FUntitled.png?table=block&id=d833a77a-52d8-4e72-b29d-dd7bbaa48f0e&cache=v2)
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로 나올 겁니다.![notion image](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2Fcd3225c3-4095-4cda-820c-58c924739529%2FUntitled.png?table=block&id=f74c7dc2-c42d-44f1-b77f-7319bdc551da&cache=v2)
위에서 runtime을 ‘edge’로 지정하는 것은 꼭 해주셔야 합니다. 그래야 ip를 가져올 수 있습니다.
x-real-ip라는 이름은 vercel이 직접 명시한 기본 명칭입니다. 참고
![vercel 깃허브 edge-headers](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2Ffcccbed5-a5df-46f1-abce-84836d304ce3%2FUntitled.png?table=block&id=d3cd7f66-d91a-4547-80d1-f46fb963c43e&cache=v2)
Supabase 테이블 생성
좋아요 데이터를 저장하기 위해 likes라는 이름으로 테이블을 생성해줍니다.
![notion image](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2F0f423ead-9e3c-4f56-8a6f-34fd10cc9526%2FUntitled.png?table=block&id=98eb44a9-6c71-4575-aac2-01ce5190af81&cache=v2)
RLS(Row Level Security)도 간단하게 설정해 줍니다.
![notion image](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2F5effa850-dc82-4089-9080-32f89372da2a%2FUntitled.png?table=block&id=462600fe-d2cf-4c12-a47f-eeb38bb508ec&cache=v2)
버튼 꾸미기
이제 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} /> ) }
결과
![완벽하네요! 🎉](https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Faa404725-54fb-4102-b842-1b14f9803432%2Fa584a885-66cf-460b-b2ba-7ab86e1e1c09%2FNov-02-2023_14-56-19.gif?table=block&id=212855e1-36f0-4926-8d30-9b1706baf53f&cache=v2)
만약 로그인 없이 익명으로 사용할 수 있는 프로덕트를 만들어 보고 싶다면, 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