"Life is Full of Possibilities" - Soul, 2020

IT (프론트엔드)

API call 최적화를 고려한 자동 완성 Input ComboBox 구현 (React, Typescript, Vanilla-extract)

m2ndy 2025. 1. 5. 23:09
본 글은 2025.1.6에 마지막으로 수정되었습니다.

 
 
기존의 Input 공통 컴포넌트에 ComboBox를 추가해야 하는 요구사항이 생겼습니다. Input ComboBox는 Input과 매우 유사하지만 에러 상태와 에러 메시지를 받지 않는데요, 따라서 Input ComboBox에서 불필요한 코드를 줄이고 유지보수성을 높이기 위해 InputComboBox라는 컴포넌트를 따로 만들기로 결정했습니다.
 
 
 
 

피그마 시안 (Input)

 
 

피그마 시안

 
 
 
 
 

Input ComboBox 

 
사용자가 "강남"을 입력하려고 할 때, 기능적인 시나리오를 정리하면 다음과 같습니다.
 
값을 입력하는 경우
1-1. 사용자가 input에 커서를 올린다.
1-2. input에 "ㄱ"를 입력한다.
1-3. 입력값이 존재하면 combobox items를 보여준다.
1-4. 커서를 아래로 내리면 combobox items의 첫 번째 값에 포커싱이 맞춰지고 input value가 첫 번째 값으로 바뀐다.
1-5. 엔터를 누르거나 combobox 요소를 클릭하면 외부에서 주입한 이벤트가 실행된다.
 
일치하는 값이 존재하는 경우
2-1. input에 "강남"을 입력한다.
2-2. combobox items에서 input의 값과 일치하는 값에 포커싱이 맞춰진다.
 
일치하는 값이 존재하지만 추가로 값을 더 입력하는 경우
3-1. input에 "강남"을 입력한다.
3-2. combobox items 에서 input의 값과 일치하는 값에 포커싱이 맞춰진다.
3-3. input에 "맛집"을 추가로 입력한다.
3-4. combobox items 에서 input의 값과 일치하는 값이 없어 포커싱이 사라진다.
 
ComboBox가 보이는 경우
a. 사용자가 값을 입력하는 경우
 
ComboBox 보이지 않는 경우
a. 입력한 값이 없는 경우
b. 사용자가 InputComboBox 컴포넌트 밖을 클릭하는 경우
c. Escape 키를 누른 경우
 
 
 
 
 

Input ComboBox 구현 방식

 
먼저, 공통 컴포넌트에서는 도메인 로직을 철저하게 분리해야 한다고 생각하기 때문에 ComboBox 에 보여줄 리스트와 ComboBox option 클릭 이벤트는 모두 외부에서 받아오도록 구현하고자 했습니다.
 
그리고 input이 변경될 때마다 API 호출이 일어나기 때문에 실제 input과 화면상에서 보이는 input은 다르게 관리되도록 InputComboBox 내부에 displayedValue를 두어 구현했습니다.

현재 comboBoxList에 표시되는 데이터들은 API 호출을 통해 받아오고 있는데요. input이 변경되면 API 호출이 일어나 comboBoxList 데이터들이 변하게 됩니다. 만약 "강남"을 입력한 뒤 방향키를 아래로 내려 "강남역"이라는 데이터에 포커싱이 이동하면, input은 "강남"에서 "강남역"으로 바뀌게 되고 API 호출이 일어나게 되겠죠. 방향키 조작 횟수가 증가하게 되면 불필요한 API 호출이 누적될 것으로 생각했고, 따라서 화면상에서만 보이는 displayedValue를 두어 InputComboBox 컴포넌트 내부에서만 관리되도록 했습니다.
 
 

1. 값을 입력하는 경우

사용자가 값을 입력하는 경우 ComboBox Items를 요청하는 API 호출이 일어나도록 하기 위해 displayedValue와 input을 모두 변경합니다.
 

import { IconType } from '@react-icons/all-files/lib';
import useClickOutside from '@/hooks/_common/useClickOutside';
import useComboBox from '@/hooks/_common/useComboBox';
import { vars } from '@/styles/theme.css';
import ComboBox from './ComboBox/ComboBox';
import * as S from './InputComboBox.css';

