openlang

String.length도 len도 바이트를 센다

잘린 에러 메시지가 이름을 반으로 갈라 놨어요. 코드는 String.sub msg 0 40으로 앞쪽 40바이트만 남겼고, 끝 무렵에 있던 이름이 한글이라 잘린 자리가 3바이트짜리 UTF-8 시퀀스 한가운데였어요. 터미널에는 마지막 음절 자리에 대체 문자가 찍혔고요.

OCaml의 String.length는 바이트 수예요. String 모듈 문서도 문자열을 바이트의 불변 시퀀스로 설명하고, String.sub도 바이트 위치를 받아요. Go의 len(s)도 마찬가지고 s[:40]도 바이트로 잘라요. 둘 다 틀린 건 아니에요. 메시지를 읽는 사람이 세는 것과 다른 걸 셀 뿐이에요.

이 자리에서 고치는 방법은 자르는 위치를 문자 시작점까지 뒤로 물리는 거예요. Go에서는 utf8.RuneStart로 짧은 루프를 돌면 돼요.

func truncate(s string, max int) string {
	if len(s) <= max {
		return s
	}
	i := max
	for i > 0 && !utf8.RuneStart(s[i]) {
		i--
	}
	return s[:i]
}

여기서 s[i]는 잘려 나갈 첫 바이트예요. 그게 문자의 시작이면 i에서 깔끔하게 잘려요. 이어지는 바이트라면 루프가 왼쪽으로 걸어가서 갈라질 뻔한 문자의 시작을 찾고, 그 앞에서 잘라요. 김다현으로 끝나는 메시지에서 제한이 다 안에 걸리면 김까지의 텍스트가 올바른 UTF-8로 나와요. s[:40]은 길 잃은 바이트 두 개를 남겼고요.

OCaml에서는 String.get_utf_8_uchar로 그 위치를 디코딩하고 Uchar.utf_decode_length로 얼마나 건너뛸지 알 수 있으니, 같은 걸음이 몇 줄 길어질 뿐 읽는 느낌은 비슷해요. Go 버전은 문자열 몇 개를 더 통과해요. 순수 ASCII 문자열과, 제한이 이미 문자 경계에 걸리는 문자열인데, 둘 다 제한 위치에서 그대로 잘려요.

이렇게 하면 결과가 올바른 UTF-8로 남아요. 제가 말할 수 있는 건 거기까지예요. 사람이 보는 글자 하나가 온전히 남는지는 또 다른 얘기고요.

이어지는 대화 6

서로의 근거를 따라
  1. 고유 링크

    .mli에 val truncate : max_bytes:int -> string -> string으로 두고, 화면에 나갈 문자열을 자르는 곳은 이 함수 하나로 두는 게 좋습니다. 그래야 나중에 누가 슬그머니 String.sub로 돌아가지 못합니다.

  2. 고유 링크

    JavaScript를 쓰면 단위 하나만 작아진 같은 버그를 자주 봐요. .length와 slice는 UTF-16 코드 유닛을 세니까, name.slice(0, 40)은 UTF-8 시퀀스 대신 서로게이트 쌍을 가를 수 있고, 끝에 짝 없는 서로게이트가 남아요. 코드 포인트 경계까지 물러나면 그건 해결돼요. 그래도 🇰🇷는 여전히 붙어 있지 못해요. 이 국기는 코드 포인트 두 개, UTF-16 유닛 네 개에 그래핌 하나라서, 코드 포인트만 지키는 자르기는 그 한가운데를 그냥 지나가요. 에러 메시지라면 아마 넘어갈 만해요. 이름이라면 그래핌 기준으로 자르고 싶어요.

  3. 고유 링크

    RuneStart 필요 없어. 문자열을 range로 돌면 이미 룬 시작 위치가 나와.

    if len(s) <= max {
    	return s
    }
    end := 0
    for i := range s {
    	if i > max {
    		break
    	}
    	end = i
    }
    return s[:end]

    올바른 UTF-8이면 결과 같고 import 하나 줄어.

  4. 고유 링크

    더 조용한 글자에서도 깨져요. e 다음에 U+0301이 오는 이름에서 제한이 그 둘 사이에 걸리면 악센트가 사라져요. utf8.RuneStart 루프는 e를 남기고 결합 악센트를 버리고, 결과는 완벽하게 올바른 UTF-8이에요. OCaml 쪽도 똑같아요. 둘 다 코드 포인트만 보니까 당연하죠. 그래서 이름이 에러 없이 틀린 철자로 나와요. 어떤 면에서는 대체 문자보다 나빠요. 아무도 눈치채지 못하니까요.

    당분간은 코드 포인트 기준 자르기를 그대로 두고, 자른 뒤에는 말줄임표를 붙이기로 했어요. 적어도 뭔가 잘렸다는 건 읽는 사람이 알 수 있게요.

  5. 고유 링크

    말줄임표는 증상만 다룬다. 자르기 전에 NFC로 정규화하면 e와 U+0301은 코드 포인트 하나인 é가 되니, 코드 포인트 단위로 잘라도 둘이 갈라지지 않는다. 이 이름이라면 된다. 그래도 모든 그래핌에 통하지는 않는다. 미리 합성된 형태가 없는 결합 시퀀스가 많기 때문이다. Go 표준 라이브러리에는 정규화 도구도 없다. golang.org/x/text/unicode/norm을 가져와야 한다.

같은 질문의 다른 갈래

  1. `isdigit()`는 통과시키고 `int()`는 거부한 위첨자 2

    작은 폼의 수량 입력란이 s.isdigit()로 검사한 다음 int(s)를 불러요. 누군가 ²를 붙여 넣었더니, 바로 그 검사가 지켜 주기로 한 줄에서 ValueError가 났어요. 둘은 서로 다른 규칙을 써요. isdigi…

    답글 4python
  2. 이름 조회에 NFC 키를 기본으로 써도 될까요?

    자, 사용자가 이름을 붙여 넣었는데 끝이 e 다음에 U+0301이에요. 나중에 같은 이름을 직접 치면 키보드가 U+00E9를 넣어 줘요. 그 사람한테는 같은 이름이고 캐노니컬 등가이기도 한데, 문자열로는 여전히 달라요. 제…

  3. NFC 조회 키와 JCS 서명

    kim_dh가 앞서 올린 NFC 조회 키 질문은 제출된 이름과 NFC 조회 키를 나눠서 봤는데, 서명이 끼면 더 복잡해져요. JSON 페이로드의 이름이 e 뒤에 U+0301을 붙인 형태이고, 그 문자열이 JCS 기반 서명에…