왜 문자열을 배열처럼 다루지 못하지?

Sungdoo Yoo
8 min readJan 9, 2010

--

스위프트를 배우면서 놀랐던 점은 문자열을 다루기가 굉장히 어렵다는 점이었다.
예를 들어, 다른 언어에서는 정말 상식적으로 생각할 수 있는 다음과 같은 코드들이 동작하지 않았다.

// JavaScript
var str = "123"
var one = str[0]
console.log(one) // "1" 출력
// Swift
var str = "123"
var one = str[0]
print(one) // Error : 'subscript' is unavailable: cannot subscript String with an Int, see the documentation comment for discussion

String에 왜 배열 연산을 적용할 수 없단 말인가. 정말 매우 매우 당황스러웠다.

배열의 원초적 의미를 생각해보자

스위프트에서 String은 Array 가 아니다! 심지어는 Collection Type조차도 아니다! 이게 무슨 소리인가? 애초에 String 이라는게, 기본적으로 Char의 Array이지 않았나? 문자열이라는 게, 결국에는 문자(char)의 배(array)이라는 것 아닌가 말이다!

여기서 다시 배열의 개념을 생각해보자. 배열이라는 것은 기본적으로 메모리상의 연속된 공간이다.

위와 같은 그림은 결코 배열을 표현하는 추상적이기만 한 그림이 아니라, 어느 정도 실제 하드웨어에 비트/바이트가 저장되는 모습을 구체적으로 표현한 그림이다. 즉, 프로그래머가 size가 5인 배열을 만들 때, 운영체제는 각 index별로 같은 크기의 저장공간을 연속적으로 메모리에 할당한다.

핵심은 같은 크기의 저장공간이다. 즉 다음과 같은 배열을 만들면

var arr = ["a","b","c"]

메모리에는 0번 인덱스에 65, 1번 인덱스에 66, 2번 인덱스에 67 이라는 숫자가 저장된다. (ASCII 인코딩이라면) 그리고 맥락에 따라 다를 수 있지만, 많은 프로그래밍 언어와 운영체제는 이 숫자가 255를 넘어가지 않을 것이라고 암묵적으로 동의하는 경향이 있다. 왜냐하면 글자 하나는 일반적으로 1바이트, 즉 8비트이기 때문이다.

한 글자의 용량은 1바이트보다 클 수도 있다.

여기서 또 하나의 핵심이 나온다. 글자 하나는 일반적으로 1바이트이다. 그래서일반적인 프로그래밍 언어들은 글자를 1바이트라고 생각한다. 그러나 유니코드 시스템에선 글자 하나가 반드시 1바이트라는 보장은 없다.

(만약 유니코드와 UTF-8에 대한 배경지식이 부족하다면 Computerphile의 이 비디오를 참고하길 바란다.)

유니코드를 지원하는 스위프트의 입장에서 봤을 때, 문자라는 것들은 결코 같은 타입의 원소들이 아니다. 최소한 그것들이 차지하는 저장공간이라는 차원에서 봤을 때는 그렇다.

As mentioned above, different characters can require different amounts of memory to store, so in order to determine which Character is at a particular position, you must iterate over each Unicode scalar from the start or end of that String. For this reason, Swift strings can’t be indexed by integer values.

- The Swift Programming Language (Swift 4)

그런데 설상가상으로, 이 “Unicode Scalar”라는 것은, 같은 Character를 두 가지 방법으로 표현하게 해주기까지 한다.

한 글자를 표현하는 방법에는 여러가지가 있을 수 있다.

예를 들어 é 라는 글자를 보자. 유니코드에서

  • é 는 0xE9(십진법으로 233)의 번호를 갖는다.
  • e는 0x65(십진법으로 101)의 번호를 갖는다.
  • ́(Combining Accute Accent) 는 0x301(십진법으로 769)의 번호를 갖는다.
  • 0x65와 0x301이 연속으로 이어져 있으면 유니코드 시스템은 é 를 출력한다.

따라서 스위프트에서는 다음과 같은 결과를 볼 수 있다.

let eWithSingleUniCode = "Pok\u{00E9}mon" 
let eWithTwoUniCode = "Pok\u{0065}\u{0301}mon"
print(eWithSingleUniCode) // "Pokémon" 출력
print(eWithTwoUniCode) // "Pokémon" 출력

두 방법 모두 Pokémon 이라는 같은 글자를 출력한다. 출력된 Pokémon 이라는 글자를 보았을 때, 개발자는 두 é 가 당연히 메모리에서 정확히 같은 용량을 차지할 것이라고 (근거없이) 생각할 수밖에 없다. 그래서 다음과 같은 상황이 나왔을 때 당황할 수밖에 없다.

