[TIL-0518] 검색 기능 구현

jiny·2025년 6월 5일

캡스톤2

목록 보기
22/22

🌟 메인 페이지 검색 기능

메인 페이지에서 "미국"으로 나라 버튼이 선택되어 있는 상태에서, 여행지 검색창에 검색어를 입력하면,다음과 같이 나라 버튼이 자동으로 "전체" 선택으로 바뀌고, 해당 검색어를 포함한 지역 카드가 나열된다.

메인 페이지의 검색 기능 구현 과정을 차근차근 정리해 보자.

메인 페이지의 검색 기능은 현재 MainPage.tsx 컴포넌트와 SearchBar.tsx 컴포넌트에 구현되어 있고, 두 컴포넌트에서 검색 기능과 관련된 부분만 뽑아서 기능별로 정리해 보았다.


🌟 검색 상태 관련 state 정의

// 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();

  • inputValuenull이 아닌 경우, 양쪽 공백을 제거한 값을 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] ]
  • 예제
    const person = {
      name: "Hyunjin",
      age: 25,
      job: "developer"
    };
    console.log(Object.entries(person));
    // [
    //   ["name", "Hyunjin"],
    //   ["age", 25],
    //   ["job", "developer"],
    // ]
    ➡️ 이 결과는 2차원 배열이며, 각각의 요소는 객체의 한 속성을 나타냄

👉 참고: COUNTRY_CITY 데이터

  export const COUNTRY_CITY: { [countryName: string]: string[] } = {
   대한민국: ["서울", "부산", "제주", "경주", "강릉", "전주", "수원"],
   일본: ["오사카", "도쿄", "후쿠오카", "삿포로"],
   중국: ["베이징", "상하이"],
   태국: ["방콕", "치앙마이", "코사무이"],
   미국: ["뉴욕", "하와이", "괌", "로스앤젤레스", "샌프란시스코", "사이판"],
   이탈리아: ["로마", "피렌체", "베네치아", "밀라노", "나폴리"],
   스페인: ["바르셀로나", "마드리드"],
 };

💻 setClickedCountry("전체");

  • 검색을 시작했기 때문에 국가 필터를 "전체"로 고정함
  • 즉, 특정 국가 버튼이 눌린 상태여도 검색 결과는 모든 국가 대상으로 필터링

💻 if (isComposing && matched.length === 0) return;

  • 한글 조합 중(isComposingtrue)인데, 아직 검색 결과가 없다면 → 이전 검색 결과를 그대로 유지 (검색 결과 갱신 안 함)
  • 이유: 한글은 입력 중간에 "ㅂ", "ㅜ", "ㅂㅜ", "부"처럼 조합되기 때문에 중간 단계에서 결과를 계속 지우면 사용자 경험(UX)이 나빠짐

💻 setSearchResult(matched);

  • 조합 중이 아니거나, 조합 중이더라도 결과가 있을 경우에는 → 검색 결과(searchResult)를 matched 배열로 갱신함

🌟 SearchBar 컴포넌트와 props 연결

// MainPage.tsx
<SearchBar
  placeholder="원하는 여행지를"
  main
  inputValue={inputValue}
  setInputValue={setInputValue}
  setIsComposing={setIsComposing}
/>

💻 main

  • 역할: boolean 타입의 커스텀 플래그
  • SearchBar 내부에서 maintrue인지 아닌지에 따라 스타일이나 동작을 달리함

💻 inputValue={inputValue}

  • 역할: SearchBar 안에 표시될 현재 입력값을 전달
  • 이 값이 <input value={inputValue} /> 형태로 설정되어 있음 (controlled input)
  • 사용자가 타이핑하면, 이 값을 계속 최신으로 반영함

💻 setInputValue={setInputValue}

  • 역할: 사용자가 입력할 때마다 inputValue를 갱신하는 함수
  • 즉, 사용자의 입력을 MainPage 컴포넌트 쪽 state에 반영함
  • 이게 있어야 SearchBar가 실제로 동작할 수 있음 (입력값을 관리하기 위해)

💻 setIsComposing={setIsComposing}

  • 역할: 사용자가 한글을 입력할 때, 조합 중인지 아닌지를 추적
  • 한국어, 일본어 등의 입력 방식에서는 문자를 완성하는 동안 여러 이벤트가 발생하기 때문에 이 구분이 중요함
  • 이 덕분에 MainPage에서는 조합 중일 때는 searchResult를 갱신하지 않고, 조합이 끝나면 갱신하도록 구현할 수 있음

