Chapter 05

코드를 쌓다
— Data, Presentation,
그리고 홈 화면에 심다

Domain 모델이 완성된 뒤 실제 동작하는 코드를 쌓았습니다.
MediaStore로 사진을 읽고, 클러스터링하고, UI로 표현하고,
기존 웹 홈 화면에 새 메뉴를 심는 것까지 — 첫 기능이 완성됐습니다.

이번 챕터 진행 흐름

4개 레이어를 순서대로 쌓다

Domain → Data → Presentation → 진입점. Clean Architecture의 의존성 방향을 따라 아래서부터 위로 구현했습니다.

📦
✓ 완료
Data 레이어 — MediaStore + 클러스터링
Android MediaStore.Images.Media를 쿼리해 기기 사진을 읽고, 30초 버스트샷 제거 → 18시간 간격 시간 클러스터링 → MIN 21장/MAX 800장 필터링까지 알고리즘을 Kotlin으로 완전 구현했습니다. Geocoder로 대표 GPS를 역지오코딩해 지역명을 붙입니다.
MediaStore RxJava3 Single Geocoder 클러스터링 알고리즘
🎨
✓ 완료
Presentation 레이어 — Orbit MVI
ViewState + SideEffect Contract 정의 → HiltViewModel → Fragment(권한 요청 포함) → ListAdapter + Glide. 기존 앱 아키텍처 패턴(Orbit MVI)을 그대로 따라 새 화면을 구현했습니다.
Orbit MVI Hilt DI Fragment + RecyclerView Glide
🔌
✓ 완료
홈 화면 진입점 — WebView JS 주입
앱 홈 화면은 WebView 기반 웹앱입니다. 네이티브 UI를 추가하는 대신, JavaScript를 WebView에 주입해 웹 DOM 안에 새 메뉴 아이콘을 심었습니다. 포토북 그리드 앞에 "나의스토리" 항목이 자연스럽게 배치됩니다.
JS 주입 MutationObserver JavascriptInterface Bridge
🐛
✓ 완료
버그 2개 발견 및 수정
실기기 테스트에서 버그가 2개 발견됐습니다. DOM 탐색 로직 오류로 위치가 틀렸고, SPA 뒤로가기 시 메뉴가 사라지는 문제가 있었습니다. 두 가지 모두 수정 완료.
DOM 탐색 오류 SPA 뒤로가기 미반영 MutationObserver로 해결
Data 레이어 구현

기기 사진을 읽어 이벤트로 묶다

서버가 클라우드에서 하던 일을 이제 기기 안에서 합니다. MediaStore로 전체 사진을 읽고, 알고리즘으로 "이벤트 묶음"을 만들어냅니다.

1
MediaStore 쿼리 ContentResolver.query()
DATE_TAKEN, LATITUDE, LONGITUDE, WIDTH, HEIGHT를 기준으로 전체 사진 메타데이터를 읽어옵니다. Android 10+ 대응을 위해 ACCESS_MEDIA_LOCATION 권한을 별도 요청합니다.
2
버스트샷 제거 30초 기준
연속으로 촬영된 사진(30초 이내 연속 촬영)은 그룹당 1장만 유지합니다. 서버 알고리즘의 _similarity_filtering을 Kotlin으로 재현한 것입니다.
3
시간 클러스터링 18시간 간격
DATE_TAKEN 기준으로 정렬 후, 이전 사진과의 간격이 18시간을 초과하면 새로운 스토리로 분리합니다. 여행과 일상 이벤트를 자연스럽게 구분하는 핵심 파라미터입니다.
4
장수 필터링 MIN 21 · MAX 800
21장 미만 클러스터는 제외(짧은 스냅은 포토북으로 부적합), 800장 초과는 잘라냅니다. 서버 기준(uniphoto)과 동일한 최소 장수를 적용했습니다.
5
역지오코딩 Geocoder · 클러스터 대표 1회
클러스터별 대표 GPS(중앙값) 1개만 Geocoder에 전달해 지역명을 획득합니다. 사진마다 호출하지 않아 성능 영향을 최소화합니다. "제주도", "도쿄" 같은 스토리 타이틀을 만들어냅니다.
Kotlin PhotoStoryRepositoryImpl.kt (핵심 로직)
// 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, ...)
    }
Presentation 레이어 구현

Orbit MVI — 상태를 흐르게 하다

기존 앱의 아키텍처 패턴(Orbit MVI)을 그대로 적용했습니다. 상태(State)와 부작용(SideEffect)을 명확히 분리해 예측 가능한 UI를 만들었습니다.

📋
PhotoStoryContract.kt
ViewState와 SideEffect를 sealed interface로 정의합니다. UI가 가질 수 있는 모든 상태를 한 파일에서 확인할 수 있습니다.
Loading | Success(stories) | Empty | PermissionDenied | Error ───────────────────────── SideEffect: NavigateToBookMaking(story) RequestPermission
⚙️
PhotoStoryViewModel.kt
ContainerHost 구현체. 권한 허용 → UseCase 호출 → 상태 업데이트. UI는 ViewModel만 바라봅니다.
@HiltViewModel fun onPermissionGranted() = intent { reduce { Loading } val stories = getPhotoStories .invoke(Unit).await() reduce { Success(stories) } }
📱
PhotoStoryFragment.kt
권한 요청 처리 + Orbit observe. READ_MEDIA_IMAGES(API 33+) / READ_EXTERNAL_STORAGE 분기 처리를 포함합니다.
vm.observe(viewLifecycleOwner, state = ::render, sideEffect = ::handleSideEffect ) // render: Loading/Success/Empty 분기 // sideEffect: 포토북 편집 화면으로 이동
패턴 일관성의 이점
기존 앱이 이미 Orbit MVI를 쓰고 있었습니다. Claude가 기존 패턴을 분석하고 동일한 방식으로 새 코드를 작성했기 때문에 코드 리뷰 부담 없이 기존 팀 코드와 자연스럽게 합쳐집니다. AI가 스타일 가이드를 자동으로 따른 것입니다.
홈 화면 진입점 추가

웹 화면 안에 네이티브 메뉴를 심다

앱 홈 화면은 WebView로 동작하는 웹앱입니다. 네이티브 레이어를 오버레이하는 대신, JavaScript를 웹뷰에 주입해 DOM 안에 "나의스토리" 항목을 직접 삽입했습니다.

시도 1 — 실패
네이티브 UI 오버레이
XML 레이아웃에 네이티브 뷰를 추가해 WebView 위에 얹었습니다. 화면 상단에 고정되어 웹 콘텐츠 안 자연스러운 위치(포토북 그리드 앞)에 배치가 불가능했습니다.
시도 2 — 성공
JavaScript DOM 주입
onPageFinished 시 JS를 주입해 기존 포토북 DOM 엘리먼트를 복제, 텍스트와 클릭 핸들러를 교체해 그리드 앞에 삽입했습니다. 웹 디자인과 완벽히 어울립니다.
🔗
연결 방식
JavascriptInterface Bridge
웹에서 HybridApp.openMyStory()를 호출하면, Bridge → Presenter → Activity → PhotoStoryActivity 순서로 전달됩니다.
Web → Android 호출 흐름:
01
🌐
JS 주입
onPageFinished 시 evaluateJavascript() 호출
02
👆
사용자 탭
나의스토리 아이콘 클릭 → HybridApp.openMyStory()
03
🔌
JavascriptInterface
RenewalHomeWebViewInterface → onOpenMyStory()
04
📋
Presenter → View
startPhotoStoryActivity() 실행
05
🎉
PhotoStoryActivity
스토리 목록 화면 시작
⚠ 모듈 경계 문제
snapsmobile 모듈은 app 모듈을 직접 import할 수 없습니다. Android 멀티 모듈 의존성 방향 때문입니다. 해결책: intent.setClassName(getPackageName(), "com.snaps.mobile.kr.PhotoStoryActivity") — 문자열로 클래스를 참조해 컴파일 의존성 없이 Activity를 시작합니다.
실기기 테스트 — 버그 발견 및 수정

코드가 동작해도 눈으로 확인해야 한다

실제 기기에서 테스트하자 2개의 버그가 발견됐습니다. 스크린샷을 찍어 Claude에게 전달했고, 원인 분석과 수정을 함께 진행했습니다.

Bug 01 — DOM 탐색 오류
아이콘이 포토북 밑에 작은 글씨로 생성됨
JS가 siblings.length >= 2로 그리드 컨테이너를 탐색하다, 포토북 아이템 내부 자식 엘리먼트에서 멈췄습니다. 그리드 자체가 아닌 내부 요소를 복제해 엉뚱한 위치에 삽입됐습니다.
수정 — 조건 변경
children.length >= 5로 그리드 컨테이너 정확히 특정
포토북, 배경지우개 등 5개 이상 자식을 가진 컨테이너가 그리드 자체임을 이용해 조건을 parentElement.children.length >= 5로 변경했습니다. 포토북 앞 올바른 위치에 삽입됩니다.
Bug 02 — SPA 뒤로가기 미반영
포토북 진입 후 뒤로가기 시 메뉴가 사라짐
앱 홈 화면은 SPA(Single Page Application)입니다. 내부 페이지 이동은 URL 변경 없이 DOM만 교체됩니다. onPageFinished는 완전한 페이지 로드 시에만 호출되어, SPA 뒤로가기 후엔 JS가 재주입되지 않았습니다.
수정 — MutationObserver
DOM 변경을 감지해 자동 재주입
JS 주입 시 MutationObserverdocument.body 변경을 감지합니다. snaps-my-story-item ID가 없어지면 자동으로 재주입합니다. SPA 내비게이션에도 항상 메뉴가 표시됩니다.
JavaScript (주입) RenewalHomeActivity.java — injectMyStoryItem()
// 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
  });
}
이번 챕터 수정 파일

