수영장(Sooyoung Archive)
홈개발일지이번캠App소개
전체보기
프로젝트
기술 & 개발

© 2026 수영장 (Sooyoung Archive). All rights reserved.

개인정보처리방침연락처
개발일지 목록
프로젝트

sooyoung archive 제작기

2026년 2월 2일약 17분 소요53회 조회

시작하며

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

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


1. 프로젝트 초기 설정

기술 스택 선택

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

기본 구조

code
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을 붙여넣으면 자동으로 마크다운 이미지 형식으로 변환하는 기능을 만들었어요.

typescript
// 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 친화적인 슬러그로 변환하고, 기존 잘못된 슬러그도 찾을 수 있도록 했어요.

typescript
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 글 수정/삭제 기능

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

typescript
// 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 반응형 레이아웃 통일

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

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

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


3. 디자인 시스템

3.1 키 컬러 정의

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

css
/* 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 (파란색)
tsx
<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 로그인을 구현했어요.

typescript
// 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 계정만 글 작성/수정/삭제가 가능하도록 했어요.

typescript
// 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로 마이그레이션했어요.

typescript
// 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 데이터베이스 스키마

sql
-- 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를 사용하도록 변경했어요.

typescript
// 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. 배포 준비

환경 변수 설정

env
# 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

배포 체크리스트

  • Supabase 프로젝트 생성 및 스키마 실행
  • Storage 버킷 생성 (uploads)
  • 환경 변수 설정
  • 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

클릭할 때마다 작가에게 따뜻한 응원이 전달됩니다 ❤️

이 글이 유익하셨나요?
동료 개발자들과 경험과 노하우를 공유해 보세요.
프로젝트다음 글
Next.js 개발 아카이브를 Vercel에 배포하기: 환경 변수와 운영 점검

함께 읽으면 좋은 다른 개발일지

'프로젝트' 및 추천 아카이브 글 모음

이번캠 운영기 2: 로그인부터 뒤로가기까지, 사용자의 흐름을 지키기
프로젝트
이번캠 운영기 2: 로그인부터 뒤로가기까지, 사용자의 흐름을 지키기
로그인 뒤 원래 글로 돌아오기, 좋아요 반응과 미디어 실패 복구, 카카오맵 장소 입력과 피드 상태 유지까지. 출시 후 사용자의 흐름이 끊기는 순간을 줄여간 과정을 정리했다.
2026. 10. 29.

댓글

댓글 개인정보 안내
목차
시작하며1. 프로젝트 초기 설정기술 스택 선택기본 구조2. 핵심 기능 구현2.1 이미지 URL 자동 변환2.2 슬러그 생성 및 매칭 로직2.3 글 수정/삭제 기능2.4 반응형 레이아웃 통일3. 디자인 시스템3.1 키 컬러 정의3.2 로고 디자인4. 인증 시스템4.1 GitHub 소셜 로그인4.2 관리자 권한 체크5. 데이터베이스 연동5.1 Supabase 설정5.2 데이터베이스 스키마5.3 이미지 업로드 (Supabase Storage)6. 주요 문제 해결 과정문제 1: 슬러그 생성 오류문제 2: 이미지 URL이 마크다운으로 변환 안 됨문제 3: 레이아웃이 왼쪽으로 치우침문제 4: 관리자 계정 매칭 실패7. 배포 준비환경 변수 설정배포 체크리스트8. 향후 계획마무리
0회
이번캠 운영기: 사용자 피드백에서 커뮤니티 분리와 이동 성능 개선까지
프로젝트
이번캠 운영기: 사용자 피드백에서 커뮤니티 분리와 이동 성능 개선까지
출시한 캠핑 서비스를 운영하며 로그인·반응·업로드 흐름을 보완하고, 방문 기록과 커뮤니티를 분리한 과정. 서울 리전, 요청 단위 캐시와 응답 경합을 다루며 측정 조건과 모바일 검증의 한계까지 정리했다.
2026. 10. 12.0회
캠핑인스타 만들기 5편: '이번캠(MyCamp)' 리브랜딩과 Capacitor로 Apple App Store 심사 통과한 이야기
프로젝트
캠핑인스타 만들기 5편: '이번캠(MyCamp)' 리브랜딩과 Capacitor로 Apple App Store 심사 통과한 이야기
캠핑인스타를 '이번캠(MyCamp)'으로 리브랜딩하고, Capacitor를 활용해 Next.js 웹 서비스를 Apple App Store(iOS)에 공식 출시하며 겪은 심사 통과와 트러블슈팅 기록.
2026. 8. 26.15회