

흩어져 있던 프로젝트와 글을 한 곳에서 보여 주고 계속 갱신하기 위해 만든 개인 포트폴리오입니다. 공개 페이지, 디자인 시스템, 글 편집기와 관리자 CMS 까지 한 사람이 설계하고 운영합니다.
LCP 9.7초→2.7초, HYEONIVERSE 포트폴리오
설계부터 운영까지 직접 수행한 포트폴리오 사이트입니다. 감으로 고치지 않고, 측정 데이터를 기준으로 문제를 추적했습니다.

혼자 설계하고, 만들고, 운영하는 포트폴리오 사이트입니다. 검토해 줄 사람도 실수를 대신 되돌려 줄 사람도 없었기 때문에, 화면을 완성하는 데서 끝내지 않고 실제 서비스와 같은 기준으로 성능·권한·데이터·운영 구조까지 직접 설계했습니다. 특히 집중한 기준은 세 가지입니다.
항목 | 내용 |
|---|---|
기간 · 인원 | 2026.02 ~ 운영 중 · 1인 |
역할 | 기획, UI·디자인 시스템, 프론트엔드, 백엔드(Supabase), 배포·운영 |
스택 | Next.js 16 · React 19 · TypeScript · Supabase(PostgreSQL) · Vercel |
사이트 | hyeoniverse.com |
코드 | github.com/hyeoniverse/web-portfolio-hyeoniverse (공개) |

포트폴리오 사이트이지만 공개 페이지와 관리자 페이지를 나눠, 콘텐츠를 직접 쓰고 운영할 수 있는 구조로 만들었습니다.


권한은 네 단계로 나눴습니다. 역할은 로그인 토큰(JWT)의 app_metadata 에 두어 사용자가 직접 바꿀 수 없고, 사이트 설정과 저자 관리는 소유자만 할 수 있습니다.


공개 페이지는 방문할 때마다 DB 를 조회해 화면을 만드는 대신 ISR(증분 정적 재생성) 을 썼습니다. 기본은 60초마다 다시 만들고, 관리자가 콘텐츠를 고치면 저장 API 가 관련 경로를 직접 무효화합니다.
방식 | 장점 | 한계 |
|---|---|---|
요청마다 서버 렌더링 | 항상 최신 | 방문마다 DB 조회 · 느린 첫 응답 |
완전 정적 생성 | 가장 빠름 | 고칠 때마다 다시 배포 |
ISR + 경로 재검증 | 미리 만든 페이지로 빠르게 응답 | 무엇을 다시 만들지 직접 정해야 함 → 아래처럼 해결 |
작업물 하나를 고치면 그 상세 페이지만 바뀌는 게 아닙니다. 작업물 목록, 이웃 상세의 이전·다음 작업물 제목, 같은 기술 태그 페이지, 글 안의 관련 작업물 카드까지 영향을 받습니다. 그래서 저장된 데이터가 어디에서 쓰이는지 따라가며 필요한 경로만 다시 만들게 했습니다.
export function revalidatePublicWorks() {
revalidatePath("/works"); // 작업물 목록
revalidatePath("/works/[slug]", "page"); // 이웃 상세의 이전·다음 제목
revalidatePath("/posts/tags/[tag]", "page"); // 같은 기술 태그
revalidatePath("/posts/[slug]", "page"); // 글 속 관련 작업물 카드
}
이 구조로 공개 페이지의 첫 응답은 가볍게 유지하면서, 콘텐츠가 바뀌면 기다리지 않고 관련 화면만 갱신합니다. 항상 새로 가져오는 방식과 미리 만들어 두는 방식 사이에서, 필요한 부분만 갱신하는 구조 를 택했습니다.
가장 많은 시간을 들인 부분은 기능 자체보다, 실제로 쓰면서 발견한 문제를 원인까지 추적하는 과정이었습니다.
모바일에서 /profile 의 LCP 가 9.7초, Lighthouse 성능 점수가 50점 까지 떨어졌습니다.
이미지나 네트워크가 느린 문제로 보일 수 있었지만, 실제 측정 결과는 달랐습니다.
첫 문장은 약 2.5초에 이미 화면에 나타났는데, 같은 문장에 LCP 가 6~7초에 다시 기록됐습니다. 이미 그려진 요소가 다시 만들어지고 있다고 보고 DOM 변화를 추적하자, 로딩 직후 <main> 아래 화면 전체가 빠졌다가 다시 생성되고 있었습니다.
원인은 BreakpointGuard 였습니다. 브레이크포인트 이름을 자식 요소의 key 로 썼는데, 모바일에서는 첫 렌더 직후 값이 기본값("desktop-tall")에서 실제 화면 크기에 맞는 값으로 바뀌었습니다. key 가 바뀌자 React 가 기존 자식 트리를 지우고 새로 만들었습니다.
브레이크포인트 이름 대신, 실제로 경계를 넘었을 때만 증가하는 값 을 key 로 썼습니다. 첫 렌더에서 값이 확정되는 것만으로는 다시 만들지 않고, 다른 레이아웃 구간으로 넘어갔을 때만 리마운트합니다.
- const [activeBreakpoint, setActiveBreakpoint] = useState("desktop-tall");
+ // 자식의 key. 경계를 넘을 때마다 1씩 올려 통째로 다시 만든다.
+ const [generation, setGeneration] = useState(0);
...
- <div key={activeBreakpoint}>{children}</div>
+ <div key={generation}>{children}</div>

