204 lines
9.5 KiB
Markdown
204 lines
9.5 KiB
Markdown
# Design — todo-app (간단한 To-Do 앱)
|
|
|
|
> PDCA Phase: **Design** · Feature: `todo-app` · 작성일: 2026-06-15
|
|
> 선택 아키텍처: **Option C — 실용 균형** · 첨부 다운로드: **서버 프록시 스트리밍**
|
|
> 상위 문서: [Plan](../../01-plan/features/todo-app.plan.md)
|
|
|
|
## Context Anchor
|
|
|
|
| 항목 | 내용 |
|
|
|------|------|
|
|
| **WHY** | 사내 워크스페이스 샘플을 실제로 쓸 수 있는 최소 To-Do 앱으로 만들어 DB+S3 활용 패턴을 보여준다. |
|
|
| **WHO** | 로그인 없이 접근하는 단일 사용자/소규모 팀(단일 공용 목록). |
|
|
| **RISK** | 첨부파일 처리(크기/타입), schema 마이그레이션 누락, S3 키-DB 레코드 정합성. |
|
|
| **SUCCESS** | 브라우저에서 할 일 CRUD + 첨부 업로드/다운로드가 동작하고 `npm run db:check`/`minio:check` 통과. |
|
|
| **SCOPE** | IN: todo CRUD, 첨부 1개/항목, 웹 UI, REST API. OUT: 인증/멀티유저, 마감일/태그/검색, 실시간. |
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
기존 Express 앱(`src/server.js`)에 To-Do 도메인을 추가한다. 구조화 데이터는 Postgres(`src/db.js` pool),
|
|
첨부파일 바이너리는 MinIO/S3(`src/s3.js` client)에 저장하고 두 저장소를 S3 object key로 연결한다.
|
|
|
|
**아키텍처 결정 (Option C — 실용 균형)**: route / repo / attachments 3개 모듈로 관심사를 분리하되,
|
|
별도 service 계층은 두지 않는다(라우트 핸들러가 repo+attachments를 조합). "간단한 앱" 목표에 맞는 최소한의 경계.
|
|
|
|
**다운로드 결정 (서버 프록시 스트리밍)**: 브라우저는 앱 엔드포인트에만 요청하고, 서버가 `GetObjectCommand`로
|
|
S3 객체를 받아 응답 스트림으로 전달. 내부 MinIO 호스트(`minio.bokdev.in`) 노출 없이 환경 무관하게 동작.
|
|
→ presigned URL 불필요, `@aws-sdk/s3-request-presigner` 의존성 추가하지 않음.
|
|
|
|
## 2. 모듈 구조
|
|
|
|
```
|
|
src/
|
|
config.js (기존) 변경 없음
|
|
db.js (기존) pool, pingDb — 재사용
|
|
s3.js (기존) s3 client, pingS3 — 재사용. GetObject/PutObject/DeleteObject 추가 export
|
|
schema.js (신규) todo 테이블 멱등 생성 (앱 시작 시 ensureSchema 호출)
|
|
todos.repo.js (신규) DB CRUD 쿼리 (pool 사용)
|
|
attachments.js (신규) S3 put / get(stream) / delete 래퍼
|
|
routes.todos.js (신규) Express Router — REST 핸들러
|
|
server.js (수정) express.json/static 미들웨어, schema 부트스트랩, 라우터 마운트
|
|
public/
|
|
index.html (신규) 단일 페이지 웹 UI (vanilla JS fetch)
|
|
```
|
|
|
|
## 3. 데이터 모델
|
|
|
|
```sql
|
|
-- schema.js 에서 CREATE TABLE IF NOT EXISTS 로 멱등 실행
|
|
CREATE TABLE IF NOT EXISTS todo (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
title text NOT NULL,
|
|
done boolean NOT NULL DEFAULT false,
|
|
attachment_key text, -- S3 object key (없으면 NULL)
|
|
attachment_name text, -- 원본 파일명
|
|
attachment_type text, -- content-type
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_todo_created_at ON todo (created_at DESC);
|
|
```
|
|
|
|
- `gen_random_uuid()`는 pgcrypto 확장이 필요. 미존재 환경 대비: schema.js에서 `CREATE EXTENSION IF NOT EXISTS pgcrypto`를
|
|
먼저 시도하고, 권한 부족으로 실패하면 앱에서 `crypto.randomUUID()`로 id를 생성해 INSERT (fallback).
|
|
- **S3 키 규칙**: `todos/{todo_id}/{원본파일명}` — 버킷은 `config.s3.bucket`(`coolify-user-data`).
|
|
|
|
## 4. API 계약
|
|
|
|
| Method | Path | Request | Response (성공) | 비고 |
|
|
|--------|------|---------|------|------|
|
|
| GET | `/` | — | `text/html` (index.html) | 정적 제공 |
|
|
| GET | `/api/todos` | — | `200 { data: Todo[] }` | created_at DESC |
|
|
| POST | `/api/todos` | `multipart/form-data`: `title`(필수), `file`(선택) | `201 { data: Todo }` | file 있으면 S3 업로드 후 key 기록 |
|
|
| PATCH | `/api/todos/:id` | `application/json`: `{ done: boolean }` | `200 { data: Todo }` | 토글 |
|
|
| DELETE | `/api/todos/:id` | — | `204` | S3 첨부 먼저 삭제 후 DB row 삭제 |
|
|
| GET | `/api/todos/:id/attachment` | — | `200` 스트림(`Content-Type`, `Content-Disposition`) | 프록시 스트리밍, 첨부 없으면 404 |
|
|
|
|
**공통 규약**
|
|
- 성공 바디: `{ data: ... }`, 에러 바디: `{ error: string }`.
|
|
- 입력 검증: `title` 비어있으면 `400 { error }`. 잘못된 `:id`(uuid 형식 아님) → `400`. 없는 todo → `404`.
|
|
- 업로드 제한: multer `limits.fileSize = 10MB`. 초과 시 `413 { error }`.
|
|
|
|
### Todo 객체 형태
|
|
```json
|
|
{
|
|
"id": "uuid",
|
|
"title": "string",
|
|
"done": false,
|
|
"attachment_name": "report.pdf | null",
|
|
"attachment_type": "application/pdf | null",
|
|
"hasAttachment": true,
|
|
"created_at": "ISO8601"
|
|
}
|
|
```
|
|
> `attachment_key`는 응답에서 제외(내부 키 비노출), 대신 `hasAttachment` boolean 노출.
|
|
|
|
## 5. 처리 흐름 (핵심 시퀀스)
|
|
|
|
**POST /api/todos (첨부 포함)**
|
|
1. multer가 `title` + `file`(메모리 버퍼) 파싱 → title 검증
|
|
2. id 생성(또는 INSERT 후 반환된 id) → S3 key `todos/{id}/{filename}` 결정
|
|
3. `attachments.put(key, buffer, contentType)` → S3 업로드
|
|
4. repo: INSERT (attachment_key/name/type 포함) → 201 반환
|
|
5. 3단계 실패 시 DB INSERT 하지 않음(또는 보상 삭제) → 500
|
|
|
|
**DELETE /api/todos/:id**
|
|
1. repo: SELECT attachment_key
|
|
2. key 있으면 `attachments.del(key)` 먼저 시도(실패는 로깅하되 계속)
|
|
3. repo: DELETE row → 204
|
|
|
|
**GET /api/todos/:id/attachment**
|
|
1. repo: SELECT attachment_key/name/type → 없으면 404
|
|
2. `attachments.getStream(key)` → `Content-Type`, `Content-Disposition: attachment; filename=...` 세팅
|
|
3. S3 Body 스트림을 res로 pipe
|
|
|
|
## 6. 에러 처리 & 정합성
|
|
|
|
| 상황 | 처리 |
|
|
|------|------|
|
|
| S3 업로드 성공, DB INSERT 실패 | 업로드한 객체 보상 삭제(best-effort) 후 500 |
|
|
| DB 삭제 전 S3 삭제 실패 | 경고 로깅 후 DB 삭제 진행(고아 객체는 허용, 데이터 무결성 우선) |
|
|
| pgcrypto 미존재 | `crypto.randomUUID()` fallback |
|
|
| 파일 크기 초과 | multer 에러 → 413 |
|
|
|
|
## 7. 의존성 변경
|
|
|
|
| 패키지 | 용도 | 비고 |
|
|
|--------|------|------|
|
|
| `multer` | multipart 업로드 파싱(메모리 스토리지) | 신규 추가 |
|
|
| `@aws-sdk/client-s3` | `GetObjectCommand`/`PutObjectCommand`/`DeleteObjectCommand` | 기존, 명령만 추가 사용 |
|
|
|
|
> presigned 방식 미채택 → `@aws-sdk/s3-request-presigner` 추가하지 않음 (Plan 대비 변경점).
|
|
> 추가 후 `npm install`로 lock 갱신.
|
|
|
|
## 8. Test Plan
|
|
|
|
**L1 — API 엔드포인트 (서버 기동 시)**
|
|
- `GET /api/todos` → 200, `{ data: [] }` 형태
|
|
- `POST /api/todos` (title만) → 201, data.id 존재
|
|
- `POST /api/todos` (title 누락) → 400
|
|
- `PATCH /api/todos/:id {done:true}` → 200, data.done=true
|
|
- `DELETE /api/todos/:id` → 204
|
|
- `GET /api/todos/:bad-uuid/attachment` → 400/404
|
|
|
|
**L2 — UI 액션 (수동 또는 Playwright)**
|
|
- 페이지 로드 → 목록 렌더
|
|
- 입력+추가 → 목록에 새 항목, 입력창 비워짐
|
|
- 체크박스 토글 → done 반영
|
|
- 삭제 → 항목 사라짐
|
|
- 파일 첨부 추가 → 다운로드 링크 노출, 클릭 시 파일 받아짐
|
|
|
|
**L3 — E2E 시나리오**
|
|
- 첨부 포함 추가 → 다운로드 → 삭제까지 전체 흐름 무오류
|
|
- 연결 점검: `npm run db:check`, `npm run minio:check` 통과 (SC-05)
|
|
- 컨테이너: `podman build` + `podman run --env-file .project-env` 동작 (SC-06)
|
|
|
|
## 9. Success Criteria 매핑
|
|
|
|
| SC | Design 반영 위치 |
|
|
|----|------------------|
|
|
| SC-01 (CRUD UI) | §2 routes.todos.js + public/index.html, §4 API |
|
|
| SC-02 (업로드→S3+DB key) | §5 POST 흐름, §3 데이터 모델 |
|
|
| SC-03 (다운로드) | §5 GET attachment, 프록시 스트리밍 |
|
|
| SC-04 (삭제 시 S3도 삭제) | §5 DELETE 흐름, §6 정합성 |
|
|
| SC-05 (db/minio check) | §8 L3 |
|
|
| SC-06 (podman) | §8 L3 |
|
|
|
|
## 10. 보안/규약 체크
|
|
|
|
- 비밀값: 전부 env(`config.js`) 경유, 코드/커밋 금지 (CLAUDE.md 준수).
|
|
- 외부 연결: db/s3/config 모듈만 경유.
|
|
- 입력 검증: title 길이/공백, uuid 형식, 파일 크기·(선택)타입.
|
|
- 내부 S3 키/호스트 비노출(프록시 + 응답에서 key 제외).
|
|
|
|
## 11. Implementation Guide
|
|
|
|
### 11.1 구현 순서
|
|
1. `src/schema.js` — ensureSchema (pgcrypto 시도 + CREATE TABLE)
|
|
2. `src/s3.js` 확장 — putObject/getObjectStream/deleteObject export
|
|
3. `src/attachments.js` — 키 생성 규칙 + put/getStream/del
|
|
4. `src/todos.repo.js` — list/create/setDone/getById/remove
|
|
5. `src/routes.todos.js` — 6개 핸들러 + multer 미들웨어 + 검증
|
|
6. `src/server.js` — express.json, static(public), ensureSchema 호출, 라우터 마운트
|
|
7. `public/index.html` — fetch 기반 UI
|
|
8. `package.json` — multer 추가, `npm install`
|
|
|
|
### 11.2 핵심 파일
|
|
- `src/routes.todos.js` (가장 로직 밀집), `src/attachments.js`, `public/index.html`
|
|
|
|
### 11.3 Session Guide (Module Map)
|
|
|
|
| 모듈 키 | 범위 | 포함 파일 | 의존 |
|
|
|---------|------|-----------|------|
|
|
| `module-1` (데이터 계층) | schema + repo + s3/attachments | schema.js, s3.js(확장), attachments.js, todos.repo.js | 없음 |
|
|
| `module-2` (API 계층) | 라우트 + 서버 연결 + 의존성 | routes.todos.js, server.js, package.json | module-1 |
|
|
| `module-3` (UI) | 웹 프런트 | public/index.html | module-2 |
|
|
|
|
**권장 세션 분할**
|
|
- 1세션에 전부 가능(소규모). 분할 시: `--scope module-1` → `--scope module-2` → `--scope module-3`.
|
|
|
|
## 12. 다음 단계
|
|
|
|
`/pdca do todo-app` (전체) 또는 `/pdca do todo-app --scope module-1` (점진 구현).
|