Back to Posts
WebNext.jsTypeScriptTailwind CSSFramer Motion

Next.js App Router로 포트폴리오 만들기

Next.js, TypeScript, Tailwind CSS, Framer Motion

이 포트폴리오 사이트를 어떻게 만들었는지, 기술 선택의 이유와 구조 설계를 공유합니다.

왜 Next.js App Router인가?

SSR + ISR로 SEO와 성능을 동시에 잡을 수 있다. MDX 콘텐츠는 빌드 타임에 정적으로 생성되고, 방문할 때마다 CDN 캐시에서 빠르게 서빙된다.

핵심 기술 스택

역할기술
프레임워크Next.js 15 App Router
스타일링Tailwind CSS v4
콘텐츠Local MDX Files
애니메이션Framer Motion
배포Vercel

MDX 파이프라인

next-mdx-remote/rsc를 사용해서 서버 컴포넌트에서 직접 MDX를 렌더링한다.

// posts/[slug]/page.tsx import { MDXRemote } from "next-mdx-remote/rsc" import remarkGfm from "remark-gfm" export default async function PostPage({ params }) { const post = getPostData(slug) return ( <MDXRemote source={post.content} components={mdxComponents} options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }} /> ) }

동적 태그 추출

MDX 파일의 frontmatter에서 categorybadges 배열을 자동으로 파싱한다. 새 포스트를 추가하면 UI 코드 수정 없이 필터에 자동 반영된다.

// lib/tags.ts export function extractAllTags(posts: PostData[]): AllTags { const categorySet = new Set<string>() const badgeSet = new Set<string>() for (const post of posts) { post.category.forEach(c => categorySet.add(c)) post.badges.forEach(b => badgeSet.add(b)) } return { categories: Array.from(categorySet).sort(), badges: Array.from(badgeSet).sort(), } }

디자인 컨셉: Cybernetic Chick

  • 배경: 순수 블랙 #000000
  • 메인 액센트: Hacker Yellow #FACC15 (= Tailwind yellow-400)
  • 서브 액센트: Hacker Blue #3B82F6 (= Tailwind blue-500)
  • 카드: Glassmorphism (bg-white/5, backdrop-blur-xl)

Bento Grid

Apple 스타일의 비정형 그리드. 데스크탑에서는 grid-cols-12로 복잡한 레이아웃, 모바일에서는 col-span-12로 자연스럽게 스택된다.

핵심은 CSS Grid의 row-span을 활용해 카드에 시각적 무게감을 주는 것이다.

방문자 카운터: 설계 결정 과정

Stats 위젯에 방문자 수를 표시하고 싶었다. 단순해 보이지만, 구현하면서 생각보다 고려할 게 많았다.

스토리지 선택

MDX 파일 기반 사이트라 별도 DB가 없었다. 방문자 수는 숫자 하나만 저장하면 되니까 풀 DB를 붙이는 건 과했다. Upstash를 선택했다.

Upstash는 서버리스 환경용 관리형 Redis 서비스다. Redis라고 하면 항상 켜진 서버가 필요할 것 같지만, Upstash는 HTTP REST API로 동작해서 Vercel 서버리스 함수에서 그냥 호출할 수 있다.

// lib/redis.ts import { Redis } from '@upstash/redis' export const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, })

Redis 키 하나(portfolio:views)에 숫자를 저장하고, INCR 명령어로 1씩 올린다.

남용 방지: 왜 필요한가

새로고침 한 번 = POST 한 번이면 두 가지 문제가 생긴다.

첫째, 카운트 부풀기. 한 사람이 새로고침 100번 하면 방문자가 100명처럼 찍힌다. 둘째, 리소스 고갈. Vercel 서버리스 함수는 호출 횟수 한도가 있다. 악의적인 반복 호출로 한도가 소진될 수 있다.

그래서 레이어를 나눠서 막았다.

sessionStorage — 카운트 중복 방지

useEffect(() => { const isVisited = sessionStorage.getItem('portfolio:visited') if (isVisited) { fetch('/api/views').then(r => r.json()).then(d => setViews(d.views)) } else { fetch('/api/views', { method: 'POST' }) .then(r => r.json()) .then(d => { setViews(d.views) sessionStorage.setItem('portfolio:visited', '1') }) } }, [])