Lighthouse 성능 50점 → 77점, LCP 9.7초 → 2.7초.
참고 PR #792
작업물 편집기에서 글자를 칠 때마다 버벅였습니다. 같은 편집기를 쓰는 새 글 화면과 비교하면 한 글자에 걸리는 시간이 약 2.5배 였습니다.
글자 51개를 90ms 간격으로 입력하며(CPU 4배 느리게, 3회 중앙값) 성능 추적과 React 프로파일러를 함께 봤습니다. 원인은 세 가지로 좁혀졌습니다.
세 번째는 React 프로파일러만으로는 보이지 않았습니다. CSS 규칙을 하나씩 지워 가며 좁힌 결과, 칩 컴포넌트의 :has() 와 :hover 조합이 원인이었습니다. :has() 안에 :hover 가 있고 뒤에 * 가 오면, 포인터 아래 요소가 바뀔 때마다 브라우저가 페이지 전체의 스타일을 다시 계산합니다.
/* 원인 — 포인터가 움직일 때마다 페이지 전체 스타일 재계산 */
.chip:has([data-close-trigger]:hover) .label * {
text-decoration: line-through;
}
섹션마다 메모이제이션을 적용하고, 내용이 변하지 않았으면 이전 배열을 그대로 넘기게 했습니다. CSS 는 :has(:hover) 를 없애고, × 버튼의 hover 상태를 칩의 데이터 속성으로 전달했습니다. 이렇게 React 렌더링과 브라우저의 스타일 계산을 각각 줄였습니다.
/* 해결 — × 버튼에 포인터가 들어오고 나갈 때 칩에 속성을 붙인다 */
.chip[data-remove-hover] .label * {
text-decoration: line-through;
}

입력 지연 상위 10% 120ms → 72ms, 51자 입력 동안 React 렌더링 387ms → 98ms, 페이지 전체 스타일 재계산 10회 → 0회.
관리자 글 목록의 ?all=true API 가 로그인 여부를 확인하지 않아, 주소만 알면 비공개 글을 읽을 수 있었습니다.
관리자 기능은 service_role 키로 DB 의 RLS 를 통째로 우회하고 있었습니다. 권한 검사가 API 코드에만 있으니, 코드 한 곳이 실수하면 비공개 데이터까지 조회되는 구조 였습니다.
권한의 최종 판단을 DB 가 맡도록 바꿨습니다. 역할은 JWT 의 app_metadata 에 두고, PostgreSQL 이 토큰에서 역할을 읽어 RLS 정책으로 행마다 접근 여부를 판정합니다. 네 가지 역할을 두고 29개의 RLS 정책 으로 읽기·쓰기·수정·삭제 권한을 나눴습니다.

