Next.js포트폴리오성능 최적화SEOGitHub Actions접근성

포트폴리오부터 방명록까지 — DB 없는 Next.js 블로그를 전면 점검했다

July 19, 202610 min read🌐 Only Korean available

사이트는 잘 돌아가는데, 포트폴리오를 보여주기엔 아쉬웠다

이 블로그는 별도 DB 없이 운영한다.

  • 글은 content/posts/의 Markdown 파일
  • 예약 글은 content/scheduled/에 넣고 GitHub Actions로 발행
  • 방명록은 content/guestbook.json에 저장
  • 배포는 GitHub main 브랜치와 연결된 Vercel

처음에는 단순한 개발 블로그였지만 프로젝트가 하나씩 늘면서 포트폴리오 역할도 같이 하게 됐다. RepoNote, Matchda, 자동 블로그 작성기, 픽셀 마을 같은 작업을 추가하다 보니 포트폴리오 페이지가 점점 길어졌다.

설명과 스크린샷을 전부 보여주는 건 자세해서 좋았다. 하지만 채용 담당자나 처음 방문한 사람이 보기에는 무엇이 대표 프로젝트인지 찾기 어려웠고, 첫 화면을 열 때 필요하지 않은 이미지까지 너무 많이 붙었다.

그래서 포트폴리오만 가볍게 고칠 생각으로 사이트를 열어봤다가, 결국 블로그 전체를 점검하게 됐다.

점검 전 눈에 띈 수치는 이랬다.

항목점검 전
포트폴리오 초기 HTML약 272KB
포트폴리오 이미지 태그약 50개
홈 초기 JavaScript약 1.245MB
글 목록 응답약 1.2~2.4초
npm audit취약점 존재
ESLint오류와 경고 존재

수치는 로컬 프로덕션 빌드에서 측정했다. 실제 Vercel 응답 시간과는 차이가 있을 수 있지만, 변경 전후를 비교하는 기준으로는 충분했다.


Step 1 — 대표 프로젝트와 나머지 프로젝트를 분리했다

기존 포트폴리오는 모든 프로젝트가 거의 같은 무게로 나열되어 있었다. 프로젝트가 적을 때는 괜찮았지만 개수가 늘어나니 중요한 작업이 아래로 묻혔다.

먼저 세 프로젝트를 대표 작업으로 올렸다.

  1. RepoNote
  2. Blog Auto-Publisher SaaS
  3. Matchda

대표 프로젝트는 기존처럼 긴 설명과 이미지 갤러리를 보여주고, 나머지는 제목, 한 줄 소개, 상태, 기술 스택, 링크만 표시하는 카드로 줄였다.

포트폴리오
  ├─ 대표 프로젝트 3개
  │    └─ 상세 설명 + 기술 스택 + 이미지
  └─ 더 만든 것들 12개
       └─ 요약 카드

이미지에는 실제 width와 height를 넣고, 화면 크기에 맞는 sizes를 지정했다. 첫 화면에서 꼭 필요하지 않은 이미지의 priority preload도 제거했다.

1차 변경 결과는 확실했다.

항목변경 전1차 변경 후
초기 HTML약 272KB약 189KB
이미지 태그약 50개13개
이미지 preload7개0개

그런데 내용을 아예 숨기니 또 아쉬웠다

페이지는 가벼워졌지만 문제가 하나 생겼다. 기타 프로젝트의 설명과 스크린샷 데이터는 코드에 남아 있는데 사용자가 볼 방법이 없어졌다.

포트폴리오는 빠르게 훑을 수 있어야 하지만, 관심이 생긴 사람에게는 왜 만들었고 어떻게 구현했는지 보여줄 수 있어야 한다. 둘 중 하나를 포기할 필요는 없었다.

그래서 각 요약 카드에 자세히 보기 · 사진 N 버튼을 추가했다.

버튼을 누르면 해당 프로젝트 하나의 상세 모달이 열린다.

  • 기존 전체 설명
  • 전체 기술 스택
  • GitHub와 서비스 링크
  • 대표 이미지
  • 스크린샷 갤러리