interface InputComboBoxProps<T> extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  buttonType?: 'none' | 'button' | 'submit';
  buttonImage?: IconType;
  comboBoxList?: T[];
  value: string;
  canSubmitByInput?: boolean;
  onChangeValue: (value: string) => void;
}

const InputComboBox = <T extends { id: string; displayedKeyword: string }>({
  label,
  buttonType = 'none',
  buttonImage: ButtonImage,
  comboBoxList,
  value,
  canSubmitByInput = true,
  onChangeValue,
  ...props
}: InputComboBoxProps<T>) => {
  const {
    displayedValue,
    handleDisplayedInputChange,
  } = useComboBox({ items: comboBoxList, value });
  
  // ...
  
  return (
    <div className={S.Layout}>
      {label && <label className={S.Label}>{label}</label>}

      <div className={S.InputBox}>
        <input
          {...props}
          className={S.Input}
          value={displayedValue}
          onKeyDown={handleKeyDown}
          onChange={(event) => {
            onChangeValue(event.target.value);
            handleDisplayedInputChange(event.target.value);
          }}
        />
      </div>
    </div>
  );
};

export default InputComboBox;
import { useEffect, useRef, useState } from 'react';

interface useComboBoxProps<T> {
  items?: T[];
  value: string;
}

const useComboBox = <T extends { displayedKeyword: string }>({
  items,
  value,
}: useComboBoxProps<T>) => {
  const [displayedValue, setDisplayedValue] = useState(value);

  const handleDisplayedInputChange = (value: string) => {
    setDisplayedValue(value);
  };

  return {
    displayedValue,
    handleDisplayedInputChange,
  };
};

export default useComboBox;

 
 
 

2. ComboBox 표시

 
이제 값이 변경되어 displayedValue가 공백이 아니게 되면 comboBox를 보여주어야 합니다.
 

 
 
useEffect를 통해 displayedValue가 변경되는 경우 ComboBox를 보여주는 isComboBoxOpen이 업데이트되도록 했습니다. 
 

import { IconType } from '@react-icons/all-files/lib';
import useClickOutside from '@/hooks/_common/useClickOutside';
import useComboBox from '@/hooks/_common/useComboBox';
import { vars } from '@/styles/theme.css';
import ComboBox from './ComboBox/ComboBox';
import * as S from './InputComboBox.css';

interface InputComboBoxProps<T> extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  buttonType?: 'none' | 'button' | 'submit';
  buttonImage?: IconType;
  comboBoxList?: T[];
  value: string;
  canSubmitByInput?: boolean;
  onChangeValue: (value: string) => void;
}

const InputComboBox = <T extends { id: string; displayedKeyword: string }>({
  label,
  buttonType = 'none',
  buttonImage: ButtonImage,
  comboBoxList,
  value,
  canSubmitByInput = true,
  onChangeValue,
  ...props
}: InputComboBoxProps<T>) => {
  const {
    displayedValue,
    handleDisplayedInputChange,
    isComboBoxOpen,
    setIsComboBoxOpen,
  } = useComboBox({ items: comboBoxList, value });
  
  // ...
  
  return (
    <div className={S.Layout}>
      {label && <label className={S.Label}>{label}</label>}

      <div className={S.InputBox}>
        <input
          {...props}
          className={S.Input}
          value={displayedValue}
          onKeyDown={handleKeyDown}
          onChange={(event) => {
            onChangeValue(event.target.value);
            handleDisplayedInputChange(event.target.value);
          }}
        />
      </div>
      
      {comboBoxList && isComboBoxOpen && <ComboBox comboBoxList={comboBoxList} />}
    </div>
  );
};

export default InputComboBox;

 

import { useEffect, useRef, useState } from 'react';

interface useComboBoxProps<T> {
  items?: T[];
  value: string;
}

