⚓ DATT 08 - QueryDSL 기반 장소 검색 API 설계 및 구현
개요
DATT는 단순 장소 검색 서비스가 아니다.
핵심 목표는:
1
2
3
특정 장소를 기준으로
주변 경험을 큐레이션하고
친구와 공유하는 플랫폼
을 만드는 것이다.
따라서:
1
2
3
4
검색 성능
검색 확장성
동적 조건 처리
위치 기반 탐색
등이 매우 중요하다.
이번 단계에서는:
1
2
Spring Data JPA
→ QueryDSL
기반으로 검색 API를 고도화하였다.
왜 QueryDSL을 도입했는가
초기에는 Spring Data JPA의 메서드 기반 조회만으로도 검색 API를 구현할 수 있다.
예를 들면:
1
findByBizesNmContaining(String keyword)
정도의 구조다.
하지만 실제 서비스 검색에서는 다음 문제가 발생한다.
1
2
3
4
5
키워드 검색
+ 지역 검색
+ 업종 검색
+ 정렬
+ 페이징
조건이 계속 조합되기 시작한다.
예시:
1
2
3
강남구 + 카페 + 스타벅스
서울특별시 + 숙박
한식 + 역삼동
이런 조건들을 Repository Method Naming만으로 처리하면:
1
2
3
메서드 수 폭증
가독성 저하
유지보수 어려움
문제가 발생한다.
따라서 이번 단계에서는 QueryDSL을 도입하였다.
QueryDSL 환경 구성
DATT는 Spring Boot 4 / Hibernate 7 기반으로 구성되어 있다.
따라서 기존:
1
com.querydsl
이 아니라:
1
io.github.openfeign.querydsl
패키지를 사용하였다.
QueryDSL 설정
build.gradle
1
2
3
4
5
implementation 'io.github.openfeign.querydsl:querydsl-jpa:7.0'
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:7.0:jpa'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
QClass 생성 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
sourceSets {
main {
java {
srcDirs += querydslDir
}
}
}
tasks.withType(JavaCompile).configureEach {
options.generatedSourceOutputDirectory = querydslDir
}
JPAQueryFactory Bean 등록
1
2
3
4
5
6
7
8
9
10
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(
EntityManager entityManager
) {
return new JPAQueryFactory(entityManager);
}
}
검색 조건 객체화를 적용한 이유
검색 조건은 단순 keyword 하나로 끝나지 않는다.
DATT 검색 조건은 다음과 같다.
1
2
3
4
5
6
keyword
ctprvnNm
signguNm
adongNm
indsMclsCd
sortType
따라서 이번 단계에서는:
1
PlaceSearchCondition
객체를 별도로 설계하였다.
PlaceSearchCondition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@Setter
public class PlaceSearchCondition {
private String keyword;
private String ctprvnNm;
private String signguNm;
private String adongNm;
private String indsMclsCd;
private PlaceSortType sortType = PlaceSortType.LATEST;
}
검색 조건 객체화를 한 이유
검색 조건을 객체화하면 장점이 매우 많다.
1. Controller 파라미터 정리
기존 방식:
1
2
3
4
5
6
search(
String keyword,
String ctprvnNm,
String signguNm,
...
)
문제:
1
2
파라미터 증가
가독성 저하
2. 유지보수 용이
조건 추가 시:
1
DTO 필드 추가
만 하면 된다.
3. QueryDSL과 궁합이 좋음
1
2
3
4
.where(
keywordContains(condition.getKeyword()),
signguNmEq(condition.getSignguNm())
)
형태로 매우 자연스럽게 연결된다.
공공데이터 업종 코드 기반 검색 전략
DATT는:
1
소상공인시장진흥공단 상권정보 API
를 사용한다.
해당 데이터는 업종을:
1
2
3
대분류
중분류
소분류
구조로 관리한다.
왜 중분류만 사용했는가
초기에는:
1
2
3
대분류
중분류
소분류
전부 관리하려고 했다.
하지만 실제 검색 기준은 대부분:
1
2
3
4
5
한식
중식
카페
숙박
주점
정도였다.
즉:
1
중분류만으로 충분
하다고 판단하였다.
최종 업종 Enum 구조
1
2
3
4
5
6
7
8
9
10
11
12
public enum PlaceIndustryCategory {
KOREAN_FOOD("I201", "한식"),
CHINESE_FOOD("I202", "중식"),
JAPANESE_FOOD("I203", "일식"),
NON_ALCOHOL("I212", "비알코올"),
GENERAL_ACCOMMODATION("I101", "일반 숙박"),
SPORTS_SERVICE("R103", "스포츠 서비스");
}
업종 코드 기반 검색 장점
1. 정규화된 검색 가능
1
2
3
카페
커피
Coffee
같은 문자열 문제 대신:
1
I212
기준으로 검색 가능.
2. 공공데이터와 직접 연결 가능
Batch 동기화와 검색 기준이 동일하다.
3. 프론트 필터 구성 용이
1
2
3
카페
숙박
놀거리
등을 코드 기반으로 쉽게 연결 가능.
Pageable 기반 검색 응답 구조
검색 API는 반드시:
1
대량 데이터 대응
을 고려해야 한다.
따라서 이번 단계에서는:
1
Pageable
기반 페이징 구조를 적용하였다.
PlaceSearchResponse
목록 조회용 DTO는 가볍게 구성하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public record PlaceSearchResponse(
Long id,
String bizesNm,
String brchNm,
String indsMclsCd,
String indsMclsNm,
String ctprvnNm,
String signguNm,
String adongNm,
String rdnmAdr,
Double lon,
Double lat
)
왜 목록 DTO와 상세 DTO를 분리했는가
목록 조회는:
1
빠르고 가벼워야 한다.
반면 상세 조회는:
1
더 많은 정보가 필요하다.
따라서:
1
2
PlaceSearchResponse
PlaceDetailResponse
를 분리하였다.
Pageable 적용 구조
1
2
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
형태로 적용하였다.
반환은:
1
Page<PlaceSearchResponse>
구조를 사용하였다.
정렬 조건 설계
초기 MVP에서는 다음 정렬만 지원하였다.
1
2
LATEST
NAME
PlaceSortType
1
2
3
4
5
6
7
8
9
10
@Getter
@RequiredArgsConstructor
public enum PlaceSortType {
LATEST("createdAt", "최신순"),
NAME("bizesNm", "이름순");
private final String property;
private final String description;
}
왜 Enum 기반 정렬 구조를 사용했는가
단순 문자열 정렬은 위험하다.
예:
1
2
3
sort=name
sort=createdAt
sort=abcd
같은 값이 직접 들어온다.
따라서:
1
허용된 정렬만 명시적으로 관리
하기 위해 Enum 구조를 사용하였다.
QueryDSL 기반 검색 구현
최종 검색 조건은 다음과 같다.
1
2
3
4
5
6
7
.where(
keywordContains(condition.getKeyword()),
ctprvnNmEq(condition.getCtprvnNm()),
signguNmEq(condition.getSignguNm()),
adongNmEq(condition.getAdongNm()),
indsMclsCdEq(condition.getIndsMclsCd())
)
왜 Elasticsearch를 바로 도입하지 않았는가
검색 서비스를 만들면 가장 먼저 나오는 이야기가:
1
"Elasticsearch 써야 하는 거 아닌가?"
이다.
하지만 이번 MVP에서는 Elasticsearch를 도입하지 않았다.
이유 1. 현재 데이터 규모가 크지 않다
현재는:
1
수만 ~ 수십만 수준 데이터
이다.
이 정도 규모에서는:
1
PostgreSQL + Index
만으로도 충분히 대응 가능하다.
이유 2. 운영 복잡도가 급격히 증가한다
Elasticsearch를 도입하면:
1
2
3
4
5
동기화
클러스터
인덱스 관리
매핑
운영 비용
등이 추가된다.
즉 MVP 단계에서는:
1
과도한 복잡도 증가
가 발생한다.
이유 3. 검색 요구사항이 아직 단순하다
현재 검색은:
1
2
3
LIKE 검색
지역 검색
업종 검색
정도다.
아직은:
1
2
3
4
형태소 분석
검색 랭킹
자동완성
오타 보정
등이 필요하지 않다.
향후 Elasticsearch 도입 기준
다음 상황이 오면 Elasticsearch를 고려할 예정이다.
1
2
3
4
5
검색 응답 지연 증가
복잡한 검색 랭킹 필요
자동완성 필요
형태소 검색 필요
인기 검색어 분석 필요
즉 현재는:
1
"지금 필요한 수준만 구현"
전략을 선택하였다.
현재 검색 구조의 장점
현재 구조는 다음 장점을 가진다.
1. 단순하다
운영 복잡도가 낮다.
2. 유지보수가 쉽다
Spring Data JPA + QueryDSL만으로 관리 가능.
3. 확장 가능하다
나중에:
1
2
3
Elasticsearch
Redis
PostGIS
등으로 확장 가능하다.
마무리
이번 단계에서는:
1
공공데이터 기반 장소 검색 API
를 QueryDSL 기반으로 고도화하였다.
핵심은:
1
2
3
4
5
동적 검색
업종 기반 검색
지역 기반 검색
페이징
정렬
을 안정적으로 처리하는 것이다.
이후 단계에서는:
1
2
3
4
5
반경 검색
Anchor 큐레이션
공유
Bookmark
Review
등을 통해 DATT의 핵심 기능을 확장할 예정이다.