Sprint 2 Architecture

Flutter 온디바이스 앱 구조와 DB 설계

Landmark Assistant를 Flutter 앱으로 구현할 때의 개발 아키텍처다. Spring식 계층 구조에 익숙한 팀원이 이해할 수 있도록, 앱 내부의 Gateway, Service, Repository, Local DB, Model Artifact, MySQL 운영 DB 역할을 함께 정리한다.

먼저 결론

MySQL은 적절하다. 다만 앱이 추론할 때마다 MySQL에 직접 붙는 구조가 아니라, MySQL은 운영/관리용 원천 DB로 두고, Flutter 앱은 export된 asset, local SQLite, ONNX/TFLite artifact로 오프라인 추론하는 구조가 가장 안정적이다.

MySQL = 랜드마크 정보, 텍스트 카탈로그, 라벨링, 모델 버전, 평가 기록을 관리하는 원천 DB
Flutter asset = 앱에 포함되는 읽기 전용 landmark metadata, text index, prototype index
Local SQLite = 사용자 설정, 검색 로그, 검색 결과 로그
Model artifact = ONNX/TFLite 모델 파일, embedding binary, tokenizer/config

전체 구조

Spring 서버 구조로 보면 Controller/Gateway → Service → Repository/DB 흐름이다. 우리 앱에서는 이 계층이 서버가 아니라 Flutter 앱 내부에 들어간다.

Flutter UI 사진 입력, 자연어 검색, 상세 화면 App Gateway Request/Response, UseCase 분기 Local AI Backend Image/Text Encoder, Search, Confidence Policy ONNX Runtime / TFLite / Platform Runtime Local DB SQLite logs/settings App Assets metadata, index, model files MySQL 운영 DB 랜드마크, 라벨링, 텍스트 카탈로그, 모델 버전 관리 앱 배포 전 JSON/SQLite/embedding asset으로 export export pipeline

Spring식 구조와 Flutter 구조 매핑

Spring에서의 개념 Flutter 앱에서의 대응 책임
Controller / Gateway LandmarkSearchGateway, UseCase Router UI 요청을 image/text/detail use case로 분기하고 공통 response schema를 만든다.
Service ImageSearchService, TextSearchService, LandmarkDetailService 전처리, 모델 추론, 유사도 계산, confidence policy 적용을 수행한다.
Repository LandmarkRepository, AssetRepository, SearchLogRepository 앱 asset, local SQLite, embedding index를 읽고 쓴다.
DB MySQL 원천 DB + 앱 내부 SQLite/JSON asset MySQL은 관리/운영, 앱 내부 DB와 asset은 오프라인 실행을 담당한다.
External AI API Local Model Runtime 서버 호출 대신 ONNX/TFLite 모델을 기기 내부에서 실행한다.

현재 텍스트 검색 계층

최신 Sprint 2 앱은 keyword-only 검색이 아니라 SQLite keyword score + MobileCLIP2-S3 text encoder semantic score를 결합한다. 단, 서버 API 호출이 아니라 앱 내부 asset과 ONNX Runtime을 사용하는 온디바이스 구조다.

TextSearchScreen
  -> LocalApiClientImpl.search(textQuery)
  -> KeywordSearchService(SQLite LIKE)
  -> TokenizerService(tokenizer_bundle.json)
  -> OnnxInferenceService.extractTextEmbedding(text_tokens)
  -> TextIndexRepository(text_index.json)
  -> SemanticTextSearchService(semantic_text_fusion)
  -> DetailScreen(landmark_id)

score_typesemantic_text_fusion으로 구분하고, 사용자가 보는 점수는 확률이 아니라 유사도 기반 표시값이다.

데이터 저장 원칙

모델 파일, 대용량 이미지 원본, 대용량 embedding binary를 MySQL에 직접 넣지 않는다. MySQL에는 버전, 경로, 사람이 관리해야 할 metadata를 넣고, 실제 앱 실행 파일은 artifact로 관리한다.

분류 저장 위치 예시 이유
운영 원천 데이터 MySQL landmark, alias, text catalog, label metadata, model version 팀원이 수정, 검수, 조회, export하기 쉽다.
앱 실행용 읽기 전용 데이터 Flutter assets landmark_info.json, landmark_text_catalog.json, prototype_index.json, text_index.bin 오프라인 실행과 빠른 조회에 적합하다.
사용자 실행 로그 Local SQLite search_log, search_result_log, user_settings 개인 기기 안에서 기록하고, 필요 시 export한다.
모델 파일 Artifact folder / app bundle image_encoder.onnx, text_encoder.onnx, tokenizer.json, preprocessing.json, manifest.json DB보다 파일 시스템과 앱 번들 관리가 적합하다. 앱은 manifest를 읽어 실제 파일명을 확인한다.
대용량 원본 이미지 파일 서버, Drive, object storage 학습 이미지 원본, 수집 이미지 묶음 MySQL에는 URI와 source metadata만 저장한다.

MySQL ERD 초안