const useComboBox = <T extends { displayedKeyword: string }>({
  items,
  value,
}: useComboBoxProps<T>) => {
  const [displayedValue, setDisplayedValue] = useState(value);
  const [isComboBoxOpen, setIsComboBoxOpen] = useState(false);
  
  useEffect(() => {
    setIsComboBoxOpen(value.trim() !== '');
  }, [value]);

  // ...

  return {
    displayedValue,
    isComboBoxOpen,
    setIsComboBoxOpen,
    handleDisplayedInputChange,
  };
};

export default useComboBox;

 

import * as S from './ComboBox.css';

interface ComboBoxProps<T> {
  comboBoxList: T[];
}

const ComboBox = <T extends { id: string; displayedKeyword: string }>({
  comboBoxList,
}: ComboBoxProps<T>) => {
  return (
    <ul className={S.ComboBox}>
      {comboBoxList?.length > 0 ? (
        <>
          <p className={S.ComboBoxLabel}>반드시 아래의 장소 중 하나로 선택해야 해요.</p>
          <div className={S.ComboBoxOptionBox}>
            {comboBoxList.map((item, index) =>
              item.displayedKeyword && typeof item.displayedKeyword === 'string' ? (
                <li key={item.id}>
                  {item.displayedKeyword}
                </li>
              ) : null,
            )}
          </div>
        </>
      ) : (
        <div className={S.NoContentBox}>검색된 결과가 없어요! 다른 키워드를 입력해 주세요.</div>
      )}
    </ul>
  );
};

export default ComboBox;

 
 
 

3. 일치하는 값 확인 및 포커싱 이동

 
다음으로 방향키를 위/아래로 이동하면 포커싱이 이동하도록 구현하기 위해 selectedIndex 상태를 추가합니다. 기본값은 -1로, comboBoxList의 인덱스가 0부터 시작하기 때문에 초기값을 -1로 설정했습니다. 입력한 값과 일치하는 값이 없는 경우에도 -1을 넣게 되면 comboBoxList의 포커싱이 사라지도록 편리하게 구현할 수 있기 때문에 이 방법을 사용했습니다.
 
 
Google 검색을 참고해보면, 사용자가 입력한 값과 일치하는 값이 있는 경우 comboBoxList에서 포커싱이 됩니다. 그 상태에서 아래로 내리면 화면에 보이는 입력값이 바뀝니다. 이 점을 착안하여 구현해 보기로 했습니다.
 

 
 
 

import { IconType } from '@react-icons/all-files/lib';
import useClickOutside from '@/hooks/_common/useClickOutside';
import useComboBox from '@/hooks/_common/useComboBox';
import { vars } from '@/styles/theme.css';
import ComboBox from './ComboBox/ComboBox';
import * as S from './InputComboBox.css';

interface InputComboBoxProps<T> extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  buttonType?: 'none' | 'button' | 'submit';
  buttonImage?: IconType;
  comboBoxList?: T[];
  value: string;
  canSubmitByInput?: boolean;
  onChangeValue: (value: string) => void;
}

const InputComboBox = <T extends { id: string; displayedKeyword: string }>({
  label,
  buttonType = 'none',
  buttonImage: ButtonImage,
  comboBoxList,
  value,
  canSubmitByInput = true,
  onChangeValue,
  ...props
}: InputComboBoxProps<T>) => {
  const {
    displayedValue,
    selectedIndex,
    handleDisplayedInputChange,
    isComboBoxOpen,
    setIsComboBoxOpen,
  } = useComboBox({ items: comboBoxList, value });
  
  // ...
  
  return (
    <div className={S.Layout}>
      {label && <label className={S.Label}>{label}</label>}

      <div className={S.InputBox}>
        <input
          {...props}
          className={S.Input}
          value={displayedValue}
          onKeyDown={handleKeyDown}
          onChange={(event) => {
            onChangeValue(event.target.value);
            handleDisplayedInputChange(event.target.value);
          }}
        />
      </div>
      
      {comboBoxList && isComboBoxOpen && <ComboBox comboBoxList={comboBoxList} selectedIndex={selectedIndex} />}
    </div>
  );
};

export default InputComboBox;
import { useEffect, useRef, useState } from 'react';

