검색 및 초성 필터링 & 선택 상태 리셋 설계

목차

1. 요구 사항

2. 흐름도

3. 구현

        3.1. 검색 이벤트 연결과 초기 진입점

       3.2. 검색어 변경 처리와 선택 상태 리셋 정책

       3.3. 한글 초성 검색을 위한 기반 데이터와 유니코드 처리

       3.4. 아이템 이름을 초성 문자열로 변환하는 함수

       3.5. 검색어를 초성 패턴으로 변환하는 함수

       3.6. 검색 조건을 포함한 슬롯 재생성 로직

4. 개발 의도

1. 시스템 요구 사항

시스템에서 항목이 많아질수록 가장 중요한 요소는 사용자가 원하는 정보를 신속하게 탐색할 수 있는 검색 편의성이다.

추후에 아이템 확장을 고려하여 검색 기능을 추가하였다.

일반적으로 부분 문자열 검색(예 : "포션")을 사용하지만, 한국어 항목은 초성 입력(예 : "ㅍㅅ")에 익숙하다.

따라서, 일반 검색에 초성 검색을 추가하여 UX를 강화하여 더 많은 편의성을 제공하고자 했다.

이 과정에서 가장 신경 쓴 부분은 검색 기능이 철저히 표시용 필터링으로만 동작해야 한다는 점이다.

검색어 변경으로 슬롯이 재생성될 때, 이전 선택 상태가 남아 있으면 화면에 존재하지 않는 항목을 대상으로 제작 UI가 유지되거나 제작 시도가 이어질 수 있다.

따라서, 검색은 제작 로직에 영향을 주지 않고 슬롯 표시만 재구성해야 하며, 검색 결과가 변경되면 선택 상태와 제작 UI는 반드시 명시적으로 초기화되어야 한다.

2. 흐름도

이 흐름에서 검색은 제작 가능 여부를 판단하거나 재화를 건드리는 단계로 절대 들어가지 않는다.

검색이 하는 일은 오직 현재 craftableItems/materialItems 목록을 어떤 기준으로 다시 그릴지를 결정하는 것뿐이며, 선택과 제작은 슬롯 클릭 이후 단계에서만 발생한다.

3. 구현

)3.1. 검색 이벤트 연결과 초기 진입점
private void Start()
{
    itemManager = GameManager_LDW.instance.itemManager;

    if (serch_InputField != null)
        serch_InputField.onValueChanged.AddListener(OnSearchValueChanged);

    CreateItemSlots();
}

Start 함수에서는 검색 기능이 자연스럽게 동작하도록 초기 설정을 수행한다.

먼저 ItemManager 참조를 가져와 이후 제작 및 재화 로직에서 사용할 기반 데이터를 준비한다.

이후 검색 입력을 담당하는 TMP_InputField의 onValueChanged 이벤트에 OnSearchValueChanged 함수를 리스너로 등록한다.

onValueChanged는 UnityEvent 기반 UI 이벤트로, 입력이 변경될 때마다 등록된 리스너가 호출된다.

이를 통해 별도의 검색 버튼 없이 입력 즉시 슬롯을 갱신하는 구조를 만들 수 있다.

이 방식은 사용자가 키를 입력하는 순간마다 결과가 바뀌는 실시간 검색 UX를 만들기에 적합하다.

입력이 잦을수록 함수 호출 빈도가 높아질 수 있다는 단점이 있지만, 제작 슬롯 수가 제한적이고 연산 비용이 크지 않기 때문에 이 프로젝트에서는 충분히 감당 가능한 수준이다.

AddListener는 UnityEvent 기반 이벤트 구독 방식으로, UI 입력과 시스템 로직을 연결해준다.

인스펙터에서 연결하는 방식도 가능하지만, 코드에서 연결하면 검색 입력이 존재하면 자동으로 검색 기능을 지원하게 된다.

또한 serch_InputField가 null일 수 있는 상황(테스트 씬, UI 미배치 상태)을 고려해 방어 차원에서 코드를 추가함으로써 런타임 에러 가능성을 줄였다.

