+) 통합 검색 1탄 : [KDR] 통합 검색 기능 구현하기 (ver1. 태그 검색)
이전 포스트에서 발생한 문제들을 해결하기 위해 태그 기능을 제거하고, 단일 keyword를 통한 통합 검색 기능으로 수정하게 되었다.
코드를 전면적으로 수정하면서, 이전 버전에서 아쉬웠던 디테일들을 전체적으로 구체화하게 되었다.
프로젝트 구조 자체의 변화는 다음과 같다.
수정 1: 검색 결과창의 다양화
먼저, 결과로 반환하는 값의 종류 및 각각을 선택했을 때의 연결 화면을 체계적으로 구분하였다.
1. 건물 : 건물 상세 모달
2. (건물+) 장소 : 장소 상세 모달
3. 시설타입 : 외부 화면에서 시설 태그 클릭한 것과 동일한 효과
4. 건물 + 시설타입 : 3에서 특정 건물을 클릭한 것과 동일한 효과
사용자 입장에서 최대한 자연스러운 연결을 고려하다보니 결과창을 여러 개로 구분하게 되었고. 덕분에 각각에 대한 API를 프론트와 맞추는 과정이 정말정말 복잡했다. 또한, 야외 장소의 경우에는 빌딩명을 지우거나, 크게 중요하지 않은 편의시설 태그들은 결과에서 제외하는 등 경우에 따라 사소한 차이들을 만들어내야 했기 때문에 개인적으로는 검색 기능 자체보다 해당 로직을 구현하는 것이 훨씬 힘들었던 것 같다...
수정 2 : DB 구조 (신버전)
기능을 수정하는 과정에서 DB 구조에서도 큰 변화가 하나 생겼다.
이전까지는 시설 조회 기능을 위해 facility를 따로 관리해왔지만, 시간이 지날수록 테이블 분리의 필요성이 사라지게 되었다. 이에 따라 강의실(classroom)과 편의시설(facility) 테이블을 장소(Place)라는 새로운 테이블로 통합하고, place type을 나타내는 ENUM에 CLASSROOM 이라는 타입을 새롭게 추가하였다.
비록 검색 기능 외에 장기적인 유지보수성이나 효율성을 고려한 결과 발생한 수정 사항이지만, 결과적으로 검색 과정에서의 DB 조회 횟수가 감소했을 뿐 아니라, 이전에는 불가능했던 편의 시설에 대한 별명 검색까지도 가능하게 되었다. (이전에는 Facility에 대한 별명 테이블이 없었음)
🔍통합 검색 (태그 미사용)
이러한 수정 사항들을 기반으로 새로운 검색 기능을 구현하였다.
여기서 가장 큰 문제였던 "건물"과 "장소"의 구분 기준을 태그가 아닌 공백으로 대체하였다. 건물명에는 띄어쓰기가 존재하지 않는다는 특수성을 활용한 설정이다.
전체적인 흐름은 아래와 같다.
먼저, 검색창의 String을 키워드로 받아와, 크게 4가지 경우로 구분한다.
1. 전체가 building이라 가정
2. 전체가 place라 가정
3. 띄어쓰기 기준 맨 앞 단어를 building으로 가정 (나머지를 place 키워드로 지정)
4. 띄어쓰기 기준 맨 뒤 단어를 building으로 가정 (나머지를 place 키워드로 지정)
다음으로, 각각의 경우에 대해 가능한 조합들을 받아온 후, 각각에 대해 매겨진 점수를 기준으로 정렬하여 반환하게 된다.
탐색 시간 문제
정보 조사가 진행됨에 따라 별명 데이터 또한 몇천~일만 건 정도로 늘어나게 되어 전체 검색 시 탐색 시간이 너무 길어진다는 문제가 발생하였다.
여기서는 자동완성 형태의 검색창이 사용되고 있는 만큼, 타 검색창들에 비해 비교적 짧은 글자의 검색어들이 자주 들어오기 때문에 이런 문제가 특히나 치명적이게 느껴졌다.
탐색 시간보다는 가져오는 데이터의 양이 많아지면서 생긴 문제에 가깝기 때문에, Pageable을 통해 간단하게 30개 제한을 주었다.
Pageable limit = PageRequest.of(0, 30, Sort.by("id").descending());
placeNicknames = placeNicknameRepository.findByNicknameContainingAndPlaceInOrderByNickname(word, places, limit);
당시에는 개발에 대한 지식 자체가 거의 없는 상태에서 만들어서 단순히 개수를 제한하는 목적으로 Pageable을 사용했는데 지금 생각해보니 무한 스크롤로 구현했다면 훨씬 자연스러운 사용이 가능했을 것 같다.
얼마전에 스터디에서 무한 스크롤 구현도 해봤으니 나중에 프론트와 협의가 된다면 이 부분도 수정해보고 싶다.
순서 설정
다음으로 해결해야 하는 문제는 순서 설정 방법이었다.
앞서 언급했듯이 점수(가중치) 체계를 사용하였는데, 이때 별명-점수의 정보를 관리하기 위해 Map을 사용하였다. 추가적으로 Map이 Key값을 중복으로 가질 수 없다는 특징을 활용한 자연스러운 중복 제거 효과를 노렸다.
그러나 실제 결과에서는 GlobalSearchRes 객체에 대한 중복 제거가 제대로 이루어지지 않았다. 일반적인 비교가 아닌, 객체의 비교에 대한 설정이 제대로 되어있지 않았기 때문이었다.
이후 객체 내부에 equals()와 hashCode()를 직접 오버라이딩 해주었더니 원하는 결과값을 얻을 수 있었다. 여기서는 결과의 name이 겹치지 않는 것이 중요하기 때문에 해당 부분만 비교하는 로직으로 수정하였다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
GlobalSearchRes that = (GlobalSearchRes) o;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
이 과정에서 hashCode를 함께 오버라이딩해줘야 하는 이유에 대해 의문이 생겼었는데, 보통 HashSet이나 HashMap 같은 자료구조는 객체를 비교할 때 hashCode() 값을 먼저 확인하기 때문에 아무리 equals()에서 true를 반환하더라도 hashCode() 값이 다르면 다른 객체로 간주될 수 있다고 한다.
객체 비교 로직을 그림으로 간단하게 나타내보면 위와 같다. 여기서 hashCode는 일반적으로 객체의 주소값을 기반으로 생성한 객체 고유의 정수값이기 때문에, 이를 함께 수정해줘야 원하는 결과를 얻을 수 있는 것이다.
각 결과에 부여되는 점수는 검색어와 장소 타입에 따라 다르게 설정하였다.
상세 규칙은 다음과 같다.
- 검색어에 띄어쓰기가 없다면, "건물"을 맨 위로 올린다.
- 띄어쓰기가 있다면, 연관 "장소"를 맨 위로 올린다. 단, 관련 건물 정보도 맨 아래에 함께 띄운다.
- 편의시설 중 이름이 ENUM에 저장된 기본 타입명과 동일한 것들 "태그" 기능을 위해 하나로 합쳐서 반환한다. (Ex. 화장실, 식당, 라운지 등) (CLASSROOM 제외)
- 편의시설 중 기본 이름과 다른 특수 이름의 편의시설(Ex. 파운드 커피, 맘스터치 등)의 경우는 일반 강의실(CLASSROOM 타입)과 동일하게 취급한다.
- (로그인한 경우) 즐겨찾기가 되어 있는 장소라면 항상 결과창의 최상단에 띄운다.
사실 5번의 경우 독단적으로 결정한 규칙이라 프론트/디자인 측에서는 차이는 구현되지 않았다..ㅎ
비록 시각적인 강조는 없지만, 자주 방문하는 곳이 위에 뜨는 것이 편할 것 같다는 의견 자체에는 다들 동의해주셔서 이대로 유지하고 있다.
각각의 경우에 따라 적절한 BaseScore를 지정해주고, 검색어 첫글자가 처음으로 나타나는 위치(index)값인 indexScore를 계산하여 더해줌으로써 최종 점수를 설정하였다.
// 점수 계산을 위한 상수
static final int BASE_SCORE_BUILDING_DEFAULT = 1000;
static final int BASE_SCORE_BUILDING_WITH_KEYWORD = -500;
static final int BASE_SCORE_FACILITY_DEFAULT = 500;
static final int BASE_SCORE_FACILITY_SPECIAL = 0;
static final int BASE_SCORE_CLASSROOM_DEFAULT = 0;
static final int BASE_SCORE_IS_BOOKMARKED = 5000;
private Map<GlobalSearchRes, Integer> calculateScore(List<GlobalSearchRes> list, String keyword, String buildingKeyword) {
Map<GlobalSearchRes, Integer> scores = new HashMap<>();
// 강의실, 특수명 편의시설은 baseScore = 0
int baseScore = 0;
int indexScore = 0;
int bookmarkScore = 0;
for (GlobalSearchRes res : list) {
if (res.getLocationType() == LocationType.BUILDING) {
baseScore = buildingKeyword == null ? BASE_SCORE_BUILDING_DEFAULT : BASE_SCORE_BUILDING_WITH_KEYWORD;
indexScore = buildingKeyword != null ? calculateScoreByIndex(res.getName(), buildingKeyword) : calculateScoreByIndex(res.getName(), keyword);
bookmarkScore = res.isBookmarked() ? BASE_SCORE_IS_BOOKMARKED : 0;
}
if (res.getLocationType() == LocationType.PLACE) {
if(res.getPlaceType().equals(PlaceType.CLASSROOM)) {
baseScore = BASE_SCORE_CLASSROOM_DEFAULT;
indexScore = calculateScoreByIndex(res.getName(), keyword);
} else {
if (res.getName().contains(" ")) {
baseScore = checkFacilityType(res.getName().split(" ", 2)[1]) ? BASE_SCORE_FACILITY_DEFAULT : BASE_SCORE_FACILITY_SPECIAL;
} else {
baseScore = BASE_SCORE_FACILITY_DEFAULT;
}
indexScore = calculateScoreByIndex(res.getName(), keyword);
}
}
scores.put(res, baseScore + indexScore + bookmarkScore);
}
return scores;
}
그러나 index 값을 계산하는 과정에서도 작은 문제가 있었다.
법학관(신관) / 사범대학본관 / 생명과학관서관 과 같이 특정 단어 뒤의 글자가 더 중요하게 사용되는 경우들, 다시 말해 "신관"을 쳤을 때 "법학관(신관)"보다 "신공학관"이 먼저 뜨는 것이 부자연스럽게 느껴졌던 것이다.
따라서 특정 키워드들을 포함하는 경우, 해당 단어를 기준으로 뒤에 나오는 String을 새로운 keyword로 설정하여 index값을 계산하도록 수정하였다.
private int calculateScoreByIndex(String originalName, String keyword) {
// 괄호() > 대학 > 관 (앞의 것이 존재한다면 해당 것을 기준으로 삼음)
String[] criteria = {"\\(", "대학", "관"};
for (String c : criteria) {
if(originalName.contains(c.replace("\\", ""))) {
String[] temp = originalName.split(c, 2);
// 기준 부분이 keyword를 포함한다면 이를 새로운 name으로 설정
if(!temp[1].isEmpty() && temp[1].contains(String.valueOf(keyword.charAt(0)))) {
originalName = temp[1];
break;
}
}
}
int index;
if(keyword.contains(" ")) {
String[] keywords = keyword.split(" ", 2);
int index1 = originalName.indexOf(keywords[0].charAt(0)); // building index 값
int index2 = originalName.indexOf(keywords[1].charAt(0)); // place index 값
index = (index1 == -1) ? 100 - index2 : (index2 == -1) ? 100 - index1 : 200 - index1 - index2;
} else {
int indexVal = originalName.indexOf(keyword.charAt(0));
index = (indexVal == -1) ? indexVal : 100 - indexVal;
}
return index;
}
마지막으로, 이렇게 계산된 score를 통해 결과 순서를 재정렬 해줌으로써 최종 결과를 도출한다.
여기서 기본 순서는 점수를 기반으로 하되, 같은 점수를 가지는 요소들은 가나다 순으로 정렬하도록 하였다.
🌟결과 화면
여러 경우에 대해 검색해보았을 때, 원하는 결과대로 잘 나오는 모습을 확인할 수 있었다!!
별명을 통한 검색이나 건물/장소의 위치가 바뀐 경우에도 잘 돌아간다.
블로그 아카이빙을 위해 깃허브에서 당시의 코드를 그대로 가져왔는데, 당시에는 클린 코드보다 기능 구현 자체에 더 집중했어서 그런지 다시 보니 미숙한 부분이 정말정말 많은 것 같다... 물론 기술 블로그보다는 아카이빙에 가까운 목적으로 작성한 글이지만 그래도 이번에 느낀 점을 바탕으로 더 읽기 쉽고 효율적인 코드로 발전시켜 봐야겠다.
사실 여기서 끝냈어도 검색 자체에 대한 큰 어려움은 없었겠지만, 개인적으로 불편하다 여기던 부분이 있어 이번 기회에 추가 수정을 하게 되었다.
여기에 대한 자세한 이야기는 마지막 글에서 더 자세히 적어보겠다.
'프로젝트 > KDR' 카테고리의 다른 글
[KDR] 통합 검색 기능 구현하기 (ver3. 미완성 글자 검색) (4) | 2025.03.29 |
---|---|
[KDR] 통합 검색 기능 구현하기 (ver1. 태그 검색) (1) | 2025.03.09 |