sooyoungarchive
소개
전체보기
프로젝트
블로그 만들기그림톡캠핑 인스타
개발

© 2026 sooyoung archive. All rights reserved.

개인정보처리방침연락처
← 목록
Sooyoung Archive

sooyoung archive 제작기

2026년 2월 2일 · 28회 조회

나만의 블로그 만들기: Next.js + Supabase로 개인 아카이브 구축하기

시작하며

개인 블로그를 만들고 싶다는 생각이 들었어요. 단순히 글만 쓰는 게 아니라, 나만의 공간을 만들어보고 싶었습니다. 그래서 시작한 프로젝트가 Sooyoung Archive입니다.

이 글에서는 처음부터 끝까지 블로그를 만드는 과정을 정리해봤어요. 기술 스택은 Next.js, TypeScript, Supabase, NextAuth를 사용했습니다.


1. 프로젝트 초기 설정

기술 스택 선택

  • Next.js 16 (App Router)
  • TypeScript
  • Tailwind CSS
  • Supabase (PostgreSQL + Storage)
  • NextAuth (GitHub 소셜 로그인)
  • React Markdown (마크다운 렌더링)

기본 구조

sooyoung-archive/
├── src/
│   ├── app/              # Next.js App Router
│   │   ├── page.tsx      # 홈 (글 목록)
│   │   ├── post/[slug]/  # 글 상세
│   │   ├── write/        # 글 작성/수정
│   │   └── login/        # 로그인 페이지
│   ├── components/       # 재사용 컴포넌트
│   ├── lib/              # 유틸리티 함수
│   └── types/            # TypeScript 타입
└── data/
    └── posts.json        # 초기 데이터 (로컬)

2. 핵심 기능 구현

2.1 이미지 URL 자동 변환

마크다운 에디터에서 이미지 URL을 붙여넣으면 자동으로 마크다운 이미지 형식으로 변환하는 기능을 만들었어요.

// src/lib/markdown.ts
const IMAGE_URL_LINE =
  /^\s*(https?:\/\/\S+|\/\S+)\.(?:png|jpe?g|gif|webp|svg)(?:\?\S*)?\s*$/i;

export function normalizeMarkdownImages(markdown: string) {
  if (!markdown.trim()) return markdown;

  return markdown
    .split("\n")
    .map((line) => {
      const trimmed = line.trim();
      if (!trimmed) return line;
      if (!IMAGE_URL_LINE.test(trimmed)) return line;
      return `![](${trimmed})`;
    })
    .join("\n");
}

이제 https://example.com/image.jpg 같은 URL을 한 줄로 붙여넣으면 자동으로 ![](https://example.com/image.jpg)로 변환돼요.

2.2 슬러그 생성 및 매칭 로직

한글 제목을 URL 친화적인 슬러그로 변환하고, 기존 잘못된 슬러그도 찾을 수 있도록 했어요.

function slugify(text: string): string {
  return text
    .trim()
    .toLowerCase()
    .replace(/\s+/g, "-")
    .replace(/[^0-9a-z가-힣-]+/g, "");
}

function normalizeSlug(value: string): string {
  try {
    return decodeURIComponent(value).normalize("NFC").toLowerCase();
  } catch {
    return value.normalize("NFC").toLowerCase();
  }
}

문제 해결: 처음에 슬러그 생성 로직이 잘못되어 -가 같은 이상한 슬러그가 생성되었어요. 정규식을 수정하고, 기존 데이터도 호환되도록 legacy 매칭 로직을 추가했어요.

2.3 글 수정/삭제 기능

상세 페이지에서 관리자만 수정/삭제 버튼을 볼 수 있도록 했어요.

// src/app/post/[slug]/post-actions.tsx
export function PostActions({ slug }: PostActionsProps) {
  const router = useRouter();
  const [deleting, setDeleting] = useState(false);

  async function handleDelete() {
    if (!confirm("정말 삭제할까요? 이 작업은 되돌릴 수 없습니다.")) return;
    // 삭제 로직...
  }

  return (
    <div className="flex flex-wrap gap-2">
      <Button variant="outline" asChild>
        <Link href={`/write/${slug}`}>수정</Link>
      </Button>
      <Button variant="destructive" onClick={handleDelete}>
        삭제
      </Button>
    </div>
  );
}

2.4 반응형 레이아웃 통일

모든 페이지가 일관된 너비와 가운데 정렬을 갖도록 했어요.

// 모든 페이지에서 동일한 컨테이너 클래스 사용
<main className="mx-auto w-full max-w-7xl px-4 py-8">

문제 해결: 처음에는 container 클래스를 사용했는데, 화면이 넓을 때 왼쪽으로 치우쳐 보였어요. mx-auto w-full max-w-7xl로 통일해서 해결했어요.


3. 디자인 시스템

3.1 키 컬러 정의

"수영"이라는 이름에서 영감을 받아 수영장 컨셉의 컬러를 선택했어요.

/* src/app/globals.css */
:root {
  --primary: #009ec2; /* Deep Ocean Blue */
  --accent: #c9f2ff; /* Light Cyan */
  --background: #f8fafc; /* Slate White */
  --foreground: #0c0c0c; /* Rich Black */
}

.dark {
  --background: #0c0c0c;
  --foreground: #f8fafc;
  /* ... */
}

3.2 로고 디자인

"sooyoung archive"의 중의적 의미(수영장 + 아카이브)를 담은 로고를 만들었어요.

  • 물결 아이콘: 수영장의 물결
  • 워드마크: sooyoung (검은색) + archive (파란색)
