안녕하세요. 콜라와 공기밥은 늘 추가하는 배민앱 고갱님이자 배민앱개발팀에서 배달의민족 안드로이드 앱을 만들고 있는 윤효정입니다. 앱 클라이언트에서 메뉴 탐색 사용성을 개선하기 위해 메뉴 검색과 퀵메뉴를 만들었던 개발썰을 풀어봅니다. (TMI 주의)

메뉴 고르기가 수고롭다.. 수고로워

한 음식점에 처음 방문한 손님은 무엇을 파는지 벽에 걸린 메뉴판을 스캔하면서 메뉴 고르기 행동을 시작합니다. 이벽 저벽에 걸려있는 메뉴들이 한눈에 들어오지 않으면, 비슷한 메뉴군들끼리 정리되어 있는 메뉴책자를 보면서 음식을 눈에 익힙니다. 고민이 많은 손님은 앞뒤로 넘겨보면서 반복된 행동을 하기도 합니다. 자주오는 손님은 메뉴판 없이 곧바로 주문하고요.

배민앱에서 메뉴 고르는 방법은 심플하게 하나였습니다. 위에서 아래로 혹은 반대로 스크롤 하면서 일일이 메뉴를 들여다 보는 방법입니다. 메뉴가 많은 가게에서는 스크롤 액션만으로는 메뉴군이 한 눈에 다 들어오지도 않고, 콜라(보통 음료군은 맨밑에 있기에) 를 찾기 위해선 스크롤을 끝까지 해야합니다. 목록이 길면 길수록 불필요한 스크롤 액션 횟수가 늘어납니다. 콜라를 항상 주문하는 저라는 고객은 콜라를 담을 때마다 메뉴검색이 있었더라면.. 이란 생각이 듭니다. (콜라 단축키를 몰래 넣었으면 해결될 일이긴 한데..)

가게상세에 메뉴검색 기능이 드디어!?

작년 12월 업데이트 과제로 가게상세 화면의 메뉴 탐색 사용성 개선과제가 잡혔습니다. 개인적으로는 호미부대TF- 소소한 변경사항으로 사용자의 편의성이 나아질 것이라 믿는 작업을 주로하는 조직-의 TODO 작업 중 하나였지만 TF종료로 하지 못했던 과제라 아쉽기도 했고, 먹는 것만 먹는 배민 사용자로서 꼭 있었으면 하는 기능을 추가한다니 쌍수들고 과제를 받았습니다. 그러나 첫 기획&디자인 리뷰를 받고 개발일정을 잡았음에도 다른 작업(배민마켓 신규)이 높은 우선순위로 들어와 한번 더 일정이 연기되었습니다. (우선순위 낮게 태어난 너란 메뉴검색..ㅠ_ㅠ)

가게상세에 메뉴검색 기능이 드디어!_최종

기획&디자인 리뷰 자리에서 기대하던 메뉴검색 기능의 스펙을 차근히 봅니다. 메뉴검색은 UI 개발은 비교적 간단하고, 핵심은 검색어 스펙 같은데, 기획서에 명시된 검색어에 세부 스펙이 간단합니다.

  1. “메뉴명”만 검색어 대상이다.
  2. 검색어가 메뉴명에 포함되면 검색결과 노출
  3. 초성검색 미지원.

생각보다 간단한 스펙에 여러가지 의문사항이 들었지만, 메뉴검색 말고 메뉴탐색 개선을 위한 다른 작업이 더 있는 것 같으니 일단 다음으로 넘어가 봅니다.

메뉴 탐색 사용성 개선을 위한 “퀵메뉴”

가게상세에서 메뉴목록은 메뉴군으로 그룹핑되어 노출되고 그룹단위로 접거나 펼칠 수 있지만, 기본적으로 메뉴그룹을 모두 펼쳐서 제공하고 있습니다. 그래서 한눈에 어떤류의 메뉴들이 있는지 볼 수 있고, 특정 메뉴그룹으로 빠르게 갈 수 있는 방법을 제공하는 기능을 제공하기 위한 새로운 위젯이 등장했습니다.

