웹 프로그래밍
Billboard Hot 100 (초급)
이 실습 문제는 Next.js를 사용하여 빌보드 Top100 관련 데이터를 기반으로 정적 페이지를 단계별로 구성하는 실습 과정입니다. 완성된 페이지는 billboard-hot100 를 참고하세요.
1. 설계 및 첫번째 구현
정적 페이지를 구성하기 위한 첫번째 단계로 간단한 HTML을 사용해서 데모를 만들어보는 실습입니다.
1-1단계: 구조 작성
HTML의 기본 구조와 시맨틱 태그(<header>, <main>, <section>, <footer>)를 사용하여 페이지의 구조를 top100.html 파일로 작성해야 합니다. 설계시 아래 3가지를 참고하세요.
- 콘텐츠 내용을 선별하여 최소한의 태그를 사용
- 시맨틱 HTML 활용 (
<header>,<main>,<section>,<footer>) - 의미 있는 클래스명을 사용해서 계층 구조를 표현
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Billboard Hot 100</title>
<style>
/* 스타일은 아직 작성하지 않음 - Tailwind CSS를 사용하여 작성 */
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-container">
<div class="header-top">
<div class="logo-section">
<div>
<div class="logo-icon">🎵</div>
<div class="logo-text">Billboard Hot 100</div>
<div class="logo-tagline">The Week's Most Popular Songs</div>
</div>
</div>
<div class="header-date">2025년 11월 10일</div>
</div>
<div class="header-main">
<h1 class="header-title">This Week's Top Hits</h1>
<p class="header-subtitle">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon">📈</div>
<div class="stat-number">100</div>
<div class="stat-label">차트 순위</div>
</div>
<div class="stat-card">
<div class="stat-icon">🎵</div>
<div class="stat-number">52</div>
<div class="stat-label">주간 업데이트</div>
</div>
<div class="stat-card">
<div class="stat-icon">📅</div>
<div class="stat-number">67</div>
<div class="stat-label">역사 (년)</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Top 3 Section -->
<section class="section">
<h2 class="section-title">Top 3 of the Week</h2>
<p class="section-subtitle">이번 주 가장 핫한 음악 TOP 3</p>
<div class="top3-cards">
<div class="top3-card">
<div class="rank-badge">#1</div>
<div class="card-info">
<div class="card-title">Anti-Hero</div>
<div class="card-artist">Taylor Swift</div>
</div>
</div>
<div class="top3-card">
<div class="rank-badge">#2</div>
<div class="card-info">
<div class="card-title">As It Was</div>
<div class="card-artist">Harry Styles</div>
</div>
</div>
<div class="top3-card">
<div class="rank-badge">#3</div>
<div class="card-info">
<div class="card-title">Flowers</div>
<div class="card-artist">Miley Cyrus</div>
</div>
</div>
</div>
</section>
<!-- Charts 4-20 Section -->
<section class="section">
<h2 class="section-title">Charts 4-20</h2>
<p class="section-subtitle">계속해서 사랑받고 있는 노래들</p>
<div class="charts-list">
<div class="chart-item">
<div class="chart-rank">4</div>
<div class="trend trend-up">+1 ↗</div>
<div class="song-info">
<div class="song-title">Calm Down</div>
<div class="song-artist">Rema & Selena Gomez</div>
</div>
<div class="chart-peak">Peak: 3</div>
<div class="chart-weeks">Weeks: 18</div>
</div>
<!-- 더 많은 chart-item들... -->
</div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
<div class="footer-container">
<div class="footer-content">
<div class="footer-column">
<h3>Billboard Hot 100</h3>
<p>1958년부터 시작된 세계에서 가장 권위 있는 음악 차트</p>
</div>
<div class="footer-column">
<h3>차트 정보</h3>
<ul>
<li>매주 업데이트</li>
<li>스트리밍 + 판매 + 라디오 집계</li>
<li>전 세계 음악 트렌드 반영</li>
</ul>
</div>
<div class="footer-column">
<h3>더 알아보기</h3>
<ul>
<li><a href="#">Billboard 200</a></li>
<li><a href="#">Artist 100</a></li>
<li><a href="#">Global 200</a></li>
</ul>
</div>
</div>
<div class="footer-copyright">
© 2025 Billboard Hot 100. All rights reserved.
</div>
</div>
</footer>
</body>
</html>1-2단계: 구성요소 배치
CSS의 Flexbox와 Grid를 사용하여 기초적인 레이아웃을 작성합니다. 레이아웃 작성시 아래 3가지를 참고하세요.
- 기본 레이아웃(
<div>) 구조 완성 - Flexbox로 수평/수직 정렬
- Grid로 카드 레이아웃 구성
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Header */
.header-container {
max-width: 1200px;
margin: 0 auto;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-main {
text-align: center;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
}
.top3-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.top3-card {
position: relative;
}
.rank-badge {
position: absolute;
top: 1rem;
left: 1rem;
}
.card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
/* Charts List */
.charts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-item {
display: grid;
grid-template-columns: 50px 40px 1fr 80px 80px;
gap: 1rem;
align-items: center;
}
.song-info {
display: flex;
flex-direction: column;
}
.trend {
display: flex;
align-items: center;
justify-content: center;
}
/* Footer */
.footer-container {
max-width: 1200px;
margin: 0 auto;
}
.footer-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
</style>1-3단계: 구성요소 정리
마진과 패딩을 추가하여 요소 간 간격을 조정하고 구성요소를 정리합니다.
- 마진으로 구성요소 간 간격 조정
- 패딩으로 구성요소 내부 여백 확보
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Header */
.header {
padding: 2rem 0;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-main {
text-align: center;
margin-bottom: 2rem;
}
.header-title {
margin-bottom: 0.5rem;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.stat-card {
padding: 1.5rem;
text-align: center;
}
.stat-icon {
margin-bottom: 0.5rem;
}
.stat-number {
margin-bottom: 0.25rem;
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
}
.section {
margin-bottom: 4rem;
}
.section-title {
margin-bottom: 0.5rem;
}
.section-subtitle {
margin-bottom: 2rem;
}
.top3-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.top3-card {
position: relative;
}
.rank-badge {
position: absolute;
top: 1rem;
left: 1rem;
}
.card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
}
.card-title {
margin-bottom: 0.25rem;
}
/* Charts List */
.charts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-item {
display: grid;
grid-template-columns: 50px 40px 1fr 80px 80px;
gap: 1rem;
align-items: center;
padding: 1rem;
}
.song-info {
display: flex;
flex-direction: column;
}
.song-title {
margin-bottom: 0.25rem;
}
.trend {
display: flex;
align-items: center;
justify-content: center;
}
/* Footer */
.footer {
padding: 3rem 0 1.5rem;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.footer-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-bottom: 2rem;
}
.footer-column h3 {
margin-bottom: 1rem;
}
.footer-column ul li {
margin-bottom: 0.5rem;
}
.footer-copyright {
padding-top: 1.5rem;
}
</style>1-4단계: Assets 추가
이미지를 추가하고 배경 이미지를 설정합니다.
- 배경 이미지 설정
- 앨범 아트 이미지 추가
- 이미지 크기 및 위치 조정
<style>
/* 이전 단계의 스타일 유지 */
.top3-card {
position: relative;
border-radius: 16px;
overflow: hidden;
aspect-ratio: 1;
background-size: cover;
background-position: center;
}
.top3-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
}
.chart-item {
display: grid;
grid-template-columns: 50px 40px 60px 1fr 80px 80px;
gap: 1rem;
align-items: center;
padding: 1rem;
}
.album-art {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
</style><!-- Top 3 Cards에 배경 이미지 추가 -->
<div class="top3-card"
style="background-image: url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop');">
<div class="rank-badge">#1</div>
<div class="card-info">
<div class="card-title">Anti-Hero</div>
<div class="card-artist">Taylor Swift</div>
</div>
</div>
<!-- Charts List에 앨범 아트 이미지 추가 -->
<div class="chart-item">
<div class="chart-rank">4</div>
<div class="trend trend-up">+1 ↗</div>
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down" class="album-art">
<div class="song-info">
<div class="song-title">Calm Down</div>
<div class="song-artist">Rema & Selena Gomez</div>
</div>
<div class="chart-peak">Peak: 3</div>
<div class="chart-weeks">Weeks: 18</div>
</div>1-5단계: 디자인 완성
디자인 요소를 추가하여 결과물을 완성합니다.
- 색상 적용 (헤더 그라데이션, 텍스트 색상 등)
- 반응형 디자인 적용(가능하다면)
- 폰트 및 크기 설정
- 호버 효과 및 트랜지션 추가
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #333;
line-height: 1.6;
}
/* Header */
.header {
background: linear-gradient(135deg, #e91e63 0%, #f06292 100%);
color: white;
padding: 2rem 0;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
font-size: 2rem;
}
.logo-text {
font-size: 1.5rem;
font-weight: bold;
}
.logo-tagline {
font-size: 0.9rem;
opacity: 0.9;
margin-top: 0.25rem;
}
.header-date {
font-size: 1rem;
}
.header-main {
text-align: center;
margin-bottom: 2rem;
}
.header-title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.header-subtitle {
font-size: 1.1rem;
opacity: 0.95;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.stat-card {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
backdrop-filter: blur(10px);
}
.stat-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
}
.section {
margin-bottom: 4rem;
}
.section-title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.section-subtitle {
font-size: 1rem;
color: #666;
margin-bottom: 2rem;
}
/* Top 3 Cards */
.top3-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.top3-card {
position: relative;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
aspect-ratio: 1;
background-size: cover;
background-position: center;
}
.top3-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
}
.rank-badge {
position: absolute;
top: 1rem;
left: 1rem;
background: #e91e63;
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
z-index: 2;
}
.card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
color: white;
z-index: 2;
}
.card-title {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.card-artist {
font-size: 1rem;
opacity: 0.9;
}
/* Charts List */
.charts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-item {
display: grid;
grid-template-columns: 50px 40px 60px 1fr 80px 80px;
gap: 1rem;
align-items: center;
padding: 1rem;
border-radius: 8px;
transition: background-color 0.2s;
}
.chart-item:hover {
background-color: #f5f5f5;
}
.chart-rank {
font-size: 1.2rem;
font-weight: bold;
text-align: center;
}
.trend {
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.trend-up {
color: #4caf50;
}
.trend-down {
color: #f44336;
}
.album-art {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
.song-info {
display: flex;
flex-direction: column;
}
.song-title {
font-weight: bold;
margin-bottom: 0.25rem;
}
.song-artist {
font-size: 0.9rem;
color: #666;
}
.chart-peak,
.chart-weeks {
text-align: center;
color: #666;
font-size: 0.9rem;
}
/* Footer */
.footer {
background-color: #2c2c2c;
color: #ccc;
padding: 3rem 0 1.5rem;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.footer-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-bottom: 2rem;
}
.footer-column h3 {
color: white;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.footer-column p,
.footer-column ul {
font-size: 0.9rem;
line-height: 1.8;
}
.footer-column ul {
list-style: none;
}
.footer-column ul li {
margin-bottom: 0.5rem;
}
.footer-column ul li a {
color: #ccc;
text-decoration: none;
transition: color 0.2s;
}
.footer-column ul li a:hover {
color: white;
}
.footer-copyright {
text-align: center;
padding-top: 1.5rem;
border-top: 1px solid #444;
font-size: 0.85rem;
color: #999;
}
/* Responsive */
@media (max-width: 768px) {
.header-title {
font-size: 2rem;
}
.stats-cards {
grid-template-columns: 1fr;
}
.top3-cards {
grid-template-columns: 1fr;
}
.chart-item {
grid-template-columns: 40px 30px 50px 1fr;
gap: 0.5rem;
}
.chart-peak,
.chart-weeks {
display: none;
}
.footer-content {
grid-template-columns: 1fr;
}
}
</style>1-6단계: 완성(top100.html)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Billboard Hot 100</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #333;
line-height: 1.6;
}
/* Header */
.header {
background: linear-gradient(135deg, #e91e63 0%, #f06292 100%);
color: white;
padding: 2rem 0;
}
.header-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
font-size: 2rem;
}
.logo-text {
font-size: 1.5rem;
font-weight: bold;
}
.logo-tagline {
font-size: 0.9rem;
opacity: 0.9;
margin-top: 0.25rem;
}
.header-date {
font-size: 1rem;
}
.header-main {
text-align: center;
margin-bottom: 2rem;
}
.header-title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.header-subtitle {
font-size: 1.1rem;
opacity: 0.95;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.stat-card {
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
backdrop-filter: blur(10px);
}
.stat-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
}
.section {
margin-bottom: 4rem;
}
.section-title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.section-subtitle {
font-size: 1rem;
color: #666;
margin-bottom: 2rem;
}
/* Top 3 Cards */
.top3-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.top3-card {
position: relative;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
aspect-ratio: 1;
background-size: cover;
background-position: center;
}
.top3-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
}
.rank-badge {
position: absolute;
top: 1rem;
left: 1rem;
background: #e91e63;
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
z-index: 2;
}
.card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
color: white;
z-index: 2;
}
.card-title {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.card-artist {
font-size: 1rem;
opacity: 0.9;
}
/* Charts List */
.charts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-item {
display: grid;
grid-template-columns: 50px 40px 60px 1fr 80px 80px;
gap: 1rem;
align-items: center;
padding: 1rem;
border-radius: 8px;
transition: background-color 0.2s;
}
.chart-item:hover {
background-color: #f5f5f5;
}
.chart-rank {
font-size: 1.2rem;
font-weight: bold;
text-align: center;
}
.trend {
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.trend-up {
color: #4caf50;
}
.trend-down {
color: #f44336;
}
.album-art {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
.song-info {
display: flex;
flex-direction: column;
}
.song-title {
font-weight: bold;
margin-bottom: 0.25rem;
}
.song-artist {
font-size: 0.9rem;
color: #666;
}
.chart-peak,
.chart-weeks {
text-align: center;
color: #666;
font-size: 0.9rem;
}
/* Footer */
.footer {
background-color: #2c2c2c;
color: #ccc;
padding: 3rem 0 1.5rem;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
.footer-content {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin-bottom: 2rem;
}
.footer-column h3 {
color: white;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.footer-column p,
.footer-column ul {
font-size: 0.9rem;
line-height: 1.8;
}
.footer-column ul {
list-style: none;
}
.footer-column ul li {
margin-bottom: 0.5rem;
}
.footer-column ul li a {
color: #ccc;
text-decoration: none;
transition: color 0.2s;
}
.footer-column ul li a:hover {
color: white;
}
.footer-copyright {
text-align: center;
padding-top: 1.5rem;
border-top: 1px solid #444;
font-size: 0.85rem;
color: #999;
}
/* Responsive */
@media (max-width: 768px) {
.header-title {
font-size: 2rem;
}
.stats-cards {
grid-template-columns: 1fr;
}
.top3-cards {
grid-template-columns: 1fr;
}
.chart-item {
grid-template-columns: 40px 30px 50px 1fr;
gap: 0.5rem;
}
.chart-peak,
.chart-weeks {
display: none;
}
.footer-content {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-container">
<div class="header-top">
<div class="logo-section">
<div>
<div class="logo-icon">🎵</div>
<div class="logo-text">Billboard Hot 100</div>
<div class="logo-tagline">The Week's Most Popular Songs</div>
</div>
</div>
<div class="header-date">2025년 11월 10일</div>
</div>
<div class="header-main">
<h1 class="header-title">This Week's Top Hits</h1>
<p class="header-subtitle">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon">📈</div>
<div class="stat-number">100</div>
<div class="stat-label">차트 순위</div>
</div>
<div class="stat-card">
<div class="stat-icon">🎵</div>
<div class="stat-number">52</div>
<div class="stat-label">주간 업데이트</div>
</div>
<div class="stat-card">
<div class="stat-icon">📅</div>
<div class="stat-number">67</div>
<div class="stat-label">역사 (년)</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Top 3 Section -->
<section class="section">
<h2 class="section-title">Top 3 of the Week</h2>
<p class="section-subtitle">이번 주 가장 핫한 음악 TOP 3</p>
<div class="top3-cards">
<div class="top3-card"
style="background-image: url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop');">
<div class="rank-badge">#1</div>
<div class="card-info">
<div class="card-title">Anti-Hero</div>
<div class="card-artist">Taylor Swift</div>
</div>
</div>
<div class="top3-card"
style="background-image: url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop');">
<div class="rank-badge">#1</div>
<div class="card-info">
<div class="card-title">Anti-Hero</div>
<div class="card-artist">Taylor Swift</div>
</div>
</div>
<div class="top3-card"
style="background-image: url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop');">
<div class="rank-badge">#1</div>
<div class="card-info">
<div class="card-title">Anti-Hero</div>
<div class="card-artist">Taylor Swift</div>
</div>
</div>
</div>
</section>
<!-- Charts 4-20 Section -->
<section class="section">
<h2 class="section-title">Charts 4-20</h2>
<p class="section-subtitle">계속해서 사랑받고 있는 노래들</p>
<!-- Charts List에 앨범 아트 이미지 추가 -->
<div class="chart-item">
<div class="chart-rank">4</div>
<div class="trend trend-up">+1 ↗</div>
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down" class="album-art">
<div class="song-info">
<div class="song-title">Calm Down</div>
<div class="song-artist">Rema & Selena Gomez</div>
</div>
<div class="chart-peak">Peak: 3</div>
<div class="chart-weeks">Weeks: 18</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
<div class="footer-container">
<div class="footer-content">
<div class="footer-column">
<h3>Billboard Hot 100</h3>
<p>1958년부터 시작된 세계에서 가장 권위 있는 음악 차트</p>
</div>
<div class="footer-column">
<h3>차트 정보</h3>
<ul>
<li>매주 업데이트</li>
<li>스트리밍 + 판매 + 라디오 집계</li>
<li>전 세계 음악 트렌드 반영</li>
</ul>
</div>
<div class="footer-column">
<h3>더 알아보기</h3>
<ul>
<li><a href="#">Billboard 200</a></li>
<li><a href="#">Artist 100</a></li>
<li><a href="#">Global 200</a></li>
</ul>
</div>
</div>
<div class="footer-copyright">
© 2025 Billboard Hot 100. All rights reserved.
</div>
</div>
</footer>
</body>
</html>2. Next.js 프로젝터로 포팅(Porting)
기존에 완성된 HTML을 Next.js App Router를 사용하는
page.tsx로 포팅하는 과정을 단계별로 진행합니다.
2-1단계: Next.js 프로젝트 설정
먼저 Next.js 프로젝트를 생성합니다. Next.js는 기본적으로 Tailwind CSS를 사용할 수 있도록 설정되어 있습니다.
- 프로젝트 생성
- 프로젝트 구조
- 실행 방법
설치가 끝나면 npm run dev 명령어로 프로젝트를 실행합니다.
이후, 브라우저를 사용해서 올바로 작동하는지 확인하세요.
2-2단계: HTML 구조를 단일 컴포넌트로 변환
app/page.tsx 파일을 생성하고 기본 구조를 작성합니다.
- HTML 구조를 JSX로 변환
style="..."에서style={{ ... }}와 같은 객체 형태로 변경class에서className으로 변경
- React 컴포넌트 기본 구조 완성
<div>를 사용해서 컴포넌트를 반환하도록 작성
// app/page.tsx
export default function Home() {
return (
<div>
{/* Header */}
<header className="header">
<div className="header-container">
<div className="header-top">
<div className="logo-section">
<div>
<div className="logo-icon">🎵</div>
<div className="logo-text">Billboard Hot 100</div>
<div className="logo-tagline">The Week's Most Popular Songs</div>
</div>
</div>
<div className="header-date">2025년 11월 10일</div>
</div>
<div className="header-main">
<h1 className="header-title">This Week's Top Hits</h1>
<p className="header-subtitle">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div className="stats-cards">
<div className="stat-card">
<div className="stat-icon">📈</div>
<div className="stat-number">100</div>
<div className="stat-label">차트 순위</div>
</div>
<div className="stat-card">
<div className="stat-icon">🎵</div>
<div className="stat-number">52</div>
<div className="stat-label">주간 업데이트</div>
</div>
<div className="stat-card">
<div className="stat-icon">📅</div>
<div className="stat-number">67</div>
<div className="stat-label">역사 (년)</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="main-content">
{/* Top 3 Section */}
<section className="section">
<h2 className="section-title">Top 3 of the Week</h2>
<p className="section-subtitle">이번 주 가장 핫한 음악 TOP 3</p>
<div className="top3-cards">
<div className="top3-card" style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="rank-badge">#1</div>
<div className="card-info">
<div className="card-title">Anti-Hero</div>
<div className="card-artist">Taylor Swift</div>
</div>
</div>
<div className="top3-card" style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="rank-badge">#1</div>
<div className="card-info">
<div className="card-title">Anti-Hero</div>
<div className="card-artist">Taylor Swift</div>
</div>
</div>
<div className="top3-card" style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="rank-badge">#1</div>
<div className="card-info">
<div className="card-title">Anti-Hero</div>
<div className="card-artist">Taylor Swift</div>
</div>
</div>
</div>
</section>
{/* Charts 4-20 Section */}
<section className="section">
<h2 className="section-title">Charts 4-20</h2>
<p className="section-subtitle">계속해서 사랑받고 있는 노래들</p>
{/* Charts List에 앨범 아트 이미지 추가 */}
<div className="chart-item">
<div className="chart-rank">4</div>
<div className="trend trend-up">+1 ↗</div>
<img src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop" alt="Calm Down" className="album-art" />
<div className="song-info">
<div className="song-title">Calm Down</div>
<div className="song-artist">Rema & Selena Gomez</div>
</div>
<div className="chart-peak">Peak: 3</div>
<div className="chart-weeks">Weeks: 18</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="footer">
<div className="footer-container">
<div className="footer-content">
<div className="footer-column">
<h3>Billboard Hot 100</h3>
<p>1958년부터 시작된 세계에서 가장 권위 있는 음악 차트</p>
</div>
<div className="footer-column">
<h3>차트 정보</h3>
<ul>
<li>매주 업데이트</li>
<li>스트리밍 + 판매 + 라디오 집계</li>
<li>전 세계 음악 트렌드 반영</li>
</ul>
</div>
<div className="footer-column">
<h3>더 알아보기</h3>
<ul>
<li><a href="#">Billboard 200</a></li>
<li><a href="#">Artist 100</a></li>
<li><a href="#">Global 200</a></li>
</ul>
</div>
</div>
<div className="footer-copyright">
© 2025 Billboard Hot 100. All rights reserved.
</div>
</div>
</footer>
</div>
);
}2-3단계: CSS 클래스를 Tailwind CSS 클래스로 변환
app/page.tsx 파일의 인라인 CSS를 Tailwind CSS 유틸리티 클래스로 변환합니다.
- 모든 CSS 클래스를 Tailwind CSS 유틸리티 클래스로 변환
- TailwindCSS의 유틸리티 클래스란 미리 정의된 CSS 속성의 값을 설정해둔 클래스들을 의미
- 강의 관련 참고 문서 중에서 TailwindCSS 관련 노트를 참고
- 더 세부적인 사항은 TailwindCSS 공식 문서를 참고
2-3(a). 기본 스타일 및 레이아웃 변환(app/page.tsx)
export default function Home() {
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-8">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-2">
<div>
<div className="text-3xl">🎵</div>
<div className="text-2xl font-bold">Billboard Hot 100</div>
<div className="text-sm opacity-90 mt-1">The Week's Most Popular Songs</div>
</div>
</div>
<div className="text-base">2025년 11월 10일</div>
</div>
<div className="text-center mb-8">
<h1 className="text-5xl font-bold mb-2">This Week's Top Hits</h1>
<p className="text-lg opacity-95">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div className="grid grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📈</div>
<div className="text-4xl font-bold mb-1">100</div>
<div className="text-sm opacity-90">차트 순위</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">🎵</div>
<div className="text-4xl font-bold mb-1">52</div>
<div className="text-sm opacity-90">주간 업데이트</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📅</div>
<div className="text-4xl font-bold mb-1">67</div>
<div className="text-sm opacity-90">역사 (년)</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-8 py-12 bg-white">
<section className="mb-16">
<h2 className="text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
{/* Top 3 Cards */}
</section>
</main>
</div>
)
}2-3(b). Top 3 카드 스타일 변환
{/* Main Content */}
<main className="max-w-6xl mx-auto px-8 py-12 bg-white">
<section className="mb-16">
<h2 className="text-3xl text-gray-600 font-bold mb-2">Top 3 of the Week</h2>
<p className="text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
{/* Top 3 Cards */}
<div className="grid grid-cols-3 gap-6">
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70"></div>
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#1
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">Anti-Hero</div>
<div className="text-base opacity-90">Taylor Swift</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70"></div>
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#1
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">Anti-Hero</div>
<div className="text-base opacity-90">Taylor Swift</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70"></div>
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#1
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">Anti-Hero</div>
<div className="text-base opacity-90">Taylor Swift</div>
</div>
</div>
</div>
</section>
</main>2-3(c). 차트 리스트 스타일 변환
{/* Charts 4-20 Section */}
<section>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-[50px_40px_60px_1fr_80px_80px] gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors">
<div className="text-xl font-bold text-center">4</div>
<div className="flex items-center justify-center font-bold text-green-600">+1 ↗</div>
<img
src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down"
className="w-15 h-15 rounded-lg object-cover"
/>
<div className="flex flex-col">
<div className="font-bold mb-1">Calm Down</div>
<div className="text-sm text-gray-600">Rema & Selena Gomez</div>
</div>
<div className="text-center text-gray-600 text-sm">Peak: 3</div>
<div className="text-center text-gray-600 text-sm">Weeks: 18</div>
</div>
</div>
</section>2-4단계: Next.js Image 컴포넌트 및 반응형 디자인 적용
Next.js의 Image 컴포넌트를 사용하고 반응형 디자인을 완성합니다.
- Next.js Image 컴포넌트로 이미지 최적화
- TailwindCSS의 반응형 브레이크포인트 활용
2-4(a). Image 컴포넌트 사용
import Image from 'next/image'
export default function Home() {
return (
// ... 기존 코드 ...
<div className="grid grid-cols-[50px_40px_60px_1fr_80px_80px] gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors">
<div className="text-xl font-bold text-center">4</div>
<div className="flex items-center justify-center font-bold text-green-600">+1 ↗</div>
<Image
src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down"
width={60}
height={60}
className="rounded-lg object-cover"
/>
{/* ... 나머지 코드 ... */}
</div>
)
}Image를 불러오는 과정에서 오류가 발생합니다. next.config.ts를 아래와 같이 수정하세요.
- Image 불러오는 과정에서 오류가 발생하는 이유
- Next.js의
Image컴포넌트는 기본적으로 외부 도메인(다른 서버)에 있는 이미지를 가져와서 표시할 때 보안 및 이미지 최적화 효율을 위해, 허용된 도메인 목록만 관리자가 명시적으로 설정하도록 요구 - 이를 설정 파일(
next.config.js또는next.config.ts)에 추가하지 않으면 외부 이미지가 표시되지 않거나, 경고/오류가 발생 - 이렇게 설계된 이유는 외부 이미지 호스팅 상황에서 잠재적인 보안 이슈(예: SSRF 등)를 방지하고, 이미지 최적화 및 캐싱 처리를 Next.js가 안전하게 수행하기 위함
- Next.js의
2-4(b). 반응형 디자인 적용
- TailwindCSS의 반응형 브레이크포인트 활용
- md, lg, xl, 2xl 브레이크포인트 활용
- md: 768px 이상
- lg: 1024px 이상
- xl: 1280px 이상
- 2xl: 1536px 이상
md:...와 같은 형태로 반응형 디자인 적용
- md, lg, xl, 2xl 브레이크포인트 활용
export default function Home() {
return (
<div className="min-h-screen">
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
{/* ... 로고 섹션 ... */}
</div>
<div className="text-center mb-8">
<h1 className="text-3xl md:text-5xl font-bold mb-2">This Week's Top Hits</h1>
<p className="text-base md:text-lg opacity-95">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{/* ... 통계 카드 ... */}
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* ... Top 3 카드 ... */}
</div>
</section>
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Charts 4-20</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">계속해서 사랑받고 있는 노래들</p>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-[40px_30px_50px_1fr] md:grid-cols-[50px_40px_60px_1fr_80px_80px] gap-2 md:gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors">
<div className="text-xl font-bold text-center">4</div>
<div className="flex items-center justify-center font-bold text-green-600">+1 ↗</div>
<Image
src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down"
width={60}
height={60}
className="rounded-lg object-cover"
/>
<div className="flex flex-col">
<div className="font-bold mb-1">Calm Down</div>
<div className="text-xs md:text-sm text-gray-600">Rema & Selena Gomez</div>
</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Peak: 3</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Weeks: 18</div>
</div>
</div>
</section>
</main>
<footer className="bg-[#2c2c2c] text-gray-300 py-12">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* ... Footer 컬럼 ... */}
</div>
</div>
</footer>
</div>
)
}2-5단계: 최종 완성 및 메타데이터 설정
Next.js의 메타데이터를 app/layout.tsx에 설정하세요.
- Next.js 메타데이터 설정
- SEO를 위해서 페이지의 제목과 설명을 설정
- 메타데이터는
app/layout.tsx에 설정
완성된 app/page.tsx는 아래와 같습니다.
import Image from 'next/image'
export default function Home() {
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
{/* Header */}
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<div>
<div className="text-3xl">🎵</div>
<div className="text-xl md:text-2xl font-bold">Billboard Hot 100</div>
<div className="text-xs md:text-sm opacity-90 mt-1">The Week's Most Popular Songs</div>
</div>
<div className="text-sm md:text-base">2025년 11월 10일</div>
</div>
<div className="text-center mb-8">
<h1 className="text-3xl md:text-5xl font-bold mb-2">This Week's Top Hits</h1>
<p className="text-base md:text-lg opacity-95">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📈</div>
<div className="text-4xl font-bold mb-1">100</div>
<div className="text-sm opacity-90">차트 순위</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">🎵</div>
<div className="text-4xl font-bold mb-1">52</div>
<div className="text-sm opacity-90">주간 업데이트</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📅</div>
<div className="text-4xl font-bold mb-1">67</div>
<div className="text-sm opacity-90">역사 (년)</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
{/* Top 3 Section */}
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#1
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">Anti-Hero</div>
<div className="text-base opacity-90">Taylor Swift</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1516280440614-37939bbacd81?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#2
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">As It Was</div>
<div className="text-base opacity-90">Harry Styles</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: "url('https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop')" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#3
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">Flowers</div>
<div className="text-base opacity-90">Miley Cyrus</div>
</div>
</div>
</div>
</section>
{/* Charts 4-20 Section */}
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Charts 4-20</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">계속해서 사랑받고 있는 노래들</p>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-[40px_30px_50px_1fr] md:grid-cols-[50px_40px_60px_1fr_80px_80px] gap-2 md:gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors">
<div className="text-xl font-bold text-center">4</div>
<div className="flex items-center justify-center font-bold text-green-600">+1</div>
<Image
src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop"
alt="Calm Down"
width={60}
height={60}
className="rounded-lg object-cover"
/>
<div className="flex flex-col">
<div className="font-bold mb-1">Calm Down</div>
<div className="text-xs md:text-sm text-gray-600">Rema & Selena Gomez</div>
</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Peak: 3</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Weeks: 18</div>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="bg-[#2c2c2c] text-gray-300 py-12">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<div>
<h3 className="text-white mb-4 text-lg">Billboard Hot 100</h3>
<p className="text-sm leading-relaxed">1958년부터 시작된 세계에서 가장 권위 있는 음악 차트</p>
</div>
<div>
<h3 className="text-white mb-4 text-lg">차트 정보</h3>
<ul className="text-sm leading-relaxed list-none">
<li className="mb-2">매주 업데이트</li>
<li className="mb-2">스트리밍 + 판매 + 라디오 집계</li>
<li className="mb-2">전 세계 음악 트렌드 반영</li>
</ul>
</div>
<div>
<h3 className="text-white mb-4 text-lg">더 알아보기</h3>
<ul className="text-sm leading-relaxed list-none">
<li className="mb-2">
<a href="#" className="text-gray-300 no-underline hover:text-white transition-colors">Billboard 200</a>
</li>
<li className="mb-2">
<a href="#" className="text-gray-300 no-underline hover:text-white transition-colors">Artist 100</a>
</li>
<li className="mb-2">
<a href="#" className="text-gray-300 no-underline hover:text-white transition-colors">Global 200</a>
</li>
</ul>
</div>
</div>
<div className="text-center pt-6 border-t border-gray-600 text-xs text-gray-500">
© 2025 Billboard Hot 100. All rights reserved.
</div>
</div>
</footer>
</div>
)
}3. 컴포넌트 분리
- 큰 컴포넌트를 작은 컴포넌트로 분리하는 방법 학습
- 컴포넌트 재사용성과 유지보수성 향상
- 코드 가독성 개선
3-1단계: Header 컴포넌트 분리
페이지 상단의 Header 부분을 독립적인 컴포넌트로 분리합니다.
app/components폴더를 생성Header.tsx파일이 정상적으로 생성되었는지 확인- 페이지가 정상적으로 렌더링되는지 확인
3-1(a). components/Header.tsx 생성
// components/Header.tsx
export default function Header() {
return (
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<div>
<div className="text-3xl">🎵</div>
<div className="text-xl md:text-2xl font-bold">Billboard Hot 100</div>
<div className="text-xs md:text-sm opacity-90 mt-1">The Week's Most Popular Songs</div>
</div>
<div className="text-sm md:text-base">2025년 11월 10일</div>
</div>
<div className="text-center mb-8">
<h1 className="text-3xl md:text-5xl font-bold mb-2">This Week's Top Hits</h1>
<p className="text-base md:text-lg opacity-95">전 세계 음악 팬들이 선택한 가장 인기 있는 곡들을 만나보세요</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📈</div>
<div className="text-4xl font-bold mb-1">100</div>
<div className="text-sm opacity-90">차트 순위</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">🎵</div>
<div className="text-4xl font-bold mb-1">52</div>
<div className="text-sm opacity-90">주간 업데이트</div>
</div>
<div className="bg-white/20 backdrop-blur-md rounded-xl p-6 text-center">
<div className="text-3xl mb-2">📅</div>
<div className="text-4xl font-bold mb-1">67</div>
<div className="text-sm opacity-90">역사 (년)</div>
</div>
</div>
</div>
</header>
)
}3-1(b). Header 부분을 컴포넌트로 교체
import Image from 'next/image'
import Header from '@/components/Header'
export default function Home() {
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
{/* ... 나머지 코드 ... */}
</main>
{/* Footer */}
<footer className="bg-[#2c2c2c] text-gray-300 py-12">
{/* ... 나머지 코드 ... */}
</footer>
</div>
)
}3-2단계: Top3Section 컴포넌트 분리
Top 3 차트 섹션을 독립적인 컴포넌트로 분리합니다.
3-2(a). components/Top3Section.tsx 생성
- Top3Section 컴포넌트를 생성
- 데이터 배열을 사용하여 반복 렌더링이 제대로 작동하는지 확인
// components/Top3Section.tsx
interface Top3Item {
rank: number
title: string
artist: string
imageUrl: string
}
export default function Top3Section() {
const top3Items: Top3Item[] = [
{
rank: 1,
title: 'Anti-Hero',
artist: 'Taylor Swift',
imageUrl: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop'
},
{
rank: 2,
title: 'As It Was',
artist: 'Harry Styles',
imageUrl: 'https://images.unsplash.com/photo-1516280440614-37939bbacd81?w=400&h=400&fit=crop'
},
{
rank: 3,
title: 'Flowers',
artist: 'Miley Cyrus',
imageUrl: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop'
}
]
return (
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{top3Items.map((item) => (
<div
key={item.rank}
className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: `url('${item.imageUrl}')` }}
>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#{item.rank}
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">{item.title}</div>
<div className="text-base opacity-90">{item.artist}</div>
</div>
</div>
))}
</div>
</section>
)
}- 반복문 대신에
map을 사용 map을 사용할 때는 반드시key를 설정
3-2(b). Top3Section 부분을 컴포넌트로 교체
import Image from 'next/image'
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
export default function Home() {
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<Top3Section />
{/* Charts 4-20 Section */}
{/* ... 나머지 코드 ... */}
</main>
{/* Footer */}
{/* ... 나머지 코드 ... */}
</div>
)
}3-3단계: ChartsSection 컴포넌트 분리
Charts 4-20 섹션을 독립적인 컴포넌트로 분리하고, 차트 아이템도 별도 컴포넌트로 분리합니다.
3-3(a). components/ChartItem.tsx 생성
// components/ChartItem.tsx
import Image from 'next/image'
interface ChartItemProps {
rank: number
change: string
changeColor: string
imageUrl: string
title: string
artist: string
peak: number
weeks: number
}
export default function ChartItem({
rank,
change,
changeColor,
imageUrl,
title,
artist,
peak,
weeks
}: ChartItemProps) {
return (
<div className="grid grid-cols-[40px_30px_50px_1fr] md:grid-cols-[50px_40px_60px_1fr_80px_80px] gap-2 md:gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors">
<div className="text-xl font-bold text-center">{rank}</div>
<div className={`flex items-center justify-center font-bold ${changeColor}`}>
{change}
</div>
<Image
src={imageUrl}
alt={title}
width={60}
height={60}
className="rounded-lg object-cover"
/>
<div className="flex flex-col">
<div className="font-bold mb-1">{title}</div>
<div className="text-xs md:text-sm text-gray-600">{artist}</div>
</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Peak: {peak}</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Weeks: {weeks}</div>
</div>
)
}3-3(b). components/ChartsSection.tsx 생성
// components/ChartsSection.tsx
import ChartItem from '@/components/ChartItem'
interface ChartData {
rank: number
change: string
changeColor: string
imageUrl: string
title: string
artist: string
peak: number
weeks: number
}
export default function ChartsSection() {
const charts: ChartData[] = [
{
rank: 4,
change: '+1',
changeColor: 'text-green-600',
imageUrl: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop',
title: 'Calm Down',
artist: 'Rema & Selena Gomez',
peak: 3,
weeks: 18
}
// 나중에 더 많은 차트 데이터를 추가할 수 있습니다
]
return (
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Charts 4-20</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">계속해서 사랑받고 있는 노래들</p>
<div className="flex flex-col gap-3">
{charts.map((chart) => (
<ChartItem key={chart.rank} {...chart} />
))}
</div>
</section>
)
}3-3(c). ChartsSection 부분을 컴포넌트로 교체
- ChartItem과 ChartsSection 컴포넌트가 정상적으로 렌더링되는지 확인
- Props를 통해 데이터가 전달되는지 확인
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
import ChartsSection from '@/components/ChartsSection'
export default function Home() {
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<Top3Section />
<ChartsSection />
</main>
{/* Footer */}
{/* ... 나머지 코드 ... */}
</div>
)
}3-5단계: 최종 정리 및 개선
- 각 컴포넌트가 단일 책임을 가지고 있는지 확인
- 재사용 가능한 컴포넌트인지 확인
- Props 타입이 명확하게 정의되어 있는지 확인
4. 데이터 fetch
정적 데이터를 사용하기 때문에, CSV 파일을 읽고 파싱하는 유틸리티 함수를 생성합니다.
parseCSV: CSV 파일을 읽어서 객체 배열로 변환parseCSVLine: CSV 라인을 올바르게 파싱(쉼표와 따옴표 처리)getLatestChartData: CSV에서 가장 최근 날짜의 상위 100개 차트 데이터를 반환
4-1단계: CSV 파싱 유틸리티 함수 생성(lib/csvParser.ts)
CSVRow 인터페이스 정의합니다.
// lib/csvParser.ts
import fs from 'fs'
import path from 'path'
export interface CSVRow {
chart_date: string
current_position: string
title: string
performer: string
previous_position: string
peak_position: string
weeks_on_chart: string
}
// CSV 파싱 결과를 캐싱하여 성능 향상
let cachedCSVData: CSVRow[] | null = null;
const CSV_FILE_PATH = "data/hot100_archive_2018_2021.csv";
// CSV 데이터를 가져오는 함수 (캐싱 사용)
function getCachedCSVData(): CSVRow[] {
if (cachedCSVData === null) {
cachedCSVData = parseCSV(CSV_FILE_PATH);
}
return cachedCSVData;
}CSV 파일을 읽어서 파싱하는 함수를 정의합니다.
// CSV 파일을 읽어서 파싱하는 함수
export function parseCSV(filePath: string): CSVRow[] {
const fullPath = path.join(process.cwd(), filePath)
const fileContent = fs.readFileSync(fullPath, 'utf-8')
const lines = fileContent.split('\n').filter(line => line.trim() !== '')
if (lines.length === 0) {
return []
}
// 헤더 추출
const headers = lines[0].split(',').map(h => h.trim())
// 데이터 행 파싱
const rows: CSVRow[] = []
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i])
if (values.length === headers.length) {
const row: any = {}
headers.forEach((header, index) => {
row[header] = values[index] || ''
})
rows.push(row as CSVRow)
}
}
return rows
}파싱하는 과정에서 한 줄(Row)를 처리하는 함수를 정의합니다.
// CSV 라인을 파싱하는 함수 (쉼표로 구분, 따옴표 처리)
function parseCSVLine(line: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
result.push(current.trim())
current = ''
} else {
current += char
}
}
result.push(current.trim())
return result
}특정 날짜의 차트 데이터를 가져오는 함수를 정의합니다.
최신 차트 데이터를 가져오는 함수를 정의합니다.
// 최신 차트 데이터를 가져오는 함수, 상위 100개 반환
export function getLatestChartData(): CSVRow[] {
const allData = getCachedCSVData()
// 날짜별로 그룹화하여 최신 날짜 찾기
const dates = [...new Set(allData.map(row => row.chart_date))].sort().reverse()
const latestDate = dates[0]
// 최신 날짜의 데이터를 순위순으로 정렬
const latestData = allData
.filter(row => row.chart_date === latestDate)
.sort((a, b) => parseInt(a.current_position) - parseInt(b.current_position))
.slice(0, 100)
return latestData
}4-2단계: 데이터 변환 함수 생성(lib/dataTransformer.ts)
CSV 데이터를 애플리케이션에서 사용하는 형식으로 변환하는 함수를 생성합니다.
transformToTop3: 상위 3개 항목을 Top3Item 형식으로 변환transformToChartData: 나머지 차트 데이터를 ChartData 형식으로 변환하며, 순위 변동 정보도 계산
transformToTop3 함수를 정의합니다. 상위 3개 항목을 선택하여 Top3Item 타입으로 변경합니다.
// lib/dataTransformer.ts
import { CSVRow } from './csvParser'
import { ChartData, Top3Item } from '@/types/chart'
// CSV 데이터를 Top3Item 형식으로 변환
export function transformToTop3(csvRows: CSVRow[]): Top3Item[] {
const top3 = csvRows
.slice(0, 3)
.map((row, index) => ({
rank: parseInt(row.current_position),
title: row.title,
artist: row.performer,
imageUrl: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop&sig=${index}` // 플레이스홀더 이미지
}))
return top3
}transformToChartData 함수를 정의합니다. 차트의 나머지 데이터를 처리합니다.
// CSV 데이터를 ChartData 형식으로 변환
export function transformToChartData(csvRows: CSVRow[]): ChartData[] {
return csvRows.slice(3).map((row) => {
const currentPos = parseInt(row.current_position)
const prevPos = row.previous_position === 'NA' ? null : parseInt(row.previous_position)
let change = 'NEW'
let changeColor = 'text-blue-600'
if (prevPos !== null) {
const diff = prevPos - currentPos
if (diff > 0) {
change = `+${diff}`
changeColor = 'text-green-600'
} else if (diff < 0) {
change = `${diff}`
changeColor = 'text-red-600'
} else {
change = '-'
changeColor = 'text-gray-600'
}
}
return {
rank: currentPos,
change,
changeColor,
imageUrl: `https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=100&h=100&fit=crop&sig=${currentPos}`,
title: row.title,
artist: row.performer,
peak: parseInt(row.peak_position),
weeks: parseInt(row.weeks_on_chart)
}
})
}4-3단계: 메인 페이지 수정(app/page.tsx)
메인 페이지에서 CSV 데이터를 로드하고 컴포넌트에 전달하도록 수정합니다.
- Next.js App Router의 서버 컴포넌트는 기본적으로 빌드 타임에 실행됨
getLatestChartData()로 CSV에서 최신 데이터를 가져옴- 변환 함수를 사용해 컴포넌트에 필요한 형식으로 변환
// app/page.tsx
import ChartsSection from '@/components/ChartsSection'
import Footer from '@/components/Footer'
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
import { getLatestChartData } from '@/lib/csvParser'
import { transformToTop3, transformToChartData } from '@/lib/dataTransformer'
export default function Home() {
// 빌드 타임에 CSV 데이터 로드
const csvData = getLatestChartData()
const top3Data = transformToTop3(csvData)
const chartsData = transformToChartData(csvData)
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
{/* <Top3Section top3Data={top3Data} /> */}
{/* <ChartsSection chartsData={chartsData} /> */}
</main>
<Footer />
</div>
)
}4-4단계: Top3Section 컴포넌트 수정(components/Top3Section.tsx)
하드코딩된 데이터 대신 props로 받은 데이터를 사용하도록 수정합니다.
top3Dataimport 제거Top3SectionProps인터페이스 추가- props로
top3Data를 받도록 수정
// components/Top3Section.tsx
import { Top3Item } from '@/types/chart'
interface Top3SectionProps {
top3Data: Top3Item[]
}
export default function Top3Section({ top3Data }: Top3SectionProps) {
return (
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{top3Data.map((item) => (
<div
key={item.rank}
className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center"
style={{ backgroundImage: `url('${item.imageUrl}')` }}
>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#{item.rank}
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">{item.title}</div>
<div className="text-base opacity-90">{item.artist}</div>
</div>
</div>
))}
</div>
</section>
)
}4-5단계: ChartsSection 컴포넌트 수정(components/ChartsSection.tsx)
하드코딩된 데이터 대신 props로 받은 데이터를 사용하도록 수정합니다.
chartsDataimport 제거ChartsSectionProps인터페이스 추가- props로
chartsData를 받도록 수정 - 제목을 “Charts 4-20”에서 “Charts 4-100”으로 변경 (전체 차트 표시)
// components/ChartsSection.tsx
import { ChartData } from '@/types/chart'
import ChartItem from '@/components/ChartItem'
interface ChartsSectionProps {
chartsData: ChartData[]
}
export default function ChartsSection({ chartsData }: ChartsSectionProps) {
return (
<section className="mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Charts 4-100</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">계속해서 사랑받고 있는 노래들</p>
<div className="flex flex-col gap-3">
{chartsData.map((chart) => (
<ChartItem key={chart.rank} {...chart} />
))}
</div>
</section>
)
}// app/page.tsx
import ChartsSection from '@/components/ChartsSection'
import Footer from '@/components/Footer'
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
import { getLatestChartData } from '@/lib/csvParser'
import { transformToTop3, transformToChartData } from '@/lib/dataTransformer'
export default function Home() {
// 빌드 타임에 CSV 데이터 로드
const csvData = getLatestChartData()
const top3Data = transformToTop3(csvData)
const chartsData = transformToChartData(csvData)
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<Top3Section top3Data={top3Data} />
<ChartsSection chartsData={chartsData} />
</main>
<Footer />
</div>
)
}4-6단계: 빌드 및 테스트
프로젝트를 빌드하여 정적 페이지가 올바르게 생성되는지 확인합니다.
- CSV 파일이
data/폴더에 있어야 함 - Next.js는 빌드 타임에 서버 컴포넌트를 실행하므로, CSV 파일이 빌드 시점에 존재해야 함
- 큰 CSV 파일(17MB)의 경우 빌드 시간이 다소 걸릴 수 있음
- 빌드가 성공적으로 완료되는지 확인
- 개발 서버에서 CSV 데이터가 올바르게 표시되는지 확인
- Top 3 섹션에 실제 CSV 데이터의 상위 3개 항목이 표시되는지 확인
- Charts 섹션에 4위부터 100위까지의 데이터가 표시되는지 확인
5. 동적 라우팅
CSV 데이터의 개별 노래를 파싱하여 세부 정보를 보여주는 동적 페이지를 구현합니다.
- Next.js App Router의 동적 라우팅을 활용하여 개별 노래 상세 페이지 생성
- CSV 데이터에서 특정 노래 정보를 조회하는 함수 구현
- 메인 페이지에서 상세 페이지로 이동할 수 있는 링크 추가
5-1단계: 타입 정의 및 개별 노래 조회 함수 추가
5-1(a). 타입 정의 파일 생성
먼저 타입 정의 파일을 생성합니다.
// types/chart.ts
export interface Top3Item {
rank: number
title: string
artist: string
imageUrl: string
}
export interface ChartData {
rank: number
change: string
changeColor: string
imageUrl: string
title: string
artist: string
peak: number
weeks: number
}
export interface SongDetail {
rank: number
title: string
artist: string
chartDate: string
currentPosition: number
previousPosition: number | null
peakPosition: number
weeksOnChart: number
imageUrl: string
}5-1(b). CSV 파서에 개별 노래 조회 함수 추가
기존 함수들 아래에 다음 함수를 추가합니다.
// lib/csvParser.ts
// 특정 노래의 상세 정보를 가져오는 함수,
export function getSongDetail(
title: string,
artist: string,
date?: string
): CSVRow | null {
const allData = getCachedCSVData()
// 날짜가 지정된 경우 해당 날짜에서만 검색
if (date) {
const song = allData.find(
row =>
row.chart_date === date &&
row.title.toLowerCase().trim() === title.toLowerCase().trim() &&
row.performer.toLowerCase().trim() === artist.toLowerCase().trim()
)
return song || null
}
// 날짜가 지정되지 않으면 모든 날짜를 검색하여 가장 최신 날짜의 것을 반환
const dates = [...new Set(allData.map(row => row.chart_date))].sort().reverse()
for (const targetDate of dates) {
const song = allData.find(
row =>
row.chart_date === targetDate &&
row.title.toLowerCase().trim() === title.toLowerCase().trim() &&
row.performer.toLowerCase().trim() === artist.toLowerCase().trim()
)
if (song) {
return song
}
}
return null
}
// 노래 제목과 아티스트로 URL 친화적인 slug 생성
export function createSongSlug(title: string, artist: string): string {
const titleSlug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
const artistSlug = artist
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `${titleSlug}-${artistSlug}`
}
// slug에서 노래 제목과 아티스트 추출
export function parseSongSlug(slug: string): { title: string; artist: string } | null {
const allData = getCachedCSVData()
// 날짜별로 정렬 (최신 날짜 우선)
const dates = [...new Set(allData.map(row => row.chart_date))].sort().reverse()
// 각 날짜를 순회하며 slug와 일치하는 노래 찾기
for (const date of dates) {
const dateData = allData.filter(row => row.chart_date === date)
for (const row of dateData) {
const rowSlug = createSongSlug(row.title, row.performer)
if (rowSlug === slug) {
return {
title: row.title,
artist: row.performer
}
}
}
}
return null
}
// 고유한 slug 목록을 캐싱
let cachedUniqueSlugs: string[] | null = null;
// CSV에 있는 모든 고유한 노래의 slug 목록을 가져오는 함수
export function getAllUniqueSongSlugs(): string[] {
if (cachedUniqueSlugs !== null) {
return cachedUniqueSlugs;
}
const allData = getCachedCSVData();
// title + performer 조합으로 고유한 노래를 추적
const uniqueSongs = new Set<string>();
// 모든 데이터를 순회하며 고유한 slug 생성
for (const row of allData) {
const slug = createSongSlug(row.title, row.performer);
uniqueSongs.add(slug);
}
cachedUniqueSlugs = Array.from(uniqueSongs);
return cachedUniqueSlugs;
}5-2단계: 동적 라우트 폴더 구조 설계
Next.js App Router에서 동적 라우트를 생성합니다. 이 폴더 구조는 /song/anti-hero-taylor-swift, /song/as-it-was-harry-styles 와 같은 URL 패턴을 생성합니다.
- slug: 웹 개발에서 URL의 끝부분에 위치한, 페이지나 콘텐츠를 인간이 읽기 쉽고 검색엔진 최적화(SEO)에 유리한 형태로 변환된 고유 식별 문자열을 의미
5-3단계: 동적 페이지 컴포넌트 작성
5-3(a). 상세 페이지 컴포넌트 생성
// app/song/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { Metadata } from 'next'
import { getSongDetail, parseSongSlug, getAllUniqueSongSlugs } from '@/lib/csvParser'
import { transformToSongDetail } from '@/lib/dataTransformer'
import Image from 'next/image'
import Link from 'next/link'
interface PageProps {
params: Promise<{
slug: string
}>
}
// 정적 생성에 필요한 모든 slug를 생성하는 함수
export async function generateStaticParams() {
try {
const allSlugs = getAllUniqueSongSlugs()
console.log(`Generating static params for ${allSlugs.length} songs...`)
// 모든 slug를 반환하여 모든 노래에 대해 정적 페이지 생성
return allSlugs.map((slug) => ({
slug: slug,
}))
} catch (error) {
console.error('Error generating static params:', error)
// 에러 발생 시 빈 배열 반환
return []
}
}
// 동적 라우팅 비활성화 - 모든 페이지를 정적으로 생성
export const dynamicParams = false
export default async function SongDetailPage({ params }: PageProps) {
// params를 await로 언래핑 (Next.js 15 이상)
const { slug } = await params
// slug에서 노래 정보 추출
const songInfo = parseSongSlug(slug)
if (!songInfo) {
console.error(`Song not found for slug: ${slug}`)
notFound()
}
// 노래 상세 정보 가져오기
const csvRow = getSongDetail(songInfo.title, songInfo.artist)
if (!csvRow) {
console.error(`Song detail not found for: ${songInfo.title} - ${songInfo.artist}`)
notFound()
}
// 데이터 변환
const songDetail = transformToSongDetail(csvRow)
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
{/* Header */}
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<Link
href="/"
className="inline-block mb-4 text-white/90 hover:text-white transition-colors"
>
← 홈으로 돌아가기
</Link>
<div className="text-3xl mb-2">🎵</div>
<div className="text-xl md:text-2xl font-bold">Billboard Hot 100</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 md:px-8 py-12">
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
{/* 노래 이미지 */}
<div className="relative h-64 md:h-96 bg-gradient-to-br from-[#e91e63] to-[#f06292]">
<Image
src={songDetail.imageUrl}
alt={songDetail.title}
fill
className="object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/50" />
<div className="absolute bottom-0 left-0 right-0 p-8 text-white">
<div className="text-4xl md:text-5xl font-bold mb-2">
{songDetail.title}
</div>
<div className="text-xl md:text-2xl opacity-90">
{songDetail.artist}
</div>
</div>
</div>
{/* 노래 상세 정보 */}
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* 현재 순위 */}
<div className="bg-gray-50 rounded-xl p-6">
<div className="text-sm text-gray-600 mb-2">현재 순위</div>
<div className="text-4xl font-bold text-[#e91e63]">
#{songDetail.currentPosition}
</div>
</div>
{/* 최고 순위 */}
<div className="bg-gray-50 rounded-xl p-6">
<div className="text-sm text-gray-600 mb-2">최고 순위</div>
<div className="text-4xl font-bold text-[#e91e63]">
#{songDetail.peakPosition}
</div>
</div>
{/* 이전 순위 */}
<div className="bg-gray-50 rounded-xl p-6">
<div className="text-sm text-gray-600 mb-2">이전 순위</div>
<div className="text-4xl font-bold text-gray-700">
{songDetail.previousPosition !== null
? `#${songDetail.previousPosition}`
: 'NEW'}
</div>
</div>
{/* 차트 주수 */}
<div className="bg-gray-50 rounded-xl p-6">
<div className="text-sm text-gray-600 mb-2">차트 주수</div>
<div className="text-4xl font-bold text-gray-700">
{songDetail.weeksOnChart}주
</div>
</div>
</div>
{/* 차트 날짜 */}
<div className="border-t pt-6">
<div className="text-sm text-gray-600 mb-2">차트 날짜</div>
<div className="text-lg font-semibold">
{new Date(songDetail.chartDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</div>
</div>
</div>
</main>
</div>
)
}5-3(b). 데이터 변환 함수 추가
기존 함수들 아래에 다음 함수를 추가합니다.
// lib/dataTransformer.ts
import { SongDetail } from '@/types/chart'
// CSV 데이터를 SongDetail 형식으로 변환
export function transformToSongDetail(csvRow: CSVRow): SongDetail {
return {
rank: parseInt(csvRow.current_position),
title: csvRow.title,
artist: csvRow.performer,
chartDate: csvRow.chart_date,
currentPosition: parseInt(csvRow.current_position),
previousPosition: csvRow.previous_position === 'NA'
? null
: parseInt(csvRow.previous_position),
peakPosition: parseInt(csvRow.peak_position),
weeksOnChart: parseInt(csvRow.weeks_on_chart),
imageUrl: `https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&h=800&fit=crop&sig=${csvRow.title.length}`
}
}5-4단계: 메인 페이지에서 개별 노래 페이지로 링크 추가
5-4(a). Top3Section 컴포넌트에 링크 추가
// components/Top3Section.tsx
import { Top3Item } from '@/types/chart'
import Link from 'next/link'
import { createSongSlug } from '@/lib/csvParser'
interface Top3SectionProps {
top3Data: Top3Item[]
}
export default function Top3Section({ top3Data }: Top3SectionProps) {
return (
<section className="mb-16 bg-white rounded-2xl p-8">
<h2 className="text-2xl md:text-3xl font-bold mb-2">Top 3 of the Week</h2>
<p className="text-sm md:text-base text-gray-600 mb-8">이번 주 가장 핫한 음악 TOP 3</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{top3Data.map((item) => {
const slug = createSongSlug(item.title, item.artist)
return (
<Link
key={item.rank}
href={`/song/${slug}`}
className="relative rounded-2xl overflow-hidden shadow-lg aspect-square bg-cover bg-center hover:scale-105 transition-transform duration-300"
style={{ backgroundImage: `url('${item.imageUrl}')` }}
>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/70" />
<div className="absolute top-4 left-4 bg-[#e91e63] text-white w-12 h-12 rounded-full flex items-center justify-center font-bold text-xl z-10">
#{item.rank}
</div>
<div className="absolute bottom-0 left-0 right-0 p-6 text-white z-10">
<div className="text-xl font-bold mb-1">{item.title}</div>
<div className="text-base opacity-90">{item.artist}</div>
</div>
</Link>
)
})}
</div>
</section>
)
}5-4(b). ChartsSection 컴포넌트에 링크 추가
// components/ChartItem.tsx
import Image from 'next/image'
import Link from 'next/link'
import { createSongSlug } from '@/lib/csvParser'
interface ChartItemProps {
rank: number
change: string
changeColor: string
imageUrl: string
title: string
artist: string
peak: number
weeks: number
}
export default function ChartItem({
rank,
change,
changeColor,
imageUrl,
title,
artist,
peak,
weeks
}: ChartItemProps) {
const slug = createSongSlug(title, artist)
return (
<Link
href={`/song/${slug}`}
className="grid grid-cols-[40px_30px_50px_1fr] md:grid-cols-[50px_40px_60px_1fr_80px_80px] gap-2 md:gap-4 items-center p-4 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
>
<div className="text-xl font-bold text-center">{rank}</div>
<div className={`flex items-center justify-center font-bold ${changeColor}`}>
{change}
</div>
<Image
src={imageUrl}
alt={title}
width={60}
height={60}
className="rounded-lg object-cover"
/>
<div className="flex flex-col">
<div className="font-bold mb-1">{title}</div>
<div className="text-xs md:text-sm text-gray-600">{artist}</div>
</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Peak: {peak}</div>
<div className="hidden md:block text-center text-gray-600 text-sm">Weeks: {weeks}</div>
</Link>
)
}5-5단계: 스타일링 및 최종 점검
5-5(a). 404 페이지 처리
Next.js는 자동으로 notFound() 함수를 호출하면 404 페이지를 표시합니다. 필요한 경우 커스텀 404 페이지를 생성할 수 있습니다.
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-700 mb-4">노래를 찾을 수 없습니다</h2>
<p className="text-gray-600 mb-8">요청하신 노래 정보가 존재하지 않습니다.</p>
<Link
href="/"
className="inline-block bg-[#e91e63] text-white px-6 py-3 rounded-lg hover:bg-[#c2185b] transition-colors"
>
홈으로 돌아가기
</Link>
</div>
</div>
)
}5-5(b). 메타데이터 추가 (선택사항)
페이지 상단에 메타데이터를 추가할 수 있습니다.
// app/song/[slug]/page.tsx
import { Metadata } from 'next'
// ... 기존 코드 ...
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
// params를 await로 언래핑 (Next.js 15 이상)
const { slug } = await params
const songInfo = parseSongSlug(slug)
if (!songInfo) {
return {
title: '노래를 찾을 수 없습니다',
}
}
return {
title: `${songInfo.title} - ${songInfo.artist} | Billboard Hot 100`,
description: `${songInfo.artist}의 "${songInfo.title}" 상세 정보를 확인하세요.`,
}
}5-6단계: 정적 페이지 생성 및 완료!
5-6(a). 정적 페이지 생성
generateStaticParams 함수를 사용하여 CSV에 있는 모든 고유한 노래에 대해 정적 페이지를 미리 생성합니다. - getAllUniqueSongSlugs() 함수를 사용하여 모든 고유한 노래의 slug를 가져옴 - dynamicParams = false로 설정하여 모든 페이지를 정적으로 생성함 - 빌드 시 모든 노래 페이지가 생성되므로 빌드 시간이 오래 걸릴 수 있음
5-6(b). 빌드 및 테스트
프로젝트를 빌드하여 정적 페이지가 올바르게 생성되는지 확인합니다.
빌드가 완료되면 다음을 확인합니다.
- 메인 페이지(
/)에서 Top 3 항목 중 하나를 클릭 - 상세 페이지로 이동하는지 확인
- 상세 페이지에서 노래 정보가 올바르게 표시되는지 확인
- 차트 목록의 항목을 클릭하여 상세 페이지로 이동하는지 확인
- 잘못된 slug로 접근하여 404 페이지가 표시되는지 확인
- 오래된 노래(예:
/song/single-ladies-put-a-ring-on-it-beyonce)도 정상적으로 접근되는지 확인
주의사항 (Next.js 15 이상)
Next.js 15부터 params와 searchParams가 Promise로 변경되었습니다. 동적 라우트를 사용할 때는 반드시 await를 사용하여 언래핑해야 합니다.
6. 년도별 차트 조회
CSV 데이터에서 특정 년도의 차트를 조회하여 표시하는 기능을 구현합니다.
- CSV 데이터에서 년도별로 차트 데이터를 필터링하는 함수 구현
- 년도 선택 페이지와 년도별 차트 페이지 생성
- 헤더에 년도 선택 링크 추가
6-1단계: CSV 파서에 년도별 조회 함수 추가
6-1(a). 년도별 차트 데이터 조회 함수 추가
기존 lib/csvParser.ts 파일에 다음 함수들을 추가합니다.
// 특정 년도의 차트 데이터를 가져오는 함수
export function getChartDataByYear(year: number | string): CSVRow[] {
const allData = getCachedCSVData();
const yearStr = year.toString();
// 해당 년도의 모든 날짜 필터링
const yearDates = [...new Set(allData.map((row) => row.chart_date))]
.filter((date) => date.startsWith(yearStr))
.sort()
.reverse();
if (yearDates.length === 0) {
return [];
}
// 해당 년도의 마지막 주차 날짜 사용
const latestDateInYear = yearDates[0];
// 해당 날짜의 데이터를 순위순으로 정렬
const yearData = allData
.filter((row) => row.chart_date === latestDateInYear)
.sort((a, b) => parseInt(a.current_position) - parseInt(b.current_position))
.slice(0, 100);
return yearData;
}6-1(b). 사용 가능한 년도 목록 조회 함수 추가
6-2단계: 년도 선택 페이지 생성
6-2(a). 년도 선택 페이지 컴포넌트 생성
app/year/page.tsx 파일을 생성합니다.
// app/year/page.tsx
import { getAvailableYears } from '@/lib/csvParser'
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import Link from 'next/link'
export default function YearListPage() {
const years = getAvailableYears()
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold mb-2">
년도별 Billboard Hot 100
</h1>
<p className="text-gray-600 mb-4">
원하는 년도를 선택하여 해당 년도의 차트를 확인하세요
</p>
<Link
href="/"
className="inline-block px-4 py-2 bg-[#e91e63] text-white rounded-lg hover:bg-[#c2185b] transition-colors mb-6"
>
← 최신 차트로 돌아가기
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{years.map((year) => (
<Link
key={year}
href={`/year/${year}`}
className="block p-6 bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white rounded-xl hover:scale-105 transition-transform text-center font-bold text-xl shadow-lg"
>
{year}년
</Link>
))}
</div>
</main>
<Footer />
</div>
)
}6-3단계: 년도별 차트 페이지 생성
6-3(a). 동적 라우트 폴더 구조 생성
Next.js App Router에서 년도별 동적 라우트를 생성합니다. 이 폴더 구조는 /year/2020, /year/2019 와 같은 URL 패턴을 생성합니다.
app/year/[year]/
6-3(b). 년도별 차트 페이지 컴포넌트 생성
app/year/[year]/page.tsx 파일을 생성합니다.
// app/year/[year]/page.tsx
import ChartsSection from '@/components/ChartsSection'
import Footer from '@/components/Footer'
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
import { getChartDataByYear, getAvailableYears } from '@/lib/csvParser'
import { transformToTop3, transformToChartData } from '@/lib/dataTransformer'
import { notFound } from 'next/navigation'
import Link from 'next/link'
interface YearPageProps {
params: Promise<{
year: string
}>
}
export default async function YearPage({ params }: YearPageProps) {
const { year: yearParam } = await params
const year = parseInt(yearParam)
// 유효하지 않은 년도인 경우 404
if (isNaN(year)) {
notFound()
}
// 빌드 타임에 CSV 데이터 로드
const csvData = getChartDataByYear(year)
// 데이터가 없는 경우 404
if (csvData.length === 0) {
notFound()
}
const top3Data = transformToTop3(csvData)
const chartsData = transformToChartData(csvData)
// 차트 날짜 추출 (표시용)
const chartDate = csvData[0]?.chart_date || ''
const formattedDate = chartDate
? new Date(chartDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: ''
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12 bg-white">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl md:text-4xl font-bold mb-2">
{year}년 Billboard Hot 100
</h1>
{formattedDate && (
<p className="text-gray-600">
{formattedDate} 기준
</p>
)}
</div>
<div className="flex gap-2">
<Link
href="/year"
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
년도 선택
</Link>
<Link
href="/"
className="px-4 py-2 bg-[#e91e63] text-white rounded-lg hover:bg-[#c2185b] transition-colors"
>
최신 차트 보기
</Link>
</div>
</div>
</div>
<Top3Section top3Data={top3Data} />
<ChartsSection chartsData={chartsData} />
</main>
<Footer />
</div>
)
}
// 정적 경로 생성 (사용 가능한 모든 년도)
export async function generateStaticParams() {
const years = getAvailableYears()
return years.map((year) => ({
year: year.toString(),
}))
}6-4단계: 헤더에 년도 선택 링크 추가
6-4(a). Header 컴포넌트 수정
components/Header.tsx 파일을 수정하여 년도 선택 링크를 추가합니다.
import Link from 'next/link'
export default function Header() {
return (
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<div>
<div className="text-3xl">🎵</div>
<div className="text-xl md:text-2xl font-bold">Billboard Hot 100</div>
<div className="text-xs md:text-sm opacity-90 mt-1">The Week's Most Popular Songs</div>
</div>
<div className="flex items-center gap-4">
<Link
href="/year"
className="px-4 py-2 bg-white/20 backdrop-blur-md rounded-lg hover:bg-white/30 transition-colors text-white font-medium"
>
년도별 차트
</Link>
<div className="text-sm md:text-base">2025년 11월 10일</div>
</div>
</div>
{/* ... 나머지 헤더 내용 ... */}
</div>
</header>
)
}6-5단계: 빌드 및 테스트
프로젝트를 빌드하여 년도별 페이지가 올바르게 생성되는지 확인합니다.
- 빌드(
npm run build)가 성공적으로 완료되는지 확인 - 특정 년도(예:
/year/2020)를 클릭하여 해당 년도의 차트가 표시되는지 확인- 헤더의 “년도별 차트” 버튼을 클릭하여 년도 선택 페이지로 이동
- 원하는 년도를 선택하여 해당 년도의 마지막 주차 Billboard Hot 100 차트 확인
- 각 년도 페이지에서 Top 3와 4-100위 차트를 모두 확인
- 헤더의 “년도별 차트” 링크가 정상적으로 작동하는지 확인
- 년도별 페이지에서 개별 노래를 클릭하여 상세 정보 확인
- 유효하지 않은 년도로 접근하여 404 페이지가 표시되는지 확인
참고사항
getChartDataByYear함수는 해당 년도의 마지막 주차 차트 데이터를 반환합니다.generateStaticParams를 사용하여 모든 년도에 대한 정적 페이지를 미리 생성합니다.- Next.js 15 이상에서는
params가 Promise이므로 반드시await를 사용해야 합니다. - CSV 파싱 결과는 캐싱되어 성능이 향상됩니다.
parseSongSlug와getSongDetail함수는 모든 날짜를 검색하여 가장 최신 날짜의 노래를 반환합니다.- 모든 노래에 대해 정적 페이지를 생성하므로 빌드 시간이 오래 걸릴 수 있습니다.
7. 년도별 시각화
CSV 데이터를 활용하여 년도별 차트 트렌드를 시각화하는 기능을 구현합니다.
- Recharts 라이브러리를 사용하여 년도별 통계를 차트로 표시
- 여러 년도 간 비교 시각화 컴포넌트 생성
- 년도별 트렌드 분석 및 통계 비교
7-1단계: Recharts 라이브러리 설치
차트 시각화를 위해 Recharts 라이브러리를 설치합니다.
7-2단계: 년도별 통계 데이터 조회 함수 추가
기존 lib/csvParser.ts 파일에 다음 함수를 추가합니다.
/**
* 여러 년도의 통계 데이터를 가져오는 함수
* @param years 년도 배열 (예: [2018, 2019, 2020, 2021])
* @returns 년도별 통계 데이터 배열
*/
export interface YearStats {
year: number
totalSongs: number
newSongs: number
avgWeeks: number
topArtist: string
topArtistCount: number
}
export function getYearStats(years: number[]): YearStats[] {
const allData = getCachedCSVData()
const stats: YearStats[] = []
for (const year of years) {
const yearData = getChartDataByYear(year)
if (yearData.length === 0) continue
const newSongs = yearData.filter(row => row.previous_position === 'NA').length
const avgWeeks = Math.round(
yearData.reduce((sum, row) => sum + parseInt(row.weeks_on_chart || '0'), 0) /
yearData.length
)
// 가장 많은 곡을 가진 아티스트 찾기
const artistCounts = new Map<string, number>()
yearData.forEach((row) => {
const count = artistCounts.get(row.performer) || 0
artistCounts.set(row.performer, count + 1)
})
const topArtistEntry = Array.from(artistCounts.entries())
.sort((a, b) => b[1] - a[1])[0] || ['N/A', 0]
stats.push({
year,
totalSongs: yearData.length,
newSongs,
avgWeeks,
topArtist: topArtistEntry[0],
topArtistCount: topArtistEntry[1]
})
}
return stats
}7-3단계: YearComparisonChart 컴포넌트 생성
components/YearComparisonChart.tsx 파일을 생성합니다.
'use client'
import { YearStats } from '@/lib/csvParser'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
LineChart,
Line
} from 'recharts'
interface YearComparisonChartProps {
yearStats: YearStats[]
}
export default function YearComparisonChart({ yearStats }: YearComparisonChartProps) {
return (
<div className="space-y-8">
{/* 신곡 vs 기존곡 비교 차트 */}
<div className="bg-white rounded-xl p-6 shadow-lg">
<h3 className="text-xl font-bold mb-4 text-gray-800">년도별 신곡 vs 기존곡</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={yearStats}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="newSongs" fill="#e91e63" name="신곡" />
<Bar dataKey={(data) => data.totalSongs - data.newSongs} fill="#9e9e9e" name="기존곡" />
</BarChart>
</ResponsiveContainer>
</div>
{/* 평균 차트 주수 비교 */}
<div className="bg-white rounded-xl p-6 shadow-lg">
<h3 className="text-xl font-bold mb-4 text-gray-800">년도별 평균 차트 주수</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={yearStats}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="avgWeeks"
stroke="#e91e63"
strokeWidth={3}
name="평균 주수"
dot={{ fill: '#e91e63', r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* 상위 아티스트 통계 */}
<div className="bg-white rounded-xl p-6 shadow-lg">
<h3 className="text-xl font-bold mb-4 text-gray-800">년도별 최다 진입 아티스트</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{yearStats.map((stat) => (
<div key={stat.year} className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4">
<div className="text-sm text-gray-600 mb-1">{stat.year}년</div>
<div className="text-lg font-bold text-purple-700">{stat.topArtist}</div>
<div className="text-sm text-gray-600 mt-1">{stat.topArtistCount}곡 진입</div>
</div>
))}
</div>
</div>
</div>
)
}7-4단계: 년도 비교 페이지 컴포넌트 생성
app/year/compare/page.tsx 파일을 생성합니다.
import { getAvailableYears, getYearStats } from '@/lib/csvParser'
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import YearComparisonChart from '@/components/YearComparisonChart'
import Link from 'next/link'
export default function YearComparePage() {
const availableYears = getAvailableYears()
// 최근 4개 년도 선택 (또는 사용 가능한 모든 년도)
const selectedYears = availableYears.slice(0, 4)
const yearStats = getYearStats(selectedYears)
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed bg-gray-50">
<Header />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl md:text-4xl font-bold mb-2">
년도별 차트 비교
</h1>
<p className="text-gray-600">
여러 년도의 Billboard Hot 100 차트를 비교하여 트렌드를 분석합니다
</p>
</div>
<div className="flex gap-2">
<Link
href="/year"
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
>
년도 선택
</Link>
<Link
href="/"
className="px-4 py-2 bg-[#e91e63] text-white rounded-lg hover:bg-[#c2185b] transition-colors"
>
최신 차트
</Link>
</div>
</div>
{/* 선택된 년도 표시 */}
<div className="flex flex-wrap gap-2 mt-4">
{selectedYears.map((year) => (
<span
key={year}
className="px-3 py-1 bg-[#e91e63] text-white rounded-full text-sm font-medium"
>
{year}년
</span>
))}
</div>
</div>
<YearComparisonChart yearStats={yearStats} />
</main>
<Footer />
</div>
)
}7-5단계: 년도별 페이지에 트렌드 차트 추가
app/year/[year]/page.tsx 파일을 수정하여 해당 년도와 이전 년도를 비교하는 차트를 추가합니다.
// app/year/[year]/page.tsx
import ChartsSection from '@/components/ChartsSection'
import Footer from '@/components/Footer'
import Header from '@/components/Header'
import Top3Section from '@/components/Top3Section'
import VisualStats from '@/components/VisualStats'
import YearComparisonChart from '@/components/YearComparisonChart'
import { getChartDataByYear, getAvailableYears, getYearStats } from '@/lib/csvParser'
import { transformToTop3, transformToChartData } from '@/lib/dataTransformer'
import { notFound } from 'next/navigation'
import Link from 'next/link'
interface YearPageProps {
params: Promise<{
year: string
}>
}
export default async function YearPage({ params }: YearPageProps) {
const { year: yearParam } = await params
const year = parseInt(yearParam)
// 유효하지 않은 년도인 경우 404
if (isNaN(year)) {
notFound()
}
// 빌드 타임에 CSV 데이터 로드
const csvData = getChartDataByYear(year)
// 데이터가 없는 경우 404
if (csvData.length === 0) {
notFound()
}
const top3Data = transformToTop3(csvData)
const chartsData = transformToChartData(csvData)
// 차트 날짜 추출 (표시용)
const chartDate = csvData[0]?.chart_date || ''
const formattedDate = chartDate
? new Date(chartDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: ''
// 비교할 년도 선택 (현재 년도와 이전 년도들)
const availableYears = getAvailableYears()
const currentYearIndex = availableYears.indexOf(year)
const compareYears = availableYears
.slice(Math.max(0, currentYearIndex - 2), currentYearIndex + 1)
.filter(y => y <= year)
.slice(-4) // 최대 4개 년도만 비교
const yearStats = getYearStats(compareYears)
return (
<div className="min-h-screen font-sans text-gray-900 leading-relaxed bg-gray-50">
<Header currentYear={year} chartDate={chartDate} />
<main className="max-w-6xl mx-auto px-4 md:px-8 py-12">
{formattedDate && (
<div className="mb-8">
<p className="text-gray-600">
{formattedDate} 기준
</p>
</div>
)}
<VisualStats chartData={csvData} year={year} />
{/* 년도 비교 차트 (2개 이상의 년도가 있을 때만 표시) */}
{compareYears.length >= 2 && (
<div className="mb-12">
<div className="mb-6">
<h2 className="text-2xl md:text-3xl font-bold mb-2">년도별 트렌드 비교</h2>
<p className="text-gray-600">최근 년도들의 차트 트렌드를 비교합니다</p>
</div>
<YearComparisonChart yearStats={yearStats} />
</div>
)}
<Top3Section top3Data={top3Data} />
<ChartsSection chartsData={chartsData} />
</main>
<Footer />
</div>
)
}
// 정적 경로 생성 (사용 가능한 모든 년도)
export async function generateStaticParams() {
const years = getAvailableYears()
return years.map((year) => ({
year: year.toString(),
}))
}7-6단계: Header 컴포넌트 수정
components/Header.tsx 파일을 수정하여 년도 비교 링크를 추가합니다.
import Link from 'next/link'
interface HeaderProps {
currentYear?: number
chartDate?: string
}
export default function Header({ currentYear, chartDate }: HeaderProps) {
return (
<header className="bg-gradient-to-br from-[#e91e63] to-[#f06292] text-white py-8">
<div className="max-w-6xl mx-auto px-4 md:px-8">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<div>
<div className="text-3xl">🎵</div>
<div className="text-xl md:text-2xl font-bold">Billboard Hot 100</div>
<div className="text-xs md:text-sm opacity-90 mt-1">The Week's Most Popular Songs</div>
</div>
<div className="flex items-center gap-4">
<Link
href="/year/compare"
className="px-4 py-2 bg-white/20 backdrop-blur-md rounded-lg hover:bg-white/30 transition-colors text-white font-medium text-sm"
>
년도 비교
</Link>
<Link
href="/year"
className="px-4 py-2 bg-white/20 backdrop-blur-md rounded-lg hover:bg-white/30 transition-colors text-white font-medium text-sm"
>
년도별 차트
</Link>
<div className="text-sm md:text-base">
{chartDate
? new Date(chartDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: '2025년 11월 10일'}
</div>
</div>
</div>
{/* ... 나머지 헤더 내용 ... */}
</div>
</header>
)
}7-7단계: 빌드 및 테스트
프로젝트를 빌드하여 년도별 시각화가 올바르게 작동하는지 확인합니다.
- Recharts 라이브러리가 정상적으로 설치되었는지 확인
- 년도 비교 페이지(
/year/compare)가 정상적으로 표시되는지 확인 - 각 년도별 페이지에서 트렌드 비교 차트가 표시되는지 확인
- 차트의 데이터가 올바르게 표시되는지 확인
- 신곡 vs 기존곡 비교 차트
- 평균 차트 주수 라인 차트
- 상위 아티스트 통계
- 반응형 디자인이 모바일에서도 정상적으로 작동하는지 확인
참고사항
- Recharts는 클라이언트 컴포넌트이므로
'use client'지시어를 사용해야 합니다. getYearStats함수는 여러 년도의 데이터를 한 번에 처리하므로 성능을 고려하여 캐싱을 활용합니다.- 년도 비교 페이지에서는 최근 4개 년도를 기본으로 표시하지만, 필요에 따라 더 많은 년도를 선택할 수 있도록 확장 가능합니다.
- 차트는 ResponsiveContainer를 사용하여 반응형으로 작동합니다.
- Next.js의 서버 컴포넌트에서 클라이언트 컴포넌트(차트)를 사용할 때는 적절히 분리하여 import해야 합니다.