콘텐츠로 건너뛰기
Home » Swift의 String타입 기초 정복 – 01. 문자열 생성 방법들

Swift의 String타입 기초 정복 – 01. 문자열 생성 방법들

문자열은 대부분의 프로그래밍 언어에서 중요한 비중을 차지하는 데이터타입이다. 많은 경우에 프로그램의 입출력은 주로 문자열 형식으로 전달되며, 사람이 읽을 수 있는 데이터를 그대로 받아서 처리하려는 경우에 문자열을 자르고 변환하고 조사하고 합치는 등의 작업은 거의 모든 프로그래머들의 필수적인 소양이며, 그만큼 프로그래밍 분야에서 문자열은 중요한 타입이다.
Swift의 문자열은 인코딩 독립적인 문자의 집합으로 타 언어에서 개별문자의 배열처럼 다뤄지는 것과는 내부적인 동작이 다르다. 따라서 직접적인 정수 인자에 의한 subscription이 불가능한 등의 제약 사항이 많아 보인다. 하지만 유니코드 문자열에 대한 완전한 지원과 유서깊은(?) NSString과의 연계등으로 여러가지 편의 기능들을 공짜로 얻게 되는 부분도 있다. 이 글에서는 Swift의 문자열에 대해서 여러 다양한 방법으로 문자열을 생성하는 것부터 시작해서 기본적인 변환/조작과 실제로 사용할 수 있는 몇 가지 예제들에 대해서 살펴보도록 하겠다.

문자열 생성

String값을 만드는 방법에 대해서 알아보자. 기본적으로 따옴표를 이용한 리터럴로 쓰는 방법과 String() 생성자를 사용하는 방법으로 나눠질 것이다.

문자열 리터럴

문자열 데이터의 생성은 기본적으로 문자열 리터럴을 사용한다. 많은 언어들과 같이 겹따옴표로 둘러싼 형태로 문자열을 만들 수 있다.

let greet = "Hello World"

Swift의 문자열은 Swift 언어차원에서 제공되는 원시 타입으로 볼 수 있으므로, 기본적으로 문자열 리터럴은 String 타입을 만든다. 하지만 NSString, NSMutableString은 자동으로 문자열 타입과 브릿징된다.

문자열 리터럴은 기본적으로 문자열 타입 데이터를 생성하지만, 변수의 타입에 따라서는 NSString 문자열이 될 수 있다. 글고 이 두 타입은 간단하게 as 연산자를 통해서 언제든 상호교환될 수 있다.

let string: String = "123"
let bridgedString: NSString = string as NS
if let number = Int(bridgedString as String) {
  print("\(bridgedString) could be converted to Int: \(number)")
}

브릿징과 관련된 문제점

3.0.1 버전 기준의 공식문서에 따르면 다음과 같은 내용이 있다.

Swift의 String 타입은 파운데이션의 NSString 클래스와 브릿징됩니다. 파운데이션은 또 한편으로 String 타입을 확장하여 NSString에서 정의한 메소드를 사용할 수 있게 합니다. 이는 파운데이션을 임포트하면, 별다른 캐스팅 없이도 String 타입에서 NSString의 메소드를 사용할 수 있다는 의미입니다.
파운데이션과 코코아에서 String 타입을 사용하는 부분에 관한 더 자세한 정보는 Working with Cocoa Data Types를 참조하세요.

브릿징1은 일종의 공짜 변환인데, 이는 타입 자체가 자동으로 변환되는 것이 아니라 타입과 관련을 맺는 API들이 자동으로 호환 대상 타입을 위한 타입으로 변환된다는 뜻이다.
그리고 여기서 참조하라고 하는 해당 페이지에서는 “You can create an NSString object by casting a String value using the as operator.”라는 설명과 함께 다음과 같은 코드가 있다.

import Foundation
let string: String = "abc"
let bridgedString: NSString = string as NSString

하지만 해당 기능은 실제로 실행되지 않는다. (결과) 단 이 테스트는 Linux 시스템에서 시행한 것으로 과연 macOS상의 Swift 3.0 에서도 같을지는 다시 확인이 필요하다.2
해당 페이지에는 아래와 같은 노트가 붙어있다.