우리가 알고있는 RecyclerView.FastScroller와 ScrollerTrack에 섹션타이틀이 붙어있는 형태(연락처에서 많이 보던 UI)와 유사한 것 같지만 다릅니다. 동작(액션) 정의는 기획서나 정적인 디자인 시안만으로는 작업범위가 얼마나 될지 감이 잡히지 않습니다. 그 때, 그래서 준비했다며 레퍼런스 앱이 등장합니다. “구글 문서 앱” 입니다.
구글 문서앱의 스크롤러 UI와 동작을 참고했다고 하지만. 써보지 않았던 생경한 느낌의 스크롤러를 몇번 써보니 크게는 어떤 것을 만들겠다는 가늠이 되지만, 세부적인 구현방법이 딱 떠오르지 않습니다. 커스텀 UI 컴포넌트를 새로 만드는 것은 하나부터 열까지 챙겨야 할게 많아 까다로운 편이라.. 누군가 만들어 둔 라이브러리가 있기를 바래봅니다. 아차차 배민앱의 가게상세 뷰구조가 복잡한 편이라 라이브러리를 그대로 적용할 수 있을거란 기대는 버립니다.

퀵메뉴 디자인 시안을 보니 느낌이 온다.. 손이 많이 갈 것이라는 느낌쓰


퀵메뉴 대비 검색기능 개발스펙이 명확하고 적어 빠르게 끝날 것으로 보고 메뉴검색부터 작업하기 시작합니다. 물론 퀵메뉴보다 검색이 더 중요하다는 판단이 들기도 했고요.

1. 세트메뉴 디스플레이 모델 재활용 가능각?

가게상세 메뉴는 대표메뉴, 세트메뉴, 일반메뉴로 세가지 UI 스타일이 있고, 각각 RecyclerView.Adapter 디스플레이 모델로 관리되고 있습니다. 검색결과 메뉴 아이템 모델은 이 중에 메뉴사진과 메인메뉴 뱃지 노출 여부 여부에 열려 있는 SetMenuDisplayModel 과 친구들(DisplayModelMapper, DisplayAdapterDelegate, layout.xml) 재활용 각이 나옵니다. UI 변경사항이 잦은 배민앱 개발 특성상 관리 포인트를 줄이고저 새로운 화면에서 쓰는 아이템 모델은 새로 생성하는 편이지만, 이 메뉴아이템 모델 UI 디자인 의도는 가게메뉴의 디자인과 동일하게 디자인을 했고, 이후에도 가게메뉴 디자인에 의존해 변경 가능성이 훨씬 크기 때문에 Mapper 까지 재활용 할 수 있었습니다.

2. 검색결과 디스플레이 모델 객체를 담은 리스트는 최초에 한번만 생성하자

글자 입력 이벤트가 발생할 때마다 검색결과를 가져오도록 스펙이 재정리 되었습니다. 매 이벤트마다 아이템 모델 리스트를 새로 매핑해야 할까, 아이템 모델 리스트를 미리 매핑해 들고 있을까? 고민됩니다.

전자로 개발시에 고려해야 할 사항이 있습니다.

  1. 매 결과 생성시 세트메뉴와 일반메뉴에 있는 대표메뉴 중복제거 고려해야하고,
  2. 이벤트1 발생 > DM 객체 생성 및 어댑터 모델목록 추가 > adapter.notify > 이벤트2 발생 > 할 때 별도 인덱스를 갖고 있지 않는 메뉴목록의 노출순서는 어떻게 보장할 것인가.
  3. 그리고 매번 모델을 새로 만드는 것이 뭔가 부담스럽다…

메뉴모델이 몇천개씩 되면 힘들겠지만 100개 언저리를 오가는 정도라.. 고려사항이 적은 미리 만들기 방법을 선택하고 PresenterModel 클래스를 정의합니다. “메뉴명”으로 메뉴검색결과를 노출해야 하니, 메뉴명과 위에서 언급한 세트메뉴 디스플레이 모델을 Pair 타입으로 잡아둡니다.