interface useComboBoxProps<T> {
  items?: T[];
  value: string;
}

const useComboBox = <T extends { displayedKeyword: string }>({
  items,
  value,
}: useComboBoxProps<T>) => {
  const [displayedValue, setDisplayedValue] = useState(value);
  const [isComboBoxOpen, setIsComboBoxOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);

  useEffect(() => {
    setIsComboBoxOpen(value.trim() !== '');
    
    if (isComboBoxOpen && items && value.trim() !== '') {
      const foundIndex = items.findIndex((item) => item.displayedKeyword === value);
      setSelectedIndex(foundIndex !== -1 ? foundIndex : -1);
    }
  }, [isComboBoxOpen, items, value]);

  // ...

  return {
    displayedValue,
    selectedIndex,
    isComboBoxOpen,
    setIsComboBoxOpen,
    handleDisplayedInputChange,
  };
};

export default useComboBox;

 
 
먼저, useEffect를 통해 value의 값이 변경되는 것을 감지합니다. 이후 comboBoxList에서 value와 일치하는 값이 있는지 찾고 (foundIndex), selectedIndex를 변경합니다. 만약 일치하는 값이 없다면 selectedIndex를 -1로 초기화합니다.
 
이후 selectedIndex는 ComboBox 컴포넌트로 전달되어 CSS를 변경하는 데 쓰입니다. li 태그에서 selectedIndex가 comboBoxList의 index와 같다면 'selected' CSS를 보여줍니다.
 
 

import * as S from './ComboBox.css';

interface ComboBoxProps<T> {
  comboBoxList: T[];
  selectedIndex: number | null;
}

const ComboBox = <T extends { id: string; displayedKeyword: string }>({
  comboBoxList,
  selectedIndex,
}: ComboBoxProps<T>) => {
  return (
    <ul className={S.ComboBox}>
      {comboBoxList?.length > 0 ? (
        <>
          <p className={S.ComboBoxLabel}>반드시 아래의 장소 중 하나로 선택해야 해요.</p>
          <div className={S.ComboBoxOptionBox}>
            {comboBoxList.map((item, index) =>
              item.displayedKeyword && typeof item.displayedKeyword === 'string' ? (
                <li
                  key={item.id}
                  className={selectedIndex === index ? S.ComboBoxOption['selected'] : S.ComboBoxOption['default']}
                >
                  {item.displayedKeyword}
                </li>
              ) : null,
            )}
          </div>
        </>
      ) : (
        <div className={S.NoContentBox}>검색된 결과가 없어요! 다른 키워드를 입력해 주세요.</div>
      )}
    </ul>
  );
};

export default ComboBox;

 

// CSS 일부

export const ComboBoxOptionBase = style({
  margin: '8px',
  padding: '3px 10px',
  borderRadius: '5px',
  cursor: 'pointer',

  ':hover': {
    backgroundColor: vars.colors.grey[200],
  },
});

export const ComboBoxOption = styleVariants({
  default: [ComboBoxOptionBase],
  selected: [ComboBoxOptionBase, { backgroundColor: vars.colors.grey[200] }],
});

 
 
 
방향키를 위/아래로 움직이는 경우 comboBoxList 포커싱이 변경되도록 하기 위해 handleKeyDown 핸들러를 추가했습니다.
 

import { useEffect, useRef, useState } from 'react';

interface useComboBoxProps<T> {
  items?: T[];
  value: string;
  canSubmitByInput: boolean;
}

