From de2b0a0f0db5ff7d5cd4f388da93cd1d4c86f83d Mon Sep 17 00:00:00 2001 From: a123 Date: Sun, 22 Mar 2026 23:50:51 +0900 Subject: [PATCH 1/5] docs: sync bo auth plan into bo-auth and update appendix --- feature/01-bo-auth-appendix.md | 549 +++++++++++++++++++++++++++++++++ feature/01-bo-auth.md | 181 +++++++++++ 2 files changed, 730 insertions(+) create mode 100644 feature/01-bo-auth-appendix.md create mode 100644 feature/01-bo-auth.md diff --git a/feature/01-bo-auth-appendix.md b/feature/01-bo-auth-appendix.md new file mode 100644 index 0000000..51cac54 --- /dev/null +++ b/feature/01-bo-auth-appendix.md @@ -0,0 +1,549 @@ +# 01. BO Auth 기술 상세 Appendix + +## 문서 관계 / 소스 오브 트루스 + +- 이 Appendix는 기술 구현 기준 문서다. +- 기존 [01-bo-auth.md](./01-bo-auth.md)의 기술 상세(데이터 모델, 인증/토큰, 권한 검사, 인덱스, 에러 코드)는 이 Appendix 기준으로 대체한다. +- PM 의사결정/일정/리스크는 [01-bo-auth-plan.md](./01-bo-auth-plan.md)를 기준으로 본다. + +## A) 목표 아키텍처 요약 + +- 인증: Google OAuth +- API 인증 유지: Authorization Bearer Token +- DB: MongoDB +- 권한: `perm` 비트마스크 +- 계정 온보딩: 초대 링크 기반 + +## B) 데이터 모델 + +- IP 개인정보 최소화 정책은 `admin_audit_logs`, `refresh_tokens` 모두에 동일 적용한다(원문 IP 미저장, `ipHash` 사용). + +## B.1 `admin_users` + +- `email` (unique, required) +- `googleSub` (unique, nullable) +- `name` (nullable) +- `pictureUrl` (nullable) +- `perm` (number, default `READ`) +- `isSuperAdmin` (boolean, default false) +- `isActive` (boolean, default true) +- `lastLoginAt` +- `createdAt`, `updatedAt` + +## B.2 `admin_invites` + +- `email` (required) +- `perm` (number, default `READ`) +- `tokenHash` (unique, required) +- `expiresAt` (required) +- `status` (`pending`, `used`, `expired`, `revoked`) +- `invitedBy` (required) +- `usedByUserId` (nullable) +- `createdAt`, `updatedAt` + +## B.3 `admin_audit_logs` + +- `actorUserId` +- `targetUserId` +- `action` +- `before`, `after` +- `ipHash` (원문 IP 저장 금지, `HMAC-SHA256(ip, SERVER_SECRET_SALT)` 적용) +- `userAgent` +- `createdAt` + +## B.4 `refresh_tokens` + +- `userId` (required) +- `tokenHash` (unique, required) +- `expiresAt` (required) +- `revoked` (boolean, default false) +- `revokedAt` (nullable) +- `deviceInfo` + - `userAgent` (raw string) + - `os` (parsed) + - `browser` (parsed) + - `ipHash` (선택, 원문 IP 저장 금지 권장) + - 알고리즘: `HMAC-SHA256(ip, SERVER_SECRET_SALT)` + - 운영 정책: `SERVER_SECRET_SALT`는 운영 중 교체 금지 + - 주의: secret 변경 시 기존 `ipHash` 비교/조회가 불가능해짐 + - 조회/비교: 세션 목록/필터링은 원문 IP가 아닌 `ipHash` 기준으로 처리 + - 비상 교체 절차: + - 트리거: secret 유출이 확인/강하게 의심되는 경우 + - 조치: `refresh_tokens` 전체 revoke + 전체 재로그인 유도 공지 + - 영향: 기존 `ipHash` 기반 조회/필터링 일시 불가 + - 기록: 교체 시각/사유/조치 내역을 운영 로그 및 감사 로그에 기록 + - `lastSeenAt` (선택) +- `createdAt`, `updatedAt` + +## C) 권한 모델 + +```ts +enum Permission { + READ = 1 << 0, + WRITE_ACTIVITY = 1 << 1, + WRITE_RECRUIT_FORM = 1 << 2, + WRITE_CLUB_INFO = 1 << 3, +} +``` + +권한 체크: + +- `(perm & WRITE_ACTIVITY) !== 0` +- `(perm & WRITE_RECRUIT_FORM) !== 0` +- `(perm & WRITE_CLUB_INFO) !== 0` +- `(perm & REQUIRED_MASK) === REQUIRED_MASK` + +운영 UI: + +- 내부 저장은 비트마스크 +- 화면은 라벨(`read/all`, `write/activity`, `write/recruit-form`, `write/club-info`, `write/all`)로 표시 +- `write/all`은 별도 비트를 두지 않고 `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO` OR 합산값으로 해석한다. +- UI 역변환 규칙: `(perm & (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)) === (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)`이면 `write/all`로 표시하고, 아니면 보유한 개별 write 라벨만 표시한다. + +비트 관리 규칙: + +- 신규 권한은 `2^n` 값만 사용 +- 기존 비트 값 변경 금지 +- 중앙 enum 파일에서만 권한 정의 +- 32bit 초과 시 64bit/bigint 확장 정책 적용 + +## D) 인증/토큰 정책 + +## D.1 Access Token + +- 만료: 10~15분(최대 15분) +- 저장: 메모리 저장(브라우저 새로고침 시 초기화되는 런타임 메모리; 예: 앱 전역 state/in-memory store) +- `localStorage/sessionStorage` 저장 금지 +- `jti` claim 포함(필요 시 replay 탐지 확장) + +## D.2 Refresh Token + +- 만료: 14일 +- 저장: `httpOnly + Secure + SameSite` cookie 권장 +- rotation + revoke 적용 +- 다중 디바이스 분리 관리 +- FE는 refresh token 기반 silent refresh로 페이지 reload 시 세션을 복구한다. +- 도메인 정책(확정): + - Option A 채택: 리버스 프록시로 FE/BE 요청 경로를 same-site로 정렬 + - FE는 동일 사이트 경로(예: `/api`)로 호출하고 프록시가 BE로 전달 + - refresh cookie는 same-site 기준으로 처리해 Safari ITP 영향 구간을 최소화 + - `SameSite=Lax` 이상을 기본으로 하고 운영 환경은 `Secure` 필수 적용 +- 잔여 리스크 및 대응: + - 프록시 misconfiguration으로 cross-site 전송이 남는 경우 Safari에서 silent refresh 실패 가능 + - Phase 1 QA에서 Safari refresh 실패가 확인되면 프록시 규칙 우선 수정, 인프라 제약으로 불가 시 BFF 패턴 전환 + +rotation atomic 처리: + +- 동시 refresh 요청 중복 방지: + - FE 1차 방어(필수): 탭 내부 싱글턴 Promise lock으로 refresh 중복 호출을 차단한다. + - FE 2차 방어(권장): `BroadcastChannel('auth')`로 탭 간 신규 access token을 공유한다. + - BE 안전망(권장): `revokedAt` 기준 grace window(5~10초) 정책을 적용한다. + - grace window 이내 재사용: 경쟁 조건으로 판단(`REFRESH_TOKEN_REVOKED`) 후 재로그인 강제 없이 복구 경로 안내 + - grace window 초과 재사용: 탈취 의심으로 판단(`REFRESH_TOKEN_REUSE_DETECTED`) 후 전체 refresh token revoke + +1. `revoked=false` 조건으로 토큰 조회/전환 +2. 기존 토큰 revoke +3. 새 refresh token 저장 +4. 새 access token 발급 +5. 2~4 중 하나라도 실패 시 전체 롤백(트랜잭션 정책은 `N) 트랜잭션 정책` 참조) + +reuse detection: + +- revoke된 refresh token 재사용 시 `REFRESH_TOKEN_REUSE_DETECTED` +- 보안 이벤트 기록 +- 해당 사용자 전체 refresh token 강제 revoke + +## D.3 CSRF 경계 + +- 일반 Bearer API는 CSRF 영향 낮음 +- refresh 엔드포인트는 cookie 사용으로 CSRF 방어 필수 +- refresh 엔드포인트 CSRF 방어: + - 요청 시 커스텀 헤더(`X-Requested-With: XMLHttpRequest`) 필수 포함 + - 서버는 해당 헤더 부재 시 요청 거부 (`401`) + - CORS preflight 의존 방식이므로 `Access-Control-Allow-Origin` 설정 엄격 유지 필수 + - 허용 도메인은 운영 allowlist로 관리하며(예: `BO_ALLOWED_ORIGINS`), 와일드카드(`*`) 사용 금지 + - 추가 검증: `Origin`(필수) 및 `Referer`(보조) 값을 allowlist와 대조하고 불일치 시 거부 +- 한계 및 보완: + - `X-Requested-With` 단독 방식은 same-origin/프록시 환경에서 우회 가능성이 있으므로 단독 방어로 간주하지 않는다. + - 따라서 custom header + origin/referer 검증을 기본 세트로 적용한다. +- refresh 요청 통과 조건(체크리스트): + - `X-Requested-With: XMLHttpRequest` 헤더 존재 + - `Origin`이 `BO_ALLOWED_ORIGINS` allowlist와 일치 + - `Referer`(존재 시)가 `BO_ALLOWED_ORIGINS` allowlist와 일치 + - 위 조건을 모두 만족할 때만 통과, 하나라도 불일치하면 `401` 거부 + +## E) Google OAuth 보안 + +- `state` 검증 +- `email_verified` 검증 +- ID Token `issuer` 검증 +- ID Token `audience(client_id)` 검증 +- 필요 시 `hd`/도메인 제한 +- 이메일 비교는 `trim + lowercase` 정규화 후 수행 + +## F) 초대 플로우 + +1. 슈퍼어드민 이메일 입력 +2. 이메일 전용 초대 링크 생성(만료 기본 2일) +3. 사용자 링크 접속 후 Google 로그인 +4. 정규화된 이메일 일치 시 승인 +5. `googleSub` 바인딩 +6. 초대 상태 `used` 처리 + +보안: + +- 토큰 엔트로피 최소 128bit +- 토큰 원문 저장 금지, `tokenHash`만 저장 +- 1회 사용 후 즉시 폐기 +- 재발급 시 기존 활성 토큰 revoke +- 초대 검증 API rate limit 적용 +- 만료 정책: + - 기본 만료는 2일 + - 허용 범위: 최소 1시간(3600초) ~ 최대 7일(604800초) + - 하드코딩 금지, 운영 설정값으로 변경 가능 + - 슈퍼어드민은 허용 범위 내에서 초대 생성 시 만료값을 조정할 수 있다. + - 허용 범위를 벗어난 요청은 `400`으로 거부한다. +- 권한 초기값/지정: + - 초대 생성 시 기본 권한은 `READ`를 부여 + - 슈퍼어드민은 초대 생성 시 추가 write 권한(`WRITE_ACTIVITY`, `WRITE_RECRUIT_FORM`, `WRITE_CLUB_INFO`)을 사전 지정할 수 있다. + +초대 생성 API 스펙(`POST /bo/admin/invites`): + +- body: + - `email` (required, 1개) + - `perm` (optional, 기본값 `READ`) + - `expiresInSec` (optional, 기본값 172800, 허용 범위 3600~604800) +- 검증: + - `perm`에 `READ` 미포함 요청 시 서버에서 `READ`를 강제 포함 + - 범위 외 `expiresInSec` 요청은 `400`으로 거부 + +동시성: + +- `status=pending` 조건에서만 수락 허용 +- 트랜잭션 내부에서 `pending -> used` 전환 후 바인딩 +- 동시 요청은 1건만 성공 + +충돌 처리: + +- `googleSub`가 이미 다른 계정에 바인딩된 경우 `GOOGLE_SUB_CONFLICT` 반환 + +## G) 슈퍼어드민 정책 + +- 런타임 판단 기준: DB `isSuperAdmin` 단일 기준 +- 서버 시작 시 `SUPER_ADMIN_EMAIL` bootstrap 보정 +- 운영 중 env 자동 덮어쓰기 금지 +- 최소 1개 이상 비상 super admin 계정 유지(활성 상태) +- Google OAuth 장애 시 임시 로컬 인증 fallback 경로를 통해 비상 계정만 로그인 허용 +- 로컬 인증 fallback 경로는 상시 개방하지 않고 장애 대응 시에만 운영 플래그로 활성화 +- 예외 전환 규칙: Phase 1 전환 기간에는 기존 로그인 fallback 경로를 임시 활성화로 유지하고, Phase 3 완료 후에는 비상 플래그 기반 fallback만 허용한다. +- env/DB mismatch: + - 기본: 경고 + 운영 알림 + - strict mode: fail-fast + +## H) DB 재검증/권한 검사 + +라우트 레벨: + +- 모든 보호 API 1차 권한 검사 미들웨어 적용 + +서비스 레벨: + +- 민감 작업 2차 DB 재검증 + +필수 DB 재검증 대상: + +- 권한 변경 API +- 초대 생성/검증/수락 API +- 계정 활성/비활성 API +- 모든 write API +- `/bo/auth/me` (화면 기준 정보 최신화) + +성능 기준: + +- `userId` 단건 조회 중심 +- write/admin API low QPS 가정 +- 필요 시 Redis 캐시 확장 +- `/bo/auth/me`는 매 요청 DB 재검증을 기본 정책으로 한다. +- 근거: `/bo/auth/me`는 관리자 화면 권한/상태 렌더링의 기준 엔드포인트이므로 stale 권한 노출 방지를 위해 성능보다 최신 권한 일관성을 우선한다. + +## I) Rate Limiting + +대상: + +- OAuth 시작 +- OAuth callback +- 초대 검증 전 단계 +- 초대 검증 API +- refresh API + +초기값 및 조정 범위: + +| 대상 | 초기값 | 조정 범위 | 기준 | +| --- | --- | --- | --- | +| OAuth callback | IP당 10 req/min | 5~20 | 로그인 실패율 모니터링 후 | +| refresh API | 사용자당 30 req/min | 20~60 | 다중 디바이스 환경 고려 | +| invite 검증 API | IP당 10 req/min | 5~20 | 초대 남용 시 강화 | + +초기값은 백오피스 내부 사용자 규모 기준으로 설정한다. 운영 중 rate limit 초과 알림이 반복되거나 브루트포스 징후 발생 시 하향 조정하고, 정상 사용자 차단 이슈 발생 시 상향 조정한다. 조정 이력은 감사 로그에 준하여 기록한다. +구현 전제: 분산 환경 일관성을 위해 Redis 기반 rate limiter를 기본으로 사용한다(단일 인스턴스 개발 환경은 in-memory fallback 허용). + +## J) 감사 로그/모니터링 + +감사 로그: + +- append-only +- 수정/삭제 API 제공 금지 +- 필요 시 해시 체인/외부 저장 이중 적재 확장 +- retention: 180일 + +모니터링: + +- 권한 변경 +- super admin 이벤트 +- rate limit 반복 초과 +- 로그인 실패 반복 + +## K) 에러 코드 카탈로그 + +공통: + +- `UNAUTHORIZED` (`401`) +- `FORBIDDEN` (`403`) +- `TOO_MANY_REQUESTS` (`429`) +- `INTERNAL_ERROR` (`500`) + +초대/인증: + +- `INVITE_EXPIRED` (`410`) +- `INVITE_ALREADY_USED` (`409`) +- `INVITE_REVOKED` (`410`) +- `INVITE_EMAIL_MISMATCH` (`422`) +- `GOOGLE_SUB_CONFLICT` (`409`) +- `OAUTH_STATE_INVALID` (`400`) +- `EMAIL_NOT_VERIFIED` (`403`) + +토큰: + +- `REFRESH_TOKEN_INVALID` (`401`) +- `REFRESH_TOKEN_REVOKED` (`401`): 이미 revoke된 토큰 사용(grace window 이내 경쟁 조건 포함) +- `REFRESH_TOKEN_EXPIRED` (`401`) +- `REFRESH_TOKEN_REUSE_DETECTED` (`401`): revoke된 토큰의 grace window 초과 재사용(탈취 의심, 전체 토큰 강제 revoke) + +권한/계정: + +- `ACCOUNT_INACTIVE` (`403`) +- `PERMISSION_DENIED` (`403`) +- `SUPER_ADMIN_REQUIRED` (`403`) + +## L) 필수 인덱스 + +`admin_users`: + +- `email` unique +- `googleSub` unique + +`admin_invites`: + +- `tokenHash` unique +- `email + status` 복합 인덱스 +- `expiresAt` TTL 인덱스(정책 적용 시) + +`admin_audit_logs`: + +- `actorUserId` +- `createdAt` +- 필요 시 `action + createdAt` 복합 인덱스 + +`refresh_tokens`: + +- `tokenHash` unique +- `userId + revoked + revokedAt` 복합 인덱스 (grace window 판정 최적화) +- `expiresAt` TTL 인덱스(정책 적용 시) + +## M) 구현 순서 (개발자 기준) + +1. [Phase 1] MongoDB 컬렉션/인덱스 생성 (`admin_users`, `admin_invites`, `admin_audit_logs`, `refresh_tokens`) +2. [Phase 1] Google OAuth 검증 로직 구현(`state`, `email_verified`, `issuer`, `audience`) +3. [Phase 1] 토큰 발급/검증 로직 구현(access/refresh, rotation, revoke) +4. [Phase 1] 기존 `passport-local` 및 세션 직렬화/역직렬화 전략 제거 +5. [Phase 1] refresh reuse detection 구현 및 보안 이벤트 연계(배치 근거: rotation/revoke와 분리 불가한 동일 인증 경로) +6. [Phase 2] 권한 enum/비트마스크 유틸 구현(라벨 <-> 비트 변환 레이어 포함, `write/all` OR 합산/역변환 규칙 포함) +7. [Phase 2] 초대 생성/검증/수락/재발급/폐기 구현 +8. [Phase 2] `googleSub` 바인딩 충돌 정책 구현(`GOOGLE_SUB_CONFLICT`) +9. [Phase 2] 라우트 미들웨어 + 서비스 DB 재검증 적용 +10. [Phase 2] `/bo/auth/me` 최신 상태 정책 및 권한 반영 검증 +11. [Phase 2] FE 라우트 가드/`useCan()` 훅/silent refresh 실패 처리 구현(`R) FE 구현 기준` 반영) +12. [Phase 3] 감사 로그 적재/조회 및 append-only 정책 적용 +13. [Phase 3] rate limiting 및 에러 코드 매핑 적용 +14. [Phase 3] 통합 테스트 및 운영 점검 + +## N) 트랜잭션 정책 (MongoDB) + +아래 시나리오는 반드시 트랜잭션으로 처리한다. + +- 초대 수락: `invite(pending->used)` + `googleSub 바인딩` + `admin_users.perm 반영` +- 권한 변경: `admin_users.perm 변경` + `admin_audit_logs 기록` +- super-admin 변경: `admin_users.isSuperAdmin 변경` + `admin_audit_logs 기록` +- 계정 비활성화: `isActive=false` + `refresh_tokens revoke` + `audit 기록` +- refresh rotation: `old refresh revoke` + `new refresh 발급` (atomic 전환) + +트랜잭션 원칙: + +- 실패 시 전체 롤백 +- 감사 로그 쓰기 실패도 롤백 조건 +- 동시성 경합 지점은 조건부 갱신(`revoked=false`, `status=pending`)으로 원자성 보장 +- 운영 영향: 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음(보안 무결성 우선 정책) +- 운영 대응: 장애 알림 즉시 전파, 복구 전까지 읽기 중심 운영 모드 유지, 복구 후 변경 작업 재개 + +## O) 기존 데이터 마이그레이션 전략 + +목표: + +- 기존 관리자 계정을 신규 인증/권한 체계로 안전하게 전환한다. + +적용 시점: + +- Phase 1 완료 후, Phase 2 시작 직전 실행 +- 실제 운영 관리자 계정 수는 Phase 1 완료 시점에 확정해 본 섹션에 기록한다. +- 예상 소요: + - 관리자 계정 30개 기준 반나절~1일 + - 관리자 계정 100개 기준 1~2일(초대 수락 속도에 따라 변동) + +기존 계정 처리 원칙: + +- `googleSub`가 없는 기존 계정은 초대 플로우 재온보딩을 기본 경로로 사용 +- 운영상 즉시 전환이 필요한 계정은 제한적으로 마이그레이션 스크립트 사용 가능(슈퍼어드민 승인 필수) +- 비밀번호 로그인 데이터는 신규 인증 기준에서 참조하지 않는다. + +실행 절차: + +1. 마이그레이션 대상 계정 목록 확정(활성 계정 기준) +2. 기존 권한 -> 신규 `perm` 매핑 테이블 확정 +3. 계정별 초대 발송 또는 승인된 스크립트 전환 수행 +4. 전환 완료 계정의 `googleSub` 바인딩 및 로그인 검증 +5. 권한 대조 리포트 생성(기존 권한 vs 신규 `perm`) + +검증 기준: + +- 마이그레이션 전/후 활성 관리자 계정 수 일치 +- 권한 매핑 불일치 0건 +- 전환 대상 계정의 Google 로그인 성공 확인 + +롤백 기준 및 절차: + +- 롤백 임계치: + - 권한 매핑 불일치 1건 이상 발생 시 즉시 중단 + - 전환 대상 계정 로그인 실패율 5% 초과 시 즉시 중단 +- 임계치 초과 시 신규 적용 중단 +- 영향 계정에 대해 기존 운영 경로(임시 fallback)로 즉시 복귀 +- 원인 수정 후 재실행 + +## P) 테스트 전략 + +단위 테스트: + +- [Phase 1] OAuth 검증: `state` 불일치, `email_verified=false`, `issuer/audience` 실패 +- [Phase 1] 토큰: 발급/검증/만료/revoke/rotation/reuse detection +- [Phase 2] 권한: `perm` 비트 연산, 복합 마스크 검사, 경계값 검증 + +통합 테스트: + +- [Phase 1] refresh rotation atomic 보장(동시 요청 포함) +- [Phase 1] 탭 내부 동시 401 상황에서 refresh API 1회만 호출되는지 검증(싱글턴 Promise lock) +- [Phase 3] 감사 로그 append-only(수정/삭제 거부) +- [Phase 2] 권한 변경 후 `/bo/auth/me` 최신 상태 반영 + +E2E 테스트: + +- [Phase 2] 초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여 +- [Phase 2] 예외 시나리오: 초대 만료/중복 수락/이메일 불일치/`GOOGLE_SUB_CONFLICT` + +Safari silent refresh QA(Phase 1 필수): + +- 환경: + - 스테이징 FE/BE를 운영과 동일한 cross-domain으로 배포해 검증 + - 브라우저/디바이스: macOS Safari, iOS Safari +- 시나리오: + - [Phase 1] access token 만료 후 자동 refresh 성공 및 원 요청 재시도 성공 + - [Phase 1] 페이지 새로고침 후 `/auth/refresh` 기반 세션 복구 성공 + - [Phase 1] Safari cross-site tracking 활성화 상태에서 refresh cookie 전달 여부 확인 + - [Phase 1] 다중 탭 동시 refresh 시 reuse detection 오탐 여부 확인 +- 판정/조치: + - 시나리오 1,2 통과 시 Phase 1 인증 기준 충족 + - 아래 조건 중 하나라도 충족하면 Safari cross-origin refresh 실패로 판정하고 BFF 패턴 전환 착수: + - 시나리오 1 실패(access token 만료 후 자동 refresh 미동작 또는 재시도 API 실패) + - 시나리오 2 실패(새로고침 후 세션 복구 실패) + - 시나리오 3에서 refresh 요청에 cookie 미첨부 확인 + - reuse detection 오탐 발생 시 동시 요청 제어(debounce/lock) 적용 후 재검증 + - grace window(5~10초) 내 재사용은 `REVOKED`로 처리되고, window 초과 재사용만 `REUSE_DETECTED`로 처리되는지 검증 + +완료 기준: + +- [Phase 1] 인증/OAuth/토큰 테스트 통과 후 Phase 2 진입 +- [Phase 2] 권한/초대 E2E 테스트 통과 후 Phase 3 진입 +- [Phase 3] 감사로그/rate limit/에러코드 회귀 테스트 통과 후 릴리스 + +## Q) 기존 API 권한 매핑 (비트마스크 기준) + +라벨-비트 기준: + +- `read/all` -> `READ` +- `write/activity` -> `WRITE_ACTIVITY` +- `write/recruit-form` -> `WRITE_RECRUIT_FORM` +- `write/club-info` -> `WRITE_CLUB_INFO` +- `write/all` -> `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO` + +라우트 매핑: + +| API | 필요 권한(라벨) | 비트마스크 조건 | +| --- | --- | --- | +| `GET /bo/member` | `read/all` | `(perm & READ) !== 0` | +| `GET /bo/member/pdf/:filename` | `read/all` | `(perm & READ) !== 0` | +| `POST /bo/semina` | `write/activity` 또는 `write/all` | `(perm & WRITE_ACTIVITY) !== 0` | +| `GET /bo/feature/recruit` | `read/all` | `(perm & READ) !== 0` | +| `PATCH /bo/feature/recruit` | `write/recruit-form` 또는 `write/all` | `(perm & WRITE_RECRUIT_FORM) !== 0` | +| `GET /bo/feature/club-info` | `read/all` | `(perm & READ) !== 0` (`신규 구현 필요`) | +| `PATCH /bo/feature/club-info` | `write/club-info` 또는 `write/all` | `(perm & WRITE_CLUB_INFO) !== 0` (`신규 구현 필요`) | +| `GET /bo/admin/users` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) | +| `POST /bo/admin/invites` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) | +| `PATCH /bo/admin/users/:id/perm` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) | +| `PATCH /bo/admin/users/:id/super-admin` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`, 감사 로그 필수) | +| `PATCH /bo/admin/users/:id/active` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) | + +## R) FE 구현 기준 + +- 앱 부팅: + - 앱 시작 시 `GET /bo/auth/me`를 호출해 사용자/권한 정보를 전역 상태(store)로 초기화한다. + - 실패(`401/403`) 시 인증 상태를 비로그인으로 전환하고 로그인 화면으로 라우팅한다. +- 라우트 가드: + - 보호 라우트 진입 전 `isAuthenticated`와 `perm/isSuperAdmin`을 검사한다. + - 가드 실패 시 권한 안내 페이지 또는 로그인 페이지로 리다이렉트한다. +- 권한 기반 UI 제어: + - `useCan()` 훅 또는 `Can` 컴포넌트로 버튼/CTA 렌더링을 제어한다. + - `write/all` 표시는 C 섹션의 UI 역변환 규칙을 동일하게 사용한다. +- silent refresh 실패 처리: + - `/auth/refresh` 실패 시 access token/사용자 상태를 즉시 초기화한다. + - 현재 페이지에서 재시도 루프 없이 로그인 페이지로 단일 리다이렉트한다. + - 필요 시 `reason=session_expired` 쿼리로 사용자 안내 문구를 노출한다. + +## S) API 응답 포맷 (`GET /bo/auth/me`) + +- 응답 원칙: + - `perm`(number)을 권한 판정의 기준값으로 사용한다. + - `permLabels`는 UI 편의를 위한 파생 필드로 제공한다. + - `permLabels`는 서버가 C 섹션의 UI 역변환 규칙(`write/all` 합산 규칙 포함)을 적용해 생성하며, FE는 이를 그대로 표시한다. + - `isSuperAdmin`는 super-admin 전용 UI/기능 노출 제어에 사용한다. + +응답 예시: + +```json +{ + "id": "664f3f2a0d8e8b7d4a12c901", + "email": "admin@uos.ac.kr", + "name": "홍길동", + "isSuperAdmin": false, + "isActive": true, + "perm": 3, + "permLabels": ["read/all", "write/activity"] +} +``` diff --git a/feature/01-bo-auth.md b/feature/01-bo-auth.md new file mode 100644 index 0000000..01e0474 --- /dev/null +++ b/feature/01-bo-auth.md @@ -0,0 +1,181 @@ +# 01. BO Auth 실행 명세서 (PM용) + +## Executive Summary + +- Safari 인증 이슈와 권한 모델 확장성 문제를 동시에 해결한다. +- 인증은 Google OAuth + Authorization 토큰 기반으로 전환한다. +- 권한은 확장 가능한 구조로 단순화하고, 슈퍼어드민 운영 통제를 강화한다. +- 2주 내 단계적 전환과 rollback 전략으로 도입 리스크를 관리한다. + +## 0) 문서 목적 + +본 문서는 백오피스 인증/권한 체계 고도화의 PM 의사결정용 실행 계획이다. +기술 구현 세부는 별도 Appendix 문서로 분리한다. + +- 기술 Appendix: [01-bo-auth-appendix.md](./01-bo-auth-appendix.md) + +## 1) Expected Impact + +- Safari의 third-party cookie 제한 이슈를 제거하기 위해 cookie 기반 인증에서 Authorization header 기반 인증으로 전환, 인증 실패율 감소(특히 Safari 환경) +- 권한 관련 운영 이슈 감소(CS/운영 문의 티켓 기준) +- 관리자 계정 생성/권한 변경 작업 시간 감소 +- 계정/권한 보안 사고 리스크 감소 +- 정량 목표/측정 기준은 `9) Success Metrics`를 따른다. +- baseline은 개편 전 최근 7일 서버 로그인 API 로그를 기준으로 산정한다. + +## 2) Risk if not implemented + +- Safari 인증 이슈가 지속된다. +- 권한 확장 시 기술 부채가 누적된다. +- 계정/보안 운영 비용이 계속 증가한다. + +## 3) Scope (2주) + +전체 담당: + +- 백엔드/프론트: 엄준식 + +### Phase 1 (3/24~3/27, 4일): 인증 전환 + +- Google OAuth + Authorization 토큰 기반 로그인 전환 +- 크로스 도메인 안정화 +- 기존 로그인 fallback 유지 + +완료 기준: + +- Safari 포함 주요 브라우저 로그인 성공 +- 인증 성공률 99% 이상 (QA + staging 로그 기준) +- Safari 환경에서 access token 만료 후 refresh 기반 세션 복구(silent refresh) 성공 +- Safari cross-domain refresh 검증은 Appendix `P) 테스트 전략`의 QA 시나리오 기준으로 판정 + +### Phase 2 (3/28~4/2, 5일): 권한 모델 전환 + +- 권한 모델 전환(확장 가능한 구조) +- 관리자 권한 관리 UX 정리 +- `/bo/auth/me` 최신 상태 기준 확정 +- 리스크: + - 권한 모델 전환 시 기존 권한 불일치 가능성 +- 대응: + - 병행 검증 기간: Phase 2 전체 기간(3/28~4/2) + - 검증 방법: 신규 권한 모델 결과와 기존 권한 기준 결과를 관리자 계정 전체 대상 대조 + - 완료 판단: QA 환경에서 권한 오검증 0건 확인 후 Phase 3 진입 + - 불일치 발생 시: 신규 모델 적용 즉시 중단 -> 원인 분석 -> 수정 후 재검증 + +완료 기준: + +- 권한 오검증 0건 +- 권한 변경 즉시 반영 정책 동작 +- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과 + +### Phase 3 (4/3~4/5, 4일): 감사/보안 고도화 + +- 감사 로그/보안 이벤트 모니터링 +- 보안 정책 마감(rate limit, 토큰 운영, 경보 연계) +- 릴리스 점검 및 운영 인수 + +완료 기준: + +- 보안 체크리스트 충족(Appendix `D`, `I`, `J`, `K`, `P` 기준) +- 운영 대시보드/알림 체계 확인 +- 기존 세션 로그인 경로 제거 완료(rollback 비상 경로 제외) + +## 4) 설계 선택 근거 (요약) + +- 인증 방식 전환(세션 쿠키 -> Authorization header): + - FE/BE 크로스 도메인 환경에서 Safari third-party cookie 제한 이슈를 줄이기 위한 선택 + - Access Token은 header로 전달해 인증 안정성을 확보하고, refresh는 별도 보안 정책으로 관리 +- 슈퍼어드민 판단 기준 단일화: + - 서버 시작 시 `SUPER_ADMIN_EMAIL`로 DB를 bootstrap(보정)하되 + - 런타임 권한 판단은 항상 DB `isSuperAdmin`만 사용 +- 권한 모델 전환(라벨 조인 -> `perm` 비트마스크): + - 권한 라벨 증가 시 조인 복잡도를 줄이고 확장성을 확보 + - 내부 저장은 비트마스크, 운영 UI는 기존 라벨 형태를 유지해 가독성 보전 +- 데이터 저장소 전환(MySQL/Sequelize -> MongoDB): + - 메인/백오피스 DB 스택을 단일화해 운영 복잡도와 이중 관리 비용을 낮춤 + - 문서/구현 기준은 MongoDB 모델을 소스 오브 트루스로 사용 + +## 5) Prerequisites / Dependencies + +Phase 1 시작 게이팅(필수, Day 1 전 완료): + +- Google Cloud OAuth 설정(Client ID/Secret, Redirect URI) +- FE/BE 도메인/CORS/TLS 확정 +- Safari 대응 인증 경로 확정: Option A(리버스 프록시 기반 same-site 정렬) 적용 + - FE 도메인에서 `/api` 경로를 BE로 프록시해 refresh cookie를 same-site로 처리 + +준비 권장 항목(병행 진행 가능): + +- 운영 알림 채널(이메일/슬랙) 준비 +- 비상 계정 운영 정책 승인 +- IP 개인정보 최소화 정책 확정 + - 원문 IP 저장 대신 `ipHash(HMAC-SHA256 + 고정 서버 secret salt)` 사용 + - 운영 중 secret 교체 금지(교체 시 기존 비교/조회 불가) + +## 6) 운영 UX 변화 + +Before/After: + +- 로그인 + - Before: 아이디/비밀번호 입력 및 세션 쿠키 기반 로그인(브라우저/크로스도메인 이슈 존재) + - After: Google 로그인 버튼 1개 + Authorization 기반 인증 +- 계정 생성 + - Before: 수동 계정 생성/전달 중심 + - After: 슈퍼어드민이 이메일 기반 초대 링크 발급 후 사용자 온보딩(초대 시 권한 사전 지정 가능) +- 권한 변경 + - Before: 권한 기준/변경 이력 가시성이 낮음 + - After: 라벨 기반 권한 UI + 권한 변경 이력 확인 +- 초대 만료 관리 + - Before: 만료 정책 운영 기준 불명확 + - After: 기본 2일 정책을 기준으로 초대 만료값을 관리(슈퍼어드민 권한 범위 내 조정 가능) + +초대 플로우 상세: + +1. 슈퍼어드민이 이메일 1개를 입력해 해당 이메일 전용 초대 링크 1개를 생성한다. +2. 초대 시 기본 권한은 읽기 전용(`read/all`)이며, 슈퍼어드민이 필요 시 write 권한을 사전 지정할 수 있다. +3. 초대 링크는 만료 시간(기본 2일)을 포함하며, 만료/재발급 상태를 운영 화면에서 확인한다. +4. 사용자가 링크 접속 후 Google 로그인하면, 로그인 이메일과 초대 이메일 일치 여부를 검증한다. +5. 일치 시 `googleSub`를 계정에 바인딩하고 이후 로그인 식별 기준은 `googleSub`로 고정한다. + +## 7) Rollback Strategy + +- Phase 1 동안 기존 세션 로그인 경로를 임시 fallback으로 유지 +- 로그인 실패율 > 2% 또는 OAuth 오류율 급증 시 OAuth 경로 비활성화 후 기존 로그인으로 즉시 복구 +- Phase 3 완료 기준 충족 후 기존 로그인 경로 제거(rollback 비상 경로 제외) + +## 8) 비상 운영 절차 + +- 판단 주체: PM + 온콜 백엔드 리드 +- 절차: + 1. 장애 판단(로그인 실패율/에러율 기준) + 2. 공지(운영 채널/사용자 안내) + 3. fallback 적용 + 4. 원인 해결 후 OAuth 재활성화 +- 운영 리스크 공유: + - 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음 + - 이 경우 읽기 중심 운영 모드를 유지하고 로그 저장소 복구 후 변경 작업을 재개 +- 사후 조치: 장애 리포트 및 재발 방지 백로그 등록 + +## 9) Success Metrics + +- 인증 성공률 99.9% 이상 (서버 로그인 API 로그 기준) +- 인증 실패율 baseline 대비 50% 이상 감소 (개편 전 7일 평균 대비) +- 권한 관련 CS 티켓 0건 (Phase 3 완료 후 2주 기준) +- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과 +- Staging 환경에서 운영자 계정 1개 이상 초대 플로우 온보딩 완료 +- 슈퍼어드민 권한 변경 시 대상 계정에 즉시 반영됨을 검증 완료 + +## 10) 리소스 리스크 및 대응 + +- 리스크: 단일 개발자 의존 구조 +- 대응: + - Phase 단위 배포로 리스크 분산 + - 각 Phase 완료 시 중간 검증/승인 + - Phase 2 지연 시 관리자 권한 변경 UI를 임시 제외하고, 슈퍼어드민 API/DB 운영 절차로 권한 변경을 대체해 기능 우선 배포 + - 개발자 이탈/병가 발생 시 즉시 백엔드/프론트 대체 담당자를 지정하고, Phase 1(인증 안정화) 범위 우선으로 축소 운영 + +## 11) 수용 기준 (PM 관점) + +- 2주 마일스톤 내 Phase 1~3 완료 +- 인증/권한/운영 절차가 문서 기준으로 인수 가능 +- 롤백/비상 대응 절차가 실제 운영 가능 상태 +- 기술 상세 항목은 Appendix 기준으로 구현/검증 완료 From 2e939096e11b71b4821166c5ef4da8c71d01f8eb Mon Sep 17 00:00:00 2001 From: a123 Date: Mon, 23 Mar 2026 00:00:05 +0900 Subject: [PATCH 2/5] docs: apply latest bo auth spec revisions --- feature/01-bo-auth-appendix.md | 4 ++-- feature/01-bo-auth.md | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/feature/01-bo-auth-appendix.md b/feature/01-bo-auth-appendix.md index 51cac54..b1f86f7 100644 --- a/feature/01-bo-auth-appendix.md +++ b/feature/01-bo-auth-appendix.md @@ -4,7 +4,7 @@ - 이 Appendix는 기술 구현 기준 문서다. - 기존 [01-bo-auth.md](./01-bo-auth.md)의 기술 상세(데이터 모델, 인증/토큰, 권한 검사, 인덱스, 에러 코드)는 이 Appendix 기준으로 대체한다. -- PM 의사결정/일정/리스크는 [01-bo-auth-plan.md](./01-bo-auth-plan.md)를 기준으로 본다. +- PM 의사결정/일정/리스크는 [01-bo-auth-plan.md](./01-bo-auth.md)를 기준으로 본다. ## A) 목표 아키텍처 요약 @@ -538,7 +538,7 @@ Safari silent refresh QA(Phase 1 필수): ```json { - "id": "664f3f2a0d8e8b7d4a12c901", + "id": "1234567890abcde", "email": "admin@uos.ac.kr", "name": "홍길동", "isSuperAdmin": false, diff --git a/feature/01-bo-auth.md b/feature/01-bo-auth.md index 01e0474..bf04843 100644 --- a/feature/01-bo-auth.md +++ b/feature/01-bo-auth.md @@ -29,12 +29,6 @@ - 권한 확장 시 기술 부채가 누적된다. - 계정/보안 운영 비용이 계속 증가한다. -## 3) Scope (2주) - -전체 담당: - -- 백엔드/프론트: 엄준식 - ### Phase 1 (3/24~3/27, 4일): 인증 전환 - Google OAuth + Authorization 토큰 기반 로그인 전환 @@ -173,7 +167,7 @@ Before/After: - Phase 2 지연 시 관리자 권한 변경 UI를 임시 제외하고, 슈퍼어드민 API/DB 운영 절차로 권한 변경을 대체해 기능 우선 배포 - 개발자 이탈/병가 발생 시 즉시 백엔드/프론트 대체 담당자를 지정하고, Phase 1(인증 안정화) 범위 우선으로 축소 운영 -## 11) 수용 기준 (PM 관점) +## 11) 수용 기준 - 2주 마일스톤 내 Phase 1~3 완료 - 인증/권한/운영 절차가 문서 기준으로 인수 가능 From 57474f7cdb1bfaf9c62254525c18aaf4e95de154 Mon Sep 17 00:00:00 2001 From: a123 Date: Fri, 3 Apr 2026 19:05:22 +0900 Subject: [PATCH 3/5] chore: freeze bo auth specs before implementation --- feature/01-bo-auth-plan.md | 184 +++++ feature/01-bo-auth.md | 4 +- feature/02-activity-cms.md | 417 ++++++++++ feature/03-club-info-cms.md | 126 +++ feature/04-recruit-form-cms.md | 1368 ++++++++++++++++++++++++++++++++ 5 files changed, 2097 insertions(+), 2 deletions(-) create mode 100644 feature/01-bo-auth-plan.md create mode 100644 feature/02-activity-cms.md create mode 100644 feature/03-club-info-cms.md create mode 100644 feature/04-recruit-form-cms.md diff --git a/feature/01-bo-auth-plan.md b/feature/01-bo-auth-plan.md new file mode 100644 index 0000000..31d9a20 --- /dev/null +++ b/feature/01-bo-auth-plan.md @@ -0,0 +1,184 @@ +# 01. BO Auth 실행 명세서 (PM용) + +## Executive Summary + +- Safari 인증 이슈와 권한 모델 확장성 문제를 동시에 해결한다. +- 인증은 Google OAuth + Authorization 토큰 기반으로 전환한다. +- 권한은 확장 가능한 구조로 단순화하고, 슈퍼어드민 운영 통제를 강화한다. +- 2주 내 단계적 전환과 rollback 전략으로 도입 리스크를 관리한다. + +## 0) 문서 목적 + +본 문서는 백오피스 인증/권한 체계 고도화의 PM 의사결정용 실행 계획이다. +기술 구현 세부는 별도 Appendix 문서로 분리한다. + +- 기술 Appendix: [01-bo-auth-appendix.md](./01-bo-auth-appendix.md) + +## 1) Expected Impact + +- Safari의 third-party cookie 제한 이슈를 제거하기 위해 cookie 기반 인증에서 Authorization header 기반 인증으로 전환, 인증 실패율 감소(특히 Safari 환경) +- 권한 관련 운영 이슈 감소(CS/운영 문의 티켓 기준) +- 관리자 계정 생성/권한 변경 작업 시간 감소 +- 계정/권한 보안 사고 리스크 감소 +- 정량 목표/측정 기준은 `9) Success Metrics`를 따른다. +- baseline은 개편 전 최근 7일 서버 로그인 API 로그를 기준으로 산정한다. + +## 2) Risk if not implemented + +- Safari 인증 이슈가 지속된다. +- 권한 확장 시 기술 부채가 누적된다. +- 계정/보안 운영 비용이 계속 증가한다. + +## 3) Scope (2주) + +전체 담당: + +- 백엔드/프론트: 엄준식 + +### Phase 1 (3/24~3/27, 4일): 인증 전환 + +- Google OAuth + Authorization 토큰 기반 로그인 전환 +- 크로스 도메인 안정화 +- 기존 로그인 fallback 유지 + +완료 기준: + +- Safari 포함 주요 브라우저 로그인 성공 +- 인증 성공률 99% 이상 (QA + staging 로그 기준) +- Safari 환경에서 access token 만료 후 refresh 기반 세션 복구(silent refresh) 성공 +- 로그인 상태 새로고침(F5) 및 신규 탭 보호 경로 직접 접근 시 `bootstrapAuth -> /auth/refresh -> /bo/auth/me` 세션 복구 흐름 성공 +- Safari cross-domain refresh 검증은 Appendix `P) 테스트 전략`의 QA 시나리오 기준으로 판정 + +### Phase 2 (3/28~4/2, 5일): 권한 모델 전환 + +- 권한 모델 전환(확장 가능한 구조) +- 관리자 권한 관리 UX 정리 +- `/bo/auth/me` 최신 상태 기준 확정 +- 리스크: + - 권한 모델 전환 시 기존 권한 불일치 가능성 +- 대응: + - 병행 검증 기간: Phase 2 전체 기간(3/28~4/2) + - 검증 방법: 신규 권한 모델 결과와 기존 권한 기준 결과를 관리자 계정 전체 대상 대조 + - 완료 판단: QA 환경에서 권한 오검증 0건 확인 후 Phase 3 진입 + - 불일치 발생 시: 신규 모델 적용 즉시 중단 -> 원인 분석 -> 수정 후 재검증 + +완료 기준: + +- 권한 오검증 0건 +- 권한 변경 즉시 반영 정책 동작 +- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과 + +### Phase 3 (4/3~4/5, 4일): 감사/보안 고도화 + +- 감사 로그/보안 이벤트 모니터링 +- 보안 정책 마감(rate limit, 토큰 운영, 경보 연계) +- 릴리스 점검 및 운영 인수 + +완료 기준: + +- 보안 체크리스트 충족(Appendix `D`, `I`, `J`, `K`, `P` 기준) +- 운영 대시보드/알림 체계 확인 +- 기존 세션 로그인 경로 제거 완료(rollback 비상 경로 제외) + +## 4) 설계 선택 근거 (요약) + +- 인증 방식 전환(세션 쿠키 -> Authorization header): + - FE/BE 크로스 도메인 환경에서 Safari third-party cookie 제한 이슈를 줄이기 위한 선택 + - Access Token은 header로 전달해 인증 안정성을 확보하고, refresh는 별도 보안 정책으로 관리 +- 슈퍼어드민 판단 기준 단일화: + - 서버 시작 시 `SUPER_ADMIN_EMAIL`로 DB를 bootstrap(보정)하되 + - 런타임 권한 판단은 항상 DB `isSuperAdmin`만 사용 +- 권한 모델 전환(라벨 조인 -> `perm` 비트마스크): + - 권한 라벨 증가 시 조인 복잡도를 줄이고 확장성을 확보 + - 내부 저장은 비트마스크, 운영 UI는 기존 라벨 형태를 유지해 가독성 보전 +- 데이터 저장소 전환(MySQL/Sequelize -> MongoDB): + - 메인/백오피스 DB 스택을 단일화해 운영 복잡도와 이중 관리 비용을 낮춤 + - 문서/구현 기준은 MongoDB 모델을 소스 오브 트루스로 사용 + +## 5) Prerequisites / Dependencies + +Phase 1 시작 게이팅(필수, Day 1 전 완료): + +- Google Cloud OAuth 설정(Client ID/Secret, Redirect URI) +- FE/BE 도메인/CORS/TLS 확정 +- Safari 대응 인증 경로 확정: Option A(리버스 프록시 기반 same-site 정렬) 적용 + - FE 도메인에서 `/api` 경로를 BE로 프록시해 refresh cookie를 same-site로 처리 +- fail-fast 환경변수 확정 및 주입: + - `COOKIE_SECRET`, `ACCESS_TOKEN_SECRET`, `BO_BACKEND_URL`, `BO_FRONTEND_URL`, `BO_ALLOWED_ORIGINS`, `MONGO_URI`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `SERVER_SECRET_SALT` + +준비 권장 항목(병행 진행 가능): + +- 운영 알림 채널(이메일/슬랙) 준비 +- 비상 계정 운영 정책 승인 +- IP 개인정보 최소화 정책 확정 + - 원문 IP 저장 대신 `ipHash(HMAC-SHA256 + 고정 서버 secret salt)` 사용 + - 운영 중 secret 교체 금지(교체 시 기존 비교/조회 불가) + +## 6) 운영 UX 변화 + +Before/After: + +- 로그인 + - Before: 아이디/비밀번호 입력 및 세션 쿠키 기반 로그인(브라우저/크로스도메인 이슈 존재) + - After: Google 로그인 버튼 1개 + Authorization 기반 인증 +- 계정 생성 + - Before: 수동 계정 생성/전달 중심 + - After: 슈퍼어드민이 이메일 기반 초대 링크 발급 후 사용자 온보딩(초대 시 권한 사전 지정 가능) +- 권한 변경 + - Before: 권한 기준/변경 이력 가시성이 낮음 + - After: 라벨 기반 권한 UI + 권한 변경 이력 확인 +- 초대 만료 관리 + - Before: 만료 정책 운영 기준 불명확 + - After: 기본 2일 정책을 기준으로 초대 만료값을 관리(슈퍼어드민 권한 범위 내 조정 가능) + +초대 플로우 상세: + +1. 슈퍼어드민이 이메일 1개를 입력해 해당 이메일 전용 초대 링크 1개를 생성한다. +2. 초대 시 기본 권한은 읽기 전용(`read/all`)이며, 슈퍼어드민이 필요 시 write 권한을 사전 지정할 수 있다. +3. 초대 링크는 만료 시간(기본 2일)을 포함하며, 만료/재발급 상태를 운영 화면에서 확인한다. +4. 사용자가 링크 접속 후 Google 로그인하면, 로그인 이메일과 초대 이메일 일치 여부를 검증한다. +5. 일치 시 `googleSub`를 계정에 바인딩하고 이후 로그인 식별 기준은 `googleSub`로 고정한다. + +## 7) Rollback Strategy + +- Phase 1 동안 기존 세션 로그인 경로를 임시 fallback으로 유지 +- 로그인 실패율 > 2% 또는 OAuth 오류율 급증 시 OAuth 경로 비활성화 후 기존 로그인으로 즉시 복구 +- Phase 3 완료 기준 충족 후 기존 로그인 경로 제거(rollback 비상 경로 제외) + +## 8) 비상 운영 절차 + +- 판단 주체: PM + 온콜 백엔드 리드 +- 절차: + 1. 장애 판단(로그인 실패율/에러율 기준) + 2. 공지(운영 채널/사용자 안내) + 3. fallback 적용 + 4. 원인 해결 후 OAuth 재활성화 +- 운영 리스크 공유: + - 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음 + - 이 경우 읽기 중심 운영 모드를 유지하고 로그 저장소 복구 후 변경 작업을 재개 +- 사후 조치: 장애 리포트 및 재발 방지 백로그 등록 + +## 9) Success Metrics + +- 인증 성공률 99.9% 이상 (서버 로그인 API 로그 기준) +- 인증 실패율 baseline 대비 50% 이상 감소 (개편 전 7일 평균 대비) +- 권한 관련 CS 티켓 0건 (Phase 3 완료 후 2주 기준) +- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과 +- Staging 환경에서 운영자 계정 1개 이상 초대 플로우 온보딩 완료 +- 슈퍼어드민 권한 변경 시 대상 계정에 즉시 반영됨을 검증 완료 + +## 10) 리소스 리스크 및 대응 + +- 리스크: 단일 개발자 의존 구조 +- 대응: + - Phase 단위 배포로 리스크 분산 + - 각 Phase 완료 시 중간 검증/승인 + - Phase 2 지연 시 관리자 권한 변경 UI를 임시 제외하고, 슈퍼어드민 API/DB 운영 절차로 권한 변경을 대체해 기능 우선 배포 + - 개발자 이탈/병가 발생 시 즉시 백엔드/프론트 대체 담당자를 지정하고, Phase 1(인증 안정화) 범위 우선으로 축소 운영 + +## 11) 수용 기준 (PM 관점) + +- 2주 마일스톤 내 Phase 1~3 완료 +- 인증/권한/운영 절차가 문서 기준으로 인수 가능 +- 롤백/비상 대응 절차가 실제 운영 가능 상태 +- 기술 상세 항목은 Appendix 기준으로 구현/검증 완료 diff --git a/feature/01-bo-auth.md b/feature/01-bo-auth.md index bf04843..ccf381a 100644 --- a/feature/01-bo-auth.md +++ b/feature/01-bo-auth.md @@ -1,4 +1,4 @@ -# 01. BO Auth 실행 명세서 (PM용) +# 01. BO Auth 실행 명세서 ## Executive Summary @@ -9,7 +9,7 @@ ## 0) 문서 목적 -본 문서는 백오피스 인증/권한 체계 고도화의 PM 의사결정용 실행 계획이다. +본 문서는 백오피스 인증/권한 체계 고도화의 실행 계획이다. 기술 구현 세부는 별도 Appendix 문서로 분리한다. - 기술 Appendix: [01-bo-auth-appendix.md](./01-bo-auth-appendix.md) diff --git a/feature/02-activity-cms.md b/feature/02-activity-cms.md new file mode 100644 index 0000000..6214c57 --- /dev/null +++ b/feature/02-activity-cms.md @@ -0,0 +1,417 @@ +# 02. Activity 동적 구성 기능 도입 (Activity CMS) + +> **📜 문서 수정 이력 (Changelog)** +> **[2026-03-23 수정 사항]** +> - **Orphaned Image (미사용 이미지) 방지 정책 추가:** 게시물 작성 중도 취소 시 클라이언트 측 즉각적인 클라우드 스토리지 비우기 및 주기적 잉여 이미지 일괄 제거(Cron) 정책 (5.1.2 항목) 반영 +> +> **[2026-03-22 수정 사항]** +> - **용어 및 계층 구조 명확화:** `Type(종류)` > `Field(속성)` > `Item(콘텐츠)` 3단계 구조로 용어 통일 +> - **속성명 변경:** 렌더링용 입력 양식임을 명확히 하기 위해 `Field`항목에 들어 있던 `type`을 `inputType`으로 변경 +> - **불변 식별자(Immutable Identifier) 도입:** 값이 변경될 소지가 있는 `name` 대신, 식별 고유 키값인 `typeId`, `fieldId`를 사용하도록 변경 +> - **시스템 아키텍처 명시:** 백오피스와 메인 웹 서버가 서로 API(구독) 통신하는 방식이 아닌, 동일한 MongoDB를 각각 직접 조회하는 Shared DB(Direct Connection) 구조 채택 및 역할 명시 (3.3 및 3.4 항목 도입) + +## 1. 개요 및 목적 +기존 메인 웹의 Activity 영역(스터디, 세미나, 개발 등) 데이터는 프론트엔드 코드(`studyData.ts` 등) 내에 **하드코딩**되어 관리되고 있습니다. 이로 인해 새로운 활동이 추가되거나 속성(필드)이 변경될 때마다 개발자가 직접 코드를 수정하고 서버를 재배포해야 하는 비효율이 발생합니다. + +본 기능 명세는 백오피스에서 Activity의 **유형(Type)** 과 **속성(Field)**, 그리고 실제 들어갈 **콘텐츠(Item)** 를 운영진이 직접 정의하고 관리할 수 있는 **동적 CMS(Content Management System)**를 구축하여 운영 효율성(UX)과 시스템 확장성(DX)을 극대화하는 것을 목표로 합니다. + +--- + +## 2. 현재 상태 분석 (As-Is) + +### 2.1 문제점 +현재 웹사이트의 메인 화면에 출력되는 Activity(스터디, 세미나, 개발 등) 데이터는 프론트엔드 소스 코드 내에 완전히 하드코딩되어 있습니다. + +- `main/frontend/src/lib/activity/studyData.ts` 등 — 각 활동별 데이터(주제, 상세내용, 기간, 툴 등)가 정적 배열로 고정되어 있음. +- 기술 스택 아이콘(`tools`) 마저 URL 검증을 개발자가 주석으로 남기며 수동으로 체크해야 하는 비효율성 존재. +- 새로운 속성(예: 세미나의 '발표자' 필드)이 필요할 때마다 고정된 인터페이스(`ActivityItem`)를 개발자가 직접 수정하고 배포해야 함. +- 이미지 자원의 URL(`https://pub-...r2.dev/*.jpeg` 등)이 코드상에 문자열로 직접 적혀 있어, 파일이 수정될 때마다 코드 수정 및 재배포가 강제됨. + +### 2.2 구체적 고정 항목들 (`studyData.ts` 기준) + +| 영역 | 고정된 내용 | +|------|------------| +| 인터페이스 구조 | `topic`, `details`, `date`, `images`, `tools`, `link` 등으로 필드 형태가 제한됨 | +| 이미지 배열 | Cloudflare R2 스토리지의 URL 문자열들이 개별 `items` 속성에 정적으로 들어가 있음 | +| 노출 순서 | 소스 코드 상의 배열(`studyItems`) 선언 순서에 강제적으로 의존하게 됨 | + +> **결론:** 아주 단순한 오타 제보나, 새로운 학기의 신규 활동 추가 시에도 운영진이 직접 할 수 없고 무조건 프론트엔드 개발자가 코드를 수정 및 재배포해야 운영에 반영됩니다. + +### 2.3 현재 시스템 결합 구조 + +이 기능의 가장 큰 목적은, 기존의 '프론트엔드 코드 내부 종속식 렌더링'을 '백오피스 DB 변경 즉시 동적 렌더링'으로 아키텍처를 뒤집는 것에 있습니다. + +```mermaid +graph LR + subgraph Current["현재 구조 (As-Is)"] + direction LR + subgraph MainFE["main/frontend"] + direction TB + MF1["lib/activity/studyData.ts
(정적 데이터 하드코딩)"] + MF2["lib/activity/seminaData.ts"] + MF3["components/ActivityCard.tsx
(고정 인터페이스 렌더링)"] + end + Cloud["Cloudflare R2
(이미지 스토리지)"] + MainFE -.->|URL 하드코딩| Cloud + end + Admin["운영진"] -.->|활동 내용 수정 요청| Dev["개발자"] + Dev -.->|수동 코드 수정 & 서버 재배포| MainFE +``` + +- 현재 구조에서는 데이터의 주인이 코드를 작성한 개발자에게 종속되어, 콘텐츠가 유연하게 관리되지 못하는 가장 큰 병목이 발생합니다. + +--- + +## 3. 핵심 아키텍처 및 해결 방안 (To-Be) + +우리가 만들 CMS는 **"구글 폼을 만드는 관리자 화면"** 과 완벽하게 동일한 원리로 동작합니다. + +### 3.1. 문제 해결 매핑 (As-Is ➡️ To-Be) +2장에서 정의한 문제점들을 아래와 같은 기술적 전략으로 일대일(1:1) 매칭하여 해결합니다. + +| As-Is (해결할 문제점) | To-Be (기술적 해결 전략) | 관련 목차 | +| --- | --- | --- | +| **하드코딩으로 인한 개발자 종속성 및 확장성 한계**
(고정된 데이터 구조 & 잦은 재배포) | **데이터베이스(MongoDB) 기반 조회 및 Schema-driven UI 도입**
하드코딩 데이터를 DB로 완전 이관(Type/Field/Item 분리)하여, **프론트엔드 코드 수정 및 재배포 없이** 백오피스에서 즉시 메인 웹 화면(신규 필드 포함)을 유연하게 제어. | 3.3, 4, 5.2 | +| **이미지 관리의 비효율성**
(코드 의존적 리소스 관리) | **Presigned URL 기반 다이렉트 클라우드 업로드**
스토리지 이미지와 DB URL 저장을 결합하여 프론트엔드 코드 배포와 파일 관리를 완전히 분리 | 5.1 | + +### 3.2. 주요 용어 사전 (Glossary) +처음 CMS 구조를 접하는 팀원들을 위해, 본 문서와 개발 과정에서 가장 자주 쓰일 핵심 용어들을 정리합니다. + +- **Type (활동의 종류) / `ActivityTypes` 컬렉션:** + - '스터디', '세미나', '해커톤' 등 메인 웹에서 하나의 **메뉴(탭)** 단위가 되는 큼직한 껍데기를 의미합니다. + - 백오피스에서 "새로운 활동 탭을 하나 만들자!"라고 할 때 이 Type을 하나 생성하게 됩니다. +- **Field / `ActivityField`(질문 문항 / 설계도):** + - 특정 ActivityType 안에서 데이터를 받기 위해 뚫어놓은 **입력칸(항목)** 들입니다. + - 예시: 스터디 Type 내부의 '기간(Date)', '상세설명(LongText)', '사진(Image)' 필드들. + - 이 필드들이 모여서 하나의 "ActivityType(Schema 설계도)"을 이룹니다. +- **Item (실제 게시물 알맹이) / `ActivityItems` 컬렉션:** + - 운영진이 만들어진 Field 양식에 맞춰 빈칸에 실제로 타이핑해서 넣은 **진짜 데이터(글, 사진)**입니다. + - 메인 웹에 예쁘게 그려지는 실제 활동들이 Item입니다. +- **하드코딩 (Hardcoding):** + - 데이터를 관리자 웹이나 DB에서 가져오지 않고, 개발자가 소스 코드 파일(예: `.ts`, `.json`) 안에 직접 텍스트로 박아 넣는 행위. 우리가 이 프로젝트에서 **없애려는 가장 큰 문제점(As-Is)**입니다. +- **Schema-driven UI 패러다임:** + - 프론트엔드가 화면을 그릴 때 코드에 하드코딩된 값(예: `item.topic`)을 찾는 것이 아니라, 백엔드가 주는 설계도(Schema) 문서만 쳐다보고 거기에 있는 필드를 꺼내서 화면을 자동으로 찍어내는(Dynamic Render) 최신 프론트엔드 설계 기법입니다. +- **Presigned URL (사전 발급된 URL):** + - 무거운 이미지나 파일을 사용자가 업로드할 때, 우리 백엔드 서버를 거치지 않게 하기 위해 **클라우드(AWS S3, Cloudflare R2 등)에서 발급받는 "일회용 클라우드 출입증 티켓"**입니다. 이 티켓을 쓰면 프론트엔드에서 클라우드로 파일을 직행시킬 수 있어 서버 부하가 줄어듭니다. + +### 3.3. 역할 분담 + +#### 백오피스: "설계도를 만들고 데이터를 채우는 곳" +- **Backoffice Frontend (기획 UX)** + - 운영진이 마우스 조작만으로 `Type`과 `Field`를 동적으로 추가/삭제하는 **컨트롤 타워** 역할을 합니다. + - 드래그 앤 드롭 기능을 지원해, 노출할 필드나 실제 카드(`Item`)의 '순서(order)'를 직관적으로 조작합니다. +- **Backoffice Backend (저장소 관리)** + - 운영 관리자로서의 권한을 깐깐하게 검증(Auth)합니다. + - 프론트엔드에서 조합된 자유로운 구조(Schema)의 양식과 실제 데이터(JSON)를 받아, MongoDB의 특성을 살려 에러 없이 유연하게 영구 저장(C/U/D)합니다. + +#### 메인 웹: "전달받은 설계도대로 렌더링" +- **Main Backend (단순 정보 배달부)** + - 메인 접속자들에 대한 복잡한 시스템 로직을 줄이고, MongoDB에 저장된 '설계도와 내용물(Item)'을 꺼내어 화면 쪽에 안전하고 빠르게 **단순 전달(Read)** 해주는 역할을 합니다. +- **Main Frontend (코어 렌더러)** + - 서버로부터 넘어온 `Type`, `Field`, `Item` 구조를 해석합니다. + - 사전에 정의된 규칙에 따라, **프론트엔드 소스 코드를 단 한 줄도 수정(재배포)하지 않고도** 그 안에 정의된 `Item(콘텐츠)`의 내용들을 알아서 예쁘게 화면에 그려냅니다(Dynamic Rendering). + +### 3.4. 시스템 간 DB 연결 구조 (Shared Database) +서비스 간 의존도를 낮추고 운영 인프라를 단순화하기 위해, **백오피스와 메인 시스템이 서로 API로 통신하지 않고 동일한 MongoDB를 각각 직접 연결(Direct Connection)하여 조회하는 구조**를 채택합니다. +- **단일 장애점(SPOF) 방지 및 의존성 분리:** 백오피스 백엔드가 API를 제공하고 메인 백엔드가 이를 호출(Fetch)하는 구독 형태를 피합니다. 이를 통해 백오피스 서버가 다운되거나 점검 중일 때도 메인 웹사이트의 고객 조회 트래픽에는 전혀 영향을 주지 않도록 격리합니다. +- **직접 연결(Direct Connection):** 기존 퀴푸 아키텍처와 동일하게 `Backoffice Backend`와 `Main Backend`는 각각 독립적인 서버로 동작하며, 공통된 하나의 MongoDB 클러스터에 접속하여 각자의 역할(백오피스는 C/U/D 위주, 메인은 R 위주)에 맞게 쿼리합니다. + +--- + +## 4. 데이터베이스 및 스키마 설계 (MongoDB) + +RDBMS처럼 고정된 열(Column)을 가지는 대신, 동적으로 변하는 데이터를 담기 위해 유연한 Document 구조(NoSQL)를 채택합니다. +두 가지의 핵심 컬렉션(`ActivityTypes`, `ActivityItems`)으로 구성되며, 이에 대한 명확한 TypeScript 인터페이스 명세는 다음과 같습니다. + +### 4.1. `ActivityTypes` 컬렉션 (양식 설계도 박스) + +ActivityType은 어떤 활동(Type)이 있고, 그 활동은 어떤 항목(Field)를 필요로 하는지 정의합니다. + +```typescript +// [공통 타입] 메인/백오피스, 프론트엔드 및 백엔드 등 모든 영역에서 공통으로 사용되는 타입 +// 지원하는 입력 필드 타입 종류 +type FieldType = + | "shortText" // 단답형 텍스트 (예: 주제, 멘토 이름) + | "longText" // 서술형 텍스트 (예: 상세 설명) + | "date" // 단일 날짜 + | "dateRange" // 기간 (시작일 - 종료일) + | "image" // 단일 이미지 업로드 + | "imageList" // 다중 이미지 업로드 + | "url" // 외부 링크 (예: Github, 퀴푸 메인웹 링크 등) + | "icon"; // 기술 스택 아이콘 (예: Devicon 키워드) + +// 개별 필드(질문 문항) 설계도 +interface ActivityField { + fieldId: string; // 데이터를 넣고 뺄 고유 영문 키 (예: "topic", "details"), 수정 불가 + label: string; // 백오피스 입력 폼에 보여질 한글 라벨 (예: "주제", "상세 설명") + inputType: FieldType; // 입력 필드의 형태 (프론트엔드 렌더링 기준) + required: boolean; // 필수 입력 여부 + order: number; // 백오피스 입력 폼에서의 출력 순서 + + // type이 "image" 또는 "imageList"일 경우의 추가 설정 + fileConfig?: { + maxFiles?: number; // 최대 허용 이미지 개수 + maxSizeMB?: number; // 장당 최대 용량 제한 (예: 5MB) + }; +} + +// [백엔드/공통 타입] DB 모델 스키마의 기준이자, 프론트엔드가 응답받게 되는 타입 +// 하나의 Type(스터디, 세미나 등)에 대한 전체 정의 +interface ActivityType { + _id: ObjectId; + typeId: string; // (예: "study", "semina") 수정 불가 + displayName: string; // 메인 웹 네비게이션에 노출될 이름 (예: "스터디") + description?: string; // 활동에 대한 전반적인 설명 + order: number; // 메인 웹 탭 메뉴에서의 노출 순서 (DnD 정렬용) + isActive: boolean; // 메인 노출 여부 토글 (비활성화 시 숨김) + fields: ActivityField[]; // 💡 이 활동에서 입력받을 필드 항목들의 배열 (Schema 설계도) + createdAt: Date; + updatedAt: Date; +} +``` + +
+실제 MongoDB 저장 예시 (JSON 데이터) + +```json +{ + "_id": "ObjectId", // 몽고DB 자동 생성 id + "typeId": "study", // 스터디 고유 키값, 수정 불가 + "displayName": "스터디", // 메인 웹 네비게이션에 노출될 이름 + "description": "개발 공부부터 코딩 테스트까지 등등...", + "order": 1, // 👈 메인 웹 네비게이션 메뉴 노출 순서 + "isActive": true, // 메인 노출 여부 토글 + "fields": [ // 💡 어떤 입력 항목(질문)을 받을 것인가? + { + "fieldId": "topic", // 나중에 실제 값을 꺼낼 키 + "label": "주제", // 백오피스 폼에 보일 질문 이름 + "inputType": "shortText", // 입력 타입 (Text, Date, Image 등 프론트엔드 렌더링 기준) + "required": true, + "order": 1 // 👈 백오피스 입력 폼에서의 위치 + }, + { "fieldId": "date", "label": "기간", "inputType": "dateRange", "required": true, "order": 2 }, + // ... 세미나 타입이라면 여기에 "fieldId": "speaker" (발표자) 필드가 동적으로 들어감. + ] +} +``` +
+ +### 4.2. `ActivityItems` 컬렉션 (실제 데이터 창고) + +Type 설계도에 맞춰 운영진이 직접 작성한 실제 게시물이 저장됩니다. 필드 값은 설계도의 불변 식별자인 `fieldId`를 키로 하는 유연한 구조(`Record`)를 가집니다. + +```typescript +// [백엔드/공통 타입] DB 모델 스키마의 기준이자, 프론트엔드가 응답받게 되는 실제 데이터 타입 +interface ActivityItem { + _id: ObjectId; + typeKey: string; // 부모격인 ActivityType의 typeId 참조값 (예: "study", "semina") + order: number; // 👈 동일 Type 내에서 리스트 노출 순서 (DnD 정렬용) + isVisible: boolean; // 승인/공개 여부 (임시저장 기능 등에 활용) + + // 💡 설계도(fields)의 규칙에 맞춰 자유롭게 들어가는 실제 데이터 + data: Record; + + createdAt: Date; + updatedAt: Date; +} +``` + +
+실제 MongoDB 저장 예시 (JSON 데이터) + +```json +{ + "_id": "ObjectId", + "typeKey": "study", // ActivityType 참조값 + "order": 1, // 👈 해당 Type 내에서 리스트 노출 순서 (드래그 앤 드롭으로 변경됨) + "isVisible": true, // 승인/공개 여부 + "data": { // 💡 설계도(fields)의 규칙에 얽매이지 않고 들어가는 유연한 데이터 박스! + "topic": "리액트 기초 스터디", + "date": "24.03.01 - 24.06.30", + "details": "프론트엔드의 대명사 리액트를 다루는 스터디입니다.", + "images": [ + // DB에는 무거운 이미지 파일이 아닌 클라우드에 직접 올린 흔적(URL)만 저장됨 + "https://pub-e688831b...dev/study-react1.jpeg" + ] + }, + "createdAt": "2026-03-10T09:00:00Z", + "updatedAt": "2026-03-10T09:00:00Z" +} +``` +
+ +> **결론:** 메인 웹의 프론트엔드는 `ActivityType`를 조회해 탭을 구성하고 각 카드가 어떤 필드를 갖고 있는지 파악한 뒤, `ActivityItem`의 `data` 객체에서 해당 필드 값을 동적으로 꺼내어 화면을 그립니다. + +--- + +## 5. 상세 구현 컴포넌트 및 API 흐름도 + +### 5.1. 이미지 업로드: Presigned URL 방식 도입 + +#### 5.1.1. 이미지 업로드 흐름 (Direct Upload) +서버 부하와 보안을 위해, 무거운 파일은 백엔드를 거치지 않고 프론트엔드에서 클라우드 스토리지(Cloudflare R2 등)로 직접 업로드합니다. +1. `Backoffice 프론트` ➡️ `Backoffice 백엔드`: "나 이미지 올릴 건데 클라우드 일회용 출입증(Presigned URL) 줘!" (권한/토큰 검사) +2. `Backoffice 백엔드` ➡️ `Backoffice 프론트`: 발급된 티켓(URL) 전달 +3. `Backoffice 프론트` ➡️ `클라우드 스토리지`: 이미지 100% 직행 전송 +4. 업로드 완료 후 생성된 URL 텍스트만 `ActivityItem`의 `data.images` 배열에 저장. + +#### 5.1.2. 🚨 Orphaned Image (미사용 이미지) 방지 정책 +작성 도중 이미지만 업로드하고 **게시물 작성을 취소하거나 브라우저 창을 닫아버리는 경우**에 발생하는 스토리지 용량 누수(고아 이미지)를 다음과 같이 즉각적으로 방지합니다. +- **클라이언트 측 즉시 삭제 (우선 고려):** 백오피스 프론트엔드에서 '작성 취소/뒤로가기' 버튼 클릭이나 브라우저 이탈(Unmount, beforeunload) 감지 시, 업로드 큐에 있던 이미지 URL들을 모아 백엔드의 이미지 삭제 API(`DELETE /bo/images`)를 호출하여 클라우드 스토리지를 즉각 비워냅니다. +- **주기적 크론(Cron) 정리 (보완책):** 클라이언트 측의 네트워크 에러나 강제 브라우저 종료 등으로 인한 삭제 API 호출 실패를 대비하여, 백엔드는 주기적(예: 매일 자정)으로 Cloudflare R2 스토리지의 목록과 DB(`ActivityItem`)에 정상 등록된 이미지 URL들을 대조해 쓰이지 않는 잉여 이미지들을 일괄 제거하는 배치(Batch) 스케줄러를 운영합니다. + +### 5.2. 프론트엔드 동적 렌더링 (Dynamic UI Rendering) 및 신규 필드 추가 대응 +메인 프론트엔드는 더 이상 하드코딩된 값(`item.topic`)을 찾지 않습니다. 동적으로 변하는 키 값을 감지하여 예외 상황을 처리하는 **Schema-driven UI** 패턴을 적용합니다. + +- **유연한 렌더링 방식 (프론트엔드 무수정 원칙):** + - 메인 프론트는 `item.data.mentor` 처럼 특정 필드 이름을 하드코딩해서 찾지 않습니다. + - 대신, 백엔드가 전달해 준 설계도(`ActivityType`의 `fields` 배열)를 순회(`map`)하며, 설계도에 있는 필드 이름(`field.fieldId`)으로 실제 데이터(`item.data[field.fieldId]`)를 동적으로 꺼내서 화면에 그립니다. + - 이 핵심 로직은 프론트엔드 코드에 아래와 같이 **단 한 번만 작성**됩니다. + + ```tsx + // [메인 프로젝트: 프론트엔드] 코어 로직 - Schema-driven UI 렌더링 핵심 컴포넌트 예시 + function DynamicActivityCard({ schema, item }) { + // schema: 백엔드에서 내려준 ActivityType (어떤 필드들이 있는지) + // item: 백엔드에서 내려준 ActivityItem 객체 전체 (단일 게시물) + + return ( +
+ {schema.fields.map((field) => { + // 💡 포인트 1: 필드 이름표(e.g., 'mentor', 'topic')로 실제 데이터를 동적으로 뽑아옴 + const fieldValue = item.data[field.fieldId]; + + // 💡 포인트 2: 만약 이번 글에 이 항목(e.g., 새로 추가된 mentor)이 안 적혀있거나 값 타입이 안 맞으면? -> 부드럽게 생략! (Fallback) + if (fieldValue === undefined || fieldValue === null) return null; + + // 💡 포인트 3: 어떤 타입의 질문(field.inputType)이었느냐에 따라 알아서 알맞은 UI 스위칭 + switch (field.inputType) { + case 'shortText': + return

{field.label}: {fieldValue}

; + case 'longText': + return
{fieldValue}
; + case 'imageList': + return ( +
+ {/* imageList는 배열이므로 map으로 순회하여 렌더링합니다. */} + {Array.isArray(fieldValue) && fieldValue.map((url, idx) => ( + {`${field.label} + ))} +
+ ); + default: + return {fieldValue}; + } + })} +
+ ); + } + ``` + +- **[DB 관점] NoSQL의 유연함 활용:** + - MongoDB는 스키마리스(Schema-less) 특성을 가집니다. 운영진이 백오피스에서 내년에 '담당 멘토(mentor)'라는 필드를 새로 추가했을 때, **기존 과거 데이터(작년 스터디 글)를 일괄 수정하거나 DB 마이그레이션을 할 필요가 전혀 없습니다.** + - 기존 문서들은 해당 필드가 없는 채로 얌전히 유지되고, 새로 작성되는 문서에만 `mentor` 필드가 포함된 채로 안전하게 저장됩니다. + +- **[백엔드 관점] Data Validation & Schema Delivery:** + - 백엔드(Express + Mongoose)는 지나치게 자유로운 DB 입력을 제어하기 위해 최소한의 뼈대 스키마(예: `typeKey`, `isVisible`, `data: Object`)만을 유지합니다. + - 백엔드의 역할은 메인 웹 접속자에게 해당 Activity의 구조(`ActivityType.fields`)와 내용(`ActivityItem`)을 가공 없이 빠르게 내려주는 API 허브 역할입니다. + +- **[프론트엔드 관점] 순수 렌더링 로직 (Zero-Code Modification):** + - 메인 프론트는 `item.data.mentor` 처럼 특정 필드 이름을 하드코딩해서 찾지 않습니다. + - 내년에 '담당 멘토(mentor)' 필드가 새로 추가되더라도, 아래 코드가 알아서 `fields` 배열을 돌면서 새 데이터를 그려냅니다. **프론트엔드 개발자는 코드를 단 한 줄도 추가하거나 수정할 필요가 없습니다.** + - 기존 과거 스터디 데이터들은 `fieldValue`가 `undefined`로 잡히므로 위 코드의 `if (fieldValue === undefined || fieldValue === null)` 방어 로직에 걸려 에러 없이 자연스럽게 넘어가며(Fallback), 과거의 모습 그대로 안전하게 유지됩니다. + +### 5.3. 순서(Order) 제어 방식 상세 +운영진이 특정 활동 탭(예: 스터디)이나 내부 게시물(예: 리액트 스터디 카드)의 노출 순서를 마음대로 바꿀 수 있어야 합니다. 각 파트별 역할은 다음과 같습니다. + +- **[DB 관점] 정렬의 기준점 (Numeric Index):** + - `ActivityType` (메뉴 탭)와 `ActivityItem` (게시물) 모두에 숫자형 필드인 `order` 를 필수값으로 추가합니다. +- **[DB 관점] 토글(Toggle)을 통한 안전한 노출 제어:** + - `ActivityType.isActive`: 전체 탭(예: 이번 학기에 운영 전면 휴식인 '해커톤' 탭) 자체를 삭제하지 않고 메인 웹에서 임시로 숨기거나 켤 수 있습니다. + - `ActivityItem.isVisible`: 개별 게시물(예: 아직 내용이 미완성된 스터디 카드)의 메인 노출 여부를 '임시저장'처럼 껐다 켤 수 있게 제어합니다. + - 메인 웹 API는 이 값들이 `true`인 데이터만 필터링하여 내려주므로, 메인 웹에는 철저하게 검증된 콘텐츠만 안전하게 노출됩니다. + +- **[프론트엔드 관점] 직관적인 Drag & Drop 정렬 (백오피스 전용):** + - 가장 강조하고 싶은 스터디를 맨 위로 올리기 위해 운영진이 숫자를 수동으로 입력하는 불편함과 휴먼 에러를 방지합니다. 프론트엔드 라이브러리(`@hello-pangea/dnd` 등)를 활용해 마우스로 끌어다 놓는 직관적인 리스트 정렬 UI를 제공해야 합니다. + - 마우스 드롭 이벤트 발생 시, 프론트엔드에서 변경된 배열 인덱스를 바탕으로 새로운 `order` 항목 값을 일관되게 재설정한 뒤 백오피스의 일괄 업데이트 API(`PATCH .../order`)를 즉각 호출합니다. + +- **[백엔드 관점] 쿼리 정렬 최적화 (DX):** + - 백오피스에서 전송된 순서 변경 요청을 받아 DB에 일괄 적용(Bulk Write)합니다. + - 가장 중요한 점은, 메인 웹이 데이터를 요청(`GET`)할 때 **백엔드가 DB에서 데이터를 꺼내 오는 단계(`find()`)에서부터 무조건 `.sort({ order: 1 })` 처리를 거쳐 오름차순으로 정렬된 깔끔한 배열을 프론트엔드에 내려주어야 합니다.** + - 이렇게 하면 메인 프론트엔드는 서버가 던져준 배열을 믿고 아무 고민 없이 `map()` 만 돌리면 되므로 프론트엔드의 비즈니스 로직(정렬 연산) 부담이 완전히 사라집니다. + +--- + +## 6. API 설계 (Endpoint 명세) + +### 6.1 백오피스 API (CMS 관리용) + +| Method | Path | 설명 | +|--------|------|------| +| `POST` | `/bo/activity-types` | 신규 활동 Type 및 Field 양식 생성 | +| `GET` | `/bo/activity-types` | 전체 활동 Type 목록 및 스키마 조회 | +| `PATCH` | `/bo/activity-types/:id` | 활동 Type 스키마(이름, Field 구성 등) 수정 | +| `PATCH` | `/bo/activity-types/order` | 드래그 앤 드롭을 통한 활동 Type 순서(order) 일괄 변경 | +| `POST` | `/bo/activities` | 특정 양식(typeKey)에 맞춘 신규 Item(게시물) 작성 | +| `GET` | `/bo/activities` | (백오피스용) Type별 Item(게시물) 전체 목록 조회 (비공개 콘텐츠 포함) | +| `PATCH` | `/bo/activities/:id` | Item(게시물) 내용 수정 및 공개/비공개 토글 | +| `PATCH` | `/bo/activities/order` | Type 내 Item(게시물) 노출 순서(order) 일괄 변경 | +| `DELETE` | `/bo/activities/:id` | Item(게시물) 삭제 | + +### 6.2 메인 웹 API (사용자 조회용) + +| Method | Path | 설명 | +|--------|------|------| +| `GET` | `/activities/types` | 활성화된(isActive: true) 탭 목록과 각 탭의 필드 설계도 조회 | +| `GET` | `/activities` | `?typeKey=study` 형태로 특정 탭의 공개된(isVisible: true) 콘텐츠 목록 조회 (order 순 자동 정렬) | + +> **인증 정책:** 메인 웹 API는 누구나 접근 가능한 Public API이며, 백오피스(`/bo/*`) API는 관리자 권한 확인 미들웨어 (Google OAuth 등)를 반드시 거쳐야 합니다. + +> **💡 API 네이밍 Note:** +> API 엔드포인트는 프론트엔드가 데이터를 다루는 관점에서의 직관성을 위해 통합 명칭인 `/activities`를 외부 모듈명으로 사용하지만, 백엔드 코어 모델에서는 이를 각각 `ActivityType`(양식) 및 `ActivityItem`(게시물) DB 모델로 정확히 매핑하여 처리합니다. + +--- + +## 7. 데이터 마이그레이션(Migration) 전략 및 타임라인 + +새로운 DB 시스템 설계가 완료되면, 기존에 하드코딩 되어있던 과거 데이터(`studyData.ts`, `seminaData.ts` 등)를 DB로 안전하게 마이그레이션해야 합니다. + +### 7.1 마이그레이션 방안 +과거 데이터들은 운영진이 수기로 직접 재입력하는 대신, **1회성 마이그레이션 스크립트**를 작성하여 자동화합니다. +1. 기존 `.ts` 데이터를 파싱해서 JSON 객체 배열로 추출. +2. 새롭게 구상한 `ActivityType` 스키마(예: shortText 타입 `topic`, dateRange 타입 `date`, imageList 타입 `images`)를 백오피스를 통해 선행 생성. +3. Node.js (또는 단순 API 호출) 1회성 스크립트를 통해 JSON 데이터를 순회하며 `ActivityItem` DB 데이터로 일괄 생성(Bulk Insert). + +### 7.2 전환 타임라인 계획 +- **Phase 1:** MongoDB 뼈대 모델 설계 및 백오피스 API 구현 완료 +- **Phase 2:** 백오피스 UI (필드 추가/수정 양식 폼 디자인, 콘텐츠 작성 페이지) 구현 +- **Phase 3:** 기존 하드코딩 데이터(`*Data.ts`) 파싱 및 MongoDB 마이그레이션 스크립트 구축/테스트 +- **Phase 4:** 메인 플랫폼의 동적 렌더링(Schema-driven UI) 교체 대응 및 기존 구형 파일(`*Data.ts` 등) 코드베이스에서 완전 제거 + +--- + +## 8. 엣지 케이스 및 유효성 검증(Validation) 정책 + +기본적인 데이터 제어는 백오피스단에서 이루어지지만, 다음과 같은 엣지 케이스들을 방어해야 합니다. + +### 8.1 필수값 누락 (Required Validation) +- `ActivityField` 설계도 상 `required: true`인 필수 필드를 빈 칸으로 두고 콘텐츠 저장을 시도할 경우, 백엔드에서 1차적으로 Validation Error(400 Bad Request)를 반환해야 합니다. +- (메인 웹 Fallback): DB에 이미 들어간 과거 데이터인데 현재 필수값 규칙(예: `topic` 이 나중에 필수로 변경됨)을 어긴 데이터라면, 해당 부분 렌더링을 완전히 생략(`if (!fieldValue) return null;`)하여 흰 화면이 뜨는 치명적 에러를 차단합니다. + +### 8.2 이미지/파일 업로드 제한 +- Presigned URL을 요청할 때 업로드할 파일의 MIME Type(예: `image/jpeg`, `image/png`)과 용량(예: 장당 최대 5MB)을 백엔드에서 미리 검증하여, 조건에 맞지 않을 시 URL 티켓 발급 자체를 거부합니다. + +### 8.3 드래그 앤 드롭 동기화 실패 (Rollback) +- 순서(order) 변경 후 `PATCH` API 호출이 네트워크 에러 등으로 실패했을 경우, 프론트엔드는 즉각적으로 적용해두었던 낙관적 업데이트(Optimistic Update) 배열을 이전 상태로 롤백(Rollback)한 뒤 유저에게 토스트 메세지로 실패 원인을 고지해야 합니다. + +--- + +## 9. 개발 단계별 Action Plan (협업 포인트) + +1. **[공통 기반 마련]** (⭐ 3, 4번 기능 담당자와 협업 필수) + - CMS 기초 뼈대(Dynamic Schema)를 작성합니다. 백엔드의 Mongoose 모델(Schema)과 데이터 저장 구조를 3팀이 모여 확정 짓습니다. + - 백오피스에서 사용할 공통 '텍스트 입력창', '날짜 선택창', '이미지 업로드창(Presigned URL 적용)' 리액트 컴포넌트를 설계하여 UI 라이브러리처럼 구축합니다. +2. **[Backoffice 뷰 개발]** (재민 담당) + - Activity 전용 Type 설계 페이지 및 Content 리스트/작성/DnD 정렬 페이지 완성. +3. **[Main 뷰 리팩토링 및 API 연동]** (재민 담당) + - 기존 `src/lib/activity/*Data.ts` 폴더 완전 제거. + - 메인 백엔드(Public API) 호출 후 동적으로 Navbar 탭과 Activity 카드가 UI 충돌 없이 렌더링되는 로직(`Schema-driven UI`) 구현. diff --git a/feature/03-club-info-cms.md b/feature/03-club-info-cms.md new file mode 100644 index 0000000..35ec55a --- /dev/null +++ b/feature/03-club-info-cms.md @@ -0,0 +1,126 @@ +# QUIPU Renewal Project: 동아리 공통 정보 관리의 백오피스 일원화 기능 명세서 + +## 1. 개요 +QUIPU Main Web 소스코드 내에 하드코딩되어 있던 동아리의 핵심 정보(임원진, 외부 링크, FAQ 등)를 Admin Web(백오피스)에서 통합 관리하고, Main Web에서 이를 동적으로 불러와 렌더링하는 시스템을 구축합니다. 이를 통해 매년 혹은 수시로 변경되는 동아리 정보를 개발자의 코드 수정과 재배포 없이, 운영진이 직접 유연하게 관리할 수 있도록 전환하는 것이 핵심입니다. + +## 2. 목표 +- **최적의 UX (사용성):** 코딩 지식이 없는 운영진도 직관적으로 데이터를 입력, 수정, 배치할 수 있는 어드민 UI 제공. +- **최적의 DX (확장성):** 프론트엔드와 백엔드 간의 명확한 타입 공유(TypeScript), 기수별 확장이 용이한 DB 스키마(MongoDB), 직관적이고 재사용 가능한 API 설계. + +--- + +## 3. 핵심 기능 명세 + +### 3.1. 백오피스 (Admin Web) 데이터 관리 기능 + +#### 3.1.1. 임원진 (Executive) 정보 관리 +- **필드 구성:** + - `cohort` (Number): 기수 (예: 40기 -> 40) + - `name` (String): 이름 + - `position` (String): 직책 (예: 회장, 부회장, 학술부장 등) + - `department` (String): 소속 학과 + - `studentId` (Number): 학번 (예: 20학번 -> 20) + - `phone` (String): 연락처 (관리자 참고용, 노출 여부 선택 가능) + - `interview` (Text): 인터뷰 내용 (Main Web의 Interview 섹션 노출용) + - `profileImage` (URL): 프로필 이미지 주소 (URL 기반 자동 업로드 기능 포함) + - `order` (Number): 정렬 순서 +- **UX/기능 요구사항:** + - 드래그 앤 드롭(Drag & Drop)을 활용한 임원진 표시 순서(`order`) 직관적 변경. + - 텍스트가 긴 인터뷰 항목의 경우 Markdown 에디터 또는 텍스트 영역(Textarea)을 통한 편리한 작성 지원. + - **이미지 업로드 편의성:** 외부 URL을 입력하면 서버가 이를 직접 fetch하여 자체 스토리지로 마이그레이션하는 'URL 업로드' 방식 지원. + +#### 3.1.2. 대외 링크 (External Links) 관리 +- **필드 구성:** + - `type` (Enum): 링크 종류 (Homepage, Instagram, GitHub, Email 등) + - `url` (String): 연결 URL 또는 이메일 주소 + - `label` (String): 사용자에게 보여질 표시 텍스트 + - `order` (Number): 정렬 순서 +- **UX/기능 요구사항:** + - 단일 페이지 내에서 인라인(Inline) 형태의 빠른 CRUD 처리. + - 올바른 URL 및 이메일 형식에 대한 실시간 유효성 검사. + +#### 3.1.3. FAQ (자주 묻는 질문) 관리 +- **필드 구성:** + - `category` (String): 질문 카테고리 (예: 지원 관련, 활동 관련, 기술 관련 등) + - `question` (String): 질문 내용 + - `answer` (String): 답변 내용 + - `order` (Number): 정렬 순서 +- **UX/기능 요구사항:** + - 카테고리 분류 추가/수정/삭제 기능. + - 아코디언 형태의 메인 웹 렌더링을 고려한 질문-답변 세트 관리. + +#### 3.1.4. 이미지 업로드 기능 (URL 기반) + 1. 프론트엔드 UI 구성 (Admin Web) + * 입력창 & 버튼: 관리자가 외부 이미지 주소(URL)를 입력할 수 있는 텍스트 창과 '스토리지로 가져오기' 버튼을 만듭니다. + * API 호출: 버튼 클릭 시, 입력된 외부 URL을 백엔드로 전송합니다. + * 결과 반영: 백엔드에서 작업이 끝나고 '내부 스토리지 URL'을 돌려주면, 이를 폼 데이터의 profileImage 값으로 자동 + 입력하고 화면에 미리보기를 띄워줍니다. + + 2. 백엔드 API 구현 (Express) + * 엔드포인트 생성: POST /api/v1/club-info/upload-by-url API를 만듭니다. + * 다운로드 및 업로드: 프론트엔드에서 받은 외부 URL로 서버가 직접 접근해 이미지를 다운로드한 뒤, 자체 스토리지(AWS S3, + Cloudinary 등)에 다시 업로드합니다. + * URL 반환: 스토리지 업로드가 완료되면 생성된 영구적인 '내부 스토리지 URL'을 프론트엔드로 응답(Response)합니다. + + 3. 데이터 저장 (MongoDB) + * 최종 전송: 관리자가 모든 임원진 정보 입력을 마치고 '저장' 버튼을 누릅니다. + * DB 반영: 1단계에서 폼 데이터에 세팅해둔 '내부 스토리지 URL'이 다른 임원진 정보(name, position 등)와 함께 기존 + 임원진 등록/수정 API로 전송되어 안전하게 DB에 저장됩니다. + +### 3.2. 메인 웹 (Main Web) 반영 (렌더링) 영역 +- **Interview 영역:** 당해 연도 임원진 정보를 `order` 순으로 조회하여 인터뷰 카드와 프로필 렌더링. +- **Footer 영역:** 활성화된 대외 링크 정보를 조회하여 아이콘 및 텍스트 링크로 렌더링. +- **FAQ 영역:** 카테고리별로 그룹화된 활성 FAQ 데이터를 `order` 순으로 렌더링 (아코디언 UI 적용). + +--- + +## 4. 데이터베이스 및 API 설계 (DX 향상 전략) + +### 4.1. 유연하고 확장 가능한 MongoDB 스키마 설계 +기존 관계형 DB(MySQL)의 고정된 스키마에서 벗어나, MongoDB의 유연성을 활용합니다. + +```typescript +// 예시: Mongoose Schema - 임원진 정보 +const ExecutiveSchema = new mongoose.Schema({ + cohort: { type: Number, required: true }, + name: { type: String, required: true }, + position: { type: String, required: true }, + department: { type: String }, + studentId: { type: Number }, + interview: { type: String }, + profileImage: { type: String }, + order: { type: Number, default: 0 } +}, { timestamps: true }); +``` + +### 4.2. RESTful API 엔드포인트 구성 +프론트엔드에서 직관적으로 사용할 수 있는 일관된 엔드포인트를 제공합니다. + +- **Executives (임원진)** + - `GET /api/v1/club-info/executives` (Query: `cohort`) + - `POST /api/v1/club-info/executives` + - `PUT /api/v1/club-info/executives/:id` + - `PATCH /api/v1/club-info/executives/reorder` (배열 형태의 id를 받아 일괄 순서 업데이트) + - `POST /api/v1/club-info/upload-by-url` (외부 URL을 받아 스토리지 업로드 후 내부 URL 반환) +- **External Links & FAQs** 도 위와 동일한 CRUD 규칙 및 라우트 구조 적용 (`/external-links`, `/faqs`). + +--- +## 5. 아키텍처 및 구현 전략 (UX & DX 통합) + +### 5.1. 타입 안정성 (Type Safety) 확보 +- 백엔드(Express)와 프론트엔드(React/Next.js) 간 데이터 교환 시 발생할 수 있는 오류를 차단하기 위해 **Zod** 또는 공통 TypeScript `interface`를 정의하여 재사용합니다. +- API 요청의 Payload 검증과 UI 렌더링 시 타입 추론을 완벽하게 맞춥니다. + +### 5.2. 성능 및 렌더링 최적화 +- **Main Web (Next.js):** 동아리 정보는 변경 빈도가 아주 높지는 않으므로, **ISR (Incremental Static Regeneration)** 을 활용하여 정적 페이지의 빠른 로딩 속도와 SEO 최적화를 챙기면서도 DB 변경 사항이 일정 주기마다 반영되도록 구현합니다. +- **에러 핸들링 및 Fallback:** API 장애 시 메인 웹의 UI가 깨지지 않도록 에러 바운더리(Error Boundary)와 기본 스켈레톤(Skeleton) UI를 적용합니다. + +### 5.3. 관리자 사용성 (Admin UX) 고도화 +- **실수 방지 및 피드백:** 데이터 삭제 및 상태 변경 시 반드시 확인 모달(Confirm Modal)을 띄우고, 작업 성공/실패 시 즉각적인 Toast 알림을 제공합니다. +- **폼 상태 관리:** `react-hook-form`을 사용하여 복잡한 입력 폼의 상태를 성능 저하 없이 관리하고, 제출 전 강력한 클라이언트 사이드 유효성 검사를 수행합니다. + +--- + +## 6. 기대 효과 +- **개발 생산성 증가:** 해마다 반복되던 텍스트 수정 및 이미지 교체 목적의 PR 생성, 코드 리뷰, 배포 과정이 사라집니다. +- **운영 민첩성 향상:** 비개발 직군 운영진도 손쉽게 동아리 정보를 최신화할 수 있어 서비스 운영의 자율성이 증대됩니다. diff --git a/feature/04-recruit-form-cms.md b/feature/04-recruit-form-cms.md new file mode 100644 index 0000000..0fa9a38 --- /dev/null +++ b/feature/04-recruit-form-cms.md @@ -0,0 +1,1368 @@ +# 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: 200 OK (formVersion 3 기준으로 제출 수락) +``` + +- 제출 시 클라이언트는 `formId`와 함께 조회한 폼의 `version`을 전송한다. +- 서버는 제출된 `formVersion`이 현재 폼의 `version`과 다르더라도 **제출을 수락**한다. 작성 중이던 응답 유실을 방지하기 위함이다. +- 응답에는 제출 시점의 `formVersion`을 함께 저장하여, 어떤 버전의 폼 기준으로 작성되었는지 추적한다. +- 운영자는 **모집 기간 중 불필요한 수정을 자제해야 한다.** + +#### 고정 필드 (Fixed Fields) 정책 + +폼 버전이 변경되더라도 항상 유지되는 **고정 필드**를 정의한다: + +| 고정 필드 | 용도 | +|-----------|------| +| 이름 | 식별, 목록 표시 | +| 학번 | 중복 체크, 식별 | +| 학과 | 목록 표시 | +| 이메일 | 연락, 단체 메일 | + +- 고정 필드는 published 상태에서 삭제하거나 타입을 변경할 수 없다. +- 백오피스 응답 목록(테이블)에서는 고정 필드 중심으로 렌더링한다. +- 각 row 클릭 시 모달에서 해당 응답의 `formVersion`에 맞는 폼 정의를 기준으로 상세 응답을 렌더링한다. + +#### 버전별 데이터 처리 + +- **백오피스 테이블**: 고정 필드(이름, 학번, 학과, 이메일)를 컬럼으로 표시. 버전에 무관하게 동일한 형태. +- **모달 상세**: 응답의 `formVersion`에 해당하는 폼 정의를 참조하여 필드별 라벨-값 쌍을 렌더링. +- **CSV/Excel export**: 버전별로 시트를 분리하여 출력. 각 시트는 해당 버전의 폼 필드를 컬럼으로 사용. + +#### published 상태 수정 제한 강화 + +published 상태에서는 **폼 구성(필드 추가/삭제/타입 변경)을 불허**한다. 옵션 라벨, 필드 라벨, 검증 규칙 등 비구조적 속성만 변경 가능하며, 변경 시 `version`이 +1 된다. + +### 6.5 options 변경 정책 + +선택형 필드(`single_choice`, `multiple_choice`, `dropdown`)의 `options`는 응답 존재 여부와 무관하게 자유롭게 변경할 수 있다. + +- `options[].label`, `options[].value` 모두 수정/추가/삭제 가능. +- 과거 응답은 제출 시점의 `value`를 그대로 보존한다. 옵션이 변경되더라도 기존 응답 데이터는 영향받지 않는다. +- 백오피스 응답 조회 시, 현재 폼 정의에 없는 `value`가 과거 응답에 있을 수 있다. 이 경우 `value`를 그대로 표시한다. + +### 6.6 백엔드 검증 원칙 + +> **프론트엔드를 신뢰하지 않는다.** 백엔드는 폼 정의를 기준으로 모든 검증을 독립적으로 재수행한다. + +백엔드 제출 처리 시 다음을 순서대로 수행한다: + +1. **폼 상태 체크** — `status === "published"`인지 확인 +2. **폼 버전 기록** — 클라이언트가 보낸 `formVersion`을 응답에 저장 (구버전 제출도 허용) +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` | 응답 상세 | +| `POST` | `/bo/recruit-form/:id/responses` | 응답 수동 추가 (백오피스에서만 가능) | +| `PUT` | `/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. 폼 버전 기록 (구버전 제출도 허용 — 작성 중 응답 유실 방지) + // formVersion은 응답에 저장하여 버전별 추적에 사용 + + // 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 업로드 흐름 (Presigned URL 방식) + +> **방침:** `02-activity-cms`에서 제안된 Presigned URL 방식과 통일한다. 초기 구현에서는 파일 URL을 직접 입력받는 간단한 방식으로 시작하고, `02-activity-cms`의 파일 업로드 기능이 완성되면 해당 컴포넌트를 재사용한다. + +```mermaid +flowchart LR + A["프론트엔드에서
Presigned URL 요청"] --> B["백엔드에서
fileConfig 검증
(accept, maxSizeMB, maxFiles)"] + B --> C["Presigned URL 발급"] + C --> D["프론트엔드에서
R2에 직접 업로드"] + D --> E["업로드 완료 후
파일 URL을 폼 제출에 포함"] + E --> F["RecruitResponse.answers에
files[] 저장"] + F --> G["백오피스 조회 시
시간 제한 서명 URL 발급"] +``` + +#### 초기 구현 (Phase 1) +- `file_upload` 타입 필드에서 파일 URL을 텍스트로 입력받는 방식으로 시작. +- 프론트엔드 파일 선택 UI 없이, 외부 업로드 후 URL 붙여넣기. + +#### 완성 구현 (Phase 2 — `02-activity-cms` 완성 후) +- `02-activity-cms`에서 구현된 Presigned URL 업로드 컴포넌트를 재사용. +- 프론트엔드에서 Cloudflare R2에 직접 업로드하고, 발급된 URL을 답변에 저장. + +### 12.2 기존 대비 변경점 + +| 항목 | 기존 | 변경 | +|------|------|------| +| 업로드 방식 | `multipart/form-data` → 백엔드 중계 | Presigned URL → 프론트엔드에서 R2 직접 업로드 | +| 파일 필드 수 | `upload.single("portfolio_pdf")` — 1개 고정 | 폼 정의의 `file_upload` 필드 수만큼 동적 | +| 파일 수 | 필드당 1개 | `maxFiles`에 따라 필드당 N개 | +| 파일명 패턴 | `퀴푸_25_1-${student_id}${name}` 하드코딩 | `recruit/{formId}/{responseId}/{fieldId}/{originalFilename}` | +| 다운로드 | filename 기반 고정 API | responseId + fieldId 기반 → 시간 제한 서명 URL (만료 1시간) | +| accept 검증 | 없음 | **프론트엔드**: `` 속성 + 클라이언트 MIME 체크. **백엔드**: Presigned URL 발급 시 `fileConfig.accept`와 매칭 | +| 응답 저장 | 단일 `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**으로 로드한다. + +- 백오피스에서 `