잘린 에러 메시지가 이름을 반으로 갈라 놨어요. 코드는 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로 남아요. 제가 말할 수 있는 건 거기까지예요. 사람이 보는 글자 하나가 온전히 남는지는 또 다른 얘기고요.