[Java] 자바에서 이모지를 처리해보자

@melonturtle · December 15, 2023 · 13 min read

개발하고 있는 프로젝트에서 문자열의 글자수를 셀 때, 이모지는 글자수를 2로 세달라는 요구사항이 있다. 이모지가 일반 한글, 영어 같은 문자보다 좀 더 공간을 차지하기 때문에 디자인적 이유다.

그럼 이걸 자바에서 어떻게 처리할 수 있을까?

Grapheme Clusters

우선 이 요구사항이 오기 전에, 이모지든 어떤 문자든 우리가 인식할 수 있는 글자수로 세는 메서드를 개발해놨었다.

  • 이 때 우리가 인식할 수 있는 글자단위를 Grapheme Cluster, Perceived character 라고 표현한다. 실제로 여러 코드 포인트로 이루어진 문자열이라도, 하나의 시각적 단위로 인식되는 경우이다.
  • 코드 포인트는 유니코드에서 각 문자에 할당된 고유한 숫자.

Java 17에서 개발했었고, 정규식을 사용했다.

정규식 이용

public class TextUtils {
    private static final Pattern graphemePattern = Pattern.compile("\\X");
    private static final Matcher graphemeMatcher = graphemePattern.matcher("");

    /*
     * 이모티콘이 포함된 문자, 즉 2byte가 넘는 문자가 있을 경우 String의 길이는 우리가 인식하는 글자 단위보다 길어진다.
     * 이 함수는 우리가 인식하는 대로 길이를 읽어온다.
     */
    public static int getLengthOfEmojiContainableText(String text) {
        if (text == null) {
            return 0;
        }
        graphemeMatcher.reset(text);
        int count = 0;
        while (graphemeMatcher.find()) {
            count++;
        }
        return count;
    }
}
  • 정규식 \X는 하나의 유니코드 grapheme과 일치한다. 이모지가 결합된 이모지여도 문제없이 하나씩 일치한다.

    Extended grapheme clusters

    The \X escape matches any number of Unicode characters that form an "extended grapheme cluster", and treats the sequence as an atomic group (see below).

  • 따라서 정규식으로 문자를 하나씩 읽어서 개수를 세줬다.

Test

String textWithEmoji = "👨‍👩‍👧‍👦안😁녕!hi";
assertThat(TextUtils.getLengthOfEmojiContainableText(textWithEmoji))
        .isEqualTo(7);
  • 그냥 자바의 String을 통해 문자열을 센다면 👨‍👩‍👧‍👦는 길이가 11, 😁는 길이가 2이다. 결합된 이모지도 있을 수 있기 때문에 이모지마다 길이가 다 다르다.
  • 위에서 작성한 메서드를 이용하면 길이가 7로 우리가 인식하는 것과 같게 측정된다.

BreakIterator

프로젝트 코드가 Java 17이라 사용하지 못했지만, Java 20부터는 BreakIterator가 grapheme cluster 단위로 글자를 처리할 수 있다.

참고: JDK-8291660

public static int getLength(String emoji) {
	BreakIterator it = BreakIterator.getCharacterInstance();
	it.setText(emoji);
	int count = 0;
	while (it.next() != BreakIterator.DONE) {
		count++;
	}
	return count;
}

// 1
getLength("👨‍👩‍👧‍👦");
  • BreakInterator.getCharacterInstance

    • Character breaks를 위한 BreakIterator 인스턴스 반환
    • Character breaks?

      Character boundary analysis allows users to interact with characters as they expect to, for example, when moving the cursor through a text string. Character boundary analysis provides correct navigation through character strings, regardless of how the character is stored. The boundaries returned may be those of supplementary characters, combining character sequences, or ligature clusters. For example, an accented character might be stored as a base character and a diacritical mark. What users consider to be a character can differ between languages.

    • 즉, 유저가 인식하는 것과 같이 문자를 처리할 수 있게 해줌.
  • 만약 단순히 개수만 세는 게 아니고, 현재 iterator가 가리키는 문자가 뭔지 알고 싶다면 다음과 같이 작성하면 된다.
public static List<String> getGraphemes(String emoji) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(emoji);

    List<String> graphemes = new ArrayList<>();
    for(int current = it.first(), next = it.next(); next != BreakIterator.DONE; current = next, next = it.next()) {
        graphemes.add(emoji.substring(current, next));
    }
    return graphemes;
}