const useComboBox = <T extends { displayedKeyword: string }>({
  items,
  value,
  canSubmitByInput,
}: useComboBoxProps<T>) => {
  const [displayedValue, setDisplayedValue] = useState(value);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const [isComboBoxOpen, setIsComboBoxOpen] = useState(false);

  useEffect(() => {
    setIsComboBoxOpen(value.trim() !== '');

    if (isComboBoxOpen && items && value.trim() !== '') {
      const foundIndex = items.findIndex((item) => item.displayedKeyword === value);
      setSelectedIndex(foundIndex !== -1 ? foundIndex : -1);
    }
  }, [isComboBoxOpen, items, value]);

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (!items || items.length === 0 || value.trim() === '') return;

    const cycleIndex = (currentIndex: number, direction: number) => {
      return (currentIndex + direction + items.length) % items.length;
    };

    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        const nextIndex = cycleIndex(selectedIndex, 1);
        setSelectedIndex(nextIndex);
        setDisplayedValue(items[nextIndex].displayedKeyword);
        break;
      }

      case 'ArrowUp': {
        event.preventDefault();
        const prevIndex = cycleIndex(selectedIndex, -1);
        setSelectedIndex(prevIndex);
        setDisplayedValue(items[prevIndex].displayedKeyword);
        break;
      }
    }
  };

  const handleDisplayedInputChange = (value: string) => {
    setDisplayedValue(value);
  };

  return {
    displayedValue,
    selectedIndex,
    isComboBoxOpen,
    setIsComboBoxOpen,
    handleKeyDown,
    handleDisplayedInputChange,
  };
};

export default useComboBox;

 
comboBoxList 내부에서 인덱싱이 순환되도록 하기 위해 cycleIndex를 두어 관리하고, 위/아래 키가 입력되는 경우 selectedIndex와 displayedValue를 변경시켰습니다. input value가 아닌 displayedValue를 변경하는 이유는 앞서 언급한 것처럼 input이 변경되면 불필요한 API가 호출되기 때문입니다.
 
 
 
 

4. 엔터 / 클릭 시 검색 이벤트 실행

 
comboBoxList에 포커싱이 맞춰진 경우 Enter를 눌렀을 때 검색이 되도록 했습니다. 단, 이 경우 form 태그로 InputComboBox를 감싸주어야 정상적으로 작동이 됩니다. 
 
예를 들어, Course 컴포넌트에 다음과 같이 InputComboBox를 사용한다고 가정해봅시다.
 

interface SearchedDataType {
  id: string;
  displayedKeyword: string;
}

const Course = () => {
  const searchedDataList: SearchedDataType[] = [
    {
      id: '26967382',
      displayedKeyword: '강남',
    },
    {
      id: '26967383',
      displayedKeyword: '강남역',
    },
    {
      id: '26967384',
      displayedKeyword: '강남구',
    },
  ];

  const [input, setInput] = useState('');

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const inputValue = formData.get('comboBox');
    navigate(`/course/detail?keyword=${inputValue}`);
  };

  return (
    <div className={S.Layout}>
      <form onSubmit={handleSubmit}>
        <InputComboBox
          label="라벨"
          buttonType="submit"
          buttonImage={IoIosSearch}
          value={input}
          comboBoxList={searchedDataList}
          name={'comboBox'}
          canSubmitByInput={false}
          onChangeValue={(e) => setInput(e)}
        />
      </form>
    </div>
  );
};

export default Course;

 
form 제출 이벤트를 통해 'comboBox' name을 갖는 요소에서 inputValue를 가져올 수 있고, 이 값을 통해 navigate 등의 다른 이벤트를 실행할 수 있습니다.
 
이때 이벤트 트리거를 활용했는데요. handleKeyDown에서 Enter 입력 발생 시 가장 가까운 form 태그를 찾아 이벤트를 일으키도록 했습니다. 
 
canSubmitByInput 변수는, input 값이 comboBoxList와 하나라도 같을 때 제출이 되도록 구현해야 했기 때문에 추가한 변수입니다. 구현하고자 하는 기능에서 클라이언트가 서버로 전송하는 데이터는 반드시 서버에 존재해야 했기 때문입니다. 그렇지만 일반적인 사이트의 검색 기능은 리스트에 있는 값과 일치하지 않아도 submit 이벤트가 작동하기 때문에 여기서는 canSubmitByInput 조건을 넣어 원하는 방향대로 선택할 수 있도록 했습니다.


