성능개선의 출발점은 고객의 행동 관찰
Nunu • KR Dongjae Shin, AX, Enterprise Solution Engineer
"내부 백오피스 서비스에 특정 페이지 검색하면 아예 표시가 안 돼요."
어느 날 CX팀을 통해 접수된 한 줄짜리 제보였습니다. biz-crm은 채널톡 내부에서 고객사 정보, 요금 청구, 이벤트 이력 등을 관리하는 백오피스 서비스로, CX팀이 매일 사용하는 핵심 도구입니다. 검색이 안 된다는 제보는 가볍게 넘길 수 없었습니다.
이 글은 biz-crm 서비스에 IndexedDAO 패턴을 도입하기까지의 과정을 기록한 글로 "왜 느린가"를 파고들다가 발견한 것들, 그리고 그 과정에서 얻은 Customer-driven에 대한 이야기입니다.
biz-crm은 채널톡 내부에서 고객사 정보, 요금 청구, 이벤트 이력 등을 관리하는 백오피스 서비스입니다. 주요 대용량테이블은 크게 세 가지입니다.
event_schemas — 각 채널별 커스텀 이벤트 스키마 정보
billing / bill_usages / invoices — 요금 청구 관련 데이터
change_logs — 설정 변경 이력 로그
문제가 된 건 이 대용량 테이블들에 대한 검색 쿼리였습니다. 특히 해당 서비스 고객인 CX팀이 이름이나 키 값으로 필터링할 때, 조건에 따라 타임아웃이 발생하거나 수 초씩 응답이 지연되는 상황이었어요.
사용자 입장에서는 상담 시 "검색이 안 된다"는 경험이지만 내부에서 보면 쿼리가 인덱스를 타지 못하고 1000만건 이상의 테이블을 Full Table Scan을 하면서 6초 이상의 응답시간이 걸리는 문제가 있었습니다. 얼마 지나지 않아 Sentry에서도 알람이 울리기 시작했습니다.
입사 후 첫 과제로 이 문제를 받았을 때, 저는 바로 EXPLAIN을 돌리거나 인덱스를 추가하는 대신 다른 것부터 했습니다.
무작정 기술적 튜닝(EXPLAIN, Indexing)을 시도하기 전에, "사용자가 이 기능을 진짜 어떻게 쓰는지" 확인부터 하자.
biz-crm의 실제 사용자들을 인터뷰했습니다. 핵심 질문은 하나였어요.
biz-crm은 id, name, bizTeam, bizDivision, roles 등 테이블의 다양한 필드를 AND/OR로 조합할 수 있는 Expression 필터를 제공하고 있었습니다.
그런데 돌아온 대답은 예상 밖이었어요.
"그냥 채널ID 복사해서 붙여넣기만 해요."
"AND 조건만 가끔 써요."
Insight 1 — 불필요한 범용성
정교하게 설계된 Expression 다중 필터가 실제로는 거의 쓰이지 않고 있었습니다. 기능은 강력하지만, 그 강력함이 오히려 성능 문제의 원인이 되고 있었던 거죠.
change_logs, event_schemas 페이지에 대해서도 물어봤습니다.
"이 테이블 전체 조회 안 합니다."
"특정 채널(ChannelID)의 최신 데이터 정도만 확인하면 됩니다."
Insight 2 — 조회 조건 ChannelID 필터 강제화 가능
실제로 필요한 것은 특정 채널의 데이터를 빠르게 조회하는 것이었습니다. 전체 테이블을 대상으로 하는 범용 필터가 필요한 케이스는 거의 없었어요. 즉, ChannelID를 필수 조건으로 강제해도 사용성에 전혀 문제가 없었습니다.
인터뷰를 통해 방향이 잡히고 기술적 해결방법을 조사하고 비교했습니다.
왜 인덱스를 타지 못했을까요? EXPLAIN 분석을 돌려보니 원인이 명확하게 나왔습니다.
-- 문제가 된 쿼리 패턴
WHERE LOWER(col) = 'value'
대소문자를 무시한 검색(Case-insensitive)을 구현하기 위해 LOWER() 함수를 컬럼에 적용하고 있었는데, 이 방식은 컬럼 자체에 걸린 인덱스를 무력화합니다. 인덱스는 원본 값 기준으로 만들어져 있는데, LOWER(col)은 매번 함수 연산을 수행하기 때문에 인덱스를 활용할 수 없게 됩니다.
기존 코드를 살펴보면, equalIgnoreCase, contains 같은 JOOQ 메서드들이 내부적으로 이 패턴을 생성하고 있었습니다. 작은 테이블에서는 문제가 없었지만, 데이터가 쌓이면서 병목이 드러난 것이었죠.
조금 더 파고들어보면, 문제는 사용자가 검색시 날리는 Expression 형태에서 시작되고 있었습니다.
EQUAL, CONTAIN 등 모든 연산자에서 .cast(SQLDataType.VARCHAR).equalIgnoreCase(x), .containsIgnoreCase(x) 형태를 사용하고 있었습니다. 이 메서드들은 내부적으로 LOWER(col) 패턴을 생성하기 때문에, 아무리 쿼리를 잘 짜도 인덱스를 타지 못하는 구조가 되어 있었던 것입니다.
원인을 파악하고 나서 세 가지 선택지를 놓고 비교했습니다.
CREATE INDEX ... ON (LOWER(name))
코드 수정이 필요 없다는 장점이 있었지만, 자주 조회 조건으로 사용되는 모든 컬럼에 인덱스를 추가해야 한다는 문제가 있었습니다. 1,000만 건 테이블에 인덱스를 새로 만드는 비용도 부담이었고요. 결정적으로 인터뷰에서 확인했듯 사용자가 쓰지도 않는 조건들에까지 인덱스를 추가하는 건 낭비였습니다.
equalIgnoreCase → equal 변경)인덱스를 즉시 활용할 수 있는 가장 단순한 방법이었습니다. 하지만 기존 biz-crm의 Expression Filter를 사용하는 모든 곳에 영향이 갈 수 있는 구조였습니다. 범용 필터 자체를 건드리는 만큼 사이드이펙트 리스크가 컸어요.
인터뷰에서 얻은 인사이트를 통해 아래 내용들의 추상화된 DAO를 만들 수 있었습니다.
사용자의 불편함으로 직결되는 큰 테이블에서는 만능 필터를 포기하고 '속도'를 선택한다.
자주 쓰는 조회 조건만 분리하여 인덱스 기반의 IndexedDAO 패턴을 도입한다.
기존 Expression Filter는 그대로 두고, 성능이 중요한 큰 테이블에 한해서만 IndexedDAO를 적용하는 전략이었습니다. 기존 코드에 영향을 주지 않으면서, 인터뷰에서 확인한 실제 사용 패턴에 딱 맞는 해결책이었습니다.
단순히 인덱스를 추가하는 것만으로는 충분하지 않았습니다. 같은 문제가 다른 테이블, 다른 쿼리에서도 반복되고 있었거든요. 근본적으로 "인덱스를 의식하는 쿼리"를 만들 수 있는 구조가 필요했습니다.
이를 위해 도입한 것이 IndexedDAO 패턴입니다. 핵심 아이디어는 간단합니다.
Generic Expression으로 작성된 쿼리를 SearchRequest 단위로 추상화하고, 자주 사용하는 조회조건으로 인덱스 히트를 강제한다.
기존에는 JOOQ의 JooqExpressionConverter가 equalIgnoreCase, contains 등을 범용적으로 변환하다 보니 인덱스 활용 여부가 불투명했습니다. 리팩토링 후에는 검색 조건을 SearchRequest로 명시적으로 선언하고, IndexedDAO 레이어에서 인덱스 히트를 보장하는 쿼리를 생성하도록 책임을 분리했습니다.
상위 클래스가 모든 검색 로직을 처리하므로, 구현체는 테이블과 엔티티 클래스만 지정하면 됩니다. 새로운 IndexedDAO를 추가할 때 필요한 것은 SearchRequest DTO와 DAO 클래스 두 개가 전부입니다.
타임아웃을 유발하던 change_logs 8초짜리 쿼리가 200ms대로 내려왔습니다. Full Table Scan을 하던 쿼리가 인덱스를 타기 시작한 결과입니다. 이후 동일한 패턴으로 고통받던 다른 테이블에도 순차적으로 적용할 수 있는 구조가 마련되었습니다.
이 작업을 하면서 계속 머릿속에 맴돈 단어가 있었습니다. Customer-driven.
성능 최적화 과제는 개발자가 먼저 발견해서 개선하는 경우도 많지만, 이번 케이스는 달랐습니다. 기술적 튜닝보다 먼저 "사용자가 이 기능을 실제로 어떻게 쓰는가"를 물어본 것이 전체 방향을 바꿔놓았습니다.
제가 생각하는 Customer-driven이란 단지 "원하는 기능을 만든다"는 뜻만이 아닙니다. 사용자가 가장 먼저 느끼는 것을 기준점으로 삼는 태도입니다.
앞으로도 고객 경험에서 출발해 채널톡에서 고객의 "진짜 문제를 해결하는" 작업을 끊임없이 이어가겠습니다.
We Make a Future Classic Product