20260522 [TIL] - 채팅 문의 인덱싱 정리

2026. 5. 22. 22:52·IL/TIL

개요

문의 채팅의 핵심 테이블은 support_rooms와 support_messages다. 두 테이블은 같은 문의 채팅 도메인 안에 있지만, 조회 패턴과 쓰기 부담이 다르게 나타난다.

  • support_rooms는 고객 문의방 목록, 상담사 배정 목록, 운영 큐, 응답 지연 방, 종료 이력처럼 운영 화면에서 반복적으로 조회된다.
  • support_messages는 최신 메시지 조회, 커서 기반 이전 메시지 조회, unread count, AI fallback용 최신 USER 메시지 탐색처럼 room 단위의 메시지 접근이 반복된다.
  • 인덱스 비교는 데이터 적재 시간과 분리해 보는 편이 더 명확하다고 봤다. 먼저 seed 테이블을 만들고, 그 테이블을 후보군별로 복제한 뒤 no-index와 각 인덱스 구성을 같은 데이터셋에서 비교했다.

이번 정리의 목적은 인덱스를 많이 붙이는 것이 아니라, 실제 조회 패턴에서 읽기 이득과 쓰기 부담이 어디에서 균형을 이루는지 확인하는 데 있다.


🧭 조회 패턴

support_rooms

시나리오 where / order by 핵심

고객 문의방 목록 customer_user_id, last_message_at desc, id desc
상담사 배정 방 목록 counselor_user_id, status, last_message_at desc, id desc
운영 큐 목록 status, counselor_user_id is null, customer_requested_counselor_at, last_message_at
응답 지연 방 목록 status, counselor_last_active_at <= cutoff
종료 이력 목록 last_counselor_user_id, status, updated_at desc, id desc
solved 자동 종료 배치 status, solved_at <= cutoff

support_messages

시나리오 where / order by 핵심

최신 메시지 50개 room_id, id desc
커서 기반 이전 메시지 50개 room_id, id < beforeMessageId, id desc
AI fallback 최신 USER 메시지 room_id, sender_type, id desc
고객 unread count room_id, id > lastRead, sender 제외 조건
상담사 unread count room_id, id > lastRead, sender 제외 조건

1. 후보군 정의

support_rooms 후보군

후보군 기준

no-index PK 외 추가 인덱스 없이 baseline으로 비교
list-optimized 고객 목록, 상담사 배정 목록처럼 목록성 조회를 먼저 받치기 위한 구성
operation-optimized 목록 조회에 더해 운영 큐, 응답 지연, 종료 이력까지 함께 고려한 구성
heavy 운영형 구성 위에 상태 중심 보조 인덱스를 더 올린 확장 후보

no-index

  • PK만 사용하고 추가 인덱스는 두지 않았다.

list-optimized

create index idx_customer_last_message_id
on support_rooms (customer_user_id, last_message_at desc, id desc);

create index idx_counselor_status_last_message_id
on support_rooms (counselor_user_id, status, last_message_at desc, id desc);

operation-optimized

create index idx_customer_last_message_id
on support_rooms (customer_user_id, last_message_at desc, id desc);

create index idx_counselor_status_last_message_id
on support_rooms (counselor_user_id, status, last_message_at desc, id desc);

create index idx_queue_request_id
on support_rooms (status, counselor_user_id, customer_requested_counselor_at, id);

create index idx_status_counselor_active_id
on support_rooms (status, counselor_last_active_at, id);

create index idx_last_counselor_status_id
on support_rooms (last_counselor_user_id, status, id);

create index idx_status_solved_at_id
on support_rooms (status, solved_at, id);

heavy

create index idx_customer_last_message_id
on support_rooms (customer_user_id, last_message_at desc, id desc);

create index idx_counselor_status_last_message_id
on support_rooms (counselor_user_id, status, last_message_at desc, id desc);

create index idx_queue_request_id
on support_rooms (status, counselor_user_id, customer_requested_counselor_at, id);