-- 토큰의 역할을 읽는 판정 함수
CREATE FUNCTION app_role() RETURNS text LANGUAGE sql STABLE AS $$
SELECT coalesce(auth.jwt() -> 'app_metadata' ->> 'role', '');
$$;
-- 글 수정은 편집 권한이 있는 사람만
CREATE POLICY posts_admin_update ON posts FOR UPDATE
USING (can_edit_post(author_ids))
WITH CHECK (can_edit_post(author_ids));
코드 한 곳에서 권한 검사를 빠뜨려도, DB 정책을 통과하지 못하면 데이터에 접근할 수 없습니다. 없는 글과 권한 없는 글도 구분해 정확한 상태 코드를 돌려줍니다.
참고 PR #606
권한만큼 삭제 방식도 다시 설계했습니다. 일반 삭제는 행을 없애는 대신 지운 시각만 기록하는 soft delete 로 처리해 관리자 화면에서 복구할 수 있고, 영구 삭제는 별도 API 로 분리했습니다.
// 삭제 — 행은 남기고 지운 시각과 보관 기한만 기록
.update({ deleted_at: now, purge_after: addDays(now, 30) })
// 복구 — 기록한 시각을 지우면 목록에 다시 나타난다
.update({ deleted_at: null })
예약 발행과 오래된 삭제 데이터 정리는 인터넷에 열린 cron API 를 두는 대신 DB 안의 pg_cron 으로 자동 실행하고, 작업이 실패하면 관리자 알림으로 확인할 수 있게 했습니다.
SELECT cron.schedule(
'publish-scheduled', '* * * * *', -- 매분
$cron$ SELECT safe_publish_scheduled(); $cron$ -- 실패하면 알림을 남기는 함수
);
항목 | 내용 |
|---|---|
권한 판정 위치 | API 코드 → DB(RLS 정책 29개) |
역할 | 소유자 · 관리자 · 저자 · 방문자 4단계, JWT |
삭제 | soft delete · 휴지통 복구 · 영구 삭제는 별도 API |
정기 작업 |
|
목록에서 상세로 이동하는 전환 중에, 효과가 먼저 끝난 뒤에도 이전 페이지가 화면에 드러났습니다. 오버레이는 약 640ms 에 걷혔지만 실제 라우트 커밋은 약 1161ms 에 일어나, 그 사이 약 500ms 동안 이전 화면이 보였습니다.
기존 구조는 애니메이션 시간 을 기준으로 화면을 가렸습니다. 하지만 실제 페이지가 바뀌는 시점은 새 페이지 데이터를 받아 오는 네트워크와 렌더링 상태에 따라 매번 달라서, 애니메이션 시간으로 전환이 끝났다고 판단할 수 없었습니다.
시점 | 기존 | 변경 |
|---|---|---|
0ms | 애니메이션 시작 | 애니메이션 시작 · 라우트 이동 바로 시작 |
640ms | 오버레이 제거 → 이전 화면 노출 | 오버레이 유지 (새 화면 대기) |
1161ms | 라우트 커밋 · 새 화면 | 라우트 커밋 확인 → 오버레이 제거 |
오버레이를 걷는 시점을 고정된 시간에서 실제 라우트 커밋 시점 으로 바꿨습니다. 오버레이 안에서 현재 경로를 구독하다가 실제로 바뀐 뒤에 걷습니다. 또한 애니메이션을 시작하자마자 라우트 이동도 시작해, 이동과 화면 전환이 겹쳐 진행되게 했습니다.
전환 중간에 이전 페이지가 드러나지 않고, 이동과 애니메이션이 동시에 진행돼 기다리는 구간도 줄었습니다.
참고 PR #1049
성능과 데이터 구조만큼 화면의 움직임도 직접 설계했습니다. 특히 About 페이지는 일반적인 세로 스크롤 대신 스크롤 위치를 장면의 진행도로 쓰는 방식 으로 구현했습니다.
About 은 14개 패널을 가로로 넘겨 보는 구조입니다. 패널이 화면 끝에서 얼마나 떨어져 있는지로 장면의 진행도를 0~1 로 바꾸고, 이 값으로 전환을 계산합니다.

