Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

137 Commits
 
 
 
 

Repository files navigation

DooDoo - 저작권 프리 이미지 다운로드 웹사이트

Doodoo는 회원가입 없이 빠른 다운로드, 저작권 걱정 없는 무료 이미지를 제공하는 Free Stock Image 사이트입니다. 실제 운영 시 발생하는 비용 문제와

대규모 데이터의 검색 노출(SEO)을 엔지니어링 관점에서 해결하는 데 집중했습니다.


🪄 주요 기능

  • 갤러리 페이지: 카테고리(사진·일러스트·템플릿·아이콘·스티커)별 필터링과 키워드 검색
  • 상세 페이지: 이미지 메타데이터, 태그, 유사 이미지 추천
  • 다운로드: 해상도별 옵션 선택 → Signed URL을 통한 안전한 원본 제공
  • 반응형 디자인: 모바일·태블릿·데스크톱 전 해상도 대응
  • 관리자 페이지: 대량 이미지 업로드, 메타데이터 관리, 파일 삭제

🚀 배포 링크

🛠️ 기술 스택

영역 기술
Frontend Next.js 16 (App Router), React 19, TypeScript 5
Styling Tailwind CSS 4.0, SASS (SCSS Modules)
Backend Cloudflare Workers (Serverless Edge)
Database Supabase (PostgreSQL)
Storage Cloudflare R2 (Object Storage)
Deploy Vercel (Frontend), Cloudflare (Backend/Storage)
Optimization React Compiler (Babel Plugin)

기술 선택 이유

  • Cloudflare Workers: 고정 서버비 없이 요청량 비례 과금. 전 세계 엣지에서 실행되어 API 응답 지연 최소화
  • Supabase + R2 분리: 이미지 메타데이터는 관계형 DB(Supabase)로 무결성 보장, 대용량 바이너리는 R2 오브젝트 스토리지로 비용 최적화
  • Next.js: SEO가 핵심인 이미지 플랫폼 특성상 SSR/SSG/ISR을 유연하게 선택할 수 있는 하이브리드 렌더링이 필요했음

📂 프로젝트 구조

src/
├── app/
│   ├── (site)/          # 공개 페이지 (Route Group)
│   │   ├── list/        # 갤러리 & 검색 결과
│   │   └── [id]/        # 이미지 상세 페이지
│   ├── (admin)/admin/   # 보호된 관리자 영역
│   └── page.tsx         # 랜딩 페이지
├── components/
│   ├── common/          # Header, Footer, SearchBar, Pagination
│   ├── admin/           # 관리자 전용 UI
│   └── home/            # HeroSection, Three.js Ballpit
├── hooks/               # useFileGrouper (파일 그루핑 로직)
├── lib/
│   └── api.ts           # 모든 API 호출 집중화
├── styles/
│   ├── base/            # 변수, 폰트, 믹스인, 리셋
│   └── components/      # 컴포넌트별 SCSS 모듈
└── types/               # TypeScript 타입 정의

구조 설계 배경

과거 프로젝트에서 모호한 컨벤션으로 인해 구조 파악과 협업에 어려움을 겪었습니다. 이를 개선하고자 이번에는 관심사 분리(SoC) 원칙에 따라 디렉토리 구조와 규칙을 먼저 설계했습니다.


🔍 기술적 도전과 해결 과정

1. 검색 엔진 최적화(SEO) 및 대규모 동적 페이지 인덱싱

문제 상황

수만 장의 이미지 상세 페이지가 검색 엔진에 개별적으로 노출되지 않으면 서비스 유입이 거의 없을 것이라는 걸 알고 있었지만, 처음엔 CSR 방식으로 개발을 시작했습니다. 검색 봇이 JavaScript 실행 전 빈 HTML을 먼저 읽는다는 것을 직접 확인하고 나서야 문제를 실감했습니다.

원인 분석

  • CSR 방식은 JS 실행 이후 데이터를 불러오므로, 크롤러가 이미지 제목·태그 정보를 읽기 전에 빈 페이지를 수집
  • 각 이미지 페이지마다 고유한 메타데이터가 없어 소셜 공유 시 미리보기 없음