마지막으로 CreateItemSlots 함수(검색어가 없는 초기 상태)를 호출하여, 전체 제작 슬롯이 화면에 렌더링되도록 한다.

이 호출이 없으면 검색 입력이 발생하기 전까지 제작 슬롯이 비어 있는 상태로 남게 된다.

3.2. 검색어 변경 처리와 선택 상태 리셋 정책
private void OnSearchValueChanged(string searchText)
{
    searchText = searchText.Trim();

    if (string.IsNullOrEmpty(searchText))
        CreateItemSlots();
    else
        CreateItemSlots(searchText);

    selectedIndex = -1;
    craftPanel.SetActive(false);
}

검색 입력이 변경될 때마다 호출되는 OnSearchValueChanged 함수는 검색 기능의 중심 진입점으로, 검색어를 기준으로 슬롯을 재생성한다.

함수의 첫 줄에서 string.Trim()을 호출하여 검색 문자열 양쪽의 공백을 제거한다.

이 처리는 사용자가 실수로 공백을 입력하거나 복사·붙여넣기 과정에서 공백이 포함되는 경우를 대비한 것이다.

Trim을 통해 검색 문자열이 정규화되어 의도치 않은 검색 결과 0개가 되는 것을 줄일 수 있다.

이후 검색 문자열이 비어 있는지 여부에 따라 CreateItemSlots 함수또는 CreateItemSlots(string)를 호출한다.

검색어가 없으면 전체 슬롯을 다시 생성하고, 검색어가 있으면 필터 조건을 적용한 슬롯만 생성한다.

중요한 점은 검색 여부와 상관없이 슬롯 생성의 진입점은 항상 CreateItemSlots라는 점이다. (참고 : 데이터 기반 제작 슬롯 동적 생성 구조 )

검색은 슬롯 생성 이전 단계에서 데이터만 걸러내는 역할을 하며, 슬롯 생성 자체의 책임은 변하지 않는다.

이 함수에서 가장 핵심은 검색 처리 이후에 반드시 선택 상태와 제작 UI를 초기화하는 것이다.

검색어가 변경되면 기존 슬롯 오브젝트들은 Destroy로 제거되고, 새로운 슬롯들이 생성된다.

만약 이전 선택 상태가 유지된다면, 화면에는 존재하지 않는 슬롯을 기준으로 제작 UI가 열려 있거나 제작 버튼이 이전 아이템의 인덱스를 참조하는 위험한 상태가 될 수 있다.

이를 방지하기 위해 selectedIndex를 -1로 초기화하고 제작 UI를 비활성화한다.

이를 통해, 검색은 오직 표시되는 슬롯 목록만 변경하는 기능으로 제한되고, 제작 로직은 항상 명시적인 슬롯 선택 이후에만 동작하도록 보장된다.

3.3. 한글 초성 검색을 위한 기반 데이터와 유니코드 처리
private static readonly char[] InitialConsonants =
{
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ',
'ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
};

초성 검색을 구현하기 위해 한글 초성 테이블을 static readonly 배열로 선언했다.

한글 완성형 문자는 유니코드 상에서 초성, 중성, 종성의 조합으로 구성되며, 완성형 범위인 0xAC00부터 0xD7A3 사이에서 초성 인덱스를 계산할 수 있다.

이 범위 내에서 특정 문자의 초성 인덱스는 (문자 코드 - 0xAC00) / (21 * 28) 연산으로 계산할 수 있다.

이 데이터가 런타임 동안 변경될 이유가 없고, 모든 인스턴스가 동일한 데이터를 공유해도 문제가 없기 때문에 static readonly로 선언하였다.

이 선언 방식은 메모리 낭비를 막고, 코드 차원에서 상수에 가까운 데이터라는 의미를 명확히 드러낸다.

이 접근 방식은 외부 라이브러리에 의존하지 않고도 한글 초성 검색을 구현할 수 있으며, 유니코드 규칙에 기반하기 때문에 동작이 예측 가능하다.

