654개 어노테이션, AOP의 배신, 3.3초를 1초로 만든 날
목차
오늘은 커밋 수가 서른을 넘긴다. 일이 많다는 게 아니라, 전선이 동시에 여러 개 열렸다는 뜻이다. 하나를 봉합하면 옆에서 터지고, 막으면 또 다른 데서 삐져나왔다. 퇴근 직전에 로그를 훑고 나서야 "이걸 하루에 다 한 거라고?" 싶었다.
WAS 3대 체제, 오늘 진짜로 마무리됐다
제일 먼저 정리된 건 인프라다. 며칠째 끌어오던 WAS3 투입이 오늘 완결됐다. deploy-prod-all 워크플로에 WAS3 순차 스텝을 추가하고, NAT 스크립트 status 분모를 하드코딩 대신 동적으로 잡도록 바꿨다. 2대 체제로 돌던 배포가 이제 1→2→3 순서대로 굴린다.
via_jump 스위치도 완전히 걷어냈다. 외부 공인 SSH 경로와 내부 NAT 경유를 스위치 값으로 분기하던 구조였는데, NAT 상시화가 됐으니 분기 자체가 필요 없어졌다. 워크플로 4개에서 해당 분기를 전부 삭제하고 점프 러너·내부 IP를 고정값으로 박았다. 줄어든 코드는 명확해진 의도다. via_jump라는 이름의 불확실성이 사라진 것만으로도 배포 흐름을 읽기가 훨씬 쉬워진다.
점검모드 토글 워크플로도 오늘 새로 만들었다. 양 WAS에 maintenance.on 파일을 생성하거나 삭제하는 것으로 점검 페이지를 분기하는 구조인데, CI에서 버튼 하나로 처리할 수 있게 됐다. 별거 아닌 것 같지만, 운영 중 긴급 점검이 필요한 상황에서 콘솔 SSH 연결하고 파일 직접 만들고 확인하는 시퀀스를 없애준다는 점에서 의미가 있다. 쇼핑몰 플랫폼 폐기에 따른 배포 워크플로 2종도 오늘 삭제했다. 이미 안 쓰는 플랫폼 워크플로가 목록에 계속 떠있는 게 눈엣가시였다.
654곳에 어노테이션을 붙이는 일
인프라가 정리되자마자 메인 작업으로 넘어갔다. @ReadOnlyRoute 어노테이션 대규모 부착이다.
며칠에 걸쳐 진행 중인 작업인데, 오늘 하루에만 1~3단계 78곳, Wave4 551곳이 추가됐다. PartnerPortalController 보류 8곳도 재부착하면서 트리 누계가 654를 넘어섰다. analytics, anomaly, apiKey, chat, ai, announcement 패키지 Controller들 하나하나다. 메서드마다 어노테이션을 붙이고, 검증기 스크립트를 돌리고, 빠진 곳이 없는지 확인하는 루프를 계속 돌렸다.
readonly_route_candidates.tsv에 후보를 추출해두고 readonly_route_verify.py로 회귀를 감시하는 구조다. Wave4에서 551곳을 한꺼번에 부착할 때는 검증기도 같이 보강했다. 숫자가 커질수록 수동 검토는 의미가 없어진다. 자동화가 잡지 못하는 건 사람도 못 잡는다는 전제로 검증기 자체의 커버리지를 올리는 게 맞다.
그런데 이 과정에서 사고가 하나 났다.
AOP가 조용히 캐시를 오염시켰다
ReadOnlyRouteAspect에서 어노테이션 인자를 바인딩하는 코드가 있었다. 포인트컷 표현식에서 @annotation(readOnlyRoute) 식으로 어노테이션 객체를 파라미터로 가져오는 패턴이었는데, 이게 JVM의 섀도매치 캐시 동작과 충돌했다.
Spring AOP는 포인트컷 매칭 결과를 JVM 레벨에서 캐시한다. 인자 바인딩이 들어가면 캐시 키가 달라질 수 있고, 특정 조건에서 JVM 인스턴스별로 다른 결과를 캐싱하는 상황이 생긴다. 3대 WAS 환경에서 이게 터지면 같은 메서드가 WAS1에선 레플리카로 라우팅되고 WAS2에선 DB1으로 가는 비대칭 상태가 만들어질 수 있다. 데이터 무결성 문제로 이어지기 전에 발견한 건 운이 좋은 거다.
수정은 간단했다. 인자 바인딩을 전부 걷어내고 어노테이션을 마커로만 쓰도록 Aspect를 바꿨다. @annotation(readOnlyRoute) 대신 @annotation(경로.ReadOnlyRoute)로 타입만 참조하고 인자 없이 포인트컷이 동작하게 했다. 코드 줄은 오히려 줄었다.
커밋 메시지에 "JVM별 섀도매치 캐시 사고 방지"라고 적었지만, 솔직하게 말하면 "AOP 캐시 동작을 얕게 이해하고 있었던 것"이다. Spring AOP 어노테이션 바인딩은 인자 이름이 포인트컷 시그니처에 노출되는 순간 캐시 동작이 달라질 수 있다는 걸 몸으로 배웠다.
CLAUDE.md에 룰을 추가했다. "AOP 어노테이션 인자 바인딩 금지 — 2026-08-20 ReadOnlyRouteAspect 사고." 이런 규칙이 쌓이는 게 좋은 일이다. 쓰지 않으면 몇 달 뒤에 다른 사람이, 아니면 내가, 똑같은 걸 밟는다.
DB 라우팅 안전장치도 함께 강화했다. 메서드 단위 자동 격리와 DB1 자동 재실행 안전장치를 붙이고, ReplicaRouteBlacklist 클래스를 새로 만들었다. 레플리카에서 오류가 나면 해당 메서드를 블랙리스트에 올리고 자동으로 DB1에서 재실행하는 구조다. 레플리카가 일시적으로 마스터보다 뒤처지는 경우에 사용자가 오래된 데이터를 보는 것보다 DB1로 폴백하는 게 낫다는 판단이다.
파트너 대시보드 쿼리 튜닝, stage1부터 stage3까지
오늘의 두 번째 축은 파트너 대시보드 성능이다. 이쪽은 단계별로 진행했다.
stage1은 SSR 4쿼리 튜닝이었다. ALTER_20260820_dashboard_slow_indexes.sql로 인덱스를 추가하면서 쿼리를 손봤다. 이 단계에서 중요한 건 값 동일 보장이다. 튜닝한 쿼리가 기존과 다른 결과를 낸다면 성능 개선이 아니라 데이터 오염이다. 귀찮아도 A-B 검증을 먼저 돌리고 쿼리 변경을 적용하는 순서를 지킨다.
stage2는 수익요약·결제구성·판매추이 3쿼리를 버킷 구조로 전환하고 라이브 전환까지 했다. ALTER_20260820_partner_metric_daily_stage2.sql이 이 단계에서 나온다. 버킷 구조는 배치가 미리 집계된 값을 주기적으로 갱신하고, 조회 시에는 그 버킷에서 읽는 방식이다. 실시간성을 조금 포기하는 대신 조회 속도가 확 빨라진다. 이 트레이드오프가 파트너 대시보드에서 수용 가능한지는 데이터 갱신 주기를 얼마나 짧게 가져가느냐에 달려 있다.
판매추이 V2 버킷에서 all-zero 행 제외 버그도 함께 잡았다. 집계 결과가 전부 0인 행이 버킷에 섞여서 조회 결과를 오염시키고 있었다. 운영 A-B 비교에서 75건 불일치가 확인됐다. 필터 조건 하나 추가로 해결됐는데, 조용히 틀린 값이 나가고 있었다는 사실은 불편하다. 운영 ALTER 실행 상태도 커밋에 반영해서 이 시점 이후로 뭐가 적용됐는지 명확히 해뒀다.
stage3는 집계 병렬화다. 직렬로 25개 쿼리를 순서대로 날리던 구조를 2웨이브 팬아웃으로 바꿨다. 첫 번째 웨이브에서 서로 의존성 없는 쿼리들을 동시에 쏘고, 두 번째 웨이브에서 첫 번째 결과에 의존하는 쿼리들을 처리하는 방식이다. 결과는 3.3초에서 1초권. 파트너 대시보드 첫 로딩이 3초를 넘는 건 사실상 "이 페이지 느리네" 소리 들을 각오를 해야 한다는 뜻이다. 1초권이면 합격선이다.
슬로우쿼리 전체 A-B 검증 케이스는 7,780건, 불일치 0이다. 이 숫자는 커밋 메시지에 박았다. 나중에 "그 튜닝 안전했냐?" 질문이 오면 로그 꺼내면 된다. 최근정산 창 조회에는 인덱스 힌트도 붙였다. 옵티마이저가 엉뚱한 인덱스를 타는 경우가 있어서 강제했다.
관리자 대시보드 운영자 수익 실시간 쿼리도 같이 튜닝했다. 이쪽도 값 동일 보장 확인 후 적용.
MaxScale 프록시 실험, 그리고 같은 날 철회
쓰기 경로에 MaxScale master-only 프록시(4008번 포트)를 경유하게 하고 DB1 직결 폴백을 두는 실험을 했다. 리뷰 피드백을 받아서 retriesAllDown=0, socketTimeout=120000도 함께 넣었다. 그런데 같은 날 안에 MaxScale을 걷어내고 DB1 직결로 복귀했다.
쓰기 경로에 프록시 레이어를 하나 더 끼우는 게 가져다주는 리스크가 얻는 이점보다 크다는 판단이었을 것이다. 실험하고, 결과가 기대와 다르거나 리스크가 보이면 빠르게 되돌리는 게 맞다. 같은 날 안에 붙였다 뗐다고 해서 낭비가 아니다. 붙여보지 않으면 답이 안 나오는 것들이 있다.
파괴문 29개, 그리고 오탐 22곳
시스템 삭제 계열 쿼리 29문에서 조건부 sysId 필터가 증발할 수 있는 경로를 차단했다. MyBatis 동적 쿼리에서 <if test="sysId != null">WHERE sysId = #{sysId}</if> 식의 패턴은, sysId가 null로 들어오는 순간 WHERE 절 전체가 사라지고 전체 행에 DELETE가 실행된다. fail-closed 하드 조건을 넣어서 sysId가 없으면 쿼리 자체가 실행되지 않도록 막았다.
29문이라는 숫자를 보고 잠깐 멈칫했다. 이게 하나하나 잠재적 폭탄이었다. 파라미터 하나 잘못 들어오면 테이블 전체가 날아가는 코드가 29군데 있었다는 뜻이다.
잔액 무결성 검산에서 SENDER 발송포탈을 제외한 건 오탐 제거 작업이다. 발송포탈은 잔액 구조가 일반 계정과 달라서 검산 로직이 오탐을 22곳에서 만성적으로 뱉고 있었다. 무결성 알림이 항상 울리면 진짜 이상한 게 나왔을 때 묻힌다. 오탐을 없애는 건 경보 자체의 신뢰도를 회복하는 작업이다.
배포 대기 시간의 수렴
배포 파이프라인 안정화 대기 시간이 오늘 세 번 바뀌었다. 60초에서 30초로 줄이면서 detach 소프트 가드(2대 UP 90초 대기)를 추가했다가, 그 가드를 제거하고 0초(헬스체크 1회만)로 수렴했다.
detach 소프트 가드가 잡당 최대 90초를 추가하는 건 3대 순차 배포에서 최대 270초, 거의 5분이다. 배포 빈도가 높은 날에는 이게 쌓인다. 헬스체크 1회 통과면 다음으로 넘어가는 방식이 결국 정답이었다.
대신 LB attach 폴백 맵에서 버그를 잡았다. 배포 마지막 스텝에서 WAS3이 LB에 복귀를 못 하고 있었던 이유가 폴백 맵에 WAS3 ID(144437867)가 없었던 것이었다. 복귀 스크립트가 맵에서 못 찾으니까 아무것도 안 했다. 이런 건 로컬에서 발견하기 어렵다. 운영에서 실제로 배포해봐야 드러난다.
X-Node 응답 헤더도 오늘 추가했다. WAS 3대가 LB 뒤에 있으니 요청이 어느 인스턴스로 갔는지 알 방법이 없었다. 필터에서 인스턴스를 해시해서 헤더에 박으면 디버깅할 때 쓸 수 있다. 특정 WAS에서만 재현되는 버그를 걸러내는 데 필요하다.
앱스토어 리젝, territories=0
대형 작업들 사이에 모바일 앱 관련 기록이 하나 끼어있었다.
구독 상품 territories=0. 앱 내 구독 상품의 판매 가능 국가가 하나도 설정되지 않은 상태로 제출됐다가 리젝됐다. 앱스토어 콘솔에서 구독 상품을 만들면 territories 기본값이 0으로 설정되는 경우가 있는데, 이걸 명시적으로 지정하지 않으면 심사에서 무조건 탈락한다. export compliance 재제출 게이트도 있었다. 두 가지를 다 처리해서 8/20 재제출했다.
기능이 완성됐어도 이런 운영 설정 하나 때문에 리젝이 나온다. 다음 제출 때를 위해 문서에 명시적으로 남겨뒀다. territories 설정 확인은 이제 체크리스트 최상단에 올라간다.
심리테스트 3종, 배치는 오늘도 돌았다
다이어트 버디 유형, 자정의 동물원 탈출, 전통시장 유형. 커버 이미지 세 장과 JSON 세 개가 자동 생성됐다. 매일 돌아가는 배치고, 오늘도 조용히 돌았다. 별 이슈 없음.
오늘 하루를 정리하면 DB 라우팅이 절반, 쿼리 튜닝이 나머지 절반이다. 중간에 AOP 사고가 하나 끼어서 대응하고 룰까지 썼다. 인프라 완성, MaxScale 실험, 파괴문 29개 봉합, 앱 리젝 처리, 배포 파이프라인 정리가 그 사이사이에 들어가 있었다.
654곳에 어노테이션을 달고, 7,780 케이스를 검증하고, 3.3초를 1초로 만든 날. 커밋이 서른을 넘기는 날은 끝에 가서 머리가 잘 안 돌아간다.
댓글 0
첫 댓글 달아줘.