<Link href="/" className="flex items-center gap-2">
  <span className="inline-flex h-6 w-6 items-center justify-center text-primary">
    {/* 물결 SVG 아이콘 */}
  </span>
  <span className="text-base font-semibold">
    sooyoung<span className="text-primary">archive</span>
  </span>
</Link>

4. 인증 시스템

4.1 GitHub 소셜 로그인

NextAuth를 사용해서 GitHub 로그인을 구현했어요.

// src/auth.ts
export const authOptions: NextAuthOptions = {
  providers: [
    GitHubProvider({
      clientId: process.env.AUTH_GITHUB_ID ?? "",
      clientSecret: process.env.AUTH_GITHUB_SECRET ?? "",
    }),
  ],
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, profile }) {
      if (profile && "login" in profile) {
        token.login = String(profile.login);
      }
      return token;
    },
    async session({ session, token }) {
      // 관리자 여부를 세션에 추가
      (session as { isAdmin?: boolean }).isAdmin = isAdminSession(session);
      return session;
    },
  },
};

4.2 관리자 권한 체크

환경 변수에 설정한 GitHub 계정만 글 작성/수정/삭제가 가능하도록 했어요.

// src/lib/auth.ts
const ADMIN_EMAIL = process.env.ADMIN_EMAIL?.toLowerCase();
const ADMIN_GITHUB = process.env.ADMIN_GITHUB?.toLowerCase();

export function isAdminSession(session: Session | null) {
  if (!session?.user) return false;

  const email = session.user.email?.toLowerCase();
  const login = session.user.login?.toLowerCase();

  if (ADMIN_EMAIL && email && email === ADMIN_EMAIL) return true;
  if (ADMIN_GITHUB && login && login === ADMIN_GITHUB) return true;

  return false;
}

권한 구조:

  • 공개: 글 목록 보기, 글 상세 보기
  • 로그인 필요: 댓글 작성 (향후 추가 예정)
  • 관리자만: 글 작성/수정/삭제, 이미지 업로드

5. 데이터베이스 연동

5.1 Supabase 설정

로컬 파일 저장소(data/posts.json)에서 Supabase로 마이그레이션했어요.

// src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

5.2 데이터베이스 스키마

-- supabase-schema.sql
CREATE TABLE IF NOT EXISTS posts (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  excerpt TEXT,
  thumbnail TEXT,
  body TEXT NOT NULL,
  "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  "updatedAt" TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts("createdAt" DESC);

5.3 이미지 업로드 (Supabase Storage)

로컬 파일 시스템 대신 Supabase Storage를 사용하도록 변경했어요.

// src/app/api/upload/route.ts
const { data, error } = await supabase.storage
  .from("uploads")
  .upload(filename, buffer, {
    contentType: file.type,
    upsert: false,
  });

const {
  data: { publicUrl },
} = supabase.storage.from("uploads").getPublicUrl(filename);

6. 주요 문제 해결 과정

문제 1: 슬러그 생성 오류

증상: -가 같은 이상한 슬러그가 생성됨

원인: 정규식에서 -가 범위로 해석됨

해결: [^0-9a-z가-힣-]+로 수정하고, 기존 데이터는 legacy 매칭으로 호환

문제 2: 이미지 URL이 마크다운으로 변환 안 됨

증상: 이미지 URL을 붙여넣어도 이미지로 표시 안 됨

해결: normalizeMarkdownImages 함수로 URL을 자동 감지해서 마크다운 형식으로 변환

문제 3: 레이아웃이 왼쪽으로 치우침

증상: 화면이 넓을 때 콘텐츠가 왼쪽에 몰림

해결: container 대신 mx-auto w-full max-w-7xl로 통일

문제 4: 관리자 계정 매칭 실패

증상: 로그인은 되는데 작성 버튼이 안 보임

해결: 세션에 login 필드 추가하고, 환경 변수와 정확히 매칭하도록 수정


7. 배포 준비

환경 변수 설정

# GitHub OAuth
AUTH_GITHUB_ID=your_github_client_id
AUTH_GITHUB_SECRET=your_github_client_secret

# 관리자 계정
ADMIN_EMAIL=your_email@example.com
ADMIN_GITHUB=your_github_username

# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key

배포 체크리스트

  • [x] Supabase 프로젝트 생성 및 스키마 실행
  • [x] Storage 버킷 생성 (uploads)
  • [x] 환경 변수 설정
  • [x] GitHub OAuth 앱 생성
  • [ ] Vercel 등에 배포
  • [ ] 도메인 연결 (선택사항)

8. 향후 계획

  1. 댓글 기능: 로그인한 사용자가 댓글 작성 가능
  2. 태그 시스템: 글에 태그 추가 및 태그별 필터링
  3. 검색 기능: 제목/본문 검색
  4. 다크모드: 이미 준비되어 있음 (CSS 변수 정의됨)
  5. RSS 피드: 블로그 구독 기능

마무리

처음에는 단순히 글만 쓰는 블로그를 만들려고 했는데, 만들다 보니 정말 나만의 공간이 되었어요.

특히 **"수영"**이라는 이름에서 영감을 받아 수영장 컨셉의 디자인을 적용한 게 마음에 들어요. 물결 아이콘과 블루 컬러가 블로그의 정체성을 잘 표현한다고 생각해요.

기술적으로는 Next.js의 App Router와 Supabase의 조합이 정말 좋았어요. 서버 컴포넌트와 클라이언트 컴포넌트를 적절히 섞어서 성능도 좋고 개발 경험도 좋았습니다.

앞으로도 이 공간에 제 생각과 경험을 기록해나가려고 해요. 🏊‍♀️


작성일: 2026년 2월 2일
기술 스택: Next.js 16, TypeScript, Supabase, NextAuth, Tailwind CSS

댓글