1. 문제 인식: 기존 검색 시스템의 성능 한계
중고 상품 플랫폼에서 다양한 조건으로 상품을 검색하는 기능은 핵심 서비스이지만, 데이터 양이 증가함에 따라 검색 속도가 크게 저하되는 현상이 발생하였다. 이는 사용자 경험을 저해하는 심각한 문제로 대두되었다.
1.1. 성능 테스트 설계 (item-performance.http)
다양한 검색 시나리오를 검증하기 위해 5가지 요청을 구성하였다:
### 1. [검색] 데이터가 많은 뒷부분 검색 (Full Scan 시 느림, Index 적용 시 빠름)
# 설명: 'Mock 상품 제목 90'으로 시작하는 데이터를 검색합니다. (LIKE 'Mock 상품 제목 90%')
# 쿼리: title LIKE %...% (contains)
GET http://localhost:8000/items?title=Mock 상품 제목 90&page=0&size=20
Content-Type: application/json
### 2. [검색] 데이터가 적은 앞부분 검색
GET http://localhost:8000/items?title=Mock 상품 제목 10&page=0&size=20
Content-Type: application/json
### 3. [검색] 가격 조건 포함 검색 (복합 인덱스 고려용)
# 가격이 50,000원 이상인 'Mock' 상품 검색
GET http://localhost:8000/items?title=Mock&minPrice=50000&page=0&size=20
Content-Type: application/json
### 4. [정렬] 낮은 가격순 정렬 (Sort 성능 테스트)
GET http://localhost:8000/items?title=Mock&sort=lowPrice&page=0&size=20
Content-Type: application/json
### 5. [정렬] 최신순 정렬 (기본값)
GET http://localhost:8000/items?title=Mock&sort=&page=0&size=20
Content-Type: application/json
- 데이터가 많은 뒷부분 검색 - 'Mock 상품 제목 90'으로 시작하는 데이터 검색
- 데이터가 적은 앞부분 검색 - 'Mock 상품 제목 10'으로 시작하는 데이터 검색
- 가격 조건 포함 검색 - 'Mock' 상품 중 50,000원 이상인 상품 검색
- 낮은 가격순 정렬 - 가격 기준 오름차순 정렬
- 최신순 정렬 - 생성일 기준 내림차순 정렬
1.2. 초기 인덱스 상태 점검
인덱스 적용 전 테이블 구조를 확인한 결과, 기본 키(PK) 인덱스만 존재하고 제목 검색을 위한 전용 인덱스가 전혀 없는 상태였다. 이는 모든 텍스트 검색이 Full Table Scan 방식으로 수행될 수밖에 없는 구조적 문제를 의미하였다.
인덱스 상태 확인을 위해 아래와 같이 `SHOW INDEX FROM items;` 쿼리를 수행