🧩 전체 흐름 정리

  1. 사용자가 <SearchBar />에 텍스트를 입력함
  2. <input> 태그의 onChange에 연결된 setInputValue를 통해 상위 컴포넌트(MainPage)의 상태(inputValue)가 갱신
  3. inputValue가 바뀌면 useEffect()가 실행되어 searchResult 필터링이 이뤄짐
  4. 한글 입력 중인지는 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 === "전체"
  ? (국가 전체 보기 조건)
  : (특정 국가 보기 조건)}
  1. 검색어가 있을 경우 (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 // 아무것도 안 보임
      • 입력은 했지만, 결과가 없는 경우
      • 조합 중도 아님 → 사용자에게 "일치하는 항목이 없음"을 알려줌
  2. 검색어가 없고 전체 보기인 경우 (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가 이 배열을 한 번에 렌더링할 수 있음

  3. 특정 국가 선택된 경우 (clickedCountry !== "전체")

    : COUNTRY_CITY[clickedCountry].map((city) => (
       <CityCard
         key={city}
         cardImg={`/images/cities/${city}.webp`}
         cardName={`${clickedCountry} ${city}`}
       />
     ))
    • 특정 국가 버튼이 눌려 있을 때, 해당 국가 도시만 보여줌
    • 예: clickedCountry = "일본""일본 도쿄", "일본 오사카" 렌더링
    • key : 도시 이름 (고유한 값)
    • 이미지: /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;
  1. Props 설명

    const SearchBar = ({
      placeholder,
      main,
      placeExploration,
      inputValue,
      setInputValue,
      setIsComposing,
    }: SearchBarProps) => { ... }
    prop타입설명
    placeholderstring입력창 힌트 텍스트
    mainboolean (optional)메인 페이지용 스타일 적용 여부
    placeExplorationboolean (optional)장소 탐색 페이지용 스타일 여부
    inputValuestring 또는 null현재 입력된 텍스트
    setInputValuesetState 함수입력값 변경 시 상태 갱신 함수
    setIsComposingsetState 함수한글 조합 상태 처리용 함수
  2. 입력 이벤트 처리

    • 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);
      }}
      • 한글 입력 중일 때입력 완료 시를 구분하기 위해 사용함
      • 조합 시작 시: isComposingtrue
      • 조합 끝났을 때
        • 조합 완료된 최종 값을 다시 setInputValue로 반영함
        • isComposingfalse로 되돌림
      • 이 부분이 없으면 useEffect에서 타이밍 문제로 검색 결과가 튀거나 깜빡이는 문제가 발생함

      🧐 e.currentTarget을 사용하는 이유?

      🔍 e.targete.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란?

      onCompositionStartonCompositionEnd는 한글(또는 일본어, 중국어 등)과 같이 문자를 조합해서 입력하는 입력기(IME: Input Method Editor)를 지원하는 상황에서 매우 중요한 이벤트이다.
      특히 React에서 검색창이나 폼 입력을 다룰 때, 이걸 정확히 이해하고 사용하는 건 UX를 망치지 않기 위한 핵심 포인트라고 할 수 있다.

      🔍 한 줄 정의

      이벤트정의
      onCompositionStart한글 등 조합형 문자의 입력이 시작될 때 발생함
      onCompositionEnd조합이 완료되어 최종 글자가 입력되었을 때 발생함

      🔍 왜 필요한가? (한글 입력의 특징)

      한글 입력 과정은 단순히 문자 하나씩 입력되는 것이 아니라,
      → '하`처럼 여러 단계에 걸쳐 조합(composition)되기 때문에, 이 중간 과정에서는 검색 결과를 갱신하거나 자동완성 추천을 띄우면 사용자 경험이 매우 나빠짐

      🔍 각 이벤트의 의미 & 시점

      단계키보드 입력이벤트 발생상태
      1사용자가 'ㅎ' 입력onCompositionStart조합 시작됨 (isComposing = true)
      2'ㅎ'+'ㅏ' = '하'(진행 중)아직 onChange는 여러 번 발생
      3스페이스, Enter, 탭 등으로 조합 완료onCompositionEnd조합 끝남 (isComposing = false)
      4-onChange가 한 번 발생value가 확정됨
  3. <input /> 필드

    <input
      type="text"
      placeholder={`${placeholder} 검색하세요.`}
      value={inputValue ?? ""}
      onChange={handleChangeInputValue}
      onCompositionStart={...}
      onCompostionEnd={...}
      className="..."
    />
    • 입력값은 inputValue로 관리하는 controlled input
    • placeholder는 외부에서 전달받은 텍스트로 구성됨
    • 입력창 크기와 색상은 Tailwind로 지정됨
    • 조합 상태 감지 이벤트가 포함되어 있음

      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도 함께 초기화
  4. 전체 동작 흐름
    1) 사용자가 타이핑하면 onChange로 상위 컴포넌트에 값을 전달
    2) 입력 중 한글 조합 여부composition 이벤트로 처리
    3) 스타일은 main, placeExploration props로 분기함
    4) 최종적으로, 외부 컴포넌트에서 검색 로직(useEffect)에 따라 결과가 갱신

0개의 댓글