MySQL은 앱 내부 추론 DB가 아니라 운영 DB다. 라벨링, 텍스트 카탈로그, 모델 버전, 평가 결과를 관리하고 앱용 asset을 export하는 기준이 된다.

landmark
  ├─ landmark_alias
  ├─ landmark_text_catalog
  ├─ landmark_media
  ├─ landmark_embedding_manifest
  ├─ dataset_image
  │    └─ dataset_label
  └─ evaluation_result

model_version
  ├─ model_artifact
  ├─ evaluation_run
  │    └─ evaluation_result
  └─ app_asset_bundle

app_search_log
  └─ app_search_result_log
테이블 주요 필드 용도
landmark landmark_id, name_ko, name_en, description_ko, lat, lng 상세 화면과 검색 결과의 기본 정보.
landmark_alias alias_text, language, alias_type, weight 이름/별칭/검색어 직접 매칭.
landmark_text_catalog text_type, language, text, weight 자연어 검색, text index 생성, caption 작성 기준.
landmark_media media_type, uri, caption, source 상세 페이지 대표 이미지, 썸네일, 출처 관리.
dataset_image image_id, landmark_id, file_uri, source_group_id 학습 이미지 metadata와 leakage 방지.
dataset_label label_status, quality_status, view_type, confusing_with 학습 투입 여부, hard negative, OOS/저품질 분리.
model_version model_id, version, dataset_fingerprint, created_at 어떤 데이터와 설정으로 만든 모델인지 추적.
model_artifact artifact_type, artifact_uri, runtime, embedding_dim ONNX/TFLite/PT 파일의 위치와 실행 조건 기록.
evaluation_run run_id, model_id, test_set_id, summary_metrics 모델 검증 실행 단위.
evaluation_result input_id, expected_landmark_id, top_k_json, decision_status 테스트 입력별 Top-3, 실패 케이스, 혼동쌍 기록.
app_search_log search_type, input_summary, device, latency_ms, decision_status 앱 실행 로그. 기본은 local SQLite, 동의 시 MySQL 업로드 가능.

핵심 SQL 초안

실제 구현에서는 JSON 필드를 MySQL JSON 타입으로 둘 수 있다. 앱용 SQLite export에서는 같은 구조를 더 가볍게 flatten하거나 JSON asset으로 변환한다.

CREATE TABLE landmark (
  landmark_id VARCHAR(80) PRIMARY KEY,
  name_ko VARCHAR(120) NOT NULL,
  name_en VARCHAR(160),
  category VARCHAR(80),
  short_description_ko TEXT,
  long_description_ko TEXT,
  latitude DECIMAL(10, 7),
  longitude DECIMAL(10, 7),
  is_active BOOLEAN NOT NULL DEFAULT TRUE,
  updated_at DATETIME NOT NULL
);

CREATE TABLE landmark_alias (
  alias_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  landmark_id VARCHAR(80) NOT NULL,
  alias_text VARCHAR(160) NOT NULL,
  language VARCHAR(12) NOT NULL,
  alias_type VARCHAR(40) NOT NULL,
  weight DECIMAL(5, 3) NOT NULL DEFAULT 1.0,
  FOREIGN KEY (landmark_id) REFERENCES landmark(landmark_id)
);

CREATE TABLE landmark_text_catalog (
  catalog_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  landmark_id VARCHAR(80) NOT NULL,
  text_type VARCHAR(60) NOT NULL,
  language VARCHAR(12) NOT NULL,
  text TEXT NOT NULL,
  weight DECIMAL(5, 3) NOT NULL DEFAULT 1.0,
  source VARCHAR(120),
  FOREIGN KEY (landmark_id) REFERENCES landmark(landmark_id)
);

CREATE TABLE model_version (
  model_id VARCHAR(120) PRIMARY KEY,
  model_name VARCHAR(120) NOT NULL,
  version VARCHAR(80) NOT NULL,
  dataset_fingerprint VARCHAR(80),
  embedding_dim INT NOT NULL,
  created_at DATETIME NOT NULL
);

CREATE TABLE model_artifact (
  artifact_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  model_id VARCHAR(120) NOT NULL,
  artifact_type VARCHAR(40) NOT NULL,
  artifact_uri TEXT NOT NULL,
  runtime VARCHAR(40) NOT NULL,
  file_sha256 VARCHAR(80),
  FOREIGN KEY (model_id) REFERENCES model_version(model_id)
);

앱 내부 asset 구조

Flutter 앱이 실제로 들고 다니는 것은 MySQL 전체가 아니다. MySQL에서 검수된 데이터를 export해서 앱 asset으로 넣고, 기기 안에서 오프라인으로 읽는다.

