티스토리 뷰
1. 서론
이전 포스팅에서 불필요한 전체 조회와 반복 쿼리를 제거하고, ID 기반 분리 조회 방식으로 인기글 API의 쿼리 구조를 개선했었습니다. 그 결과, 쿼리 수는 1,000회 이상 → 9회, 응답 시간은 5초 이상 → 약 80ms 수준으로 크게 줄일 수 있었습니다.
이번에는 DB 레벨에서 성능을 더 끌어올려 보기 위해 인덱스를 적용해보도록 하겠습니다.
2. 왜 인덱스가 필요할까?
지금 구조에서는 다음과 같은 쿼리들이 실행됩니다.
1) 인기글 ID 조회 쿼리
SELECT m.id
FROM meeting m
LEFT JOIN like l ON l.meeting_id = m.id
WHERE m.recruitment_type = ? AND m.is_confirmed = false
GROUP BY m.id
ORDER BY COUNT(l.id) DESC
LIMIT 3;
- 필터링 컬럼: recruitment_type, is_confirmed
- 정렬 대상: COUNT(l.id)
2) 기술 스택 조회 쿼리
SELECT mts.*, ts.name
FROM meeting_tech_stack mts
LEFT JOIN tech_stack ts ON mts.tech_stack_id = ts.id
WHERE mts.meeting_id IN (?, ?, ?);
- 필터링 컬럼: meeting_id (IN 조건)
3) 좋아요 수 조회 쿼리 (3회 반복)
SELECT COUNT(*) FROM like WHERE meeting_id = ?;
- 필터링 컬럼: meeting_id
4) 좋아요 여부 조회 쿼리 (3회 반복, 로그인 사용자만)
SELECT EXISTS (
SELECT 1 FROM like WHERE meeting_id = ? AND user_id = ?
);
- 필터링 컬럼: meeting_id, user_id
각 기능마다 쿼리의 필터링 컬럼과 정렬 대상을 정리한 이유는, 이들이 실제로 조회 성능에 직접적인 영향을 미치기 때문입니다.
- COUNT나 EXISTS 쿼리는 테이블 전체를 훑는 방식(풀스캔)으로 동작할 가능성이 높습니다.
- 특히 데이터가 많아질수록 WHERE 조건에 인덱스가 없다면 탐색 비용이 기하급수적으로 증가합니다.
- 이런 쿼리를 빠르게 처리하기 위해, 조건 컬럼에 인덱스를 적용하면 성능을 크게 개선할 수 있습니다.
3. 인덱스 설계를 해보자
위에서 정리한 쿼리 구조와 필터링 컬럼을 기반으로, 다음과 같은 컬럼들에 인덱스를 적용했습니다.
[ 인덱스 설계 기준 ]
- WHERE 조건에 자주 사용되는 컬럼 우선
- JOIN의 기준 컬럼
- 정렬 대상 (ORDER BY) 포함 시 복합 인덱스 고려
3-1. Like 테이블
1) 좋아요 수 조회 쿼리
EXPLAIN SELECT COUNT(*) FROM like WHERE meeting_id = 123;
- 복합 인덱스 없음
type | key | rows | Extra |
ALL | NULL | 50000 | Using where |
- ALL → 풀스캔 ❌
- rows = 50,000 → 테이블 전체 훑음 → 비효율적
- meeting_id 단일 인덱스 존재
type | key | rows | Extra |
ref | idx_like_meeting | 50 | Using index |
- ref → 인덱스 잘 씀 ✅
- rows = 50 → meeting_id = 123 에 해당하는 평균 좋아요 수.
- 빠르게 조회됨, 풀스캔 방지
- meeting_id + user_id 복합 인덱스만 존재(단일 인덱스 없음)
type | key | rows | Extra |
ref | idx_like_meeting_user | 50 | Using index |
- 복합 인덱스에서도 meeting_id 단독 조건 잘 작동 ✅
- 단일 인덱스 없어도 문제 없음.
2) 좋아요 여부 조회 쿼리
EXPLAIN SELECT EXISTS (
SELECT 1 FROM like WHERE meeting_id = 123 AND user_id = 456
);
- meeting_id 단일 인덱스만 존재
type | key | rows | Extra |
ref | idx_like_meeting | 50 | Using where |
- meeting_id 인덱스만 사용 → user_id 조건은 추가 필터링 필요.
- 성능 괜찮지만, user_id 조건까지 최적화는 못함 ❌
- meeting_id + user_id 복합 인덱스 존재
type | key | rows | Extra |
const | idx_like_meeting_user | 1 | Using index |
- const → 거의 즉시 탐색 ✅
- rows = 1 → meeting_id + user_id 조합을 정확히 찾음 → 빠름
3) 결론
복합 인덱스 (meeting_id, user_id) 하나만 있어도, 좋아요 수 조회와 좋아요 여부 확인 모두 효율적으로 처리 가능하며,
특히 meeting_id 단독 조건도 복합 인덱스의 왼쪽 컬럼이므로 문제 없이 최적화가 되는 것을 확인할 수 있습니다.
CREATE INDEX idx_like_meeting_user ON like (meeting_id, user_id);
3-2. Meeting 테이블
인덱스 대상 | 이유 |
recruitment_type, is_confirmed | WHERE 조건 최적화 |
end_date, is_confirmed | 마감임박 인기글 정렬 (ORDER BY end_date ASC) |
1) 인기글 ID 조회
EXPLAIN
SELECT m.id
FROM meeting m
LEFT JOIN like l ON l.meeting_id = m.id
WHERE m.recruitment_type = '스터디' AND m.is_confirmed = false
GROUP BY m.id
ORDER BY COUNT(l.id) DESC
LIMIT 3;
- 인덱스 적용 전
type | key | rows | Extra |
ALL | NULL | 1000 | Using where; Using temporary; Using filesort |
- ALL → 테이블 풀스캔 ❌
- Using temporary; Using filesort → GROUP BY + ORDER BY 비효율적 처리
- recruitment_type + is_confirmed 복합 인덱스 적용
type | key | rows | Extra |
ref | idx_meeting_recruit_confirm | 500 | Using index condition; Using where |
- ref → 인덱스 잘 사용 ✅
- WHERE 조건 필터링이 인덱스로 최적화됨.
- Using temporary; Using filesort는 여전히 있음 (GROUP BY, ORDER BY 때문)
2) 마감 임박 인기글 조회
EXPLAIN
SELECT *
FROM meeting
WHERE is_confirmed = false
ORDER BY end_date ASC
LIMIT 3;
- 인덱스 적용 전
type | key | rows | Extra |
ALL | NULL | 1000 | Using where; Using filesort |
- 풀스캔 + 정렬 → 성능 비효율 ❌
- end_date + is_confirmed 복합 인덱스 적용
type | key | rows | Extra |
index | idx_meeting_enddate_confirm | 3 | Using where; Using index |
- index → 인덱스 스캔으로 정렬 최적화 ✅
- LIMIT 3 덕분에 인덱스에서 빠르게 상위 3개 찾음.
- rows = 3 → 바로 정렬된 데이터 조회 가능
3) 결론
recruitment_type, is_confirmed 복합 인덱스를 통해 인기글 ID 조회 시 WHERE 조건 필터링 성능이 향상을 했고,
end_date, is_confirmed 복합 인덱스는 마감 임박 인기글 정렬 쿼리에서 정렬 과정이 인덱스 스캔으로 대체되어, filesort 제거 및 빠른 LIMIT 처리가 가능하게 처리했습니다.
CREATE INDEX idx_meeting_recruit_confirm ON meeting (recruitment_type, is_confirmed);
CREATE INDEX idx_meeting_enddate_confirm ON meeting (end_date, is_confirmed);
3-3. meeting_tech_stack 테이블
1) 기술 스택 조회
EXPLAIN
SELECT mts.*, ts.name
FROM meeting_tech_stack mts
LEFT JOIN tech_stack ts ON mts.tech_stack_id = ts.id
WHERE mts.meeting_id IN (101, 202, 303);
- 인덱스 적용 전
type | key | rows | Extra |
ALL | NULL | 3000 | Using where; Using join buffer |
- ALL → 풀스캔 ❌
- join buffer 사용 → 비효율적 JOIN 처리
- meeting_id 조건 없으면, 3,000건 전체 탐색
- meeting_id 단일 인덱스 적용
type | key | rows | Extra |
range | idx_meeting_tech_stack_meeting | 9 | Using where |
- range → IN 조건에 인덱스 사용 ✅
- rows = 9 → 평균적으로 3개 인기글에 대해 9건 정도 조회 예상
- 빠른 조회 가능, JOIN도 인덱스 기반으로 최적화됨
3) 결론
meeting_tech_stack 테이블에 meeting_id 인덱스를 적용함으로써, IN 조건 기반 기술 스택 조회가 풀스캔 없이 빠르게 처리되었다. 특히 인기글 API 특성상 항상 3개의 meeting_id를 대상으로 조회하기 때문에, range 인덱스 스캔을 통해 최소한의 행만 탐색하는 효율적인 구조를 갖추게 되었다.
결과적으로, 기술 스택 조회 시 JOIN 조건 최적화가 이루어졌으며, 전체 API 응답 속도 개선에 일정 부분 기여하게 되었다.
CREATE INDEX idx_meeting_tech_stack_meeting ON meeting_tech_stack (meeting_id);
4. [고민] 조회 최적화 vs 쓰기 성능 저하
서비스를 운영하다 보면, 조회 성능을 높이기 위해 인덱스를 적극 적용하고 싶지만 동시에 쓰기 성능 저하를 걱정하게 되는 순간이 옵니다. 저 역시 StarHub 서비스를 개발하면서, 인기글 기능을 위해 인덱스 최적화를 고민하는 과정에서 비슷한 고민을 겪었습니다.
그렇다면 인기글 API처럼 빠른 응답 속도가 중요한 기능에서도, 인덱스를 과감히 적용해도 괜찮을까?
4-1. 조회 최적화를 선택한 이유
StarHub 서비스에서 인기글 조회를 포함해 Like, Meeting, meeting_tech_stack 테이블은 조회 빈도가 매우 높은 편입니다.
특히 인기글 API는 사용자 접근 빈도가 높고, 빠른 응답 속도가 사용자 경험에 직접적인 영향을 미칩니다.이러한 데이터 특성을 종합적으로 고려했을 때, 조회 최적화에 집중하는 편이 현재 시스템에는 더 큰 효과를 가져올 것으로 판단했습니다.
물론 지금은 데이터 규모와 쓰기 비율이 낮기 때문에 인덱스 추가가 부담되지 않지만, 장기적으로 데이터가 급증하거나 쓰기 트래픽이 늘어날 가능성도 염두에 두고 있습니다. 추후에는 읽기 전용 DB를 별도로 분리해 쓰기 부하를 줄이는 방안도 고려할 수 있습니다.
예를 들어, 사용자가 참여 중인 모임 리스트를 조회하거나, 특정 모임의 상세 페이지를 확인할 때 좋아요 수나 사용자의 좋아요 여부를 빠르게 보여주어야 합니다. 전체 모임 목록 페이지에서는 사용자별 좋아요 상태를 실시간으로 표시하기도 합니다.
즉, 인기글 API뿐만 아니라 서비스 전반에서 읽기 성능이 곧 서비스 품질을 좌우하고 있는 상황입니다. 이번 인기글 기능 최적화 과정에서, "읽기 성능을 우선시한다"는 명확한 원칙을 세울 수 있었습니다.
5. 실제 적용해보자
개발 작업을 위해서 로컬에서는 JPA의 테이블 자동 생성 기능을 활용하기 위해 @Index 어노테이션을 활용해 엔티티에 정의하여 관리하였습니다.
운영 환경에서는 현재 AWS RDS를 활용하고 있기 때문에, 직접 SQL 스크립트를 작성해서 인덱스를 수동 반영했습니다.
CREATE INDEX idx_like_meeting_user ON like (meeting_id, user_id);
CREATE INDEX idx_meeting_recruit_confirm ON meeting (recruitment_type, is_confirmed);
CREATE INDEX idx_meeting_enddate_confirm ON meeting (end_date, is_confirmed);
CREATE INDEX idx_meeting_tech_stack_meeting ON meeting_tech_stack (meeting_id);
6. 결과를 확인해보자
인덱스를 적용 전에는 쿼리 구조 개선만으로도 응답 시간 약 80ms까지 줄어들었지만, 추가적으로 인덱스를 적용한 후에는 각 쿼리의 처리 속도가 더 개선되어, 전체 응답 시간은 40~60ms 수준으로 더 줄어드는 효과를 확인할 수 있었습니다.
'Troubleshooting' 카테고리의 다른 글
[인기글 불러오기] #4 @Cacheable로 발생한 메모리 문제와 GC 분석 실험기 (0) | 2025.04.30 |
---|---|
[인기글 불러오기] #3 캐싱 적용해보자 (0) | 2025.04.07 |
[인기글 불러오기] #1 쿼리 구조 개선 (0) | 2025.04.07 |
[이메일 발송 기능] #3 비동기 성능 최적화 (0) | 2025.04.04 |
[이메일 발송 기능] #2 별도 스레드를 활용한 비동기 호출 방식 (0) | 2025.01.10 |
- 인메모리 db
- @ManyToOne
- 스키마 자동 생성
- 즉시 로딩
- 최적화
- @TransactionalEventListener
- 메일
- 엔티티 매니저
- N + 1
- @Id
- @MappedSuperclass
- @Entity
- Redis
- 연관관계
- @Cacheable
- 준영속
- @GeneratedValue
- 변경감지
- mappedBy
- @Table
- 조인 전략
- 비동기
- JPA
- 비영속
- @OneToMany
- @joincolumn
- 1차 캐시
- onetoone
- 영속성 컨텍스트
- 단일 테이블 전략