전환은 transform 하나가 아니라 여러 요소를 조합했습니다. 진행도를 코사인 곡선 0.5 − cos(πt)/2 로 부드럽게 만들고, 들어오는 패널은 clip-path 로 보이는 영역을 넓히며, 나가는 패널은 조금 작아지고 어두워져 뒤로 물러납니다. 장면 종류는 data-cut-kind 로 나눠 같은 전환 로직 안에서 서로 다른 효과를 냅니다.
컷 | clip-path | 쓰인 곳 |
|---|---|---|
iris |
| Hello → Overview |
wipe |
| 기본 순환 |
rise |
| Overview → Architecture |
slide | 패널째 아래에서 밀어 올림 | Build Process → Tech Stack |
전환 도중에 휠을 멈추면 반쯤 걷힌 화면이 남기 때문에, 굴리던 방향으로 조금이라도 진행했으면 끝까지 넘깁니다. 이때 지금 속도를 이어받아 도착점에서 속도 0 으로 멈추는 3차 에르미트 곡선 으로 마무리합니다.
// u: 0 → 1, v: 이어받은 속도(거리 단위), dist: 남은 거리
state.scrollX = from + (u3 - 2 * u2 + u) * v + (-2 * u3 + 3 * u2) * dist;
패널 안 연출은 JavaScript 가 진행도만 CSS 변수(--item-p · --item-in · --item-out)로 넘기고, 세부 움직임은 CSS 가 계산합니다. 스크롤마다 React 상태를 바꾸지 않아 컴포넌트가 다시 렌더되지 않습니다.
/* User Flow — 노드마다 순서(--s)가 있어 앞 노드부터 차례로 그려진다 */
--_r: clamp(0, calc(var(--item-p, 1) * var(--n, 1) * 1.5 - var(--s, 0)), 1);
opacity: calc(1 - var(--item-out, 0));
가로 스크롤 영역에서 휠을 굴리면 가로 이동과 세로 페이지 스크롤이 동시에 일어났습니다. 휠 한 번에 트랙이 가로로 51px 가는 동안 페이지는 세로로 98px 밀렸습니다.
가로 영역은 preventDefault() 로 세로 스크롤을 막고 있었지만, 부드러운 스크롤 라이브러리 Lenis 는 페이지 전체의 휠 이벤트를 자기 리스너에서 받아 직접 페이지를 굴립니다. preventDefault() 는 브라우저 기본 동작만 막을 뿐이라 Lenis 는 멈추지 않았습니다.

그래서 휠 이벤트의 소유권을 나눴습니다. 가로 영역이 휠을 가져갈 때만 data-lenis-prevent-wheel 표시를 켜 Lenis 가 물러나게 하고, 가로 스크롤이 끝에 닿으면 표시를 떼서 세로 스크롤이 이어받게 했습니다.
const take = aligned && !atHorizontalEdge(e.deltaY);
// Lenis 는 defaultPrevented 를 보지 않는다. 물러나게 하는 수단은 이 표시뿐이다
section.toggleAttribute("data-lenis-prevent-wheel", take);
if (!take) return;
e.preventDefault();
가로로 넘기는 동안 세로 화면은 움직이지 않고(98px → 0), 끝에 닿으면 세로 스크롤로 자연스럽게 이어집니다. 작업물 목록의 가로 스크롤에도 같은 방식을 적용했습니다.
참고 PR #1050
언어와 테마는 브라우저에 저장된 값이라 서버는 사용자의 실제 설정을 알 수 없습니다. 서버가 한국어로 HTML 을 만든 뒤 브라우저가 저장된 영어 설정을 읽으면, 서버 HTML 과 첫 렌더링 결과가 달라 하이드레이션 불일치가 생기고 화면이 한 번 다시 그려집니다.
그래서 언어와 테마를 Context 값이 아니라 useSyncExternalStore 로 구독하는 저장소로 분리했습니다. 서버와 첫 렌더는 같은 기본값으로 HTML 을 맞추고, 그 뒤 브라우저의 저장값을 읽어 필요한 컴포넌트만 다시 그립니다.
모바일 레이아웃도 화면 크기가 바뀔 때마다 다시 만들지 않고, 실제 브레이크포인트 경계를 넘을 때만 다시 만듭니다(2.1.).
포트폴리오를 정적 페이지로 두지 않고, 콘텐츠를 계속 추가하고 고칠 수 있는 관리 시스템으로 만들었습니다.

작업물과 글은 블록 단위로 씁니다. Plate.js 를 기반으로 표, 열, 콜아웃, 토글, 코드, 수식, 다이어그램, 투표, 달력, 파일 첨부 블록을 쓸 수 있습니다. 표 기능 때문에 TipTap 에서 Plate.js 로 옮겼고, 필요한 블록은 직접 노드로 정의했습니다. 편집 화면과 게시 화면이 같은 데이터 구조와 직렬화기를 써서, 편집기에서 본 모양이 발행 뒤에도 그대로 나옵니다.
작성 중인 내용을 잃지 않도록 자동 저장을 만들었습니다. 브라우저 저장소에는 즉시 쓰고, 입력이 3초 멈추면 서버에 수정본을 남기며, 페이지를 떠날 때는 sendBeacon 으로 마지막 변경을 보냅니다.

