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

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

5. 개발 의도

1. 시스템 요구 사항

제작 슬롯을 데이터 기반으로 동적 생성하고, 실제 운영에서 바로 부딪히는 문제는 아이템이 많아졌을 때 원하는 제작 항목을 빠르게 찾을 수 있어야 한다는 점이다.

특히 한국어 아이템 이름은 초성 입력(예: “ㅂㅈ”)이 익숙한데, 일반적인 부분 문자열 검색만으로는 초성 기반 탐색을 제공하기 어렵다.

또 하나의 핵심 요구사항은 검색 기능이 어디까지나 표시용 필터링으로만 동작해야 한다는 점이다.

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

따라서 검색은 제작 로직 자체를 바꾸지 않고, 슬롯 표시를 재구성하는 선에서 끝나야 하며, 검색 결과가 바뀌는 순간 선택 상태와 제작 패널은 반드시 명시적으로 초기화되어야 한다.

2. 설계  목표

- InputField 입력 변화에 따라 슬롯 목록을 즉시 재구성할 것

- 일반 검색과 초성 검색을 동시에 지원할 것

- 검색 기능은 슬롯 표시 필터 역할만 하도록 제한할 것

- 검색 결과 변경 시 선택 상태(selectedIndex)와 제작 패널을 명시적으로 리셋할 것

- 검색 로직이 제작/재화 검증 로직에 영향을 주지 않도록 분리할 것

3. 흐름도

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

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

4. 구현

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

제작 UI가 활성화되는 순간부터 검색 기능이 즉시 동작할 수 있도록, Start()에서 검색 InputField의 onValueChanged 이벤트에 리스너를 연결한다.

onValueChanged는 입력이 바뀔 때마다 호출되기 때문에, 별도의 검색 버튼 없이 즉시 반응하는 UX를 만들 수 있다.

또한 Start()에서 CreateItemSlots()를 한 번 호출해 최초에 전체 슬롯이 화면에 렌더링되도록 한다.

private void Start()
{
    itemManager = GameManager_LDW.instance.itemManager;

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

    CreateItemSlots();
}

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

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

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

Start()에서 CreateItemSlots()를 한 번 호출하는 이유는, 검색어가 없는 초기 상태에서 전체 제작 슬롯이 기본으로 렌더링되도록 하기 위함이다.

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

검색 입력이 들어오면 OnSearchValueChanged가 호출되고, 이 함수는 검색어를 기준으로 슬롯을 재생성한다.

중요한 점은 검색 처리 이후, 반드시 선택 상태와 제작 패널을 명시적으로 초기화한다는 점이다.

private void OnSearchValueChanged(string searchText)
{
    searchText = searchText.Trim();

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

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

Trim()을 쓰는 이유는 입력 양쪽 공백 때문에 검색이 실패하는 상황을 막기 위함이다.

예를 들어 실수로 공백이 들어가거나, 복붙 과정에서 공백이 붙는 경우가 흔하다.

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

이 함수에서 가장 중요한 부분은 다음 두 줄이다.

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

검색어가 변경되면 슬롯은 재생성되며, 이전 슬롯 오브젝트는 Destroy()로 제거된다.

만약 이전 선택이 유지되면 화면에 없는 슬롯을 기준으로 제작 패널이 남아 있는 상태가 될 수 있다.

더 위험한 경우는, 제작 버튼이 이전 선택의 리스너/인덱스를 기반으로 남아 있어 보이지 않는 아이템 제작 시도가 발생하는 것이다.

따라서 검색은 슬롯 표시만 바꾸는 기능이고, 선택 상태와 제작 패널은 항상 명시적으로 리셋된다.

이 정책 덕분에 검색 기능은 제작 로직에 간접적으로 영향을 주지 않으며, 제작 시스템은 선택된 항목이 있을 때만 작동한다는 전제가 유지된다.

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

초성 검색을 구현하기 위해 초성 테이블을 코드에 고정 배열로 둔다.

한글 완성형 문자는 유니코드 상에서 초성/중성/종성 조합으로 구성되며, 완성형 범위(AC00~D7A3)에서 초성 인덱스를 계산할 수 있다.

private static readonly char[] InitialConsonants =
{
'ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ',
'ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'
};

static readonly로 선언한 이유는 초성 테이블이 런타임에 변할 이유가 없고, 모든 인스턴스가 동일한 데이터를 공유해도 안전하기 때문이다.

이 방식은 불필요한 메모리 복제를 막고, 의도적으로 상수 데이터라는 의미를 코드 레벨에서 표현한다.

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

아이템 이름(예: “생명의 반지”)을 초성 문자열(예: “ㅅㅁㅇㅂㅈ”)로 변환하는 함수가 GetInitialsFromName이다.

이 함수는 검색 대상 문자열을 초성 형태로 정규화해두기 위한 단계다.

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

여기서 핵심은 두 가지다.

첫째, 한글 완성형 범위를 직접 체크해 초성 인덱스를 계산한다. (초성 19개, 중성 21개, 종성 28개 조합이므로 21*28로 나누면 초성 인덱스가 나온다.)

둘째, StringBuilder를 사용한다.

C#의 string은 immutable이기 때문에 반복적으로 문자열을 더하면 매번 새 문자열이 생성되어 GC 부담이 커진다.

반면 StringBuilder는 내부 버퍼에 누적해 한 번에 문자열을 만들기 때문에, 검색 입력처럼 자주 호출되는 경로에서 합리적인 선택이다.

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

검색어는 두 종류가 섞일 수 있다. “생반”처럼 완성형을 입력할 수도 있고, “ㅅㅂ”처럼 자모만 입력할 수도 있다.

GetInitialPatternFromFilter는 이 둘을 동일한 비교 기준(초성 패턴)으로 정규화한다.

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

자모 범위(3131~314E)를 그대로 허용하는 것이 포인트다.

이 덕분에 사용자는 “ㅅ” 한 글자만 입력해도 초성 패턴 매칭이 가능해지고, 완성형 입력과 자모 입력이 동일한 검색 루틴에서 처리된다.

결과적으로 검색 UX가 한국어 사용 습관에 맞게 자연스럽게 동작한다.

4.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;
        }
    }
    
    ...
}

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

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

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

이 방식은 구현 비용이 낮고 직관적이며, UI 검색에 필요한 반응성을 충분히 제공한다.

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

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

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

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

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

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

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

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

5. 개발 의도

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

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

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

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

유니코드 기반 초성 추출과 StringBuilder를 사용한 이유는 한국어 검색 요구를 만족하면서도, 입력 변화가 잦은 환경에서 불필요한 비용을 줄이기 위한 구현 선택이었다.