읽는 데 10분조회 7
개인 홈페이지 만들기 — Next.js, Drizzle, PostgreSQL
버전 1.0.0
작성 기준: 2026-09-08
이 홈페이지를 어떻게 만들었는지 적는다. 나흘쯤 걸려 첫 판을 냈고, 그 뒤로 계속 손보고 있다. 지금 v0.9.0 이고 커밋 170개, 버전 태그 14개다.
잘된 것만 쓰면 남는 게 없으니 걸린 것도 같이 적는다. 뒤쪽 8번이 그 이야기다.
1. 무엇으로 만들었나
| 것 | 무엇 |
|---|---|
| 화면 | Next.js 16 (App Router), React 19 |
| 스타일 | Tailwind CSS 4 |
| DB | PostgreSQL 18 |
| DB 다루기 | Drizzle ORM |
| 로그인 | iron-session (암호화 쿠키) |
| 글 | 마크다운 — react-markdown, remark-gfm, rehype-highlight |
| 배포 | Docker, GitHub Actions, 자체 호스팅 러너 |
| 앞단 | Caddy |
| 통계 | umami (직접 띄움) |
| 시험 | Vitest 76개, Playwright 39개 |
바깥에서 가져다 쓰는 서비스가 거의 없다. 통계도 직접 띄웠다. 남의 서비스에 기대면 그쪽이 정책을 바꿀 때마다 흔들린다.
운영 의존성은 열 개다. drizzle-orm, iron-session, next, postgres,
react, react-dom, react-markdown, rehype-highlight, rehype-slug,
remark-gfm. 그 이상은 필요하지 않았다.
2. 내용을 파일이 아니라 DB에 넣었다
처음에 정해야 할 것이 하나 있었다. 글을 마크다운 파일로 두고 커밋할 것인가, DB에 넣고 웹에서 고칠 것인가.
DB로 갔다. 이유는 하나다. 폰에서 글을 고칠 수 있어야 한다.
파일로 두면 글자 하나 고치려고 노트북을 켜고 커밋하고 배포해야 한다. 그러면 안 고친다.
지금 DB에 든 것.
profile,careers,skills— 소개 페이지 내용posts,comments— 글과 댓글projects— 프로젝트 목록uploads— 글에 넣은 이미지. bytea로 통째로messages— 연락 폼으로 받은 것links— 내가 돌리는 다른 서비스 주소login_attempts,admin_password— 관리자 로그인
표 열두 개다. 전부 /admin 에서 웹으로 고친다.
이미지도 DB에
uploads 표에 bytea 로 넣는다. 파일 시스템에 두지 않는다.
이유는 백업이다. pg_dump 한 번으로 글과 이미지가 함께 보관된다. 파일로
두면 DB 백업과 파일 백업 시점이 어긋나서, 복구하면 글은 있는데 이미지가
없는 상태가 생긴다.
주소는 내용 해시로 만든다. 같은 파일을 두 번 올리면 한 번만 저장되고, 주소가 내용에 묶여 있으니 캐시를 1년 걸어도 안전하다.
올릴 때 sharp로 webp 로 바꾸고 가로 1600px까지 줄인다. 2400×1600 JPEG 672KB가 1600×1067 webp 324KB가 됐다. 바꾼 것이 원본보다 크면 원본을 그대로 둔다. GIF는 움직임이 사라지니 건드리지 않는다.
3. SQL 직접 쓰다가 Drizzle로 돌아온 이야기
처음에는 Drizzle을 붙였다가 걷어냈다. 스키마를 TypeScript로 또 적는 게 번거로웠고, SQL은 이미 오래 써서 손에 익었다.
그래서 postgres 드라이버로 SQL을 직접 썼다.
const rows = await sql`select * from posts where slug = ${slug}`;
이 자체는 편했다. 그런데 두 가지가 걸렸다.
첫째, 결과 타입을 손으로 적어야 한다. 표 구조를 바꾸면 타입을 같이
고쳐야 하는데, 안 고쳐도 컴파일이 된다. 그러다 화면에서 undefined가
나온다.
둘째, 마이그레이션을 스스로 관리해야 한다. 어떤 SQL을 어디까지 적용했는지 세는 일을 직접 만들어야 한다.
다시 Drizzle로 돌아왔다. 스키마를 한 곳에 적으면 타입과 마이그레이션 SQL이 같이 나온다.
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
content: text("content").notNull(),
tags: text("tags").array().notNull().default([]),
// null 이면 임시저장이다. 공개 목록에 안 나온다
publishedAt: timestamp("published_at", { withTimezone: true }),
},
(t) => [index("posts_published_idx").on(t.publishedAt.desc())],
);
export type Post = typeof posts.$inferSelect;
$inferSelect 하나로 타입이 나온다. 표를 바꾸면 타입이 따라 바뀌고,
안 고친 코드는 컴파일이 깨진다. 그게 원하는 동작이다.
복잡한 조회는 여전히 SQL을 섞어 쓴다.
where(sql`${posts.title} ilike ${pattern} or ${posts.content} ilike ${pattern}`)
ORM이 SQL을 막지 않는 게 중요하다.
4. 마이그레이션은 직접 돌린다
drizzle-kit migrate 를 쓰지 않고 직접 돌리는 스크립트를 만들었다.
scripts/migrate.mjs 다.
이유는 Docker다. 컨테이너 안에서 마이그레이션만 돌려야 하는데, drizzle-kit은 개발 의존성이라 운영 이미지에 없다. 운영 이미지에 개발 도구를 넣고 싶지 않았다.
적용 기록은 drizzle-kit과 같은 형식으로 남긴다. 파일 내용의 sha256을 해시로,
journal의 when을 시각으로 쓴다. 그래야 나중에 drizzle-kit으로 돌아가도
같은 기록을 읽는다.
compose에서 순서를 잡는다.
migrate:
image: ${MYHOME_IMAGE:-myhome:latest}
command: ["node", "scripts/migrate.mjs"]
depends_on:
postgres:
condition: service_healthy
restart: "no"
app:
depends_on:
migrate:
condition: service_completed_successfully
postgres 가 건강해지면 migrate 가 한 번 돌고, 그게 성공해야 app 이 뜬다.
앱과 같은 이미지를 쓰니 코드와 마이그레이션이 항상 같은 버전이다.
5. 자바스크립트가 없어도 되는 화면
Next.js의 Server Actions를 쓴다. 폼을 서버 함수에 바로 연결한다.
<form action={createPost}>
<input name="title" />
<button type="submit">발행하기</button>
</form>
여기서 신경 쓴 것이 하나 있다. 자바스크립트가 없어도 동작해야 한다.
Next는 그걸 위해 폼에 숨은 필드를 넣어 준다. 자바스크립트가 안 뜨면 브라우저가 평범한 POST를 보내고 서버가 받는다.
그런데 이걸 깨뜨리기 쉽다. 실제로 두 번 깨뜨렸다.
하나, 서버 액션을 감싸면 안 된다.
// 이렇게 하면 숨은 필드가 안 붙는다
useActionState(async (prev, form) => uploadImage(prev, form), {})
// 이렇게 넘겨야 한다
useActionState(uploadImage, {})
감싸면 함수 참조가 바뀌어서 Next가 어떤 액션인지 알아내지 못한다.
둘, 폼 안에 폼을 넣으면 안 된다. 이미지 업로드가 자체 폼인데 글 편집 폼 안에 있었다. 브라우저가 안쪽 폼을 조용히 버린다. HTML 규칙이다. 바깥으로 뺐다.
오류도 상태 대신 쿼리스트링으로 알린다.
if (!values.title) redirect(`/admin/posts/${id}?e=required`);
자바스크립트가 없으면 상태를 화면에 못 그리니, 주소에 실어 보내고 서버가 렌더할 때 읽는다.
6. 배포는 버전 태그로
처음에는 main 에 푸시하면 배포되게 했다. 편했지만 곧 불편해졌다.
문서 한 줄 고쳐도 배포가 돌았고, 운영에 떠 있는 것이 어느 시점인지
말하기 어려웠다.
버전 태그로 바꿨다.
npm run release:minor # 버전 올림 → 커밋 → 태그 → 푸시
태그가 올라가면 검사가 돌고, 통과하면 배포되고, 끝나면 GitHub 릴리스까지
만들어진다. main 에 합치는 것만으로는 아무 일도 안 일어난다.
여러 기능을 모았다가 한 번에 내보낼 수 있고, "지금 운영은 v0.9.0" 이라고 말할 수 있다.
빌드는 서버에서 한다
이 서버가 arm64다. GitHub 기계에서 빌드하면 에뮬레이션으로 10분이 넘는다. 서버에 GitHub 러너를 설치하고 거기서 빌드한다.
deploy:
needs: check
if: startsWith(github.ref, 'refs/tags/v')
runs-on: [self-hosted, myhome]
검사는 GitHub 기계에서, 배포는 서버에서. 검사가 통과해야 배포가 시작된다.
설정 원본과 실행 위치를 나눴다
저장소의 infra/ 가 원본이고, 배포 스크립트가 ~/MyHome 으로 복사해서
거기서 띄운다. 비밀값(.env)만 실행 위치에 있고 저장소에는 없다.
~/MyHome 쪽 파일을 직접 고치면 다음 배포에 덮어써진다. 그게 맞는 동작이다.
7. 개발과 운영을 나눴다
한 서버에서 둘을 돌린다.
| 주소 | 포트 | DB | 반영 | |
|---|---|---|---|---|
| 운영 | 대표 주소 | 40000 | myhome | 버전 태그를 올릴 때 |
| 개발 | dev. 하위 | 40002 | myhome_dev | 파일 저장 즉시 |
DB 서버는 같이 쓰고 데이터베이스만 나눴다. 개발하다 글을 지워도 운영은 그대로다.
개발 인스턴스는 robots.txt 에서 전체 색인을 거부한다. 같은 내용이 두
주소에 있으면 검색엔진이 둘 다 낮춘다.
8. 걸린 것들
여기가 이 글의 본론이다.
PostgreSQL 18에서 볼륨 마운트 지점이 바뀌었다
컨테이너가 계속 재시작했다. 17까지는 /var/lib/postgresql/data 에
마운트했는데, 18 공식 이미지는 /var/lib/postgresql 에 하고 그 안에
버전별 디렉터리를 만든다.
volumes:
- postgres_data:/var/lib/postgresql # 18 부터는 여기
:dev 명령이 운영 DB에 붙고 있었다
이게 제일 아찔했다. db:seed:dev 같은 명령을 만들어 뒀는데, 실제로는
운영 DB에 붙고 있었다. 돌렸으면 운영 데이터를 지웠을 것이다.
원인은 환경변수 파일을 읽는 순서였다. Node의 loadEnvFile 은 이미 설정된
값을 덮어쓰지 않는다. 운영 파일을 먼저 읽고 개발 파일을 나중에 읽고
있었으니, 나중 것이 무시됐다.
const envFiles = process.env.DRIZZLE_ENV === "dev"
? [".env.development.local", ".env.local", ".env"] // 개발 것을 먼저
: [".env.local", ".env"];
화면은 Next.js 자체 규칙으로 읽어서 제대로 동작했다. 그래서 안 드러났다. 스크립트와 프레임워크가 같은 파일을 다르게 읽는다는 걸 몰랐다.
앞뒤 글이 자기 자신을 가리켰다
글 아래에 "이전 글 / 다음 글" 을 붙였는데, 어떤 글에서 다음 글이 자기 자신으로 나왔다.
PostgreSQL은 시각을 마이크로초까지 저장하고, JavaScript의 Date 는
밀리초까지만 다룬다. 발행 시각을 JS로 읽어 다시 조회에 쓰면 값이
잘려서, 자기 자신이 "자기보다 나중" 으로 판정됐다.
시각만으로 비교하지 않고 글 번호로 자기 자신을 뺐다.
where(and(ne(posts.id, postId), lte(posts.publishedAt, publishedAt)))
서버 액션 본문이 1MB로 막혀 있었다
사진을 올리면 500이 났다. Server Actions의 기본 본문 상한이 1MB다. 사진 한 장이 그걸 넘는다.
experimental: {
serverActions: { bodySizeLimit: "6mb" },
},
우리 쪽 안내 문구(5MB)가 먼저 뜨도록 조금 크게 잡았다.
standalone 빌드에 DB 드라이버가 없었다
Docker 이미지를 output: "standalone" 으로 만든다. 필요한 파일만 모아
주는 방식이라 이미지가 작다.
그런데 마이그레이션 스크립트가 postgres 를 못 찾았다. Next가 앱 코드에서는
드라이버를 번들에 넣지만, 별도로 실행하는 스크립트는 그 번들을 쓰지 않는다.
COPY --from=deps /app/node_modules/postgres ./node_modules/postgres
흐린 글자가 라이트 모드에서만 안 보였다
접근성을 훑으니 색 대비 위반이 여섯 군데 나왔다. 전부 라이트 모드에서만.
본문보다 흐린 글자를 투명도로 만들고 있었다.
| 흰 바탕 | 검은 바탕 | |
|---|---|---|
text-foreground/50 | 3.41:1 미달 | 4.74:1 통과 |
text-foreground/40 | 2.55:1 미달 | 3.40:1 미달 |
기준은 4.5:1 이다. 다크 모드에서는 통과해서, 다크로 쓰는 동안은 평생 몰랐을 문제다.
투명도 대신 테마마다 색을 따로 줬다.
:root {
--muted: #6b6b6b; /* 5.33:1 */
--faint: #767676; /* 4.54:1 */
}
41군데를 고쳤다. 다시 훑으니 0건이다. 시험도 붙여서 다시 흐려지면 걸리게 했다.
태그를 올려도 릴리스가 안 생겼다
버전 태그로 열 번 배포했는데 GitHub 저장소 오른쪽 Releases 는 계속 비어 있었다.
태그와 릴리스는 다른 것이다. 태그는 git이 만드는 이름표고, 릴리스는
GitHub이 따로 만들어 주는 것이다. git push --follow-tags 는 태그까지만 한다.
배포 뒤에 릴리스를 만드는 단계를 붙였다. 본문은 CHANGELOG.md 의 그 버전
절을 꺼내 쓴다. 릴리스 노트를 따로 쓰면 CHANGELOG와 반드시 어긋난다.
파이프가 종료 코드를 삼켰다
이건 내가 만든 실수다.
npx tsc --noEmit 2>&1 | head -3
타입 검사에 실패했는데 이 명령의 종료 코드는 0이었다. 파이프의 종료 코드는
마지막 명령 것이라, tsc 가 실패해도 head 가 성공하면 0이 나온다.
오류 메시지는 화면에 찍혔는데 그걸 실패로 처리하지 않고 넘어갔다.
깨진 것을 태그까지 올렸다. 다행히 CI가 막아서 배포는 안 됐다.
지금은 이렇게 확인한다.
npm run lint > /dev/null 2>&1; echo "lint: $?"
npm run typecheck > /dev/null 2>&1; echo "typecheck: $?"
npm test > /dev/null 2>&1; echo "test: $?"
npm run build > /dev/null 2>&1; echo "build: $?"
눈으로 보지 않고 숫자로 본다.
9. 시험은 두 층으로
Vitest 76개 — 함수 단위. 주소 만들기, 읽는 시간 계산, 제목 뽑기, 검색어 이스케이프, 비밀번호 해싱, 알림 보내기.
Playwright 39개 — 브라우저. 공개 페이지, 접근 제어, 글 쓰고 고치고 지우기, 댓글, 연락 폼, 이미지 올리기, 비밀번호 바꾸기.
여기에 axe로 접근성까지 본다. 공개 페이지 다섯 개와 404, 글 화면을 라이트와 다크 각각 훑는다. 색 대비가 테마마다 다르다는 걸 겪었기 때문이다.
시험을 짜면서 배운 것 두 가지.
하나, 클릭은 서버 액션이 끝나기 전에 반환된다. 클릭하고 바로 다른 주소로 옮겨 가면 전송이 취소된다. 세 번에 한 번쯤 실패하는 시험이 됐다. 목록에 나타난 것을 확인하고 넘어가게 고쳤다.
둘, 로그인 실패는 아껴 써야 한다. 로그인 시도를 10분에 5번으로 막아 뒀는데, 시험이 일부러 틀린 비밀번호를 넣으면 그 횟수를 까먹는다. 뒤에 오는 시험이 전부 잠겨서 줄줄이 실패했다.
10. 지금 상태
| 버전 | v0.9.0 |
| 커밋 | 170개 |
| 배포 | 14번 |
| 표 | 12개 |
| 마이그레이션 | 11개 |
| 시험 | 단위 76 · 브라우저 39 |
| Lighthouse | 데스크톱 성능 98 · 접근성 100 · 권장사항 100 · SEO 100 |
앞단을 Caddy로 묶은 이야기는 따로 적었다.
남은 것은 대시보드와, 모바일에서 첫 화면이 1초쯤 늦는 문제다. 한글 폰트 CSS가 화면 그리기를 막는데, 화면을 막지 않게 불러오면 빨라지는 대신 글자가 잠깐 시스템 글꼴로 나왔다가 바뀐다. 보기와 속도를 맞바꾸는 일이라 아직 정하지 못했다.
되돌아보면
가장 잘한 결정은 내용을 DB에 넣은 것이다. 폰에서 고칠 수 있으니 실제로 고친다. 파일로 뒀으면 첫 판 그대로 방치했을 것이다.
가장 여러 번 발목을 잡은 것은 확인을 눈으로만 한 것이다. 화면에 찍힌 오류를 보고도 넘어갔고, 맞는 값이 통하는 것만 보고 틀린 값이 막히는지는 안 봤다. 세 번 연속 배포에서 넘어졌는데 원인이 다 같았다.
댓글1
댓글이 잘되나 시험