Files
comrelay/docs/integration-progress.md
T
ttipo fa4bc40761 docs: 8단계 진행현황·API 계약 갱신 + 알림/Redis 검토 문서 추가
- integration-progress/api-contract: 8-1 페이지네이션·8-2 알림·8-3 Redis 캐싱 반영.
- notification-review.md: 알림 시스템 전수 검토(유형/빠짐/주의) + 보완 이력.
- redis-cache-review.md: Redis 캐시 전수 검토 + 보완 이력.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:13:07 +09:00

45 KiB

Relay 백엔드 연동 진행 현황

프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. 계약/스키마 상세는 api-contract.md 참조. 최종 갱신: 2026-06-19 · 현재 위치: 0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 + 실시간 알림 + Redis 캐싱 완료. 8단계 진행: ① 페이지네이션 전 도메인 완료(더보기/전체 로드). ② 실시간 알림(인앱 + SSE) 완료. ③ Redis 캐싱 완료 — CacheModule을 Redis(keyv)로 전환(미설정 시 인메모리 폴백), 저장소 목록(GET /repos)을 버전 네임스페이스 캐시. 그 외 8단계 항목(소셜 로그인·이메일 인증·AI 패널)은 미착수.


1. 작업 전략

  • 도메인별 수직 슬라이스: 한 도메인을 엔티티 → DTO → service → controller → Swagger → 프론트 composable → store → 페이지 목업 교체까지 끝내고 다음 도메인으로 이동. (수평 분할 아님)
  • 프론트는 백엔드 원시 데이터(타임스탬프/카운트)를 받아 표시용 파생값(상대시간·진행률·아바타 색상 등)은 shared/utils/format.ts에서 계산.
  • 모든 응답은 표준 래퍼 { success, data } / { success, error }. 에러 코드 맵은 CLAUDE.md SSOT.
  • 인증은 이메일+JWT만 먼저(소셜 로그인·이메일 인증은 8단계로 보류). access/refresh 모두 HttpOnly·Secure 쿠키.

단계 순서

0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트)
→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 ✅(백엔드+프론트) → 6. 활동 피드 ✅(백엔드+프론트) → 7. 내 업무 → 8. 마무리

2. 완료된 단계

0단계 — 계약 정렬

  • docs/api-contract.md: 7개 도메인 전체 엔드포인트(요청/응답/권한), ID 전략, 파생 필드 규칙, 상태 머신, 비즈니스 에러 메커니즘을 단일 진실 공급원으로 정의.
  • 비즈니스 에러 메커니즘: backend/src/common/exceptions/business.exception.tsBusinessException(code, message, status).
    • 전역 필터(http-exception.filter.ts)가 { code, message } 를 status 매핑보다 우선 처리 → 정확한 HTTP 상태를 유지하면서 사용자에게 구체 문구 노출.
    • 프론트 useApi 인터셉터는 BIZ_001 코드일 때 서버 message 를 우선 표시.
    • 기존 status→코드 매핑 구조는 그대로 유지(덧댄 우선 분기). BusinessException 을 쓰지 않으면 기존과 동일 동작.

1단계 — 인증 (이메일+JWT)

백엔드 backend/src/modules/auth/, modules/user/

  • User 엔티티(UUID, email unique, password_hash select:false, name, role nullable).
  • UserService(Repository 기반): findById/findByEmail/findByEmailWithPassword/create/toPublic.
  • AuthService: 가입(중복 검사), 로그인 검증(bcryptjs), access/refresh 토큰 발급.
  • AuthController: POST /auth/signup·/login·/logout·/refresh(refresh 가드), GET /auth/me(access 가드). HttpOnly 쿠키 발급/제거.
  • JWT access/refresh 전략(쿠키에서 추출) + 가드 + @CurrentUser() 데코레이터.
  • main.tscookie-parser. JWT 시크릿은 backend/.env.{env}(커밋된 .example 포함).

프론트 frontend/src/

  • types/auth.ts, composables/useAuth.ts, stores/auth.store.ts(bootstrap/login/signup/logout).
  • composables/useApi.ts: 401→/auth/refresh 자동 1회 재시도, silent 플래그(부트스트랩 무알림), BIZ_001 서버 메시지 우선.
  • 라우터 가드: 최초 진입 시 /auth/me 세션 복원 + 미인증 리다이렉트(redirect 쿼리 보존).
  • LoginPage/SignupPage 실제 연동(소셜 버튼은 안내, 가입 즉시 자동 로그인), AppShell 아바타=실사용자 + 로그아웃 드롭다운.

로그인 실패 문구: BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401) / 중복 가입: 동일 패턴 409.

2단계 — 저장소(Repo) CRUD

백엔드 backend/src/modules/repo/

  • Repo 엔티티: slugName(영문, 라우팅 id, unique), owner('marketing-team' 고정 상수), name(한글 표시제목), description, visibility, branch.
  • RepoMember 엔티티: repo↔user 조인, roleType(admin/member), isOwner, @Unique([repo, user]).
  • RepoService: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner).
  • GET/POST/PATCH/DELETE /repos[/:repoId] — 전부 JwtAuthGuard. 응답 RepoResponse(원시값; doneCount/totalCount 는 당시 0 하드코딩 → 4단계에서 업무 status 집계로 교체됨).

프론트

  • shared/utils/format.ts: avatarColor(id 해시)·initialOf·relativeTimeKo·progressOf 추가.
  • types/repo.ts, composables/useRepo.ts, stores/repo.store.ts(API→기존 mock Repo/User 뷰로 매핑: 아바타 3명+moreCount, 진행률, 상대시간).
  • 페이지 연동: RepoListPage(목록·로딩/빈상태), RepoCreatePage(생성→상세), RepoDetailPage(헤더·진행률만), RepoSettingsPage(저장·삭제·이름변경 안내).