중요한 점은 모든 상세 이미지를 처음부터 렌더링하지 않는 것이다. 선택한 프로젝트 하나만 모달에 렌더링하므로 초기 이미지 태그는 여전히 13개다. 최종 초기 HTML은 약 167KB로 측정됐다.

모달에는 접근성 처리도 같이 넣었다.

  • ESC로 닫기
  • 배경 클릭과 닫기 버튼 지원
  • Tab 포커스가 모달 밖으로 빠져나가지 않게 처리
  • 닫은 뒤 원래 자세히 보기 버튼으로 포커스 복귀
  • 모바일에서는 화면 높이에 맞춰 내부 스크롤
  • 상세 이미지를 누르면 기존 라이트박스로 확대

포트폴리오에서 얻은 결론은 단순했다.

처음에는 요약을 보여주고, 관심이 생겼을 때만 상세 정보를 연다.

정보를 지우는 것과 처음부터 보여주지 않는 것은 전혀 다른 설계였다.


Step 2 — 공개 글을 요청마다 GitHub에서 읽지 않게 했다

이 블로그는 DB가 없기 때문에 GitHub API를 많이 사용한다. 관리자에서 글을 저장하거나 이미지를 올릴 때 GitHub에 커밋하는 건 자연스럽다.

문제는 방문자가 글 목록을 열 때도 GitHub GraphQL API를 호출하고 있었다는 점이다.

방문자 요청
  → Vercel 서버 함수
  → GitHub GraphQL API
  → Markdown 목록 수신
  → 파싱 후 HTML 응답

게다가 no-store라서 요청할 때마다 다시 조회했다. GitHub가 잠시 느려지거나 API 제한에 걸리면 공개 블로그까지 같이 느려지는 구조였다.

생각해보면 발행된 글은 이미 저장소의 content/posts/에 있다. Vercel이 애플리케이션을 빌드할 때 그 파일들을 배포 번들에 포함하면, 공개 페이지는 파일시스템에서 바로 읽을 수 있다.

변경 전: 방문자 → Vercel → GitHub API → 글 목록
변경 후: 방문자 → Vercel의 빌드 콘텐츠 → 글 목록

관리자 저장에는 GitHub API를 계속 사용하고, 공개 읽기만 로컬 콘텐츠로 분리했다.

이 변경으로 로컬 프로덕션 warm 요청 기준 글 목록 응답이 약 1.2~2.4초에서 0.03초로 줄었다. 빌드 때 발생하던 파일 추적 경고도 같이 사라졌다.

여기서 배운 건 DB가 없다는 것과 매 요청마다 GitHub API를 호출해야 한다는 것은 다르다는 점이다.

  • 쓰기: GitHub API로 커밋
  • 발행된 콘텐츠 읽기: 빌드에 포함된 파일
  • 실시간 데이터가 필요한 부분만 API 조회

읽기와 쓰기의 성격을 나누니 구조가 훨씬 단순해졌다.


Step 3 — 홈에서 Three.js를 자동으로 열지 않았다

블로그에는 RPG 스타일 픽셀 마을이 있다. 캐릭터로 마을을 돌아다니며 글과 프로젝트를 찾고, 숨겨진 장소에서 방명록 나무를 심는 페이지다.

문제는 홈에 들어오면 이 마을이 전체 화면 오버레이로 자동 실행됐다는 것이다.

재미는 있었지만 모든 방문자가 게임을 하러 오는 것은 아니다. 글을 읽거나 포트폴리오 링크를 확인하려는 사람에게 Three.js, 월드 데이터, 방명록 데이터까지 처음부터 보내고 있었다.

그래서 홈의 우선순위를 바꿨다.

첫 번째 CTA  → 포트폴리오
두 번째 CTA  → 글 목록
별도 링크    → 픽셀 마을 입장

픽셀 마을을 없앤 게 아니라 선택해서 들어가는 경험으로 바꿨다.

