일기 slecs

데드락 네 겹과 흩어진 열두 가지

목차

오늘은 아침부터 지갑 코드에 붙어 있었다. 며칠째 미완으로 걸려 있던 출금·충전 데드락 건이었다. 두 트랜잭션이 서로를 기다리다가 타임아웃이 터지는 현상인데, 재현이 일정하지 않아서 "정확히 언제 터지는가"를 특정하지 못하고 있었다. 재현 환경을 더 정밀하게 다듬는 방향도 있었지만, 어느 순간부터 그보다는 경우의 수를 전부 잡는 쪽이 맞다는 생각이 들었다. 어차피 원인이 단 하나가 아닐 것이라는 확신이 있었기 때문이다.

한 번에 네 겹으로 잡기로 했다

단일 수정으로 끝날 문제가 아니었다. 데드락이라는 게 "여기 한 줄 고치면 된다"는 방식으로 해결되는 경우를 별로 못 봤다. 여러 경로에서 경쟁 조건이 맞물리는 것이라, 각 경로를 개별적으로 막아야 하고 이미 터진 이후의 후처리까지 생각해야 한다.

결국 네 가지를 한 커밋에 묶었다.

첫째는 재시도 로직이다. 데드락 예외가 발생했을 때 즉시 실패 처리하지 않고 일정 횟수 재시도하게 만드는 건 가장 기초적인 처방이다. 충전 쪽에는 이미 붙어 있었는데 출금 경로에 없었다. 언제 이 차이가 생겼는지는 히스토리를 뒤져도 명확하지 않았다. 초기 개발 때 빠진 채로 들어갔거나, 충전 쪽 개선할 때 출금은 후순위로 밀렸거나. 이유는 모르겠는데, 어쨌든 정리했다.

둘째가 3분 선차단인데, 이게 설계를 제일 오래 붙잡았다. 같은 지갑 ID에 대한 출금 요청이 들어왔을 때, 처리가 완료되기 전까지 후속 동일 요청을 애플리케이션 레벨에서 먼저 막는 방식이다. DB 락이 충돌하기 전에 차단한다는 개념이다. 3분이라는 숫자는 외부 이체 API가 응답하는 최대 예상 지연 시간을 보수적으로 잡은 것이다. 30초도 고민했고 1분도 검토했는데, 외부 기관 시스템이 실제로 지연됐을 때의 최악 케이스를 기준으로 보면 3분이 합리적이었다. 정상적인 유스케이스에서 동일 지갑에 3분 안에 출금이 두 번 들어올 상황 자체가 없기도 했다.

셋째는 이체 대사 배치다. 이건 데드락을 막는 코드가 아니라 사후 복구용 안전망이다. 외부 이체가 실제로 완료됐는데 내부 DB 상태가 못 따라간 케이스를 주기적으로 감지해서 맞춰준다. 데드락이 터졌다가 재시도로 복구되는 과정에서 상태 업데이트가 빠지는 경우가 있었다. 지금까지는 이걸 운영자가 수동으로 DB를 들여다보면서 처리하고 있었다. batch/withdrawal 패키지에 클래스를 하나 추가했다. 당장 눈에 띄는 변화는 아닌데, 밤새 이 배치가 돌면서 조용히 데이터 정합성을 맞춰준다는 게 중요하다.

넷째가 PROCESSING 잔류 감시다. 거래 상태가 PROCESSING에 박혀서 영구히 나오지 못하는 케이스가 있었다. 실제 이체는 완료됐거나 실패했는데 상태 업데이트만 놓쳐서 PROCESSING에 머무는 것이다. 이게 쌓이면 사용자는 "처리 중"인 거래가 언제 끝나는지 알 수 없고, 운영 입장에서도 수동 확인 전까지는 파악이 안 된다. 일정 시간 이상 PROCESSING이 유지되면 감지해서 처리 대상으로 올리는 로직을 utl 쪽에 추가했다.

네 가지가 건드린 파일이 dev/web, batch/withdrawal, co/wallet/web, utl 두 곳으로 다섯 군데가 넘는다. 한 커밋에 이 정도 규모면 좀 크다 싶었는데, 전부 "출금·충전 데드락과 그 후처리"라는 단일 맥락으로 연결돼 있어서 쪼개면 오히려 리뷰가 어렵겠다고 판단했다. 뭉쳤다. api-reference.md도 같이 갱신했다.

이게 완전한 해결인지는 운영에서 봐야 안다. 재현 테스트에서 발생이 없는 것까지 확인했지만, 운영 트래픽 패턴은 다르다. "완전한 해결"이라고 선언할 수 있는 데드락 픽스를 그다지 본 적이 없다. 네 겹의 방어막을 깔았으니 일단 지켜보는 수밖에 없는데, 그게 찝찝함이 완전히 안 가시는 이유이기도 하다.