2.5단계 — Gitea 조직 연동

백엔드 backend/src/modules/gitea/

  • GiteaService: 외부 Gitea(/api/v1) 얇은 클라이언트(Node 내장 fetch, 새 의존성 없음). createRepo/updateRepo/deleteRepo + enabled 게이트. GiteaApiError(상태코드 보존).
  • 환경변수 GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 셋 다 있어야 활성화. 비어 있으면 저장소 CRUD 가 Gitea 호출 없이 기존대로 동작(로컬/미연동 대비).
  • Repo 엔티티에 연동 컬럼 추가: giteaRepoId, giteaFullName, cloneUrl, htmlUrl(전부 nullable). RepoResponsecloneUrl/htmlUrl 노출.
  • RepoService 와이어링:
    • 생성: Gitea 조직에 repo 생성 → 성공 시 Relay 저장(연동정보 보관). 소유자=GITEA_ORG. 이름 충돌(409)→BIZ_001. Relay 저장 실패 시 Gitea repo 롤백 삭제. (빈 저장소=auto_init, 템플릿=generate — 아래 생성폼 보강 참조)
    • 수정: 표시제목/설명→Gitea description, 공개범위→private 동기화(slug 불변). 베스트 에포트.
    • 삭제: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그).
  • 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 backend/.env.{env} 의 백엔드 고유 시크릿. 커밋용 backend/.env.{env}.example 에 키 문서화.

프론트 : 공용 컴포넌트 components/GiteaRepoBar.vue(클론 주소 표시·복사 + 'Gitea에서 열기', 미연동 시 렌더 안 함)를 RepoSettingsPage(일반 카드)·RepoDetailPage(헤더 아래)에 적용. Repo 뷰 타입/repo.store 매핑에 cloneUrl/htmlUrl/giteaMissing 추가.

2.6단계 — Gitea → Relay 역방향 동기화 (백엔드)

백엔드 backend/src/modules/gitea/, modules/repo/repo-sync.service.ts

  • 문제: 동기화가 단방향(Relay→Gitea)이라 Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음.
  • GiteaService.listOrgRepos(): 조직 저장소 페이지네이션 순회(GET /orgs/{org}/repos). 결과에 name/description/private 추가.
  • RepoSyncService: 서버 기동 직후 1회(OnApplicationBootstrap, 비차단) + 매시간(@Cron(EVERY_HOUR)) 동기화. ScheduleModule.forRoot()app.module 에 이미 등록됨.
    • 추가형: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT.
    • 누락 표기: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 giteaMissing:true 로만 표기(재출현 시 복구). Repo.giteaMissing 컬럼 추가, RepoResponse/프론트 ApiRepo 에 노출.
    • 미연동 시 건너뜀 + 중복 실행 가드.
    • import 저장소 멤버 데드락 해소(2026-06-17): import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 REPO_IMPORT_ADMIN_EMAIL(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: backend/.env.{env}.example.
  • 목록 모델 변경: GET /repos 가 '내 멤버 저장소' → '조직 저장소 전체'(findAll). 단건(findOne)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용.

프론트 : RepoListPage 에 giteaMissing '원격 없음' 배지(저장소 행 제목 옆) 표시. 헤더 'Gitea 연동됨' 배지는 실제 연동 여부(htmlUrl 보유)에 따라 노출되도록 보정. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.

3단계 — 멤버 관리 (백엔드 + 프론트)

백엔드 backend/src/modules/repo/

  • MemberService + MemberController(repo 모듈에 흡수, RepoMember 엔티티 재사용). 엔드포인트: GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId].
    • 목록: 조직 모델상 인증 사용자면 누구나 열람(생성순 정렬, RepoService.findOne 과 동일 정책). 응답 RepoMemberResponse { user, email, taskCount, roleType, owner }. (※ 2.6단계 조직 모델 전환 후에도 list 가 옛 '멤버 or 공개' 체크를 유지해 import/타인 생성 저장소 멤버 탭이 403 나던 버그 수정 — 2026-06-17)
    • 초대: admin 권한. 이미 가입된 사용자만 이메일로 초대(별도 초대 메일/계정 생성 없음). 미가입 이메일→BIZ_001(404), 이미 멤버→BIZ_001(409).
    • 역할 변경·제거: admin 권한. 소유자(owner)는 역할 변경·제거 불가(BIZ_001 403).
  • InviteMemberDto(email/roleType), UpdateMemberDto(roleType?). 단위 테스트 member.service.spec.ts(소유자 보호·중복·권한 가드).
  • taskCount 는 4단계 업무 모듈 도입 전까지 0.
  • Gitea 미동기화: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보).

사용자 검색 API backend/src/modules/user/ (멤버 초대 자동완성용)

  • GET /users?q=<검색어>(JwtAuthGuard) — q 있으면 이메일/이름 부분일치(ILike, 대소문자 무시), 없으면 전체(이름순, 최대 50). 응답 PublicUser[].
  • UserController 신규 + UserService.search() 추가, UserModule 에 컨트롤러 등록. (1단계에서 삭제했던 샘플 user.controller 와 무관한 신규 검색 전용)

프론트 frontend/src/

  • types/member.ts(ApiRepoMember/InviteMemberPayload/UpdateMemberPayload), composables/useMember.ts(list/invite/update/remove), stores/member.store.ts(API→화면용 RepoMember 매핑: 이니셜·색상 파생, isYou=현재 사용자 비교).
  • RepoMembersPage 목업 제거 → 실제 연동: 멤버 목록(로딩/빈 상태), 헤더·탭 카운트 실데이터, 초대 모달(이메일+역할, 가입 사용자만, 사용자 자동완성 — 포커스 시 전체·입력 시 이메일/이름 부분일치, 기존 멤버 제외), 역할 변경 드롭다운(멤버↔관리자), 멤버 제거(confirm). 초대/역할변경/제거 컨트롤은 현재 사용자가 admin 일 때만 노출(isAdmin), 소유자(owner) 행은 변경·제거 불가.
  • 미가입/중복 초대 등은 인터셉터가 서버 BIZ_001 문구를 그대로 노출. taskCount 는 4단계까지 0.

