PhotoGram의 Place V1 구현이 끝났다는 보고를 처음 받았을 때 상태는 꽤 좋아 보였다.
backend와 frontend 구현이 모두 들어갔고 테스트도 통과했고, 보고서에는 다음과 비슷한 결론이 있었다.
STATUS: PASS
CONTRACT DEVIATIONS: NONE
그대로 merge해도 되는 것처럼 보였다.
하지만 실제 source review를 한 번 더 진행하자 결론이 달라졌다.
PRODUCT / ARCHITECTURE
= CLOSED
IMPLEMENTATION
= SUBSTANTIALLY PRESENT
SOURCE REVIEW
= FAIL / REPAIR REQUIRED
MERGE READY
= NO
기능이 없었던 것은 아니다.
오히려 대부분 구현돼 있었다.
문제는 기능 사이의 경계가 계약과 다른 방식으로 연결된 것이었다.
P0만 다섯 개가 나왔다.
P0-1: 좌표를 저장하지 않아도 로그로 새어 나갈 수 있다
Place에는 EXIF GPS나 사용자가 넘긴 coordinate로 외부 지도 provider를 조회하는 흐름이 있다.
privacy contract에서는 exact capture coordinate를 durable application state나 log에 남기지 않는 방향을 이미 잡고 있었다.
DB schema만 보면 잘 지켜진 것처럼 보였다.
그런데 provider client의 error logging을 보니 request URI 전체를 출력하고 있었다.
좌표는 query parameter에 들어간다.
즉 provider error가 발생하면:
request URI
→ application log
→ exact longitude / latitude included
가 가능했다.
이 문제는 "GPS column을 만들지 않았다"는 확인으로는 잡을 수 없다.
NO GPS COLUMN
!=
NO GPS LEAK
coordinate-bearing provider call에서는 URI 자체를 log하지 않는 것이 맞다.
필요한 operational 정보는 provider, operation, HTTP status, normalized error code, latency 정도면 충분하다.
애초에 필요하지 않은 민감값은 log path에 넣지 않는 편이 낫다.
P0-2: DTO 하나에 Place를 붙였더니 list 전체가 N+1이 됐다
Place 정보를 Photo 응답에 추가하는 구현도 겉으로는 간단했다.
Photo
→ map response
→ Place snapshot lookup
→ Place projection lookup
단일 Photo detail에서는 큰 문제가 아니다.
하지만 같은 mapper가 feed, search, profile, saved, following 같은 list projection에서도 호출됐다.
그러면 Photo 하나당 최대 두 번의 추가 Place read가 발생할 수 있다.
N photos
→ N snapshot queries
→ N projection queries
기능은 정상적으로 보인다.
테스트 데이터가 적으면 속도 문제도 잘 드러나지 않는다.
하지만 projection layer에 per-item repository access를 숨기는 순간 read path 비용은 데이터 개수에 따라 선형으로 커진다.
필요한 repair는 architecture rewrite가 아니었다.
post ids collect
→ batch load Place presentation state
→ map in memory
로 바꾸면 된다.
문제의 핵심은 Place 자체가 아니라 mapper가 I/O를 소유하게 된 경계였다.
P0-3: photoCount가 visibility를 무시하면 privacy leak이 된다
Place detail에는 연결된 사진 수가 필요하다.
처음 구현은 단순했다.
Place membership rows
→ count
→ photoCount
하지만 실제 Photo list는 viewer visibility를 적용한다.
private content, follow 관계, block 관계 같은 정책에 따라 사용자가 볼 수 있는 사진 수가 다르다.
따라서:
raw membership count
!= viewer-visible photo count
다.
예를 들어 사용자가 실제로 볼 수 있는 Photo가 0장인데 UI에 photoCount = 3이 표시되면, 내용은 보이지 않더라도 숨겨진 association이 존재한다는 사실을 알려준다.
작은 숫자 하나지만 authorization projection의 일부다.
viewer-facing count는 실제 Photo query와 동일한 visibility semantics를 사용해야 한다.
P0-4: 화면은 있었지만 Root search contract가 없었다
Place V1의 제품 계약에는 Places Root에서 장소를 검색하고, 선택한 장소를 기준으로 spatial context와 Photo discovery로 이어지는 흐름이 있었다.
query
→ Place results
→ select Place
→ spatial context
→ Photo discovery
그런데 frontend source를 보면 Root page에는 explore API는 있었지만 실제 Place search interaction이 없었다.
검색 backend가 존재한다는 사실과 사용자가 그 기능을 사용할 수 있다는 것은 다르다.
API EXISTS
!=
PRODUCT FLOW EXISTS
특히 search empty, region empty, selected Place zero-photo는 각각 다른 상태다.
이걸 하나의 "결과 없음"으로 처리하면 product contract가 사라진다.
P0-5: 두 소스 검색이 실제로는 provider 하나에 종속돼 있었다
backend search의 원래 의도는 두 소스를 독립적으로 검색하는 것이었다.
A. existing PhotoGram PlaceProjection
B. external provider Place search
그리고 두 결과를 normalize/dedupe해서 보여준다.
이 구조라면 provider가 일시적으로 실패해도 이미 PhotoGram에 존재하는 Place는 검색할 수 있다.
하지만 구현은 실제로:
provider search
→ provider candidates
→ each candidate has local projection?
순서였다.
즉 local PlaceProjection이 provider 결과에 종속됐다.
그 결과:
provider does not return an existing Place
→ local Place disappears
provider outage
→ local Place search also disappears
가 된다.
두 source가 코드에 모두 등장한다고 해서 two-source architecture가 구현된 것은 아니었다.
TWO DATA SOURCES IN CODE
!=
INDEPENDENT TWO-SOURCE SEARCH
local projection search와 provider search를 독립적으로 수행하고 provider + externalPlaceId 기준으로 dedupe해야 한다.
P1에서는 transition과 transaction 문제가 더 나왔다
P0 외에도 source review에서 여러 high-priority 문제가 나왔다.
대표적인 것만 보면:
map initialization vs API response race
stale initial state persisted on unmount
cached data bounds vs actual map viewport mismatch
ordering / seek cursor mismatch
external provider call inside DB read transaction
search on every keystroke
older async response overwriting newer result
category truncation before global distance ranking
각각 작은 구현 디테일처럼 보인다.
하지만 공통점이 있었다.
정적인 코드 조각이 아니라 시간과 경계가 바뀔 때 깨지는 문제라는 점이다.
예를 들어 manual search에서 debounce만 넣는다고 끝나지 않는다.
request A starts
request B starts
request B returns
request A returns late
라면 A가 B의 결과를 덮어쓰지 못하게 generation이나 abort semantics가 필요하다.
map도 마찬가지다.
API data ready
+
map adapter not ready
인 순간 marker update를 한 번 놓치면, 나중에 map이 준비돼도 marker가 영원히 안 나타날 수 있다.
"테스트가 있다"와 "그 동작을 테스트했다"도 달랐다
frontend Place 테스트 일부는 TSX source text를 읽고 특정 문자열이 존재하는지 확인하는 방식이었다.
이런 테스트는 component에 symbol이나 handler가 존재하는지는 확인할 수 있다.
하지만 다음은 증명하지 못한다.
map/API readiness synchronization
latest state restoration
stale response suppression
search result selection behavior
provider-only result behavior
SOURCE STRING ASSERTION
!=
RUNTIME BEHAVIOR TEST
backend도 마찬가지였다.
visibility-scoped count나 query-count, concurrent first-use race는 실제 repository/query/transaction semantics를 확인해야 한다.
테스트 개수보다 중요한 것은 테스트가 어떤 failure mode를 증명하는지였다.
중요한 건 설계를 다시 연 것이 아니었다
이 리뷰에서 의도적으로 하지 않은 것이 있다.
P0가 많이 나왔다고 해서 Place 제품 설계를 다시 시작하지 않았다.
DESIGN REOPEN
= NO
SOURCE REPAIR
= YES
Place가 independent discovery axis라는 제품 방향, privacy consent hierarchy, map-first가 아니라 Photo discovery 중심이라는 UI 방향은 그대로 유지했다.
수정 대상은 그 계약을 구현한 코드였다.
이 구분이 없으면 code review에서 defect 하나를 발견할 때마다 architecture가 흔들린다.
반대로 architecture를 이미 확정했다는 이유로 source defect를 넘기면 실제 제품이 설계와 달라진다.
완료 보고서보다 소스가 더 권위 있다는 뜻도 아니다
여기서 얻은 결론을 "executor report는 믿지 말고 항상 모든 코드를 처음부터 읽어라"로 만들고 싶지는 않다.
그건 비용이 너무 크다.
더 정확한 관계는 이렇다.
IMPLEMENTATION REPORT
= evidence
TEST RESULT
= evidence
SOURCE REVIEW
= evidence
PRODUCT CONTRACT
= comparison baseline
어느 하나가 다른 모든 것을 자동으로 대체하지 않는다.
이번 Place V1에서는 completion report와 source가 충돌했고, source review가 구체적인 반례를 보여줬기 때문에 closure를 거부했다.
그 뒤 repair 범위도 발견된 defect에만 제한했다.
기능 완성도보다 경계 완성도가 더 늦게 드러난다
Place V1의 대부분 기능은 실제로 존재했다.
그래서 첫 인상은 "거의 끝났다"가 맞았다.
하지만 실제 제품 품질을 결정한 결함은 다음 같은 경계에 있었다.
coordinate -> log
Photo -> Place query
membership -> viewer count
local search -> provider dependency
API result -> UI timing
network call -> DB transaction
기능 하나 안에서 찾기보다 두 책임이 만나는 선에서 나온 문제들이다.
그래서 이 리뷰 이후 Place V1의 상태를 이렇게 보는 편이 가장 정확했다.
FEATURES
= substantially present
BOUNDARIES
= still need repair
PRODUCT DIRECTION
= remains closed
코드가 많아질수록 "무엇이 구현됐는가"만 보는 리뷰보다 "어떤 의미가 어디에서 다른 의미로 바뀌는가"를 보는 리뷰가 더 중요해진다.
Place V1에서 P0 다섯 개를 만든 것도 결국 기능 부족보다 그 경계들이었다.