3.4. 아이템 이름을 초성 문자열로 변환하는 함수
private string GetInitialsFromName(string source)
{
    if (string.IsNullOrEmpty(source))
        return string.Empty;

    StringBuilder sb = new StringBuilder();

    foreach (char ch in source)
    {
        if (ch >= 0xAC00 && ch <= 0xD7A3)
        {
            int unicode = ch - 0xAC00;
            int initialIndex = unicode / (21 * 28);
            sb.Append(InitialConsonants[initialIndex]);
        }
    }

    return sb.ToString();
}

GetInitialsFromName 함수는 아이템 이름 문자열을 초성 문자열로 변환하는 역할을 한다.

예를 들어 “생명의 반지”라는 이름은 이 함수를 거치면 “ㅅㅁㅇㅂㅈ” 형태로 변환된다.

이 과정은 검색 대상 데이터를 초성 기준으로 정규화하기 위한 사전 처리 단계다.

함수 내부에서는 먼저 입력 문자열이 null이거나 비어 있는지를 검사해 불필요한 연산을 피한다.

이후 foreach를 통해 문자열을 한 글자씩 순회하며, 해당 문자가 한글 완성형 범위에 속하는 경우에만 초성을 계산해 추가한다.  (초성 19개, 중성 21개, 종성 28개 조합이므로 21*28로 나누면 초성 인덱스가 나온다.)

공백이나 특수문자, 영어 등은 초성 검색의 대상이 아니므로 자연스럽게 제외된다.

이 함수에서 StringBuilder를 사용한 이유는 C#의 string이 불변 객체이기 때문이다.

문자열을 반복적으로 이어 붙이는 방식은 매번 새로운 문자열을 생성하게 되고, 검색 입력처럼 자주 호출되는 경로에서는 불필요한 GC 부담을 만든다.

StringBuilder는 문자열을 누적한 뒤 한 번에 결과를 생성하기 때문에, 반복 처리에 적합하다.

3.5. 검색어를 초성 패턴으로 변환하는 함수
private string GetInitialPatternFromFilter(string filter)
{
    if (string.IsNullOrEmpty(filter))
        return string.Empty;

    StringBuilder sb = new StringBuilder();

    foreach (char ch in filter)
    {
        if (ch >= 0xAC00 && ch <= 0xD7A3)
        {
            int unicode = ch - 0xAC00;
            int initialIndex = unicode / (21 * 28);
            sb.Append(InitialConsonants[initialIndex]);
        }
        else if (ch >= 0x3131 && ch <= 0x314E)
        {
            sb.Append(ch);
        }
    }

    return sb.ToString();
}

GetInitialPatternFromFilter 함수는 플레이어가 입력한 검색어를 초성 비교가 가능한 형태로 변환한다.

검색어는 “생반” 처럼 완성형일 수도 있고, “ㅅㅂ” 처럼 자모만 입력될 수도 있기 때문에, 이 두 입력을 동일한 비교 기준(초성 패턴)으로 정규화한다.

이 함수는 완성형 한글의 경우 초성을 추출하고, 자모 범위(0x3131~0x314E)에 속하는 문자는 그대로 사용한다.

따라서, 플레이어는 "ㅅ” 한 글자만 입력해도 초성 패턴 매칭(검색)이 가능하고, 완성형과 자모 입력이 동일한 필터링 로직으로 처리된다.

이 방식은 한국어 플레이어의 입력 습관을 그대로 반영한 설계로, 검색 UX를 제한하지 않는다.

구현 비용은 다소 증가하지만, 검색 결과가 사용자의 기대와 일치한다는 점에서 충분한 가치가 있다고 판단했다.

