pile·
DB / 데이터·channel-talk채널톡·

biz-crm IndexedDAO 적용기

채널톡 내부 백오피스 biz-crm에서 "특정 페이지 검색하면 아예 표시가 안 된다"는 CX팀 제보로 시작해, 8초짜리 쿼리를 200ms대로 줄인 과정이다. 특징은 EXPLAIN을 돌리거나 인덱스를 추가하기 전에 실제 사용자를 인터뷰한 순서다. 정교하게 설계된 다중 필터가 거의 쓰이지 않고 사용자는 채널ID를 복사해 붙여넣기만 한다는 답이 돌아왔고, 그 인사이트가 해법의 방향을 바꿨다.

핵심 포인트
  • 원인은 대소문자 무시 검색을 위해 컬럼에 LOWER()를 적용한 것이었다. 인덱스는 원본 값 기준으로 만들어져 있어 매번 함수 연산을 하면 인덱스를 못 탄다.
  • JOOQ의 특정 메서드들이 내부적으로 이 패턴을 생성하고 있었다. 작은 테이블에서는 문제가 없다가 데이터가 쌓이며 병목이 드러났다.
  • 인터뷰에서 다중 필터가 거의 안 쓰이고 채널ID를 필수 조건으로 강제해도 사용성에 문제가 없다는 것이 확인됐다.
  • 그래서 기존 범용 필터는 그대로 두고, 성능이 중요한 큰 테이블에만 인덱스를 의식하는 IndexedDAO를 적용하는 전략을 택했다.
  • 타임아웃을 유발하던 8초 쿼리가 200ms대로 내려왔다.
상세 정리
  • 서비스 성격: biz-crm은 고객사 정보, 요금 청구, 이벤트 이력 등을 관리하는 백오피스로 CX팀이 매일 쓰는 핵심 도구다. 검색이 안 된다는 제보를 가볍게 넘길 수 없었던 이유다.
  • 증상: 대용량 테이블에서 이름이나 키 값으로 필터링할 때 조건에 따라 타임아웃이 나거나 수 초씩 지연됐다. 사용자에게는 검색이 안 되는 경험이지만 내부에서는 쿼리가 인덱스를 타지 못하고 6초 이상 걸리는 문제였고 Sentry 알람도 울리기 시작했다.
  • 접근 순서: 입사 후 첫 과제로 이 문제를 받았을 때 바로 기술적 튜닝을 시도하는 대신 사용자가 이 기능을 진짜 어떻게 쓰는지부터 확인했다.
  • 인터뷰 결과 1: 여러 필드를 AND/OR로 조합할 수 있는 Expression 다중 필터를 제공하고 있었는데 실제로는 거의 쓰이지 않았다. 기능은 강력하지만 그 강력함이 오히려 성능 문제의 원인이 되고 있었다.
  • 인터뷰 결과 2: 테이블 전체 조회는 하지 않고 특정 채널의 최신 데이터 정도만 확인하면 된다는 답이었다. 즉 채널ID를 필수 조건으로 강제해도 문제가 없었다.
  • EXPLAIN 분석: 대소문자 무시 검색 구현을 위해 컬럼에 함수를 적용한 방식이 인덱스를 무력화하고 있었다. 사용자가 날리는 필터 표현식에서 cast와 대소문자 무시 비교 메서드가 그 패턴을 만들어내 아무리 쿼리를 잘 짜도 인덱스를 못 탔다.
  • 후보 1 함수 기반 인덱스 추가: 코드 수정이 필요 없다는 장점이 있지만 자주 쓰이는 모든 컬럼에 인덱스를 추가해야 하고, 1,000만 건 테이블에 인덱스를 새로 만드는 비용도 부담이었다. 결정적으로 사용자가 쓰지도 않는 조건에까지 인덱스를 추가하는 건 낭비였다.
  • 후보 2 범용 필터 수정: 인덱스를 즉시 활용할 수 있는 가장 단순한 방법이지만 Expression Filter를 쓰는 모든 곳에 영향이 갈 수 있어 사이드이펙트 리스크가 컸다.
  • 후보 3 채택: 큰 테이블에서는 만능 필터를 포기하고 속도를 선택하고, 자주 쓰는 조회 조건만 분리해 인덱스 기반 DAO 패턴을 도입한다.
  • 설계 아이디어: Generic Expression으로 작성된 쿼리를 DAO 단위로 추상화하고 자주 쓰는 조회 조건으로 인덱스 히트를 강제한다. 리팩터링 전에는 범용 변환 과정에서 인덱스 활용 여부가 불투명했지만, 이후에는 DAO 레이어가 인덱스 히트를 보장하는 쿼리를 생성하도록 책임을 분리했다.
  • 확장성: 상위 클래스가 모든 검색 로직을 처리하므로 구현체는 테이블과 엔티티 클래스만 지정하면 되고, 새 DAO를 추가할 때 필요한 것은 SearchRequest DTO와 DAO 클래스 두 개뿐이다.
  • 결과: Full Table Scan을 하던 쿼리가 인덱스를 타기 시작해 change_logs 조회가 8초에서 200ms대로 내려왔고, 같은 패턴으로 고통받던 다른 테이블에도 순차 적용할 수 있는 구조가 마련됐다.
  • 회고: 단순히 인덱스를 추가하는 것만으로는 부족했고 같은 문제가 다른 테이블과 쿼리에서도 반복되고 있었기 때문에, 인덱스를 의식하는 쿼리를 만들 수 있는 구조가 필요했다고 정리한다.
  • 저자가 말하는 customer-driven은 원하는 기능을 만든다는 뜻만이 아니라 사용자가 가장 먼저 느끼는 것을 기준점으로 삼는 태도다.