Swift의 String 타입은 인코딩 독립적인 유니코드 문자들로 구성되며, 다양한 유니코드 표기에 의해 이러한 문자들에 접근할 수 있는 방법들을 지원합니다. NSString 클래스는 유니코드 호환 텍스트를 인코딩하며, 일련의 UTF-16 코드 유닛들로 표현됩니다. 문자열의 길이, 특정 문자의 위치, 부분 범위를 나타내는 NSString의 표기들은 모두 16비트 플랫폼엔디언 값으로 표현되며 Swift의 String 타입의 메소드들은 정수나 범위가 아닌 String.Index 기반의 값을 사용하게 됩니다. 원문

리눅스 컴파일러의 문제

위에서 언급한 대로 리눅스 컴파일러는 String, NSString 간의 변환을 바로 할 수 없다. 대신에 NSStringinit(string:)을 사용하여 변환한다.

// needs Foundation
let msg = "hello world"
//: `String` -> `NSString`
let bridgedString: NSString = NSString(string: msg)
//: `NSString` -> `String`
let string: String = String(describing: bridgedString)

인터폴레이션(내삽)

어떤 스크립트 언어들은 문자열 리터럴 내에 변수를 집어 넣고 변수 값을 확장하여 문자열로 변환하는 기능을 지원한다. 이를 interpolation이라 하는데 Swift는 이를 지원해준다. 단, 이는 암묵적으로 문자열로 변환이 가능한 타입인 경우에만 가능한데, 모든 Swift 기본 타입들은 이를 지원한다. 이를 지원하는 커스텀 타입을 만들기 위해서는 CustomStringConvertible 프로토콜을 따르도록 하면 된다. 인터폴레이션은 문자열 리터럴 내에서 역슬래시와 괄호를 이용한다.

let year = 2016
let month = 11
let day = 26
print("Today is \(year)-\(month)-\(day)")

임의 타입의 값으로부터 생성하기

값으로부터 생성하는 것은 String( )으로 특정 값을 감싸서 문자열로 만드는 것이다. Swift의 기본 타입들은 모두 이 변환이 가능하며, (사실 인터폴레이션이 가능한 타입들은 다 된다고 보면 된다.) 그외에 이를 통한 직변환이 불가능한 타입들은 String(describing:)을 이용해서 변환하면 된다.

포맷으로부터 생성하기(FND)3

NSString은 기본적으로 Objective-C의 문자열 리터럴을 지원하고 있으며, Objective-C 는 C이기 때문에 C의 리터럴 중 하나인 포맷으로부터 문자열을 만드는 것을 지원한다.
printf()와 완전히 동일한 형태로 사용하거나 아니면 인자들을 하나의 배열에 넣어 사용하는 방법도 지원한다. 그외에 파운데이션 확장에는 로케일 정보를 추가하는 방법도 있다.

  • init(format:_:)
  • init(format:arguments:) (Array 타입으로 인자들을 전달한다.)
  • init(format:locale:_:)
  • init(format:locale:argument:)
// needs Foundation
let i = 13.456
let s = String(format: "value: %.4f", i)
// "value: 13.4560"

반복되는 패턴으로 생성하기

줄문자 등을 출력하기 위해서는 init(repeating:count:)를 사용할 수 있다.

// needs Foundation
let line = String(repeating:"-", count: 80)
print(line)
//"--------------------------------------------------------------------------------"

데이터로부터 생성하기

컴퓨터 프로그램 속에 존재하는 모든 것이 데이터이기는 하지만 여기서는 좁은 의미에서는 Data(NSData)로부터 시작해서 C의 문자배열이나 포인터, 혹은 그와 유사하게 Swift 내의 포인터나 Array<UInt8> 속에 들어있어서 결국에 문자열로 변환할 수 있는 여러가지 경우를 생각해보겠다. 여기서의 데이터는 대체로 저장/전송/전달받은 바이트 버퍼를 의미하며, 그 소스가 어디에 어떤 형태로 존재하는가에 따라서 약간 다른 API를 사용한다는 것을 보여준다.

NSData로부터

NSData/Data는 일련의 바이트 데이터를 저장하고 있는 메모리 공간을 객체화하여 감싸고 있는 데이터 타입으로 저수준의 메모리 관리 및 포인터 접근등을 배제하고 데이터를 보관, 저장, 전송하는 고수준 API를 위한 데이터 버퍼에 대한 래퍼타입이다. 4 NSStringNSData로부터 생성가능하듯, Stringinit?(data:encoding:)으로 생성될 수 있다.

// needs Foundation
let data = Data( .. )
let string: String? = String(data:data, encoding: .utf8)

포인터

