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 앱 내부에 들어간다.
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_type은 semantic_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에서 조회한다.
이미지 검색 요청 흐름
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 개발 순서
- MySQL ERD 초안을 확정하고 landmark/text catalog/model version 데이터를 넣는다.
- MySQL에서 Flutter asset으로 export하는 스크립트를 만든다.
- Flutter에서 asset loader와 repository를 먼저 구현한다.
- model runtime wrapper를 붙여 image/text embedding을 생성한다.
- Gateway request/response schema를 고정하고 UI는 해당 schema만 사용한다.
- 검색 로그는 local SQLite에 저장하고, 필요하면 나중에 서버 업로드를 붙인다.
주의할 점
- 오프라인 시연이 목표라면 앱 실행 경로가 MySQL 네트워크 연결에 의존하면 안 된다.
- 모델 파일과 대용량 embedding binary를 MySQL BLOB로 넣지 않는다.
- 앱 asset의
model_version,dataset_fingerprint,embedding_dim을 반드시 남긴다. - 검색 결과의 퍼센트는 확률이 아니라 유사도 표시값이므로 response schema에
raw_score와display_confidence를 분리한다. - 사용자 로그를 서버로 올릴 경우 개인정보와 사진 원본 저장 여부를 별도 정책으로 분리한다.