더 중요한 문제는 동시에 저장할 때 오래된 내용이 최신 내용을 덮어쓰는 것 이었습니다. 글마다 버전 번호를 두고, 글을 저장할 때 요청이 알고 있는 버전과 지금 버전이 같은지 확인합니다. 다르면 저장을 거부하고 409 Conflict 를 돌려줍니다. 확인과 저장이 한 문장 안에서 일어나 사이에 끼어들 틈이 없습니다.
.update({ ...body, version: baseVersion + 1 })
.eq("id", id)
.eq("version", baseVersion) // 그 사이 다른 곳이 저장했으면 0행 → 409
복원도 브라우저에 남은 초안이 마지막 저장본보다 새로울 때만 합니다.
사용자가 직접 입력하는 값은 서버에서 다시 검증합니다. 클라이언트에서 검증한 값을 그대로 믿지 않고, 데이터가 저장되는 마지막 단계에서도 다시 확인하는 구조 를 유지했습니다.
방어 | 내용 |
|---|---|
형식 · 길이 | UUID 형식, 길이 제한(댓글 2000자 · 비밀번호 72바이트 · 이메일 254자), 허용 목록 기반 값 검증 |
중복 | 좋아요 · 투표 · 조회 · 방문을 UNIQUE 인덱스로 DB 가 거절 |
익명 댓글 | 수정 · 삭제는 bcrypt 로 저장한 비밀번호로만 |
개인 정보 | 응답에 원문 IP 를 싣지 않고, 방문 기록의 IP 는 90일 뒤 익명화 |
비밀 값 | 외부 API 키는 DB 에 암호화 저장, service role 키는 서버에서만 사용 |
한 번의 큰 최적화보다, 실제 측정 결과를 기준으로 작은 문제를 반복해서 고쳤습니다.

모든 최적화가 좋은 결과를 낸 것은 아니었습니다. 같은 조건에서 다시 재서 전체적으로 손해면 되돌렸습니다.
시도 | 결과 | 판단 |
|---|---|---|
3D 컴포넌트 지연 로딩 | LCP 는 좋아졌지만 Speed Index 2.6초 → 8.4초, 점수 58 → 51 | 3D 는 화면의 큰 부분이라 늦추면 오래 미완성으로 보임 → 되돌림 |
CSS 를 작은 파일로 분리 | 홈 CSS 494KB → 212KB, 그러나 글 목록 69 → 58, 프로필 53 → 48 | JS 묶음이 나빠져 입력 반응 악화 → 되돌림 |
제목 글꼴 preload 제거 | Home 76 → 85, 그러나 About 82 → 66 | About 의 LCP 가 그 글꼴로 쓴 제목 → 원상 복구 |
이 프로젝트에서는 "최적화했다" 보다 "변경 전후를 같은 기준으로 비교했다" 를 더 중요하게 봤습니다.
혼자 개발하는 프로젝트라, 다른 사람이 리뷰하지 않아도 변경을 다시 확인할 수 있는 방식을 만들었습니다.


처음 3개월은 기능을 빠르게 추가했습니다. 기능이 늘자 한 곳을 고치면 다른 곳이 깨지는 일이 생겨, 이후 약 2개월 동안 새 기능을 멈추고 구조를 정리 했습니다. 거대한 파일을 기능 단위로 나누고, 반복되던 화면을 공통 컴포넌트로 모으고, 규칙을 린트 오류로 올렸습니다.
API 라우트 104개의 응답 방식을 한꺼번에 통일하기 전에는, 라우트를 실제로 호출해 지금의 상태 코드와 응답 모양을 고정하는 안전망 테스트 119건 을 먼저 만들었습니다. 정리하는 동안 응답이 바뀌면 바로 걸리게 하기 위해서입니다. 기능을 계속 더하기보다 바꿔도 깨지지 않는 상태를 만드는 것 에 시간을 쓴 시기였습니다.
참고 PR #697
화면마다 스타일을 따로 만들지 않고, 색·글꼴·간격을 토큰으로 관리했습니다.