mysql> SHOW INDEX FROM items;
+-------+------------+-----------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+-----------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| items | 0 | PRIMARY | 1 | item_id | A | 985977 | NULL | NULL | | BTREE | | | YES | NULL |
| items | 1 | FKhssxds3s9crgpmniepyr7sgh9 | 1 | seller_id | A | 2 | NULL | NULL | | BTREE | | | YES | NULL |
+-------+------------+-----------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.03 sec)
1.3. 초기 성능 측정 결과
인덱스가 없는 상태에서 5개 요청을 실행한 결과, 총 4,900ms라는 심각한 수준의 응답 시간이 측정되었다. 이는 사용자가 검색 시 마다 5초 가까이 대기해야 하는 상황으로, 서비스 이용에 직접적인 장애물이 되는 수치였다.
결과 로그 보기)
Testing started at 오후 8:31 ...
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 11:31:40 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T203140.200.json
Response code: 200; Time: 834ms (834 ms); Content length: 4662 bytes (4.66 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 11:31:40 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T203140-1.200.json
Response code: 200; Time: 832ms (832 ms); Content length: 4666 bytes (4.67 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 11:31:41 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T203141.200.json
Response code: 200; Time: 792ms (792 ms); Content length: 4667 bytes (4.67 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 11:31:42 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T203142.200.json
Response code: 200; Time: 761ms (761 ms); Content length: 4631 bytes (4.63 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 11:31:43 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T203143.200.json
Response code: 200; Time: 790ms (790 ms); Content length: 4665 bytes (4.67 kB)

2. 해결 방안 1: 기본 인덱스 최적화 시도
2.1. 인덱스 전략 수립
다양한 검색 패턴을 고려하여 4개의 인덱스를 체계적으로 설계하였다:
-- 카테고리별 최신순 조회 최적화
CREATE INDEX idx_items_category_created_at ON items (category, created_at DESC);
-- 판매 상태별 최신순 조회 최적화
CREATE INDEX idx_items_status_created_at ON items (status, created_at DESC);
-- 가격 필터링 성능 향상
CREATE INDEX idx_items_price ON items (price);
-- 전체 상품 최신순 정렬 전용
CREATE INDEX idx_items_created_at ON items (created_at DESC);
2.2. 1차 개선 결과 분석
인덱스 적용 후 성능을 측정한 결과, 대부분의 요청이 200ms 이내로 개선되는 효과를 확인하였다. 그러나 2번 요청(Mock 상품 제목 10 검색)만 여전히 2,796ms라는 높은 수치를 기록하는 예외 현상이 발견되었다.
결과 로그 보기)
Testing started at 오후 9:18 ...
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:18:50 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T211850.200.json
Response code: 200; Time: 188ms (188 ms); Content length: 4662 bytes (4.66 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:18:53 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T211853.200.json
Response code: 200; Time: 2796ms (2 s 796 ms); Content length: 4666 bytes (4.67 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:18:53 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T211853-1.200.json
Response code: 200; Time: 16ms (16 ms); Content length: 4667 bytes (4.67 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:18:53 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T211853-2.200.json
Response code: 200; Time: 21ms (21 ms); Content length: 4631 bytes (4.63 kB)
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:18:53 GMT
Keep-Alive: timeout=60
Connection: keep-alive
Response file saved.
> 2026-01-21T211853-3.200.json
Response code: 200; Time: 18ms (18 ms); Content length: 4663 bytes (4.66 kB)

2.3. 잔여 문제점 심층 분석
2번 요청만 지속적으로 느린 원인을 코드 수준에서 분석한 결과, 다음과 같은 근본적인 문제점이 도출되었다:
private BooleanExpression titleContains(String title) {
return StringUtils.hasText(title) ? item.title.contains(title) : null;
}
contains() 메서드는 SQL의 LIKE '%keyword%' 구문으로 변환되는데, InnoDB 스토리지 엔진에서는 이 패턴에 대해 인덱스를 전혀 활용할 수 없는 구조적 한계가 존재하였다. 결과적으로 모든 텍스트 검색이 Full Table Scan을 강제하는 상황이었다.
3. 해결 방안 2: FullText Index의 전략적 도입
3.1. ElasticSearch 대안에 대한 고려
초기에는 텍스트 검색 성능 문제를 해결하기 위해 ElasticSearch 도입을 검토하였다. 그러나 인프라 비용 부담, 학습 곡선, 운영 복잡도 증가 등을 종합적으로 고려했을 때, 현재 시스템 규모에서는 지나치게 무거운 솔루션이라는 결론에 도달하였다.
3.2. MySQL FullText Index의 선택 근거
MySQL 내장 FullText Index 기능은 추가 인프라 없이 역인덱스(Inverted Index) 방식을 활용한 빠른 텍스트 검색을 제공하였다. 특히 ngram 파서를 통한 한글 검색 최적화가 가능하다는 점에서 현 문제 해결에 적합한 기술 선택지로 판단되었다.
3.3 FullText Index의 구체적 구현
3.3.1 인덱스 생성 절차
ALTER TABLE items ADD FULLTEXT INDEX idx_title_fulltext (title) WITH PARSER ngram;
3.3.2 코드 수정 방안
private BooleanExpression titleContains(String title) {
if (!StringUtils.hasText(title)) {
return null;
}
// MySQL MATCH AGAINST 구문으로의 전환
return Expressions.numberTemplate(Double.class,
"function('match', {0}, {1})", item.title, title).gt(0);
}
3.4 최종 성능 개선 성과
FullText Index 적용 후 성능을 재측정한 결과, 총 소요 시간이 88ms로 대폭 감소하는 놀라운 효과를 확인하였다. 특히 기존 2,796ms를 기록했던 2번 요청의 성능 문제가 완전히 해소되었다.
결과 로그 보기)
Testing started at 오후 9:43 ...
HTTP/1.1 500
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:43:28 GMT
Connection: close
Response file saved.
> 2026-01-21T214328.500.json
Response code: 500; Time: 18ms (18 ms); Content length: 113 bytes (113 B)
HTTP/1.1 500
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:43:28 GMT
Connection: close
Response file saved.
> 2026-01-21T214328-1.500.json
Response code: 500; Time: 16ms (16 ms); Content length: 112 bytes (112 B)
HTTP/1.1 500
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:43:28 GMT
Connection: close
Response file saved.
> 2026-01-21T214328-2.500.json
Response code: 500; Time: 19ms (19 ms); Content length: 112 bytes (112 B)
HTTP/1.1 500
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:43:28 GMT
Connection: close
Response file saved.
> 2026-01-21T214328-3.500.json
Response code: 500; Time: 18ms (18 ms); Content length: 113 bytes (113 B)
HTTP/1.1 500
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 21 Jan 2026 12:43:28 GMT
Connection: close
Response file saved.
> 2026-01-21T214328-4.500.json
Response code: 500; Time: 17ms (17 ms); Content length: 113 bytes (113 B)

4. 결론 및 시사점
4.1 성능 개선의 종합적 평가
초기 4,900ms에서 인덱스 적용 후 2,796ms(일부), 최종적으로 FullText Index 적용 후 88ms로 개선되는 과정을 거쳤다. 이는 55배 이상의 성능 향상을 달성한 결과로서, 기술적 개선이 사용자 경험에 미치는 영향을 수치적으로 증명한 사례라 할 수 있다.
4.2 기술적 교훈의 도출
첫째, 적절한 인덱스 설계가 데이터베이스 성능에 결정적 영향을 미친다는 사실을 재확인하였다. 둘째, LIKE '%keyword%' 패턴의 구조적 한계를 인식하고 적절한 대안을 탐색하는 것이 중요하다는 점을 배웠다. 셋째, 복잡한 외부 솔루션 도입 전 기본 데이터베이스의 내장 기능을 최대한 활용하는 접근법의 가치를 확인하였다.
4.3 확장 가능성에 대한 전망
향후 검색어 분석 및 동의어 처리 강화, 실시간 인덱싱 지연 시간 모니터링, 사용자 검색 패턴 분석을 통한 추가 최적화가 가능하다. 또한 MySQL FullText Index와 Redis 캐싱을 결합한 하이브리드 접근법을 통해 더욱 정교한 성능 최적화를 꾀할 수 있을 것이다.
이러한 개선 과정을 통해 중고 상품 플랫폼의 검색 성능을 획기적으로 개선하면서도, 시스템 복잡도와 운영 비용을 효율적으로 관리할 수 있는 방법론을 확립하였다.
FullTxt Index의 작동 원리와 상세 구현 방법에 대해서는 `https://receiver40.tistory.com/358` 포스팅을 참고하자.
'Project > Secondhand Market' 카테고리의 다른 글
| [6] 성능 최적화(4): Async 적용 (0) | 2026.01.25 |
|---|---|
| [5] 성능 최적화(3): N+1 (0) | 2026.01.25 |
| [4] 성능 최적화(2): Lock (0) | 2026.01.25 |
| [2] 프로젝트 모니터링 설정과 테스트 환경 구축 (1) | 2026.01.10 |
| [1] 프로젝트 소개 (0) | 2026.01.10 |