왜 읽나대소문자 무시 검색 때문에 인덱스가 죽은 상황을 겪는 백엔드 개발자에게 원인과 세 가지 대안의 비교를 주고, 튜닝 전에 사용 패턴부터 확인하는 순서의 효과를 보여준다.
channel-talk
채널톡 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. DB / 데이터·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    분석가가 직접 만든 마케팅 자동화 시스템 — 오디언스 빌더 제작기

    여기어때 데이터 분석팀(UB팀)이 개발 경험 없이 AI 에이전트 바이브 코딩으로 마케팅 자동화 시스템 '오디언스 빌더'를 구축한 과정이다. SQL 없이 UI에서 타겟 조건을 설정하면 BigQuery에서 수천만 행 대상자를 추출해 Kafka·S3를 통해 마케팅 도구로 전달하는 파이프라인을 직접 만들어, 캠페인 준비 시간을 2~3일에서 10분으로 단축했다.

    요약 이어보기
    #data-pipeline#kafka#postgres+2
  2. DB / 데이터·vercel-blogVercel Blog·

    모든 Vercel 빌드를 지탱하는 데이터베이스 마이그레이션 이야기

    Vercel이 빌드 웜 풀(warm pool) 상태 데이터를 Redis에서 DynamoDB로 마이그레이션한 과정을 다룬다. 빌드 풀의 청구 매핑 데이터가 Redis 장애 시 복구 불가능하다는 구조적 위험이 이주를 결정한 핵심 이유였고, 24/7 프로덕션 트래픽 속에서 다운타임 없이 5단계 점진적 전환을 완료했다.

    요약 이어보기
    #database-migration#zero-downtime#redis+2
  3. DB / 데이터·channel-talk채널톡·

    DynamoDB 핫 파티션을 해결하는 3가지 방법 (3): 조회를 인덱스 테이블로 옮기기

    채널톡이 DynamoDB 핫 파티션을 해결하려 GSI를 별도 인덱스 테이블로 떼어낸 연재의 마지막 편으로, 조회 경로를 GSI에서 인덱스 테이블로 옮긴 과정을 다룬다. 2편에서 쓰기 파이프라인이 완성돼 WriteThrottleEvents가 월 110만 회에서 0회가 됐지만, 조회 코드가 여전히 GSI를 읽고 있어 GSI를 지울 수 없었다. GSI가 읽기에서 대신해주던 키 해석·정확 일치·삭제 필터·페이지 채우기를 사내 공통 라이브러리의 조회 규칙으로 흡수해, 호출부는 GSI든 인덱스 테이블이든 같은 코드를 쓰게 만들었다.

    요약 이어보기
    #migration#dynamodb#hot-partition+5