[리액트] 검색의 목적은 편리함에 있다 ( 초성 검색, 자동완성)
들어가며
가끔 OTP 서비스를 사용하다 보면 한글 초성으로만 검색하는 기능을 확인할 수 있습니다.
입력이 불편한 리모컨 특성상 사용자에게 편리함을 제공하기 위해 많이 사용합니다.
그래서 이번엔 초성으로도 검색 가능한 검색창 컴포넌트를 만들어보도록 하겠습니다.
기존 컴포넌트 & 로직
컴포넌트
우선 컴포넌트를 하나 보고 오겠습니다.
이건 제가 예전에 사이트 프로젝트하면서 만들었던 컴포넌트인데요. 보시다시피 주식명 또는 코드를 입력하면 찾아주는 컴포넌트입니다.
근데 만약 여기서 삼성전자 대신에 ㅅㅅㅈㅈ를 입력했을때도 삼성전자가 검색된다면 얼마나 좋을까요?
그래서 한번 만들어가보겠습니다.
로직
일단 기존의 로직부터 확인하고 가겠습니다.
const matchStock = (
stockList: Holdings[],
query: string,
checkedList: Holdings[]
) => {
if (!query) {
return [];
}
if (!isNaN(Number(query)) && query.length < 3) {
return [];
}
return stockList
.filter((stock: Holdings) => {
return (stock.code.includes(query) || stock.name.toLowerCase().includes(query));
})
.map((stock: Holdings) => {
let checked = checkedList.some((item) => item.name === stock.name);
return { ...stock, checked: checked };
});
};
stockList와 검색 query, 그리고 체크됐는지 확인을 위한 checkedList가 들어옵니다.
checkedList는 아래 사진과 같이 이미 선택된 주식을 들고 있는 값입니다.
삼성전자를 이미 포함하고 있으니 검색창에서 선택에 check가 되어있습니다.
현재 상황에선 서비스에서 제공하는 주식 데이터가 stockList이고 query는 "삼성전자", checkedList는 ["SK하이닉스"..."삼성전자"] 이겠네요.
그리고 단순하게 stock의 코드나 주식명에 query에 해당하는 값을 포함하고 있는지 includes를 통해 확인하는 간단한 로직입니다.
includes로 확인하니 당연히 초성을 가지고 비교하는 것은 안되고요. 이제 초성을 체크하는 로직을 구현하러 가보겠습니다.
개선 컴포넌트 & 로직
초성을 찾아보자
초성을 찾는 로직은 제가 이글을 작성할때만 해도 별다른 라이브러리가 없었는데요. 신뢰와 믿음의 토스에서 라이브러리를 하나 오픈해주셨습니다. 따라서 내용을 추가하겠습니다.
직접 구현 로직
일단 초성 단위로 체크하는 건 복잡합니다. offset 기준으로 찾고 변형하고 하는 과정이 필요합니다. 자세한 로직과 방법론은 참고한 사이트 링크로 대체합니다.
하지만 위에서 나온 코드를 바로 사용할 순 없습니다.
타입스크립트 문법에도 맞도록 그리고 원하는 기능만 뽑아서 사용할 수 있도록 로직을 좀 변경하겠습니다.
const is초성match = (query: string, target: string) => {
const reg = new RegExp(query.split("").map(pattern).join(".*?"), "i");
const matches = reg.exec(target);
return Boolean(matches);
};
초성 단위까지 판별하여 찾는 로직입니다.
이것만 보면 그게 복잡한게 없어 보입니다. 그리고 중요한 부분는 pattern입니다.
pattern은 위 사이트에 있는 코드를 타입스크립트에 맞춰 사용했으니 참고 부탁드립니다.
const reESC = /[\\^$.*+?()[\]{}|]/g;
const reChar = /[가-힣]/;
const reJa = /[ㄱ-ㅎ]/;
const offset = 44032;
const orderOffest = [
["ㄱ", 44032],
["ㄲ", 44620],
["ㄴ", 45208],
["ㄷ", 45796],
["ㄸ", 46384],
["ㄹ", 46972],
["ㅁ", 47560],
["ㅂ", 48148],
["ㅃ", 48736],
["ㅅ", 49324],
];
const con2syl = Object.fromEntries(orderOffest as readonly any[]);
const pattern = (ch: string) => {
let r;
if (reJa.test(ch)) {
const begin =
con2syl[ch] || (ch.charCodeAt(0) - 12613) * 588 + con2syl["ㅅ"];
const end = begin + 587;
r = `[${ch}\\u${begin.toString(16)}-\\u${end.toString(16)}]`;
} else if (reChar.test(ch)) {
const chCode = ch.charCodeAt(0) - offset;
if (chCode % 28 > 0) return ch;
const begin = Math.floor(chCode / 28) * 28 + offset;
const end = begin + 27;
r = `[\\u${begin.toString(16)}-\\u${end.toString(16)}]`;
} else r = ch.replace(reESC, "\\$&");
return `(${r})`;
};
토스 라이브러리 사용(24.07.11업데이트)
토스에서 만든 현대적인 JavaScript 한글 라이브러리인 es-hangul입니다.
es-hangul은 편리하게 한글을 다룰 수 있도록 돕는 작은 JavaScript 라이브러리입니다. 초성을 검색하고, 조사를 붙이는 등의 동작을 편리하고 깔끔한 API로 제공합니다.
import { chosungIncludes } from 'es-hangul';
const searchWord = '라면';
const userInput = 'ㄹㅁ';
const result = chosungIncludes(searchWord, userInput);
console.log(result); // true
해당 라이브러리를 이용하면 검색을 하는데 조건식이 조금더 간편해질 것 같네요.
그런 다음에 코드르 변경해봅시다.
기존의 includes로 값을 확인하는 로직을 새롭게 만든 is초성match로 변경하겠습니다.
const matchStock = (
stockList: Holdings[],
query: string,
checkedList: Holdings[]
) => {
if (!query) {
return [];
}
if (!isNaN(Number(query)) && query.length < 3) {
return [];
}
return stockList
.filter((stock: Holdings) => {
// before
//return (stock.code.includes(query) || stock.name.toLowerCase().includes(query));
// after <---------------------------------------------------
return stock.code.includes(query) || is초성match(query, stock.name);
})
.map((stock: Holdings) => {
let checked = checkedList.some((item) => item.name === stock.name);
return { ...stock, checked: checked };
});
};
그럼 결과를 잠깐 확인해볼까요?
이젠 삼성ㅈㅈ가 되어도 제대로 동작하는 것을 확인했습니다.
근데 삼성전자처럼 기존의 컴포넌트처럼 색상을 포커스 해주진 않네요.
그럼 한번 더 개선하러 가보겠습니다.
저는 완전히 매칭 되는 값 삼성은 파란색, 초성이 같은 건 빨간색으로 표시해주고 싶네요. ㅋㅋ
추가로 만약 서버로부터 값을 가져온다면 파란색을 포함하는 주식명을 전부 찾아서 프론트에서 가져오고 빨간색으로 비교한다면 더욱 좋은 검색 UX가 될것 같습니다.
색상을 칠해보자
이것도 기존 컴포넌트를 확인해봐야겠죠?
const BodyItem = ({ StockData, searchQuery, onAdd, onDelete, matchedStocks }: BodyItemProp) => {
const classes = useStyles();
const bodyRowClicked = (e: any) => {
/* 중략 */
};
const onCheckboxClicked = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 중략 */
};
const ColoredItem = ({ item, query }: { item: string; query: string }) => {
return item.includes(query) ? (
<>
{item.split(query)[0]}
<span style={{ color: "#3F51B5" }}>{query}</span>
{item.split(query)[1]}
</>
) : (
<>{item}</>
);
};
return (
<div className={classes.stockItem} key={StockData.code + "layout"} onClick={bodyRowClicked}>
<div className={classes.stockCode} id={`${StockData.code}_code`}>
<ColoredItem item={StockData.code} query={searchQuery} />
</div>
<div className={classes.stockName} id={`${StockData.name}_name`}>
<ColoredItem item={StockData.name} query={searchQuery} />
</div>
<Checkbox
id={`${StockData.code}_checkbox`}
checked={StockData.checked}
color="primary"
onChange={onCheckboxClicked}
/>
</div>
);
};
그리고 아래와 같이 랜더링이 됩니다.
그리고 보시다시피 여기서도 includes를 통해 string을 분리하고 랜더링 해주고 있습니다.
이걸 바꿔야 합니다.
기존 is초성match는 true/false만 주는 함수였으니 새로운 함수 getMatchedGroupList를 만들어보겠습니다.
완전한 문자와 불완전한 문자로 이뤄진 그룹의 정보를 주는 것입니다.
그전에 잠깐 위에서 pattern을 통해 분리된 문자열을 살펴보겠습니다.
이렇게 나옵니다.
쿼리가 "삼성ㅈㅈ"일 때 삼, 성은 완전한 문자고 나머지 ㅈㅈ는 불완전한 문자라고 봐야겠네요.
여기서 pattern에 대한 로직을 바꿔 처리할수 있지만, 그건 귀찮으니 이미 나온 값으로 판단해보겠습니다.
쿼리(["삼","성","ㅈ","ㅈ"]) 와 query.split("").map(pattern)의 결과값 중에 공통값이 바로 완전한 문자들("삼","성")이라고 판단해도 되겠네요.
즉 두 배열의 교집합이 완전한 문자입니다. 교집합은 배열상으로 아래와 같이 찾습니다.
// 배열 교집합
let arrA = [1, 2, 3, 4];
let arrB = [3, 4, 5, 6];
arrA.filter(it => arrB.includes(it)); // returns [3, 4]
그리고 query.split("").map(pattern) 에서 교집합을 제외한, 즉 차집합이 불완전한문자정규식입니다.
// 배열 차집합
let arrA = [1, 2, 3, 4];
let arrB = [3, 4, 5, 6];
arrA.filter(it => !arrB.includes(it)); // returns [1, 2]
이제 기본적인 개념을 공부했으니 getMatchedGroupList를 만들러 가봅시다.
const getMatchedGroupList = (query: string, target: string) => {
const 검색어 = [...query];
const 초성정규식 = query.split("").map(pattern);
const 완전한문자 = 검색어.filter((it) => 초성정규식.includes(it));
const 불완전한문자정규식 = 초성정규식.filter((it) => !검색어.includes(it));
const 완전한문자그룹 = 완전한문자.reduce(
(
group: { index: number; letter: string }[],
curr: string,
currentIndex: number
) => {
if (검색어.includes(curr)) {
group.push({ index: currentIndex, letter: curr });
}
return group;
},
[]
);
const reg = new RegExp(불완전한문자정규식.join(".*?"), "i");
const [_, ...rest] = reg.exec(target) as any;
const 불완전한문자그룹: { index: number; letter: string }[] = rest.map(
(letter: string, index: number) => {
return { index: target.indexOf(letter), letter: letter };
}
);
return { 완전한문자그룹, 불완전한문자그룹 };
};
완전한문자그룹과 불완전한문자그룹이 나옵니다.
이제 각각의 인덱스를 찾았으니 색칠하러 가보겠습니다.
const BodyItem = ({
StockData,
searchQuery,
onAdd,
onDelete,
matchedStocks,
}: BodyItemProp) => {
/* 중략 */
const { 완전한문자그룹, 불완전한문자그룹 } = getMatchedGroupList(
searchQuery,
StockData.name
);
const 색칠된_주식명 = [...StockData.name].map((letter, index) => {
if (완전한문자그룹.map((item) => item.index).includes(index)) {
return <span style={{ color: "#1b31b1" }}>{letter}</span>;
}
if (불완전한문자그룹.map((item) => item.index).includes(index)) {
return <span style={{ color: "#d43912" }}>{letter}</span>;
}
return <span>{letter}</span>;
});
return (
<div
className={classes.stockItem}
key={StockData.code + "layout"}
onClick={bodyRowClicked}
>
<div className={classes.stockCode} id={`${StockData.code}_code`}>
<ColoredItem item={StockData.code} query={searchQuery} />
</div>
<div className={classes.stockName} id={`${StockData.name}_name`}>
// <ColoredItem item={StockData.name} query={searchQuery} /> before
{색칠된_주식명} // <------------------------------------------- after
</div>
<Checkbox
id={`${StockData.code}_checkbox`}
checked={StockData.checked}
color="primary"
onChange={onCheckboxClicked}
/>
</div>
);
};
결과
마치며
이렇게 개선된 검색창 구현이 완료되었습니다.
여기에서 한 발짝 더 나아가면 검색된 결과에서 키보드 이벤트를 통해 원하는 텍스트를 선택하고 엔터를 누르면 check 되는 것인데, 요 프로젝트는 종료된 것이라 굳이 여기서 만들 필요는 없었습니다 ㅎㅎ
한글이 사용하기 쉬운데 반해 이걸 지원하는 개발자 입장에서는 영어와 다른 처리 작업이 필요하다는걸 다시 한번 느낀 시간이었습니다.
당연한 얘기이지만 유저에게 편리함을 주기위해선 그 뒷단에서는 멘탈이 갈리고 있는 개발자가 있습니다..
끝.
p.s 이건 시간날때 라이브러리화해서 만들어볼까 합니다. 여러번 사용해보니 아직 완전하지 않은 점도 있고 하니
참고