
해당 글은 게스트하우스 검색 시스템 설계 - Workaway을 개선한 내용을 담고있습니다.
새로운 요구사항 반영 및 성능 최적화를 중심으로 진행한 작업들을 기록한 글입니다.
여러가지 레퍼런스를 참고하며 기획이 변경되었습니다.
이에 따라 기능들이 추가되고 검색 시스템에도 변경사항이 생겼습니다.
새롭게 요구된 기능 중 하나는 다양한 필터 조건에 따라 게스트하우스를 검색하는 기능이었습니다.
필터는 다음과 같은 항목으로 구성되어 있습니다
applyFilters 메서드를 정의하여 기존 코드에 필터 기능을 새롭게 추가하였습니다
// 해시태그 필터링
if (request.getHashtagIds() != null && !request.getHashtagIds().isEmpty()) {
query.where(JPAExpressions
.select(guesthouseHashtag.id.countDistinct())
.from(guesthouseHashtag)
.where(
guesthouseHashtag.guesthouse.id.eq(guesthouse.id),
guesthouseHashtag.hashtag.id.in(request.getHashtagIds())
)
.eq((long) request.getHashtagIds().size()));
}
// 편의시설 필터링
.
.
.
필터, 정렬에 사용하는 값들을 서브쿼리로 처리하고 있었습니다.
이로 인해 1개의 게스트하우스 당 수많은 서브쿼리를 반복해야했기 때문에 성능에 문제가 발생하고 있었습니다.
위 2가지를 제외한 리뷰 평균 점수, 리뷰 수, 좋아요 수 데이터는 집계가 가능하다고 생각하였습니다.
또한 추가 가격 계산 시 예약이 불가능하다면 0을 반환하는 코드를 통해 예약 존재 여부를 판단하도록 코드를 수정하였습니다.
집계 테이블은 아래와 같이 구성하였습니다.
public class 집계테이블 {
@Id
@Column(name = "guesthouse_id")
private Long guesthouseId;
/** 리뷰 관련 통계 */
@Column(name = "avg_rating")
@Builder.Default
private Double avgRating = 0.0;
@Column(name = "review_count")
@Builder.Default
private Integer reviewCount = 0;
/** 관심/좋아요 통계 */
@Column(name = "favorite_count")
@Builder.Default
private Integer favoriteCount = 0;
@Column(name = "last_updated_at")
private LocalDateTime lastUpdatedAt;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "guesthouse_id")
private Guesthouse guesthouse;
@PrePersist
@PreUpdate
private void updateTimestamp() {
this.lastUpdatedAt = LocalDateTime.now();
}
}
집계에서 완전한 실시간 데이터를 반영하지않더라도 사용자 경험에 크게 문제가 되지않을 것이라 판단했습니다.
따라서 매일 자정, 특정 주기로 스케줄링을 등록하여 집계 테이블을 업데이트하는 방식으로 구현하였습니다.
수 많은 서브쿼리가 줄어듬에 따라 성능 향상도 얻을 수 있었습니다.
기존 방식은 아래와 같이 검색을 구현하였습니다.
게스트하우스 이름에 검색어가 포함되는 경우 LIKE %키워드%를 통해 검색
// QueryDSL Code
BooleanExpression 조건 = guesthouse.guesthouseName.containsIgnoreCase(키워드);
// SQL로 변환 시
WHERE LOWER(guesthouse_name) LIKE '%키워드%'
10km이내에 존재하는 게스트하우스ST_Distance_Sphere 사용NumberExpression<Double> 조건 = Expressions.numberTemplate(
Double.class,
"cast(ST_Distance_Sphere(point({0}, {1}), point({2}, {3})) as double)",
guesthouse.lng, guesthouse.lat,
keywordLng, keywordLat
return 조건.loe(10000.0);
하지만 해당 방식은 아래와 같은 문제점이 있습니다.
LIKE %키워드%, LIKE %키워드와 같이 와일드카드를 앞부분에 사용하는 경우 인덱스의 정렬 특성(왼쪽부터 정렬된 값을 기반으로 탐색)과 충돌하여 인덱스를 타지못하고 Full Table Scan을 진행.ST_Distance_Sphere는 지리 함수의 검색 성능을 올리기 위해 적용한 공간인덱스(Spatial Index)가 적용되지않는 지리 함수.이러한 문제점으로 인해 검색 성능에 좋지 못한 영향을 끼치고 있었습니다.
FullText Search 활용FullText Index를 적용하면 대량의 텍스트 검색에서 빠른 성능을 기대할 수 있습니다.MATCH AGAINST 문법을 지원 X,Mysql8CustomDialect를 정의해 FullText Search 함수를 수동 등록하여 해결public class Mysql8CustomDialect extends MySQLDialect {
private static final String FUNCTION_NAME = "MATCH";
private static final String FUNCTION_PATTERN = "MATCH (?1) AGAINST (?2 IN BOOLEAN MODE)";
@Override
public void contributeFunctions(FunctionContributions functionContributions) {
functionContributions.getFunctionRegistry()
.registerPattern(
FUNCTION_NAME,
FUNCTION_PATTERN,
functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(DOUBLE));
}
}
ST_Distance_Sphere → ST_Buffer + ST_Contains 변경ST_Buffer : 좌표를 기준으로 원을 생성하는 지리함수ST_Contains : 특정 좌표가 특정 범위 안에 존재하는지 확인하는 지리 함수공간 인덱스(Spatial Index)를 활용할 수 있어 성능 개선double radius = 10000.0; // 10km
BooleanExpression 조건 = Expressions.booleanTemplate(
"ST_Contains(ST_Buffer(ST_GeomFromText({0}, 4326), {1}), {2})",
keyword.getCenterPointWkt(),
radius,
guesthouse.location
);
해당 코드는 Point_WKT 데이터를 Geometry 타입으로 변환(GeomFromText)하여, 10km 반경의 원을 생성(ST_Buffer)한 뒤, 해당 원에 게스트하우스의 위치(guesthouse.location)이 존재하는지 확인하는 코드입니다.
아래 이미지는 특정 지역에서 10km원을 생성했을 때를 Python을 통해 생성한 이미지입니다.

결과적으로는 10km 이내에 존재하는 게스트하우스만 검색하도록 구조를 변경했습니다.
A지역에 존재하지않는 데 A지역을 이름으로 사용하는 게스트하우스는 극소수일 것이라고 판단했으며, 이름만으로 검색된 결과는 실제 사용자의 의도와 맞지 않다고 생각했습니다.
하지만, 이름 기반 검색이 완전히 불필요한 것은 아니기때문에, 별도의 API를 만들어 게스트하우스 명으로 검색할 수 있는 기능으로 분리하도록 하였습니다.
기존 검색 Keyword에는 단순하게 위도, 경도에 대한 데이터들만 저장해두었습니다.
[
{
"_id": {"$oid": "6853c2415b72227deab7f24b"},
"address": "제주특별자치도 서귀포시",
"category": "행정구역",
"choseong": "ㅅㄱㅍㅅ",
"keyword": "서귀포시",
"lat": 33.253925,
"lng": 126.5597875,
"synonyms": ["ㅅㄱㅍㅅ", "서귀포시"]
}
]
하지만, 추후에 특정 지역에서만 검색하는 기능들이 추가될 수 있으며, Spring에서 직접 위도, 경도를 지리 타입(Point)로 변환하여 사용하여야 했습니다.
이러한 문제를 해결하기 위해 읍/면/동 데이터, 시/군/구 데이터, 관광지 데이터를 전처리하여 아래와 같이 MongoDB에 저장하였습니다.
[
{
"_id": {"$oid": "6880ebab01c22718f5e8bef5"},
"address": "제주특별자치도 서귀포시 남원읍 하례리",
"category": "행정구역",
"center_point": {
"type": "Point",
"coordinates": [126.58954180431991, 33.33459551926222]
},
"center_point_wkt": "POINT (33.3345955192622228 126.5895418043199072)",
"choseong": "ㅅㄱㅍㅅ",
"keyword": "서귀포시",
"polygon": {
"type": "Polygon",
"coordinates": [[...]],
"polygon_wkt": "POLYGON ((33.4825308959956232 126.9018354701199200, 33.4806680407749937 126.8845199496604010, 33.4689594083613429 126.8667517770136755, 33.4670580414387189 126.8577366447086945, 33.4537560356095725 126.8287264958892564, 33.4467115359197038 126.8213777394704920, 33.4445919851551281 126.8083671430508161, 33.4443351934389881 126.8068571075086339, 33.4413552848220661 126.7885627971222533, 33.4214605143710770 126.7612831985717889, 33.4202692162278083 126.7414047654483511, 33.4243305007984546 126.7334255083480343, 33.4218971746081479 126.7090582769129981, 33.4132847207527703 126.6877282888420240, 33.4002373030941015 126.6683440010882293, 33.3950549212400603 126.6534189976620297, 33.3901900705624257 126.6263346777514869, 33.3850361334321875 126.6249154266293573, 33.3822375045044240 126.6069339475781277, 33.3743444181309883 126.5893218708036301, 33.3718189032927341 126.5745715788277437, 33.3736763760479604 126.5537160458121235, 33.3632327508948805 126.5347356784170074, 33.3598467602065867 126.4990914093750831, 33.3549543996141580 126.4904484927276229, 33.3600775246665222 126.4648174123900901, 33.3562682568051798 126.4385495573691429, 33.3466462375135109 126.4364203645807123, 33.3410389391451929 126.4264237803020734, 33.3467614065173166 126.3753101978985995, 33.3413828807582462 126.3687013056619151, 33.3436310963261064 126.3416384811933426, 33.3265897051567137 126.3238485681393826, 33.3266535506881567 126.3059466155909973, 33.3219920554629567 126.2963227085782876, 33.3104936557802631 126.2924091947572833, 33.2994842904382153 126.2792197890983914, 33.2928052923258448 126.2530289274585442, 33.2875791049911314 126.2441646683534202, 33.2763776951080672 126.2387049973935831, 33.2846744779706469 126.2297875584058175, 33.2955044386289316 126.2027033037649204, 33.2900918863836637 126.1804196596545893, 33.2830939902279397 126.1681370764842285, 33.2613728814796303 126.1814025707896008, 33.2472008261978544 126.2010154095989662, 33.2432271517016531 126.2198445629255161, 33.2293627877044386 126.2405225366750017, 33.2237461036478763 126.2412451178489334, 33.1954256941371000 126.2704060801898152, 33.2035630146771723 126.2904477493641480, 33.2202129446779750 126.2949132383885740, 33.2386827798003068 126.3202472155349909, 33.2357963368440110 126.3372879086960126, 33.2366387368937382 126.3610639177935866, 33.2314803916160528 126.3692246754465174, 33.2352780521095426 126.3862458083473257, 33.2438748534165427 126.4035822207137727, 33.2448320500285988 126.4132734482241602, 33.2351727949077329 126.4291372529923336, 33.2412594949238809 126.4545157231147101, 33.2255195644145189 126.4713893806793124, 33.2330897980427125 126.4978313665018277, 33.2306436875774622 126.5080719379923124, 33.2412737038169794 126.5211675728213550, 33.2393073228797391 126.5334018830047995, 33.2398516533786221 126.5617781599519276, 33.2445483722246138 126.5860088073013259, 33.2359253332121654 126.5988496748202579, 33.2420877870086002 126.6171430749490980, 33.2519652695497427 126.6236465227217707, 33.2579273692665609 126.6400041165361898, 33.2647415496374634 126.6411756370513615, 33.2714231545671666 126.6616581764829732, 33.2681646671316145 126.6772266996001264, 33.2709636628085335 126.6991969065434347, 33.2860077970469561 126.7503946391184257, 33.2914482526959077 126.7637065527277400, 33.2988861086234635 126.7665228039794272, 33.3070866668593197 126.7784529789260120, 33.3030526126183162 126.7921350900998902, 33.3077538744634651 126.8167848641272712, 33.3063366277135486 126.8292092131998032, 33.3209171221226370 126.8477767539016980, 33.3292085345705829 126.8367902574550925, 33.3399825039538626 126.8569649045057304, 33.3551563033830689 126.8685277286821815, 33.3638880167483407 126.8672511503964841, 33.3839311748337195 126.8830181481536670, 33.3914445347449913 126.9062609175235821, 33.4026050761678874 126.9029622599356202, 33.4201819418585373 126.9118487154362356, 33.4342248001115649 126.9241680198497022, 33.4499682668172866 126.9227959964612893, 33.4590885823591151 126.9302065419339982, 33.4569533465895645 126.9385848592750250, 33.4736746874083124 126.9362305913409301, 33.4693702633650645 126.9203854198638624, 33.4825308959956232 126.9018354701199200))",
"synonyms": ["제주 서귀포시", "제주시 서귀포시"]
}
]
데이터가 변경됨에 따라 Spring서버에서는 WKT를 그저 지리함수의 인자로 넘겨주기만 하여 유지보수 및 가독성 또한 좋아질 수 있었습니다.
POINT (경도 위도)
MySQL에 지리 공간 데이터를 직접 저장할 때는 위와 같이 저장되지만,
POINT (위도 경도)
WKT로 저장할 때는 아래와 같이 저장해야 ST_GeomFromText 함수가 정상적으로 변환해줍니다
게스트하우스 검색 시 모든 데이터를 순회 (Full Table Scan) 하던 코드를 공간 인덱스를 통해 빠르게 검색할 수 있도록 변경하였으며, 서브쿼리들을 집계 테이블로 변환함에 따라 큰 성능 향상을 확인할 수 있었습니다.
실행 계획을 데이터를 AI에게 전달하여 요약한 내용은 아래와 같습니다
| 테이블 | 접근 방식 | 사용 인덱스 | 조건 / 목적 | 인덱스 사용 여부 | 소요 시간 | 비고 |
|---|---|---|---|---|---|---|
| G 테이블 | range | 공간 인덱스 | 거리 기반 필터링 (ST_Contains(...)) | ✅ 사용 | 약 0.53~0.59ms | 공간 쿼리 최적화에 유리 |
| 통계 요약 테이블 | eq_ref | 기본 키 | G 테이블과 1:1 매핑 | ✅ 사용 | 약 0.02ms × 4회 | 매우 빠른 조인 |
| R 테이블 | ref | 복합 인덱스 | G 테이블 기준 객실 조건 필터링 | ✅ 사용 | 약 0.07ms | 효율적인 조건 조회 |
| 이미지 테이블 | ref | 복합 인덱스 | 썸네일 여부 조건 포함 | ✅ 사용 | 약 0.07~0.10ms | 조건 기반 이미지 조인 |



| 항목 | 기준값 | 변경 후 | 개선률 (%) | 비고 |
|---|---|---|---|---|
| 평균 응답시간 (ms) | 675 | 44 | -93.48% | ✅ 대폭 감소 |
| 처리량 (req/sec) | 183.9 | 249.1 | +35.47% | ✅ 처리량 증가 |
| 표준편차 (ms) | 608.80 | 102.05 | -83.24% | ✅ 응답 안정성 향상 |
| 수신량 (KB/sec) | 305.83 | 366.90 | +19.96% | ✅ 네트워크 처리 증가 |
| 전송량 (KB/sec) | 52.98 | 71.77 | +35.45% | ✅ 네트워크 처리 증가 |



| 항목 | 기준값 | 변경 후 | 개선률 (%) | 비고 |
|---|---|---|---|---|
| 평균 응답시간 (ms) | 3734 | 644 | -82.76% | ✅ 대폭 감소 |
| 처리량 (req/sec) | 169.5 | 398.8 | +135.22% | ✅ 2배 이상 증가 |
| 표준편차 (ms) | 1731.83 | 444.31 | -74.35% | ✅ 응답 안정성 향상 |
| 수신량 (KB/sec) | 281.90 | 587.23 | +108.27% | ✅ 네트워크 처리량 2배 ↑ |
| 전송량 (KB/sec) | 48.83 | 114.88 | +135.28% | ✅ 네트워크 처리량 2배 ↑ |
위와 같은 구조 변경 및 최적화 작업을 통해, 기존 Full Table Scan 기반 구조에서 인덱스를 활용한 고성능 검색 구조로 전환할 수 있었으며, 평균 응답시간 93% 감소, TPS 2배 증가 등의 성능 향상을 달성할 수 있었습니다.