# AI Photo Organizer — 시스템 아키텍처 설계 > 상태: **확정 (Final)** · 최종 갱신: 2026-06-01 > 선행 문서: [PRD](./PRD.md) · [DECISIONS.md](./DECISIONS.md) > 승인 완료 — 본 설계로 스캐폴딩/구현 착수. --- ## 0. 한눈에 보기 (TL;DR) - **플랫폼**: Electron 데스크톱 앱 (Windows/macOS). - **3-프로세스 구조**: `Main`(파일·오케스트레이션) + `UI Renderer`(React 화면) + `Inference Renderer`(숨김 창, 얼굴인식 전담). - **왜 분리?** face-api.js는 브라우저(WebGL/Canvas) 환경이 필요하고, 파일 I/O는 Node 환경이 필요하다. 둘을 다른 프로세스로 나눠 **UI 프리징 0**, **빌드 안정성**을 동시에 확보한다. - **데이터 흐름**: 스캔(Main) → 얼굴인식(Inference) → 날짜추출(Main) → 경로생성/이동·복사(Main) → 진행률 이벤트(UI). --- ## 1. 기술 스택 확정 (Tech Stack & Versions) | 영역 | 라이브러리 | 버전(기준) | 비고 | |------|-----------|-----------|------| | 런타임/셸 | **Electron** | `^33.0.0` | Chromium + Node 번들. WebGL 가속 사용. | | 빌드 도구 | **electron-vite** | `^2.3.0` | Main/Preload/Renderer 멀티 엔트리를 Vite로 통합 빌드. | | 번들러 | **Vite** | `^5.4` | electron-vite 내부. | | UI | **React** | `^18.3` | 함수형 + Hooks. | | 언어 | **TypeScript** | `^5.5` | 전 프로세스 공통. `strict: true`. | | 상태관리 | **Zustand** | `^4.5` | 경량 글로벌 스토어 (Redux 과잉 회피). | | 스타일 | **Tailwind CSS** | `^3.4` | 빠른 UI 구성. (선호 시 교체 가능) | | **얼굴 인식** | **@vladmandic/face-api** | `^1.7` | 원본 `face-api.js`(유지보수 중단)의 API 호환 포크. **tfjs(WebGL 백엔드)를 내부 번들로 포함** → 별도 tfjs 의존 불필요. Inference Renderer에서만 사용. | | **EXIF 파싱** | **exifr** | `^7.1` | 빠르고 HEIC/주요 포맷 지원. Main(Node)에서 실행. | | 파일 작업 | **node:fs/promises** | 내장 | 비동기 I/O. | | 동시성 제어 | **(자체 구현 세마포어)** | - | 외부 `p-limit` 대신 경량 직접 구현. | | 패키징 | **electron-builder** | `^25` | 배포용 인스톨러(.exe/.dmg) 생성. | | 테스트 | **Vitest** | `^2` | 순수 로직(스캐너/EXIF/경로/충돌) 단위 테스트. | > Node는 Electron 번들 버전(Electron 33 → Node 20 계열)을 사용하므로 별도 Node 버전 고정 불필요. --- ## 2. 프로세스 아키텍처 (Process Topology) ``` ┌──────────────────────────────────────────────────────────────────┐ │ Electron App │ │ │ │ ┌─────────────────┐ IPC ┌──────────────────────┐ │ │ │ UI Renderer │◀──────────────────▶│ Main Process │ │ │ │ (visible window)│ progress/control │ (orchestrator/Node) │ │ │ │ React + Zustand │ │ │ │ │ └─────────────────┘ │ scanner / exif / │ │ │ ▲ preload(contextBridge) │ fileOps / pathBuilder│ │ │ │ │ profileStore /reporter│ │ │ │ └──────────┬───────────┘ │ │ │ │ IPC (descriptor│ │ │ │ 요청/응답) │ │ │ ┌──────────▼───────────┐ │ │ │ │ Inference Renderer │ │ │ └─────────(미표시)────────────────▶│ (hidden window) │ │ │ │ face-api + WebGL │ │ │ │ faceEngine/imageLoader│ │ │ └──────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘ ``` ### 2.1 각 프로세스 책임 | 프로세스 | 환경 | 책임 | |----------|------|------| | **Main** | Node | 폴더 스캔, EXIF 추출, 파일 이동/복사(원자성), 프로필 영속화, 작업 오케스트레이션, 리포트/로그, IPC 라우팅. | | **UI Renderer** | Chromium(보임) | 프로필 등록 UI, 폴더 선택, 실행 제어, 실시간 진행률/파일목록, 결과 리포트 표시. **무거운 연산 없음 → 프리징 없음.** | | **Inference Renderer** | Chromium(숨김) | face-api 모델 로드, 이미지 디코딩, 얼굴 검출·descriptor 계산, 프로필과 매칭. UI 스레드와 분리되어 연산이 화면을 막지 않음. | > **대안 검토**: Inference를 별도 숨김 창 대신 *Web Worker + OffscreenCanvas*로 둘 수도 있으나, face-api는 `document`/`canvas` 의존이 있어 worker 환경 패치가 까다롭다. v1은 **숨김 BrowserWindow**가 가장 안정적. (성능 이슈 시 worker로 이전 검토.) --- ## 3. 모듈 구조 (Module Breakdown) ### 3.1 Main Process (`src/main/`) | 모듈 | 파일 | 역할 | 핵심 인터페이스(개념) | |------|------|------|----------------------| | **Scanner** | `scanner.ts` | 소스 폴더 재귀 순회, 확장자 필터, 이미지 경로 스트림 산출. | `async *scan(root): AsyncIterable` | | **ExifReader** | `exif.ts` | `DateTimeOriginal` 추출, 없으면 `fs.stat().mtime` 폴백 → `{year, month}`. | `getCaptureDate(path): Promise<{year,month}>` | | **FileOps** | `fileOps.ts` | 이동(복사→검증→삭제)/복사, 충돌 시 자동 리네임, 원자성·오류처리. | `move(src,dest)`, `copy(src,dest)` | | **PathBuilder** | `pathBuilder.ts` | `/<프로필>/YYYY/MM/` · `[미정]/YYYY/MM/` 경로 생성, 파일명 충돌 해소. | `build(outRoot, who, date, filename)` | | **ProfileStore** | `profileStore.ts` | 프로필(이름·순서·참조이미지·descriptor) JSON 영속화/로드. | `load()`, `save()`, `upsert()` | | **Orchestrator** | `orchestrator.ts` | 전체 잡 파이프라인 구동, 동시성 제어, 진행률 이벤트 발행, 중단/취소. | `run(job)`, `cancel()` | | **InferenceBridge** | `inferenceWindow.ts` | 숨김 Inference 창 생성/관리, descriptor 요청-응답 RPC. | `detect(imagePath): Promise` | | **Reporter** | `reporter.ts` | 통계 집계(총/성공/실패/소요), 결과 리포트 + 로그파일 출력. | `summarize()`, `writeLog()` | | **IpcMain** | `ipc.ts` | UI/Inference 채널 핸들러 등록·라우팅. | - | | **Logger** | `logger.ts` | 구조적 로그(파일+콘솔), 오류 추적. | - | ### 3.2 Inference Renderer (`src/inference/`) | 모듈 | 파일 | 역할 | |------|------|------| | **FaceEngine** | `faceEngine.ts` | 모델 1회 로드(SSD MobileNet v1 + Landmark68 + Recognition), 이미지당 얼굴검출+descriptor, `FaceMatcher`로 프로필 매칭(거리 `<0.5`). | | **ImageLoader** | `imageLoader.ts` | 파일→`HTMLImageElement`/`canvas` 디코딩, 대형 이미지 다운스케일(예: 장변 1024px), 처리 후 canvas/objectURL 해제로 메모리 회수. | | **bootstrap** | `main.ts` | 모델 로드 완료 시 Main에 ready 통지, descriptor 요청 수신 루프. | ### 3.3 UI Renderer (`src/renderer/`) | 컴포넌트 | 파일 | 역할 | |----------|------|------| | App 셸 | `App.tsx` | 단계별 화면 라우팅(프로필→폴더→실행→리포트). | | ProfileManager | `components/ProfileManager.tsx` | 최대 3인 프로필 등록/수정, 참조 이미지 추가, descriptor 생성 트리거. | | FolderPicker | `components/FolderPicker.tsx` | 소스 폴더·출력 루트 선택(다이얼로그). | | RunControl | `components/RunControl.tsx` | 실행/취소, 임계값 등 옵션. | | ProgressView | `components/ProgressView.tsx` | 진행률 바, 처리 중 파일명, 카운터. | | FileList | `components/FileList.tsx` | 처리 결과 스트림(파일→대상폴더/판정) 가상 스크롤. | | ReportView | `components/ReportView.tsx` | 총처리/성공/실패/소요시간 + 로그 열기. | | Store | `store.ts` | Zustand: 프로필·잡상태·진행률·결과. | ### 3.4 Preload (`src/preload/`) `contextBridge`로 안전한 API만 노출 (`nodeIntegration: false`, `contextIsolation: true`). ```ts window.api = { profiles: { list, upsert, remove, addReference }, dialog: { pickSource, pickOutput }, job: { run, cancel }, on: (event, cb) => unsubscribe, // progress / fileProcessed / done / error } ``` ### 3.5 Shared (`src/shared/`) `types.ts`(Profile, Job, MatchResult, ProgressEvent, Report 등), `constants.ts`(확장자, 기본 임계값, IPC 채널명). --- ## 4. 데이터 흐름 (End-to-End Sequence) ### 4.1 프로필 등록 ``` UI: 이름 + 참조이미지 선택 → IPC profiles.addReference(path[]) → Main: 이미지 경로를 InferenceBridge.detect()로 전달 → Inference: 참조이미지의 descriptor 계산 → 반환 → Main ProfileStore: {name, order, refImages, descriptors[]} 저장 (다수 → 평균 descriptor) → UI: 등록 완료 표시 ``` ### 4.2 정리 잡 실행 (핵심 파이프라인) ``` UI: [실행] (source, outputRoot, options) → IPC job.run → Main Orchestrator 시작 1) Scanner.scan(source) ──▶ 이미지 경로 큐 (스트리밍) 2) 동시성 N(기본 3)개 워커로 각 경로 처리: a. InferenceBridge.detect(path) Inference: 이미지 로드 → 얼굴검출 → descriptor → FaceMatcher로 프로필 매칭(거리<0.5) → MatchResult { matched: [{name, order, distance}], faceFound: bool } b. ExifReader.getCaptureDate(path) → {year, month} (없으면 mtime) c. 분기: - matched.length ≥ 1: 정렬(등록순서 asc) → first=이동, rest=복사 PathBuilder로 각 대상경로 생성 → FileOps.move/copy - matched.length == 0 (미검출/실패): PathBuilder([미정], date) → FileOps.move d. 진행률/결과 이벤트 발행 → UI 갱신 3) 큐 소진 시 Reporter.summarize() → 로그 작성 → IPC done(report) → UI ReportView ``` ### 4.3 이동(Move)의 원자성 (데이터 무결성 0 Error) ``` move(src, dest): 1. dest 충돌 검사 → 있으면 자동 리네임(dest_1, dest_2 …) 2. fs.copyFile(src, tmp) # 임시로 복사 3. 무결성 검증 (size 일치 + 필요 시 해시) 4. rename(tmp → dest) # 원자적 노출 5. unlink(src) # 원본 삭제 (검증 통과 후에만) 실패 시: 원본 보존, tmp 정리, 에러 로깅 → 잡 계속 (해당 파일만 실패 처리) ``` > 동일 볼륨이면 `fs.rename` 최적화 가능하나, 안전성 우선으로 copy-verify-delete를 기본으로 한다. (옵션화 가능) --- ## 5. 프로젝트 폴더 트리 ``` PhotoAI/ ├─ docs/ │ ├─ PRD.md │ ├─ DECISIONS.md │ └─ ARCHITECTURE.md ← 본 문서 ├─ models/ # face-api 가중치 (ssd_mobilenetv1, landmark68, recognition) ├─ src/ │ ├─ main/ # Main 프로세스 │ │ ├─ index.ts # 앱 진입, 창 생성 │ │ ├─ orchestrator.ts │ │ ├─ scanner.ts │ │ ├─ exif.ts │ │ ├─ fileOps.ts │ │ ├─ pathBuilder.ts │ │ ├─ profileStore.ts │ │ ├─ reporter.ts │ │ ├─ inferenceWindow.ts │ │ ├─ ipc.ts │ │ └─ logger.ts │ ├─ preload/ │ │ └─ index.ts # contextBridge API │ ├─ renderer/ # UI 창 │ │ ├─ index.html │ │ ├─ main.tsx │ │ ├─ App.tsx │ │ ├─ store.ts │ │ ├─ components/ │ │ │ ├─ ProfileManager.tsx │ │ │ ├─ FolderPicker.tsx │ │ │ ├─ RunControl.tsx │ │ │ ├─ ProgressView.tsx │ │ │ ├─ FileList.tsx │ │ │ └─ ReportView.tsx │ │ └─ styles/index.css │ ├─ inference/ # 숨김 추론 창 │ │ ├─ index.html │ │ ├─ main.ts │ │ ├─ faceEngine.ts │ │ └─ imageLoader.ts │ └─ shared/ │ ├─ types.ts │ └─ constants.ts ├─ tests/ # Vitest 단위 테스트 (scanner/exif/pathBuilder/fileOps) ├─ electron.vite.config.ts ├─ electron-builder.yml ├─ tsconfig.json ├─ tailwind.config.js ├─ package.json └─ README.md ``` ### 5.1 런타임 데이터 위치 - **프로필 저장**: `app.getPath('userData')/profiles.json` (OS 표준 사용자 데이터 경로). 내보내기 기능으로 백업 가능. - **작업 로그**: 출력 루트 하위 `_PhotoAI_logs/run-<타임스탬프>.log` (사용자가 결과와 함께 확인). - **모델**: 앱 번들 내 `models/` (오프라인 동작, 클라우드 미사용). --- ## 6. IPC 채널 명세 | 채널 | 방향 | 페이로드 | 설명 | |------|------|---------|------| | `profiles:list` | UI→Main(invoke) | - | 프로필 목록 | | `profiles:upsert` | UI→Main | `{id?, name, order}` | 등록/수정 | | `profiles:addReference` | UI→Main | `{id, imagePaths[]}` | 참조이미지+descriptor | | `profiles:remove` | UI→Main | `{id}` | 삭제 | | `dialog:pickSource` / `dialog:pickOutput` | UI→Main | - | 폴더 선택 | | `job:run` | UI→Main | `{source, outputRoot, options}` | 잡 시작 | | `job:cancel` | UI→Main | - | 취소 | | `job:progress` | Main→UI(send) | `{done, total, current}` | 진행률 | | `job:fileProcessed` | Main→UI | `{file, decision, target}` | 파일 결과 | | `job:done` | Main→UI | `Report` | 완료 | | `job:error` | Main→UI | `{file, message}` | 파일 단위 오류 | | `infer:detect` | Main→Inference(invoke) | `{imagePath}` | descriptor/매칭 | | `infer:ready` | Inference→Main | - | 모델 로드 완료 | --- ## 7. 얼굴 인식 상세 (Accuracy ≥ 98% 전략) - **모델 조합**: `SsdMobilenetv1`(검출, 정확도 우선) + `faceLandmark68Net` + `faceRecognitionNet`(128-d descriptor). - 속도 우선 옵션으로 `TinyFaceDetector` 토글 제공. - **매칭**: `faceapi.FaceMatcher`, Euclidean distance 임계값 **0.5**(노출형 파라미터). 낮출수록 엄격(오탐↓/미탐↑). - **정확도 향상**: 인물당 참조이미지 다수 등록 → 라벨별 다중 descriptor 또는 평균값 사용. - **메모리 관리**: 이미지 장변 다운스케일, 처리 후 `canvas`/`ObjectURL`/텐서 즉시 해제, 동시성 상한으로 피크 메모리 제어. 모델은 1회 로드·앱 생애 유지. --- ## 8. 비기능 요구 매핑 (PRD §5 대응) | 요구 | 설계 반영 | |------|----------| | Atomic Operation | §4.3 copy-verify-delete, 파일 단위 try/catch, 실패 격리 | | No Data Loss | 자동 리네임(덮어쓰기 금지), 검증 후에만 원본 삭제 | | Asynchronous Processing | 모든 fs I/O 비동기 + 스트리밍 스캔 + 별도 추론 프로세스 → UI 프리징 0 | | Memory Management | 다운스케일·즉시 해제·동시성 상한·모델 1회 로드 | | Extension Auto-detection | `constants.ts`의 확장자 화이트리스트(`.jpg/.jpeg/.png/.webp`) | --- ## 9. 빌드 · 실행 · 테스트 - **개발**: `electron-vite dev` (HMR, 3개 엔트리 동시). - **배포**: `electron-vite build` → `electron-builder` 인스톨러. - **테스트**: Vitest로 순수 로직(스캐너/EXIF 폴백/경로생성/충돌 리네임/이동 원자성) 검증. 얼굴인식은 고정 샘플로 통합 스모크. --- ## 10. 열린 결정 — 확정됨 (Resolved) | # | 항목 | 확정 | |---|------|------| | 1 | 얼굴인식 라이브러리 | **`@vladmandic/face-api`** (원본 face-api.js 유지보수 포크, API 호환) | | 2 | Inference 격리 방식 | **숨김 BrowserWindow** (v1) | | 3 | UI 프레임워크 | **React + TypeScript + Vite + Tailwind** | | 4 | 프로필 저장 위치 | **OS userData** 경로 | | 5 | 패키징 범위 | **Windows + macOS 동시** (electron-builder: `nsis` + `dmg`) | > 전 항목 확정. 본 설계로 구현 착수. ```