4단계 — 업무(Task) CRUD + 상태 전이 (백엔드 + 프론트)

백엔드 backend/src/modules/task/

  • Task 엔티티: 저장소 종속(@ManyToOne Repo, Relation<>), seq(저장소 내 순번 #N, @Unique([repo, seq])), title, content(jsonb 문단 배열), status, dueDate, issuer(User, SET NULL), assignees(@ManyToManytask_assignees), checklist(@OneToMany ChecklistItem, cascade). ChecklistItem: text/done/sortOrder.
  • TaskService: 목록(상태/담당자 필터, seq 내림차순) / 상세 / 생성 / 수정 / 삭제 / changeStatus.
    • 담당자 = 저장소 멤버만: resolveMemberAssigneesassigneeIds 를 멤버십과 대조, 비멤버 포함 시 BIZ_001(400).
    • 순번 채번: 저장소 내 max(seq)+1.
    • 상태 머신: todo→prog→review→done, review→changes, changes→{review,prog}. 허용 외 전이는 BIZ_001(409). 역할: 승인/수정요청(review 출발)은 지시자/admin, 그 외 작업 전이는 담당자/지시자/admin. 비멤버는 전이 불가(403).
    • 승인 완료(→done) 부수효과: 해당 업무의 체크리스트 항목 done 을 모두 true 로 영속 처리(승인=완료. done 은 출구 전이 없어 안전). 디자인 시안의 '승인 완료=체크리스트 100%' 동작 일치.
  • TaskController: GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../:taskId/status(taskId=seq, ParseIntPipe). 생성/수정/삭제는 admin.
  • 저장소 진행률 실집계: RepoServiceTaskService.countsByRepodoneCount/totalCount 를 채운다(기존 0 하드코딩 제거). 멤버 taskCount: MemberServiceTaskService.assigneeCounts 로 사용자별 담당 수 집계(RepoModuleTaskModule import). 순환 모듈 없음(TaskModule 은 엔티티만 참조).
  • 단위 테스트 task.service.spec.ts(상태머신 위반·승인 권한·비멤버 차단·담당자 멤버 검증·승인 시 체크리스트 일괄 완료). 백엔드 18 tests pass.
  • comments/activities/files/approval5단계 영역 — 응답에 빈 배열로 내려간다.

프론트 frontend/src/

  • shared/utils/format.ts: taskStatusLabel·taskDueInfo(dueDate+완료여부→ dateLabel/dday/listLabel/overdue)·monthDayKo 추가.
  • types/task.ts, composables/useTask.ts(list/get/create/update/remove/changeStatus), stores/task.store.ts(API→mock RepoTask/TaskDetail 뷰 매핑: 마감/dday/상태 라벨 파생, 지시자 null 폴백).
  • 페이지 연동: RepoDetailPage(업무 목록 실데이터+로딩/빈상태, 툴바 ‘새 업무’ 버튼), TaskCreatePage(제목/내용 textarea/체크리스트/마감일/담당자=멤버 선택 드롭다운 → POST → 생성 상세로 이동), TaskDetailPage(상세 fetch, 삭제=DELETE, 승인=review→done·수정요청=review→changes), TaskAssigneePage(상세 fetch, 실제 status 로 카드 분기: 시작 todo→prog·승인요청 prog→review·재요청 changes→review, 삭제).
  • 업무 수정 UI: TaskCreatePage 를 생성/수정 겸용으로 확장. 라우트 /repos/:repoId/tasks/:taskId/edit(task-edit) 추가 → taskId 있으면 편집 모드(기존 값 미리 채움 + useTask().get 로 원시값 로드 + 제출 시 PATCH taskStore.update). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 …/edit 로 연결(기존엔 빈 생성 폼으로 오연결). (※ 수정 폼의 체크리스트 항목 추가/삭제는 2026-06-18 에 지원 — UpdateTaskDto.checklist 동기화. 제목/내용/담당자/마감과 함께 저장.)
  • 댓글/AI 에이전트 패널/첨부 업로드는 로컬 데모 유지(5단계 실연동). 담당자 페이지의 ?state= 쿼리 의존 제거 → task.status 기반 분기.
  • 역할 기반 액션 통합(2026-06-17): 목록은 항상 TaskDetailPage(…/tasks/:taskId)로 진입하고, 이 페이지가 현재 사용자 역할로 액션을 분기한다. isIssuer(task.issuer===me) / isAssignee(me ∈ assignees) 계산 → 지시자는 승인 대기(review)에서 승인/수정요청, 담당자는 업무 시작(todo→prog)·승인 요청(prog→review)·다시 승인 요청(changes→review)·승인 대기 중(review) 표시. 담당자가 "업무 시작"에 도달할 경로가 없던 문제 해소(기존 시작 버튼은 /view(TaskAssigneePage)에만 있었고 거기로 가는 링크가 mock MyTasksPage뿐이었음). TaskAssigneePage//view 는 mock MyTasks 전용으로 잔존(7단계 정리). 한계: 지시자가 아닌 admin은 화면상 승인 버튼 미노출(편집/삭제는 가능, 백엔드는 admin 승인 허용).
  • UI 정리(2026-06-17): 공용 RepoHeader 기본 ‘새 업무’ 버튼 제거(#actions 슬롯 제공 시에만 렌더) → 상세 헤더 중복 버튼 해소. 작성 폼 ‘임시저장’ 제거, 체크박스 토글 비활성(어느 화면도 사용자 체크 불가), 담당자 칩을 입력칸 아래로 분리, 지시자 직무 줄 제거. 저장소 목록의 진행률 라벨(‘시작 전/67% 완료’) 제거(업무 수·바는 유지).
  • 디자인 시안 정렬(2026-06-17): 담당자 액션 카드를 시안의 .ap-card 색상 체계로 정렬 — 승인요청(prog)=accent(request)·수정요청(changes)=빨강·승인완료(done)=초록·승인대기(review)=앰버. ‘업무 시작’도 request 톤(인디고)으로 통일. 사이드 상태 뱃지(.status-badge)에 todo/changes/done 변형 추가(기존엔 review 변형만 있어 나머지가 파란색으로 떨어지던 버그 수정). 카드 본문의 행위자·시각·승인노트는 5·6단계 데이터라 지시자 이름만 넣어 근사.

저장소 생성 폼 보강 (소유자/설명/템플릿 — 백엔드+프론트)

소유자 하드코딩 제거

  • 백엔드 GET /repos/meta{ owner, template:{available,slug} }(컨트롤러에서 :repoId 보다 먼저 선언). owner=GITEA_ORG(미연동 시 상수).
  • 프론트 useRepo.meta() + repo.store(owner/templateAvailable/templateSlug/fetchCreateMeta). RepoCreatePage 고정표시·RepoListPage 배너의 marketing-team 하드코딩 → 실제 조직값.

프로젝트 설명 필수화

  • CreateRepoDto.description 필수(@IsNotEmpty, ≤300자). 프론트 생성폼 라벨 *·검증 추가. 누락 시 VAL_001(400).

템플릿 복제 생성

  • GiteaService.generateFromTemplate() — Gitea generate API(POST /repos/{tplOwner}/{tplRepo}/generate, git_content:true)로 템플릿 저장소를 복제. 기본 브랜치는 템플릿을 따름.
  • 템플릿 출처 env GITEA_TEMPLATE_OWNER/GITEA_TEMPLATE_REPO(기본 TtiPo/project-base, Gitea 에서 template 설정 필요).
  • CreateRepoDto.useTemplate=true 면 빈 저장소 대신 복제 생성. 프론트 생성폼 템플릿 드롭다운=실제 옵션(없음 / TtiPo/project-base).
  • 라이브 검증: 생성된 repo 의 파일 트리가 project-base 와 동일하게 복제됨 확인.

5단계 — 업무 상세 부속 (백엔드 + 프론트)

백엔드 backend/src/modules/task/

  • 신규 엔티티: Comment(답글은 parent 자기참조로 1단계 깊이, attachment 선택 연결, author/parent Relation<>), Attachment(원본명·storedName·size·mime·kind, uploader SET NULL). Task 에 승인 워크플로우 필드 추가(approvalNote/approvalRequestedBy/approvalRequestedAt/changesNote).
  • 체크리스트: 상세 화면 읽기 전용(완료 토글은 사용자가 하지 않음 — 승인 완료 시 일괄 처리, 4단계 부수효과). 항목 추가/삭제(정의)는 업무 생성·수정 폼에서 한다: 생성=CreateTaskDto.checklist, 수정=UpdateTaskDto.checklist(id 기준 전체 동기화 — 기존 유지·완료 보존, 신규 추가, 빠진 항목 삭제). — 2026-06-18: 5단계 초기의 항목별 토글 API 제거 → 수정 PATCH 로 통합
  • 댓글/답글: POST .../comments(text/fileId?), POST .../comments/:commentId/replies — 멤버. 답글은 최상위 댓글에만.
  • 파일 첨부: POST .../files(multipart, FileInterceptor + dest), DELETE .../files/:fileId, GET .../files/:fileId/download(@Res 직접 스트리밍, Content-Disposition UTF-8) — 멤버. 비ASCII 파일명 latin1→UTF-8 복원, MIME/확장자로 kind 파생.
  • 승인 워크플로우: POST .../approval/request(prog/changes→review, note 선택), /approve(review→done), /changes(review→changes, note 필수). 4단계 상태머신 전이 로직을 transition() 으로 추출해 POST .../status 와 공유(역할 검증 동일). 승인 정보는 review 상태일 때만 approval{requester,requestedAt,note} 로 노출, changes 상태는 changesNote.
  • TaskDetailResponsecomments/files/approval/changesNote 실데이터 채움. 목록 commentCount 실집계. activities 는 6단계라 빈 배열 유지.
  • 파일 저장 방식(결정됨): 서버 로컬 업로드 폴더(upload.config.ts UPLOAD_DIR, 기본 cwd/uploads). @types/multer 의존 없이 UploadedDiskFile 인터페이스 자체 정의. 운영 Dockerfile 이 /app/uploadsnode 소유로 생성(비-root 쓰기 가능). dev 는 ./backend:/app 바인드라 backend/uploads(gitignore)로 영속, 운영 영속이 필요하면 해당 경로에 볼륨 마운트(추후). S3/MinIO 는 후순위.
  • 테스트 task.service.spec.ts 보강(승인 요청/수정요청 역할, 체크리스트 비멤버 차단). 백엔드 21 tests pass, build/lint 0 err.

프론트 frontend/src/

  • shared/utils/format.ts: formatBytes(바이트→"18.4 MB"). types/task.ts: ApiAttachment/ApiComment/ApiCommentReply/ApiApproval + 부속 페이로드. composables/useTask.ts: 체크리스트/댓글/답글/파일(FormData)/승인 메서드. stores/task.store.ts: 댓글·첨부·승인 뷰 매핑(작성자 시간·역할배지·바이트 변환), 부속 변경 후 상세 재조회로 일관 갱신.
  • mock 뷰 타입(ChecklistItem/Attachment/Comment/Reply/TaskDetail)에 옵셔널 id/url/changesNote 추가(연동용, 기존 화면 호환).
  • TaskDetailPage: 체크리스트는 읽기 전용(표시만), 댓글/답글 실연동(로컬 데모 제거), 첨부 카드 항상 표시+업로드/다운로드 링크/삭제, 승인 요청 메모(prog/changes 카드 textarea)·수정 요청 메모(모달 textarea) 전송, 지시자 승인 카드에 요청 메모 노출·담당자 수정요청 카드에 보완 메모 노출. type-check/lint/build 0 err.

한계: 댓글에 파일 첨부(fileId)는 백엔드만 지원(상세 화면 UI 미노출). 업무 삭제 시 디스크 파일은 베스트에포트 정리(DB 행은 FK CASCADE). AI 에이전트 패널은 여전히 로컬 데모(8단계).

6단계 — 활동 피드 (백엔드 + 프론트)

백엔드 backend/src/modules/activity/

  • Activity 엔티티: repo(FK, CASCADE)·taskSeq(int nullable, 업무 활동 필터용 — Task FK 대신 seq 저장)·actor(User SET NULL)·type·payload(jsonb)·createdAt. 표시 문구/아이콘/톤은 저장하지 않고 프론트가 type+payload 로 조립(구조화 채택).
  • ActivityService: record()(베스트 에포트 적재 — 실패해도 본 흐름 안 막음), listForRepo(slug)(최신순 200건), listForTask(repoId, seq). ActivityController: GET /repos/:repoId/activity(인증, 조직 모델상 누구나 열람).
  • 이벤트 자동 적재(다른 서비스가 ActivityService.record 호출): RepoService(생성/이름변경/공개범위), MemberService(초대/역할변경/제거), TaskService(생성·상태전이[transition 단일 지점]·댓글/답글·파일 첨부/삭제·업무 삭제·업무 수정[변경 측면별: 담당자/마감/일반]). 상태전이는 task.status_changed 단일 타입 + payload{statusFrom,statusTo} 로 적재하고 프론트가 from→to 로 문구 파생(시작/승인요청/승인/수정요청). 업무 수정(update)은 이전 상태와 비교해 실제 바뀐 측면만 적재(task.assignees_changed/task.due_changed/task.edited). ActivityModule 을 RepoModule·TaskModule 이 import(Activity 는 taskSeq 만 보관해 Task 엔티티 미참조 → 순환 없음).
  • TaskDetailResponse.activitiesActivityService.listForTask 로 채움(기존 빈 배열 제거).

프론트 frontend/src/

  • format.ts: dayGroupKo(오늘/어제/이번 주/날짜), escapeHtml(XSS 방지). types/activity.ts(ApiActivity), composables/useActivity.ts, stores/activity.store.ts(type+payload→톤/아이콘/문구 조립 + 날짜 그룹핑). 사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 후 신뢰 태그만 조립해 v-html 렌더.
  • RepoActivityPage: mock(REPO_ACTIVITY/findRepo) 제거 → 실연동(저장소 헤더=repo.store, 활동=activity.store, tone 필터, 로딩/빈 상태).
  • TaskDetailPage 활동 탭: task.storetoTaskActivityView 로 API 활동을 시안 Activity 뷰(tone/icon/html/statusFrom·To/file)로 매핑 → 실데이터 표시.
  • 검증: 프론트 type-check/lint/build 0 err, 백엔드 build/lint 0 err·20 tests pass.

한계: 활동은 6단계 도입 이후 발생분만 기록(소급 적재 없음). 활동 문구 내 업무 링크는 표시(.tgt)만 하고 클릭 라우팅은 생략. 체크리스트 개별 완료는 별도 이벤트 없이 승인 완료(status_changed→done)로 일괄 표현. 저장소 설명/브랜치 변경은 미적재. (2026-06-18 점검·보강: 업무 수정[담당자/마감/일반]·답글·업무 삭제·첨부 삭제 이벤트 추가.)


7단계 — 내 업무 집계 (백엔드 + 프론트)

  • 백엔드 MyTaskController GET /tasks/assigned·/tasks/issued(저장소 횡단, TaskService.listAssigned/listIssued, 마감 임박순). 플랫 행 배열 반환, 그룹핑·라벨은 프론트 파생.
  • 프론트 format.ts taskDueInfoddayLevel 추가, types/my-task.ts/composables/useMyTask.ts/stores/my-task.store.ts(담당/지시 뷰별 그룹 순서·라벨·미니아바타·승인대기 강조). MyTasksPage mock 제거→실연동(로딩/빈상태).
  • TaskAssigneePage(/viewtask-assignee-view 라우트·파일 제거 완료(상세가 역할 기반 통합). 행 클릭은 항상 …/tasks/:taskId.
  • 라이브 검증: /tasks/assigned·/tasks/issued 200·정상.

3. 남은 단계

8단계 — 마무리(선택) · 진행 중

8-1. 페이지네이션 통일 — 백엔드 / 프론트 전 도메인

결정(2026-06-18): 전체 목록 통일 + 프론트는 더보기 버튼 방식(시안에 페이지 UI 없음).

백엔드 (완료)

  • 공통 유틸 backend/src/common/pagination/pagination.ts: resolvePage(page,size)(범위 보정 + skip/take), paginated(items,total,pg), PaginatedResult<T>{items,total,page,size}. 기본 size 20, 최대 100.
  • 컨트롤러는 @Query('page', DefaultValuePipe(1), ParseIntPipe) / size 로 받아 서비스에 전달.
  • 적용: GET /repos(RepoService.findAll), GET /repos/:id/members(MemberService.list), GET /tasks/assigned·/tasks/issued(TaskService.listAssigned/listIssued). 응답이 배열→{items,total,page,size} 로 변경.
  • GET /repos/:id/tasks(TaskService.list)는 세그먼트(all/prog/todo/done)·검색(q)·페이지네이션 + 세그먼트 카운트 동봉(RepoTaskListResult = PaginatedResult<RepoTaskResponse> & { counts }). 세그먼트 prog=prog+review, changes는 전체에만 집계(기존 RepoDetailPage 동작 보존). 기존 ?status 쿼리는 ?segment 로 대체.
  • 검증: 백엔드 build/lint 0 err, 20 tests pass(member spec 의 list 응답 {items,total} 으로 갱신).

프론트 — 2/4 도메인

  • 공통 타입 types/pagination.ts(Paginated<T>, DEFAULT_PAGE_SIZE=20), 전역 .load-more 버튼 스타일(relay.css).
  • 저장소 목록: useRepo.list(page,size)Paginated, repo.store(total/page/hasMore/loadingMore + loadMore append), RepoListPage 하단 더보기 + total 카운트. 검색/공개여부 필터는 클라 유지(저장소 소규모).
  • 내 업무: useMyTask.assigned/issued(page,size), my-task.store(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + loadMoreAssigned/loadMoreIssued), MyTasksPage 뷰별 더보기.
  • 검증: 프론트 type-check/lint/build 0 err.

응답 계약 회귀 수정 (2026-06-19, c128b19)

  • 백엔드 페이지네이션 도입으로 목록 API 가 {items,total,...} 를 반환하는데 프론트 멤버·업무 소비처가 배열로 기대list.map 이 깨지고 멤버 탭·업무 목록이 비어 보이던 회귀를 수정.
    • 프론트: useMember.list·useTask.listPaginated<T> 반환하도록 맞추고, member.store·task.store.fetchList 에서 .items 로 언래핑(저장소·내업무와 동일 계약). → 첫 페이지(기본 20건)는 정상 표시.
    • 백엔드(task): 업무 목록 쿼리가 to-many 조인 컬럼(checklist.sort_order)으로 정렬해 페이지네이션 시 TypeORM 이 크래시하던 문제 제거 → task.seq 만 정렬(목록은 체크리스트 [완료/전체] 수만 쓰므로 항목 순서 불필요).
  • ⚠️ 이전의 '전환기 화면 깨짐' 위험은 이 커밋으로 해소. 단 더보기/세그먼트 이전은 아직이라 첫 페이지 이후 항목은 보이지 않음(아래 ).

프론트 — 남은 2/4 도메인 (2026-06-19)

  • 멤버(RepoMembersPage): 더보기 대신 전체 로드 채택. member.store.fetchList 가 페이지네이션 API를 끝까지 순회(while items)해 전체 멤버를 적재한다. 이유: ① 역할/검색 세그먼트 카운트(전체/관리자/멤버)가 전수 기준이어야 하고(부분 로드 시 합계 불일치), ② TaskCreatePage 가 담당자 후보 풀로 같은 fetchList 를 사용하므로 전체가 보장돼야 하며, ③ 멤버는 팀 규모로 한정적이라 전체 로드가 안전. → 두 소비처(멤버 페이지·업무 생성 폼) 모두 페이지 수정 없이 전체 셋 확보. (더보기 UI 불필요)
  • 저장소 업무(RepoDetailPage): 세그먼트/검색을 백엔드로 이전task.storetotal/page/counts/hasMore/loadingMore + loadMore 추가, useTask.list(repoId, {segment,q}, page, size){items,total,counts} 반환. 페이지가 segment(즉시)·keyword(250ms 디바운스) 변경 시 1페이지 리셋 로드, 더보기로 다음 페이지 append. 세그먼트 배지 수는 응답 counts(검색어 반영) 사용. 타입 TaskSegment/TaskSegmentCounts/ApiRepoTaskListResult 신설, 기존 TaskListQuery.status{segment,q} 로 교체.
  • 검증: 프론트 type-check/lint/build 0 err.

알려진 한계: RepoDetailPage 진행률 스트립의 '지연 N건'은 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원). 업무 삭제 시 세그먼트 카운트는 목록 재진입(재마운트) 시 재조회로 갱신(remove 는 총계만 보정).

8-2. 실시간 알림 (인앱 + SSE) (2026-06-19)

전송 방식 결정: SSE(Server-Sent Events) 채택. 알림은 서버→클라이언트 단방향 푸시라 SSE 가 정확히 맞고, HttpOnly 쿠키 인증(EventSource withCredentials)과 호환되며 NestJS @Sse() 네이티브 지원이라 새 의존성 없음(WebSocket 은 socket.io 등 추가 필요). 단일 인스턴스 가정 → 인메모리 pub/sub(RxJS Subject).

백엔드 backend/src/modules/notification/

  • Notification 엔티티: recipient(User, CASCADE)·type·payload(jsonb)·read·createdAt. 복합 인덱스 [recipient, createdAt]. (활동 피드와 별개 — 활동=저장소 전체 로그, 알림=개인 수신함)
  • NotificationService: notify({recipientIds, actorId, type, payload})(행위자 자동 제외 + 베스트 에포트 적재 후 스트림 푸시), listFor(페이지네이션 + unreadCount), markRead/markAllRead, streamFor(userId)(RxJS Subject 를 userId 로 필터 + 25초 하트비트). 인메모리 Subject<{userId,data}> 하나를 구독 시 필터.
  • NotificationController: GET /notifications(목록+미읽음), POST /notifications/:id/read, POST /notifications/read-all, @Sse('stream')(실시간). SSE 는 @SkipTransform() 로 표준 래퍼({success,data})를 건너뛴다(신규 common/decorators/skip-transform.decorator.ts + TransformInterceptor 가 Reflector 로 마커 확인).
  • 적재 지점(다른 서비스가 notify 호출, 8종): TaskService — 업무 생성→담당자(task.assigned), 상태전이 transition 단일 지점에서 review→지시자(task.approval_requested)·done→담당자(task.approved)·changes→담당자(task.changes_requested), 댓글/답글→관련자(task.commented, 답글은 원댓글 작성자 포함), 업무 수정 시 새 담당자(task.assigned). MemberService — 초대→피초대자(member.invited), 역할 변경→당사자(member.role_changed), 제거→당사자(member.removed). NotificationModule 을 TaskModule·RepoModule 이 import(순환 없음 — Notification 은 User 만 참조).
    • (2026-06-19 보완: P1[멤버 역할변경/제거, 답글→원댓글 작성자] + P2[담당 해제 task.unassigned·마감 변경 task.due_changed] + P3[삭제 task.removed·시작 task.started·답글 isReply 구분] → 알림 12종, gap 전건 해소. 전수 검토는 notification-review.md)

프론트 frontend/src/

  • types/notification.ts, composables/useNotification.ts(list/markRead/markAllRead — SSE 는 axios 비대상이라 store 에서 EventSource 직접 처리), stores/notification.store.ts(items/unreadCount/total + connect(멱등, 최초 1회 목록 로드 + 스트림 오픈)/disconnect/loadMore/markRead(낙관적)/markAllRead, type+payload→톤/문구/이동경로 뷰 매핑, 사용자 입력 escapeHtml 후 신뢰 태그만 v-html).
  • AppShell: 장식이던 상단바 종 버튼 → 실연동. 미읽음 뱃지(99+ 캡), 드롭다운(목록·모두 읽음·더보기·빈 상태), 항목 클릭=읽음 처리 + 대상(/repos/:id/tasks/:seq 또는 /repos/:id)으로 이동. 스트림은 store 싱글톤이 보유 → 화면 전환에도 끊기지 않고, 연결은 AppShell 마운트 시(로그인 상태) 1회, 해제는 로그아웃 시.
  • 검증: 백엔드 build/lint 0 err·20 tests pass, 프론트 type-check/lint/build 0 err.

알려진 한계: ① 단일 인스턴스 전용다중 인스턴스 지원 완료(2026-06-19, R6): REDIS_HOST 설정 시 Redis pub/sub(ioredis)로 fan-out — notify 가 채널 relay:notifications 에 발행, 모든 인스턴스가 구독해 실시간 전달. 미설정 시 인메모리 폴백. ② access 토큰(15분) 만료 후 SSE 가 끊겨 재연결되는 시점에 쿠키가 만료돼 있으면, 다음 axios 호출이 토큰을 갱신할 때까지 5초 간격 무알림 재시도(유휴 상태 한정, 사용 재개 시 자동 복구). ③ 알림은 도입 이후 발생분만(소급 없음), 이메일/푸시는 범위 밖.

8-3. Redis 캐싱 (2026-06-19)

스택: @nestjs/cache-manager@3 + cache-manager@6(Keyv 기반) → Redis 연결은 신규 의존성 @keyv/redis. docker-compose 가 이미 redis 컨테이너 + REDIS_HOST/PORT/PASSWORD 주입.

백엔드

  • CacheModule → Redis 전환(app.module.ts registerAsync): REDIS_HOST 있으면 createKeyv(redis://…) 스토어, 없으면 인메모리 폴백(단독 실행/테스트). keyv error 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
  • 저장소 목록 캐싱(GET /repos): 신규 RepoCacheService(repo 모듈) — 버전 네임스페이스 무효화. 키 repos:list:v{ver}:p{page}:s{size}, 변경 시 repos:list:ver 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백.
    • 무효화 지점: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + MemberService 초대/제거(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — 2026-06-19 보강). RepoCacheService 공유.
    • 보강(2026-06-19, 검토 redis-cache-review.md): ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ main.ts enableShutdownHooks() — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 onModuleDestroy 가 死코드였음).
    • 업무 카운트 staleness: 업무 상태 변경은 목록 버전을 올리지 않아 doneCount/totalCount 는 최대 60초(TTL) 지연 — 대시보드 특성상 허용(정확 무효화 대신 task↔repo 결합 회피).
  • 캐싱 제외: GET /repos/meta 는 순수 인메모리 getter(DB 미접근)라 Redis 왕복이 오히려 손해 → 캐싱 안 함.

검증: 백엔드 build/lint 0 err·20 tests pass. (프론트 변경 없음 — API 계약 동일, 응답만 캐시.)

알려진 한계: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ 의존성 추가(@keyv/redis)라 dev 컨테이너는 up -d --build -V(익명 node_modules 볼륨 갱신) 필요.

8-4. 그 외(미착수)

  • 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동.

4. 전환기 한계 (현재 시점)

  • 모든 화면이 실데이터로 연동 완료. 남은 미연동 요소는 AI 에이전트 패널(로컬 데모, 8단계) 뿐.
  • src/mock/relay.mock.ts 는 이제 타입 정의 공급원으로만 사용(여러 store/page 가 Repo/User/TaskStatus/RepoTask/TaskDetail/Activity/MyTaskGroup 등 뷰 타입을 import). 정적 데이터(REPO_ACTIVITY/MY_TASKS_*/REPOS/USERS 등)는 사용처가 없어졌다 — 추후 타입만 src/types/ 로 이전하고 데이터는 제거 가능(현재는 유지).

5. 알아둘 사항 / 결정 기록

  • 엔티티 순환참조: 서로 참조하는 관계(예: RepoRepoMember)의 단일 클래스 관계 속성에는 TypeORM Relation<> 래퍼를 사용한다. 안 그러면 SWC 환경에서 Cannot access 'X' before initialization 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
  • raw 집계 쿼리용 FK 컬럼명: createQueryBuilder().select('task.repo_id', …) 처럼 raw SQL 로 FK 컬럼을 직접 참조하면, @ManyToOne 의 기본 FK 컬럼명이 camelCase(repoId)라 task.repo_id(snake) 와 불일치해 42703 column does not exist 가 난다. → 관계에 @JoinColumn({ name: 'repo_id' }) 를 명시해 컬럼명을 snake_case 로 고정(프로젝트 컬럼 네이밍과도 일치). Task.repo 에 적용. (task.repo = :id 같은 관계 쿼리는 자동 매핑되어 무관)
  • bcrypt → bcryptjs: Docker 베이스가 node:20-alpine(빌드 도구 없음)이라 네이티브 모듈 bcrypt 대신 순수 JS bcryptjs 사용.
  • dev 도커 익명 볼륨: /app/node_modules 가 익명 볼륨이라 의존성 변경 시 up -d --build -V(익명 볼륨 갱신) 필요. --build 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
  • owner 고정: 조직 모델이 없어 owner='marketing-team' 상수. Gitea 연동 시 owner=GITEA_ORG 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체.
  • ID 전략: User/Repo PK=UUID, Repo 라우팅 식별자=slugName, Task=저장소 내 순번(#9, 4단계).
  • Gitea 연동 방식: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 fetch 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). slug↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음).
  • 연동 비활성 폴백: GITEA_* 미설정 시 GiteaService.enabled=false → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지.
  • 저장소 생성 폼 보강: ① 프로젝트 설명 필수화(CreateRepoDto.description required, 프론트 검증 추가). ② 템플릿 복제useTemplate=true 면 Gitea generate API 로 GITEA_TEMPLATE_OWNER/REPO(기본 TtiPo/project-base, template 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 GET /repos/meta(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거.
  • import 저장소 멤버 데드락 / REPO_IMPORT_ADMIN_EMAIL: import 저장소는 멤버 0명 → admin 부재 → 초대 불가의 데드락. env(REPO_IMPORT_ADMIN_EMAIL)로 지정한 가입 사용자를 동기화 시 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정해 해소. (선택지 중 "env 지정 이메일" 채택 — 동기화는 시스템 작업이라 실행 주체가 없기 때문)
  • subRole(저장소 내 직무) 제거: 표시/권한 어디에도 관여하지 않는 장식 라벨이라 백엔드 엔티티·DTO·응답 + 프론트 타입·표시·초대입력에서 완전 제거(2026-06-17). owner 에 박던 '리드' 기본값도 제거. dev synchronizerepo_members.sub_role 컬럼을 드롭함. (권한은 roleType(admin/member)만 사용. 전사 직무 User.role 은 별개로 유지)
  • env 변경 시 컨테이너 재생성 필요: docker-composeenv_file(루트·backend/.env.*)은 컨테이너 시작 시 1회만 주입된다. nest start --watch 의 핫리로드는 코드만 다시 읽고 process.env 는 갱신하지 않으므로, env 값을 바꾸면 up -d(설정 변경 감지 시 재생성) 또는 up -d --force-recreate <서비스> 로 컨테이너를 다시 만들어야 한다. docker compose restart 는 env_file 을 다시 읽지 않음. (코드 변경만이면 watch 가 자동 반영)
  • env 파일 구조 정규화: 각 실제 .env.{env} 와 그 .env.{env}.example주석·키 순서·빈 줄까지 동일하게 맞추고 차이는 값(실제 secret ↔ placeholder)만 두도록 정리. frontend 는 고유 키가 없어 헤더 주석만 동일하게 둠.
  • 전역 relay.css 모달 규칙 상속 주의: 공통 .modal-body(text-align:center)·.mbtn(flex:1)·.modal-backdrop 가 전역이라, 다른 레이아웃의 커스텀 모달(예: .invite-modal)은 스코프 클래스로 text-align/flex 를 명시적으로 리셋해야 한다. (초대 모달 본문 좌측정렬·푸터 버튼 폭 깨짐의 원인이었음)

6. 실행 / 검증

# 개발 환경 기동 (의존성 변경 시 --build -V)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build -V

# 백엔드 로그
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
  • 백엔드: npm run build / npm run lint (현재 0 error)
  • 프론트: npm run type-check / npm run lint / npm run build-only (현재 0 error)
  • dev synchronize=trueusers·repos·repo_members·tasks·checklist_items·task_assignees 테이블 자동 생성(Gitea 연동 컬럼 포함)·스키마 변경 자동 반영(예: sub_role 컬럼 드롭, tasks.repo_id FK 컬럼명). DB가 비어 있으므로 회원가입 후 로그인. 엔티티 신규 추가(4단계) 시 백엔드 컨테이너 재기동으로 스키마 반영.
  • 포트: 프론트 5273, 백엔드 3100(/api), Swagger http://localhost:3100/api-docs.

Gitea 연동 설정/검증

  1. backend/.env.{env}GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 채우기(예시: backend/.env.{env}.example).
    • 토큰: Gitea → Settings → Applications → Generate Token(조직 repo 생성 권한 필요).
    • docker-compose 는 backend/.env.{env}env_file 로 자동 주입하므로 compose 수정 불필요.
  2. 부팅 로그에서 Gitea 연동 활성화 — <url> (org: <org>) 확인(미설정 시 비활성 경고).
  3. Relay 에서 저장소 생성 → Gitea <org> 조직에 동일 slug repo 가 생기는지 확인. 응답 cloneUrl/htmlUrl 채워짐.
  4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인.
  5. (선택) REPO_IMPORT_ADMIN_EMAIL가입된 사용자 이메일을 넣으면, 동기화 시 멤버 0명 import 저장소에 그 사용자가 owner-admin 으로 지정된다. env 변경이므로 컨테이너 재생성 필요(up -d --force-recreate backend). 부팅 로그의 import 저장소 owner-admin 지정: <slug> ← <email> 로 확인.