create index idx_status_counselor_active_id
on support_rooms (status, counselor_last_active_at, id);

create index idx_last_counselor_status_id
on support_rooms (last_counselor_user_id, status, id);

create index idx_status_solved_at_id
on support_rooms (status, solved_at, id);

create index idx_customer_status_id
on support_rooms (customer_user_id, status, id);

create index idx_counselor_status_id
on support_rooms (counselor_user_id, status, id);

create index idx_status_last_message_id
on support_rooms (status, last_message_at desc, id desc);

support_messages 후보군

후보군 기준

no-index PK 외 추가 인덱스 없이 baseline으로 비교
minimal room 단위 최신/이전 메시지 조회를 가장 낮은 비용으로 받치기 위한 최소 구성
fallback minimal에 sender_type 조건을 더해 AI fallback 조회까지 보강한 구성
heavy fallback 위에 message_type, created_at, sender_user_id 축까지 넓힌 확장 후보

minimal

create index idx_room_id_id
on support_messages (room_id, id);

fallback

create index idx_room_id_id
on support_messages (room_id, id);

create index idx_room_id_sender_type_id
on support_messages (room_id, sender_type, id);

heavy

create index idx_room_id_id
on support_messages (room_id, id);

create index idx_room_id_sender_type_id
on support_messages (room_id, sender_type, id);

create index idx_room_id_message_type_id
on support_messages (room_id, message_type, id);

create index idx_room_id_created_at_id
on support_messages (room_id, created_at, id);

create index idx_sender_user_id_created_at_id
on support_messages (sender_user_id, created_at, id);

🧪 측정 환경과 지표

항목 값

도구 JUnit 5, JdbcTemplate, MySQL EXPLAIN
Spring Boot 4.0.5
Gradle 8.14.4
JDK Temurin 21.0.10
MySQL 8.0
기준 데이터 bench_support_rooms_seed, bench_support_messages_seed
support_rooms row 수 50,000
support_messages row 수 50,000
비교 방식 seed 테이블 복제 후 후보군별 인덱스 적용
read 측정 warm-up 1회, measured 5회
write 측정 warm-up 1회, measured 5회, 라운드마다 seed 복제

write 시나리오별 작업 수

테이블 시나리오 operations per round

support_rooms last message update 5,000
support_rooms counselor active update 1,500
support_rooms counselor assign update 300
support_messages message insert 5,000

지표

지표 의미

avg 평균 처리 시간
min 가장 빠른 샘플
max 가장 느린 샘플
stddev 샘플 간 흔들림
diff vs no-index no-index 대비 변화율
EXPLAIN 실제로 어떤 인덱스를 탔는지 확인하는 근거

read는 avg / min / max / stddev를 ms 단위로 비교했고, write는 1건당 평균 ms 기준으로 비교했다.


2. 실행 결과

📌 support_rooms 읽기 성능

시나리오 no-index list-optimized operation-optimized heavy

customer room list 17.302ms 10.119ms (-41.52%) 9.760ms (-43.59%) 9.831ms (-43.18%)
assigned counselor room list 17.136ms 10.834ms (-36.78%) 10.973ms (-35.97%) 10.287ms (-39.97%)
queue list 19.661ms 16.668ms (-15.22%) 16.682ms (-15.15%) 18.539ms (-5.70%)
stale response room list 20.142ms 17.910ms (-11.08%) 9.120ms (-54.72%) 8.867ms (-55.98%)
closed history list 17.185ms 18.264ms (+6.28%) 10.349ms (-39.78%) 9.480ms (-44.84%)

support_rooms 읽기 상세 지표

시나리오 no-index list-optimized operation-optimized heavy

customer room list 17.302 / 16.311 / 17.912 / 0.620 10.119 / 9.730 / 10.605 / 0.397 9.760 / 9.247 / 9.979 / 0.311 9.831 / 9.090 / 10.650 / 0.680
assigned counselor room list 17.136 / 16.791 / 17.423 / 0.227 10.834 / 10.278 / 11.491 / 0.458 10.973 / 10.024 / 12.462 / 1.095 10.287 / 9.548 / 11.057 / 0.579
queue list 19.661 / 18.702 / 20.571 / 0.806 16.668 / 15.906 / 17.064 / 0.456 16.682 / 16.238 / 17.418 / 0.505 18.539 / 17.089 / 20.891 / 1.418
stale response room list 20.142 / 19.143 / 21.531 / 0.993 17.910 / 17.248 / 19.739 / 1.048 9.120 / 8.605 / 10.087 / 0.621 8.867 / 8.561 / 9.326 / 0.288
closed history list 17.185 / 15.683 / 18.941 / 1.237 18.264 / 17.384 / 19.544 / 0.873 10.349 / 9.883 / 10.496 / 0.261 9.480 / 8.849 / 9.871 / 0.412

📌 support_rooms 쓰기 성능

시나리오 no-index list-optimized operation-optimized heavy

last message update 11.023ms 11.724ms (+6.36%) 11.508ms (+4.41%) 11.463ms (+3.99%)
counselor active update 11.101ms 11.109ms (+0.07%) 10.921ms (-1.62%) 11.237ms (+1.22%)
counselor assign update 6.546ms 6.565ms (+0.29%) 6.648ms (+1.56%) 6.661ms (+1.76%)

support_rooms 쓰기 상세 지표

시나리오 no-index list-optimized operation-optimized heavy

last message update 11.023 / 10.569 / 11.348 / 0.338 11.724 / 11.001 / 12.408 / 0.511 11.508 / 11.206 / 12.102 / 0.349 11.463 / 11.036 / 11.920 / 0.369
counselor active update 11.101 / 10.467 / 11.611 / 0.507 11.109 / 10.522 / 11.740 / 0.495 10.921 / 10.609 / 11.444 / 0.321 11.237 / 10.696 / 11.946 / 0.510
counselor assign update 6.546 / 6.495 / 6.633 / 0.053 6.565 / 6.354 / 6.711 / 0.159 6.648 / 6.476 / 6.743 / 0.105 6.661 / 6.579 / 6.880 / 0.127

📌 support_messages 읽기 성능

시나리오 no-index minimal fallback heavy

latest 50 messages 12.247ms 8.541ms (-30.26%) 8.459ms (-30.93%) 8.073ms (-34.08%)
previous 50 messages by cursor 12.647ms 9.570ms (-24.33%) 9.657ms (-23.64%) 7.726ms (-38.91%)
ai fallback latest user message 8.110ms 8.875ms (+9.43%) 8.986ms (+10.80%) 8.186ms (+0.93%)
customer unread count 8.785ms 8.200ms (-6.66%) 7.738ms (-11.92%) 8.935ms (+1.71%)
counselor unread count 8.178ms 8.068ms (-1.35%) 11.733ms (+43.46%) 9.592ms (+17.29%)

support_messages 읽기 상세 지표

시나리오 no-index minimal fallback heavy

latest 50 messages 12.247 / 11.428 / 13.210 / 0.659 8.541 / 7.796 / 9.811 / 0.824 8.459 / 7.914 / 8.952 / 0.457 8.073 / 7.756 / 8.427 / 0.302
previous 50 messages by cursor 12.647 / 11.702 / 14.064 / 0.988 9.570 / 9.166 / 10.215 / 0.469 9.657 / 9.315 / 10.002 / 0.262 7.726 / 7.301 / 8.418 / 0.489
ai fallback latest user message 8.110 / 7.792 / 8.284 / 0.188 8.875 / 8.413 / 9.303 / 0.336 8.986 / 8.251 / 9.530 / 0.536 8.186 / 7.915 / 8.402 / 0.199
customer unread count 8.785 / 8.433 / 9.038 / 0.239 8.200 / 7.722 / 8.621 / 0.432 7.738 / 7.216 / 8.435 / 0.450 8.935 / 8.729 / 9.322 / 0.248
counselor unread count 8.178 / 7.586 / 8.912 / 0.507 8.068 / 7.346 / 8.449 / 0.445 11.733 / 9.460 / 19.498 / 4.347 9.592 / 9.079 / 9.971 / 0.343

📌 support_messages 쓰기 성능

시나리오 no-index minimal fallback heavy

message insert 10.852ms 10.848ms (-0.04%) 10.532ms (-2.95%) 10.924ms (+0.66%)

support_messages 쓰기 상세 지표

시나리오 no-index minimal fallback heavy

message insert 10.852 / 10.658 / 11.049 / 0.183 10.848 / 10.606 / 11.404 / 0.322 10.532 / 10.370 / 10.713 / 0.123 10.924 / 10.792 / 10.985 / 0.076

3. EXPLAIN에서 먼저 본 변화

support_rooms

시나리오 no-index 인덱스 적용 후 해석

customer room list type=ALL, rows=50000, Using where; Using filesort idx_customer_last_message_id, type=ref, rows=100, Using index 고객 목록은 customer_user_id + last_message_at 축이 직접 먹혔다.
assigned counselor room list type=ALL, rows=50000 list-optimized는 idx_counselor_status_last_message_id, operation/heavy는 idx_queue_request_id 상담사 + 상태 조합이 들어가면 인덱스 후보가 달라질 수 있다는 점이 보였다.
queue list type=ALL, rows=50000, Using filesort list/operation은 idx_counselor_status_last_message_id, heavy는 idx_counselor_status_id 운영 큐는 목록 인덱스만으로는 완전히 정리되지 않았고, 여전히 filesort가 남았다.
stale response room list type=ALL, rows=50000, Using filesort operation/heavy는 idx_status_counselor_active_id, type=range, rows=11886 응답 지연 조회는 운영형 인덱스의 필요성이 가장 분명하게 드러난 구간이었다.
closed history list type=ALL, rows=50000, Using filesort operation/heavy는 idx_last_counselor_status_id, type=ref, rows=1 종료 이력은 last_counselor_user_id + status 축이 직접 효과를 냈다.

support_messages

시나리오 no-index 인덱스 적용 후 해석

latest 50 messages PRIMARY, backward scan idx_room_id_id, type=ref, rows=100, Using index 최신 메시지 조회는 (room_id, id) 하나만으로도 충분히 줄었다.
previous 50 messages by cursor PRIMARY, rows=24958, backward scan minimal은 rows=99, fallback은 skip scan, heavy는 created_at index 기반 filesort 커서 페이지네이션은 (room_id, id)가 가장 직관적으로 맞았고, 확장 인덱스는 오히려 우회 경로를 타기도 했다.
ai fallback latest user message no-index도 backward scan으로 빠름 fallback/heavy는 idx_room_id_sender_type_id 사용 sender_type 보조 인덱스는 실행 계획에는 반영됐지만, 평균 시간 이득은 크지 않았다.
unread count PRIMARY, rows=3962 모든 인덱스 후보에서 idx_room_id_id, rows=4 unread 계산은 room 축을 먼저 좁히는 구성이 중요했다.

4. 최종 선택

support_rooms는 operation-optimized

operation-optimized는 읽기와 쓰기 사이의 균형이 가장 좋았다.

  • customer room list: 43.59%
  • stale response room list: 54.72%
  • closed history list: 39.78%
  • queue list도 list-optimized와 거의 같은 수준이었다.

쓰기 저하는 크지 않았다.

  • last message update: +4.41%
  • counselor active update: 1.62%
  • counselor assign update: +1.56%

heavy는 일부 조회에서 더 빠르긴 했지만, 운영 큐에서 오히려 밀렸고 쓰기에서도 분명한 추가 이점을 만들지는 못했다. list-optimized는 목록성 조회에는 맞았지만 운영성 조회까지 같이 받치기에는 부족했다.

최종 구성은 아래 6개다.

create index idx_customer_last_message_id
on support_rooms (customer_user_id, last_message_at desc, id desc);

create index idx_counselor_status_last_message_id
on support_rooms (counselor_user_id, status, last_message_at desc, id desc);

create index idx_queue_request_id
on support_rooms (status, counselor_user_id, customer_requested_counselor_at, id);

create index idx_status_counselor_active_id
on support_rooms (status, counselor_last_active_at, id);

create index idx_last_counselor_status_id
on support_rooms (last_counselor_user_id, status, id);

create index idx_status_solved_at_id
on support_rooms (status, solved_at, id);

support_messages는 minimal

support_messages는 읽기 이득만 보는 것보다, insert 비용을 거의 건드리지 않으면서 핵심 조회를 얼마나 안정적으로 받쳐 주는지가 더 중요했다.

minimal은 다음 구간에서 고르게 안정적이었다.

  • latest 50 messages: 30.26%
  • previous 50 messages by cursor: 24.33%
  • customer unread count: 6.66%
  • counselor unread count: 1.35%
  • message insert: 0.04%

fallback은 message insert만 보면 -2.95%로 가장 좋았지만, 읽기에서는 counselor unread count가 +43.46%까지 흔들렸고 stddev도 4.347로 가장 컸다. heavy는 일부 페이지 조회가 더 빨랐지만, 전체 기본안으로 두기에는 과한 구성에 가까웠다.

최종 구성은 아래 1개다.

create index idx_room_id_id
on support_messages (room_id, id);

🔍 더 고려해 볼 수 있는 점

메시지 row 수를 더 키운 보강 비교

이번 정리는 support_rooms 50,000, support_messages 50,000 기준이다. 실제 문의 채팅에서는 room보다 message가 더 빠르게 누적되므로, 다음 단계에서는 support_messages만 더 큰 row 수로 다시 비교해 보는 것도 자연스럽다.

count 쿼리 분리

페이지 기반 API가 늘어나면 COUNT 쿼리 비용이 다시 병목으로 드러날 수 있다. 그 시점에는 count 전용 쿼리를 분리해서 JOIN과 DISTINCT를 줄이는 방향을 함께 검토할 수 있다.

unread 계산 구조 분리

지금 unread는 last_read_message_id 기준으로 다시 계산하는 구조다. 규모가 더 커지면 room summary materialization이나 별도 unread 집계 구조로 나누는 방향도 고려할 수 있다.

저작자표시 비영리 (새창열림)

'IL > TIL' 카테고리의 다른 글

사용자/제품 로그 설계  (0) 2026.05.27
CloudFront 기반 이미지 에셋 관리 방식 채택  (0) 2026.05.26
최종프로젝트에서 flyway 쓰는 이유  (0) 2026.05.21
외부 AI Provider 호출 보호를 위한 Redis 기반 Rate Limit 적용  (0) 2026.05.20
20260510 [TIL] - 카페 주문 시스템  (0) 2026.05.10
'IL/TIL' 카테고리의 다른 글
  • 사용자/제품 로그 설계
  • CloudFront 기반 이미지 에셋 관리 방식 채택
  • 최종프로젝트에서 flyway 쓰는 이유
  • 외부 AI Provider 호출 보호를 위한 Redis 기반 Rate Limit 적용
견지
견지
개발로 개발하는지 새발로 개발하는지 내가 개인 건지 새인 건지 사람인 건지
  • 견지
    개발새발
    견지
  • 전체
    오늘
    어제
    • 분류 전체보기 (33)
      • ... (0)
      • IL (33)
        • TIL (29)
        • WIL (4)
        • MIL (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    java
    git
    DB
    JavaScript
    CSS
    oracle
    JSP
    HTML
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
견지
20260522 [TIL] - 채팅 문의 인덱싱 정리
상단으로

티스토리툴바