Skip to content

perf: 장소 조회 응답 캐시 도입 (역지오코딩 7일 · 검색 1시간) - #372

Merged
YuGeonHui merged 4 commits into
env/devfrom
refactor/v2-phase-g-place-cache
Sep 24, 2026
Merged

YuGeonHui merged 4 commits into
env/devfrom
refactor/v2-phase-g-place-cache

Conversation

@YuGeonHui

Copy link
Copy Markdown
Collaborator

왜

"내 최신 목적지 검색을 매번 서버통신하는건 리소스 낭비라 생각해"

실측으로 확인됐다. 응답 캐시가 코드베이스 전체에 0건이라, 검색 화면은 진입할 때마다 위치 획득 + GET /locations/rgeo를 다시 돌았고 같은 키워드를 다시 쳐도 매번 서버로 갔다.

무엇을

CoreStorage에 ExpiringCache<Value>를 추가하고, Data 레이어에서 CachingPlaceRepository 데코레이터로 감쌌다. 피처·Domain은 한 줄도 바뀌지 않는다 — 여전히 PlaceRepository 프로토콜만 본다.

캐시 대상을 고른 기준: 틀렸을 때의 피해

대상 틀리면 결정
역지오코딩 주소 표기가 조금 낡는다 7일 — 좌표→주소는 행정구역 개편 수준에서만 바뀐다
장소 검색 새로 생긴 가게가 안 보인다 1시간 — POI는 생기고 없어진다
서비스 지역 판정 되는 지역을 막는다 캐시 안 함 — 호출도 집 주소 저장 시 1회뿐이라 아낄 왕복이 없다

막차 경로(/routes/last-routes)는 이 PR에 없다. 피해가 "막차를 놓친다"라서, 화면 스탬프("HH:mm 기준")와 "알람 등록 근거로는 캐시 금지"를 함께 넣어야 하고 후자는 RegisterAlarmUseCase 시그니처가 바뀐다. 별도 리뷰가 필요한 크기라 분리했다 (조건은 CLAUDE.md에 적어 뒀다).

설계에서 값을 하는 지점 3개

  • 좌표 반올림 (역지오코딩 4자리≈11m, 검색 편향 3자리≈110m). GPS는 가만히 있어도 미터 단위로 떨려서, 원시 좌표를 키로 쓰면 적중률이 사실상 0이 된다. 회귀 테스트로 고정했다.
  • 만료 ±10% 지터. 같은 TTL로 한꺼번에 채워진 항목은 한꺼번에 만료된다. 이 앱의 실제 피크는 막차 시간대(23:30~00:30)에 수천 기기가 동시에 앱을 여는 순간이라, 만료가 몰리면 그 순간 캐시가 통째로 무력해진다.
  • 실패는 캐시하지 않는다. 지하철에서 한 번 실패한 키워드가 지상에 나와도 계속 실패로 답하면 재시도 버튼이 의미를 잃는다.

저장은 FileKeyValueStore(.cache) — 백업 제외. UserDefaults는 첫 접근에 plist 전체를 메모리로 올리므로 이 크기(≈20KB)를 담을 자리가 아니다.

부수

  • RecentSearchRecordDTO → PlaceRecordDTO. 최근 검색과 캐시가 같은 표현을 쓰게 되어 이름이 용도를 좁게 말하고 있었다.
  • CLAUDE.md에 UseCase·상태 채널·저장/캐시 규약을 기록했다 (리팩토링으로 확정된 규칙이 커밋 메시지에만 남아 있었다).

검증

항목 결과
AtchaV2 Debug / Stage / Release 3구성 모두 통과
CoreStorage 44 통과 (+11 신규)
AtchaData 64 통과 (+11 신규)
Domain · AtchaV2 · Home · Search · Settings 112 · 39 · 66 · 26 · 18 전부 통과

🤖 Generated with Claude Code

YuGeonHui and others added 4 commits September 24, 2026 22:54
GetCurrentLocation / ReverseGeocode / SearchPlaces / RecentSearches 를
지우고 호출처가 Domain 포트(LocationService·PlaceRepository·
RecentSearchRepository)를 직접 주입받는다. 넷 다 단일 Repository 메서드를
한 줄 위임하던 계층이라, 같은 프로토콜에 이름만 하나 더 입히고 있었다.