해결 방법

  1. generateMetadata 함수로 DB의 이미지 메타데이터(Title, Description, Tags)를 서버에서 생성해 페이지마다 고유한 Open Graph, Twitter Card를 동적으로 주입
  2. robots.ts, sitemap.xml을 Next.js App Router 기반으로 동적 생성하여 Google Search Console에 제출

결과 및 개선 방향

  • Lighthouse SEO 점수 100점 달성
  • 소셜 공유 시 이미지별 커스텀 미리보기 정상 노출 확인
  • 현재 Google Search Console로 색인 현황을 모니터링 중. 아직 대부분의 페이지가 색인 대기 상태라 실제 유입 증가 효과는 추적 중

2. 방대한 스톡 데이터 관리

문제 상황

수만 장의 스톡 이미지를 한 버킷에 그냥 올려두면 R2 퍼블릭 버킷의 엔드포인트가 그대로 노출되고, 원본 이미지까지 무제한 접근 가능해지는 구조였습니다. 비용과 보안 두 가지 문제가 동시에 있었습니다.

해결 방법

이미지를 접근 성격에 따라 두 가지로 분류했습니다.

구분 저장 위치 용도
썸네일, 미리보기 R2 Public Bucket 갤러리·상세 페이지 노출용
원본 스톡 R2 Private Bucket 다운로드 전용 (인증 후 접근)

퍼블릭 버킷의 경우 R2 기본 엔드포인트(*.r2.dev) 대신 서브도메인을 연결해 실제 스토리지 구조가 외부에 노출되지 않도록 했습니다.

결과 및 개선 방향

  • 원본 이미지 무단 접근 경로 차단
  • 퍼블릭 버킷의 스토리지 구조 은닉으로 직접 URL 스캔 공격 방어
  • 현재 버킷 분리는 되어 있지만, 퍼블릭 버킷에 대한 핫링킹(외부 사이트에서 이미지 URL을 직접 사용하는 행위) 제한은 아직 미구현 상태. Cloudflare WAF 규칙 적용을 검토 중

3. 스톡 데이터 보안 설계

문제 상황

원본 스톡 이미지는 프라이빗 버킷에 저장했지만, 다운로드 기능을 구현하려면 클라이언트가 어떤 방식으로든 파일에 접근해야 합니다. 다운로드 URL을 클라이언트에 직접 노출하면 해당 URL을 외부에서 반복 사용하거나 크롤링하는 것을 막을 수 없었습니다.

해결 방법

다운로드 요청 시 클라이언트가 R2에 직접 접근하지 않고, Cloudflare Workers를 거쳐 Signed URL을 발급받는 구조로 설계했습니다.

클라이언트 → Workers API 요청 → R2 Signed URL 생성 (유효시간 제한) → 클라이언트에 전달 → R2 직접 다운로드
  • Signed URL은 유효시간이 지나면 무효화되어 URL 재사용 불가
  • Workers 단에서 허용된 도메인(Referer 검증)에서만 요청을 수락하도록 제한

결과 및 개선 방향

  • R2 원본 URL 미노출로 엔드포인트 악용 방지
  • 허용된 도메인 외부에서의 직접 다운로드 차단
  • 현재는 만료 시간만 설정된 단순한 Signed URL 구조. 추후 사용자 세션 기반 일회성 토큰으로 고도화해 동일 URL의 다중 다운로드까지 제한할 계획

4. API 호출 최소화와 캐싱 전략

문제 상황

이미지 플랫폼 특성상 갤러리 한 페이지에 최소 30개의 이미지가 노출되고, 검색·필터링·페이지네이션마다 API를 호출합니다. 사용자가 늘거나 봇 트래픽이 증가하면 Cloudflare Workers API 호출 비용이 선형으로 늘어나는 구조였습니다. 무료 플랜 한도를 생각하면 캐싱 전략이 필수였습니다.

공부한 내용

Next.js App Router의 fetch() 는 기본적으로 응답을 캐싱합니다. 동일 요청이 한 렌더링 사이클에서 여러 번 호출되어도 중복 요청이 발생하지 않는 Request Memoization 동작 방식을 이해하고, 페이지 유형별로 revalidate 값을 다르게 설정하는 전략을 세웠습니다.

