개요
개인 사진 아카이브 웹사이트. 보유한 카메라별로 촬영한 사진을 분류·관리하고, 갤러리 형태로 공개하는 포트폴리오 사이트.
Next.js 14 App RouterTypeScriptTailwind CSS (다크 테마)Prisma ORMSupabase PostgreSQLCloudflare R2Vercel 배포
기술 스택
| 분류 | 기술 | 용도 |
|---|
| 프레임워크 | Next.js 14 App Router + TypeScript | SSR/SSG, API Routes, Middleware |
| 스타일 | Tailwind CSS 3 다크 테마 | 반응형 UI, 커스텀 색상 토큰 |
| 폰트 | Playfair Display + Noto Serif KR / Inter / JetBrains Mono | 제목(serif) / 본문(sans) / 데이터(mono) |
| DB | Prisma 5 + Supabase PostgreSQL | ORM, 스키마 관리, 마이그레이션 |
| 스토리지 | Cloudflare R2 (S3 호환) | 원본/대형/썸네일 이미지 저장 |
| 이미지 처리 | sharp | 서버사이드 리사이즈 (1920px large, 400px thumb, WebP) |
| EXIF 파싱 | exifr | 클라이언트 사이드 EXIF 메타데이터 추출 |
| 인증 | jsonwebtoken + HttpOnly Cookie | 관리자 JWT 인증 (7일 만료) |
| 배포 | Vercel | 서버리스 함수, 자동 빌드/배포 |
디자인 시스템
색상 토큰
| 토큰 | 값 | 용도 |
|---|
| bg | #0d0d0d | 페이지 배경 (near-black) |
| bg-surface | #1a1a1a | 카드/입력 필드 배경 |
| border | #2e2e2e | 모든 테두리/구분선 |
| text | #e8e0d4 | 기본 텍스트 (warm off-white) |
| text-muted | #8a8078 | 보조 텍스트 |
| accent | #c8a87a | 링크, 버튼, 강조 (warm gold) |
| accent-hover | #d4b88a | accent 호버 상태 |
| danger | #b05a5a | 삭제, 에러 |
폰트
- •font-serif: Playfair Display, Noto Serif KR — 제목, 카메라 이름
- •font-sans: Inter — 본문 기본
- •font-mono: JetBrains Mono — EXIF 값, 날짜, 사진 수
데이터베이스 스키마
Camera (cameras 테이블)
| 필드 | 타입 | 설명 |
|---|
| id | String @id | 슬러그 (예: leica-m6) |
| name | String | 카메라 이름 (예: Leica M6) |
| maker | String | 제조사 (예: Leica) |
| type | CameraType | MIRRORLESS | DSLR | COMPACT | MEDIUM_FORMAT | SMARTPHONE | FILM |
| yearStart | Int | 사용 시작 연도 |
| monthStart | Int? | 사용 시작 월 (1-12) |
| yearEnd | Int? | 사용 종료 연도 (null = 현재 사용중) |
| monthEnd | Int? | 사용 종료 월 (1-12) |
| description | String @db.Text | 카메라 설명 |
| coverImage | String | 커버 이미지 URL |
| order | Int | 정렬 순서 |
| photoCount | Int | 사진 수 (캐시) |
Photo (photos 테이블)
| 필드 | 타입 | 설명 |
|---|
| id | String @id @default(uuid) | UUID |
| filename | String | 원본 파일명 |
| urlOriginal / urlLarge / urlThumb | String | R2 이미지 URL (원본/1920px/400px WebP) |
| cameraMake / cameraModel | String? | EXIF 카메라 정보 |
| lens / focalLength / aperture / shutterSpeed | String? | EXIF 렌즈 정보 |
| iso | Int? | ISO 감도 |
| takenAt | DateTime? | 촬영 일시 |
| gpsLat / gpsLng | Float? | GPS 좌표 |
| cameraId | String (FK → Camera) | 소속 카메라 |
| locationTag | String? | 장소 태그 |
| isFeatured | Boolean | 홈페이지 Featured 표시 여부 |
| isDeleted | Boolean | 소프트 삭제 여부 |
페이지 구조
공개 페이지
| 경로 | 설명 | 렌더링 |
|---|
| / | 홈: Featured 사진 그리드 + 카메라 목록 + 타임라인 | Server (force-dynamic) |
| /archive | 사진 갤러리: 무한 스크롤 Masonry + 필터 (카메라/연도/렌즈) | Client |
| /archive/[id] | 사진 상세: 큰 이미지 + EXIF 메타데이터 | Server + generateMetadata |
| /dev-notes | 개발 기록 타임라인 | Server |
| /prompt | 프로젝트 명세 (이 페이지) | Server |
관리자 페이지 (인증 필요)
| 경로 | 설명 |
|---|
| /admin | 관리자 대시보드 (카메라 관리 / 사진 업로드 링크) |
| /admin/login | 로그인 (ID + 비밀번호) |
| /admin/cameras | 카메라 목록 관리 (편집/삭제) |
| /admin/cameras/new | 카메라 신규 등록 |
| /admin/cameras/[id] | 카메라 편집 (커버 이미지 업로드 포함) |
| /admin/upload | 사진 업로드 (드래그앤드롭, EXIF 자동 파싱, 카메라 자동 매칭) |
API 라우트
공개 API
| 메서드 | 경로 | 설명 |
|---|
| GET | /api/cameras | 전체 카메라 목록 |
| GET | /api/cameras/[id] | 카메라 상세 |
| GET | /api/photos | 사진 목록 (커서 기반 페이지네이션, 필터 지원) |
| GET | /api/photos/[id] | 사진 상세 |
| GET | /api/filters | 필터 옵션 (카메라, 렌즈, 연도) |
관리자 API (인증 필요)
| 메서드 | 경로 | 설명 |
|---|
| POST | /api/admin/login | 로그인 → JWT HttpOnly Cookie 발급 |
| POST | /api/admin/logout | 로그아웃 → 쿠키 삭제 |
| GET | /api/admin/me | 인증 상태 확인 |
| POST | /api/admin/cameras | 카메라 생성 |
| PUT | /api/admin/cameras/[id] | 카메라 수정 |
| DELETE | /api/admin/cameras/[id] | 카메라 삭제 |
| POST | /api/admin/cameras/[id]/cover | 커버 이미지 presigned URL 발급 |
| PATCH | /api/admin/cameras/[id]/cover | 커버 이미지 URL DB 저장 |
| PATCH | /api/admin/photos/[id] | 사진 메타데이터 수정 |
| DELETE | /api/admin/photos/[id] | 사진 소프트 삭제 |
| POST | /api/admin/upload/presign | 사진 업로드 presigned URL 발급 (복수) |
| POST | /api/admin/upload/complete | 업로드 완료 처리 (썸네일 생성, DB 저장, 카메라 매칭) |
핵심 기능 상세
사진 업로드 흐름
- 1.클라이언트에서 이미지 선택 → exifr로 EXIF 파싱 (카메라, 렌즈, 조리개, 셔터속도, ISO, 촬영일시, GPS)
- 2.POST /api/admin/upload/presign → R2 presigned PUT URL 발급 (originals/{year}/{month}/{uuid}.{ext})
- 3.XHR PUT으로 R2에 직접 업로드 (진행률 표시)
- 4.POST /api/admin/upload/complete → 서버에서 sharp로 large(1920px)/thumb(400px) WebP 생성
- 5.카메라 자동 매칭: __auto__ 모드 시 EXIF 카메라 정보로 findOrCreateCamera() 호출
- 6.EXIF 데이터 타입 안전 처리 (safeStr, safeInt, safeFloat, safeDate) 후 DB 저장
- 7.maxDuration: 60초로 Vercel 서버리스 타임아웃 확장
카메라 자동 생성 (findOrCreateCamera)
- 1.EXIF cameraModel → 슬러그 생성 (예: 'LEICA Q3 43' → 'leica-q3-43')
- 2.EXIF cameraMake → MAKER_MAP으로 정규화 (예: 'LEICA CAMERA AG' → 'Leica')
- 3.키워드 기반 카메라 타입 추론 (스마트폰/중형/컴팩트/미러리스)
- 4.prisma.camera.upsert — 이미 존재하면 무시, 없으면 생성
커버 이미지 업로드
- 1.카메라 편집 페이지에서 'Choose Image' 클릭
- 2.POST /api/admin/cameras/[id]/cover → presigned URL (covers/{cameraId}/{uuid}.{ext})
- 3.XHR PUT으로 R2 직접 업로드 (진행률 바 표시)
- 4.PATCH /api/admin/cameras/[id]/cover → DB에 public URL 저장
- 5.미리보기 즉시 갱신
인증 체계
- •관리자 ID/비밀번호를 환경변수로 관리 (ADMIN_ID, ADMIN_PASSWORD)
- •로그인 성공 시 JWT 생성 (jsonwebtoken, 7일 만료) → HttpOnly Cookie 설정
- •미들웨어: /admin/* 경로 접근 시 쿠키 확인, 없으면 /admin/login으로 리다이렉트
- •API 라우트: isAuthenticated(request)로 쿠키의 JWT 검증
- •클라이언트: /api/admin/me로 인증 상태 확인 → 조건부 관리자 UI 표시
컴포넌트
| 컴포넌트 | 파일 | 설명 |
|---|
| Header | src/components/Header.tsx | 네비게이션 바 (Cameras, Archive, Dev Notes, Admin/Logout). 클라이언트에서 /api/admin/me 호출로 인증 상태 반영 |
| Footer | src/components/Footer.tsx | 저작권 표시 + Dev Notes, Prompt 링크 |
| Lightbox | src/components/Lightbox.tsx | 사진 뷰어 (75vw×75vh 고정 박스). 키보드 네비게이션 (ESC/←/→), EXIF 데이터 하단 표시 |
| PhotoEditModal | src/components/PhotoEditModal.tsx | 사진 메타데이터 인라인 편집 모달 (카메라, 장소, 렌즈, EXIF 값) |
환경변수
| 변수명 | 설명 |
|---|
| DATABASE_URL | Supabase PostgreSQL 연결 URL (pooled) |
| DATABASE_URL_UNPOOLED | Supabase PostgreSQL 직접 연결 URL (마이그레이션용) |
| ADMIN_ID | 관리자 로그인 아이디 |
| ADMIN_PASSWORD | 관리자 로그인 비밀번호 |
| JWT_SECRET | JWT 서명 키 |
| R2_ACCOUNT_ID | Cloudflare 계정 ID |
| R2_ACCESS_KEY_ID | R2 API 접근 키 |
| R2_SECRET_ACCESS_KEY | R2 API 시크릿 키 |
| R2_BUCKET_NAME | R2 버킷 이름 |
| R2_PUBLIC_URL | R2 퍼블릭 CDN URL |
R2 스토리지 키 구조
originals/{year}/{month}/{uuid}.{ext} # 원본 이미지
large/{year}/{month}/{uuid}.webp # 리사이즈 (1920px, WebP 85%)
thumbs/{year}/{month}/{uuid}.webp # 썸네일 (400px, WebP 80%)
covers/{cameraId}/{uuid}.{ext} # 카메라 커버 이미지
디렉토리 구조
src/
├── app/
│ ├── page.tsx # 홈 (카메라 목록 + Featured + 타임라인)
│ ├── archive/
│ │ ├── page.tsx # 갤러리 (Masonry + 무한스크롤 + 필터)
│ │ └── [id]/page.tsx # 사진 상세
│ ├── dev-notes/page.tsx # 개발 기록
│ ├── prompt/page.tsx # 프로젝트 명세 (이 페이지)
│ ├── admin/
│ │ ├── page.tsx # 관리자 대시보드
│ │ ├── login/page.tsx # 로그인
│ │ ├── upload/page.tsx # 사진 업로드
│ │ └── cameras/
│ │ ├── page.tsx # 카메라 목록 관리
│ │ ├── new/page.tsx # 카메라 신규 등록
│ │ └── [id]/page.tsx # 카메라 편집
│ └── api/
│ ├── cameras/ # 공개 카메라 API
│ ├── photos/ # 공개 사진 API
│ ├── filters/ # 필터 옵션 API
│ └── admin/ # 관리자 API (인증/카메라/사진/업로드)
├── components/
│ ├── Header.tsx # 네비게이션 바
│ ├── Footer.tsx # 푸터
│ ├── Lightbox.tsx # 사진 뷰어
│ └── PhotoEditModal.tsx # 사진 편집 모달
├── lib/
│ ├── auth.ts # JWT 인증
│ ├── prisma.ts # Prisma 클라이언트 싱글턴
│ ├── r2.ts # R2 클라이언트 + presigned URL
│ ├── camera.ts # 카메라 자동 생성/추론
│ └── utils.ts # cn() 유틸리티
└── middleware.ts # /admin/* 인증 미들웨어