Presentation → Domain 경계는 그대로다 — 피처가 받는 건 여전히 Domain
프로토콜이고 구체 Data 타입은 AppDIContainer만 본다.

부수 정리:
- 위임 여부만 검증하던 Domain 테스트 5개 삭제 (Observe/ObserveChange/
  RequestAlarmSync/GetLastRouteDetail/ReverseGeocode). 앞 커밋에서 UseCase를
  지울 때 테스트 파일이 남아 DomainTests가 컴파일되지 않고 있었다.
- Example/Tests 스텁이 좁은 UseCase 대신 PlaceRepository 전체를 채택하게 되어
  쓰지 않는 메서드에 기본 구현을 달았다(의도된 Tests/Example 중복 규약).
- RecentSearchesUseCase.fetch() → RecentSearchRepository.recentSearches()로
  호출명이 바뀐다.

검증: Debug/Stage/Release 3구성 빌드 + Domain 112 · AtchaData 53 · AtchaV2 39
· HomeFeature 66 · SearchFeature 26 · SettingsFeature 18 전부 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
검색 화면은 진입할 때마다 위치 획득 + GET /locations/rgeo를 다시 돌았고,
같은 키워드를 다시 쳐도 매번 서버로 갔다. 코드베이스 전체에 응답 캐시가
0건이었다.

CoreStorage에 ExpiringCache<Value>를 추가하고, Data 레이어에
CachingPlaceRepository 데코레이터로 감쌌다. 피처는 여전히 Domain
프로토콜만 보므로 호출처는 한 줄도 바뀌지 않는다.

캐시 대상은 "틀렸을 때의 피해"로 골랐다:
- 역지오코딩 7일 — 좌표→주소는 행정구역 개편 수준에서만 바뀐다
- 장소 검색 1시간 — POI는 생기고 없어진다
- 서비스 지역 판정 캐시 안 함 — 틀리면 되는 지역을 막는다. 호출도
  집 주소 저장 시 1회뿐이라 아낄 왕복이 없다

좌표는 키로 쓰기 전에 반올림한다(역지오코딩 4자리≈11m, 검색 편향
3자리≈110m). GPS는 가만히 있어도 미터 단위로 떨려서, 원시 좌표를 키로 쓰면
적중률이 사실상 0이 된다.

만료에는 ±10% 지터를 넣는다. 같은 TTL로 한꺼번에 채워진 항목은 한꺼번에
만료되는데, 이 앱의 실제 피크는 막차 시간대에 수천 기기가 동시에 앱을 여는
순간이라 만료가 몰리면 그 순간 요청이 서버에 몰린다.

실패는 캐시하지 않는다 — 지하철에서 한 번 실패한 키워드가 지상에 나와도
계속 실패로 답하면 재시도 버튼이 의미를 잃는다.

저장은 FileKeyValueStore(.cache) — 백업에서 제외된다. UserDefaults는 첫
접근에 plist 전체를 올리므로 이 크기(≈20KB)를 담을 자리가 아니다.

부수: RecentSearchRecordDTO → PlaceRecordDTO. 최근 검색과 캐시가 같은
표현을 쓰게 되어 이름이 용도를 좁게 말하고 있었다.

검증: 3구성 빌드 + CoreStorage 44(+11) · AtchaData 64(+11) 등 전 스킴 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
리팩토링으로 확정된 규칙이 커밋 메시지에만 남아 있었다. 다음 작업자가
같은 판단을 다시 내리지 않도록 규약으로 적는다.

- UseCase는 기본이 아니다(포트 2개 조합 또는 비즈니스 규칙일 때만)
- 화면 상태 채널은 정확히 2개, != oldValue 게이트 유지
- 영속·캐시는 CoreStorage의 actor 3종으로만, 매체는 값 크기로 가름
- 응답 캐시는 Data 데코레이터로, 대상은 "틀렸을 때의 피해"로 선정
- 막차 경로 캐시를 왜 아직 안 했는지와 도입 조건 2가지

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AlarmSessionLifecycleService.isAcknowledged는 쓰기 2곳, 읽기 0곳이었다.
"스냅샷 영속화의 인메모리 미러"였는데 정본(AlarmSessionStore)이 세션 수명을
들게 되면서 미러를 볼 이유가 사라졌다. 남겨 두면 두 곳이 어긋날 수 있다는
인상만 준다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@YuGeonHui
YuGeonHui merged commit 4547b53 into env/dev Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant