
목표
☑️ ui 추상화
☑️ 추천 검색어 보여주기
☑️ focusing
☑️ Debounce
☑️ 캐싱
☑️ 테스트 코드 작성
📊 클래스 다이어그램

⏳ 동작 방식
로드 할 때
- App에서 searchBox와 serachResult 컴포넌트를 생성.
- searchBox는 suggestionList 컴포넌트를 생성.
검색할 때
- keydown 이벤트가 일어날 때 value 값을 받아 suggestionList 컴포넌트의 상태를 갱신.
- 이벤트는 debounce 되어 api 호출 시간을 지연(300ms).
- 만약 캐싱된 결과가 있다면 캐싱된 결과를 보여주고 그렇지 않을 경우 api를 호출.
- value가 없다면 suggestionList 컴포넌트에 hide 속성을 주어 보이지 않음.
위아래 방향키를 누를 때
- suggestionList의 cursor가 focus를 만들고 있으므로 event key가
ArrowUp
또는ArrowDown
일 때 prevCursor와 nextCursor를 계산해 다음 cursor를 상태로 전달.
clear 아이콘을 누를 때
- classList 중 clear-icon이란 이름을 갖고 있을 때 발생.
- input의 value를 빈 값으로 넣고 다른 검색을 할 수 있도록 focus 시킴.
검색 결과를 제출할 때
- submit 이벤트가 일어나거나 li 태그를 클릭할 경우에 발생.
- App의 onSubmitInput이 searchResult에 content 상태를 전달하는데, onSubmitInput은 searchBox의 inpute에 value가 담길 때 실행.
- input에 value가 담기면 suggestionList는 cursor 값과 일치하는 content를 전달.
🤔 고민한 기록
주저리주저리 적은 것들.
오버스펙
검색기에서
한 번도 검색하지 않았을 때
와 검색 결과가 없을 때
를 구분하려 했다. 검색 결과가 없을 때 api에서 빈 배열이 내려오는 데 length로 처리하자니 앞서 말한 둘의 구분이 되지 않았다. 그래서 null과 array의 length가 0인 걸 구분하기 위해 null일 때와 length가 0일 때를 구분했다. 다만 코드가 예외처리가 많아 지저분해졌다.또한 api 결과를 담은 suggestionList의 타입에 null이 들어가 있으니 항상 null에 대한 타입을 예외로 지정해주어야 했다.
그러다 요구사항을 다시 봤다. 요구 사항에는 이 둘을 구분해야 한다고 명시되어있지 않았다. 만약 협업에서 일한다면 어떻게 하는 게 좋겠냐고 물어보겠지만 지금은 그런 상황이 아니라 결국 이 둘을 구분하지 않기로 했다. 즉, 처음 검색을 위해 input에 포커싱을 시켰을 때와 api에 없는 단어를 검색했을 때 모두 자동 완성이 뜨지 않는 같은 결과를 보여주기로 결정했다.
이렇게 하니 초기 상태를 빈 배열로 지정할 수 있어서 코드가 훨씬 깔끔해졌다. 내가 구현하려는 게 UX적으론 좋을 수 있지만 우선 순위가 있진 않았던 것이다.
isComposing
자동 완성된 추천 단어를 방향키로 이동해야 했다. 그러려면 키보드 이벤트 중 keydown이 일어날 때를 감지해서 focus를 이동시켜야 하는데 처음 방향키를 누를 때 두 번 눌리는 이슈가 있었다. 처음엔 코드 어딘가에서 이벤트를 또 부르는 줄 알고 찾았지만 찾지 못했다.
한참을 헤매다 다시 처음으로 돌아가서 keyboard를 눌렀을 때 어떤 이벤트가 들어오는지 확인했다. 처음 이벤트를 눌렀을 때가 문제였으니 처음과 두 번째 이벤트의 어떤 변화가 있는지 집중적으로 봤다. 그랬더니 하나의 차이점이 있었다. 바로 첫 이벤트에
isComposing
이 true
였다. 나머지 이벤트는 전부 false
인 걸로 보아 여기에 정답이 있다고 판단했다.isComposing
이란 입력된 문자가 조합 문자인지 아닌지를 나타내는 논릿값이라고 한다. 그리고 구글링 결과 - 한글이나 중국어같이 영어가 아닌 글자들은 한 글자가 영어보다 많은 정보를 담고 있기 때문에 키 입력 순간부터 입력 완료 까지 시간이 걸리고,
- 이는 keydown 이벤트가 이미 발생하고 난 후에도 진행 중일 수 있다는 것을 의미한다.
- 그 상태를 isCompsing 이란 event 객체 속성값으로 확인 할 수 있다는 것이다.
라는 걸 알 수 있었다.
즉, 이벤트 함수는 호출되어도 글자가 구성되는 중이라 중복돼서 찍힌다는 것이다.
keypress
로 대안을 찾을 수도 있다 했는데 keydown
이란 이벤트를 쓰고 싶어서 isComposing
이 true
라면 return을 했다. 이 컨텍스트를 알려면 시간이 필요하여 내 코드에선 주석으로 간단히 설명해두었다.
상태들을 한 번에 갱신하지 않을 때
컴포넌트 패턴에서 상태 전달은 빈번히 일어난다. 이때 어떤 컴포넌트에 A와 B라는 상태가 있는데, 이 둘의 상태를 갱신하는 경우가 다를 때 고민이 생겼다.
typescript에서 상태 타입을 미리 정의해야 하는 데 들어올 수도 있고, 들어오지 않을 수도 있는 값엔 물음표를 붙인다. 앞선 사례를 구체적으로 설명하면 setState 시 사용자가 input에 입력할 때 suggestionList가 갱신되고, 추천 검색어가 보일 때 방향키로 이동하면 cursor가 갱신된다. 이렇게 상황에 따라 바꾸는 상태가 다를 때 물음표를 쓰는데 이게 코드를 지저분하게 만든다는 느낌을 받았다.
예를 들어 cursor 상태가 변화함에 따라 처리하는 코드는 다음과 같다.
const prevCursor = this.state?.cursor ?? 0; const suggestionList = this.state?.suggestionList ?? [];
상태가 들어올 수도 있고 들어오지 않을 수도 있는 값이라는 이유로 많은 물음표가 생겼다. 이 코드가 동작할 시점엔 무조건 cursor가 있지만, typescript가 알 길이 없으니 옵셔널 체이닝으로 처리해주었다. 이것보다 좋은 방법이 있는지 고민이다.
api 불러오는 방법
api 호출은 많이 해봤지만 공부할 때마다 호출하는 방식이 달라지는 것 같다. 현재 시점에서 내가 선호하는 api 호출 방법은 api 호출 코드가 담긴 파일 안에 api path나 type 정의를 모두 하는 것이다.
const API_END_POINT = "blabla"; const getSuggestionListPath = (word: string) => `${API_END_POINT}/autocomplete?value=${word}`; export const fetchSuggestionList = async ( word: string ): Promise<SuggestionItem[]> => { try { const res = await fetch(getSuggestionListPath(word)); if (!res.ok) { throw new Error("Fail to call api"); } return res.json(); } catch { return []; } }; export interface SuggestionListProps { suggestionItems: SuggestionItem[]; } export interface SuggestionItem { text: string; id: number; }
코드 작성 초반엔 api 호출하는 곳에서 api url 일부를 word와 함께 전달하거나 api를 사용하는 곳에서 타입 정의를 하기도 했다. 하지만 api 호출 부에서 path와 type을 적으면 다른 컴포넌트에서 path를 알 필요가 없고, type은 export로 작성하니, 다시 보고 싶을 때 api 파일만 들어가면 된다고 생각해서 코드가 간단해진 느낌이다.
추후 path를 export할 일이 생겨도 path 관련 코드에 export만 추가해주면 되는 것이다.
focusing
input 영역이 아닌 다른 곳을 클릭하면 focus가 해제되면서 추천 검색 창을 닫아야 했다. 처음엔 addEventListenr에 있는 focustIn, focusOut 이벤트를 쓰면 간단하게 구현할 수 있을 거라 생각했다. 하지만 문제는 추천 리스트에 한해서 클릭되면 검색 결과를 보여줘야 하지 검색 창을 숨기면 안된다는 것에서 생겼다.
사실 검색 결과를 보여주는 것도 구현 요구 사항에는 없었다. 하지만 검색 결과는 보여줘야 자동 완성 검색기가 하나의 프로그램은 될 수 있다고 생각했다. 시간적으로 나쁘지 않았기 때문에 결과는 보여주기로 했다.
input을 target으로 한 focusOut 입장에서 검색 결과는 input 영역이 아니기 때문에 focusOut을 시켜야 했지만 검색 결과를 SearhResult.ts에 전달하기 전에 닫아버리니 상태가 전달되지 않았다. 결국 focusOut 대신 hide라는 함수를 만들어 hide가 필요한 상황에 넣어주기로 결정했다.
document.addEventListener("click", (e) => { const $eventTarget = e.target as HTMLElement; if (!$eventTarget.closest(idToQuery(this.$target.id))) { this.suggestionListComp?.hide(); } });
여기서 closest가 사용됐다. closest는 현재 요소에서 가장 가까운 조상을 반환한다. 여기서 this.$target.id는 searchBoxComponent를 의미한다. 따라서 hide가 될 부분은 searchBox 안에 있는 input, 아이콘, 추천검색 리스트를 제외한 부분이기에 이벤트 타깃의 가까운 조상이 searchBoxComponent가 아니라면 숨기면 된다.
다만 show, hide 함수를 만들어 모든 이벤트마다 넣어주는 방식이 좋은진 아직 모르겠다.
리팩터링
코드 작성 중에도 수정을 자주 해서 크게 바꿀 건 없다고 생각했는데 다시 돌아보면 읽기 어려운 부분들이 보였다. 리팩터링한 부분은 다음과 같다.
- suggestionList, suggestionItem, suggestionItems의 혼재
- 여기서 특히 suggestionList와 suggestionItems가 중복된다 생각했다. 그래서 suggestionItems를 suggestionList로 통일하고 List안에 있는 각각을 suggestionItem으로 정의했다.
- enter를 submit으로 변경
- keydown 이벤트를 만들다 보니 Enter를 누르면 submit이 되기 때문에 submit이 되었을 때 관련된 행동을 Enter를 눌렀을 때 일어나도록 코드를 작성했다. 하지만 Enter는 하나의 keydown 이벤트일 뿐 submit이라고 하긴 어렵다. 그래서 submit 이벤트를 만들어 Enter에 적힌 코드를 옮겼다. 같은 동작이지만 코드가 확실히 분명해진 기분이다.
- className을 classList로 변경
- 입력을 지워주는 버튼 등 특정 className으로 판단해서 행동을 붙일 때가 있다. 이때 className을 event target으로부터 추출하여 사용했다. 근데 className은 class가 하나일 때만 판별할 수 있다. 따라서 여러 class가 들어갈 것을 고려해 classList에 contain이란 속성으로 class를 판별하는 것이 확장성 측면에서 좋다고 생각하여 변경했다.
테스트 코드 작성
테스트 코드를 작성할 때 겪었던 어려움은 크게 두 가지 이다.
- keydown 이벤트는 wait을 걸거나 intercept 해야 한다.
- keydown 이벤트 중 downArrow를 명령했는데 api가 호출됐음에도 불구하고 downArrow만 적용이 안 돼서 헤맸다. 해결은 제목에 나와있다 시피 눈에는 api가 바로 호출된 것 같아도 downArrow가 api 호출이 완료되기 전에 불려서 방향키가 동작하지 않는 것처럼 보인 거였다. 생각하면 쉬운 원리였는데 cypress는 처음이라 사용법이 잘못됐을 가능성 때문에 더 헤맸다.
- api 호출을 기다리는 방법은 2가지다. 일정한 시간 동안 wait를 주거나 api 호출을 intercept하고 이를 기다리거나. 나는 후자를 선택했다. 가볍게 wait를 줘도 되지만 wait을 줄 때 생기는 불안정함이 걸렸다.
- 네트워크 상태가 순간 느려져서 api 호출이 wait보다 느려질 수도 있다.
- 임의로 정의한 시간을 기다려야 한다.
- 정확도가 높지 않다.
그래서 intercept 하기로 했다.
//intercept 하는 함수를 만들어 사용. const interceptSuggestionList = () => { cy.intercept("/web-front/autocomplete?value=*").as( "interceptSuggestionList" ); };
- api 호출 여부를 확인하는 방법
- input에 입력값이 없을 때 호출이 안 되는 걸 테스트하기 위해 api의 호출 여부를 판단해야 했다. 찾아보니 함수를 감시하는 spy로 해결이 될 것 같았다. 그래서 공식 문서를 보며 수없이 시도했지만 감시가 되지 않았다. 이때도 내가 spy를 사용할 줄 모른다고 생각해 예시를 찾아다녔다. 하지만 spy는 프로덕트와 테스트가 돌고 있는 컨텍스트가 달라 돔이 아닌 함수를 테스트는 어렵다고 하여 spy는 사용하지 못했다.
- 그렇다면 어떻게 api 호출 여부를 확인할 수 있을까? 아직 잘 모르겠다.. 우선 한가지 알아낸 건 api 호출이 일어나지 않으면 intercept도 되지 않는다. 그래서 intercept가 일어나지 않으면 true로 만들고 싶은데 코드를 작성하다 보니 오히려 헷갈리게 만드는 코드가 된 것 같아서 여기에만 남겨둔다.
it("input value가 없으면 api를 호출하지 않아요.", () => { clearSessionStorage(); interceptSuggestionList(); searchWord(" "); cy.intercept("/web-front/autocomplete?value=*", () => { expect(false).to.be.true; }); });