String test = "👨‍👩‍👧‍👦😁🙆🏻‍♀️hi💸𓁵";
// [👨‍👩‍👧‍👦, 😁, 🙆🏻‍♀️, h, i, 💸, 𓁵]
List<String> graphemes = getGraphemes(test);

이모지 찾기

이제 여기서 추가로 개발해야 하는 건 이모지 여부 판단이다.

가능한 방법

이 기능 구현을 위해 처음에 찾아본 방법들은 다음과 같았다.

  1. 외부 라이브러리 사용
  2. 정규식 사용
  3. 한글, 영어, 숫자, 특수문자를 정규식으로 제외하고 나머지를 이모티콘으로 처리해서 갯수 세기
  4. 현재 나와있는 이모티콘 데이터를 사전처럼 활용하기

문제점

그리고 각 방법에 대해 내가 생각해본 문제점은 다음과 같다.

  1. 외부 라이브러리 사용

    • 유니코드 이모지는 계속 추가된다. 그래서 외부 라이브러리 업데이트가 늦으면 처리되지 않는 이모지가 생김.
    • 외부 라이브러리 종속성 추가됨.
  2. 정규식 사용

    • 유니코드 이모지는 계속 추가되므로, 새로운 이모지가 추가되면 정규식에 처리되는 유니코드 범위를 바꿔줘야함.
  3. 한글, 영어, 숫자, 특수문자를 정규식으로 제외하고 나머지를 이모티콘으로 처리해서 갯수 세기

    • 우리 서비스에 한글 영어 제외한 다른 나라 문자가 쓰일 일은 많이 없겠지만.. 뭔가 찝찝? 그래도 최선일 것 같음.
  4. 현재 나와있는 이모티콘 데이터를 사전처럼 활용하기

    • 이것도 개발자가 사전 데이터를 트래킹해야함

Java 21

그래서 좀 더 깔끔하게 구현하기 위해 Java 21 부터 추가된 이모지 여부를 판단하는 Character.isEmoji(int codePoint) 함수를 활용하기로 했다.

  • 나는 지금 Java 17 + Spring 3.1.x 를 활용하여 개발중이었으므로, Java 21을 사용하기 위해선 Spring 3.2.x을 사용해야 했다.

String 문자열을 그대로 isEmoji 메서드를 활용하면, 결합 이모지를 하나로 처리할 수 없다. 애초에 하나의 유니코드 code point 값을 인자로 받기 때문이다.

String text = "👨‍👩‍👧‍👦";
text.codePoints().forEach(cp -> {
    System.out.println("Character.toString(cp) = " + Character.toString(cp));
    System.out.println("Character.isEmoji(cp) = " + Character.isEmoji(cp));
});
Character.toString(cp) = 👨
Character.isEmoji(cp) = true
Character.toString(cp) = ‍
Character.isEmoji(cp) = false
Character.toString(cp) = 👩
Character.isEmoji(cp) = true
Character.toString(cp) = ‍
Character.isEmoji(cp) = false
Character.toString(cp) = 👧
Character.isEmoji(cp) = true
Character.toString(cp) = ‍
Character.isEmoji(cp) = false
Character.toString(cp) = 👦
Character.isEmoji(cp) = true

결국 Grapheme cluster 대로 나눠야 인식할 수 있는 단위로 글자를 나누고 이모지를 구분할 수 있다.

그래서 내가 생각한 최선의 방식은 grapheme cluster를 세는 메서드와 grapheme cluster에서 이모지를 세는 메서드 두개를 구현하고 합하는 방식이다.

public static int countGraphemeClusters(String text) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(text);
    int count = 0;
    while (it.next() != BreakIterator.DONE) {
        count++;
    }
    return count;
}

public static int countEmojis(String text) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(text);
    int count = 0;
    while (it.current() < text.length()) {
        if (Character.isEmoji(text.codePointAt(it.current()))) {
            count += 1;
        }
        it.next();
    }
    return count;
}

public static int countGraphemeClustersWithLongerEmoji(String text) {
    return countGraphemeClusters(text) + countEmojis(text);
}

String text = "👨‍👩‍👧‍👦😁🙆🏻‍♀️hi💸𓁵";
// Grapheme: 7, Emoji: 4 -> 11
System.out.println(TextUtils.countGraphemeClustersWithLongerEmoji(text));

