개발 slecs

결제 분해 동기화 제거로 쿼리 성능 개선

목차

특정 화면이 느리다는 건 이미 알고 있었다. 무시할 수 없는 수준이었는데, 더 불편한 건 데이터가 쌓일수록 선형으로 나빠지는 구조라는 거였다. '지금은 괜찮다'는 말이 통하지 않는 케이스. 방치하면 시간이 지날수록 손 대기 더 어려워지는 종류의 문제라서 이번에 정리했다.

변경 파일은 세 개다. 내부 클래스 하나, SQL 매퍼 하나, 뷰/스타일 파일 하나. 규모만 보면 작아 보이지만 핵심 조회 경로에 걸려 있던 파일들이라서 체감 차이는 컸다.

뭐가 문제였나

시작은 EXPLAIN이었다.

-- 개선 전 실행 계획 (요약)
-- select_type: SIMPLE
-- type: ALL                  → 인덱스 미사용, 풀 테이블 스캔
-- rows: 수만 건              → 데이터 누적될수록 선형 증가
-- Extra: Using temporary, Using filesort

type: ALL이 뜨면 인덱스를 타지 않고 테이블 전체를 읽는다는 뜻이다. Using temporaryUsing filesort가 같이 붙으면 ORDER BY나 GROUP BY 처리를 위한 임시 테이블을 만드는 중이라는 건데, 이게 반복 실행되면 서버 스펙과 무관하게 버텨내질 못한다. 데이터가 적을 때는 빠르게 끝나니까 안 보이다가, 누적이 되는 시점에 갑자기 터지는 유형이다.

원인은 크게 두 갈래였다.

결제 분해 동기화가 조회 흐름 안에 동기로 물려 있었다. 결제 데이터를 읽어올 때 항목 단위 분해 처리가 함께 돌면서 불필요한 조인과 반복 스캔이 생겼다. 분해 연산 자체는 필요하지만, 조회 시점에 굳이 붙어서 실행될 이유가 없는 연산이었다. 처음 설계할 때 편의상 묶어 놓은 게 그대로 굳어버린 케이스인데, 이런 게 오래되면 주변 코드가 그 구조를 의존하기 시작해서 나중에 건드리기 무서워진다.

SubordinateCount가 분산된 채로 중복 실행되고 있었다. 하위 항목 개수를 세는 로직이 여러 경로에서 각자 DB를 찌르는 구조였다. 결과가 동일한 집계를 여러 번 날리는 거라서 통합하면 바로 DB 왕복이 줄어드는 지점이었다.

어떻게 바꿨나

세 파일의 역할을 정리하면 이렇다.

파일 유형 변경 내용
내부 클래스 SubordinateCount 통합 - 중복 집계 로직을 단일 경로로 통일, 불필요한 호출 제거
SQL 매퍼 결제 분해 동기화 제거, 쿼리 재작성, 복합 인덱스 활용 조건 정렬
뷰/스타일 매퍼 변경에 따른 바인딩 조정

SQL 매퍼 재작성이 핵심이었다. 방향은 크게 네 가지로 잡았다.

  • SELECT * 걷어내고 실제로 쓰는 컬럼만 명시적으로 나열
  • WHERE 조건이 인덱스를 탈 수 있는 형태로 재정렬
  • 서브쿼리 반복 실행 구간을 조인으로 전환해 중복 스캔 제거
  • 결제 분해 연산을 조회 흐름에서 분리

복합 인덱스 설계할 때 챙긴 것들이 있다. 등치 조건(=)에 해당하는 컬럼을 앞에 두고, 범위 조건(BETWEEN, >, <)에 해당하는 컬럼을 뒤에 배치하는 게 기본이다. 카디널리티, 즉 값의 종류 수가 많은 컬럼을 앞에 두는 게 원칙이기도 한데, 실제 쿼리 패턴이 항상 그 원칙과 딱 맞아떨어지진 않는다. EXPLAIN으로 실측하면서 컬럼 순서를 조정하는 게 제일 정확하다.

-- 복합 인덱스 구성 예시
-- 실제 컬럼명은 도메인 특성상 익명 처리
CREATE INDEX idx_payment_lookup
  ON payment_table (category_col, created_at, status_col);

-- 위 인덱스를 탈 수 있는 조건 형태
WHERE category_col = ?
  AND created_at BETWEEN ? AND ?
  AND status_col = ?
ORDER BY created_at DESC
LIMIT 50;

인덱스를 추가할 때마다 따라오는 트레이드오프가 있다. 인덱스가 많아지면 INSERT/UPDATE 시 인덱스를 함께 갱신해야 하니까 쓰기 비용이 생긴다. 읽기 빈도가 압도적으로 높은 테이블이면 문제없지만, 쓰기가 잦은 테이블에 인덱스를 마구 붙이다 보면 전체 처리량이 오히려 떨어지는 경우가 있다. 이번엔 조회가 훨씬 많은 경로라서 인덱스 추가 방향으로 결론냈다.

SubordinateCount 통합은 내부 클래스 쪽이었는데, 산재된 집계 로직을 한 곳으로 모으는 작업이라 영향 범위 파악이 먼저였다. 중복 제거가 목적이기 때문에 기능적으로 동일한 결과를 내야 했고, 통합 전후 결과가 다르면 버그가 되는 작업이라서 꼼꼼하게 봤다. 여러 경로에서 계산하던 걸 단일 집계 경로로 통일하고, 나머지는 그 결과를 참조하는 구조로 바꿨다.

이번 작업으로 다시 확인한 것들

성능이 체감할 수 있는 수준으로 개선됐다. 정확한 수치보다 더 직관적인 지표는, 기다린다는 느낌이 사라진 것이다.

작업하면서 다시 확인한 게 몇 가지 있다.

쿼리 느리다는 느낌이 오면 코드 수정보다 EXPLAIN이 먼저다. typerows 두 개만 봐도 방향이 잡힌다. type: ALL이면 인덱스 구조나 쿼리 조건 문제, Using temporary가 붙으면 정렬/그룹핑 처리 방식 문제다. 원인 파악 없이 인덱스부터 붙이거나 캐싱으로 덮으면 근본 구조는 그대로 남아있어서 나중에 다시 걸린다.

동기 연산이 조회 경로에 섞이면 언젠가 발목을 잡는다. 결제 분해처럼 쓰기/변환 성격의 연산이 읽기 흐름에 묶여 있으면, 데이터가 적을 땐 드러나지 않다가 누적 시점에 터진다. 조회는 조회만, 연산/집계는 별도 경로로 분리하는 게 기본인데 처음 설계 단계에서 그렇게 구분하는 게 쉽지 않을 때가 있다. 그게 쌓이면 기술 부채가 된다.

중복 로직 통합은 타이밍이 중요하다. SubordinateCount처럼 여러 곳에 퍼져 있는 계산을 하나로 모으는 작업은, 너무 이른 시점에 하면 요구사항 변화로 다시 분리해야 하는 경우가 생기고, 너무 늦으면 주변 코드가 각자의 구현에 의존하기 시작해서 건드리기 무거워진다. 이번엔 타이밍이 잘 맞았다.

파일 세 개짜리 작업이지만, 실제 코딩 시간보다 실행 계획 분석하고 로직 흐름 파악하고 영향 범위 체크하는 데 시간이 더 걸렸다. 그게 맞는 순서라고 생각한다. 쿼리 성능 작업의 목표는 빠르게 고치는 게 아니라 정확하게 고치는 거라서, 분석이 부실하면 고친 것 같아도 다른 지점에서 또 터진다.

댓글 0

첫 댓글 달아줘.