본문 바로가기
[Developer]/Kotlin

한국어 초성을 추출하는 유틸리티 함수

by 해피빈이 2026. 1. 5.

개인적으로 개발하는 프로젝트에서 특수한 요구사항을 생성했다.

연락처 앱, 카카오톡 등 사용자 목록을 표현하지만, 그 목록을 알파벳 혹은 한글 초성 순으로 표현해야할 필요가 있을 때, 그것을 빠르게 스크롤 하기 위한 목적으로 우측 스크롤 바를 보여주긴 하지만, 그것을 바로 뛰어넘을 수 있도록 초성만 따로 추출하여 순서대로 보여주는 것이다.

가령 '김철수', '김영희', '마동탁' 이렇게 있으면, [ㄱ, ㅁ] 목록을 따로 추출하여 순차적으로 돌려주는 것이다.

이것을 만들기 위한 알고리즘과 그것을 만드는 과정에 대한 의미를 담아 포스팅 해본다.

 

총 3가지 단계로 이루어지며, 이것을 테스트하는 단계를 마지막 하나로 두어 기록한다.

 

1.  한글 문자의 초성 추출

우선 한글 첫 문자만 들어왔을 때, 즉, 한글로 된 문자 하나를 받았을 때 그 문자가 어떤 것이든 한글 초성으로 변환할 수 있어야 한다.

    private val initialConsonants = arrayOf(
        'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
        'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
    )

    /**
     * 한글 문자의 초성을 추출
     * @param char 한글 문자
     * @return 초성 문자, 한글이 아니면 원래 문자 반환
     */
    fun getInitial(char: Char): Char {
        if (char in '가'..'힣') {
            val code = char.code - '가'.code
            val initialIndex = code / (21 * 28)
            return initialConsonants[initialIndex]
        }
        return char
    }

 

받은 문자열이 일단, '가'에서 '힣' 사이이어야만 하는데, 한글은 유니코드상 이 범위 안에 있기 때문이다.(참고로 '가'는 AC00이다.)

그래서 상대 위치를 설정해야 하는데, 현재 한글에서 '가'의 위치를 빼주면, '가' 기준으로 얼마나 멀리 떨어져 있는지 상대적인 위치를 구할 수가 있다.

그 다음에는 그렇게 나온 상대적인 위치에서, 'ㄱ'에 해당하는 범위에서 'ㄴ'에 해당하는 범위까지, 또한 'ㄴ'에 해당하는 범위에서 'ㄷ'에 해당하는 범위까지.... 이런식으로 즉 각 문자 초성에 해당하는 범위는 유니코드에서는 다행히도 정형화 되어 있다. 바로 다음에 곱하는 21과 28이 그 의미인데, 21은 중성의 갯수이며, 28은 종성의 갯수이다.

중성(21개): ㅏ, ㅐ, ㅑ, ㅒ, ㅓ, ㅔ, ㅕ, ㅖ, ㅗ, ㅘ, ㅙ, ㅚ, ㅛ, ㅜ, ㅝ, ㅞ, ㅟ, ㅠ, ㅡ, ㅢ, ㅣ
종성(28개): 없음, ㄱ, ㄲ, ㄳ, ㄴ, ㄵ, ㄶ, ㄷ, ㄹ, ㄺ, ㄻ, ㄼ, ㄽ, ㄾ, ㄿ, ㅀ, ㅁ, ㅂ, ㅄ, ㅅ, ㅆ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ

즉, 21*28개=588개를 지날 때마다 초성이 하나씩 증가하는 패턴을 갖고 있다는 의미이다.

 

그래서 즉, '가'에서 얼마나 떨어져 있는지만 안다면, 초성을 바로 구할 수 있게 되는 것이다.

그 초성 또한 initialConsonants에서 정의된 순서대로 제공되어 있다.

그래서 결국 계산에서 얻어온 index를 initialConsonants 배열의 index와 매핑시키면 현재 초성을 얻어올 수 있는 것이다.

 

이 부분이 해결되었으니 나머지 부분들은 간단하게 해결이 가능해진다.

 

2. 문자열의 첫 글자 초성 추출

그 다음엔 한국어만 들어온다고 하면 별 문제는 없는데, 영어와 숫자까지 고려하는 상황이라고 가정하자. 사실 숫자 외의 문자도 커버는 가능하다. 여기에서 핵심은 한국어와 영문까지는 그래도 순차 정렬이 가능하다는 것이다. 나머지는 맨 앞으로 몰아서 처리할 예정이다.

    /**
     * 문자열의 첫 글자 초성을 추출
     * @param text 입력 문자열
     * @return 첫 글자의 초성, 빈 문자열이면 '#' 반환
     */
    fun getInitial(text: String): Char {
        if (text.isEmpty()) return '#'
        val firstChar = text.first()
        return if (firstChar in '가'..'힣') {
            getInitial(firstChar)
        } else if (firstChar.isLetter()) {
            firstChar.uppercaseChar()
        } else {
            '#'
        }
    }

 