Uptime Kuma를 내부에 구워 넣기로 한 이유

데드락 작업이 일단락되고 나서 헬스체크 쪽으로 넘어갔다. 자연스러운 흐름이었다. 지갑 안정성을 보강했으면, 그 시스템이 지금 어떤 상태인지를 외부에서 볼 수 있어야 운영이 편하다. 지금까지는 "서버 살아 있어요?"라는 질문이 오면 직접 확인하고 대답해야 했다.

Uptime Kuma를 외부 도구로 붙이는 것도 잠깐 고려했다. 깔끔하고 기능도 충분하다. 근데 별도 서비스를 띄우고 관리하는 오버헤드를 감수할 만큼인가 싶었다. 현재 시스템 안에 직접 구워 넣는 편이 낫겠다는 결론을 냈다. 대신 Uptime Kuma의 UI 컨셉은 그대로 차용했다. 일정 간격으로 체크한 이력을 타임라인 블록으로 표현하는 방식 - 초록이 쭉 이어지다가 빨간 블록 하나 나오는 그 화면 - 이 정보 밀도가 높다. 숫자 목록보다 패턴이 더 빨리 읽힌다.

구조는 두 단계로 나눴다. 먼저 배치를 추가해서 주기적으로 각 컴포넌트 상태를 체크하고 결과를 DB에 누적한다. 그다음 그 이력을 읽어서 화면에 보여주는 공개 페이지를 만든다. 공개 페이지가 데이터를 읽으려면 이력이 먼저 쌓여야 하니까 배치 커밋이 앞에 왔다.

공개 페이지는 /dev/health로 잡았다. 인증 없이 접근 가능해야 하는 페이지다 보니 WebMvcConfig에서 인터셉터 예외 경로를 건드려야 했다. 이게 항상 신경 쓰이는 부분이다. 범위를 잘못 잡으면 인증이 필요한 다른 경로까지 열릴 수 있어서 /dev/health 하나만 정확히 예외 처리했다. SQL 매퍼도 새로 작성하고 JSP로 화면을 구성했다.

마이페이지의 조용한 황당함

이건 오늘 일정에 없던 작업이었다. 다른 걸 건드리다가 발견했다.

마이페이지에서 유저의 소속 시스템이 내부 코드 값 그대로 화면에 노출되고 있었다. 사용자 입장에서 저게 뭔지 알 수 없다. Controller에서 JOIN을 빠트리고 시스템명을 따로 가져오지 않은 것이었다. 쿼리 수정하고 JSP에서 코드 대신 시스템명을 표시하게 고쳤다. 작업 규모는 크지 않았다.

수정하면서 같은 파일 다른 부분을 보다가 레이아웃 로직이 이상한 걸 발견했다. 마이페이지 레이아웃이 요청 파라미터의 sysId가 아니라 세션의 loginSysId를 기준으로 결정되고 있었다. 관리자가 특정 시스템 사용자의 마이페이지를 조회할 때 URL 파라미터로 sysId=apple을 넘기는데, 레이아웃은 현재 로그인한 관리자 세션의 시스템을 기준으로 잡혔다. 다른 시스템 사용자를 조회할 때 레이아웃이 엉뚱한 시스템 기준으로 나오는 것이다.

따로 커밋으로 잡았다. 왜 이렇게 짠 건지 히스토리를 뒤지기보다 명백한 방향으로 그냥 고쳤다. 어떤 의도가 있었어도 현재 요구사항과 맞지 않는 건 명확하니까.

하드코딩 8개가 18건을 놓치고 있었다

app-review-watch가 오늘 가장 많이 손댄 영역이다. 발단이 좀 황당했다.

Play 감시 대상 앱 목록이 코드에 8개 하드코딩으로 박혀 있었다. 처음 봤을 때 "신규 앱이 등록될 때마다 코드 수정하고 배포했나?"라는 생각이 먼저 들었다. 확인해 보니 안 됐다. 그 결과로 콘솔에 18건의 추가 승인 알림이 누락된 상태였다. 8개 앱 목록에 없는 앱들의 리뷰와 별점 변화는 처음부터 감시 대상이 아니었으니 당연한 결과다. 18건이라는 숫자가 꽤 컸다.

play_console_apps.json에서 앱 목록을 동적으로 로드하게 전환했다. 이제 앱이 추가되면 JSON 파일만 갱신하면 된다. 코드 수정도 재배포도 필요 없다. 바꾸고 나서 콘솔 전체 앱 대상으로 다시 돌리니까 누락됐던 것들이 한꺼번에 처리됐다.

별점만 남긴 평가 알림도 추가했다. 텍스트 없이 별점만 남기는 리뷰가 의외로 많다. 기존 감시 로직은 텍스트가 있는 리뷰 기준이어서 별점 전용 리뷰는 걸리지 않았다. 앱 품질 모니터링 관점에서 별점 하락이나 낮은 별점이 쌓이는 것을 놓치는 건 의미 있는 누락이다. 별점 전용 감시 경로를 따로 만들어 알림이 가게 했다.

