Loading
Loading
개인 블로그 개발기

Next.js 14 App Router, Fastify, PostgreSQL, Oracle Cloud — 프론트엔드부터 서버, 인프라까지 직접 설계하고 배포한 개인 블로그 개발기입니다. 인증 아키텍처, 토큰 갱신 전략, 포트폴리오 애니메이션 등 구현 과정에서 고민했던 부분들을 정리했습니다.
![]()
![]()
![]()
| 역할 | 기술 |
|---|---|
| Framework | Next.js 14 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS + CSS Variables |
| Data Fetching | TanStack Query |
| State | Zustand |
| Form | React Hook Form |
| Markdown | react-markdown, remark-gfm |
| AI | Google Gemini API |
| 역할 | 기술 |
|---|---|
| Framework | Fastify 5 |
| Language | TypeScript |
| ORM | Prisma 7 |
| Database | PostgreSQL |
| Auth | JWT + OAuth (GitHub) |
| Storage | Supabase (이미지) |
| Infra | Oracle Cloud + Nginx + PM2 |
가장 고민이 많았던 부분입니다.
JWT를 localStorage에 저장하면 XSS 공격에 노출됩니다. 그래서 HttpOnly 쿠키를 선택했습니다. 브라우저 JS에서 접근할 수 없으니 탈취 위험이 없습니다.
Client → Next.js /api 프록시 → Backend API
클라이언트는 백엔드를 직접 호출하지 않습니다. Next.js의 API Route가 중간에서 쿠키를 읽어 Authorization 헤더로 변환해서 백엔드로 전달합니다.
Loading...
[다이어그램: BFF 인증 흐름]
서버(SSR)와 클라이언트(CSR)는 API 호출 방식이 다릅니다.
cookies() 헤더 전달/api 프록시 경유 (브라우저가 자동으로 쿠키 전송)Loading...
typeof window로 환경을 판단해서 자동으로 올바른 인스턴스를 선택합니다. 서비스 레이어를 사용하는 쪽에서는 신경 쓸 필요가 없습니다.
초기에는 클라이언트에서 /me API를 호출해 로그인 상태를 확인했습니다. 그러면 이런 일이 생깁니다.
1. 페이지 로드 → SSR HTML (비로그인 UI 포함)
2. JS 실행 → /me API 호출
3. 응답 수신 → 로그인 UI로 교체
이 1→3 과정에서 "로그인" 버튼이 잠깐 보였다가 "로그아웃" 버튼으로 바뀝니다. 사용자 입장에서 거슬리는 동작입니다.
해결책: 서버 컴포넌트에서 유저 정보를 받아 props로 내려줍니다.
Loading...
SSR HTML부터 올바른 UI가 포함되니 깜빡임이 사라집니다.
Access Token(15분) 만료 후 Refresh Token으로 갱신하는 로직이 필요합니다.
처음엔 서버 컴포넌트에서 cookies().set()을 시도했는데, Next.js는 Server Action이나 Route Handler에서만 쿠키 쓰기를 허용합니다. 렌더링 중에는 불가능합니다.
문제: Server Component → cookies().set() → 무시됨 (에러 없음!)
해결책: 미들웨어는 모든 요청을 가로챌 수 있고, 응답 쿠키를 자유롭게 설정할 수 있습니다.
Loading...
미들웨어에서 갱신 후 새 토큰을 쿠키에 심어두면, 이후 서버 컴포넌트에서 cookies()로 읽을 수 있습니다.
| 경로 | 위치 | 트리거 |
|---|---|---|
| SSR | middleware.ts | 페이지 요청 시 |
| CSR | api/[...path]/route.ts | 클라이언트 API 호출 시 |
![]()
포트폴리오 카드를 클릭하면 카드 위치에서 전체 화면으로 확장되는 애니메이션이 재생됩니다.
구현 방식은 이렇습니다.
getBoundingClientRect()로 현재 위치/크기 저장requestAnimationFrame으로 transform 제거 → 자연스러운 확장Loading...
createPortal로 document.body에 렌더링해서 z-index 충돌도 없습니다.
![]()
![]()
글 작성 화면은 좌측에 마크다운 입력, 우측에 실시간 미리보기가 나란히 배치됩니다.
에디터 스크롤에 따라 미리보기가 자동으로 따라가는 동기화 기능도 구현했습니다.
Loading...
이미지 업로드는 드래그 앤 드롭과 붙여넣기 모두 지원합니다. 클립보드에서 이미지를 감지해 바로 업로드하고 마크다운에 삽입합니다.
![]()
포트폴리오를 발행할 때 Google Gemini API로 내용을 3문장으로 자동 요약합니다.
Loading...
Server Action으로 구현해서 API 키가 클라이언트에 노출되지 않습니다. 토글 버튼으로 켜고 끌 수 있습니다.
![]()
로그인한 사용자는 댓글을 달고, 댓글에 답글을 달 수 있습니다. 1단계까지만 중첩을 허용합니다.
댓글 삭제는 soft delete 방식입니다. deleted_at 컬럼에 삭제 시각을 기록하고, 조회 시 WHERE deleted_at IS NULL로 필터링합니다. 부모 댓글이 삭제되면 하위 답글도 함께 soft delete됩니다.
좋아요는 Optimistic Update로 구현해 즉각적인 피드백을 제공합니다. 요청이 실패하면 이전 상태로 rollback됩니다.
[다이어그램: 인프라 구성]
Oracle Cloud Free Tier에 Ubuntu 서버를 올렸습니다. 무료인데 꽤 안정적입니다.
이미지는 Supabase Storage에 저장합니다. 업로드 시 Sharp로 WebP 변환 + 리사이즈해서 용량을 줄입니다.
set-cookie 덮어쓰기미들웨어에서 토큰 갱신 후 백엔드의 set-cookie를 headers.append()로 추가하고, 이후 cookies.set()으로 다른 쿠키를 설정했더니 앞서 append한 쿠키가 사라졌습니다.
Next.js의 ResponseCookies.set()이 내부 맵을 헤더에 전체 동기화(교체) 하기 때문입니다.
해결: headers.append()를 쓰지 않고, 모든 쿠키를 response.cookies.set()으로 통일했습니다.
POST /auth/refresh처럼 body가 필요 없는 엔드포인트에서 Fastify가 파싱 오류를 냈습니다.
Content-Type은 application/json인데 body가 비어있으니 파서가 실패한 것입니다.
해결: body 없는 POST 요청에도 JSON.stringify({}) 전송.
Loading...
iptables에서 80/443 포트를 열었는데도 외부에서 접속이 안 됐습니다.
Oracle Cloud는 OS 방화벽과 VCN Security List 두 곳 모두 열어야 합니다. 한쪽만 열면 안 됩니다.
해결: Oracle Cloud 콘솔 → VCN → Security Lists에서 Ingress Rule 추가.
직접 만들길 잘했습니다. 플랫폼 블로그에서는 경험하기 어려운 것들을 많이 배웠습니다.
인증 흐름, 서버/클라이언트 렌더링 경계, 인프라 구성까지 전부 직접 다뤄볼 수 있었습니다.
코드는 GitHub에서 볼 수 있습니다.