일기 slecs

핫로우를 잡고, 같은 파일을 세 번 고쳤다

목차

오늘 커밋 로그를 위에서부터 훑으면 이상한 패턴이 보인다. android/app/build.gradle.kts 가 네 개의 커밋에 등장하고, AndroidManifest.xml 도 마찬가지다. pubspec.yaml 역시. 처음엔 실수인가 싶었는데 의도된 것이다. CAS.ai 미디에이션 전환을 세 개의 앱에 순차로 밀어 넣은 결과다. 같은 작업을, 앱 하나씩, 각자의 코드 구조에 맞춰 조심스럽게 반복한 것. 커밋이 19개인 날인데 그 중 CAS 관련만 대충 일곱 개다.

같은 길을 세 번 — CAS 전환의 반복

CAS.ai 는 광고 미디에이션 플랫폼이다. 안드로이드 앱에서 AdMob 단독 대신 여러 광고 네트워크를 묶어서 최적 eCPM 을 자동으로 뽑아주는 방식이다. 전환 자체는 이론적으로 깔끔하다. Gradle 플러그인 추가하고, 안드 전용 AdMob App ID 를 교체하고, 다트 코드에서 플랫폼 분기로 casId 를 iOS 와 안드로이드 각각 넣어주면 끝.

근데 세 앱을 차례로 건드리면서 깨달은 게 있다. "이론적으로 간단하다"는 말은 사실 "첫 번째 앱에서 반나절이 걸린다"는 뜻이기도 하다.

첫 번째 앱은 광고 초기화 로직이 ad_config.dart 에 있었다. 두 번째 앱은 ads_setup.dart. 세 번째는 main.dart 에서 직접 잡고 있었다. 세 앱 모두 Flutter 기반인데도 기존 구조가 제각각이다. 공통 패키지 hedvion_ads 0.4.0 이 있긴 하지만, 각 앱이 그걸 어떻게 붙잡고 있냐가 달랐다. 억지로 통일하려면 리팩터링 범위가 오늘 일정을 훌쩍 넘기기 때문에, 각 앱의 구조를 존중하고 거기에 맞춰 casId 분기를 끼워 넣는 방식으로 갔다. 기술 부채를 더 쌓은 건지, 현실적인 선택을 한 건지는 솔직히 아직 판단이 안 선다.

Gradle 플러그인은 4.7.4, Optimal 전략. CAS SDK 의 미디에이션 전략에는 Optimal, Balanced 같은 옵션이 있는데 Optimal 은 eCPM 을 우선시한다. 이 선택이 실제로 맞는지는 며칠치 데이터를 봐야 알 수 있다. 오늘은 일단 플러그인이 정상 동작하고 기본 광고 로딩이 되는 것까지 확인했다.

pubspec.lock 커밋이 따로 있는 이유가 있다. hedvion_ads 0.4.0 반영 lock 파일을 명시적으로 커밋해서 "이 시점 기준으로 빌드했다"는 트레이싱 포인트를 남기는 것이다. lock 파일을 .gitignore 에 넣는 팀도 있는데, 모바일 앱에서는 재현 가능성이 중요하다. 스토어 제출 이후 버그가 나왔을 때 그 빌드를 정확히 재현할 수 있어야 하고, "그때 어떤 버전이었지?"를 추적하는 데 lock 파일이 유일한 근거가 될 때가 있다.

마지막 앱은 vc16 으로 Play 프로덕션 제출까지 갔다. versionName 1.2.0 유지, 버전코드만 증분하는 방식. 이를 위해 play_bump.py 스크립트를 정리했다. 버전코드 올리고, 빌드하고, 제출하는 과정을 반자동화한 것이다. 세 앱을 하루에 전환하는 상황에서 이 스크립트가 없었다면 저녁 내내 버전 숫자만 세고 있게 됐을 것이다. 두 번째 앱부터는 배포 절차가 확연히 빨라졌다.

