diff --git a/2026-renewal/feature/04-recruit-form-cms.md b/2026-renewal/feature/04-recruit-form-cms.md index e69de29..69ee154 100644 --- a/2026-renewal/feature/04-recruit-form-cms.md +++ b/2026-renewal/feature/04-recruit-form-cms.md @@ -0,0 +1,1317 @@ +# 04. 모집 폼의 동적 구성 기능 (Recruit Form CMS) + +--- + +## 1. 현재 상태 분석 + +### 1.1 문제점 + +현재 모집 폼은 2025년 기준으로 완전히 하드코딩되어 있다. + +- `main/frontend/src/app/recruit/page.tsx` — 765줄짜리 단일 파일에 폼 UI, 유효성 검증, 제출 로직이 전부 박혀있음 +- `main/frontend/src/types/recruitType.d.ts` — `RecruitFormData` 타입이 `name`, `student_id`, `semina`, `dev`, `study`, `external` 등 고정 필드로 선언 +- `main/backend/src/models/Member.js` — Sequelize 모델이 고정 컬럼 (`motivation_semina`, `field_dev`, `portfolio_pdf` 등)으로 구성 +- `main/backend/src/routes/recruit_R2.js` — 필수 필드 검증, 활동별 motivation 검증이 전부 하드코딩 + +### 1.2 구체적 고정 항목들 + +| 영역 | 고정된 내용 | +|------|------------| +| 기본 정보 | 이름, 학과, 학년(1~4 select), 학번, 전화번호 | +| 활동 선택 | 세미나(필수), 개발, 스터디, 대외활동 — 4개 고정 | +| 조건부 필드 | 세미나→주제, 개발→분야/포트폴리오PDF/깃허브, 스터디→참여희망, 대외→참여희망 | +| 유효성 검증 | 학번 10자리, 전화번호 `000-0000-0000` 정규식 — 코드에 직접 박혀있음 | +| 학과 목록 | `recruitData.ts`에 48개 학과 하드코딩 | +| FAQ | `recruitData.ts`에 9개 항목 하드코딩 | + +> **결론:** 내년에 활동이 바뀌거나, 질문 하나를 추가/삭제하려면 프론트엔드 + 백엔드 + DB 스키마를 전부 수정하고 재배포해야 한다. + +### 1.3 현재 시스템 결합 구조 (코드 기준) + +이 기능의 진짜 난이도는 폼 빌더가 아니라, **main과 backoffice가 같은 MySQL/Sequelize 기반 `members` 테이블을 서로 다른 서버에서 공유하는 운영 구조 자체를 바꾸는 일**에 있다. + +```mermaid +graph LR + subgraph Current["현재 구조"] + direction LR + subgraph MainFE["main/frontend"] + direction TB + MF1["recruit/page.tsx
formData shape 완전 고정"] + MF2["세미나 기본값 true, 해제 불가"] + MF3["전화번호 자동 하이픈 로직 하드코딩"] + MF4["FormData.append 개별 필드명 직접 매핑"] + end + subgraph MainBE["main/backend"] + direction TB + MB1["recruit_R2.js
upload.single('portfolio_pdf') - 파일 1개 고정"] + MB2["semina/dev/study/external 활동별 validation 하드코딩"] + MB3["student_id 기반 중복 체크 고정"] + MB4["파일명: 퀴푸_25_1-학번이름 - 연도/학기 하드코딩"] + end + subgraph BOFE["backoffice/frontend"] + direction TB + BF1["recruitDB.jsx
목록 표 컬럼 완전 고정"] + BF2["모달 상세: 고정 필드별 분기 렌더링"] + BF3["엑셀 export: json_to_sheet(data) 그대로"] + BF4["포트폴리오 다운로드: filename 기반 고정 API"] + end + subgraph BOBE["backoffice/backend"] + direction TB + BB1["member.js - 같은 Member 테이블 직접 조회"] + BB2["feature.js - Feature(recruit) 한 줄 토글"] + end + end + DB[(MySQL
members 테이블
+ Feature 테이블)] + MainBE -->|"INSERT"| DB + BOBE -->|"SELECT"| DB + BOBE -->|"UPDATE Feature"| DB +``` + +#### 핵심 결합 포인트 상세 + +**메인 프론트엔드 결합** + +| 위치 | 결합 내용 | +|------|----------| +| `recruit/page.tsx:26` | `formData` shape가 완전 고정 | +| `recruit/page.tsx:32, :147` | 세미나는 기본값 `true`, 해제도 막음 | +| `recruit/page.tsx:227` | 전화번호 자동 하이픈 formatting이 입력 로직에 박혀있음 | +| `recruit/page.tsx:358` | 제출 payload가 `FormData.append`를 개별 필드명으로 직접 매핑 | + +**메인 백엔드 결합** + +| 위치 | 결합 내용 | +|------|----------| +| `recruit_R2.js:16` | `upload.single("portfolio_pdf")` — 파일 필드 1개만 전제 | +| `recruit_R2.js:52` | 활동별 validation이 `semina/dev/study/external` 하드코딩 | +| `recruit_R2.js:89` | 중복 제출 체크가 `student_id` 하나로 고정 | +| `recruit_R2.js:106` | 파일명이 `퀴푸_25_1-${student_id}${name}` 형식 — 연도/학기까지 하드코딩 | + +**백오피스 프론트엔드 결합** + +| 위치 | 결합 내용 | +|------|----------| +| `recruitDB.jsx:221` | 목록 표 컬럼이 `student.name`, `student.grade`, `student.major` 등 완전 고정 | +| `recruitDB.jsx:299` | 모달 상세도 고정 필드별 분기 렌더링 | +| `recruitDB.jsx:403` | 엑셀 export가 `json_to_sheet(data)` — 동적 필드 구조 들어오면 즉시 깨짐 | +| `recruitDB_api.jsx:55` | 포트폴리오 다운로드가 filename 기반 고정 API | + +**백오피스 백엔드 결합** + +| 위치 | 결합 내용 | +|------|----------| +| `member.js:9` | 같은 `Member` 테이블을 직접 `SELECT` | +| `recruit.js:3` | 모집 ON/OFF가 폼 단위가 아닌 `Feature(feature_name="recruit")` 한 줄 토글 | +| `feature.js:7`, `app.js:44` | 인증이 세션 + `isLoggedIn` 기반 | + +### 1.4 변경 범위 재정의 + +이번 변경은 폼 UI만 바꾸는 것이 아니라 다음을 한 번에 움직여야 한다: + +1. **제출 저장소 변경** — MySQL `members` 테이블 → MongoDB `recruit_responses` 컬렉션 +2. **백오피스 조회 방식 변경** — 고정 컬럼 뷰어 → 동적 필드 기반 렌더링 +3. **모집 상태 관리 방식 변경** — 전역 Feature toggle → 폼 단위 상태 +4. **파일 다운로드 방식 변경** — filename 기반 → responseId + fieldId 기반 +5. **백오피스 데이터 export 변경** — 고정 컬럼 → 동적 필드 flatten → CSV +6. **백오피스 인증 모델 전환** — 기존 세션 + `isLoggedIn` → Google OAuth (01-bo-auth 범위) + +--- + +## 2. 목표 + +백오피스에서 모집 폼을 구글폼처럼 자유롭게 설계하고, 메인 웹은 해당 정의를 기반으로 동적 렌더링한다. + +- 연도별 모집 정책 변경 시 **코드 수정 0**, 설정 변경만으로 반영 (핵심: 백오피스/메인 재배포 없이 데이터 정의만 변경) +- 필드 추가/삭제/순서변경/유효성규칙 변경을 백오피스 UI에서 처리 +- Regex 기반 커스텀 유효성 검증 지원 +- **기존 시스템을 안전하게 떠나는 마이그레이션 경로 확보** + +--- + +## 3. 저장소 기술 결정: MongoDB + +### 3.1 프로젝트 전체 방향 + +2026 Renewal 프로젝트의 기술 스택은 **TypeScript + MongoDB 중심 아키텍처**로 확정되어 있다. (`01-overview.md` §6, §9.3) + +- 전체 기술 스택: React, TypeScript, Express, **MongoDB** +- 배포 인프라: 프론트엔드 Vercel, 백엔드 Render, DB **MongoDB Atlas** +- 필수 도구: **MongoDB Compass** + +### 3.2 MongoDB가 이 기능에 특히 적합한 이유 + +- **동적 스키마**: 폼 필드 구조가 연도/학기마다 바뀌므로, 고정 컬럼 스키마보다 도큐먼트 모델이 자연스러움 +- **중첩 문서 쿼리**: `answers[].fieldId` / `answers[].value` 기반 중복 체크가 `$elemMatch`로 네이티브 지원 +- **인덱싱**: `answers.fieldId` + `answers.value` 복합 인덱스로 중복 체크 성능 확보 +- **JSON 직렬화 불필요**: 폼 정의와 응답 구조가 그대로 도큐먼트로 저장/조회됨 + +--- + +## 4. 구글폼 분석 기반 설계 원칙 + +구글폼의 핵심 구조를 분석하여 QUIPU에 필요한 범위만 채택한다. + +### 4.1 구글폼 핵심 구조 + +```mermaid +graph LR + Form --> Info["info: title, description"] + Form --> Settings["settings: isAcceptingResponses"] + Form --> Items["items[]: flat ordered array"] + Items --> Q["QuestionItem (질문)"] + Items --> PB["PageBreakItem (섹션 구분)"] + Items --> T["TextItem (설명 텍스트)"] + Items --> M["ImageItem / VideoItem (미디어)"] +``` + +- 섹션은 별도 트리가 아니라 `items[]` 배열에 `pageBreak`를 끼워넣는 **flat 구조** +- 조건 분기는 단일선택(radio/dropdown) 옵션에 `goToSectionId`를 붙이는 방식 +- 응답은 `questionId → value` 맵으로 저장 (동적 스키마에 최적) + +### 4.2 구글폼 필드 타입 → QUIPU 채택 범위 + +| 구글폼 타입 | QUIPU 채택 | 비고 | +|------------|-----------|------| +| Short Text | ✅ | 숫자 입력도 `short_text` + `pattern` 검증으로 처리 | +| Paragraph (Long Text) | ✅ | | +| Multiple Choice (Radio) | ✅ | | +| Checkboxes | ✅ | | +| Dropdown | ✅ | | +| File Upload | ✅ | 다중 파일 지원 (`maxFiles`) | +| Linear Scale | ❌ | | +| Date / Time | ❌ | | +| Grid | ❌ | | +| Rating | ❌ | | + +> **숫자 입력 정책:** 별도 `number` 필드 타입은 두지 않는다. 숫자 입력이 필요한 경우 `short_text` + `pattern: "^[0-9]+$"` 또는 `min/max` validation으로 처리한다. + +### 4.3 구글폼 유효성 검증 → QUIPU 채택 범위 + +| 구글폼 검증 | QUIPU 채택 | +|------------|-----------| +| Required | ✅ (`FormField.required`로 관리, validation 배열과 분리) | +| Min/Max Length | ✅ | +| Regex Pattern | ✅ | +| Number Range (min/max) | ✅ (`short_text`에 적용, 값을 숫자로 파싱하여 비교) | +| Email / URL 형식 | ✅ (preset 패턴) | +| Custom Error Message | ✅ | + +--- + +## 5. 데이터 모델 (MongoDB) + +### 5.1 핵심 설계 결정 + +| 결정 | 내용 | 근거 | +|------|------|------| +| **required 단일 진실원** | `FormField.required`를 유일한 필수 여부 기준으로 사용한다. `validation[]`에 `"type": "required"`를 포함하지 않는다. | 프론트/백 검증 시 우선순위 충돌 방지 | +| **폼 상태 모델** | `status: "draft" \| "published" \| "closed" \| "archived"` 사용 | 운영 라이프사이클 표현 (초안→게시→종료→보관) | +| **options 자유 변경** | `options[].label`과 `options[].value` 모두 자유롭게 변경 가능. | MongoDB 스키마리스, 운영 유연성 우선 | +| **폼 버전 관리** | `version: number` 필드 추가. 수정마다 +1. 제출 시 클라이언트가 version을 함께 전송. | 작성 중 폼 변경 시 낙관적 동시성 제어 | +| **폼 식별** | `title`로 관리. `year`/`semester`는 메타데이터일 뿐 unique 제약 없음. | 같은 학기에 여러 draft를 만들 수 있어야 하고, 복제 시에도 충돌 없음 | +| **기본 섹션** | 필드는 반드시 섹션에 소속. 폼 생성 시 기본 섹션 1개 자동 생성. | 섹션 없는 필드 방지 | +| **백엔드 검증 원칙** | 프론트엔드를 신뢰하지 않는다. 백엔드에서 모든 검증을 독립적으로 재수행한다. | 보안 | + +### 5.2 폼 정의 — `RecruitForm` 컬렉션 + +```typescript +// ---- 필드 타입 ---- +type FieldType = + | "short_text" + | "long_text" + | "single_choice" // radio + | "multiple_choice" // checkbox + | "dropdown" + | "file_upload"; + +// ---- 유효성 검증 규칙 ---- +// ⚠️ "required"는 여기 포함하지 않는다. FormField.required를 단일 진실원으로 사용. +type ValidationType = "minLength" | "maxLength" | "min" | "max" | "pattern" | "preset"; + +interface ValidationRule { + type: ValidationType; + value: string | number; + // type=pattern → value는 regex 문자열 (예: "^[0-9]{10}$") + // type=preset → value는 "email" | "url" | "phone" | "student_id" | "korean_name" | "number_only" + // type=min/max → value는 숫자 (short_text의 값을 숫자로 파싱하여 비교) + // type=minLength/maxLength → value는 숫자 + message: string; // 검증 실패 시 표시할 에러 메시지 +} + +// ---- 조건부 표시 규칙 ---- +// is_empty, is_not_empty 연산자에는 value가 불필요하므로 optional +interface ConditionalRule { + fieldId: string; + operator: "equals" | "not_equals" | "contains" | "is_empty" | "is_not_empty"; + value?: string | string[]; // is_empty, is_not_empty에서는 생략 +} + +interface ConditionalLogic { + action: "show" | "hide"; + logicType: "all" | "any"; // AND / OR + rules: ConditionalRule[]; +} + +// ---- 섹션 분기 (구글폼 goToSection 방식) ---- +interface BranchRule { + optionValue: string; // 이 옵션을 선택하면 + goToSectionId: string; // 이 섹션으로 이동 + goToAction?: "next" | "submit"; +} + +// ---- 폼 필드 (질문 항목) ---- +interface FormField { + id: string; // uuid + type: FieldType; + label: string; // 질문 텍스트 + description?: string; // 부가 설명 + placeholder?: string; + required: boolean; // ⚠️ 필수 여부의 단일 진실원. validation[]에 required를 넣지 않는다. + order: number; // 섹션 내 정렬 순서 + sectionId: string; // 소속 섹션 id (필드는 반드시 섹션에 소속) + isListColumn?: boolean; // 백오피스 목록 테이블에 표시할 필드 여부 + + // single_choice, multiple_choice, dropdown 전용 + options?: { + label: string; // 표시용 + value: string; // 저장 기준값 + }[]; + + // file_upload 전용 + fileConfig?: { + accept: string[]; // ["application/pdf", "image/*"] + maxSizeMB: number; // 기본 5 + maxFiles: number; // 1이면 단일, 2 이상이면 다중 + }; + + // 유효성 검증 (required 제외) + validation: ValidationRule[]; + + // 조건부 표시 + conditionalLogic?: ConditionalLogic; + + // 섹션 분기 (single_choice, dropdown에서만 사용) + branchRules?: BranchRule[]; +} + +// ---- 섹션 ---- +interface FormSection { + id: string; + title: string; + description?: string; + order: number; + // 섹션 단위 조건부 표시 (이 섹션 전체를 조건부로 보이기/숨기기) + conditionalLogic?: ConditionalLogic; +} + +// ---- 폼 상태 ---- +type FormStatus = "draft" | "published" | "closed" | "archived"; +// draft → 작성 중. 메인 웹에 노출되지 않음. +// published → 게시됨. 메인 웹에서 조회/제출 가능. 동시에 1개만 published. +// closed → 모집 종료. 메인 웹에서 "모집 기간이 아닙니다" 표시. 응답 조회 가능. +// archived → 보관 완료. 백오피스 목록에서 별도 필터로 조회 가능. 데이터는 유지. + +// ---- 폼 정의 (최상위) ---- +interface RecruitForm { + _id: ObjectId; + title: string; // "QUIPU 2026-1 모집 폼" — 폼의 식별 기준 + description?: string; + year?: number; // 메타데이터. unique 제약 없음. + semester?: number; // 메타데이터. unique 제약 없음. + status: FormStatus; // 폼 운영 상태 + version: number; // 수정마다 +1. 제출 시 동시성 체크에 사용. + + sections: FormSection[]; // 최소 1개 (기본 섹션 자동 생성) + fields: FormField[]; + + settings: { + confirmationMessage: string; // 제출 완료 메시지 + duplicateCheckField?: string; // 중복 체크 기준 필드 id (예: 학번 필드) + }; + + createdBy: string; + createdAt: Date; + updatedAt: Date; +} +``` + +### 5.3 응답 저장 — `RecruitResponse` 컬렉션 + +```typescript +// ---- 파일 메타데이터 ---- +interface FileInfo { + fileName: string; + fileKey: string; // R2 object key + mimeType: string; + sizeBytes: number; +} + +// ---- 필드 응답 ---- +interface FieldAnswer { + fieldId: string; + value: string | string[]; // string = text/single_choice/dropdown, string[] = multiple_choice + files?: FileInfo[]; // file_upload 전용. 단일/다중 모두 배열로 통일. +} + +// ---- 제출 응답 ---- +interface RecruitResponse { + _id: ObjectId; + formId: ObjectId; // RecruitForm._id 참조 + formVersion: number; // 제출 시점의 폼 version + answers: FieldAnswer[]; + submittedAt: Date; + metadata?: { + userAgent?: string; + ip?: string; + }; +} +``` + +> **file_upload 응답 규칙:** `maxFiles`가 1이든 N이든 `files` 필드는 항상 `FileInfo[]` 배열로 저장한다. 단일 파일이면 길이 1인 배열이다. `value` 필드는 빈 문자열(`""`)로 둔다. + +### 5.4 인덱스 설계 + +``` +RecruitForm: + - { status: 1 } + +RecruitResponse: + - { formId: 1, submittedAt: -1 } + - { formId: 1, "answers.fieldId": 1, "answers.value": 1 } // 중복 체크용 +``` + +--- + +## 6. 동적 폼 런타임 규칙 + +> 이 섹션은 "폼이 동적으로 동작할 때 어떤 필드가 유효하고, 어떤 값이 제출되는가"를 정의한다. 프론트엔드 상태 관리, 제출 payload 구성, 백엔드 검증 모두 이 규칙을 따른다. + +### 6.1 필드 및 섹션 가시성 판정 + +조건부 표시는 **필드 단위**와 **섹션 단위** 모두 지원한다. 섹션에 `conditionalLogic`이 설정된 경우 해당 섹션 전체가 조건부로 표시/숨김된다. 섹션이 숨겨지면 소속 필드는 개별 `conditionalLogic`과 무관하게 전부 숨겨진다. + +```mermaid +flowchart TD + A["섹션의 conditionalLogic 존재?"] -->|있음| B["섹션 가시성 평가"] + A -->|없음| C["섹션 표시"] + B -->|숨김| D["소속 필드 전부 숨김"] + B -->|표시| C + C --> E["각 필드의 conditionalLogic 존재?"] + E -->|없음| F["필드 표시"] + E -->|있음| G["필드 가시성 평가"] + G --> H{"action?"} + H -->|show| I["조건 충족 → 표시 / 불충족 → 숨김"] + H -->|hide| J["조건 충족 → 숨김 / 불충족 → 표시"] +``` + +```typescript +function isFieldVisible( + field: FormField, + section: FormSection, + allValues: Record +): boolean { + // 1. 섹션 단위 조건부 표시 먼저 평가 + if (section.conditionalLogic) { + const sectionVisible = evaluateLogic(section.conditionalLogic, allValues); + if (!sectionVisible) return false; + } + + // 2. 필드 단위 조건부 표시 평가 + if (!field.conditionalLogic) return true; + return evaluateLogic(field.conditionalLogic, allValues); +} + +function evaluateLogic( + logic: ConditionalLogic, + allValues: Record +): boolean { + const { action, logicType, rules } = logic; + const results = rules.map((rule) => { + const fieldValue = allValues[rule.fieldId]; + switch (rule.operator) { + case "equals": + return fieldValue === rule.value; + case "not_equals": + return fieldValue !== rule.value; + case "contains": + if (Array.isArray(fieldValue)) { + return Array.isArray(rule.value) + ? rule.value.every((v) => fieldValue.includes(v)) + : fieldValue.includes(rule.value as string); + } + return typeof fieldValue === "string" && fieldValue.includes(rule.value as string); + case "is_empty": + return !fieldValue || (Array.isArray(fieldValue) && fieldValue.length === 0); + case "is_not_empty": + return !!fieldValue && (!Array.isArray(fieldValue) || fieldValue.length > 0); + default: + return true; + } + }); + const conditionMet = logicType === "all" ? results.every(Boolean) : results.some(Boolean); + return action === "show" ? conditionMet : !conditionMet; +} +``` + +### 6.2 숨김 필드 정책 + +| 규칙 | 설명 | +|------|------| +| **숨김 필드의 기존 입력값** | 프론트엔드에서 **제거(clear)한다.** 숨겨진 필드가 다시 보일 때 사용자는 처음부터 입력한다. | +| **제출 payload** | 숨겨진 필드의 값은 **제출 payload에 포함하지 않는다.** | +| **백엔드 validation** | 숨겨진 필드는 **validation 대상에서 제외한다.** `required: true`여도 숨겨져 있으면 검증하지 않는다. | + +### 6.3 섹션 분기(Branch) 정책 + +`branchRules`가 있는 필드에서 사용자의 선택에 따라 특정 섹션으로 이동할 수 있다. 이때 건너뛴 섹션의 필드는 숨김 필드와 동일하게 처리한다. + +| 규칙 | 설명 | +|------|------| +| **유효 응답 판정** | 현재 경로(branch path)에 포함된 섹션의 필드만 유효 응답이다. | +| **건너뛴 섹션** | 해당 섹션의 모든 필드 값을 제출 payload에서 제외한다. | +| **백엔드 validation** | 건너뛴 섹션의 필드는 validation 대상에서 제외한다. | + +### 6.4 폼 버전 동시성 제어 + +사용자가 폼을 조회한 뒤 작성하는 동안 운영자가 폼을 수정하거나 상태를 변경할 수 있다. + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Server as 서버 + User->>Server: GET /recruit-form/active + Server-->>User: form (version: 3) + Note over User: 사용자가 폼 작성 중... + Note over Server: 운영자가 폼 수정 (version: 4) + User->>Server: POST /recruit-form/submit { formId, formVersion: 3, answers } + Server-->>User: 409 Conflict "폼이 수정되었습니다. 새로고침 후 다시 제출해주세요." +``` + +- 제출 시 클라이언트는 `formId`와 함께 조회한 폼의 `version`을 전송한다. +- 서버는 현재 폼의 `version`과 비교하여 불일치 시 **409 Conflict**를 반환하고, 사용자에게 재조회를 안내한다. +- 운영자는 **모집 기간 중 불필요한 수정을 자제해야 한다.** + +### 6.5 options 변경 정책 + +선택형 필드(`single_choice`, `multiple_choice`, `dropdown`)의 `options`는 응답 존재 여부와 무관하게 자유롭게 변경할 수 있다. + +- `options[].label`, `options[].value` 모두 수정/추가/삭제 가능. +- 과거 응답은 제출 시점의 `value`를 그대로 보존한다. 옵션이 변경되더라도 기존 응답 데이터는 영향받지 않는다. +- 백오피스 응답 조회 시, 현재 폼 정의에 없는 `value`가 과거 응답에 있을 수 있다. 이 경우 `value`를 그대로 표시한다. + +### 6.6 백엔드 검증 원칙 + +> **프론트엔드를 신뢰하지 않는다.** 백엔드는 폼 정의를 기준으로 모든 검증을 독립적으로 재수행한다. + +백엔드 제출 처리 시 다음을 순서대로 수행한다: + +1. **폼 상태 체크** — `status === "published"`인지 확인 +2. **폼 버전 체크** — 클라이언트가 보낸 `formVersion`과 현재 폼 `version` 비교 +3. **중복 제출 체크** — `duplicateCheckField` 기반 +4. **가시성 재평가** — 클라이언트가 보낸 answers를 기준으로 각 필드/섹션의 `conditionalLogic`을 재평가하여 visible 필드 목록을 서버에서 독립적으로 산출 +5. **visible 필드만 validation** — `required`, `validation[]` 규칙, **선택형 필드의 option value 유효성** (폼 정의에 존재하는 값인지) 모두 체크 +6. **payload에 없어야 할 필드 제거** — 숨김 필드의 답변이 payload에 포함되어 있으면 무시(저장하지 않음) + +--- + +## 7. 마이그레이션 전략 + +> **"새 시스템을 어떻게 만들지"도 중요하지만, "기존 시스템을 어떻게 안전하게 떠날지"가 더 중요하다.** + +### 7.1 기본 원칙 + +기존 MySQL/Sequelize 기반 시스템을 **완전히 대체**한다. + +```mermaid +flowchart LR + A["기존 MySQL
members + Feature"] -->|"폐기"| X["사용하지 않음"] + B["2026 이후"] -->|"신규"| C["MongoDB
RecruitForm + RecruitResponse"] + C -->|"CRUD"| D["백오피스 + 메인 웹"] +``` + +| 구분 | 전략 | +|------|------| +| **2025 이전 데이터** | 자동 마이그레이션하지 않는다. 기존 데이터가 필요할 경우 개발자가 MySQL에서 MongoDB로 수동 마이그레이션한다. | +| **2026 이후 데이터** | MongoDB `RecruitForm` + `RecruitResponse`만 사용. | +| **기존 코드** | `recruitDB.jsx`, `Member.js`, `recruit_R2.js` 등은 새 코드로 완전 대체. | + +### 7.2 모집 상태(ON/OFF) 전환 전략 + +현재 모집 ON/OFF는 `Feature(feature_name="recruit")` 한 줄 토글이다. 새 시스템에서는 `RecruitForm.status`로 이동한다. + +| 항목 | 결정 | +|------|------| +| `Feature.recruit` 레코드 | **폐기한다.** 새 폼의 `status`가 대체한다. | +| 상태 매핑 | `status === "published"` = 모집 진행 중 (기존 `Feature.recruit === true`에 대응) | +| 메인 웹 판단 로직 | `status === "published"`인 폼이 존재하면 해당 폼을 렌더링, 없으면 "모집 기간이 아닙니다" | + +### 7.3 백오피스 응답 조회 + +백오피스 응답 조회는 새 `ResponseList.tsx`에서 MongoDB `recruit_responses`를 조회하며, 폼 정의 기반으로 동적 컬럼을 렌더링한다. 기존 `recruitDB.jsx`는 완전 대체한다. + +### 7.4 파일 다운로드 방식 전환 + +| 항목 | 기존 | 변경 후 | +|------|------|--------| +| 파일 식별 | filename 기반 (`퀴푸_25_1-학번이름.pdf`) | `responseId + fieldId` 기반 메타 조회 | +| 다운로드 API | `GET /bo/recruit/download/:filename` | `GET /bo/recruit-form/:formId/responses/:responseId/file/:fieldId` | +| R2 키 형식 | 하드코딩 패턴 | `recruit/{formId}/{responseId}/{fieldId}/{originalFilename}` | +| 백오피스 UX | 고정 "포트폴리오 다운로드" 버튼 | 응답 상세에서 `file_upload` 타입 필드마다 다운로드 링크 동적 생성 | +| URL 보안 | 없음 | 시간 제한 있는 서명된 URL 발급 (만료: 1시간) | + +### 7.5 백오피스 인증 모델 + +> **결정:** 새 `/bo/recruit-form/*` API는 **01-bo-auth에서 구축하는 Google OAuth 미들웨어를 사용한다.** + +### 7.6 전환 타임라인 + +```mermaid +gantt + title 마이그레이션 타임라인 + dateFormat YYYY-MM-DD + axisFormat %m/%d + section Phase 1 + Mongoose 모델 + 타입 정의 :p1, 2026-03-23, 4d + 백엔드 공개 API (조회/제출) :p2, after p1, 6d + section Phase 2 + 메인 웹 동적 폼 렌더러 :p3, after p2, 7d + 백엔드 백오피스 API :p4, after p2, 5d + section Phase 3 + 백오피스 폼 빌더 UI :p5, after p4, 7d + 백오피스 응답 조회 (동적) :p6, after p4, 6d + section Phase 4 + 파일 업로드 연동 :p7, after p6, 4d + 통합 QA :p8, after p7, 4d + section 전환 + 기존 시스템 폐기 :milestone, after p8, 0d +``` + +--- + +## 8. API 설계 + +### 8.1 백오피스 API (폼 관리) + +| Method | Path | 설명 | +|--------|------|------| +| `POST` | `/bo/recruit-form` | 폼 생성 (status: draft, 기본 섹션 1개 자동 생성) | +| `GET` | `/bo/recruit-form` | 폼 목록 조회 (전체 상태 포함, 상태별 필터 지원) | +| `GET` | `/bo/recruit-form/:id` | 폼 상세 조회 | +| `PUT` | `/bo/recruit-form/:id` | 폼 수정 (version +1) | +| `DELETE` | `/bo/recruit-form/:id` | 폼 삭제 (응답 있으면 불가) | +| `PATCH` | `/bo/recruit-form/:id/status` | 상태 전환 | +| `POST` | `/bo/recruit-form/:id/copy` | 폼 복제 (title, year, semester를 새로 지정) | + +> **인증:** 모든 `/bo/*` API는 01-bo-auth의 Google OAuth 인증 미들웨어 적용. + +### 8.2 백오피스 API (응답 관리) + +| Method | Path | 설명 | +|--------|------|------| +| `GET` | `/bo/recruit-form/:id/responses` | 응답 목록 (페이지네이션: `?cursor=&limit=20`) | +| `GET` | `/bo/recruit-form/:id/responses/:responseId` | 응답 상세 | +| `DELETE` | `/bo/recruit-form/:id/responses/:responseId` | 응답 삭제 (백오피스에서만 가능) | +| `GET` | `/bo/recruit-form/:id/responses/:responseId/file/:fieldId` | 파일 다운로드 (시간 제한 서명 URL, 만료 1시간) | + +### 8.3 메인 웹 API (공개) + +| Method | Path | 설명 | +|--------|------|------| +| `GET` | `/recruit-form/active` | published 상태 폼 조회 (version 포함) | +| `POST` | `/recruit-form/submit` | 폼 제출 (formId + formVersion + answers) | + +### 8.4 응답 목록 페이지네이션 + +cursor 기반 페이지네이션을 사용한다: + +``` +GET /bo/recruit-form/:id/responses?limit=20 +GET /bo/recruit-form/:id/responses?limit=20&cursor={lastResponseId} +``` + +- `limit`: 한 페이지당 응답 수 (기본 20, 최대 100) +- `cursor`: 마지막 응답의 `_id`. 다음 페이지 조회 시 사용. +- 응답은 `submittedAt` 역순(최신순) 정렬. + +### 8.5 CSV Export + +백오피스에서 응답 데이터를 내보낼 때는 **프론트엔드에서 CSV를 빌드**한다. + +| 항목 | 결정 | +|------|------| +| 생성 주체 | 프론트엔드 (백엔드 API로 전체 응답을 조회한 뒤 클라이언트에서 CSV 생성) | +| 형식 | CSV (UTF-8 BOM 포함, 한글 호환) | +| 컬럼 구성 | `flattenResponses()` 결과 기반. 폼 정의의 필드 순서대로 컬럼 생성. | +| 라이브러리 | 별도 라이브러리 없이 직접 CSV 문자열 생성 또는 경량 라이브러리 사용 | + +--- + +## 9. Regex 유효성 검증 시스템 + +### 9.1 Preset 패턴 + +| Preset ID | 패턴 | 용도 | +|-----------|------|------| +| `phone` | `^010-[0-9]{4}-[0-9]{4}$` | 전화번호 | +| `email` | `^[^\s@]+@[^\s@]+\.[^\s@]+$` | 이메일 | +| `url` | `^https?:\/\/.+` | URL | +| `student_id` | `^[0-9]{10}$` | 학번 (10자리) | +| `korean_name` | `^[가-힣]{2,5}$` | 한글 이름 | +| `number_only` | `^[0-9]+$` | 숫자만 | + +### 9.2 커스텀 Regex 정책 + +운영자가 직접 정규식을 입력할 수 있되, 안전장치를 적용한다: + +| 제한 | 내용 | +|------|------| +| **길이 제한** | regex 문자열 최대 200자 | +| **금지 패턴** | 재귀적/중첩 quantifier 차단 (예: `(a+)+`, `(a*)*`) | +| **서버 검증** | 저장 시 `new RegExp(input)`으로 문법 검증 + 테스트 입력에 대해 100ms 내 실행 확인 | +| **런타임 보호** | `safe-regex` 라이브러리로 ReDoS 취약 패턴 사전 차단 | + +```json +{ + "type": "pattern", + "value": "^[A-Z]{2}[0-9]{4}$", + "message": "코드 형식이 올바르지 않습니다 (예: AB1234)" +} +``` + +### 9.3 프론트엔드 검증 흐름 + +```typescript +function validateField( + field: FormField, + value: string | string[], + files?: FileInfo[] +): string | null { + // 1. required 체크 (validation[]과 별도) + if (field.required) { + if (field.type === "file_upload") { + if (!files || files.length === 0) return `${field.label}을(를) 업로드해주세요.`; + } else if (field.type === "multiple_choice") { + if (!Array.isArray(value) || value.length === 0) return `${field.label}을(를) 선택해주세요.`; + } else { + if (!value || (typeof value === "string" && !value.trim())) return `${field.label}을(를) 입력해주세요.`; + } + } + + // 2. file_upload는 별도 검증 (accept, maxSizeMB, maxFiles) + if (field.type === "file_upload" && files && files.length > 0) { + if (field.fileConfig) { + if (files.length > field.fileConfig.maxFiles) { + return `최대 ${field.fileConfig.maxFiles}개까지 업로드할 수 있습니다.`; + } + for (const file of files) { + if (file.sizeBytes > field.fileConfig.maxSizeMB * 1024 * 1024) { + return `파일 크기는 ${field.fileConfig.maxSizeMB}MB 이하여야 합니다.`; + } + } + } + return null; + } + + // 3. multiple_choice는 validation[] 규칙 적용하지 않음 (required만 체크) + if (field.type === "multiple_choice") return null; + + // 4. text/single_choice/dropdown에 대해 validation[] 규칙 적용 + const strValue = typeof value === "string" ? value : ""; + if (!strValue) return null; + + for (const rule of field.validation) { + switch (rule.type) { + case "minLength": + if (strValue.length < Number(rule.value)) return rule.message; + break; + case "maxLength": + if (strValue.length > Number(rule.value)) return rule.message; + break; + case "min": + if (Number(strValue) < Number(rule.value)) return rule.message; + break; + case "max": + if (Number(strValue) > Number(rule.value)) return rule.message; + break; + case "pattern": + try { + const regex = new RegExp(rule.value as string); + if (!regex.test(strValue)) return rule.message; + } catch { + // 잘못된 regex는 무시 (백오피스에서 사전 검증) + } + break; + case "preset": + const presetPatterns: Record = { + phone: /^010-[0-9]{4}-[0-9]{4}$/, + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + url: /^https?:\/\/.+/, + student_id: /^[0-9]{10}$/, + korean_name: /^[가-힣]{2,5}$/, + number_only: /^[0-9]+$/, + }; + const preset = presetPatterns[rule.value as string]; + if (preset && !preset.test(strValue)) return rule.message; + break; + } + } + return null; +} +``` + +### 9.4 백오피스 Regex 입력 UI 안전장치 + +1. 입력 즉시 `new RegExp(input)` 으로 문법 검증 → 실패 시 에러 표시 +2. `safe-regex` 라이브러리로 ReDoS 취약 여부 경고 +3. 테스트 입력란 제공 — 샘플 값을 넣어서 매칭 여부 실시간 확인 +4. 저장 시 서버에서도 regex 문법 + 안전성 재검증 + +--- + +## 10. 동적 응답 Flatten 전략 + +### 10.1 문제 + +동적 폼의 응답은 `answers: [{ fieldId, value }, ...]` 형태의 배열이다. 백오피스에서는 이 데이터를 세 가지 형태로 표시해야 한다: + +1. **목록 테이블** — 각 필드가 컬럼으로 펼쳐져야 함 +2. **모달 상세** — 필드별 라벨-값 쌍으로 렌더링 +3. **CSV 내보내기** — 각 필드가 CSV 컬럼이 되어야 함 + +### 10.2 Flatten 유틸 + +```typescript +function flattenResponses( + form: RecruitForm, + responses: RecruitResponse[] +): Record[] { + const orderedFields = [...form.fields].sort((a, b) => { + const secA = form.sections.find(s => s.id === a.sectionId)?.order ?? 0; + const secB = form.sections.find(s => s.id === b.sectionId)?.order ?? 0; + return secA !== secB ? secA - secB : a.order - b.order; + }); + + return responses.map((resp) => { + const row: Record = { + "제출일시": resp.submittedAt.toISOString(), + }; + for (const field of orderedFields) { + const answer = resp.answers.find(a => a.fieldId === field.id); + if (!answer) { + row[field.label] = ""; + } else if (answer.files && answer.files.length > 0) { + row[field.label] = answer.files.map(f => f.fileName).join(", "); + } else if (Array.isArray(answer.value)) { + row[field.label] = answer.value.join(", "); + } else { + row[field.label] = answer.value; + } + } + return row; + }); +} +``` + +### 10.3 적용 위치 + +| 용도 | 구현 | +|------|------| +| **목록 테이블** | `isListColumn: true`인 필드만 컬럼으로 표시. 폼 정의의 필드 라벨을 헤더로 동적 생성. | +| **모달 상세** | 응답의 `answers[]`를 폼 정의의 `fields[]`와 매칭하여 전체 필드의 라벨-값 쌍 렌더링. | +| **CSV 내보내기** | 프론트엔드에서 `flattenResponses` 결과로 CSV 문자열 생성 후 다운로드. 전체 필드 포함. | + +--- + +## 11. 중복 제출 방지 + +`settings.duplicateCheckField`에 지정된 필드(예: 학번)를 기준으로 중복 체크한다. + +### 11.1 조회 전략 + +MongoDB의 `$elemMatch`를 사용하여 `answers` 배열 내에서 정확한 매칭을 보장한다: + +```typescript +async function handleSubmit(formId: string, formVersion: number, answers: FieldAnswer[]) { + const form = await RecruitForm.findById(formId); + + // 1. 폼 상태 체크 + if (form.status !== "published") { + throw new BadRequestError("현재 모집 기간이 아닙니다."); + } + + // 2. 폼 버전 동시성 체크 + if (form.version !== formVersion) { + throw new ConflictError("폼이 수정되었습니다. 새로고침 후 다시 제출해주세요."); + } + + // 3. 중복 제출 체크 + if (form.settings.duplicateCheckField) { + const checkAnswer = answers.find( + (a) => a.fieldId === form.settings.duplicateCheckField + ); + if (checkAnswer) { + const existing = await RecruitResponse.findOne({ + formId: form._id, + answers: { + $elemMatch: { + fieldId: checkAnswer.fieldId, + value: checkAnswer.value, + }, + }, + }); + if (existing) { + throw new ConflictError("이미 제출하셨습니다."); + } + } + } + + // 4. 백엔드 검증 (§6.6 참조) + // 5. 저장 + await RecruitResponse.create({ + formId: form._id, + formVersion: form.version, + answers, + submittedAt: new Date(), + }); +} +``` + +### 11.2 인덱스 활용 + +`{ formId: 1, "answers.fieldId": 1, "answers.value": 1 }` 복합 인덱스가 `$elemMatch` 쿼리를 커버한다. 모집 기간 동안의 응답 수가 수백 건 수준이므로 성능 이슈 가능성은 낮다. + +--- + +## 12. 파일 업로드 처리 + +### 12.1 업로드 흐름 + +```mermaid +flowchart LR + A["메인 웹에서
multipart/form-data 제출"] --> B["백엔드에서
file_upload 타입 필드 식별"] + B --> C["fileConfig 검증
(accept, maxSizeMB, maxFiles)
프론트+백엔드 양쪽 수행"] + C --> D["R2에 업로드"] + D --> E["RecruitResponse.answers에
files[] 저장"] + E --> F["백오피스 조회 시
시간 제한 서명 URL 발급"] +``` + +### 12.2 기존 대비 변경점 + +| 항목 | 기존 | 변경 | +|------|------|------| +| 파일 필드 수 | `upload.single("portfolio_pdf")` — 1개 고정 | `upload.any()` — 폼 정의의 `file_upload` 필드 수만큼 동적 | +| 파일 수 | 필드당 1개 | `maxFiles`에 따라 필드당 N개 | +| 파일명 패턴 | `퀴푸_25_1-${student_id}${name}` 하드코딩 | `recruit/{formId}/{responseId}/{fieldId}/{originalFilename}` | +| 다운로드 | filename 기반 고정 API | responseId + fieldId 기반 → 시간 제한 서명 URL (만료 1시간) | +| accept 검증 | 없음 | **프론트엔드**: `` 속성 + 클라이언트 MIME 체크. **백엔드**: 파일 MIME 타입을 `fileConfig.accept`와 매칭 (`image/*` 와일드카드 포함) | +| 응답 저장 | 단일 `fileInfo` 객체 | `files: FileInfo[]` 배열 (단일/다중 통일) | + +### 12.3 업로드 실패 복구 + +R2 업로드와 DB 저장 사이에 실패가 발생할 수 있다. Orphan file(DB에 기록되지 않은 R2 파일) 정리 정책: + +| 시나리오 | 대응 | +|---------|------| +| R2 업로드 성공 → DB 저장 실패 | 업로드된 R2 파일을 즉시 삭제 시도. 실패 시 로그 기록. | +| 일괄 정리 | 주기적 배치(일 1회)로 `recruit/` 프리픽스 아래 R2 오브젝트와 DB의 `files[].fileKey`를 대조하여 orphan 파일 삭제. | + +--- + +## 13. 백오피스 폼 빌더 UI 설계 + +### 13.1 화면 구성 + +```mermaid +graph TB + subgraph FormBuilder["폼 빌더 — QUIPU 2026-1 모집 폼   [저장] [미리보기] [상태: draft ▼]"] + direction TB + AddSection["➕ 섹션 추가"] + + subgraph Sec1["섹션 1: 기본 정보   ↑↓ ✕"] + direction TB + F1["📝 이름 (short_text)   |   필수: ✓   |   검증: preset:korean_name"] + F2["📝 학번 (short_text)   |   필수: ✓   |   검증: preset:student_id"] + AddField1["➕ 필드 추가"] + end + + subgraph Sec2["섹션 2: 활동 선택   ↑↓ ✕"] + direction TB + F3["☑️ 희망 활동 (multiple_choice)   |   옵션: 세미나/개발/스터디/대외활동   |   필수: ✓"] + AddField2["➕ 필드 추가"] + end + + subgraph Sec3["섹션 3: 세미나 상세   ↑↓ ✕   조건: 희망활동 contains 세미나"] + direction TB + F4["..."] + end + end +``` + +### 13.2 필드 설정 패널 + +```mermaid +graph TB + subgraph FieldSettings["필드 설정"] + direction TB + Basic["타입: short_text ▼
라벨: ________
설명: ________
플레이스홀더: ________
필수: ✓
목록 컬럼 표시: ✓"] + + subgraph Validation["유효성 검증 (required 제외)"] + direction TB + AddRule["➕ 규칙 추가"] + Rule1["규칙 1: preset ▼ → student_id ▼
메시지: 학번 10자리를..."] + Rule2["규칙 2: pattern ▼
Regex: ^[0-9]{10}$
안전성: ✓ 통과
테스트: 2025440001 ✓ 매칭
메시지: ________"] + end + + subgraph Conditional["조건부 표시"] + direction TB + AddCond["➕ 조건 추가"] + Cond1["조건: 희망활동 ▼ → contains ▼ → 세미나"] + end + end +``` + +### 13.3 핵심 인터랙션 + +- **드래그 앤 드롭**으로 필드/섹션 순서 변경 (`order` 값 자동 재계산) +- **미리보기** 버튼 → 메인 웹 폼 렌더러를 **iframe**으로 로드하여 실제 렌더링 결과 확인 +- **폼 복제** 기능 → 전년도 폼을 복사하면서 title, year, semester를 새로 지정 +- **상태 전환** 버튼 → draft ↔ published ↔ closed → archived + +### 13.4 상태 전환 규칙 + +```mermaid +stateDiagram-v2 + [*] --> draft : 폼 생성 + draft --> published : 게시 (동시에 1개만) + published --> closed : 모집 종료 + closed --> published : 모집 재개 (동시에 1개만) + closed --> archived : 보관 + draft --> draft : 수정 (자유) + published --> published : 수정 (version +1, 제한적) + closed --> closed : 조회 전용 +``` + +| 전환 | 조건 | +|------|------| +| draft → published | 다른 published 폼이 없어야 함. 필드가 1개 이상 존재해야 함. | +| published → closed | 언제든 가능. | +| closed → published | 다른 published 폼이 없어야 함. (모집 재개) | +| closed → archived | 언제든 가능. | +| published 상태에서 수정 | 필드 삭제 불가. 옵션/라벨/검증 등은 자유 변경. version +1. | + +--- + +## 14. 메인 웹 폼 렌더러 설계 + +### 14.1 렌더링 흐름 + +```mermaid +flowchart TD + A["GET /recruit-form/active"] --> B{"status === published?"} + B -->|아니거나 없음| C["모집 기간이 아닙니다 표시"] + B -->|published| D["sections를 order 순 정렬"] + D --> E["각 섹션의 conditionalLogic 평가"] + E --> F["각 필드의 isFieldVisible() 평가"] + F --> G["field.type에 따라 입력 컴포넌트 렌더링"] + G --> H["제출 시 visible 필드만 validateField() 실행"] + H --> I["POST /recruit-form/submit
{formId, formVersion, answers}"] +``` + +### 14.2 필드 타입별 렌더링 컴포넌트 매핑 + +```typescript +const FIELD_COMPONENTS: Record> = { + short_text: ShortTextField, + long_text: LongTextField, + single_choice: RadioField, + multiple_choice: CheckboxField, + dropdown: DropdownField, + file_upload: FileUploadField, +}; +``` + +### 14.3 미리보기 연동 + +백오피스 폼 빌더의 **미리보기**는 메인 웹 폼 렌더러를 **iframe**으로 로드한다. + +- 백오피스에서 `