1. 포트폴리오 사이트, Next.js로 전부다 갈아엎기
2025년 10월 1일
기존 github page로 만들어두었던 포트폴리오 작품을 next로 바꾸고자 합니다.
프로젝트의 블로그를 추가하여, 작업을 하며 생겼던 트러블 슈팅이나 과정을 기록하고자 합니다. 겸사겸사 next.js의 사용법도 익히면서..
많은 현업 개발자들을 최근 만나면서, 회고의 중요성에 대해 강조하고 또 강조하셨습니다. 저도 굉장히 동감합니다. AI시대가 되면서, 오히려 더 중요한 것은 기본기이고 내 코드에 대한 이해도, 선택의 근거, 등 이 중요하다고 생각합니다.
순수 자바스크립트 포트폴리오를 Next.js로 전환하기
1. 프로젝트 전환 배경
기존 포트폴리오는 순수 HTML, CSS, JavaScript로 구성된 정적 사이트였습니다. 시간이 지나면서 다음 한계가 보였습니다:
- 코드 재사용성 부족: HTML이 길고 중복이 많음
- 유지보수 어려움: 데이터 변경 시 여러 곳을 수정해야 함
- 타입 안정성 부족: JavaScript만으로 런타임 에러 위험
- 확장성 제한: 기능 추가가 어려움
이를 해결하기 위해 Next.js + TypeScript로 전환하기로 했습니다.
2. 프로젝트 초기 설정
2.1 Next.js 프로젝트 생성
npx create-next-app@latest dowankim-portfolio --typescript --tailwind --app
선택한 옵션:
- TypeScript: 타입 안정성
- Tailwind CSS: 유틸리티 기반 스타일링
- App Router: Next.js 14의 새로운 라우팅 방식
2.2 프로젝트 구조 설정
dowankim1024.github.io/
├── app/
│ ├── page.tsx # 메인 포트폴리오 페이지
│ ├── layout.tsx # 루트 레이아웃
│ └── globals.css # 전역 스타일
├── components/
│ └── Home/ # 포트폴리오 섹션 컴포넌트
│ ├── Header/
│ ├── Home/
│ ├── About/
│ ├── Career/
│ ├── Work/
│ ├── Contact/
│ └── ArrowUp/
└── public/
└── images/ # 정적 이미지 파일
3. HTML을 React 컴포넌트로 변환
3.1 메인 페이지 구조 변환
기존 HTML (index.html):
<!DOCTYPE html> <html lang="en"> <head> <title>Dowan Kim' Portfolio</title> <!-- ... 메타 태그들 ... --> </head> <body> <header class="header">...</header> <main> <section id="home">...</section> <section id="about">...</section> <!-- ... --> </main> <footer id="contact">...</footer> </body> </html>
Next.js 구조:
app/layout.tsx (루트 레이아웃):
import type { Metadata } from 'next' import { Noto_Sans } from 'next/font/google' import Script from 'next/script' import './globals.css' const notoSans = Noto_Sans({ subsets: ['latin'], weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], variable: '--font-noto-sans', }) export const metadata: Metadata = { title: 'Dowan Kim Portfolio', description: 'Front-end developer with design skills', } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="ko" className={notoSans.variable}> <head> <link rel="shortcut icon" href="/images/fav.ico" type="image/x-icon" /> </head> <body className={notoSans.className}> <Script src="https://kit.fontawesome.com/a13d39dcb0.js" crossOrigin="anonymous" strategy="afterInteractive" /> {children} </body> </html> ) }
변경 사항:
- Google Fonts를 Next.js의
next/font로 최적화 - Font Awesome을 Next.js
Script컴포넌트로 로드 - 메타데이터를 Next.js
MetadataAPI로 관리
app/page.tsx (메인 페이지):
import Header from '@/components/Home/Header' import Home from '@/components/Home/Home' import About from '@/components/Home/About' import Career from '@/components/Home/Career' import Work from '@/components/Home/Work' import Contact from '@/components/Home/Contact' import ArrowUp from '@/components/Home/ArrowUp' export default function PortfolioPage() { return ( <> <Header /> <main> <Home /> <About /> <Career /> <Work /> <ArrowUp /> </main> <Contact /> </> ) }
3.2 Header 컴포넌트 변환
기존 HTML:
<header class="header"> <div class="header__logo"> <img class="header__logo__img" src="images/projects/prof.jpeg" alt="logo" /> <h1 class="header__logo__title"><a href="#">DowanKim</a></h1> </div> <nav class="header__nav"> <ul class="header__menu"> <li><a class="header__menu__item active" href="#home">Home</a></li> <li><a class="header__menu__item" href="#about">About</a></li> <!-- ... --> </ul> </nav> <button class="header__toggle" aria-label="navigation menu toggle"> <i class="fa-solid fa-bars"></i> </button> </header>
기존 JavaScript (active_menu.js):
const menuItems = document.querySelectorAll('.header__menu__item') menuItems.forEach(item => { item.addEventListener('click', () => { menuItems.forEach(i => i.classList.remove('active')) item.classList.add('active') }) })
Next.js 컴포넌트:
'use client' import { useEffect, useState, useRef } from 'react' import Image from 'next/image' import Link from 'next/link' import { usePathname } from 'next/navigation' import { NAV_ITEMS } from './Header.constants' export default function Header() { const pathname = usePathname() const [isDark, setIsDark] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false) const headerRef = useRef<HTMLElement>(null) // 스크롤 시 헤더 배경색 변경 useEffect(() => { const header = headerRef.current if (!header) return const handleScroll = () => { const headerHeight = header.getBoundingClientRect().height if (window.scrollY > headerHeight) { setIsDark(true) } else { setIsDark(false) } } window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) }, []) const isActive = (href: string) => { if (href === '/') { return pathname === '/' } return pathname?.startsWith(href) } return ( <header ref={headerRef} className={`fixed top-0 w-full p-4 flex justify-between items-center z-10 transition-all duration-300 bg-transparent ${ isDark ? 'bg-[#050a13] shadow-[0_2px_4px_rgba(0,0,0,0.1)]' : '' }`} > <div className="flex items-center gap-2"> <Image className="w-9 h-9 object-cover rounded-full border border-[#03e8f9]" src="/images/projects/prof.jpeg" alt="logo" width={36} height={36} /> <h1 className="text-3xl"> <Link href="/">DowanKim</Link> </h1> </div> <nav className="hidden md:block"> <ul className="flex gap-1 mr-4"> {NAV_ITEMS.map((item) => ( <li key={item.href}> <Link href={item.href} className={`block px-4 py-2 border-b border-transparent transition-all duration-[250ms] hover:border-[#03e8f9] ${ isActive(item.href) ? 'border border-[#03e8f9] rounded' : '' }`} > {item.label} </Link> </li> ))} </ul> </nav> {/* 모바일 메뉴 토글 버튼 */} <button className="block text-white text-2xl absolute top-5 right-4 bg-transparent border-none cursor-pointer md:hidden" aria-label="navigation menu toggle" onClick={() => setIsMenuOpen(!isMenuOpen)} > <i className="fa-solid fa-bars"></i> </button> {/* 모바일 메뉴 */} {isMenuOpen && ( <nav className="block absolute top-full left-0 w-full bg-[#050a13] md:hidden"> <ul className="flex flex-col text-center my-4 mx-16 gap-4"> {NAV_ITEMS.map((item) => ( <li key={item.href}> <Link href={item.href} className="block px-4 py-2 border-b border-transparent transition-all duration-[250ms] hover:border-[#03e8f9]" onClick={() => setIsMenuOpen(false)} > {item.label} </Link> </li> ))} </ul> </nav> )} </header> ) }
변경 사항:
'use client'로 클라이언트 컴포넌트 지정useState로 메뉴 열림/닫힘 상태 관리useEffect로 스크롤 이벤트 처리- Next.js
Image컴포넌트로 이미지 최적화 usePathname으로 현재 경로 확인 및 활성 메뉴 표시
3.3 Home 컴포넌트 변환
기존 HTML:
<section id="home"> <div class="max-container home__container"> <img class="home__avatar" src="images/projects/prof.jpeg" alt="Dowan Kims's profile" /> <div> <h2 class="home__title"> <strong class="home__title--strong">Designer&Developer,</strong><br /> Dowan Kim </h2> <p class="home__description">Front-end developer with design skills</p> <a class="home__contact" href="#contact">Contact Me</a> </div> </div> </section>
Next.js 컴포넌트:
'use client' import Image from 'next/image' import Link from 'next/link' import { socialLinks } from './Home.constants' export default function Home() { const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>) => { e.preventDefault() const element = document.getElementById('contact') if (element) { element.scrollIntoView({ behavior: 'smooth' }) } } return ( <section id="home" className="relative bg-[#050a13] text-white py-20 pt-28 text-left max-md:text-center"> <div className="max-w-[1200px] mx-auto flex flex-row items-center gap-24 justify-center max-md:flex-col max-md:items-center"> <Image className="w-auto h-auto max-w-[400px] max-h-[500px] object-contain object-top rounded-2xl border-[3px] border-[#03e8f9] max-md:max-w-[300px] max-md:max-h-[375px]" src="/images/projects/prof.jpeg" alt="Dowan Kim's profile" width={400} height={500} style={{ width: 'auto', height: 'auto' }} /> <div className="flex flex-col items-start flex-1 max-md:items-center max-md:text-center"> <h2 className="text-5xl mb-4 flex flex-col items-start max-md:items-center"> <strong className="text-[#03e8f9] mb-2"> Front-end Engineer </strong> <span>Dowan Kim</span> </h2> <p className="text-xl">Front-end developer with design skills</p> <ul className="flex justify-center gap-4 py-4 text-3xl list-none my-4"> {socialLinks.map((link) => ( <li key={link.href}> <Link className="transition-colors duration-[250ms] text-white hover:text-[#03e8f9]" href={link.href} target="_blank" title={link.title} > <i className={link.iconClass}></i> </Link> </li> ))} </ul> <Link className="inline-block bg-[#03e8f9] my-8 px-4 py-2 font-bold text-[#050a13] rounded transition-all duration-[250ms] hover:bg-transparent hover:text-white hover:outline hover:outline-2 hover:outline-[#03e8f9]" href="#contact" onClick={scrollToSection} > Contact Me </Link> </div> </div> </section> ) }
3.4 Work 컴포넌트 변환
기존 JavaScript (projects.js):
const categories = document.querySelectorAll('.category') const projects = document.querySelectorAll('.project') categories.forEach(category => { category.addEventListener('click', () => { // 활성 카테고리 변경 categories.forEach(c => c.classList.remove('category--selected')) category.classList.add('category--selected') // 프로젝트 필터링 const categoryId = category.dataset.category projects.forEach(project => { if (categoryId === 'all' || project.dataset.type === categoryId) { project.style.display = 'block' } else { project.style.display = 'none' } }) }) })
Next.js 컴포넌트:
'use client' import { useState } from 'react' import Image from 'next/image' import Link from 'next/link' import { categories, projects } from './Work.constants' export default function Work() { const [selectedCategory, setSelectedCategory] = useState('front-end') const [isAnimating, setIsAnimating] = useState(false) const handleCategoryClick = (categoryId: string) => { if (categoryId === selectedCategory) return setIsAnimating(true) setSelectedCategory(categoryId) setTimeout(() => { setIsAnimating(false) }, 250) } const filteredProjects = selectedCategory === 'all' ? projects : projects.filter(project => project.type === selectedCategory) return ( <section id="work" className="bg-[#050a13] text-white py-16"> <div className="max-w-[1200px] mx-auto px-4 text-center"> <h2 className="text-4xl my-4">My work</h2> <p className="text-2xl my-2">Projects</p> {/* 카테고리 버튼 */} <ul className="flex justify-center my-10 gap-4 flex-col md:flex-row"> {categories.map((category) => ( <li key={category.id}> <button className={`text-lg px-4 py-2 rounded border border-[#03e8f9] cursor-pointer transition-all duration-[250ms] ${ selectedCategory === category.id ? 'bg-[#03e8f9] text-[#050a13]' : 'bg-transparent text-white hover:bg-[#03e8f9]/20' }`} onClick={() => handleCategoryClick(category.id)} > {category.label} </button> </li> ))} </ul> {/* 프로젝트 그리드 */} <ul className={`grid grid-cols-2 md:grid-cols-4 gap-4 transition-all duration-[250ms] ${ isAnimating ? 'opacity-0 scale-[0.96] translate-y-5' : '' }`}> {filteredProjects.map((project, index) => ( <li key={index} className="flex justify-center items-center relative rounded-lg overflow-hidden group"> <Link href={project.href} target={project.href === '#' ? '_self' : '_blank'} className="block" > <Image src={project.img} alt={project.title} className="w-[300px] h-[200px] max-w-full max-h-full object-cover" width={300} height={300} /> <div className="absolute top-0 left-0 w-full h-full bg-black opacity-0 flex flex-col justify-center items-center transition-all duration-[250ms] group-hover:opacity-80"> <h3 className="text-white font-bold mb-2">{project.title}</h3> <p>{project.description}</p> </div> </Link> </li> ))} </ul> </div> </section> ) }
변경 사항:
- DOM 조작 대신 React 상태로 필터링
- 선언적 UI 업데이트
- 애니메이션 상태 추가
3.5 ArrowUp 컴포넌트 변환
기존 HTML:
<aside> <a class="arrow-up" href="#home" title="back to top"> <i class="fa-solid fa-arrow-up"></i> </a> </aside>
기존 JavaScript:
const arrowUp = document.querySelector('.arrow-up') window.addEventListener('scroll', () => { if (window.scrollY > 500) { arrowUp.style.opacity = '1' } else { arrowUp.style.opacity = '0' } })
Next.js 컴포넌트:
'use client' import { useEffect, useState } from 'react' import Link from 'next/link' export default function ArrowUp() { const [opacity, setOpacity] = useState(0) useEffect(() => { const handleScroll = () => { const homeSection = document.getElementById('home') if (!homeSection) return const homeHeight = homeSection.offsetHeight if (window.scrollY > homeHeight / 2) { setOpacity(1) } else { setOpacity(0) } } window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) }, []) const scrollToTop = (e: React.MouseEvent<HTMLAnchorElement>) => { e.preventDefault() window.scrollTo({ top: 0, behavior: 'smooth' }) } return ( <aside> <Link className="fixed bottom-12 right-12 z-[1000] text-5xl w-18 h-18 rounded-full text-center bg-[#050a13] shadow-[0_3px_10px_#03e8f9] transition-opacity duration-300 text-white flex items-center justify-center" href="#home" title="back to top" onClick={scrollToTop} style={{ opacity }} > <i className="fa-solid fa-arrow-up"></i> </Link> </aside> ) }
4. CSS를 Tailwind CSS로 변환
4.1 전환 이유
- 유틸리티 클래스로 빠른 스타일링
- 반응형 디자인 간편화 (
max-md:,md:등) - CSS 파일 크기 감소
- 일관된 디자인 시스템
- 많은 사람들이 이용하는 tailwind css 연습
4.2 변환 예시
기존 CSS:
.home__container { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: row; align-items: center; gap: 24px; } @media (max-width: 768px) { .home__container { flex-direction: column; align-items: center; } }
Tailwind CSS:
<div className="max-w-[1200px] mx-auto flex flex-row items-center gap-24 max-md:flex-col max-md:items-center">
4.3 커스텀 색상 설정
tailwind.config.js:
module.exports = { content: [ './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { primary: '#03e8f9', dark: '#050a13', 'dark-secondary': '#1b1e26', }, }, }, plugins: [], }
5. 데이터 분리 및 상수 파일 생성
5.1 컴포넌트별 상수 파일 생성
각 컴포넌트의 데이터를 별도 파일로 분리:
components/Home/Home/Home.constants.ts:
export const socialLinks = [ { href: 'https://github.com/dowankim1024', iconClass: 'fa-brands fa-github', title: 'my github link', }, { href: 'https://blog.naver.com/kimdowan1004', iconClass: 'fa-solid fa-blog', title: 'my blog link', }, { href: 'https://www.instagram.com/dowan.kim_developer', iconClass: 'fa-brands fa-instagram', title: 'my instagram link', }, ]
components/Home/Work/Work.constants.ts:
export interface Category { id: string label: string } export interface Project { href: string img: string title: string description: string type: string } export const categories: Category[] = [ { id: 'all', label: 'All' }, { id: 'front-end', label: 'Front-end' }, { id: 'design', label: 'Design' }, { id: 'plan', label: 'Plan' }, ] export const projects: Project[] = [ { href: 'https://2025-pnu-design-technology-graduate.vercel.app/about', img: '/images/projects/Graduate.webp', title: '2025 PNU Design&Technology Graduate Website', description: 'Designer(SeEun Park), Developer(DoWan Kim)', type: 'front-end', }, // ... 더 많은 프로젝트들 ]
components/Home/Career/Career.constants.ts:
export interface Experience { company: string role: string period: string } export interface Education { school: string major: string | string[] period: string } export const experiences: Experience[] = [ { company: 'LS Information Technology Co., Ltd.', role: 'Front-end Developer', period: '2025.01 - 2025.03', }, // ... ] export const education: Education[] = [ { school: 'PNU Design&Technology', major: ['Design', 'Technology'], period: '2020.03 - 2026.02', }, ]
장점:
- 데이터와 UI 분리
- 타입 안정성 확보
- 재사용성 향상
6. 이미지 경로 및 최적화
6.1 이미지 경로 변경
기존:
<img src="images/projects/prof.jpeg" alt="profile" />
Next.js:
import Image from 'next/image' <Image src="/images/projects/prof.jpeg" alt="profile" width={400} height={500} />
변경 사항:
public폴더의 파일은/로 시작하는 절대 경로 사용- Next.js
Image컴포넌트로 자동 최적화
6.2 Next.js Image 컴포넌트 장점
- 자동 이미지 최적화 (WebP 변환 등)
- 지연 로딩 (Lazy Loading)
- 반응형 이미지 자동 생성
- 레이아웃 시프트 방지
7. 주요 문제 해결 과정
7.1 스크롤 이벤트 최적화
문제: 스크롤 이벤트 리스너가 cleanup되지 않아 메모리 누수 발생 가능
해결: useEffect의 cleanup 함수에서 이벤트 리스너 제거
useEffect(() => { const handleScroll = () => { // 스크롤 처리 로직 } window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) // cleanup }, [])
7.2 반응형 디자인 구현
Tailwind CSS의 반응형 유틸리티 사용:
<div className="flex flex-row max-md:flex-col"> {/* 데스크톱: 가로 배치, 모바일: 세로 배치 */} </div>
8. 전환 후 개선 사항
8.1 개발 경험
- 컴포넌트 기반으로 재사용성 향상
- TypeScript로 타입 안정성 확보
- Hot Module Replacement로 빠른 개발
8.2 코드 품질
- 데이터와 UI 분리로 유지보수 용이
- 타입 체크로 런타임 에러 감소
- 컴포넌트 단위 관리
8.3 성능
- Next.js 이미지 최적화
- 코드 스플리팅으로 초기 로딩 개선
- Font Awesome 지연 로딩
9. 결론
- 컴포넌트 기반 구조로 전환
- TypeScript 도입으로 타입 안정성 확보
- Tailwind CSS로 스타일링 개선
- Next.js 최적화 기능 활용
- 코드 구조 개선으로 유지보수성 향상
이제 firebase로 백엔드를 연결하고, 블로그 기능(프로젝트별 블로그, 어드민 페이지로 글 작성)을 구현하고자 합니다.