직렬 25쿼리가 무너지는 데 하루가 걸렸다
목차
오늘 커밋이 열한 개 나왔다. 각자 다른 영역, 다른 맥락이고, 실제로 세션 내내 컨텍스트가 계속 튀었다. 정산 쿼리 들여다보다가 제주은행 피드백 처리하고, SEO 설정 보다가 다시 쿨다운 로직으로 돌아오고. 그래도 오늘을 관통하는 무게중심은 파트너 포탈 성능 작업이었으니, 거기서 시작한다.
16초의 출처를 찾는 과정
정산내역 화면이 느리다는 얘기는 진작부터 있었다. "느리다"는 표현이 모호하니 정확하게 말하면, 첫 화면 로딩에 16.5초. 파트너사 담당자들은 그 화면을 열어놓고 다른 탭으로 넘어갔다가 돌아오는 방식으로 쓰고 있었던 것 같다. 못 쓸 화면은 아니지만 체감상 에러에 가깝다. 이 문제를 오늘 드디어 열었다.
처음엔 인덱스 문제겠거니 했다. 쿼리 몇 개 EXPLAIN 때려보고 빠진 인덱스 걸어주면 두세 시간에 끝날 거라고 생각했다. 그런데 DAO 레이어를 열어보는 순간 멈칫했다. 쿼리가 25개가 있었고, 그게 전부 직렬로 실행되고 있었다. 앞 쿼리가 끝나야 다음 쿼리가 시작되는 구조. 서로 의존성이 없는 쿼리들이 코드 위아래로 늘어서 있다는 이유만으로 순서대로 실행되고 있었던 것이다.
왜 이렇게 됐는지는 이해한다. 처음에는 쿼리가 몇 개 안 됐을 것이다. 기능이 하나 추가될 때마다 개발자가 기존 코드 아래에 쿼리를 붙여 넣었다. 이게 나쁜 습관이냐고 묻는다면 반드시 그렇지는 않다. 당장 동작하는 게 먼저인 상황에서 병렬화까지 고민하기는 어렵다. 하지만 결과적으로 25개짜리 직렬 체인이 됐고, 각 쿼리 평균 실행 시간이 600-700ms 수준이라면 합산이 16초를 넘기는 건 산수 문제다.
ParallelQueryUtil로 팬아웃했다. 의존성이 없는 쿼리들을 묶어서 동시에 실행하고, 선행 결과가 필요한 것들만 명시적으로 순서를 잡아줬다. 이론적으로는 병렬 배치 중 가장 느린 단일 경로 시간으로 수렴한다. 25개 직렬이 아니라 최장 경로가 전체 시간을 결정하는 구조로 바뀐 것이다. 여기에 쿼리 튜닝까지 붙으면 그 최장 경로 자체도 줄어든다.
쿼리 12종 전수 튜닝 - 결과 동치 검증이 더 오래 걸렸다
병렬화만으로 끝낼 생각이 없었다. 병렬로 돌려봤자 개별 쿼리가 느리면 한계가 있다. 정산 화면 관련 느린 쿼리 12종을 따로 전수조사했다.
EXPLAIN ANALYZE로 실행계획을 보면 문제 유형이 명확하게 나온다. 인덱스가 없어서 풀스캔하는 것, 인덱스가 있는데 옵티마이저가 타지 않는 것, 조건절에 함수가 씌워져서 인덱스가 무력화된 것. 이 중 마지막 유형이 제일 많았다. WHERE DATE_FORMAT(reg_dt, '%Y-%m') = ? 같은 패턴이 여기저기 있었다. reg_dt 컬럼에 인덱스가 있어도 컬럼에 함수가 씌워지면 인덱스를 타지 못한다는 건 알려진 문제인데, 실제 코드에서 보면 생각보다 자주 나온다. 조건 구조를 바꿔서 컬럼에 직접 함수를 씌우지 않는 형태로 재작성했고, 인덱스를 새로 추가한 케이스도 있어서 DDL_TABLES.sql에 DDL이 같이 들어갔다.
시간이 많이 걸린 건 튜닝 자체보다 A-B 전수검증이었다. 12종 쿼리 각각에 대해 튜닝 전/후로 동일한 파라미터를 넣었을 때 결과셋이 완전히 일치하는지 확인해야 한다. 특히 정산 쪽은 수치가 틀리면 금전 오류로 직결되기 때문에 대충 넘길 수 없다. 12종 전부 값 동치 확인 후에 머지했다. 이 과정에서 한 케이스가 미묘하게 달라서 다시 들여다봤다. 페이징 처리 방식이 달라서 생긴 차이였는데, 그거 잡는 데 시간이 더 들었다.
AJAX 지연 로드는 이 두 작업과 별도 커밋으로 분리했다. 정산 화면에서 하위수수료 현황 섹션은 화면을 처음 열자마자 반드시 봐야 하는 정보가 아니다. 담당자들이 특정 확인 작업을 할 때 들여다보는 섹션이다. 첫 로딩 때 이 섹션 데이터까지 무조건 다 가져오는 건 낭비라는 판단이었다. settlement-subfee-fragment.jsp를 분리해서 처음에는 플레이스홀더만 보여주고, 해당 섹션을 열면 비동기로 가져오는 구조로 바꿨다. 컨트롤러에 AJAX 엔드포인트가 추가됐고, 이것만으로 첫 화면이 5초 초반에서 2초 초반으로 줄었다.
병렬화, 쿼리 튜닝, AJAX 분리 세 가지가 합쳐진 결과가 실측으로 얼마나 나오는지는 배포 후에 확인해야 한다. 이론치와 실측치가 항상 같지 않으니 지금은 기다리는 수밖에 없다.
403이 폴링으로 흘러들어오고 있었다
하루 시작은 파트너 포탈 워크스페이스 탭 전환 문제였다. 서버 로그에서 403이 특정 패턴으로 반복되는 게 보였다. 확인해보니 탭을 전환할 때 세션 컨텍스트가 바뀌는데, 기존에 떠 있던 폴링 타이머가 그걸 따라가지 못하고 이전 컨텍스트로 계속 요청을 날리는 것이었다.
수정 방향은 명확했다. 탭 전환 이벤트를 감지해서 폴링을 중단하고, 새 컨텍스트가 세팅된 후 재시작하는 것. 근데 이 패턴이 파트너 포탈 주요 화면 다섯 군데에 다 퍼져 있었다. coupon-issue.jsp, dashboard.jsp, orders.jsp, subordinate-withdrawals.jsp, partnerSidebar.jsp. 포탈에서 폴링을 쓰는 화면이 전부 같은 구조였다. 한 군데만 고치면 나머지에서 같은 문제가 또 나왔을 게 뻔하니 다 같이 고쳤다.
수정보다 더 찜찜한 건 이게 언제부터 이랬냐다. 403이 폴링으로 조용히 쌓이고 있으면 실제 인증 오류가 섞였을 때 구분이 안 된다. 로그 신뢰도 문제다. 빨리 잡은 게 맞다.
제주은행이 짚어준 문구 하나
민원 이의제기 문서 작업은 성격이 달랐다. 제주은행 측에서 공식 이의제기 양식에 처리자의 의견이나 판단이 읽히는 표현이 들어가 있다는 지적을 해왔다.
공식 민원 처리 서식은 객관적 사실 기술에 그쳐야 한다. "~한 것으로 보입니다", "~가 확인됩니다" 같은 표현도 문맥에 따라 처리자의 판단이 들어간 것처럼 읽힐 수 있다. 이의제기 문서가 법적 효력을 갖는 맥락에서는 이런 표현이 문제가 된다는 지적이다. 세심하게 짚어줬다는 생각이 들었고, 동시에 이런 문구가 어떻게 들어갔나 싶기도 했다.
수정 범위가 넓었다. 템플릿 파일인 jeju-objection-form-v2.docx를 고치는 것과 동시에, 이 문서가 코드에서 동적으로 생성되는 구조이기 때문에 렌더링 로직도 봐야 했다. 내부 클래스에서 끼워 넣는 문자열들을 정제했고, 어드민 쪽 admin-complaint-doc.js도 건드렸다. 판단성 표현들을 전부 제거하거나 중립적인 서술로 교체했다. 수정 후 재확인 요청을 해뒀다. 이런 건 빠른 답이 오면 좋겠다.
robots.txt가 서빙이 안 됐다
SEO 쪽 작업인데, 네이버 서치어드바이저 소유확인 메타태그 추가보다 같이 발견한 문제가 더 거슬렸다.
robots.txt가 제대로 서빙이 안 되고 있었다. WebMvcConfig.java에서 정적 리소스 핸들러 설정이 robots.txt 요청 경로를 가로채서 컨트롤러로 올리고 있었고, 거기서 404가 나고 있었던 것이다. Spring MVC에서 정적 리소스 핸들러 경로를 수정하면서 의도치 않게 발생한 부작용이었을 것이다. 설정 한 줄로 해결됐다.
근데 "언제부터 이랬냐"는 질문에 답이 없다. 크롤러가 robots.txt를 못 읽으면 기본적으로 전체 허용으로 동작하니 크리티컬한 문제는 아니다. 하지만 서치어드바이저 소유확인 과정에서 크롤러가 파일을 정상적으로 접근하는지 확인하는데, robots.txt 응답이 404였다면 거기서 이미 신호가 이상하게 읽혔을 수 있다. 메타태그는 cmmnMeta.jsp에 심었고, UPDATE_20260821_seo_naver_verification.sql도 같이 커밋됐다.
쿨다운 사다리와 이행확약서의 연결
연속 거래 쿨다운 백오프 기능이다. 문제가 되는 거래를 연속으로 시도할 경우 쿨다운 시간이 단계적으로 늘어나는 구조다. 1회면 n분, 2회면 더 길게, 누적될수록 사다리를 올라가는 방식이다.
여기에 이행확약서 연동이 붙는다. 특정 조건을 초과하면 이행확약서에 서명이 완료되어야 거래가 재개된다. 시간적 제약(쿨다운) 위에 명시적 행위(서명)를 요구하는 이중 게이트인 셈이다.
파일 범위를 보면 complaint, member, site, trade, batch의 aml, econtract 패키지 전반에 걸쳐 있다. AML 배치가 이 흐름과 연결되어 있다는 게 이 기능의 성격을 말해준다. 거래 빈도를 제한하는 단순한 UX 기능이 아니라, 자금세탁 방지 체계와 연계된 거래 통제다. 커밋 하나로 나왔지만 여섯 개 패키지를 넘나드는 작업이었다.
654 vs 17 - stale이 이렇게 쌓인다
@ReadOnlyRoute 부착 현황을 전수 재검증했다. 결과가 좀 당황스러웠다.
문서에 적힌 수치는 17. readonly_route_verify.py 스크립트가 실측한 수치는 654. 37배 넘게 차이난다.
나쁜 의도로 틀린 수치를 기록한 게 아니다. 초기에 누군가 세어서 기록했고, 그 이후로 코드는 계속 늘어났는데 문서는 갱신되지 않았던 것이다. 이런 식으로 stale이 쌓인다. 처음엔 정확했던 수치가 시간이 지나면서 실체와 멀어지고, 아무도 의심하지 않으면 그게 진실로 통용된다.
동작에는 문제가 없다. 654곳에 제대로 @ReadOnlyRoute가 붙어 있다. 문제는 "17개뿐이니 더 달아야 한다"는 잘못된 판단이 내려질 수 있다는 것이다. 실측 없이 문서만 보면 실체와 완전히 다른 방향으로 갈 수 있다. CLAUDE.md를 갱신해서 654를 반영했고, readonly_route_verify.py를 커밋해서 다음엔 실측이 쉽게 되도록 해뒀다.
오늘 중 제일 민망한 발견이었다. 그리고 적혀 있는 수치를 실측 전에는 가설로 봐야 한다는 원칙이 654 vs 17로 다시 한번 입증됐다.
챗봇 전화번호 - 8번이라는 임계값
회원 챗봇에서 전화번호를 안내하는 시점을 세션 내 문의 횟수로 제한하는 기능이다. 8회 이상 문의가 누적되어야 전화번호 안내가 노출된다.
8이라는 숫자가 어떤 근거로 정해졌는지는 커밋에서 알 수 없다. 협의 과정이 있었겠지. 너무 낮으면 챗봇이 있어도 바로 전화로 빠져나가는 경로가 되어버리고, 너무 높으면 사용자가 지쳐서 이탈한다. 8번 문의해도 해결이 안 되면 사람이 직접 받아야 할 케이스라는 기준점을 잡은 것으로 보인다.
세션에 카운터를 두고 매 문의마다 증가시키는 방식이다. utl 패키지에 카운팅 유틸이 들어갔고, AI 챗 JSP와 컨트롤러에 게이트 체크가 붙었다. api-reference.md도 업데이트됐는데, 챗봇 API 응답에 전화번호 노출 여부를 알려주는 플래그 같은 게 추가됐을 것이다.
8번째 문의에서 갑자기 전화번호가 나오는 게 사용자에게 자연스러운 흐름이 되려면 전환 시점의 메시지 설계가 잘 돼야 한다. 그 부분이 어떻게 됐는지는 직접 써봐야 안다.
전자계약 OFF 배포 가이드 - "실측 반영"이라는 표현
docs/econtract-off-deploy.md 하나 커밋. 전자계약 기능을 OFF하는 배포 절차 가이드다. dev와 운영 DDL 적용이 완료됐다는 내용이 "실측 반영"이라고 명시되어 있다.
이 문서가 "실측 반영"이라고 표현한 게 오늘 @ReadOnlyRoute 사태를 보고 나니 더 잘 보인다. "DDL 적용됐겠지"가 아니라 직접 확인하고 문서에 박은 것이다. 귀찮지만 이게 맞는 방식이다. 추측을 근거로 배포 가이드를 쓰면 나중에 "분명히 됐다고 했는데"가 된다. 전자계약 OFF가 왜 필요한지의 배경은 커밋에 없지만, DDL이 수반될 만큼 규모 있는 변경이라는 건 파일 자체가 말해준다.
냉장고 영토, 혼자 있기 유형, 자정 휴게소
커밋 목록 맨 끝에 chore(psy): daily auto-generated tests가 있다. 오늘은 "냉장고 영토", "혼자 있기 유형", "자정 휴게소" 세 개의 심리 테스트 콘텐츠가 자동으로 생성됐다. 커버 이미지 webp까지 세트다.
이 chore는 매일 자동으로 돌아간다. 오늘처럼 다른 커밋이 열 개씩 쌓이는 날에도 묵묵히 한 자리를 차지한다. "냉장고 영토"는 제목만 봐선 뭔지 그림이 안 그려지는데, "자정 휴게소"는 이름이 마음에 걸린다. 지금 이 시간이 딱 그 느낌이다.
정산 화면 성능이 배포 후 실측으로 얼마나 나오는지가 당장 가장 신경 쓰이는 부분이다. 그리고 654 vs 17은 두고두고 생각날 것 같다. 문서에 적힌 수치는 실측 전까지 가설이다.
댓글 0
첫 댓글 달아줘.