항목변경 전변경 후
홈 초기 JavaScript약 1.245MB약 685KB
홈 HTML약 106KB약 76KB

재미있는 기능일수록 무조건 첫 화면에 보여주고 싶어진다. 하지만 핵심 사용자 흐름을 막으면서까지 자동 실행할 필요는 없었다.


Step 4 — SEO는 메타 태그 몇 개가 아니라 URL 전체를 봐야 했다

페이지를 확인해보니 하위 페이지가 루트의 Open Graph 정보를 일부 상속하고 있었다. 포트폴리오 링크를 공유해도 제목이나 URL이 홈 기준으로 표시될 수 있었다.

SEO 관련 변경은 공통 metadata helper로 묶었다.

  • locale별 canonical
  • 한국어·영어 hreflang
  • Open Graph 제목, 설명, URL
  • Twitter card
  • 1200×630 공유 이미지
  • 사이트맵의 언어 대체 URL

사이트맵에는 빠져 있던 포트폴리오, 픽셀 마을, 앱 개인정보처리방침 페이지를 추가했다. 관리자와 API 경로는 robots에서 차단하고 관리자 화면에는 noindex를 설정했다.

www.backtodev.com으로 들어오는 요청은 루트 도메인으로 308 리다이렉트해 중복 URL도 정리했다.

www.backtodev.com/ko/portfolio
  → 308
backtodev.com/ko/portfolio

다국어 화면에 남아 있던 영어 고정 문구도 함께 정리했다. 내비게이션, 검색, Topics, 빈 결과, 날짜를 locale에 맞게 표시하고, 항상 1분으로 나오던 읽기 시간은 실제 본문 길이로 계산하도록 바꿨다.


Step 5 — 키보드로도 사이트를 끝까지 사용할 수 있게 했다

마우스로만 사용할 때는 잘 몰랐지만 Tab 키로 이동해보면 빠진 부분이 많았다.

  • 모바일 메뉴가 열렸는지 스크린리더가 알 수 없음
  • 현재 페이지 링크 정보가 없음
  • 라이트박스를 닫으면 포커스가 사라짐
  • ESC와 포커스 고정 처리가 부족함
  • 모션 축소 설정을 무시함

다음 항목을 추가했다.

본문 바로가기 링크
focus-visible 전역 스타일
aria-current / aria-expanded / aria-controls
라이트박스 dialog + focus trap + ESC
닫은 뒤 트리거로 focus 복귀
prefers-reduced-motion 대응

관리자 화면의 일반 <a> 내부 링크는 Next.js Link로 바꾸고, effect 안에서 불필요하게 동기 상태를 갱신하던 코드도 정리했다. 그 결과 남아 있던 ESLint 오류와 경고가 모두 없어졌다.


Step 6 — 기본 보안 헤더를 추가했다

보안 헤더는 next.config.tsheaders()에서 전체 경로에 적용했다.

헤더목적
Strict-Transport-SecurityHTTPS 사용 강제
X-Content-Type-Options: nosniffMIME 타입 추측 방지
X-Frame-Options: SAMEORIGIN외부 iframe 삽입 제한
Referrer-Policy외부 이동 시 referrer 정보 제한
Permissions-Policy카메라, 마이크, 위치 권한 비활성화

Next.js의 X-Powered-By 응답 헤더도 제거했다.

Content Security Policy는 이번에 넣지 않았다. 블로그에서 Google AdSense를 사용하고 있어 script, frame, connect 도메인을 정확히 정리하지 않은 상태에서 CSP부터 강제하면 광고나 분석 기능이 깨질 수 있다.

나중에 적용한다면 처음부터 차단하지 않고 Content-Security-Policy-Report-Only로 위반 내역을 먼저 모을 예정이다.


Step 7 — 예약 발행은 성공하는 것보다 잘못 발행하지 않는 게 중요했다

예약 글은 publish_date가 되면 GitHub Actions가 파일을 이동한다.

content/scheduled/
  → publish_date 도달
