Database

캐시를 고쳤는데 안 빨라졌다

문제: “데이터 조회가 느리다”

사용자 리포트는 이 한 줄이었다.

  • 화면 — 배출량 산정의 원데이터 목록 탭
  • 증상 — 목록이 뜨기까지 눈에 띄게 대기
  • 주어진 정보 — 그게 전부. 어느 쿼리인지, 어느 카테고리인지 없음

여기서 두 갈래가 갈린다. 의심 가는 곳을 고칠 것인가, 어디가 느린지 먼저 잴 것인가. 나는 전자를 골랐고, 그게 틀렸다.

1차 대응: 캐시와 병렬화

화면을 열어보고 눈에 띈 것부터 고쳤다.

  • 발견 — 배출계수 상세조회가 탭을 나갔다 올 때마다 매핑된 그룹 수(N건)만큼 재호출
  • 원인 — API 슬라이스 기본값이 keepUnusedDataFor: 0 (캐시 없음)
  • 조치 — 상세조회 엔드포인트만 keepUnusedDataFor: 300으로 오버라이드
// 검색 목록 API는 최신성이 중요해 무캐시 유지
// 상세조회만 5분 캐시로 재방문 시 재조회 비용 제거
getScope3GhgEmissionFactor: builder.query({
  keepUnusedDataFor: 300,
})

하나 더 있었다.

  • 발견 — 일괄 자동 매핑이 그룹마다 순차 조회 (await-in-loop)
  • 조치 — Promise.all 병렬 조회로 변경
  • 효과 — 그룹 수만큼 더해지던 대기시간 → 가장 느린 요청 1건 시간

두 조치 모두 그 자체로는 옳다. 불필요한 재조회를 없앴고, 순차를 병렬로 바꿨다. 코드 리뷰에서 지적받을 것도 없다.

그런데 화면은 여전히 느렸다.

왜 안 빨라졌나: 고친 곳이 병목이 아니었다

내가 고친 것들의 공통점을 뒤늦게 봤다.

  • 배출계수 상세조회 — 탭을 재방문할 때 발생하는 비용
  • 순차→병렬 — 일괄 자동 매핑 버튼을 눌렀을 때의 비용
  • 사용자가 느리다고 한 것 — 목록을 처음 여는 순간

세 번째와 앞의 두 개가 겹치지 않는다. 재방문 비용을 없애도 첫 방문은 그대로고, 매핑 버튼을 빠르게 해도 목록 로딩과는 무관하다.

증상은 “목록이 느리다”였는데 나는 목록 쿼리를 한 번도 보지 않았다. 화면을 열어보고 “여기 비효율이 있네” 싶은 곳을 고쳤을 뿐이다.

이때 커밋 메시지에 적어둔 문장이 나중에 스스로를 겨눴다 — "데이터 조회가 느리다" 리포트 대응. 리포트에 대응하긴 했는데, 리포트가 가리킨 곳에 대응하지는 않았다.

진짜 원인: ORDER BY 안의 상관 서브쿼리

목록 쿼리를 직접 열어봤다. 운송 카테고리(C4/C9)의 원데이터 목록이었고, 정렬 기준이 상관 서브쿼리로 들어가 있었다.

-- 수정 전: 서브쿼리에 스코프가 없다
WHERE COALESCE(d3.shipment_uid, CONCAT('row:', r3.id)) = ...

바깥 쿼리에는 회사·연도·카테고리 조건이 제대로 걸려 있었다. 문제는 ORDER BY 안에서 도는 서브쿼리(r3)에 그 조건이 하나도 없었다는 것이다.

  • 바깥 WHERE — company_id, collection_year, category 로 이 회사 데이터만 선택
  • 정렬용 서브쿼리 — 조건 없음. 전체 테넌트(모든 회사) 데이터를 스캔
  • 상관 서브쿼리이므로 — 결과 행마다 한 번씩 그 스캔이 반복

즉 이 회사 행 100건을 정렬하려고 전 회사 데이터를 100번 훑고 있었다. 데이터가 쌓일수록, 다른 회사가 늘어날수록 느려지는 구조다. 우리 회사 데이터만 늘어난 게 아닌데도 느려진다는 게 이 결함의 성질이다.

수정은 세 줄이었다.

-- 수정 후: 서브쿼리에도 같은 스코프를 건다
WHERE r3.company_id = :companyId
  AND r3.collection_year = :year
  AND r3.category IN ('CAT_04','CAT_09')
  AND COALESCE(d3.shipment_uid, CONCAT('row:', r3.id)) = ...

데이터 유출은 아니었지만 정렬은 어긋날 수 있었다

이건 성능 이슈로 시작했지만 검토하다 보니 정확성 문제도 있었다.

  • 반환되는 행 — 바깥 WHERE로 이미 회사 스코프됨. 다른 회사 데이터가 나오지는 않는다
  • 다만 정렬 기준 — 전 테넌트를 보고 계산됨
  • 따라서 — 다른 회사가 우연히 같은 shipment_uid를 쓰면 정렬 순서가 뒤섞일 수 있다

빈도가 낮고 눈에 잘 안 띄는 종류의 버그다. 그래서 회귀 테스트를 붙였다.

  • 시나리오 — 다른 회사가 더 작은 id로 같은 shipment_uid를 사용
  • 검증 — 그래도 이 회사 스코프 기준 정렬이 깨지지 않는가
  • 확인 — 수정 전 코드로는 이 테스트가 실패함을 직접 돌려봤다

테스트를 새로 쓸 때 이 마지막 단계를 건너뛰면 안 된다. 수정 후에만 통과하는 걸 확인하면 “이 테스트가 정말 그 버그를 잡는가”를 모른다. 수정 전 코드에 붙여 빨간불을 먼저 본 다음에야 그 테스트를 믿을 수 있다.

같은 화면의 중복 호출

원인을 찾으면서 하나 더 나왔다.

  • 상황 — 분류 필터를 선택하지 않은 “전체” 칩 상태
  • 동작 — 동일한 카운트 쿼리를 totalLegstotalLegsUnfiltered 두 변수로 각각 호출
  • 조치 — 조건이 같을 때는 한 번만 호출하고 값을 공유

필터가 없으면 “필터된 개수”와 “필터 없는 개수”가 같다는 당연한 사실을 코드가 모르고 있었다.

남겨둔 것

같은 조회에 병목이 하나 더 있는데, 이번엔 손대지 않았다.

  • 대상 — 선적 그룹핑 키를 COALESCE(...) / CONCAT(...)으로 그때그때 만드는 부분
  • 문제 — 함수로 감싼 컬럼은 비-sargable. 인덱스를 못 탄다
  • 제대로 된 해법 — shipment_key를 컬럼으로 저장하고 인덱스를 거는 설계 변경
  • 판단 — 이번 범위를 넘어섬. 커밋 메시지에 명시하고 남겨둠

고칠 수 있는 것과 이번에 고칠 것은 다르다. 스코프 3줄은 위험이 거의 없지만 스키마 변경은 마이그레이션·백필·배포 순서가 따라붙는다. 같은 PR에 섞으면 되돌릴 때 같이 되돌아간다.

같은 함정의 다른 얼굴: 행마다 도는 쿼리

이 조회를 고친 뒤, 같은 성격의 문제를 다른 곳에서도 찾았다. 배출량 산정의 라벨 부착 로직이었다.

  • 기존 — 행마다 findByCompanyIdAndCategoryAndGroupingKey1AndGroupingKey2 호출
  • 결과 — 행이 수십~수백 건인 카테고리에서 [적용] 클릭마다 그 건수만큼 DB 왕복
  • 수정 — (companyId, category) 단위로 findByCompanyIdAndCategory 배치 조회 1회 + 메모리 매칭

측정치는 커밋에 이렇게 남겼다.

산정 10k 48s → 0.085s, 결과불변 골든 82테스트

행 1만 건 기준이고, 직접 재서 커밋에 적은 값이다. 앞의 상관 서브쿼리와 형태는 다르지만 원리는 같다 — 바깥 반복문 안에 쿼리가 들어 있으면 데이터가 늘어날 때 곱해진다.

여기서 신경 쓴 건 속도보다 결과가 변하지 않았음을 증명하는 쪽이었다.

  • SQL의 null / '' 처리를 메모리 매칭에서도 동일하게 정규화
  • 혼합 입력에서도 정확히 동작하도록 그룹별로 처리
  • 골든 테스트 82개 — 수정 전후 출력이 완전히 동일한지 비교

성능 개선에서 가장 흔한 실패는 느려서가 아니라 빨라졌는데 답이 달라져서 생긴다. 배치 조회로 바꾸면 SQL이 하던 비교를 애플리케이션이 넘겨받는데, 이때 null 취급 하나만 어긋나도 결과가 조용히 달라진다.

남는 교훈

이번 작업에서 실제로 시간을 쓴 순서는 이랬다.

단계 한 일 결과
1 눈에 띄는 비효율 수정 (캐시·병렬화) 체감 변화 없음
2 실제 목록 쿼리 확인 원인 발견
3 스코프 3줄 추가 + 회귀 테스트 해결

1단계에서 고친 코드는 지금도 그대로 남아 있다. 틀린 수정이 아니라 틀린 대상이었기 때문이다. 그래서 더 위험했다 — 리뷰에서도 걸러지지 않고, 커밋 로그만 보면 성능 개선 작업이 정상적으로 진행된 것처럼 보인다.

측정을 먼저 해야 한다는 건 알고 있었다. 그런데도 건너뛴 이유는 “눈에 보이는 비효율”이 측정보다 싸 보였기 때문이다. 실제로는 그게 더 비쌌다. 헛수고한 시간에 더해, 한동안 “고쳤는데 왜 그대로지”라는 잘못된 전제를 안고 다음 단서를 찾았다.

지금은 순서를 이렇게 잡는다.

  • 리포트의 동작을 특정한다 — “느리다”가 아니라 “무엇을 눌렀을 때 느린가”
  • 그 동작이 부르는 쿼리를 직접 본다 — 화면을 보고 추측하지 않는다
  • 고친 뒤 같은 동작으로 재확인한다 — 다른 경로가 빨라진 건 증거가 아니다