총 11개 파일 — 신규 8, 수정 3

🆕
PhotoStoryContract.kt
presentation-editor 모듈 신규 생성. ViewState + SideEffect 정의.
sealed interface PhotoStoryViewState PhotoStorySideEffect
🆕
PhotoStoryViewModel.kt
Orbit MVI HiltViewModel. UseCase를 RxJava3 .await()로 호출.
@HiltViewModel ContainerHost 구현 Single → coroutine 브릿지
🆕
PhotoStoryFragment.kt
권한 요청 + Orbit observe + OnStorySelectedListener 인터페이스.
READ_MEDIA_IMAGES (API33+) READ_EXTERNAL_STORAGE 분기 ACCESS_MEDIA_LOCATION 추가
🆕
PhotoStoryAdapter.kt
ListAdapter + DiffUtil. Glide로 커버 이미지 로드.
rank / location dateRange / photoCount Glide.load(coverPhoto.uri)
🆕
fragment_photo_story.xml
item_photo_story.xml
RecyclerView 레이아웃. ivCover(100×100) + 텍스트 4종.
ConstraintLayout RecyclerView + ProgressBar + tvEmpty
🆕
PhotoStoryActivity.kt
app 모듈에 신규 생성. Fragment 호스팅. AndroidManifest 등록.
@AndroidEntryPoint OnStorySelectedListener 구현 setClassName으로 참조
✏️
RenewalHomeActivity.java
수정. injectMyStoryItem() 추가 + startPhotoStoryActivity() 구현.
evaluateJavascript() MutationObserver 주입 setClassName Intent
✏️
RenewalHomeContract.java
RenewalHomePresenter.java
RenewalHomeWebViewInterface.java
수정. onOpenMyStory() 인터페이스 추가 + @JavascriptInterface openMyStory() 추가.
Contract.View: startPhotoStoryActivity() Contract.OnCalledJs: onOpenMyStory() WebViewInterface: @JavascriptInterface
이 단계의 AI 활용 포인트

스크린샷 한 장으로 버그를 고쳤다

📸
비주얼 디버깅
스크린샷 → 버그 원인 즉시 진단
잘못된 위치에 아이콘이 생긴 스크린샷을 Claude에게 전달했습니다. JS DOM 탐색 조건이 어디서 멈추는지 즉시 분석하고 수정 코드를 제안했습니다.
🏗️
레이어 설계
모듈 경계 문제를 패턴으로 해결
멀티 모듈 구조에서 직접 import가 안 되는 상황을 setClassName 패턴으로 해결했습니다. Android 모바일 지식 없이도 올바른 해결책을 찾았습니다.
🔄
SPA 이해
웹 개념을 Android 코드로 연결
onPageFinished가 SPA에서 왜 호출 안 되는지, MutationObserver가 왜 올바른 해결책인지 — 웹/모바일을 가로지르는 개념을 Claude가 연결해줬습니다.
★ 이 단계의 핵심
코드를 썼고, 실행됐고, 버그를 잡았다.
기능 하나가 처음으로 완성됐다.
Domain → Data → Presentation → 진입점. 레이어를 하나씩 쌓아 올렸습니다. 스크린샷으로 버그를 잡고, MutationObserver로 SPA를 대응하고, setClassName으로 모듈 경계를 넘었습니다. 모바일 전문가 없이도 AI와 함께라면 기능을 완성할 수 있다는 걸 증명했습니다.
🎉
이 단계의 산출물
Phase 1B 완료 — 나의스토리 기능 첫 동작 확인
MediaStore 클러스터링 → Orbit MVI 스토리 목록 화면 → 홈 화면 진입점(JS 주입 + JavascriptInterface Bridge)까지 완성. 다음은 Phase 2: 스토리 선택 후 포토북 편집 화면(SmartRecommendBookMakingActivity) 연결 + 서버 API 연동.
✓ 클러스터링 완성 ✓ 스토리 UI 완성 ✓ 홈 화면 진입점 완성 → Phase 2 예정