content/posts/
  → Git commit & push
  → Vercel 배포

기존 워크플로우는 기본 동작은 했지만 운영 중 문제가 될 수 있는 부분이 있었다.

  • 예약 실행과 수동 실행이 겹칠 수 있음
  • 잘못된 날짜가 들어오면 숫자 비교에서 실패할 수 있음
  • 같은 파일명이 이미 posts에 있으면 덮어쓸 수 있음
  • main이 아닌 브랜치에서 수동 실행할 수 있음
  • 작업 도중 main이 변경되면 push가 충돌할 수 있음

그래서 다음 안전장치를 추가했다.

  1. concurrency group으로 발행 작업 직렬화
  2. main 브랜치에서만 실행
  3. 10분 timeout
  4. 날짜를 YYYY-MM-DD로 검증
  5. 같은 이름의 발행 파일이 있으면 이동 중단
  6. 작업 전 git pull --ff-only
  7. 커밋 후 rebase하고 HEAD:main으로 명시적 push
  8. 커밋 작성자를 github-actions[bot]으로 통일

자동화는 정상 흐름보다 실패 흐름을 먼저 생각해야 했다. 글을 하루 늦게 발행하는 건 다시 실행하면 되지만, 기존 글을 덮어쓰면 복구 비용이 훨씬 크다.


Step 8 — GitHub JSON 방명록의 동시 저장 문제

픽셀 마을의 방명록은 DB 대신 content/guestbook.json을 사용한다. 방문자가 나무를 심으면 API가 JSON을 읽고 항목을 추가한 뒤 GitHub Contents API로 커밋한다.

방명록 읽기 (SHA: A)
  → 새 나무 추가
  → SHA A를 기준으로 PUT
  → GitHub commit

두 방문자가 거의 동시에 저장하면 둘 다 같은 SHA A를 읽을 수 있다.

방문자 1: SHA A 읽기 → 저장 성공 → SHA B
방문자 2: SHA A 읽기 → 저장 시도 → 409 Conflict

이 경우 두 번째 방문자의 글을 그냥 실패시키는 대신 다음과 같이 처리했다.

409 Conflict
  → 최신 guestbook.json 다시 읽기
  → 빈 나무 좌표 다시 계산
  → 항목 다시 추가
  → 최대 3회 재시도

더 위험한 문제도 있었다. JSON 파싱이 실패하면 빈 배열로 반환했는데, 그 상태에서 새 글을 저장하면 손상된 기존 파일을 방명록 하나만 있는 새 배열로 덮어쓸 수 있었다.

그래서 JSON이 배열이 아니거나 파싱에 실패하면 저장 자체를 중단하도록 바꿨다.

요청 검증도 강화했다.

  • application/json만 허용
  • 본문 최대 2KB
  • 이름과 메시지 길이 제한
  • 제어문자 제거
  • honeypot 유지
  • UUID 기반 ID
  • 저장 성공 후에만 IP throttle 기록
  • 한국어·영어 오류 메시지

다만 이 구조에는 아직 남은 문제가 있다. 방명록 커밋도 main에 들어가기 때문에 나무 한 그루가 추가될 때마다 Vercel 배포가 발생할 수 있다.

DB 없이 유지하려면 다음 단계에서 방명록 JSON을 전용 브랜치나 별도 저장소로 분리하는 게 좋다.


Step 9 — 안 쓰는 이미지와 깨진 링크 정리

public 이미지 전체를 파일명 기준으로 코드와 Markdown에서 검색했다. 참조가 0건인 이미지 9개, 약 4.37MB를 제거했다.

정리 과정에서 Google Play 관련 한글·영문 게시글의 깨진 이미지도 하나 발견했다. 실제 파일은 있었지만 Markdown URL의 타임스탬프 한 자리가 달랐다.

게시글 URL: ...1778551636467.png
실제 파일명: ...1778555636467.png

단순히 미사용 파일을 지우는 작업이었지만, 삭제 전에 추적 파일 전체에서 참조를 다시 확인한 덕분에 기존 깨진 링크까지 고칠 수 있었다.