드물기는 하지만 Swift 에서도 포인터를 간접적인 방법이나마 다룰 때가 있다. 포인터에 대해서 Data 타입으로 래핑한다음에 init?(data:encoding)을 써도 되지만 한번에 init(cString:)을 이용할 수 있다. 이 때 포인터는 CChar 타입(이는 Int8의 다른 이름이다.)이거나 UInt8 타입이다. "hello"의 각 글자의 아스키코드가 104, 101, 108, 108, 111 이므로 이로부터 hello를 생성하는 코드를 통해서 사용법을 살펴보자.

// needs Foundation
let ptr = UnsafeMutablePointer<UInt8>.allocate(capacity: 5)
var q = ptr
// 버퍼내에 각 글자의 코드값 입력
for i in [104, 101, 108, 108, 111] as [UInt8] {
    q.pointee = i
    q += 1
}
let string = String(cString: ptr)
print(string) // "hello"
ptr.deallocate(capacity: 5)

NSString역시 init?(cString:encoding:)을 지원한다. 차이점은 String 타입은 Cstring 이 항상 UTF8 타입임을 상정하는데 반해서 NSString은 인코딩을 선택해야 하며, 해당 데이터로부터 디코딩이 실패하는 경우를 대비하여 옵셔널 타입을 리턴한다는데 있다. 또한 NSString에는 비슷한 init?(utf8String:)도 있다.

포인터 배열

C에서 포인터는 배열의 시작번지로부터 특정 원소를 오프셋으로 참조하는데 사용되며, 이는 UnsafePointer를 이용해서 오프셋을 옮겨가며 액세스하는 것이 가능하다. Swift는 이보다 좀 더 안전하게 특정 영역의 메모리 버퍼를 마치 배열처럼 액세스하게 해주는 UnsafeBufferPointer 타입을 제공한다.
버퍼 포인터의 .baseAddress 를 이용해서 시작 번지를 얻고, 이를 init(cString:)으로 활용하는 방법도 있지만, 버퍼자체를 이용하는 방법이 있는데, NSStringinit?(bytes:encoding:)을 사용하는 것이다. 아래는 그 예제이다.

import Foundation
let v: [UInt8] = [104, 101, 108, 108, 111]
// 반드시 메모리를 할당해줘야 한다. nil을 시작번지로 버퍼를 만들면 안된다.
let ptr = UnsafeMutablePointer<UInt8>.allocate(capacity: 5)
let bPtr = UnsafeMutableBufferPointer<UInt8>(start:ptr, count: 5)
for (i, c) in v.enumerated() {
    bPtr[i] = c
}
// 시작번지를 가져와서...
let string = String(cString:bPtr.baseAddress!)
print(string) // "hello"
if let string2 = String(bytes:bPtr, encoding: .utf8) {
  print(string2) // "hello"
}

동작 코드의 주소 : http://swiftlang.ng.bluemix.net/#/repl/5819913bf9f5f14d876a304c

코드값의 배열

흥미로운 부분은 init?(bytes:encoding:)의 시그니처이다. 이 메소드는 다음과 같이 정의되어 있다.

// needs Foundation
init?<S: Sequence where S.Iterator.Element == UInt8>(bytes: s, encoding: String.Encoding)

따라서 위 예제에서 bPtr은 마치 배열처럼 bPtr[0] 과 같이 각 원소값을 얻을 수 있기 때문에 변환이 가능했는데, 그렇다면 [UInt8] 타입에 대해서도 변환은 바로 가능하다는 점이다.

import Foundation
let v: [UInt8] = [104, 101, 108, 108, 111]
if let string = String(bytes:v, encoding:.utf8) {
    print(string) // "hello"
}

따라서 대소문자 변환이나 개별 문자를 코드값 수준에서 다루는 함수의 경우에 결과를 배열로 만들고 문자열로 변환하는 등의 처리가 가능하다.

코드값 배열의 트릭

Swift에서 C API를 다룰 때 특정한 타입의 포인터를 받는 C 함수가 있다고 하면, 아마 원래의 C함수는 배열 같은 걸 인자로 받았을 것이다.5 만약 UnsafePointer<CChar> 타입을 인자로 받는 API를 호출한다고 하면, Swift는 실제 포인터를 만들어서 넘겨줄 수 도 있지만, 그냥 [CChar] 값을 넘겨주어도 된다. (그러면 내부적으로 Swift 컴파일러가 이 부분을 알아서 처리해준다.) 그렇다면 String.init(cString:)UnsafePointer<UInt8> , UnsafePointer<CChar> 를 인자로 받으니, [UInt8]을 여기에 그냥 사용해도 된다는 소리잖아?