현재 입력된 문자열이 비어있다면 '#'으로 처리할 것이고, 그 외의 경우에도 '#'으로 처리할 것이다.

빈 문자가 아닌 경우만 처리하도록 해 놓았으므로, 첫 번째 문자를 추출할 수 있다.

그 첫 번째 문자가 '가'에서 '힣'으로 끝나는 경우라면, 우리가 앞서 만들어 놓은 한글 초성 추출 함수에 적합한 케이스가 된다.

그래서 이 경우에는 앞에서 만든 함수를 호출하고 그 결과값을 반환하도록 한다.

 

여기에 해당하지 않는다면, isLetter 함수를 호출한다. 문자가 알파벳이냐는 의미이다.

만약 이 경우에 해당한다면 대소문자 구분을 하게 할 필요는 없으므로 언제나 대문자로 변환해준다. 그 변환한 결과값을 반환한다.

 

그러면 이 함수를 거쳐간 뒤에는 ㄱ~ㅎ, A~Z, # 이 경우만 반환되게 될 것이다.(물론 isLetter가 영문 알파벳만 처리하는 것은 아니기 때문에 독일어의 알파벳에 해당하는 문자도 나올 수는 있지만, 그 또한 잘못된 케이스는 아니므로 이정도는 모두 포함된다고 보면 된다.)

 

3. 문자열 목록에서 고유한 초성 목록 추출

자 거의 다 왔다. 이제는 여러개의 중복을 감안한 문자열을 다양하게 받게 될 것이다.

그럼에도 유니크한 초성 및 알파벳을 반환해야 하도록 이것을 한번 더 감싸서 함수로 만들어 보자.

    /**
     * 문자열 리스트에서 고유한 초성 목록을 추출
     * @param items 문자열 리스트
     * @param getName 각 아이템에서 이름을 추출하는 함수
     * @return 초성 리스트 (정렬됨)
     */
    fun <T> getUniqueInitials(
        items: List<T>,
        getName: (T) -> String
    ): List<Char> {
        val initials = items.map { getInitial(getName(it)) }
            .distinct()
            .sorted()
        return initials
    }

 

이것은 어떤 타입의 객체라도 받아서 처리할 수 있도록 Generic을 사용하였다.

이렇게 받을 수 있도록 하지 않으면, 해당하는 문자열만 map으로 전처리해야만 하는 불편함이 동반되기 때문이다.

이러한 기법은 다른 개발에서도 응용하면 좋을 것이다.

 

그렇게 받아온 객체 목록을 map으로 처리하는데, 처리하는 방법은 getName에서 정의한 방법을 사용한다. 즉, 해당 객체에서 내가 정렬하고 싶은 기준이 되는 문자열에 해당하는 프로퍼티를 넘겨주면 된다. 그러면 그것을 기준으로 앞에서 만든 getInitial() 함수를 호출하게 되고, 그렇게 나온 목록을 중복 없이 반환할 수 있도록 distinct() 처리와, 정렬된 상태로 반환되도록 sorted()처리까지 해주면 완벽하다.

이것을 Char의 List로 반환하면 원하는 형태로 사용할 수 있게 된다.

 

 

4. 테스트

이제 이 코드는 순수 kotlin으로 편하게 테스트가 가능하다.

아래와 같은 방식으로 테스트를 진행해보자.

fun main() {
    val friendList: MutableList<Friend> = mutableListOf()
    friendList.apply {
        add(Friend("마동탁", 31))
        add(Friend("나기쁨", 32))
        add(Friend("John", 39))
        add(Friend("홍반장", 22))
        add(Friend("Kay", 29))
        add(Friend("김철수", 34))
        add(Friend("2김상무", 36))
        add(Friend("하이팅", 34))
        add(Friend("Bob", 33))
        add(Friend("1최사장", 30))
        add(Friend("박문수", 31))
    }
    
    val initialList = KoreanInitialExtractor.getUniqueInitials(friendList) { it.name }
    initialList.forEach {
        println("element: $it")
    }
}

data class Friend(
    val name: String,
    val age: Int
)

 

결과는 아래처럼 나온다.

element: #
element: B
element: J
element: K
element: ㄱ
element: ㄴ
element: ㅁ
element: ㅂ
element: ㅎ

 

 

이 코드는 try.kotlinlang.org 에서 작성하여 아래의 내용으로 공유했으니 필요할 때 편하게 참고할 수 있으면 좋겠다.

 

 

 

또한 이러한 내용이 이제는 AI로 작성할 수 있으므로 알 필요가 있을까 생각하는 사람이 있을지도 모르겠다. 하지만, AI의 도움은 충분히 받으면서 개발하되, 사람이 생각하는 것은 멈추지 않아야 한다고 생각한다. 그런 측면에서 생각을 조금이라도 할 수 있게 하는 하나의 작은 자료가 되었으면 좋겠다.

 

 

 

 

 

 

 

 

 

 

 

반응형

댓글