Domain 모델이 완성된 뒤 실제 동작하는 코드를 쌓았습니다.
MediaStore로 사진을 읽고, 클러스터링하고, UI로 표현하고,
기존 웹 홈 화면에 새 메뉴를 심는 것까지 — 첫 기능이 완성됐습니다.
Domain → Data → Presentation → 진입점. Clean Architecture의 의존성 방향을 따라 아래서부터 위로 구현했습니다.
서버가 클라우드에서 하던 일을 이제 기기 안에서 합니다. MediaStore로 전체 사진을 읽고, 알고리즘으로 "이벤트 묶음"을 만들어냅니다.
ACCESS_MEDIA_LOCATION 권한을 별도 요청합니다._similarity_filtering을 Kotlin으로 재현한 것입니다.// 18시간 기준 시간 클러스터링 val SEPARATION_MS = TimeUnit.HOURS.toMillis(18) val clusters = mutableListOf<MutableList<PhotoMetadata>>() var current = mutableListOf(sortedPhotos.first()) for (photo in sortedPhotos.drop(1)) { val gap = photo.dateTaken - current.last().dateTaken if (gap > SEPARATION_MS) { clusters.add(current) current = mutableListOf() } current.add(photo) } clusters.add(current) // MIN/MAX 필터링 후 PhotoStory 변환 clusters .filter { it.size in MIN_PHOTOS..MAX_PHOTOS } .mapIndexed { rank, photos -> PhotoStory(rank = rank + 1, photos = photos, ...) }
기존 앱의 아키텍처 패턴(Orbit MVI)을 그대로 적용했습니다. 상태(State)와 부작용(SideEffect)을 명확히 분리해 예측 가능한 UI를 만들었습니다.
앱 홈 화면은 WebView로 동작하는 웹앱입니다. 네이티브 레이어를 오버레이하는 대신, JavaScript를 웹뷰에 주입해 DOM 안에 "나의스토리" 항목을 직접 삽입했습니다.
HybridApp.openMyStory()를 호출하면, Bridge → Presenter → Activity → PhotoStoryActivity 순서로 전달됩니다.intent.setClassName(getPackageName(), "com.snaps.mobile.kr.PhotoStoryActivity") — 문자열로 클래스를 참조해 컴파일 의존성 없이 Activity를 시작합니다.
실제 기기에서 테스트하자 2개의 버그가 발견됐습니다. 스크린샷을 찍어 Claude에게 전달했고, 원인 분석과 수정을 함께 진행했습니다.
siblings.length >= 2로 그리드 컨테이너를 탐색하다, 포토북 아이템 내부 자식 엘리먼트에서 멈췄습니다. 그리드 자체가 아닌 내부 요소를 복제해 엉뚱한 위치에 삽입됐습니다.
parentElement.children.length >= 5로 변경했습니다. 포토북 앞 올바른 위치에 삽입됩니다.
onPageFinished는 완전한 페이지 로드 시에만 호출되어, SPA 뒤로가기 후엔 JS가 재주입되지 않았습니다.
MutationObserver로 document.body 변경을 감지합니다. snaps-my-story-item ID가 없어지면 자동으로 재주입합니다. SPA 내비게이션에도 항상 메뉴가 표시됩니다.
// SPA 대응: DOM 변경 감지 → 자동 재주입 if (!window.__myStoryObserver) { window.__myStoryObserver = new MutationObserver(function() { if (!document.getElementById('snaps-my-story-item')) { inject(); // 없어지면 다시 심기 } }); window.__myStoryObserver.observe(document.body, { childList: true, subtree: true }); }