Skip to content

Commit 45c3f75

Browse files
authored
chore: week2 audit (#34)
1 parent 852f5ea commit 45c3f75

16 files changed

Lines changed: 136 additions & 46 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# ---------------------------------------------------------------------------
55

66
# --- App ---
7+
# 외부 노출 URL. layout.tsx 의 metadataBase 가 이걸 쓴다 (OG/사이트맵 절대 URL).
8+
# 비워두면 Next 가 요청 host 로 fallback — 로컬 개발에선 그래도 OK.
79
NEXT_PUBLIC_APP_URL=http://localhost:3000
810

911
# --- Database (SQLite) ---

app/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { Metadata, Viewport } from "next";
22
import "./globals.css";
33

4+
// NEXT_PUBLIC_APP_URL 이 비어 있으면 Next 기본(요청 host)을 쓴다.
5+
// staging/prod 의 .env 에 외부 URL 을 넣어두면 OG 이미지/링크가 그 URL 기준이 됨.
6+
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
7+
48
export const metadata: Metadata = {
9+
metadataBase: appUrl ? new URL(appUrl) : undefined,
510
title: "뮤런",
611
description: "애니뮤 러닝 소모임의 정기 운동 아카이브",
712
};

app/sessions/[id]/_components/PhotoSection.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useActionState } from "react";
55

66
import { ErrorAlert } from "@/components/form/ErrorAlert";
77
import { SubmitButton } from "@/components/form/SubmitButton";
8+
import { encodeUploadPath } from "@/lib/upload-url";
89