assets/
  data/
    landmark_info.json              # 상세 화면용 기본 정보
    landmark_aliases.json           # 이름/별칭 직접 매칭
    landmark_text_catalog.json      # 사람이 읽을 수 있는 검색 사전
    confidence_policy.json          # matched/ambiguous/out_of_scope/low_quality 기준
  embeddings/
    image_prototypes.bin            # landmark별 image prototype 또는 prototype_index.json
    text_index.bin                  # text catalog embedding index
    embedding_manifest.json         # dim, dtype, model_version, offset 정보
  model/
    mobileclip2_s3_server_full_ce_hardneg_image_encoder_fp16_mixed.onnx
    mobileclip2_s3_server_full_ce_hardneg_image_encoder_fp16_mixed.onnx.data
    mobileclip2_s3_server_full_ce_hardneg_text_encoder_fp16_mixed.onnx
    mobileclip2_s3_server_full_ce_hardneg_text_encoder_fp16_mixed.onnx.data
    tokenizer.json
    preprocessing.json
    manifest.json

local SQLite:
  search_log
  search_result_log
  user_settings

최신 Sprint 2 artifact는 image encoder와 text encoder가 분리되어 있다. 모델은 landmark 이름을 직접 반환하지 않고 512차원 embedding을 반환하므로, 앱의 search service가 prototype/text index와 cosine similarity를 계산해야 한다. 상세 화면에 필요한 이름, 설명, 대표 이미지는 모델 artifact가 아니라 local catalog 또는 DB에서 조회한다.

이미지 검색 요청 흐름

Image UI Gateway ImageSearchRequest Quality blur/dark/screen Encoder image embedding Search prototype cosine Top-3 status
ImageSearchRequest
{
  "search_type": "image",
  "image_uri": "local://cache/input.jpg",
  "top_k": 3,
  "debug": false
}
SearchResponse
{
  "decision_status": "matched",
  "latency_ms": 190,
  "results": [
    {
      "rank": 1,
      "landmark_id": "gwanghwamun",
      "name_ko": "광화문",
      "score": 0.44,
      "display_confidence": 44
    }
  ]
}

텍스트 검색 요청 흐름

사용자 자연어 입력
  ↓
TextSearchRequest 생성
  ↓
TextNormalizer가 한글/영문/공백 정리
  ↓
AliasSearch가 직접 문자열/별칭 매칭
  ↓
TextEncoder가 query embedding 생성
  ↓
TextCatalog embedding과 cosine similarity 비교
  ↓
keyword score + embedding score fusion
  ↓
Top-3 landmark 조회
  ↓
UI에 SearchResponse 반환
TextSearchRequest
{
  "search_type": "text",
  "query": "돌담 있는 공원",
  "language_hint": "ko",
  "top_k": 3
}
SearchResponse
{
  "decision_status": "matched",
  "results": [
    {
      "rank": 1,
      "landmark_id": "naksan_park",
      "name_ko": "낙산공원",
      "score": 0.71,
      "display_confidence": 71
    }
  ]
}

상세 페이지 요청 흐름

상세 페이지는 모델을 다시 실행하지 않는다. 검색 결과의 landmark_id를 기준으로 local asset 또는 local DB에서 상세 정보를 조회한다.

Top-3 결과에서 landmark_id 선택
  ↓
LandmarkDetailService.getDetail(landmark_id)
  ↓
LandmarkRepository가 landmark_info, media, aliases 조회
  ↓
상세 페이지 표시
LandmarkDetailResponse
{
  "landmark_id": "cheongwadae",
  "name_ko": "청와대",
  "name_en": "Cheongwadae",
  "description_ko": "...",
  "aliases": ["Blue House", "Presidential Residence"],
  "latitude": 37.5866,
  "longitude": 126.9745,
  "image_path": "assets/images/cheongwadae.jpg"
}

Flutter 패키지 구조 초안

lib/
  app/
    app.dart
    routes.dart
  features/
    search/
      presentation/        # Flutter widgets, screens
      application/         # LandmarkSearchGateway, use cases
      domain/              # request/response/status/value objects
      infrastructure/      # model runtime, repositories, SQLite
    landmark_detail/
      presentation/
      application/
      domain/
      infrastructure/
  core/
    model_runtime/         # ONNX/TFLite wrapper
    embeddings/            # vector load/search
    confidence/            # status policy
    db/                    # SQLite helper
    assets/                # JSON/bin asset loaders

앱 개발에서는 UI가 모델 파일이나 SQLite를 직접 만지지 않게 한다. UI는 Gateway에 request를 보내고 response만 받아 그린다. 이 선을 지키면 Streamlit 데모에서 Flutter 앱으로 옮길 때도 기능 단위 테스트가 쉬워진다.

Sprint 2 개발 순서

  1. MySQL ERD 초안을 확정하고 landmark/text catalog/model version 데이터를 넣는다.
  2. MySQL에서 Flutter asset으로 export하는 스크립트를 만든다.
  3. Flutter에서 asset loader와 repository를 먼저 구현한다.
  4. model runtime wrapper를 붙여 image/text embedding을 생성한다.
  5. Gateway request/response schema를 고정하고 UI는 해당 schema만 사용한다.
  6. 검색 로그는 local SQLite에 저장하고, 필요하면 나중에 서버 업로드를 붙인다.

주의할 점