같은 탭 세션에서 첫 방문만 POST(카운트 증가)하고, 이후 새로고침은 GET(숫자 읽기)만 한다. 탭을 닫으면 세션이 리셋되어 다음 방문은 다시 카운팅된다. 모바일에서도 동일하게 작동한다.

캐시 — 함수 과부하 방지

여러 사람이 동시에 접속해 새로고침을 반복하면 sessionStorage와 무관하게 GET 요청이 쏟아진다. 이건 캐시로 막는다.

// app/api/views/route.ts export async function GET() { const views = (await redis.get<number>('portfolio:views')) ?? 0 return NextResponse.json({ views }, { headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate' }, }) }

s-maxage=60은 Vercel CDN에게 "60초 동안 이 응답을 캐싱해서 돌려줘라"는 지시다. 60초 안에 아무리 많은 요청이 와도 Redis는 한 번만 읽힌다.

페이지 자체는 ISR을 쓰지 않았다. 처음엔 export const revalidate = 60으로 60초마다 재생성하려 했지만, 생각해보니 이유가 없었다. ISR은 "서버에서 데이터를 주기적으로 새로 가져와야 할 때" 의미가 있다. 그런데 이 사이트의 포스트는 DB가 아닌 로컬 MDX 파일에서 온다. 새 글을 올리려면 어차피 파일을 추가하고 배포해야 한다. 배포하면 Next.js가 전체를 새로 빌드하니 ISR이 끼어들 여지가 없다. 불필요한 60초 재생성을 걷어내고 완전 정적(Static)으로 뒀다. 정적 파일은 CDN에서 바로 서빙되어 서버리스 함수 자체가 실행되지 않으니, 오히려 더 강한 보호가 된다.

60초가 특별한 근거가 있는 숫자는 아니다. "1분 안에 내 방문이 반영되면 충분하다"는 판단이었다.

sessionStorage와 60초 캐시는 다른 것이다

헷갈리기 쉬운데 둘은 목적이 완전히 다르다.

역할지속시간
sessionStorage중복 카운팅 방지탭 닫을 때까지
60초 캐시GET API 과부하 방지60초

sessionStorage는 탭을 닫기 전까지 유지된다. 새로고침을 100번 해도 POST는 처음 한 번만 호출된다. 탭을 닫으면 세션이 사라지고, 다시 열면 새 방문으로 카운팅된다.

60초 캐시는 여러 사람이 동시에 GET을 요청할 때 Redis를 보호하는 역할이다. 둘은 막는 대상이 다르기 때문에 함께 필요하다.

트레이드오프: 60초 동안 숫자가 안 바뀐다

캐시를 쓰면 자연스럽게 생기는 문제가 있다.

A 방문 (T=0s) → POST → Redis에서 43 받음 → 43 표시 B 방문 (T=10s) → POST → Redis에서 44 받음 → 44 표시 A 새로고침 → GET → CDN 캐시 반환 → 43 표시 (최대 60초)

A는 B가 방문한 사실을 최대 60초 동안 모른다. 이걸 최종 일관성(Eventual Consistency) 이라고 한다. 데이터가 틀린 게 아니라, 동기화에 시간이 걸리는 것이다.

포트폴리오 방문자 카운터는 실시간이어야 할 이유가 없다. "대략 이 정도 방문했구나"를 보여주는 게 목적이니 60초 지연은 완전히 허용 범위다. 캐시를 없애면 지연은 없어지지만 보호 레이어가 하나 사라진다. 지금 설계는 그 트레이드오프를 의도적으로 선택한 결과다.

남아있는 허점: 반복 방문

탭을 닫고 다시 열면 sessionStorage가 초기화되어 POST가 다시 호출된다. 누군가 스크립트로 이를 반복하면 POST를 무한 호출해 리소스를 소진시킬 수 있다. 이를 완전히 막으려면 서버에서 IP 기반 rate limiting을 추가해야 한다.

다만 포트폴리오 규모에서 이 정도 공격은 현실적으로 일어나기 어렵고, 대응하려면 구현 복잡도가 크게 올라간다. 일반 사용자의 자연스러운 반복 방문은 문제가 없으니, 지금 수준의 보호로 충분하다고 판단했다.

마치며

코드보다 내용을 채우는 게 더 어렵다는 걸 다시 한번 깨달았다. 이제 진짜 글을 써야 할 시간.

All Posts

© 2026 DooYee. All rights reserved.