nextjs/웹사이트 만들기
산정보 보기 웹사이트 만들기(1) - sql을 사용해서 데이터 가져오기
lamarcK
2025. 4. 14. 04:53
활용 자료
https://www.bigdata-forest.kr/frn/index
산림빅테이터 거래소의 산림관광정보 csv입니다. 한국산림복지진흥원에서 무료로 제공하는 데이터입니다.
데이터 상품 정보 - 산림 관광지와 관련된 산정보소재지, 산정보상세내용, 산이미지 등
■ 기간 및 범위 ∙ 2019년 1월 ~ 2019년 12월 (월단위)
■ 컬럼(속성) 정보
∙ 산정보 목록 : 산정보 카테고리(산정보소재지. 산정보상세내용 등)
∙ 산 이미지 정보 : 산 이미지 카테고리(이미지명, 이미지파일명 등)
∙ 전통마을숲 : 전통마을숲 카테고리(전통마을숲명, 전통마을숲소재지명 등)
∙ 명산정보 목록 : 명산정보 카테고리(100대명산 선정이유 내용, 소재지 등)
■ 활용 예제 ∙ 본 데이터 상품을 활용하여 사용자는 다음과 같은 정보를 확인할 수 있습니다.
1) 국내의 100대 명산 소재지, 산정보관리주체명, 산정보관리자 전화번호 등의 산림관광에 대한 정보 파악 가능
초기코드(프로토타입)
**1. Next.js 프로젝트 생성
npx create-next-app@latest my-mountain-app
**옵션 선택
Would you like to use TypeScript? › Yes
Would you like to use ESLint? › Yes
Would you like to use Tailwind CSS? › Yes
Would you like to use `src/` directory? › Yes
Would you like to use App Router? (recommended) › Yes
Would you like to customize the default import alias? › No
**2. 필요한 추가 패키지 설치
npm install sqlite sqlite3
**3. 프로젝트 구조:
src/
├── app/
│ ├── page.tsx
│ ├── layout.tsx
│ ├── states/
│ │ └── [state]/
│ │ └── page.tsx
│ └── mountains/
│ └── [id]/
│ └── page.tsx
├── components/
│ └── MountainCard.tsx
├── lib/
│ └── db.ts
└── types/
└── index.ts
**4. src/types/index.ts
export interface Mountain {
ID: number;
NAME: string;
PROVINCE: string;
HEIGHT: number;
DESCRIPTION: string;
IMAGE: string;
}
export interface Province {
PROVINCE: string;
}
**5. src/lib/db.ts
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import { Mountain, Province } from '@/types';
let db: any = null;
async function openDb() {
if (!db) {
db = await open({
filename: './mountain01.db',
driver: sqlite3.Database
});
}
return db;
}
export async function getStates(): Promise<Province[]> {
const db = await openDb();
try {
return await db.all('SELECT DISTINCT PROVINCE FROM T');
} catch (error) {
console.error('Error fetching provinces:', error);
return [];
}
}
export async function getMountainsByState(province: string): Promise<Mountain[]> {
const db = await openDb();
try {
return await db.all('SELECT * FROM T WHERE PROVINCE = ?', [province]);
} catch (error) {
console.error('Error fetching T:', error);
return [];
}
}
export async function getMountainById(id: string): Promise<Mountain | null> {
const db = await openDb();
try {
return await db.get('SELECT * FROM T WHERE id = ?', [id]);
} catch (error) {
console.error('Error fetching T:', error);
return null;
}
}
- csv를 sql데이터로 변경해서 사용
- 필터 활용을 위해서 주소 부분을 province 부분만 따로 분리해서 별도의 테이블을 가지도록 수정
- 원래는 MNTN_INFO_POFLC 부분만 존재
**6. src/app/layout.tsx
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '한국의 산',
description: '대한민국의 아름다운 산들을 소개합니다.',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<nav className="bg-gray-800 text-white p-4">
<div className="container mx-auto">
<a href="/" className="text-xl font-bold">한국의 산</a>
</div>
</nav>
<main className="container mx-auto p-4">
{children}
</main>
</body>
</html>
)
}
**7. src/app/page.tsx
import { getStates } from '@/lib/db'
import Link from 'next/link'
export default async function Home() {
const provinces = await getStates()
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">대한민국 산 정보</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{provinces.map((province) => (
<Link
href={`/provinces/${province.PROVINCE}`}
key={province.PROVINCE}
className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold">{province.PROVINCE}</h2>
</Link>
))}
</div>
</div>
)
}
**8. src/app/provinces/[PROVINCE]/page.tsx
import { getMountainsByState } from '@/lib/db'
import MountainCard from '@/components/MountainCard'
export default async function StatePage({
params
}: {
params: { state: string }
}) {
const mountains = await getMountainsByState(params.state)
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">{params.state} 산 목록</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{mountains.map((mountain) => (
<MountainCard key={mountain.ID} mountain={mountain} />
))}
</div>
</div>
)
}
**9. src/app/mountains/[id]/page.tsx
import { getMountainById } from '@/lib/db'
import Image from 'next/image'
import { notFound } from 'next/navigation'
export default async function MountainPage({
params
}: {
params: { id: string }
}) {
const mountain = await getMountainById(params.id)
if (!mountain) {
notFound()
}
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">{mountain.NAME}</h1>
<div className="relative h-96 mb-6">
<Image
src={`/images/mountains/${mountain.IMAGE}`}
alt={mountain.NAME}
fill
className="rounded-lg object-cover"
/>
</div>
<div className="bg-white p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold mb-4">상세 정보</h2>
<div className="space-y-2">
<p><strong>높이:</strong> {mountain.HEIGHT}m</p>
<p><strong>위치:</strong> {mountain.PROVINCE}</p>
<p><strong>설명:</strong> {mountain.DESCRIPTION}</p>
</div>
</div>
</div>
)
}
**10. src/components/MountainCard.tsx
import { Mountain } from '@/types'
import Image from 'next/image'
import Link from 'next/link'
export default function MountainCard({ mountain }: { mountain: Mountain }) {
return (
<Link
href={`/mountains/${mountain.ID}`}
className="block bg-white rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden"
>
<div className="relative h-48">
<Image
src={`/images/mountains/${mountain.IMAGE}`}
alt={mountain.NAME}
fill
className="object-cover"
/>
</div>
<div className="p-4">
<h2 className="text-xl font-semibold">{mountain.NAME}</h2>
<p className="text-gray-600">{mountain.HEIGHT}m</p>
</div>
</Link>
)
}
**11. next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['localhost'],
},
}
module.exports = nextConfig
1차 개선 : null 처리
기본적으로 province 부분을 가져오기 때문에 해당 부분에 null이 있을 경우 공백으로 반환하여 빈 공간이 생겨버림
{provinces.map((province) => (
<Link
href={`/provinces/${province.PROVINCE}`}
key={province.PROVINCE}
className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold">{province.PROVINCE}</h2>
</Link>
))}
이부분 때문인데 데이터 무결성을 위해서 빈칸은 데이터를 입력하고 사용하지만 테스트 부분같이 태생적으로 null인 부분을 굳이 지우지 않고 사용할 수 있도록 null처리 구분을 추가했다.
**변경 전/후
// 변경 전
return await db.all('SELECT DISTINCT PROVINCE FROM T');
return await db.all('SELECT * FROM T WHERE PROVINCE = ?', [province]);
return await db.get('SELECT * FROM T WHERE id = ?', [id]);
// 변경 후
return await db.all('SELECT DISTINCT PROVINCE FROM T WHERE PROVINCE IS NOT NULL AND trim(PROVINCE) != ""');
return await db.all('SELECT * FROM T WHERE PROVINCE IS NOT NULL AND trim(PROVINCE) != "" AND PROVINCE = ?', [province]);
return await db.get('SELECT * FROM T WHERE id IS NOT NULL AND id = ?', [id]);
기본적으로 db를 얻어오는 부분만 수정하면 된다.
**주요 함수와 연산자
WHERE : WHERE 뒤쪽의 필터링 조건에 따라 데이터를 필터링한다
trim(): 문자열 앞뒤의 공백을 제거
IS NOT NULL: NULL 값이 아닌지 확인
!=: 같지 않음을 비교
AND: 여러 조건을 모두 만족해야 함
=: 정확히 일치하는지 비교
?: 파라미터가 들어갈 자리 (SQL injection 방지)
**SQL 실행 순서 (Logical Query Processing)
1. FROM: 테이블 지정
2. WHERE: 행 필터링
3. GROUP BY: 그룹화
4. HAVING: 그룹 필터링
5. SELECT: 열 선택/계산
6. DISTINCT: 중복 제거
7. ORDER BY: 정렬
8. LIMIT/OFFSET: 행 수 제한
//이러한 구조로 인해
NULL 값 제외
빈 문자열 제외
원하는 데이터만 정확히 필터링
SQL injection 방지
데이터 정확성 보장
**NULL 처리의 특징
-- NULL vs 빈 문자열('')
WHERE column IS NOT NULL -- NULL이 아닌 값
WHERE column != '' -- 빈 문자열이 아닌 값
WHERE column IS NULL -- NULL인 값
-- trim() 함수 사용 : 앞뒤 공백만 제거하고 중간에 있는 공백은 제거하지 않는다.
TRIM([LEADING | TRAILING | BOTH] [문자열] FROM 문자열)
LTRIM(): 왼쪽 공백 제거
RTRIM(): 오른쪽 공백 제거
**Prepared Statement
-- 장점
1. SQL Injection 방지
2. 쿼리 실행 계획 재사용 (성능 향상)
3. 타입 안정성 보장
4. 쿼리 캐싱 가능
-- 사용 예
const query = 'SELECT * FROM users WHERE id = ?';
db.get(query, [userId]);
**데이터 필터링 예시
-- NULL과 빈 문자열 모두 처리
SELECT * FROM table
WHERE column IS NOT NULL
AND TRIM(column) != '';
-- 복합 조건
SELECT * FROM table
WHERE column IS NOT NULL
AND TRIM(column) != ''
AND column LIKE 'pattern%';
**데이터 무결성 관련
-- 제약조건 설정
CREATE TABLE example (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT ''
);
-- CHECK 제약조건
ALTER TABLE example
ADD CONSTRAINT check_name
CHECK (TRIM(name) != '');
결과적으로 null 자체를 제외해서 데이터를 가져오기 때문에 null 값이 있던 곳은 반영이 되지 않는다.