910
import { uploadSessionPhoto, removeSessionPhoto } from "../photo-actions";
1011
import { PhotoReplaceButton } from "./PhotoReplaceButton";
@@ -37,7 +38,7 @@ export function PhotoSection({
3738
const error = uploadError ?? removeError;
3839

3940
if (groupPhotoPath) {
40-
const src = `/api/uploads/${encodePath(groupPhotoPath)}`;
41+
const src = `/api/uploads/${encodeUploadPath(groupPhotoPath)}`;
4142
return (
4243
<section className="mb-6">
4344
<div className="relative aspect-video w-full overflow-hidden rounded-md border bg-muted">
@@ -109,9 +110,3 @@ export function PhotoSection({
109110
);
110111
}
111112

112-
function encodePath(relPath: string): string {
113-
return relPath
114-
.split("/")
115-
.map((s) => encodeURIComponent(s))
116-
.join("/");
117-
}

app/sessions/[id]/page.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,6 @@ export default async function SessionDetailPage({ params }: PageProps) {
6969
{sessionRow.weather && <span>{sessionRow.weather} · </span>}
7070
호스트: {sessionRow.host.name}
7171
</p>
72-
{isHostOrAdmin && (
73-
<p className="text-xs text-muted-foreground">
74-
[세션 수정 — 다음 PR에서 추가]
75-
</p>
76-
)}
7772
</header>
7873

7974
<PhotoSection

app/sessions/_components/SessionCard.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Image from "next/image";
22
import Link from "next/link";
33

4+
import { encodeUploadPath } from "@/lib/upload-url";
5+
46
type Props = {
57
id: number;
68
date: Date;
@@ -14,7 +16,7 @@ type Props = {
1416

1517
export function SessionCard(props: Props) {
1618
const photoSrc = props.groupPhotoPath
17-
? `/api/uploads/${encodePath(props.groupPhotoPath)}`
19+
? `/api/uploads/${encodeUploadPath(props.groupPhotoPath)}`
1820
: null;
1921

2022
return (
@@ -65,9 +67,3 @@ function formatDate(d: Date): string {
6567
}).format(d);
6668
}
6769

68-
function encodePath(relPath: string): string {
69-
return relPath
70-
.split("/")
71-
.map((s) => encodeURIComponent(s))
72-
.join("/");
73-
}

auth.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const authConfig = {
1414
session: { strategy: "jwt" },
1515
providers: [
1616
Google({
17-
clientId: process.env.AUTH_GOOGLE_ID,
18-
clientSecret: process.env.AUTH_GOOGLE_SECRET,
17+
clientId: process.env.AUTH_GOOGLE_ID ?? "",
18+
clientSecret: process.env.AUTH_GOOGLE_SECRET ?? "",
1919
authorization: {
2020
params: {
2121
hd: REQUIRED_HD,

deploy/.env.example

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,55 @@
1-
# deploy/.env — N100 호스트에서 compose 가 읽는 환경변수.
2-
# repo에는 example 만, 실제 값은 호스트의 deploy/.env (gitignore됨).
1+
# ---------------------------------------------------------------------------
2+
# 뮤런 — N100 host 용 compose env.
3+
# repo에는 example 만. 실제 파일은 호스트의 deploy/.env(또는 .env.staging /
4+
# .env.prod) 로 두고 commit 금지. .gitignore 가 해당 파일을 무시함.
35
#
4-
# 사용:
6+
# 사용 (수동, 단일 환경):
57
# cp .env.example .env
68
# docker compose up -d --build
79
#
8-
# 이 파일은 compose 자신과 app 컨테이너 둘 다에 주입된다 (compose 의 env_file: .env).
10+
# 사용 (staging / prod 분리):
11+
# cp .env.example .env.staging
12+
# cp .env.example .env.prod
13+
# MURUN_ENV_FILE=.env.staging docker compose -p murun-staging \
14+
# --env-file .env.staging up -d
15+
#
16+
# 이 파일은 compose 측 변수(IMAGE_TAG, DOMAIN 등) + app 컨테이너의 런타임 env
17+
# 두 가지 용도로 같이 쓰인다 (compose 의 env_file: ${MURUN_ENV_FILE:-.env}).
18+
# ---------------------------------------------------------------------------
919

10-
# --- compose 측 ---
20+
# --- compose / image ---
21+
# 호스트에서 직접 docker compose up 할 때만 의미 있음. GH Actions deploy 가
22+
# 돌리는 경우엔 workflow 가 IMAGE_TAG=<target>-<sha> 로 덮어쓴다.
1123
IMAGE_TAG=local
1224

1325
# --- Caddy ---
1426
# 도메인 붙이기 전: ":80" (자동 HTTP만, 인증서 발급 시도 안 함)
15-
# 도메인 붙은 후: "murun.duckdns.org" (Let's Encrypt 자동)
27+
# 도메인 붙은 후: "murun.duckdns.org" (Let's Encrypt 자동 HTTPS)
1628
DOMAIN=:80
1729
CADDY_HTTP_PORT=80
1830
CADDY_HTTPS_PORT=443
1931

2032
# --- App ---
2133
NODE_ENV=production
22-
# SQLite 파일 경로 (app 컨테이너 내부의 /app/data 호스트 volume murun_data매핑됨)
34+
# SQLite 파일 경로. /app/data 호스트 volume murun-*_data마운트됨.
2335
DATABASE_URL=file:/app/data/murun.db
36+
# 외부 노출 URL (layout.tsx metadataBase + 향후 OG 절대 URL 용).
37+
# IP 임시 테스트: http://<N100-IP>
38+
# DuckDNS 붙은 뒤: https://<name>.duckdns.org
2439
NEXT_PUBLIC_APP_URL=http://localhost
2540
UPLOADS_DIR=/app/uploads
2641

2742
# --- Auth.js v5 ---
43+
# Auth.js base URL. AUTH_URL 도 NEXT_PUBLIC_APP_URL 과 동일하게 외부 URL.
2844
AUTH_URL=http://localhost
29-
AUTH_SECRET= # openssl rand -base64 32
45+
# 32 bytes 이상. 생성: openssl rand -base64 32. staging/prod 는 서로 다른 값.
46+
AUTH_SECRET=
3047
AUTH_TRUST_HOST=true
3148

3249
# --- Google OAuth ---
50+
# Google Cloud Console → Credentials → OAuth 2.0 Client ID 새로 발급.
51+
# Authorized redirect URI 에 <AUTH_URL>/api/auth/callback/google 등록.
3352
AUTH_GOOGLE_ID=
3453
AUTH_GOOGLE_SECRET=
54+
# 이메일 도메인 강제. snu.ac.kr 이외는 lib/auth.ts signIn callback 에서 reject.
3555
AUTH_GOOGLE_HD=snu.ac.kr

docs/wiki/Retrospective-Week2.md

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 회고 · Week 2
22

3-
기간: 2026-06-01 ~ 2026-06-07
3+
기간: 2026-06-01 ~ 2026-06-08
44

55
이번 주는 처음으로 코드가 도는 주차였다. 1주차에 정한 결정들을 끼워 맞춰 보니 사람이 보는 surface 가 한 번 다 깔렸고 — 동시에, 내가 정한 워크플로우 룰이 실제로 어떤 부분에서 약했는지가 드러났다.
66

@@ -18,9 +18,12 @@
1818
| Hotfix: 빈 입력 noop / encType 제거 / `Session.id` cuid→Int | #28 |
1919
| 폼 inline validation + double-submit 차단 (`useActionState` + `useFormStatus`) | #29 |
2020
| `/sessions` 아카이브 리스트 + 워크플로우 보강 (SKILL §4.5) | #30 |
21-
| 사진/admin 폼 retrofit + CI + Deploy + 이 회고 | (이 PR) |
21+
| 사진/admin 폼 retrofit + CI + Deploy + 회고 | #31 |
22+
| Hotfix: Docker image build (public/ 누락, GHCR 이름 불일치, deploy secrets 분기) | #32 |
23+
| Hotfix: Prisma runtime deps + healthcheck `/api/health` + N100 git sync | #33 |
24+
| 코드 audit: cursor 페이지네이션 / 죽은 env / server-only 가드 | (이 PR) |
2225

23-
8 + 1 PR. 모두 `dev` 에 머지. `main`아직 비어있고 Week 2 마지막에 `v0.1.0` 태그와 함께 첫 머지 예정.
26+
11 + 1 PR. 모두 `dev` 에 머지. `main` 은 Week 2 마지막에 `v0.1.0` 태그와 함께 첫 머지.
2427

2528
---
2629

@@ -38,7 +41,38 @@
3841
- `.gjc/skills/murun-feature/SKILL.md` §4.5 — 새 폼/입력 추가할 때 negative case alert / `useActionState` / `useFormStatus` / DB unique 안전망을 **체크 없이 진행 금지** 로 명시.
3942
- `docs/wiki/06-Checkpoints.md` #7 — "happy path 만으로는 부족, 빈 입력 / 잘못된 타입 / 더블 클릭 / 권한 없는 사용자를 머릿속에서라도 흘려본다."
4043

41-
이번 PR(#?)에서 그 룰을 이미 있는 폼(사진 업로드, admin 승인/거절) 에 소급 적용했다. 처음부터 박혔어야 할 코드가 늦게 들어간 거라 새로운 가치는 없지만, **룰이 실제로 문서에 머무르지 않고 다음 PR 에 영향을 줬다는 게 1주차에 정한 것과의 차이**.
44+
PR #31 에서 그 룰을 이미 있는 폼(사진 업로드, admin 승인/거절) 에 소급 적용했다. 처음부터 박혔어야 할 코드가 늦게 들어간 거라 새로운 가치는 없지만, **룰이 실제로 문서에 머무르지 않고 다음 PR 에 영향을 줬다는 게 1주차에 정한 것과의 차이**.
45+
46+
---
47+
48+
## 사고 시즌 2: 배포 사이클의 hotfix 3연발
49+
50+
CI/Deploy 파이프라인을 올리고 N100 에 처음 SSH 배포를 굴리면서 사고 3건이 추가로 터졌다 (#32, #33, 이번 PR). 코드는 로컬에서 그린이었는데, 환경이 바뀌니 한 번도 같이 돌려본 적 없는 조합에서 깨졌다.
51+
52+
**사고 3. `public/` 디렉터리가 없는데 Dockerfile 이 무조건 COPY (#32).**
53+
앱 빌드는 통과하는데 컨테이너 빌드만 깨졌다. 로컬 `pnpm build` 만 보던 안전망의 사각.
54+
55+
**사고 4. compose 의 image 이름이 GHCR push 이름과 달랐다 (#32).**
56+
build 는 통과, push 도 통과 — N100 에서 pull 만 못 했을 케이스. 직접 N100 배포까지 가지 않았으면 한참 묻혀있었다.
57+
58+
**사고 5. Prisma `migrate deploy` runtime 의존 (#33).**
59+
Dockerfile 이 `node_modules/prisma`, `node_modules/@prisma` 만 복사해서 `effect` 같은 transitive deps 가 누락 → entrypoint 가 부팅 단계에서 죽음. Healthcheck 도 `/` 를 보고 있었는데 그게 로그인 redirect 라 healthy 가 영영 안 됐다. 인증과 무관한 `/api/health` 로 따로 뺐다.
60+
61+
**사고 6. 이번 audit 에서 잡힌 cursor 페이지네이션 누락.**
62+
`orderBy: (date desc, id desc)` 인데 cursor 는 `id < cursorId` 만 봤다. 오래된 날짜인데 늦게 만들어진(=id 가 큰) 세션이 페이지 사이에서 누락. 데이터가 한 자리수일 땐 안 보이는 버그. 이건 사용자가 못 발견했어도 시간 문제였다.
63+
64+
같이 잡은 audit 항목:
65+
- 죽은 `NEXT_PUBLIC_APP_URL` 변수 → `layout.tsx``metadataBase` 로 실제 사용.
66+
- `lib/uploads.ts` 가 client 컴포넌트의 transitive import 로 끌려갈 위험 → `import "server-only"` 가드 + URL helper 분리.
67+
- `[세션 수정 — 다음 PR에서 추가]` placeholder 노출 → 제거.
68+
69+
**이 시즌의 공통 패턴.** 모두 "로컬에서 한 가지 경로만 굴려보고 그린이면 통과 보고" 였다. 코드는 같은 환경에서 같은 입력으로만 굴렸고, 다른 환경 (Docker, GHCR, N100, edge case 데이터) 은 한 번도 같이 굴려본 적 없었다. Hotfix 가 3번 연달아 터지면서 알게 된 것:
70+
71+
- **로컬 build 그린 ≠ 컨테이너 build 그린**. Docker 단계는 다른 안전망이 필요.
72+
- **컨테이너 부팅 ≠ 앱 healthy**. healthcheck 가 진짜 ready 신호를 봐야 함.
73+
- **앱이 healthy ≠ 새 코드가 N100 에 도달**. deploy pipeline 의 git sync 가 빠지면 image 만 새거고 compose 파일은 옛것.
74+
75+
수정: `.gjc/skills/murun-feature/SKILL.md``06-Checkpoints.md` 에 "환경별 검증 분리" 를 추가한다 (3주차에서 보강).
4276

4377
---
4478

@@ -52,9 +86,10 @@
5286

5387
AI 가 실제 브라우저를 못 클릭하는 건 사실이다. 그렇다고 머릿속 시뮬레이션까지 못 할 이유는 없다. "이 폼이 빈 입력이면 어떻게 되지?" 같은 질문은 코드를 다시 한 번 읽으면 답이 나온다. 그걸 안 했을 뿐이다.
5488

55-
3주차에 적용할 변화는 개:
89+
3주차에 적용할 변화는 개:
5690
1. spec 의 "Out of scope" 가 정말 분리 가치가 있는지, 아니면 그냥 미루는 건지 구분한다. 미루는 것일 가능성이 보이면 OOS 에서 빼고 같은 PR 에 합친다.
5791
2. PR 본문의 Fidelity check #7 에 negative case 머릿속 시나리오 1~2줄을 강제로 적는다 — "빈 입력 → ...", "권한 없는 사용자 → ..." 같은 한 줄.
92+
3. 인프라/배포 PR 은 **로컬 build 그린 + 컨테이너 build 그린 + healthcheck 가 진짜 ready 를 보는가** 세 줄을 PR 본문에 명시. 코드만 보면 사고 시즌 2가 또 반복된다.
5893

5994
---
6095

@@ -76,20 +111,24 @@ AI 가 실제 브라우저를 못 클릭하는 건 사실이다. 그렇다고
76111
- 이번 PR 의 Deploy 워크플로우 — dev push → ghcr.io build → SSH → N100 staging 자동. main 은 environment 보호 후 수동 승인.
77112
- SSH key + N100_HOST 등 secrets 등록은 호스트(나) 의 책임. README 에 절차가 있고, 등록 전에는 이미지 build/push 까지만 성공시키고 SSH deploy 는 skip 하도록 했다.
78113

79-
남은 호스트 작업:
80-
- N100 에서 첫 `docker compose up -d` 직접 — 코드의 진짜 검증
81-
- duckdns 도메인 연결 (Week 3 초)
114+
남은 호스트 작업 (Week 2 안에 끝남):
115+
- N100 에서 `docker compose up -d` 로 staging 기동 — 자동 배포 파이프라인 통과 확인
82116
- `dev → main` 첫 머지 + `v0.1.0` 태그
83117

118+
Week 3 로 미루는 호스트 작업:
119+
- duckdns 도메인 + HTTPS
120+
- prod environment 보호 + main → prod 라인 첫 발사
121+
84122
---
85123

86124
## 다르게 한다면 (3주차에서 바꾸는 것)
87125

88126
1. **spec 작성 시 OOS 항목을 "정말 미루는가" 한 번 자문**. PR #26/#28 의 inline validation 처럼, OOS 가 사실 사용자 경험을 직접 망치는 거면 같은 PR 에 합친다.
89127
2. **PR 본문 Fidelity #7 에 negative case 한 줄 명시 강제**. "빈 입력 → alert", "더블 클릭 → dedupe 동작" 같이 머릿속 시나리오를 글로.
90128
3. **AI 가 만든 PR 을 머지하기 전 코드 측 검증 + 머릿속 시나리오 둘 다 통과한 다음에만 사용자에게 검증 요청**. 빌드 그린만 보고 "검증 가능" 이라고 보고하는 패턴 폐기.
91-
4. **PR scope 묶기/쪼개기 결정을 한 줄 메모로**. 이번 PR 처럼 4 commit 1 PR 도 합리적, 작은 PR 4개도 합리적. 결정 기준은 "사용자 머지 부담" 과 "도메인 응집도" — 매번 자문.
92-
5. **dev → main 머지 주기를 정해둠**. 매 PR 마다 하지 않더라도, Week 2 끝/3 끝에 v0.x.0 태그와 함께 일괄. main 이 비어있는 채로 가는 기간이 너무 길지 않게.
129+
4. **PR scope 묶기/쪼개기 결정을 한 줄 메모로**. 4 commit 1 PR 도 합리적, 작은 PR 여러 개도 합리적. 결정 기준은 "사용자 머지 부담" 과 "도메인 응집도".
130+
5. **dev → main 머지 주기를 정해둠**. 매 PR 마다 하지 않더라도, Week 끝마다 v0.x.0 태그와 함께 일괄. main 이 비어있는 채로 가는 기간이 너무 길지 않게.
131+
6. **배포 PR 은 다른 환경까지 한 사이클 굴려본 다음 머지**. local pnpm check 그린만 보고 배포 PR 머지하지 않는다. workflow_dispatch 로 한 번 굴려본 다음 그린이면 머지.
93132

94133
---
95134

lib/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// → 관리자가 승인하면 다음 요청부터 즉시 반영 (재로그인 불필요)
1111
// 6. session callback 이 user.id/approved/role 을 Session 객체에 노출
1212

13+
import "server-only";
1314
import NextAuth from "next-auth";
1415
import { PrismaAdapter } from "@auth/prisma-adapter";
1516

lib/db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// DB connection 누수가 발생한다. globalThis 에 한 번 캐싱해서 재사용한다.
55
// 운영 환경(node --production)에선 cache 자체가 의미 없으므로 매번 새로 만든다.
66

7+
import "server-only";
78
import { PrismaClient } from "@prisma/client";
89

910
const globalForPrisma = globalThis as unknown as {

0 commit comments

Comments
 (0)