Files
photoai/docs/ARCHITECTURE.md
T
koriweb 8a8c10248c Initial commit: AI Photo Organizer (Electron + face-api)
Local-first photo organizer that auto-sorts images by face recognition
and EXIF capture date.

- Electron app with 3-process split: Main (Node) / UI Renderer (React) /
  hidden Inference Renderer (face-api + WebGL)
- Core pipeline: scan -> face match + EXIF -> path build -> atomic move/copy
- Move = copy -> verify -> delete; auto-rename on filename collision
- 1st-registered profile = move, others = copy; unmatched -> [미정]/YYYY/MM
- EXIF date with mtime fallback
- Vitest unit tests (pathBuilder / fileOps / concurrency) all green
- electron-builder config for Windows (nsis) + macOS (dmg)
- Docs: PRD / DECISIONS / ARCHITECTURE

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

304 lines
16 KiB
Markdown

# 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<string>` |
| **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<MatchResult>` |
| **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`) |
> 전 항목 확정. 본 설계로 구현 착수.
```