Raw 는 실제 색과 간격 값, Semantic 은 배경·글자·테두리 같은 역할, Component 는 버튼처럼 특정 UI 에 필요한 값입니다. 다크 테마는 화면마다 색을 바꾸지 않고 Semantic 토큰의 값만 교체합니다.
/* 컴포넌트는 역할 이름만 알고 실제 색은 모른다 */
:root { --text-muted: var(--color-neutral-600); }
[data-theme="dark"]{ --text-muted: var(--color-neutral-500); }
.caption { color: var(--text-muted); }
색은 사람이 느끼는 밝기를 더 고르게 다루는 oklch 로 정의하고, 글자와 배경 조합은 WCAG 기준(본문 4.5 : 1)으로 대비를 확인했습니다.

규칙 | 이유 |
|---|---|
마젠타는 한 화면에 한 역할만 | 링크 · 주요 버튼 · 현재 위치 중 하나에만 씁니다. |
작은 글자에는 더 진한 마젠타 | #D10061 은 바탕 위 5.00 : 1 로 여유가 없어, 13px 이하에는 7.29 : 1 인 #A3004E 를 씁니다. |
다크에서는 마젠타를 밝게 | #D10061 은 어두운 바탕에서 3.22 : 1 이라 #FF4DA6(5.68 : 1)으로 바꾸고, 버튼 글자는 검정입니다. |
상태 원색은 작은 글자에 쓰지 않기 | 경고 #DA950B 는 밝은 바탕에서 2.35 : 1 이라, 글자에는 한 단계 진한 색을 씁니다. |

HYEONIVERSE 는 완성된 화면을 보여 주는 데서 끝나지 않고, 직접 콘텐츠를 만들고 배포하고 운영하며 생기는 문제를 계속 고칠 수 있는 서비스 가 됐습니다.
영역 | 결과 |
|---|---|
성능 | Lighthouse 성능 97 · 접근성 100 · SEO 100 · LCP 0.7초 · TBT 20ms · 레이아웃 밀림 0 |
구조와 데이터 | 공개 / 관리자 분리 · 권한 4단계 · RLS 정책 29개 · soft delete 와 복구 · 동시 저장 충돌 감지 · 자동 저장 |
콘텐츠와 인터랙션 | 한국어 / 영어 · 블록 기반 편집기 · 14개 패널 장면 전환 · 가로·세로 스크롤 충돌 해결 · 다크 모드 · 반응형 |
개발 품질 | 단위 테스트 816개 · API 안전망 119건 · Playwright E2E · 자동 검사 · Issue → Branch → PR · 측정 기반 개선과 되돌리기 |

이 프로젝트에서 가장 중요했던 것은 기술을 많이 썼다는 사실보다, 문제를 발견하고 원인을 확인한 뒤 고치고 다시 재는 과정을 혼자서도 반복할 수 있는 구조를 만든 것 입니다.
보여 주기 위한 결과물에 머무르지 않고, 실제 서비스와 같은 기준으로 설계하고, 운영하며, 개선하였습니다.
본문에서 흐름을 끊지 않으려고 뺀 자료입니다. 기술별 사용 방식, 데이터와 API 구조, 보안 규칙, 설계 판단 기록, 참고 PR 을 모았습니다.
각 기술을 고른 이유와, 그 기술의 어떤 기능을 어디에 썼는지 정리했습니다. 본문에서 자세히 다룬 부분은 링크로 이어 두었습니다.

테이블은 25개입니다. 글과 작업물이 중심에 있고, 댓글·반응·조회 기록이 그 둘에 붙습니다. 운영 기록과 설정은 따로 떨어져 있습니다. 한 글이 초안에서 영구 삭제까지 거치는 상태는 별도 테이블 없이 컬럼 하나씩으로 표현합니다.
글 테이블에는 운영에 필요한 상태를 컬럼으로 두었습니다. 한국어와 영어는 행을 나누지 않고 같은 행의 _en 컬럼으로 둬서, 한 글의 두 언어가 항상 함께 저장되고 함께 지워집니다.
-- posts 일부
title text, title_en text, -- 언어별 필드는 같은 행에
version integer NOT NULL DEFAULT 1, -- 저장할 때마다 +1, 다르면 409
scheduled_at timestamptz, -- 지나면 cron 이 발행
deleted_at timestamptz, -- 값이 있으면 휴지통
purge_after timestamptz, -- 지나면 cron 이 영구 삭제
-- 휴지통에 있는 행만 인덱스
CREATE INDEX posts_purge_after_idx ON posts (purge_after)
WHERE deleted_at IS NOT NULL;
pg_cron 작업 | 주기 | 하는 일 |
|---|---|---|
| 매분 |
|
| 매일 03:00 |
|
| 매일 03:30 | 90일이 지난 방문·조회 기록의 IP 를 익명화 |