/***
* ShopDetailMenuSearchPresenter 모델
*
***/
@Getter
@Builder
public class ShopDetailMenuSearchModel {

    private final boolean isBaeraShop;

    private final Map<String, Menu> menuMap;

    private final List<Pair<String, SetMenuDisplayModel>> menuDisplayModelPairs;

}

Presenter 모델 정의가 끝났으니, 입력이벤트와 검색결과를 연결해 봅시다.

검색어 조건 이대로 가야하는 건희?

기획초안 버전에서 검색어 조건 스펙은 이렇습니다.

  1. 검색어 대상은 “메뉴명” 이다.
  2. 검색어가 메뉴명에 포함되면 해당 메뉴를 노출한다.

아아~아주 심플한 2번 스펙은 한글기준으로 같은 음절단위 문자의 포함여부를 보고 검색결과를 보여준다는 것인데, 개발 테스트를 해보니 개인적으로는 아쉬운 결과물이 나왔습니다.

검색결과 없는 화면이 예상보다 빈번하게 노출되서 눈에 거슬리네요.


  • “이탈리안”로 검색해 이탈리안 피자를 찾고 싶을 때 현상을 살펴보면 이렇습니다.
    • ‘ㅇ’,’ㅣ’ 두 문자를 입력해야 처음으로 “이”를 포함한 메뉴명 검색결과 목록이 나옵니다.
    • ‘이1’, ‘이2’, ‘이3’ … 이 검색결과 상단에 있다면, “이탈리안”를 보기 위해선 다음 글자인 ‘이탈’까지 입력할 때도 역시, ‘ㅌ’,’ㅏ’,’ㄹ’ 세 문자를 입력해야 다시 결과를 볼 수 있습니다.
    • 물론 ‘잍’, ‘이타’ 상태에서는 ‘이’의 이전결과도 사라져 있습니다.

평소에 많이 쓰던 다른 서비스들의 검색결과와 달라서인지 글자가 완성(syllable, 음절단위)될 때만 결과가 나오는 것이 부자연스러워 보입니다. 일반 사용자 마인드를 장착하고 키보드를 몇번 눌러만 봐도 키보드 입력마다 검색을 때리는 걸 알겠는데, 글자가 완성될 때만 검색 결과가 노출되어 검색결과가 없다고 했다가 있다고 했다가 빈번하게 바뀌는 것이 개인차가 있겠지만.. 저는 확실히 잘못된 것처럼 보입니다. 이런 완성형 한글 검색이라면 차라리 입력 이벤트 마다가 아닌 검색버튼으로만 검색 조회를 하는 것이 낫지 싶었습니다. (라고 말하지만 후자와 같은 검색은 쓰고 싶지 않고요)

그래서 원하는 결과가 뭡니까?

흰색 화면(검색결과 없음)이 빈번하게 노출되지 않았으면 좋겠다. 인데 뭘 더 해야할지 구체적인 요구사항으로 정리하기 위해 애정하는 서비스 몇곳에서 검색기능을 간단히 살펴보았습니다. 둘러본 결과 한번 검색어 입력이 시작되면 최대한 검색결과 화면은 비어있지 않는 것을 추구(?)하는 것 같아 보였습니다. 유즈케이스 하나로 이 요구사항을 정리해 봅니다.

“이탈리안피자”를 찾고 싶을 때, 입력한 값에 대한 기대결과는 이렇습니다.

  1. ㅇ, ㅇㅌ, ㅇㅌㄹ, ㅇㅌㄹㅇ 입력하는 동안 “이탈리안피자”는 검색결과에 항상 있어야 한다.
  2. ㅌ, 타, 탈 을 입력하는 동안 “이탈리안피자”를 포함한 검색결과는 항상 있어야 한다.
  3. ㅌ, 타, 탈 을 입력하는 동안 “이탈리~”, “이타리~” 등이 검색결과에 항상 있어야 한다.
  4. 이탈ㄹㅇ 으로 입력해도 “이탈리안피자”는 검색결과에 항상 있어야 한다.

추가된 스펙은 두개로 압축됩니다.

  1. 초성검색을 지원할 것.

  2. 마지막 입력된 글자가 종성이라면 다음글자의 초성으로 사용되도록 쪼개서 다시 검색할 것.

한글 초성검색 오픈소스를 커스텀 해보자.

처음 해보는 한글자모를 활용한 개발이 시작되었습니다. 한국어 지원 서비스들이 공개한 라이브러리가 있기를 기대하며 구글신에게 요청을 보냅니다. 스펙1에 만족하는 초성검색과 한글일치를 보는 하는 오픈소스가 있어 살펴보니, 스펙2는 없군요. 여윽시 흔한 개발자들은 이 정도는 다 만들어서 쓰나봅니다. 처음부터 개발일정(퀵메뉴가 기다리고 있어!)이 빠듯하니, 하던대로 오픈소스를 파악하고 커스텀 해봅니다.

조합된 한글에서 초성, 초중성을 발라내자.

  1. 한글 음절단위(조합형 한글) 문자값이 Hangul Syllables 표에서 몇번째 인지 인덱스를 구한 후, 중성과 종성의 조합된 총 개수로 나눠 초성의 인덱스를 얻습니다.

  2. Hangul Jamo 표에 정의된 초성(0x1100 ~ 0x1112) 값들 중에서 몇번째 위치했는지 초성 인덱스로 해당 초성 char 값을 얻을 수 있습니다.

    public static char getChoseong(char value) {
        final int syllableIndex = syllable - 0xAC00; // 0xAC00는 한글음절표의 시작값인 '가'
    	final int choseongIndex = syllableIndex / (JUNGSEONG_COUNT * JONGSEONG_COUNT);
           
        return (char) (0x1100 + choseongIndex); // 0x1100는 한글자모 표의 초성 시작값인 'ㄱ'
    }
    


  3. 추가로 Hangul Compat Jamo에 정의된 호환 초성(0x3131~ 0x314E)값들 중에서 추출하는 경우에는 초성 인덱스를 이용해 추출하되, Hangul Jamo와 동일한 인덱스로 얻어 올 수 있도록 Hangul Jamo와 겹치는 값 만으로 이뤄진 배열로 관리합니다. (참고로 호환자모는 initial consonants, medial vowels, final consonants을 의미하는 자음과 모음의 컬렉션과는 달리, 문자(Letter) 자체를 의미합니다. 즉 호환자모의 ‘ㄱ’ 이라는 문자값은 초성이나 종성으로 간주할 수 있습니다.)

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


  4. 초성, 호환초성이 대상어에 포함된 것 뿐만 아니라, “초중성”으로 조합된 단어가 포함되었는지도 봐야합니다. “초중성” 종성 인덱스를 구해서 빼주면 “초중성” 단어의 char값을 구할 수 있습니다.

    public static char getChoJungseong(char value) {
        final int syllableIndex = syllable - 0xAC00;
        final int jongseongIndex = syllableIndex % 28;// 종성 개수는 받침없는 것까지 포함해 28 개로
        return (char) (value - jongseongIndex); 
    }
    

검색 대상어(target)와 검색 패턴(pattern) 비교문을 작성하자.

  1. 넘어오는 패턴값이 초성, 초중성인지 케이스를 나눠서 비교합니다.

    private boolean match(String text, int startIndex, int length, String pattern) {
            for (int i = startIndex; i < startIndex + length - pattern.length() + 1; i++) {
                for (int j = 0; j < pattern.length(); j++) {
                    final char textChar = text.charAt(i + j);
                    final char patternChar = pattern.charAt(j);
                    if (!contains(textChar, patternChar)) {
                        break;
                    }
                       
                    if (j == pattern.length() - 1) {
                        return true;
                    }
                }
            }
            return false;
    }
       
    private static boolean contains(char target, char pattern) {
            char result = 0;
       
            if (isCompatChoseong(pattern) || isChoseong(pattern)) {
                if (isCompatChoseong(target) || isChoseong(target)) {
                    return target == pattern;
                }
       
                if (isCompatChoseong(pattern)) {
                    result = getCompatChoseong(target);
                } else if (isChoseong(pattern)) {
                    result = getChoseong(target);
                }
            } else if (isChoJungseong(pattern)) {
                if (isChoJungseong(target)) {
                    return target == pattern;
                }
       
                result = getChoJungseong(target);
            } else {
                result = target;
            }
       
            return result == pattern;
    }
    


  2. 마지막 글자에 종성이 있으면 종성을 분리해서 다음 글자의 초성으로 붙여서 매칭해 봐야합니다. 기대결과 3 구현을 위해서는 “갈” 입력시에 “가리비”가 나오려면 패턴을 “가ㄹ”로 변조해서 검색을 한번 더 해야합니다. “가ㄹ” 을 포함하는 메뉴명은 “갈” 을 포함하는 메뉴명보다 노출 순위가 낮아야 하므로 “갈” 패턴어로 메뉴명 리스트를 매칭로직을 돌린 후 “가ㄹ”로 한 번 더 돌립니다. 다시 말하자면, 입력중인 마지막 단어에 종성이 입력되면 메뉴명 리스트는 두바퀴 뽀문을 돌게됩니다.

    private Observable<List<DisplayModel>> matchKeyword(String keyword) {
            return just(matchOriginalKeyword(keyword)
                    .concatWith(matchReorderingKeyword(keyword))
                    .distinct()
                    .map(stringMenuDisplayModelPair -> stringMenuDisplayModelPair.second)
                    .cast(DisplayModel.class)
                    .switchIfEmpty(error(new NotFoundException(keyword)))
                    .toList()
                    .blockingGet());
        }
       
    private Observable<Pair<String, MenuDisplayModel>> matchOriginalKeyword(String keyword) {
        return just(keyword)
            .map(KoreanTextMatcher::new)
            .flatMap(matcher -> fromIterable(model.getMenuDisplayModelPairs())
                     .filter(stringMenuDisplayModelPair -> matcher.match(stringMenuDisplayModelPair.first).success()));
    }
       
    private Observable<Pair<String, MenuDisplayModel>> matchReorderingKeyword(String keyword){
        return just(keyword)
            .map(s -> s.charAt(s.length() - 1))
            .filter(KoreanChar::hasJongseong)
            .map(KoreanChar::moveJongseongToNextChoseong)
            .flatMap(movedLastString ->
                     just(keyword.substring(0, keyword.length() - 1) + movedLastString)
                     .map(KoreanTextMatcher::new)
                     .flatMap(matcher -> fromIterable(model.getMenuDisplayModelPairs())
                              .filter(stringMenuDisplayModelPair -> matcher.match(stringMenuDisplayModelPair.first).success())));
    }
       
    public static String moveJongseongToNextChoseong(char value) {
        char nextChoseong = '\0';
        int jongseongIndex = getJongseongIndex(value);
        if (jongseongIndex > 0) {
            nextChoseong = COMPAT_JONGSEONG_ARRAY[jongseongIndex - 1];
        }
        return String.valueOf(getChoJungseong(value)) + nextChoseong;
    }
    

좋아 자연스러진것 같다..

이전과 비교하면 "검색결과가 비어있는 화면" 노출횟수가 현저히 줄었습니다.

그 외

  1. OS 하위버전 단말기에서는 사용자의 키보드 입력속도를 시스템의 뷰 업데이트 속도를 따라 잡을 수 없어 딜레이 되는 현상에 대해 검토하자는 iOS 개발자의 의견에 따라 업데이트 입력이벤트에 대한 일정 시간동안 중복이벤트는 무시하도록 debouncing 처리했습니다. 최소 지원버전인 API19/갤럭시 S3에서 테스트를 했으나, 사실 큰 차이를 보이지 않았습니만 로직은 남겨둡니다.
  2. 배민마켓에도 검색기능도 추가해야 했네요. (한다 안한다 하다 하게된 배민마켓 검색…) 배민마켓은 MVP 코드가 분리되어 있습니다. 첫 개발 당시 해당화면의 아이템 구성이 배너/2단메뉴/메뉴그룹더보기/하단 정보 등으로 UI스타일이 달라, RecyclerView.LinearLayoutManager 하나로 관리할 수 있도록 메뉴 아이템 모델은 메뉴를 2개씩 담는 형태로 만들었습니다.

    그러나 매 이벤트 처리마다 객체 생성을 피하고자 디스플레이 모델을 미리 담을 시에 검색결과에 따라 달라지는 메뉴순서를 대응할 수 없는 모델이었습니다. GridLayoutManager로 메뉴 한개만 노출되는 아이템모델과 친구들을 새로 만들면서 예상일정에 한번 더 빗겨나가게 됩니다. ㅎ ㅏ 검색기능이 들어갈지 알고 있었음에도..일전에 중첩된 RecyclerView을 사용할 것을 염두하지 않았던 것인지!! 한치앞도 보지 못한 모델로 설계한 저를 매우 치고 싶었습니다. (이 때부터 일정에 마음이 급해지기 시작하죠.)