print(eWithSingleUniCode.characters.count) // 7 출력
print(eWithTwoUniCode.characters.count) // 7 출력
print(eWithSingleUniCode.utf16.count) // 7 출력
print(eWithTwoUniCode.utf16.count) // 8 출력
print(eWithSingleUniCode.utf8.count) // 8 출력
print(eWithTwoUniCode.utf8.count) // 9 출력
print(eWithSingleUnicode == eWithTwoUnicode) // true 출력
print(eWithSingleUnicode.utf16.elementsEqual(eWithTwoUnicode.utf16)) // false 출력

물론 여기다 대고 eWithTwoUniCode.characters.count 만 쓰면 되는 것 아니냐! 라는 얘기를 할 수도 있겠지만, 한글등의 비ASCII 문자를 다룰 때, 혹은 방대한 양의 문자를 다룰 때는 당연히 유니코드 숫자를 직접 다뤄야 할 수도 있다. 링크: Strings, Characters And Performance In Swift, A Deep Dive

암튼 이런 고로, Swift는 우리가 String을 “같은 타입의 자료로 이루어진 배열”이라고 받아들이길 원하지 않는 것 같다. Swift 에서 String은 그저 유니코드들의 집합을 보여주는 ‘View’일 뿐이다. 이는 유니코드 문자들을 깨뜨리지 않고 보여주는데에는 탁월하다. 그리고 이 탁월함은 사실 우리같은 비ASCII 문화권의 개발자들이 늘 염원하던 일이다.

그러나 이 탁월함에는 분명한 대가가 따른다. 기존 언어에서와 같은 간단한 문자열 처리가 어렵기도 하고, character 단위의 문자열 처리에 기존 대비 큰 리소스가 소모된다. 간단하게 얘기해서 256개만 신경쓰면 되던 일을 이제는 십만개의 글자를 신경써야 하기 때문이다.

그러나 이러한 문제점들에 대한 돌파구는 있다 . Character 를 쓰지 않고 UTF8View, UTF16View, UnicodeScalarView 를 쓰면 된다.

그래서, 문자열의 문자 참조는 어떻게 한다고?

원래 내가 하고 싶었던 문자열 처리는 이런 것이었다.

let greeting = "Guten Tag!"
for i in 0..<greeting.characters.count{
print(greeting[i]) // ERROR!!
}

이 코드를 다음과 같이 바꾸면 된다.

let greeting = "Guten Tag!"for i in 0..<greeting.characters.count {
var index = greeting.index(greeting.startIndex, offsetBy:i)
print(greeting[index])
}

즉 문자열 참조를 “아무 숫자”로만 하는 것이 아니라 index라는 특별한 자료형을 통해서 실시하는 것이다.
이렇게 하면 유니코드에 무지한 개발자가 “아무숫자”로 문자를 호출해 벌어지는 이상한 상황을 막을 수 있다. 다만 척 봐도 기존에 비해 많은 Computation이 들어간다. 또 코드도 깔끔하지 않다. 따라서 index라는 숫자를 꼭 써야하는 경우가 아니라면

let greeting = "Guten Tag!"for char in 0..<greeting {
print(char) // char 는 Character 자료형이다.
}

을 쓰는 것이 코드의 가독성이라는 측면에서 맞는 선택이겠고, 연산의 효율성이 필요한 경우라면

let greeting = "Guten Tag!"for u in greeting.unicodeScalars{
print(u)
}

을 쓰는 것이 더 효율적이겠다.

결론

폐쇄적인 미국기업이라고 생각한 애플이 유니코드 지원을 위해 이 정도의 노력을 했다는 것이 정말 놀랍다. 그리고 이 노력의 결과로 기존에 비해 코드 작성이 더 어려워지고 컴퓨터의 연산능력이 더 소모된다는 점까지 고려한다면 더욱 그렇다.

그러나 컴퓨터는 미국인들만 쓰는 것이 아니기 때문에 이러한 수고는 당연히 전세계인들이 감내해야 하는 노력이라고 본다. 다만 미국인들이 이런 생각을 했고 그것을 실천했다는 것이 정말 놀랍다. 삐딱하게 보자면 전세계에 아이폰을 팔아야 한다는 계산에 의한 것이겠지만, 그러한 계산조차도 열린 사고가 없다면 불가능한 것이기 때문이다.

아무튼 유니코드 지원에 대한 이러한 애플의 노력에 박수를 보낸다.

--

--