3.6. 검색 조건을 포함한 슬롯 재생성 로직
private void CreateItemSlots(string filter)
{
    ...
    
    string lowerFilter = string.IsNullOrEmpty(filter) ? string.Empty : filter.ToLower();
    string initialFilter = GetInitialPatternFromFilter(filter);

    for (int i = 0 ; i < craftableItems.Count; i++) 
    {
        Item item = craftableItems[i]; 
        Item material = materialItems[i];
        
        if (!string.IsNullOrEmpty(lowerFilter))
        {
            string nameLower = item.itemName.ToLower(); 
            string nameInitials = GetInitialsFromName(item.itemName);
            
            bool nameMatch = nameLower.Contains(lowerFilter);
            bool initialMatch = !string.IsNullOrEmpty(initialFilter) && nameInitials.Contains(initialFilter);

            if (!nameMatch && !initialMatch)
                continue;
        }
    }
    
    ...
}

CreateItemSlots(string filter) 함수는 검색 조건을 포함해 실제 슬롯 생성이 이루어지는 핵심 단계다.

이 함수에서는 먼저 검색 문자열을 소문자로 정규화한 lowerFilter와 초성 검색을 위한 initialFilter를 준비한다.

문자열 검색을 위해 string.ToLower()와 string.Contains()를 사용하였다.

검색 기능은 “반지”, “체력” 같은 부분 문자열 검색이 가능해야 했기 때문에, 대소문자 이슈를 제거하기 위해 ToLower로 정규화한 뒤 Contains로 포함 관계를 검사했다.

ToLower 함수를 쓰면 필터와 이름이 같은 케이스로 맞춰져 비교가 단순해지고, Contains는 완전 일치가 아니라 부분 일치 검색을 지원한다.

이는 구현 비용이 낮고, UI 검색에 필요한 반응성을 충분히 제공한다.

한국어에는 ToLower의 효과가 크지 않지만, 아이템 이름에 영어가 섞이는 경우까지 고려해 비교 기준을 정규화하였다.

앞서 정의한 초성 문자열 변환 로직과 StringBuilder 기반 구현을 그대로 활용하여, 일반 문자열 검색과 초성 검색을 하나의 필터링 로직으로 통합했다.

이 과정에서 초성 문자열 변환은 입력 변화가 잦은 환경을 고려해, StringBuilder를 사용함으로써 불필요한 문자열 할당 비용을 최소화했다.

문자열은 불변(immutable)이기 때문에 반복 concatenation을 하면 매번 새 문자열이 만들어지는데, 검색 로직은 입력이 바뀔 때마다 실행될 수 있으므로 누적 생성 비용을 줄이는 선택이 합리적이다.

이를 통해 사용자는 “생명의 반지” 같은 이름을 “ㅅㅁㅇㅂㅈ” 형태로 찾을 수 있고, “ㅅ”처럼 한 글자만 입력해도 초성 기반으로 폭넓게 필터링이 가능해진다.

현재는 제작 슬롯 검색 구조 설명에 집중하기 위해, 제작 아이템과 재료를 동일 인덱스로 매핑한 단순한 구조를 사용했다.

CreateItemSlots는 검색 조건에 따라 데이터를 걸러낸 뒤, 그 결과를 슬롯으로 렌더링하는 공통 출력 단계 역할만 수행한다.

검색 방식이 추가되더라도 슬롯 생성의 진입점은 변하지 않도록 의도적으로 구현했다.

* 결과

초성 검색
단어 검색

4. 개발 의도

이 게시글에서의 의도는 제작 시스템이 확장될 때도 안정적으로 유지되는 입력/필터 파이프라인을 만드는 것이다.

검색은 제작 로직을 건드리지 않고 슬롯 표시만 재구성해야 하며, 그 과정에서 선택 상태와 제작 UI가 남아 시스템을 방해하면 안 된다.

그래서 검색 입력이 바뀌는 시점에 슬롯을 재생성하고, 동시에 selectedIndex를 -1로 초기화하며 제작 UI를 닫았다.

또한 초성 검색은 단순 편의 기능이 아니라, 아이템 개수가 늘어났을 때 제작 UI가 계속 사용 가능한 상태로 유지되기 위한 핵심 UX 장치다.