API 라우트는 현재 113개입니다. 관리자 전용 라우트는 /api/admin 아래에 모아, 경로만 보고도 권한이 필요한 요청인지 알 수 있게 했습니다.
모든 /api/* 의 POST · PATCH · PUT · DELETE 는 proxy.ts 에서 Origin 이 이 사이트인지 먼저 확인합니다. 응답은 공통 오류 형식과 상태 코드 규칙으로 통일했고, 그 전에 안전망 테스트 119건으로 기존 동작을 고정했습니다(6장).

요청이 지나가는 순서대로 층마다 한 번씩 막습니다. 앞 층이 뚫려도 뒤 층이 남도록 한 곳에만 기대지 않았습니다.
층 | 규칙 |
|---|---|
브라우저 | 사용자 입력은 React 가 이스케이프하고, 서버에서도 HTML 태그와 제어 문자를 지웁니다. |
경로 |
|
API | Origin 검사로 다른 사이트의 쓰기 요청을 막습니다. UUID 형식, 길이(댓글 2000자, 이메일 254자, 비밀번호 72바이트), enum, 카테고리 목록을 검사합니다. 익명 댓글 수정은 bcrypt 비밀번호 한 경로로만 허용합니다. |
DB | 모든 테이블에 RLS 를 켜고, 좋아요 · 투표 · 조회 · 방문은 UNIQUE 인덱스로 중복을 막습니다. 쿼리는 파라미터로만 값을 넘깁니다. |
비밀값 | 외부 API 키는 |
선택지가 둘 이상이었던 결정은 배경, 검토한 방법, 결정, 결과 순서로 기록해 About 에 공개했습니다.

질문 | 결정 |
|---|---|
역할과 권한을 어디에 저장할까 |
|
공개 API 가 초안까지 돌려줄 때 인가는 어디서 할까 | API 코드의 조건문 대신 DB 규칙(RLS)이 판정하게 옮겼습니다. |
삭제는 행을 지울까, 상태를 바꿀까 | 기본은 |
두 곳에서 같은 글을 저장하면 | 잠그지 않고 |
정기 작업은 어디서 돌릴까 | 호스팅 cron 대신 |
파일과 DB 둘 다 고칠 수 있으면 | 더 최근에 손댄 쪽을 남깁니다. 읽을 때는 DB 값, 없으면 빌드 때 만든 마크다운 값을 씁니다. |
src/
├─ app/ 라우트 — (home) · about · profile · works · posts · admin · api
├─ components/ 도메인별 — about · works · posts · admin · comments · ui · layout
├─ hooks/ 화면 로직 (자동 저장 · 스크롤 · 미리보기 …)
├─ lib/ 서버 로직 — supabase 클라이언트 · 권한 · 메일 · 업로드
├─ providers/ 테마 · 언어 · Lenis · 페이지 전환
├─ stores/ zustand — 모달 · 토스트 · 소리
├─ styles/tokens/ 디자인 토큰 세 층
└─ locales/ ko · en 문구
content/ 글 · 작업물 · About 마크다운 → DB 동기화
supabase/ setup.sql + 마이그레이션
e2e/ scripts/ Playwright · 동기화 스크립트
본문에서 인용한 PR 입니다. 각 PR 에 문제, 원인, 변경, 전후 결과를 남겼습니다.
본문 | PR | 내용 |
|---|---|---|
2.1. | 모바일에서 첫 방문마다 페이지가 통째로 다시 만들어지던 문제 | |
2.2. | 편집기 한 글자 입력에 드는 렌더와 스타일 재계산 | |
2.3. | service role 에서 RLS 로 권한 판정 이전 | |
2.4. | 페이지 전환 덮개가 경로 전환보다 먼저 걷히던 문제 | |
3장 | 가로 스크롤 영역의 휠 입력 충돌 | |
5장 | 홈 다운로드 · 글 목록 LCP · 관리자 LCP · 편집기 접근성 | |
6장 | API 응답 통일 전 안전망 테스트 |
전체 목록 github.com/hyeoniverse/web-portfolio-hyeoniverse/pulls
공개 페이지


편집기 블록
