메인 페이지에서 "미국"으로 나라 버튼이 선택되어 있는 상태에서, 여행지 검색창에 검색어를 입력하면,
다음과 같이 나라 버튼이 자동으로 "전체" 선택으로 바뀌고, 해당 검색어를 포함한 지역 카드가 나열된다.
메인 페이지의 검색 기능 구현 과정을 차근차근 정리해 보자.
메인 페이지의 검색 기능은 현재 MainPage.tsx 컴포넌트와 SearchBar.tsx 컴포넌트에 구현되어 있고, 두 컴포넌트에서 검색 기능과 관련된 부분만 뽑아서 기능별로 정리해 보았다.
// MainPage.tsx
const [inputValue, setInputValue] = useState<string | null>(null); // 검색창 입력값
const [isComposing, setIsComposing] = useState<boolean>(false); // 한글 조합 중 여부
const [searchResult, setSearchResult] = useState<string[]>([]); // 검색 결과
💻
const [inputValue, setInputValue] = useState<string | null>(null);
- 목적: 사용자가 검색창에 입력하는 값을 실시간으로 저장하는 상태
- 왜
string | null?
- 초기값을
null로 설정했기 때문에 타입은string | null로 지정해야 함- 실제로는 문자열(string)로 사용되지만, 초기 상태에선 아무 입력도 없다는 것을 명시적으로 나타내기 위해
null을 사용함- 사용 예시
➡️<SearchBar inputValue={inputValue} setInputValue={setInputValue} />SearchBar컴포넌트에 연결되어, 사용자의 입력이 바뀔 때마다setInputValue를 통해 상태가 업데이트됨
💻
const [isComposing, setIsComposing] = useState<boolean>(false);
- 목적
- 한글 입력 중인지 여부를 추적하기 위한 상태
- 한글은 입력 중간에
"ㅂ" → "ㅜ" → "ㅂㅜ" → "부"와 같이 조합이 되므로, 이런 조합 중에는 검색 결과를 즉시 갱신하지 않도록 제어하는 데 사용됨- 왜
boolean?
- 조합 중이면
true, 조합이 끝났으면false- 두 가지 상태만 있으므로 불리언 타입이 적절함
- 어떻게 설정됨?
➡️<input onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} />SearchBar컴포넌트에서onCompositionStart,onCompositionEnd이벤트를 통해 제어됨
💻
const [searchResult, setSearchResult] = useState<string[]>([]);
- 목적
- 사용자의 검색어에 따라 필터링된 도시 목록을 저장하는 상태
"대한민국 서울","일본 오사카"등의 도시 이름이 포함됨- 왜
string[]?
- 하나의 검색어가 여러 도시와 일치할 수 있으므로, 여러 문자열을 저장할 수 있는 배열 형태가 필요함
- 배열이기 때문에
.map()을 활용해 각 도시 카드 렌더링 가능- 사용 예시
searchResult.map((result) => ( <CityCard key={result} cardName={result} /> ))
// MainPage.tsx
useEffect(() => {
// 입력값 앞뒤 공백 제거
const trimmedInput = inputValue?.trim();
// 입력이 비어있으면 검색 결과 초기화 후 종료
if (!trimmedInput) {
setSearchResult([]);
return;
}
// 조건에 맞는 도시 목록을 담을 배열
const matched: string[] = [];
// 전체 국가-도시 목록 순회하며 입력값이 포함된 도시명 찾기
Object.entries(COUNTRY_CITY).forEach(([country, cities]) => {
cities.forEach((city) => {
const fullName = `${country} ${city}`;
if (fullName.includes(trimmedInput)) matched.push(fullName);
});
});
// 검색 중일 때는 국가 필터를 "전체"로 고정
setClickedCountry("전체");
// 한글 조합 중이고 결과가 없을 경우: 기존 결과 유지 (업데이트 생략)
if (isComposing && matched.length === 0) return;
// 조합 중이 아니거나 결과가 있는 경우: 결과 갱신
setSearchResult(matched);
}, [inputValue, isComposing]);
💻
useEffect(() => { ... }, [inputValue]);
inputValue가 변경될 때마다 호출됨- 즉, 사용자가 검색창에 무언가 입력할 때마다 이 함수가 실행됨
💻
const trimmedInput = inputValue?.trim();
inputValue가null이 아닌 경우, 양쪽 공백을 제거한 값을trimmedInput에 저장함- 예: ' 부산 ' → '부산'
❓
trim()메서드란?
문자열의 양 끝(앞뒤)의 공백 문자(whitespace)를 제거하는 String 메서드
const text = " Hello world! "; console.log(text.trim()); // "Hello world!"문자열 중간의 공백은 건드리지 않음
trim()이 제거하는 공백 문자(Whitespace characters)
문자 설명 ' '일반 공백 (space) '\t'탭 '\n'줄바꿈 (LF) '\r'캐리지 리턴 (CR) '\v'수직 탭 '\f'폼 피드 '\u00A0'non-breaking space (HTMP $nbsp;)'\u1680','\u2000'~'\u200B','\u3000'등다양한 유니코드 공백 문자들 포함
💻
if (!trimmedInput) { ... }
- 입력값이 비어있다면, (
null, '', 공백만 있는 경우 포함)
- 검색 결과를 초기화함 (
searchResult를 빈 배열로 설정함)- 더 이상 아래 로직은 실행하지 않고 함수를 종료함
💻
const matched: string[] = [];
- 검색 결과를 담을 빈 배열을 선언함
- 여기에 조건에 맞는 도시명이 하나씩 추가될 예정임
💻
Object.entries(COUNTRY_CITY).forEach(...);
COUNTRY_CITY는{ 나라이름: [도시1, 도시2, ...] }형태의 객체Object.entries로 국가-도시 쌍을 하나씩 순회함- 각 도시마다
"국가명 도시명"(예:"대한민국 부산") 형태로 만든 후 (fullName)
- 사용자가 입력한 문자열(
trimmedInput)이 포함되어 있다면, 검색 결과 배열인matched에 추가함- 예시
🎯 입력:"도쿄"
🎯 fullName:"일본 도쿄"→includes("도쿄")→true→ 배열에 추가
❓
Object.entries()란?
- 객체의 속성을
[key, value]형태의 배열로 변환해주는 메서드리턴 타입Object.entries(obj)[ [key1, value1], [key2, value2], ..., [kenN, valueN] ]- 예제
➡️ 이 결과는 2차원 배열이며, 각각의 요소는 객체의 한 속성을 나타냄const person = { name: "Hyunjin", age: 25, job: "developer" }; console.log(Object.entries(person)); // [ // ["name", "Hyunjin"], // ["age", 25], // ["job", "developer"], // ]
👉 참고:
COUNTRY_CITY데이터export const COUNTRY_CITY: { [countryName: string]: string[] } = { 대한민국: ["서울", "부산", "제주", "경주", "강릉", "전주", "수원"], 일본: ["오사카", "도쿄", "후쿠오카", "삿포로"], 중국: ["베이징", "상하이"], 태국: ["방콕", "치앙마이", "코사무이"], 미국: ["뉴욕", "하와이", "괌", "로스앤젤레스", "샌프란시스코", "사이판"], 이탈리아: ["로마", "피렌체", "베네치아", "밀라노", "나폴리"], 스페인: ["바르셀로나", "마드리드"], };
💻
setClickedCountry("전체");
- 검색을 시작했기 때문에 국가 필터를
"전체"로 고정함- 즉, 특정 국가 버튼이 눌린 상태여도 검색 결과는 모든 국가 대상으로 필터링됨
💻
if (isComposing && matched.length === 0) return;
- 한글 조합 중(
isComposing이true)인데, 아직 검색 결과가 없다면 → 이전 검색 결과를 그대로 유지 (검색 결과 갱신 안 함)- 이유: 한글은 입력 중간에 "ㅂ", "ㅜ", "ㅂㅜ", "부"처럼 조합되기 때문에 중간 단계에서 결과를 계속 지우면 사용자 경험(UX)이 나빠짐
💻
setSearchResult(matched);
- 조합 중이 아니거나, 조합 중이더라도 결과가 있을 경우에는 → 검색 결과(
searchResult)를matched배열로 갱신함
SearchBar 컴포넌트와 props 연결// MainPage.tsx
<SearchBar
placeholder="원하는 여행지를"
main
inputValue={inputValue}
setInputValue={setInputValue}
setIsComposing={setIsComposing}
/>
💻
main
- 역할: boolean 타입의 커스텀 플래그
SearchBar내부에서main이true인지 아닌지에 따라 스타일이나 동작을 달리함
💻
inputValue={inputValue}
- 역할:
SearchBar안에 표시될 현재 입력값을 전달함- 이 값이
<input value={inputValue} />형태로 설정되어 있음 (controlled input)- 사용자가 타이핑하면, 이 값을 계속 최신으로 반영함
💻
setInputValue={setInputValue}
- 역할: 사용자가 입력할 때마다
inputValue를 갱신하는 함수- 즉, 사용자의 입력을
MainPage컴포넌트 쪽 state에 반영함- 이게 있어야
SearchBar가 실제로 동작할 수 있음 (입력값을 관리하기 위해)
💻
setIsComposing={setIsComposing}
- 역할: 사용자가 한글을 입력할 때, 조합 중인지 아닌지를 추적함
- 한국어, 일본어 등의 입력 방식에서는 문자를 완성하는 동안 여러 이벤트가 발생하기 때문에 이 구분이 중요함
- 이 덕분에
MainPage에서는 조합 중일 때는searchResult를 갱신하지 않고, 조합이 끝나면 갱신하도록 구현할 수 있음
🧩 전체 흐름 정리
- 사용자가
<SearchBar />에 텍스트를 입력함<input>태그의onChange에 연결된setInputValue를 통해 상위 컴포넌트(MainPage)의 상태(inputValue)가 갱신됨inputValue가 바뀌면useEffect()가 실행되어searchResult필터링이 이뤄짐- 한글 입력 중인지는
setIsComposing으로 추적해서, 불필요한 갱신을 막음
// MainPage.tsx
{inputValue?.trim()
? searchResult.length > 0 || isComposing
? searchResult.map((result) => (
<CityCard
key={result}
cardImg={`/images/cities/${result.split(" ")[1].webp}`}
cardName={result}
/>
))
: null // 완성된 입력이고 결과 없음 → 아무것도 안 보임
: clickedCountry === "전체"
? Object.entries(COUNTRY_CITY).flatMap(([country, cities]) =>
cities.map((city) => (
<CityCard
key={city}
cardImg={`/images/cities/${city}.webp`}
cardName={`${country} ${city}`}
/>
))
)
: COUNTRY_CITY[clickedCountry].map((city) => (
<CityCard
key={city}
cardImg={`/images/cities/${city}.webp`}
cardName={`${clickedCountry} ${city}`}
/>
))}
🔍 전체 구조 개요
{inputValue?.trim() ? (검색 중일 때 조건) : clickedCountry === "전체" ? (국가 전체 보기 조건) : (특정 국가 보기 조건)}
검색어가 있을 경우 (inputValue?.trim())
inputValue?.trim()
사용자가 입력한 값이 존재하면(공백 제외), 검색 중인 상태라고 판단함
- 하위 조건: 검색 결과가 있거나 한글 조합 중일 때
searchResult.length > 0 || isComposing ? searchResult.map((result) => ( <CityCard key={result} cardImg={`/images/cities/${result.split(" ")[1].webp}`} cardName={result} /> ))
searchResult:"중국 상하이","이탈리아 로마"처럼"국가명 도시명"형식result.split(" ")[1]: 도시 이름만 추출해서 이미지 경로 생성cardImg:/images/cities/서울.webp,/images/cities/도쿄.webp등cardName: 전체 이름 그대로 전달- ✅ 조합 중에는 검색 결과가 없더라도 이전 결과를 보여주기 위해
|| isComposing조건을 사용함
- 검색 결과도 없고, 조합 중도 아닌 경우
: null // 아무것도 안 보임
- 입력은 했지만, 결과가 없는 경우
- 조합 중도 아님 → 사용자에게 "일치하는 항목이 없음"을 알려줌
검색어가 없고 전체 보기인 경우 (clickedCountry === "전체")
: clickedCountry === "전체"
? Object.entries(COUNTRY_CITY).flatMap(([country, cities]) =>
cities.map((city) => (
<CityCard
key={city}
cardImg={`/images/cities/${city}.webp`}
cardName={`${country} ${city}`}
/>
))
)
COUNTRY_CITY는 { "대한민국": ["서울", "부산", ...] , ... } 형태의 객체Object.entries()로 국가-도시 쌍을 순회함flatMap()을 사용하여 국가별 도시 배열들을 하나의 평평한 배열로 만듦"대한민국 서울"/images/cities/서울.webp🧐
flatMap()을 사용하는 이유?🔍
Object.entries(COUNTRY_CITY)가 반환하는 형태
COUNTRY_CITY형태는 다음과 같음const COUNTRY_CITY = { 대한민국: ["서울", "부산", "제주", "경주", "강릉", "전주", "수원"], 일본: ["오사카", "도쿄", "후쿠오카", "삿포로"], ... };따라서
Object.entries(COUNTRY_CITY)를 하면, 결과는 아래와 같은 2차원 배열이 나옴[ ["대한민국", ["서울", "부산", "제주", "경주", "강릉", "전주", "수원"]], ["일본", ["오사카", "도쿄", "후쿠오카", "삿포로"]], ... ]🔍
map()만 썼을 경우Object.entries(COUNTRY_CITY).map(([country, cities]) => cities.map((city) => ( <CityCard key={city} cardName={`${country} ${city}`} /> )) )이렇게 하면 배열의 배열(
JSX[][])이 생성됨[ [<CityCard key="서울" />, <CityCard key="부산" />], [<CityCard key="도쿄" />, <CityCard key="오사카" />], ]➡️ 이중 배열이므로 React에서 직접 렌더링하면 레이아웃이 깨질 수 있음 (예:
key, warning, 불필요한 중첩)🔍 그래서
.flatMap()을 쓰는 이유Object.entries(COUNTRY_CITY).flatMap(([country, cities]) => cities.map((city) => ( <CityCard key={city} cardName={`${country} ${city}`} /> )) )
flatMap()은.map()으로 만든 내부 배열을 자동으로 평탄화(1차원 배열) 해주므로, 결과는 다음과 같음[ <CityCard key="서울" />, <CityCard key="부산" />, <CityCard key="도쿄" />, <CityCard key="오사카" /> ]➡️ React가 이 배열을 한 번에 렌더링할 수 있음
특정 국가 선택된 경우 (clickedCountry !== "전체")
: COUNTRY_CITY[clickedCountry].map((city) => (
<CityCard
key={city}
cardImg={`/images/cities/${city}.webp`}
cardName={`${clickedCountry} ${city}`}
/>
))
clickedCountry = "일본" → "일본 도쿄", "일본 오사카" 렌더링/images/cities/도쿄.webp 등SearchBar 컴포넌트)// SearchBar.tsx
import clsx from "clsx";
interface SearchBarProps {
placeholder: string;
main?: boolean;
placeExploration?: boolean;
inputValue: string | null;
setInputValue: React.Dispatch<React.SetStateAction<string | null>>;
setIsComposing: React.Dispatch<React.SetStateAction<boolean>>;
}
const SearchBar = ({
placeholder,
main,
placeExploration,
inputValue,
setInputValue,
setIsComposing,
}: SearchBarProps) => {
const handleChangeInputValue = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
return (
<div
className={clsx(
"flex justify-between px-[20px] py-[10px] bg-white rounded-[6px] w-[450px] h-[55px]",
main && "[box-shadow:6px_6px_10px_rgba(0,0,0)]",
placeExploration && "[box-shadow:6px_6px_10px_rgba(0,0,0,0.2)]"
)}
>
<input
type="text"
placeholder={`${placeholder} 검색하세요.`}
value={inputValue ?? ""}
onChange={handleChangeInputValue}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setInputValue(e.currentTarget.value);
setIsComposing(false);
}}
className="outline-none w-[370px] text-black placeholder:text-[#b8b8b8] placeholder:text-[14px]"
/>
<img src="/images/search.svg" alt="search" className="text-[10px]" />
</div>
);
};
export default SearchBar;
Props 설명
const SearchBar = ({
placeholder,
main,
placeExploration,
inputValue,
setInputValue,
setIsComposing,
}: SearchBarProps) => { ... }
| prop | 타입 | 설명 |
|---|---|---|
placeholder | string | 입력창 힌트 텍스트 |
main | boolean (optional) | 메인 페이지용 스타일 적용 여부 |
placeExploration | boolean (optional) | 장소 탐색 페이지용 스타일 여부 |
inputValue | string 또는 null | 현재 입력된 텍스트 |
setInputValue | setState 함수 | 입력값 변경 시 상태 갱신 함수 |
setIsComposing | setState 함수 | 한글 조합 상태 처리용 함수 |
입력 이벤트 처리
handleChangeInputValue
const handleChangeInputValue = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
setInputValue를 호출해서 상위 컴포넌트 상태에 입력값을 반영함onCompositionStart / onCompositionEnd
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setInputValue(e.currentTarget.value);
setIsComposing(false);
}}
isComposing → truesetInputValue로 반영함isComposing → false로 되돌림useEffect에서 타이밍 문제로 검색 결과가 튀거나 깜빡이는 문제가 발생함🧐
e.currentTarget을 사용하는 이유?🔍
e.target과e.currentTarget
속성 설명 사용 상황 e.target이벤트가 실제로 발생한 요소 (event source) 위임 이벤트, 자식 요소에서 클릭 등 e.currentTarget현재 이벤트 핸들러가 등록된 요소 (지금 this가 가리키는 요소)내가 직접 이벤트를 건 요소에서 값 읽기 🔍 코드 다시 보기
onCompositionEnd={(e) => { setInputValue(e.currentTarget.value); setIsComposing(false); }}
- 여기서 이 이벤트 핸들러는
<input />요소에 직접 걸려 있음- 즉,
e.currentTarget === input 요소가 항상 보장되는 상황임🔍 그런데 왜
e.target.value대신e.currentTarget.value를 쓰는가?
- 💡 안전성과 명확성 때문!
- 일반적으로 이벤트 버블링이 일어날 수 있는 구조에서는
e.target이 내가 의도하지 않은 자식 요소일 수 있음여기서 버튼을 클릭하면<div onClick={handler}> <button>Click</button> </div>
✅e.target→<button>
✅e.currentTarget→<div>(핸들러가 붙어 있는 곳)🔍 하지만 지금은
<input>에 직접 핸들러가 걸려 있기 때문에
e.currentTarget===<input>e.target===<input>➡️ 둘이 같아 보이지만, 엄밀히 말하면 다르게 동작할 수 있는 가능성이 있어서
currentTarget을 쓰는 게 더 명확하고 안전함
🧐
onCompositionStart,onCompositionEnd란?
onCompositionStart와onCompositionEnd는 한글(또는 일본어, 중국어 등)과 같이 문자를 조합해서 입력하는 입력기(IME: Input Method Editor)를 지원하는 상황에서 매우 중요한 이벤트이다.
특히 React에서 검색창이나 폼 입력을 다룰 때, 이걸 정확히 이해하고 사용하는 건 UX를 망치지 않기 위한 핵심 포인트라고 할 수 있다.🔍 한 줄 정의
이벤트 정의 onCompositionStart한글 등 조합형 문자의 입력이 시작될 때 발생함 onCompositionEnd조합이 완료되어 최종 글자가 입력되었을 때 발생함 🔍 왜 필요한가? (한글 입력의 특징)
한글 입력 과정은 단순히 문자 하나씩 입력되는 것이 아니라,
ㅎ→ㅏ→ '하`처럼 여러 단계에 걸쳐 조합(composition)되기 때문에, 이 중간 과정에서는 검색 결과를 갱신하거나 자동완성 추천을 띄우면 사용자 경험이 매우 나빠짐🔍 각 이벤트의 의미 & 시점
단계 키보드 입력 이벤트 발생 상태 1 사용자가 'ㅎ' 입력 onCompositionStart조합 시작됨 ( isComposing = true)2 'ㅎ'+'ㅏ' = '하' (진행 중) 아직 onChange는 여러 번 발생3 스페이스, Enter, 탭 등으로 조합 완료 onCompositionEnd조합 끝남 ( isComposing = false)4 - onChange가 한 번 발생value가 확정됨
<input /> 필드
<input
type="text"
placeholder={`${placeholder} 검색하세요.`}
value={inputValue ?? ""}
onChange={handleChangeInputValue}
onCompositionStart={...}
onCompostionEnd={...}
className="..."
/>
inputValue로 관리하는 controlled inputplaceholder는 외부에서 전달받은 텍스트로 구성됨❓ controlled input이란?
- 입력값이 React의 state로 관리되는 input 요소
<input>태그의value속성이useState같은 상태 변수와 연결되어 있고,onChange이벤트를 통해 값을 업데이트하는 구조임const [text, setText] = useState(""); return ( <input value={text} onChange={(e) => setText(e.target.value)} /> );
value={text}: input의 값은 항상text상태로 결정됨onChange: 사용자가 입력할 때마다 상태(text)를 업데이트- 이렇게 되면, 입력값은 DOM이 아니라 React state에 의해 완전히 제어되는 상태가 됨 ➡️ 그래서 "controlled"라고 부름
❓ uncontrolled input이란?
<input defaultValue="초기값" />
value가 아닌defaultValue를 쓰고, 상태(useState)로 입력값을 제어하지 않음- 실제 값은 DOM이 가지고 있음 ➡️ React가 직접적으로 제어하지 않음
🧐 controlled input을 쓰는 이유
이유 설명 입력값 동기화 상태를 기반으로 동작하므로 다른 UI, 로직과 쉽게 연결 가능 유효성 검사 입력 즉시 검사 가능 (예: 비밀번호 8자 이상 확인) 리렌더링 기반 처리 조건부 렌더링, 자동 포커싱, 디바운싱 등 처리 가능 값 초기화 버튼 클릭 시 상태 초기화 → input도 함께 초기화
전체 동작 흐름
1) 사용자가 타이핑하면 onChange로 상위 컴포넌트에 값을 전달함
2) 입력 중 한글 조합 여부는 composition 이벤트로 처리함
3) 스타일은 main, placeExploration props로 분기함
4) 최종적으로, 외부 컴포넌트에서 검색 로직(useEffect)에 따라 결과가 갱신됨