변경은 전부 작은 커밋으로 나눴다

사이트 전체를 한 번에 수정하면 문제가 생겼을 때 어느 변경 때문인지 찾기 어렵다. 이번 작업은 기능 단위로 커밋했다.

영역커밋
의존성 보안 업데이트6639443
포트폴리오 구조 개선d538e02
공개 포스트 로컬 조회6f1cda0
홈 Three.js 선택 로딩2de6d4a
SEO와 공유 이미지1101968
다국어와 읽기 시간f6099ab
접근성과 ESLintd5bc52f
보안 응답 헤더e49d7a8
예약 발행 안정화d29f30b
방명록 충돌 처리a45b7f3
게시글 이미지 복구a7d5c5c
미사용 이미지 정리bdfc1ee
프로젝트 상세 모달d5a73df

배포 후 문제가 생기면 필요한 작업만 되돌릴 수 있다.

# 예: 프로젝트 상세 모달만 되돌리기
git revert d5a73df
git push origin main

여러 커밋을 되돌릴 때는 최신 작업부터 역순으로 revert하는 편이 안전하다.


최종 결과

항목변경 전변경 후
포트폴리오 초기 HTML약 272KB약 167KB
포트폴리오 초기 이미지 태그약 50개13개
홈 초기 JavaScript약 1.245MB약 685KB
홈 HTML약 106KB약 76KB
글 목록 응답약 1.2~2.4초약 0.03초
ESLint오류·경고 존재0개
npm audit취약점 존재0개

마지막으로 아래 검증을 실행했다.

npm run lint
npm run build
npm audit --audit-level=moderate
git diff --check

Next.js 프로덕션 빌드에서 300개 페이지 생성이 끝났고, 파일 추적 경고도 더 이상 나오지 않았다. 방명록 API는 잘못된 Content-Type에 415, 잘못된 JSON에 400, 과대 요청에 413을 반환하는 것도 확인했다.

자동 브라우저 연결을 사용할 수 없어 상세 모달의 자동 시각 회귀 테스트는 하지 못했다. 이 부분은 실제 모바일 화면과 키보드 탐색으로 한 번 더 확인할 예정이다.


정리 — 기능을 더하는 것만큼, 언제 로드할지 정하는 게 중요했다

이번 점검에서 얻은 교훈은 다섯 가지다.

  1. 포트폴리오는 모든 내용을 처음부터 보여줄 필요가 없다. 요약으로 관심을 만들고 상세 정보는 요청할 때 보여주는 편이 낫다.
  2. DB가 없다고 공개 읽기까지 API에 의존할 필요는 없다. 쓰기는 GitHub API, 발행 콘텐츠 읽기는 빌드 파일로 분리할 수 있다.
  3. 재미있는 기능도 핵심 진입을 막으면 선택 기능으로 내려야 한다. 픽셀 마을은 자동 실행하지 않아도 충분히 존재감을 가질 수 있다.
  4. 자동화는 성공 경로보다 충돌과 덮어쓰기를 먼저 막아야 한다. 예약 발행과 JSON 방명록 모두 같은 문제였다.
  5. 큰 리팩터링일수록 커밋은 작게 나누는 게 좋다. 성능, SEO, 접근성, 운영 변경을 각각 되돌릴 수 있어야 안심하고 배포할 수 있다.

처음에는 포트폴리오 페이지만 조금 정리하려고 했다. 막상 전체 흐름을 따라가 보니 느린 글 목록, 자동으로 로드되는 Three.js, 빠진 SEO 메타데이터, 방명록 충돌 가능성까지 서로 연결되어 있었다.

사이트가 커질수록 새 기능을 하나 더 붙이는 일보다 이미 있는 기능이 언제, 왜, 어떤 비용으로 동작하는지 다시 보는 일이 중요해지는 것 같다.

PM

backtodev

A 40-something PM returns to code. Learning, failing, and growing.