Chaen
개인 포트폴리오 · 아티클 아카이빙 사이트
개인 포트폴리오 · 아티클 아카이빙 사이트
덧붙임 — 이력서·프로젝트 목록 PDF signed URL 발급 분리
포트폴리오 사이트를 만들기로 했을 때 가장 먼저 한 질문은 이 사이트가 나를 어떻게 소개해야 하는가 였습니다.
단순히 프로젝트 목록을 나열하거나, 인터랙션 하나로 시선을 끄는 것만으로는 부족하다고 생각했습니다. 첫 인상부터 실제 콘텐츠 탐색, 이력서 다운로드, 방문자와의 교류까지 — 하나의 흐름으로 연결된 경험을 만들고 싶었습니다.
그래서 Chaen은 포트폴리오이면서 동시에 블로그, 프로젝트 아카이브, 이력서 배포 페이지, 방명록, 관리자 에디터를 하나의 제품으로 통합한 풀스택 프로젝트입니다.
[IMAGE: 사이트 전체 구조 또는 스크린샷 — 히어로 / 프로젝트 / 아티클 / 이력서 섹션]
프론트엔드 Next.js 15 App Router · React 19 · TypeScript · Panda CSS · React Three Fiber · GSAP
백엔드/인프라 Supabase · next-intl · Vitest · Zod · pnpm · Husky · Commitizen
[IMAGE: 3D 히어로 씬 전체 뷰]
포트폴리오 사이트의 첫 화면의 목표는 3D 장면이 실제 웹 콘텐츠로 자연스럽게 이어지는 것이었습니다.
히어로 씬에는 캐릭터가 등장합니다. 평소엔 헤드 트래킹으로 마우스를 시선으로 따라가고, 일정 주기로 타이핑 애니메이션이 실행됩니다. 좌측 카메라 아이콘을 클릭하면 찰칵 소리가, 우측 기타 아이콘을 누르면 줄 하나가 울립니다.
스크롤을 내리면 카메라가 180도 회전하면서 캐릭터의 노트북 화면으로 시점이 전환됩니다. 노트북이 점점 확대되면서 그 화면이 실제 HTML 오버레이로 교체되고, 클릭 가능한 프로젝트 쇼케이스로 이어집니다.
[IMAGE: 스크롤 전환 시퀀스 — 3D 뷰 → 노트북 클로즈업 → HTML UI]
연출의 자연스러움보다 전환 타이밍에서의 인터랙션 정합성이 더 중요했습니다. 3D 씬이 페이드아웃되는 도중에 HTML UI가 클릭되면 안 되기 때문에, Web UI 레이어의 pointer-events를 opacity 임계값과 함께 제어해 전환 중인 구간에서 잘못된 상호작용이 일어나지 않도록 처리했습니다.
3D 렌더링 라이브러리로 순수 Three.js 대신 React Three Fiber(R3F) 를 선택했습니다.
이 프로젝트는 Next.js + React 기반으로 구성되어 있는데, 순수 Three.js는 Scene, Camera, Renderer 초기화부터 애니메이션 루프, 리사이즈 핸들링까지 모든 설정을 직접 관리해야 합니다. R3F는 이 보일러플레이트를 React 컴포넌트 모델로 추상화해 카메라·조명·오브젝트를 JSX로 선언하고, Canvas 외부의 React 상태와 3D 씬 간 동기화도 자연스럽게 처리할 수 있습니다. 이 프로젝트처럼 스크롤 진행도·UI 레이어 상태·3D 씬이 긴밀하게 연결되는 구조에서 특히 유효한 선택이었습니다.
[IMAGE: 텍스처 아틀라스 그룹 구분 / ORM 패킹 다이어그램]
3D 자산 최적화는 Blender 단계에서부터 시작됩니다.
Subdivision은 면의 개수를 실제로 4배씩 늘려 부드럽게 만들지만, 웹 환경에서 폴리곤 수는 직접적인 렌더링 비용입니다. Weighted Normal 모디파이어는 면을 늘리지 않고 노멀(법선) 방향만 재계산해, 로우폴리임에도 하이폴리처럼 매끈하게 보이게 만듭니다.
파츠마다 별도 머티리얼을 사용하면 GPU 호출(Draw Call)이 그만큼 늘어납니다. 관련 파츠를 용도별로 묶어 하나의 아틀라스 텍스처로 합쳐 Draw Call을 최소화했습니다.
Occlusion, Roughness, Metalness 텍스처를 각각의 이미지로 두는 대신 하나의 이미지 RGB 채널에 패킹했습니다. 텍스처 3장이 1장으로 줄어들어 GPU 메모리와 네트워크 전송량을 동시에 절감합니다.
같은 지오메트리가 여러 곳에 쓰이는 경우 인스턴싱 기법으로 GPU에 한 번만 올려 중복 연산을 제거했습니다.
Chaen은 ko / en / ja / fr 4개 언어를 지원합니다. 다국어 사이트에서 중요한 건 번역 문자열이 여러 개 있다는 사실보다, 번역 누락이나 경로 분기 때문에 사용자 경험과 검색 노출이 동시에 깨지지 않는 구조입니다.
콘텐츠는 구조로 설계했습니다. , , 는 공통 slug를 기준 식별자로 유지하고, 실제 제목과 본문은 같은 별도 테이블에서 locale별로 관리합니다. 요청이 들어오면 target locale → → → → 순서로 가장 적절한 번역을 선택합니다.
URL과 콘텐츠 참조는 안정적으로 유지하면서, 화면에 보여줄 제목·설명·라벨만 translation pool에서 선택합니다.
화면에 보이는 텍스트뿐 아니라 aria-label, placeholder, 오류 메시지, 스크린 리더용 상태 텍스트까지 전부 locale 자산으로 관리했습니다. 본문만 번역되고 안내 문구가 다른 언어로 남아 있으면 경험은 금방 조각납니다. 사용자가 "보는 언어"와 보조기기가 "읽는 언어"가 분리되지 않도록 한 것입니다.
각 페이지의 generateMetadata 단계에서 locale별 canonical과 hreflang alternate를 자동 계산하도록 구성했습니다. 번역 누락이 생기면 fallback locale을 canonical로 가리켜 duplicate content 가능성을 줄이면서도 사용자에게는 fallback 콘텐츠를 계속 제공합니다.
아티클 상세에는 BlogPosting, 프로젝트 상세에는 CreativeWork, 공통 breadcrumb에는 BreadcrumbList 스키마를 주입했습니다. 검색엔진이 HTML 파싱 외의 방식으로도 문서 타입·제목·게시 시점·페이지 계층을 명확하게 해석할 수 있도록 했습니다.
정적 XML 파일 대신 Next.js Metadata API 기반으로 sitemap과 robots를 코드에서 생성합니다. Supabase 데이터와 실제 URL 구조를 바로 반영하고, locale가 늘거나 데이터가 바뀌어도 sitemap이 자동으로 최신 상태를 유지합니다. 관리자·콜백·게스트북 경로는 noindex, nofollow로 명시적으로 차단했습니다.
[IMAGE: 성능 개선 전후 비교 — 체감 로딩 타임라인 또는 Waterfall 캡처]
목록에서 아티클 상세로 이동할 때 체감 대기 시간이 약 5초에 달하는 경우가 있었습니다. 문제는 fetch 하나가 느린 게 아니었습니다.
상세 페이지의 레이아웃 구조상 본문 외에도 한 경로에서 처리해야 할 것들이 많았습니다. 좌측 아카이브 목록, 태그 라벨, 관련 글, 댓글 첫 페이지, route 단계의 인증 판별이 한 번에 올라오는 구조였고, 여기에 다국어 locale fallback 처리까지 더해졌습니다. 아카이브 목록은 무한 스크롤 구조라 첫 페이지 데이터도 서버에서 미리 준비해야 했습니다. 모든 조각이 동일한 우선순위로 준비될 때까지 사용자가 기다리는 구조였습니다.
전체 콘텐츠를 빌드 시 prebuild하지 않고, locale별 대표 slug 하나만 seed로 제공합니다. 빌드 시간은 유지하면서 동일 경로 재진입 시 SSR 부담을 줄이는 방식입니다. 콘텐츠가 계속 추가되는 환경에서 빌드 비용과 첫 요청 성능을 동시에 관리하기 위한 선택이었습니다.
본문 shell에 필요한 최소 데이터와 늦게 와도 되는 보조 데이터를 나눴습니다. 아카이브 목록, 태그, 관련 글은 promise를 시작한 뒤 section-level Suspense fallback으로 넘겨, 본문 구조가 먼저 보이고 나머지가 후행 로딩되도록 바꿨습니다.
[IMAGE: 렌더 타이밍 분리 전후 — 본문 먼저 / 보조 섹션 후행]
댓글은 본문과 완전히 다른 캐시 전략이 필요한 영역입니다. 브라우저 세션 범위 캐시로 이동해 본문 진입을 막지 않도록 했습니다. 같은 글을 다시 열면 첫 페이지를 클라이언트 메모리 캐시에서 재사용하고, 생성/수정/삭제 이후에는 fresh 재조회로 덮어씁니다.
중복 auth 조회를 제거하고, 인기 태그 목록·태그 라벨 같은 독립적인 조회는 use cache 기반 cached read로 묶어 병렬로 처리했습니다.
체감 속도는 약 5초에서 0.5~1초 수준으로 줄었습니다. 수치보다 더 달라진 건 "주변이 계속 늦게 붙는 느낌"이 사라지고 본문이 먼저 안정적으로 보인다는 점이었습니다.
이 구조가 단계적으로 정리될 수 있었던 것은 route, view, entity, feature의 책임이 FSD 관점으로 나뉘어 있었기 때문입니다. 한 덩어리의 거대한 파일을 뒤집는 방식이 아니라, 병목을 만드는 레이어를 하나씩 분리하며 진행할 수 있었습니다.
같은 맥락에서, 이력서와 프로젝트 목록 페이지도 정리했습니다. 원래 페이지 렌더링 시점에 Supabase signed URL을 함께 생성하고 있었는데, 이 방식은 HTML 응답 전체를 짧은 만료 시간을 가진 URL에 의존하게 만들어 페이지를 force-dynamic으로 유지해야 하는 원인이었습니다.
"파일이 존재하는지 여부"만 렌더링 시점에 확인하고, 실제 signed URL은 사용자가 버튼을 클릭할 때 내부 API route에서 발급하도록 분리했습니다. 덕분에 이력서 페이지와 프로젝트 목록 모두 revalidate 기반 캐시가 가능한 구조로 바뀌었습니다.
[IMAGE: 커서 기반 목록 탐색 흐름 또는 아카이브 UI]
콘텐츠가 늘어날수록 목록 탐색은 단순한 리스트 렌더링 문제가 아니라 정보 구조 문제가 됩니다. 기존 Offset 방식은 두 가지 문제를 안고 있었습니다. 페이지가 뒤로 갈수록 DB가 앞의 데이터를 모두 읽고 버려야 하는 O(N) 성능 저하, 그리고 사용자가 스크롤하는 도중 새 글이 올라오면 피드가 튀는 중복 노출입니다.
publish_at + id 조합을 불투명(Opaque) 커서로 직렬화해 절대 좌표 기반 페이지네이션을 구현했습니다. "특정 시점 이전"이라는 고정된 기준으로 조회하기 때문에 실시간으로 글이 업데이트되어도 피드 흐름이 끊기지 않고, 인덱스를 타고 필요한 데이터만 조회하므로 데이터 규모와 무관하게 O(log N) 성능을 유지합니다.
검색 결과에도 같은 커서 구조를 확장했습니다. 검색어가 있을 때는 연관도(rank) + publish_at + id 3단계 정렬 좌표를 커서로 직렬화해, 관련도순 정렬을 유지하면서도 페이지 경계에서 중복/누락이 발생하지 않도록 했습니다.
검색 성능은 tsvector 컬럼을 DB 트리거로 사전 계산해 GIN 인덱스를 타도록 구성했습니다. 검색 요청마다 CPU를 쓰는 대신, 데이터 생성/수정 시 미리 지문을 만들어 두는 방식입니다. 제목(가중치 A)과 설명(가중치 B)에 차등 점수를 부여해 검색 정확도도 함께 개선했습니다.
커서 기반 URL은 상태값을 포함하기 때문에 그대로 두면 검색 색인에 부정적인 영향을 줍니다. noindex, follow와 Canonical 태그를 커서 URL에 적용해 크롤러가 중복 색인하지 않도록 했고, 동시에 rel="prev" / rel="next" 메타데이터를 생성해 봇은 정적인 페이지 링크를 따라갈 수 있는 구조를 유지했습니다.
keyset은 page 번호만으로 목표 위치를 복원할 수 없습니다. ?page=N 형태의 수동 deep-link는 cursor 없이 진입하는 경우 notFound()로 처리해 서버가 중간 페이지를 직렬 복원하는 비용을 없앴습니다.
상세 페이지 좌측 아카이브는 현재 보고 있는 항목을 목록에 끼워 넣어 사용자가 목록 맥락을 잃지 않도록 했습니다. 이때 nextCursor도 실제 마지막 렌더링 아이템 기준으로 다시 계산해 다음 페이지가 누락 없이 이어지도록 처리했습니다.
sentinel이 초기에 뷰포트에 보인다는 이유만으로 의도하지 않은 추가 요청이 발생하지 않도록, 사용자가 실제로 스크롤 의도를 보인 이후에만 자동 로드가 동작하도록 처리했습니다.
[IMAGE: 커서 직렬화 구조 또는 FTS 트리거 다이어그램]
인터랙션이 많은 사이트일수록 접근성 품질은 작은 실수의 누적으로 무너집니다. 접근성을 마감 체크리스트가 아닌 인터랙션 설계와 함께 올라가는 품질 기준으로 다뤘습니다.
진입 시 첫 포커스 요소로 이동, Tab 순환, Escape 시 닫기, 닫힐 때 이전 포커스 복귀까지 처리하는 공용 훅을 만들었습니다. 컴포넌트 하나하나를 개별 대응하는 대신 공용 레이어에 모아, 인터랙션이 늘어나도 포커스 흐름이 일관되게 유지되도록 했습니다.
검색 폼에는 , , 상태 텍스트를 적용해 시각적으로 보이지 않는 진행 상태까지 전달했습니다. 스포일러, 태그 필터, 페이지네이션, 액션 메뉴에도 , , focus-visible 스타일을 일관되게 적용했습니다.
아이콘이 장식용인지 의미 전달용인지에 따라 과 을 구분해 처리하는 공용 아이콘 래퍼를 만들었습니다. , , 에 연결되는 스크린 리더용 문구, keyboard shortcut 힌트, input validation message까지 전부 locale 단위로 관리했습니다. 사용자가 "보는 언어"와 보조기기가 "읽는 언어"가 분리되지 않도록 한 것입니다.
Chaen은 개인 포트폴리오를 넘어, 제가 어떤 기준으로 프론트엔드 제품을 설계하는지를 보여주는 프로젝트입니다.
인터랙션을 좋아하지만, 인터랙션 자체만으로 프로젝트를 설명하고 싶지는 않습니다. 좋은 인터랙션은 성능, 접근성, 정보 구조, 검색 친화성, 운영성까지 함께 설계될 때 비로소 제품 경험이 됩니다. Chaen은 그 관점을 가장 집약적으로 담은 작업입니다.
[IMAGE: 최종 사이트 전체 스크롤 또는 주요 화면 모아보기]
히어로 씬 (3D Canvas)
└─ 스크롤 진행
└─ 카메라 180° 회전 (GSAP ScrollTrigger)
└─ 노트북 화면 확대
└─ HTML Overlay 페이드 인 (pointer-events 전환)
└─ 프로젝트 쇼케이스articlesprojectstagsarticle_translationskoenjafr// publish_at + id 조합을 URL-safe base64url 커서로 직렬화
export const serializePublishedAtIdCursor = ({ id, publishedAt }: PublishedAtIdCursor): string =>
Buffer.from(JSON.stringify({ id, publishedAt }), 'utf-8').toString('base64url');
// referenced table에 keyset 조건과 공개 상태 필터를 한 번에 조합
export const buildReferencedPublicContentFilter = ({
cursor, nowIsoString,
}: { cursor?: PublishedContentCursor | null; nowIsoString: string }) => {
const publishWindowCondition = `publish_at.lte.${nowIsoString}`;
if (!cursor) return publishWindowCondition;
return [
`and(${publishWindowCondition},publish_at.lt.${cursor.publishedAt})`,
`and(${publishWindowCondition},publish_at.eq.${cursor.publishedAt},id.lt.${cursor.id})`,
].join(',');
};-- 데이터 저장 시 자동으로 검색 지문 생성 (트리거)
CREATE OR REPLACE FUNCTION articles_fts_trigger() RETURNS trigger AS $$
BEGIN
NEW.fts_vector :=
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;role="search"aria-busyaria-livearia-expandedaria-currentaria-hiddenrolearia-labelaria-describedbyaria-live