canSubmitByInput가 false 이면서 selectedIndex도 -1이라면 (일치하는 값이 없는 경우) 이벤트를 일으키지 않고, canSubmitByInput가 true인 경우 (input 값 작성만으로도 제출이 가능한 경우)는 이벤트가 일어납니다.
 

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (!items || items.length === 0 || value.trim() === '') return;

    const cycleIndex = (currentIndex: number, direction: number) => {
      return (currentIndex + direction + items.length) % items.length;
    };

    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        const nextIndex = cycleIndex(selectedIndex, 1);
        setSelectedIndex(nextIndex);
        setDisplayedValue(items[nextIndex].displayedKeyword);
        break;
      }

      case 'ArrowUp': {
        event.preventDefault();
        const prevIndex = cycleIndex(selectedIndex, -1);
        setSelectedIndex(prevIndex);
        setDisplayedValue(items[prevIndex].displayedKeyword);
        break;
      }

      case 'Enter':
        if (!canSubmitByInput && selectedIndex === -1) {
          event.preventDefault();
          return;
        }
        if (inputRef.current) {
          const form = inputRef.current.closest('form');
          if (form) form.dispatchEvent(new Event('submit', { cancelable: true }));
          setIsComboBoxOpen(false);
        }
        break;
    }
  };

 
 
 
 
 
 

 
 
 

5. 마우스 포커싱이 외부로 이동 / Escape 시 ComboBox숨기기

 
현 상태라면 사용자가 comboBox를 닫기 위해서는 입력했던 검색어를 모두 지워야 하는 불편함이 있습니다. 따라서 이를 해결하기 위해 외부 클릭 시 또는 키보드에서 Escape를 누르는 경우 comboBox를 숨길 수 있도록 useRef를 활용하여 구현했습니다.
 
먼저 외부를 클릭하는 경우를 살펴보면, 마우스 이벤트를 감지하여 event의 target에 inputRef가 존재하지 않으면 isComboBoxOpen을 false로 변경합니다. 
 
 

import { useEffect } from 'react';

const useClickOutside = (ref: React.RefObject<HTMLElement>, onClickOutside: () => void) => {
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        onClickOutside();
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [ref, onClickOutside]);
};

export default useClickOutside;

 
 
 
InputComboBox 컴포넌트에서 Escape를 대응하기 위해서 handleKeyDown 핸들러에 'Escape' 조건을 추가합니다.
 

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (!items || items.length === 0 || value.trim() === '') return;

    const cycleIndex = (currentIndex: number, direction: number) => {
      return (currentIndex + direction + items.length) % items.length;
    };

    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault();
        const nextIndex = cycleIndex(selectedIndex, 1);
        setSelectedIndex(nextIndex);
        setDisplayedValue(items[nextIndex].displayedKeyword);
        break;
      }

      case 'ArrowUp': {
        event.preventDefault();
        const prevIndex = cycleIndex(selectedIndex, -1);
        setSelectedIndex(prevIndex);
        setDisplayedValue(items[prevIndex].displayedKeyword);
        break;
      }

      case 'Enter':
        if (!canSubmitByInput && selectedIndex === -1) {
          event.preventDefault();
          return;
        }
        if (inputRef.current) {
          const form = inputRef.current.closest('form');
          if (form) form.dispatchEvent(new Event('submit', { cancelable: true }));
          setIsComboBoxOpen(false);
        }
        break;

      case 'Escape':
        event.preventDefault();
        setIsComboBoxOpen(false);
        break;
    }
  };

 
 
 

 
 
 
 
 

마무리

 
처음에는 Input과 InputComboBox를 하나의 컴포넌트로 구현하려 했는데, 오히려 코드가 복잡해지고 가독성이 좋지 않게 되었습니다. 이런 경우에는 컴포넌트를 분리하는 방향이 유지보수에 좋을 것이라는 판단을 했고, 실제로 구현하는 시간도 더 짧게 걸렸네요. 하나의 컴포넌트를 재사용성 있게 구현하는 것이 늘 옳은 것은 아니라는 깨달음을 얻을 수 있었습니다.

그리고 form에 대해서도 알아갈 수 있었는데요, form data를 상태로 관리하는 방법뿐만 아니라 이처럼 name 태그를 통해 가져오는 방식도 있다는 것을 배웠습니다!
 
오류를 발견하셨다면 편하게 댓글로 알려주세요!
 
감사합니다 :)