빌드하다가 OOM 이 한 번 났다. Flutter 안드로이드 빌드는 Gradle 데몬이 메모리를 상당히 먹는데, 앱을 연속으로 빌드하면 이전 Gradle 데몬이 남아서 메모리가 쌓인다. 이걸 처음 만나면 왜 갑자기 빌드가 죽는지 모른다. 에러 메시지가 명확하지 않아서 처음엔 코드 문제인가 싶었다. docs/mobile-app-launch.md 에 CAS 전환 표준 절차를 처음으로 등재한 게 바로 그 때문이다. 커밋 메시지에 "3앱 실전 확립"이라고 썼는데, 그 "확립" 뒤에는 "다음번엔 이 삽질을 반복하지 말자"는 의지가 있다. gradle 플러그인 적용 순서, 안드 AdMob App ID 가 iOS 것과 다른 것, 빌드 OOM 회피법, 검증 3종 체크리스트. 이게 문서에 없으면 다음 앱 전환 때 또 두 시간을 쓴다.

세 번째 앱을 마칠 때쯤엔 집중력이 눈에 띄게 떨어졌다. 같은 작업을 세 번 하면 세 번째에서 "어, 이거 아까 했는데"라고 방심하는 순간이 온다. 그게 실수가 생기는 타이밍이다. 오늘은 각 앱에서 빌드 성공과 광고 로딩 확인까지 끝낸 뒤에 다음 앱으로 넘어가는 방식으로 통제했다. 완벽하진 않지만, 적어도 앱 사이에서 상태가 섞이는 건 방지할 수 있다.

핫로우가 페이지를 붙잡고 있었다

CAS 작업이 오전에 집중됐다면 오후는 성능 수술이었다. 오늘 perf 접두사 커밋이 세 개다.

perf(visitor) 부터. 방문 기록을 비동기 큐로 분리한 작업이다. 커밋 메시지가 솔직하다: "통계 핫로우 줄서기가 페이지 응답을 붙잡던 것 해소." 사용자가 페이지에 접근할 때마다 방문 기록을 INSERT 하는 로직이 있는데, 이게 동기로 묶여 있었다. INSERT 가 느릴 때 그 지연이 HTTP 응답 시간에 그대로 더해졌다.

왜 INSERT 가 느렸냐. 통계 테이블 핫로우 때문이었다. 핫로우란 동일한 행에 UPDATE 가 집중될 때 InnoDB row-level lock 이 대기 행렬을 만드는 현상이다. 방문자가 몰리는 시간대에 같은 통계 행을 수십 개의 요청이 동시에 건드리면 잠금 대기가 쌓인다. 그 대기 시간이 사용자 눈에는 "페이지가 느리다"로 보인다. 슬로우 쿼리 로그엔 안 잡힌다. 쿼리 자체가 느린 게 아니라 잠금 대기가 느린 것이니까. 이 차이를 파악하는 데 시간이 좀 걸렸다.

VisitorWriteQueue.java 가 그 해결책으로 나온 클래스다. 요청은 큐에 쌓이고 별도 스레드가 배치로 처리한다. 페이지 응답은 큐에 넣는 순간 끝난다. 통계 테이블에 실제로 쓰이는 건 그다음의 일이다. 이 구조에서는 통계 업데이트가 좀 늦어져도 페이지 응답은 바로 나간다. 방문 통계가 실시간이 아닌 준실시간이 되는 트레이드오프인데, 이 경우엔 충분히 수용 가능한 수준이다.

이 과정에서 query-tuning.md §7 을 썼다. INSERT 스톨 판별법, 핫로우 지도, 처방 순서. 오늘 만난 것을 글로 정리하면서 스스로의 이해도 더 명확해졌다. 판별은 SHOW ENGINE INNODB STATUSTRANSACTION 섹션에서 lock wait 를 찾는 것, 핫로우 식별은 sys.innodb_lock_waits 로 어느 행에 경합이 몰리는지 보는 것. 처방 순서는 "비동기화 먼저, 배치 병합 다음, 마지막에 파티셔닝 검토". 순서가 중요하다. 파티셔닝부터 떠올리면 과잉 엔지니어링이 된다. 같은 증상에서 오버엔지니어링을 선택하고 싶은 유혹이 있는데, 오늘은 그걸 자제하고 큐 분리로 끝냈다.

perf(partner) 도 같은 날이다. 발송포탈 발송내역 목록 쿼리를 디코릴레이트했다. "값동일·약 7배"라고 커밋 메시지에 적었는데, 상관 서브쿼리를 제거하고 JOIN 으로 풀어서 같은 결과를 내면서 실행계획이 크게 달라진 것이다. 상관 서브쿼리는 바깥 쿼리의 각 행마다 서브쿼리를 새로 실행한다. 결과 행이 N 개면 서브쿼리도 N 번. 테이블이 작을 때는 티가 안 나지만 발송내역이 쌓일수록 선형적으로 느려지는 구조다. 7배는 현재 데이터 규모 기준이고, 데이터가 더 쌓이면 개선 폭이 더 커진다. 진작 고쳐야 했는데 미뤘다가 오늘 건드리게 됐다. visitor 핫로우를 파다가 쿼리 성능 전반에 대한 사고가 열려있는 상태였고, 그 흐름에서 partner 쿼리도 같은 범주에서 봐야 한다는 게 명확해진 것이다.

perf(batch) 는 야간 지표 버킷 리빌드를 증분화한 작업이다. 매일 밤 전체 리빌드하는 배치가 부담이 됐다. 전체를 돌리면 안전하지만 시간이 오래 걸리고 DB에 부하를 준다. 전략을 바꿨다. 평일에는 최근 10일 창만 증분 처리하고, 일요일에만 전체를 돌린다. 거기에 검산 폴백을 붙였다. 증분 결과가 검산 기준을 벗어나면 자동으로 전체 리빌드로 복구한다. 커밋 메시지의 "자가치유"가 그 로직이다. 배치가 이상을 스스로 감지하고 더 안전한 경로로 전환하는 것.

이 폴백 로직의 임계값 설정이 까다로웠다. 너무 빡빡하면 정상 데이터도 전체 리빌드를 트리거하고, 너무 느슨하면 이상 데이터가 통과된다. 지표 데이터가 오염되면 일주일치 보고가 틀어질 수 있어서, 이쪽은 시간을 꽤 들였다. 최종적으로 오늘 올라간 로직이 맞는 임계값인지는 실제 운영에서 검증해야 한다.

kp-pay 9/1, 앱 콜백 회귀를 발견했다

오늘 문서 작업 중 가장 무거운 것이 이쪽이다. kp-pay 가상계좌 통합인증 의무 전환. 9월 1일부터 REST API 방식이 차단된다. PG 사에서 고지한 데드라인이고 우리가 선택할 수 있는 게 아니다.

계획서 갱신이 오늘 두 번 커밋됐다. 처음에 분석과 TODO 계획서를 썼고, 그다음에 §4.5 를 추가했다. 앱 콜백 전역 노출 회귀. 통합인증 방식으로 전환할 때 기존 팝업과 흐름이 달라지는 부분이 있는데, 그 과정에서 앱 콜백 URL 이 전역으로 노출되는 회귀가 생길 수 있다는 걸 설계 중에 발견했다. 발견하는 순간 계획서에 박았다. 이런 걸 머릿속에만 두면 나중에 테스트하다가 뒤늦게 터진다.

규격서 PDF 를 같이 커밋한 것도 의도적이다. 코리아결제시스템 가상계좌출금정보 등록 API 연동가이드 ver2.2. PG 규격서는 버전이 바뀌면 파라미터나 에러 코드가 달라지는 경우가 있고, 어느 버전 기준으로 개발했는지 추적이 안 되면 나중에 역추적하는 데 시간을 쓴다. PDF 를 git 에 넣는 게 항상 깔끔한 선택은 아니지만, 이 경우엔 그게 맞다고 판단했다.

9/1 까지 시간이 촉박하다. 단순한 파라미터 변경이 아니라 결제 흐름 자체를 바꾸는 거다. 팝업에서 통합인증 페이지 리다이렉트로. 콜백 처리, 상태 복원, 에러 케이스 전부 검토해야 한다. 오늘 계획서 갱신과 회귀 발견 메모가 그 작업의 출발점이다. 실제 코드는 아직 손을 안 댔다.

sync_apps.py 와 안전장치들의 탄생

저녁 무렵엔 포트폴리오 쪽 자동화 작업을 했다. 커밋 수로만 보면 많아 보이는데, 사실 한 흐름이다.

app_status_export.py 가 출발이다. iTunes API 와 공개 페이지, health 체크를 통해 실측 스냅샷을 찍는 스크립트. 포폴 앱 카드를 동기화하려면 "지금 어떤 앱이 실제로 스토어에 살아있는지"를 알아야 한다. 수동으로 관리하면 틀린다. 퇴출된 앱이 포폴에 계속 나타나거나, 새로 출시된 앱이 빠져 있거나. 68개 앱을 사람이 하나씩 확인하는 건 현실적으로 불가능하다. 그래서 스냅샷을 자동으로 뽑는 소스를 먼저 만들었다.

sync_apps.py 는 그 스냅샷을 바탕으로 index.html 을 업데이트하는 스크립트다. 처음 버전은 단순했다. 스냅샷 읽어서 카드 HTML 만들어서 교체. 근데 배포 직전에 생각이 걸렸다. 이게 포폴 메인에 직결되는 작업이다. 스크립트가 반쯤 실행되다 에러나면, 스냅샷 데이터가 이상하면, 공개 페이지가 깨진다.

그래서 안전장치를 세 겹 추가했다.

  • health 게이트: 스냅샷이 유효한지 검증한 다음에만 동기화 시작. 파일이 오래됐거나 앱 수가 예상과 크게 다르거나 필수 필드가 없으면 중단.
  • 다운그레이드 차단: 기존에 스토어 링크가 있던 앱에서 링크가 사라지는 방향의 변경은 자동 반영하지 않는다. 스토어에서 실제로 내려간 경우도 있지만 스냅샷 오류로 링크가 사라지는 경우도 있어서, 하향 변경은 명시적 확인이 필요하게 했다.
  • patch 원자성: 임시 파일에 먼저 쓰고 완전히 성공했을 때만 교체. index.html 수정이 중간에 끊기지 않게.

paintap 의 Play 404 링크는 이 과정에서 발견해서 제거했다. 실측했더니 링크가 죽어있었다. 수동 관리였다면 언제 발견했을지 모른다.

Play 링크 19건을 apps_map.json 에 반영했고, 경조부 카드도 새로 추가됐다. 미제출 앱 15개에 '출시 준비 중' 카드가 붙었다. 전체 68개가 이제 포폴에 반영된다. 실제로 스토어에 올라간 것도, 아직 준비 중인 것도. agolog.jpg, arrowwise.jpg, billwise.jpg, cncfeeds.jpg, dutymate.jpg 섬네일이 새로 들어갔다. make_thumb.py 덕분에 이걸 직접 손으로 만들지 않았다.

psy 는 알아서 돌아갔다

chore(psy): daily auto-generated tests (2026-08-12).

이 커밋은 내가 의식적으로 만든 게 아니다. 자동 생성이다. 커버 이미지 세 개와 테스트 JSON 세 개. family-car-trip-type, midnight-piano-ghost, playlist-personality. 매일 자동으로 돌아가는 루틴이 오늘도 제 시간에 실행됐다는 뜻이다.

작업량이 많은 날일수록 "알아서 되는 것" 의 존재가 얼마나 중요한지 실감한다. CAS 전환 세 앱, 성능 수술 세 건, kp-pay 계획, 포폴 자동화, 표준 문서 두 개. 여기에 psy 테스트 생성까지 손으로 했다면 어딘가는 빠졌을 것이다. 자동화가 빠지는 것보다 더 나쁜 건 자동화가 잘못된 채로 실행되는 것이고, 그래서 오늘 sync_apps 에 안전장치를 붙이고 batch 에 검산 폴백을 넣었다. 오늘 추가한 장치들이 미래의 어떤 날에 잘못된 자동화를 막아줄 것이다.

오늘의 레이어

돌아보면 오늘은 크게 세 층이었다.

제일 아래층은 이미 문제가 있던 것들의 수술 — visitor 핫로우, partner 상관 서브쿼리, batch 전체 리빌드. 핫로우라는 실마리를 잡은 뒤로 성능 관련 사고가 열렸고, 그 흐름에서 세 건을 연달아 건드렸다. query-tuning.md §7 이 그 날의 기록이다.

중간층은 새로운 기능의 반복 도입 — CAS 전환. 같은 작업을 세 앱에 걸쳐 했고, 첫 번째 앱에서 배운 것이 세 번째 앱에서 문서로 굳었다.

제일 위층은 미래 준비 — kp-pay 9/1 과 포폴 자동화. 당장 오늘 뭔가 터지는 게 아니라 앞으로를 위해 쌓은 것들. 계획서, 안전장치, 스크립트.

vc16 이 프로덕션에 들어갔다. 핫로우는 해소됐다. 9/1 은 계획이 있다. 포폴 자동화는 오늘부터 돌아간다.

댓글 0

첫 댓글 달아줘.