GCS reviews CSV 보완도 같이 붙었다. Google Play API는 최근 7일 이내 데이터만 반환한다. 그 이전 텍스트 리뷰는 API로 가져올 수 없어서 GCS에 쌓아둔 CSV를 백필 데이터로 활용한다. 7일 이전 리뷰가 있는데 API 응답에 없으면 CSV에서 보완하는 방식이다. 이 조합으로 API 7일 제한으로 인한 공백을 메운다.

ASO 스캐너는 오늘 새로 만든 스크립트다. scripts/play_aso_scan.py. Play 스토어 리스팅이 ASO 기준에 맞는지 자동으로 전수 검사하는 것이다. 검사 항목은 네 가지였다:

  • 앱 제목 25자 이상 여부
  • 짧은 설명 70자 이상 여부
  • "free" 계열 금지어 포함 여부
  • 영어 텍스트가 그대로 복붙된 것 감지

지금까지 이 검사를 사람 눈으로 했다. 앱이 적을 때는 가능한데 숫자가 늘어나면 누락이 생긴다. 독립 실행 가능한 구조로 만들어뒀기 때문에 필요할 때 그냥 돌리면 된다. 지금 당장 감시 봇에 통합하진 않았는데, 붙이기 쉽게 인터페이스를 잡아뒀다.

watch.py 독스트링도 갱신했다. "8개 앱 하드코딩"이라고 적혀 있던 설명이 코드 현실과 어긋나고 있었다. 작은 것이지만 문서가 코드와 다르면 다음 사람이 어느 쪽을 믿어야 하는지 모른다.

아잔 소리를 직접 고른다는 것

기도 시간 앱에 커스텀 아잔 사운드 기능이 들어갔다. 기기에 저장된 오디오 파일을 직접 선택해서 아잔 소리로 설정하는 것이다. v1.0.5+8.

Android는 MainActivity.kt에서 파일 피커 연동을 처리했고 iOS는 project.pbxproj를 수정했다. Flutter 앱이다 보니 플랫폼별 네이티브 코드를 따로 건드려야 한다. 기능 하나에 파일이 여러 곳으로 흩어지는 게 Flutter 개발의 귀찮은 부분이다. 다국어 문자열은 아랍어, 벵골어, 영어 arb 파일 세 개에 각각 추가했다.

iOS 쪽은 CLAUDE.md에 1.0.5 제출 준비 세팅을 기록해뒀다. 실제 제출은 아직 안 했다. 준비 상태를 문서로 남겨두는 이유는, 며칠 후에 다시 봤을 때 어디까지 됐는지 컨텍스트를 빠르게 복원하기 위해서다. 제출 직전에 확인해야 할 것들을 기억에만 두면 빠진다.

이 기능 요청은 오래된 것이었다. 기본 제공 아잔 소리가 취향이나 지역과 맞지 않아서 자기 지역 소리를 쓰고 싶다는 요청이 여러 번 있었던 것으로 기억한다. 기술적으로 특별히 복잡한 건 아닌데, iOS/Android 양쪽 다 뚫어야 하고 다국어도 챙겨야 하니 건드리는 곳이 많아진다. 그래도 오래 밀렸던 것 하나 처리했다는 점에서 기분은 나쁘지 않다.

매일 조용히 돋아나는 것들

psych 봇이 오늘도 돌았다. 커밋 목록 끝에 chore(psy): daily auto-generated tests가 조용히 끼어 있었다. 오늘은 세 개 - family-photo-ghost, friend-moving-day, study-cafe-type. 커버 이미지 webp와 JSON 데이터 파일이 세트로 들어왔다.

이게 제대로 돌아가는 것을 커밋 목록에서 확인할 때마다 처음 설계하고 세팅할 때 공들였던 게 조용히 이자로 돌아오는 느낌이 든다. 사람이 아무것도 안 해도 콘텐츠가 쌓인다. 봇이 잘 돌고 있다는 증거가 매일 커밋으로 남는 것이 나름 만족스럽다.


오늘 커밋이 열세 개였다. 하루 치로 많은 편이다. 그런데 하나하나 떼어놓고 보면 어느 것도 "이제 완전히 끝났다"는 느낌으로 닫을 수가 없다. 데드락은 운영에서 확인될 때까지 결론이 없고, 헬스체크 UI는 더 다듬고 싶은 부분이 남아 있고, ASO 스캐너는 봇 통합이 남았고, 아잔 기능은 App Store 제출이 남았다. 전부 "다음 단계로 일단 넘긴" 상태다.

하루가 길었는데 마감 느낌이 안 난다. 원래 이런 건지, 오늘이 유독 그런 건지.

댓글 0

첫 댓글 달아줘.