이게 최선인지 모르겠지만.. 여기까지 코드를 작성하고 보니, 이모지를 이렇게 세는 대신에 2byte로 표현되지 않는, 하나의 Char로 표현할 수 없는 문자들의 개수만 세는 정도로도 충분할 것 같다는 생각이 들었다. 😂

그래도 겸사겸사 Java 21의 기능도 살펴보면서 이모지 처리를 한번 더 생각해볼 수 있었다.

Java 21 ⬇️

그리고 Java 21로 버전업을 하고 개발했는데 문제가 생겼다. 🥲

현재 프로젝트에서 사용 중인 Java, Spring, Gradle도 다 버전업을 하고 Github action에서 사용하는 자바 버전도 바꾸고.. 완벽하게 했다고 생각했으나 배포 환경을 생각하지 않았다.

AWS Beanstalk을 사용해 배포중이었는데, beanstalk에선 아직 Java 21이 지원되지 않는다. 현재 프로젝트는 Corretto 17을 사용하고 있었다.

Java 21도 나온지 얼마 안됐기도 하고, Corretto 17이 언제부터 지원됐나를 보니 정말 최근이었다.

그리고 Corretto 21 관련 이슈도, In progress도 아니고 Proposed 상태기때문에 몇년 안에는 지원되지 않을 것 같다.

그렇다고 이게 beanstalk을 포기하고 Java 21을 쓸 정도의 문제인가? 하면 그건 아니다. 그냥 버전을 올려도 문제없는 것 같았고 최신 문법도 한번 써보면 좋을 것 같아서 업그레이드 했을 뿐..

그래서 다시 방법을 생각해봤다. 위에서 작성한대로, 아주 정확하게 구분할 필요 없이 하나의 char로 표현할 수 없는 문자들의 개수만 세는 정도로도 개발 중인 서비스에는 충분할 것 같았기 때문이다. 그래도 정말 괜찮은지는 한번 확인해보자.

JVM의 문자 처리

Character class
The Java platform uses the UTF-16 representation in char arrays and in the String and StringBuffer classes. ... A char value, therefore, represents Basic Multilingual Plane (BMP) code points, including the surrogate code points, or code units of the UTF-16 encoding. An int value represents all Unicode code points, including supplementary code points.

  • char(2바이트)는 int(4바이트)와 달리 모든 유니코드를 표현하지 못하고, BMP 등등만 표현할 수 있다고 되어있다. 그러면 String의 길이가 실제우리가 보는 것보다 길게 나오는 건 char안에 담지 못하는 문자들 때문인걸 알 수 있다.
  • 여기서 Plane이라고 표현하는 건 유니코드 평면이다.
  • 그럼 지금 우리의 목적인 이모티콘이 어느 평면에 속해있는 지를 살펴보면, SMP, 즉 다국어 보충 평면에 Emoticons (1F600–1F64F), Symbols and Pictographs Extended-A (1FA70–1FAFF)로 있었다.

  • 그럼 BMP에 있지 않으면서, 이모티콘이 아닌 다른 평면들을 살펴보자. 다른 평면에 있는 문자들은 사실상 우리 서비스에 쓰일 일이 없는 문자들이었다. 예를 들어 고대 문자, 일부 다른 나라 문자, 𫝆같은 일부 한자가 포함되어 있었다.
// 2
"𫝆".length();
  • 결론적으로, 이런 문자들은 신경 안써도 되는 문자들이고, 그냥 길이가 1보다 긴 문자들은 다 이모지처럼 처리해도 괜찮다고 판단했다.
  • 그래서 우리가 인식할 수 있는 문자 단위로 자르고, 거기서 길이가 1이 아니면 다 이모지로 판단하도록 개발했다!

구현

public class TextUtils {
    private static final Pattern graphemePattern = Pattern.compile("\\X");

    public static int countGraphemeClusters(String text) {
        if (text == null) {
            return 0;
        }
        final Matcher graphemeMatcher = graphemePattern.matcher(text);
        return (int) graphemeMatcher.results().count();
    }

    public static int countEmojis(String text) {
        if (text == null) {
            return 0;
        }
        final Matcher graphemeMatcher = graphemePattern.matcher(text);
        return (int) graphemeMatcher.results()
                .filter(result -> result.group().length() > 1)
                .count();
    }
}

참고

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!