해결 방법

페이지 유형 렌더링 방식 캐싱 전략
이미지 상세 페이지 ISR revalidate: 86400 (24시간, 데이터 변경이 드묾)
검색 결과 1페이지 SSR 요청마다 최신 결과 보장, no-store
검색 결과 2페이지~ CSR 브라우저 캐시 + 뒤로가기 시 재요청 없음
관리자 페이지 CSR 캐싱 없이 항상 최신 상태 유지

결과 및 개선 방향

  • 상세 페이지 반복 접근 시 Cloudflare Workers API 호출 없이 CDN 캐시에서 응답
  • 검색 결과 페이지네이션에서 불필요한 중복 호출 제거
  • 현재는 시간 기반 revalidate만 사용 중. 관리자가 이미지를 수정했을 때 즉시 반영되도록 revalidatePath / revalidateTag를 활용한 온디맨드 캐시 무효화를 적용할 계획

5. SSR과 CSR 하이브리드 렌더링

문제 상황

Next.js App Router를 처음 써보면서 가장 헷갈렸던 부분입니다. 대학에서 SSR과 CSR을 이론으로만 배웠는데, 실제로 어떤 컴포넌트를 서버 컴포넌트로 두고 어떤 것을 'use client'로 분리할지 기준이 없었습니다. 처음엔 익숙한 방식대로 모든 것을 CSR로 만들었다가, SEO와 초기 로딩 성능이 나빠지는 것을 직접 확인하고 나서 렌더링 전략을 전면 재설계했습니다.

적용 기준

"외부로 보여야 하는 콘텐츠는 서버, 사용자 반응에 따라 달라지는 것은 클라이언트"

구분 적용 페이지/컴포넌트 이유
SSR/ISR 상세 페이지, 검색 결과 1페이지, 메타데이터 크롤러가 읽어야 하는 콘텐츠, 초기 로딩 성능
CSR 다운로드 버튼, 검색 결과 2페이지~, 관리자 전체 사용자 인터랙션 필수, SEO 불필요

상세 페이지의 경우 이미지 미리보기와 메타데이터는 SSR로 렌더링하고, 다운로드 버튼만 'use client' 컴포넌트로 분리했습니다. 검색 결과는 첫 페이지만 SSR로 제공하고 이후 페이지는 CSR 방식으로 처리해 불필요한 서버 렌더링 비용을 줄였습니다.

결과 및 개선 방향

  • 검색 엔진 크롤러가 수집해야 하는 콘텐츠의 정상 색인 확인
  • 첫 번째 페이지 FCP(First Contentful Paint) 개선
  • 아직 미흡한 부분: 검색 결과 1페이지와 2페이지 간의 UX 전환이 어색함 (SSR → CSR 전환 시 레이아웃 시프트). loading.tsx와 스켈레톤 UI를 보완할 예정

🤔 개발하면서 느낀 점

처음으로 실제 운영을 목표로 만든 프로젝트입니다. 이전까지는 "돌아가면 완성"이었는데, 이번엔 비용이 얼마나 나올지, 누가 원본 이미지를 훔쳐가면 어떻게 막을지, 트래픽이 갑자기 늘면 어떻게 될지를 고민하며 개발해야 했습니다.

가장 크게 배운 건 설계를 먼저 하고 코드를 나중에 쓰는 습관입니다. 버킷을 처음부터 두 개로 나눈 덕분에 보안 문제를 초반에 잡을 수 있었고, 렌더링 전략을 미리 정해두니 기능 추가 때 어디를 건드려야 할지 명확했습니다. 반대로 SSR/CSR 경계는 처음에 고민 없이 짰다가 나중에 전면 재작업했는데, 설계 단계에서 더 신중했어야 했다고 느꼈습니다.

아직 실제 유입이 많지 않아 캐싱 효과나 보안 설계의 효과를 정량적으로 측정하지 못한 부분이 아쉽습니다. Google Search Console 데이터가 쌓이는 대로 SEO 효과를, Cloudflare Analytics로 캐시 히트율을 추적해나갈 계획입니다.

About

doodoo frontend 레포지토리

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages