카테고리 없음

Django + Lambda 환경에서 매치 리스트 API 쿼리 최적화 삽질기

수구리 2026. 4. 10. 18:14
반응형

오늘은 관리자용 리스트 API의 성능을 개선하면서 배운 점을 정리한다. 결론부터 말하면, 캐시보다 먼저 봐야 할 것은 실행 계획이었다.
아래 내용은 외부 공개를 위해 도메인명, 수치, 일부 구현 세부를 일반화했다.

배경

운영 중인 관리자용 API에서 응답 시간이 간헐적으로 크게 튀는 문제가 있었다.

APM을 보면 패턴은 크게 두 가지였다.

패턴 특징 원인
A 애플리케이션 로직은 짧은데 전체 응답만 길다 런타임 초기화 비용
B 요청 시간 대부분이 DB 구간에서 소비된다 SQL 병목

느린 패턴에서 특히 눈에 띈 쿼리는 특정 기간 데이터의 최소/최대 일시를 구하는 집계 쿼리였다.

첫 번째 시도: ORDER BY LIMIT 1

처음에는 aggregate보다 ORDER BY ... LIMIT 1이 인덱스를 더 잘 탈 것이라고 생각했다.

# Before
queryset.aggregate(first_date=Min("scheduled_at"), last_date=Max("scheduled_at"))

# After
first = queryset.order_by("scheduled_at").values("scheduled_at").first()
last = queryset.order_by("-scheduled_at").values("scheduled_at").first()

하지만 결과는 오히려 나빠졌다. EXPLAIN을 보니 정렬 과정에서 Using temporary, Using filesort가 추가됐다.

핵심은 LIMIT 1이 있어도, JOIN 결과 전체를 정렬해야 첫 행을 반환할 수 있다는 점이었다.

교훈은 단순했다. 이론적으로 빨라 보이는 변경도 EXPLAIN 없이 판단하면 쉽게 빗나간다.

근본 원인: JOIN이 옵티마이저 선택을 꼬이게 했다

실행 계획을 더 자세히 보니 문제는 aggregate 자체가 아니었다. 관련 테이블을 따라가는 JOIN 때문에 옵티마이저가 비효율적인 순서를 선택하고 있었고, 그 결과 메인 테이블의 인덱스를 충분히 활용하지 못했다.

즉, 문제는 MIN/MAXORDER BY LIMIT 1이냐가 아니라, 필터링을 위해 붙인 JOIN 구조가 쿼리 전체를 느리게 만들고 있었다.

두 번째 시도: 캐싱

다음으로는 JOIN을 우회하려고 관련 ID 목록을 캐시에 저장한 뒤, 본 쿼리에서 IN (...)으로 조회하는 방식을 떠올렸다.

처음에는 그럴듯해 보였지만 금방 비용이 드러났다.

  • 전역 캐시 때문에 테스트 격리 비용이 늘어났다.
  • 캐시를 비활성화하거나 설정을 바꾸면 다른 테스트들이 연쇄적으로 흔들렸다.
  • 성능 최적화와 직접 관련 없는 보조 수정이 계속 따라붙었다.
  • TTL 동안 stale 데이터를 감수해야 했다.

작은 쿼리 최적화를 위해 설계 복잡도와 운영 부담을 크게 늘린 셈이었다.

최종 해결: 서브쿼리

결국 가장 단순한 방법이 가장 좋았다. JOIN으로 바로 필터링하지 않고, 먼저 조건을 만족하는 관련 ID 서브쿼리를 만든 뒤 메인 테이블에서 IN (subquery)로 거는 방식이다.

# Before
queryset = Listing.objects.filter(
    location__group__is_active=True,
    location__group__is_public=True,
    state__in=VISIBLE_STATES,
    scheduled_at__gte=start,
    scheduled_at__lt=end,
)

# After
available_location_ids = Location.objects.filter(
    group__is_active=True,
    group__is_public=True,
).values("id")

queryset = Listing.objects.filter(
    location_id__in=available_location_ids,
    state__in=VISIBLE_STATES,
    scheduled_at__gte=start,
    scheduled_at__lt=end,
)

이 방식으로 바꾸자 외부 쿼리는 메인 테이블 중심으로 단순해졌고, 기존 인덱스를 훨씬 자연스럽게 활용할 수 있었다. 정렬 부하도 사라졌고, 별도 캐시 없이도 충분히 빠른 응답을 얻을 수 있었다.

왜 이 방식이 더 좋았나

접근 복잡도 캐시 의존 데이터 신선도 테스트 영향
캐싱 높음 있음 TTL 영향
서브쿼리 낮음 없음 실시간 작음

결과적으로 캐시 코드를 제거하고, 몇 줄의 쿼리 변경만으로 같은 문제를 더 단순하게 해결할 수 있었다.

오늘의 교훈

1. EXPLAIN을 먼저 보자

감으로 튜닝하면 돌아가기 쉽다. 실행 계획 한 번이 추측 여러 번보다 훨씬 싸다.

2. 테스트 복잡성은 좋은 경고 신호다

성능 최적화 하나 때문에 테스트 격리, 설정 분기, 보조 수정이 계속 늘어난다면 접근이 과한 것일 수 있다.

3. 기본적인 ORM 패턴이 의외로 강하다

고급 캐시 전략보다, 단순한 서브쿼리 한 번이 더 효과적인 경우가 많다.

4. JOIN 자체보다 "어떤 실행 계획이 나오는가"가 중요하다

JOIN이 항상 나쁜 것은 아니다. 다만 현재 인덱스, 데이터 분포, 조건 조합에서 옵티마이저가 어떤 선택을 하는지는 꼭 확인해야 한다.

5. 인프라 이슈와 쿼리 이슈는 분리해서 봐야 한다

같은 느린 응답이라도 원인이 런타임 초기화 비용인지, DB 병목인지에 따라 해결책은 완전히 달라진다.

마무리

오늘 가장 크게 배운 건 "복잡한 최적화보다 먼저 실행 계획을 보자"는 점이었다. 캐시를 붙이기 전에 쿼리 구조를 다시 보면, 더 단순하고 더 안전한 해법이 나오는 경우가 많다.

반응형