TC 가 채워준 놓친 검색어 케이스 - 너는 멍청이(feat. 화사)

받침이 있지만 받침을 무시하고 메뉴가 노출되는 이슈가 올라왔습니다. (퀵메뉴 개발을 시작해야하는데.. 이슈가 올라옵니다. ㅎ ㅏ 진정하고) 현상은 “배ㅇ” 로 검색할 때, “떡볶이(백원)” 메뉴명이 검색결과에 노출되는 버그가 있었습니다. “백”의 ㄱ종성이 있음에도 초중성 “배” 추려내고 다음 초성을 붙여 비교를 하고 있나 봅니다. 저런.. 정의 및 개발할 때 놓쳤던 케이스였습니다.

매칭로직을 패턴의 마지막 문자인 경우와 이전 문자인 경우를 나눠 비교를 하도록 수정합니다. 이전 글자는 초성을 포함하거나 같은 문자여야 하고, 마지막 글자는 초중성을 포함하거나 같은 문자여야 성공하도록 변경합니다.

private boolean match(String text, int startIndex, int length, String pattern) {
...
                if (j < pattern.length() - 1) {
                    if (!containsChoseongOrEquals(textChar, patternChar)) {
                        break;
                    }
                } else {
                    if (!containsChoJungseongOrEquals(textChar, patternChar)) {
                        break;
                    }
                }
...       
}

글이 길어지니 일단락 하자면

간만에 개발자 본인 의지가 있는 기능을 넣는다니 기왕이면 쓸만하게 만들어야 겠다는 욕심이 앞서 스펙을 불렸습니다. (🥰오예!!!)
검색 UI 개발이 예상보다 일찍 끝났기 때문에 한글검색 조건 세부스펙을 추가 정의하고, 개발, 테스트를 하는 것이 만족스러웠습니다. 그러나 추가된 스펙 때문에 따라오는 이슈들로 개발완료 일이 늦어졌고, 퀵메뉴 개발을 시작해야 하는 시점을 넘어가기 시작했습니다. 미리 얘기하자면 결국 “퀵메뉴”는 개발이 늦어져 배포 일정이 한번 밀렸습니다. (🤪안오예!!!)
일장을 위해 일단(기획자에게는 이단이상 일 수도 있음… 일정이 밀리다니 )이 생긴 상황이 되니 불필요한 작업을 하느라 시간을 허비한 것은 아닐까 라는 생각이 듭니다.
그 후 모든 기능이 배포되고 뒤늦게 알게 된 사실이 있습니다. 기능을 기획/디자인한 담당자는 메뉴 탐색 개선 작업에서 검색보다 퀵메뉴 기능에 더 공을 많이 들이고, 핵심적인 기능으로 바라봤다는 것.. 멍.. 다시 생각해 봅니다. 퀵메뉴 기능이 훨씬 더 주요한 피쳐로 기획된 것임을 개발전에 알았더라면 작업의 순서와 속도가 달라지지 않았을까, 개발자인 스스로 편향된 사고를 좀 덜 수 있지 않았을까! 그게 더 중한 기능이라는 것을 미리 알려고 하지 않았을까! 이런… 다음 글에서 이어집니다!

퀵메뉴 개발썰은 2편으로 분리합니다.