let s = "hello world"
let cv:(UInt8) -> UInt8 = { (97..<122) ~= $0 ? $0 + 1 : $0 }
print(String(cString: s.utf8.map(cv))) // "ifmmp xpsme"

이제 .map{ Character(UnicodeScalar($0)) } 같은 거 쓰지 않아도 된다.

파일, URL 리소스

텍스트 파일이나 웹서버로부터 전달받은 데이터들은 주로 UTF8이나 그외 인코딩으로 텍스트를 인코딩한 이진데이터이다. 위에서 언급한 NSData는 이들 데이터를 메모리로 읽어와서 그 메모리를 NSData로 감싼 것이다. 따라서 파일을 열고 데이터를 읽거나, 네트워크 포트를 열고 외부 시스템으로부터 데이터를 읽어오는 과정을 앞단에 붙여주면 똑같은 방식으로 파일과 URL로부터 문자열을 읽어올 수 있다.
이 때 URL은 로컬 디스크상의 파일 URL일 수도 있고, 네트워크 상의 URL일 수도 있다.

// fileurl
if let url = URL(fileURLWithPath: "./Resources/mytext.txt") {
    if let string = String(contentsOf:url, encoding:.utf8) {
      print(string)
    }
}
if let path = url?.path {
    if let string = String(contentsOfFile: path, encoding: .utf8) {
        print(string)
    }
}

그외

Character 는 한 글자의 유니코드 글자를 나타내는데6 생성시에 문자열과 똑같은 리터럴을 사용하며 한글자짜리 문자열이나 다름없기 때문에 String(ch)와 같이 바로 변환이 가능하다.
문자열은 내부적으로 String.CharacterView, String.UnicodeScalarView, String.UTF8View, String.UTF16View 등의 표현형을 가질 수 있고, 이런 표현형들은 모두 배열과 비슷한 시퀀스 타입이며, 따라서 부분열을 가져다가 문자열로 생성할 수 있다.

let string = "hello world"
let str2 = String(string.unicodeScalars.prefix(8))
print(str2) // "hello wo"

재밌는 점은 [Character] 타입은 즉시 문자열로 변환 가능한데 비해서 [UnicodeScalar] 타입은 그렇지 않다는 것이다. 따라서 [UnicodeScalar] 타입의 데이터를 가지고 있다면 Character 로 변환하는 맵핑을 한 후에 문자열로 변환해야 한다.

// 대소문자 변환
let convertC: (UnicodeScalar) -> UnicodeScalar? = { x in
    if x.value >= 97 && x.value <= 122 {
        return UnicodeScalar(x.value + 65 - 97)
    }
    return x
}
let s = "hello world"
let t1 = String(s.unicodeScalars.map(convertC).flatMap{ $0 }) ]// Fail (use init(describing:))
let t2 = String(s.unicodeScalars.map(convertC).flatMap{ $0 }.map(Character.init))
//"HELLO WORLD"
let m1 = Mirror(reflecting:s.unicodeScalars.map(convertC).flatMap{ $0 } )
let m2 = Mirror(reflecting:s.unicodeScalars.map(convertC).flatMap{ $0 }.map{ Character($0) } )
debugPrint(m1) // Mirror for Array<UnicodeScalar>
debugPrint(m2) // Mirror for Array<Character>

목차

  1. 문자열 생성방법
  2. 문자열 조작하기
  3. 활용

  1. 아무런 별도 코드 없이 자동으로 A 타입이 B 타입으로 취급되는 것. NSString을 써야 하는 곳에는 String을 넣으면 되고, NSString이 리턴되는 메소드들은 Swift 내에서는 String을 호출한다는 의미로 이해하면 된다. 
  2. 이곳에서 관련 답변을 볼 수 있다. 리눅스 상의 Swift 컴파일러는 as 로 캐스팅할 수 없으며, NSString(string:)으로 별도로 생성해야 한다는 내용이 있다. 
  3. (FND)가 붙으면 파운데이션에서만 가능한 방법이라는 의미이다. 
  4. 자세한 것은 NSData 레퍼런스를 참고하자. 
  5. C언어에서는 int sum(int[]) 따위로 배열타입으로 인자를 코딩해도 실제로 컴파일 되는 결과는 const int*이다. 
  6. Character 자체는 하나의 단위로 취급하지만 유니코드 문자 1개는 같은 바이트 길이를 갖지 않음을 명심 또 명심할 것.