본문 바로가기

개발/Swift

6. 문자열과 텍스트 처리

여기서는 스위프트가 문자열을 어떻게 나타내고 처리하는지에 대해서 알아보도록 하겠다.

1. 문자열 기본 사용법

스위프트에서 문자열은 String 타입으로 표현된다.
Character 타입도 있지만, 보통은 문자열 단위로 사용하는 경우가 많기 때문에 대부분 String을 사용하게 된다.
문자열을 생성하는 가장 일반적인 방법은 문자열 리터럴을 사용하는 것이다.
실제 코드에서도 대부분 이 방식을 기준으로 문자열을 다룬다.

 

let greeting = "Hello"

 

큰따옴표(" ")로 감싼 텍스트는 String 값으로 해석되며,
컴파일러가 자동으로 문자열 타입임을 추론한다.

문자열 생성

문자열 리터럴

let text = "Swift String"
let text: String = "Swift String"

 

가장 직관적이고 읽기 쉬운 방식이다.
짧은 텍스트부터 일반적인 문장까지, 대부분의 문자열은 이 형태로 작성된다.

멀티라인 문자열

여러 줄의 문자열을 표현해야 할 경우, 멀티라인 문자열 리터럴을 사용할 수 있다.

 

let message = """
Hello,
This is a multiline string.
"""

 

세 개의 큰따옴표(""")로 감싸며, 줄바꿈과 공백이 코드에 작성된 그대로 문자열에 포함된다.
긴 설명 문구나 포맷이 중요한 텍스트를 다룰 때 유용하다.

 

이 멀티라인은 제법 최신의 언어에 추가된 기능이다보니 익숙하지 않아서 몇 가지 놓치기 쉬운 포인트들이 있는데
멀티라인 문자열은 비교적 나중에 추가된 기능이라 처음 사용할 때 놓치기 쉬운 규칙들이 몇 가지 있다.

 

// Error
let message = """ Hello,
"""

// OK
let message = """ 
Hello,
"""

 

멀티라인을 시작하는 따옴표 뒤에 바로 내용을 작성할 수 없고, 반드시 다음 줄부터 문자열이 시작되어야 한다.

 

let message = """
    Hello,
    This is a multiline string.
    Good bye!
  """

print("1234567890")
print(message)

// 실행결과
1234567890
  Hello,
  This is a multiline string.
  Good bye!


// Error
let errorMesssage = """
닫는 따옴표가 문장보다 뒤에 있는 것은 허용하지 않는다.
    """

 

들여쓰기는 닫는 따옴표의 위치에 따라 결정된다.
위의 message의 닫는 따옴표 위치를 기준으로 전체 문장의 들여쓰기가 결정되는데,
이 규칙에 대한 자세한 설명은 스위프트 공식 문서에 잘 나와있으니 그부분을 참고하면 된다.

특수 문자 표기하기

특수 문자, 특수 기호, 유니코드 등을 표현하는 방법 또한 스위프트는 지원한다.

스위프트 문자열은 다음과 같은 특수 문자들을 리터럴에 포함시킬 수 있다.

 

  • 이스케이프된 문자 \0(null 문자), \\(역슬래시), \t(수평 탭), \n(개행), \r(캐리지 리턴), \"(쌍따옴표)와 \'(홑따옴표)
  • 임의의 유니코드 스칼라 값은 \u{n} 형식으로 작성되며, 여기서 n은 1–8 자리의 16진수 숫자

 

let wiseWords = "\"Imagination is more important than knowledge\" - Einstein" 
// "Imagination is more important than knowledge" - Einstein 
let dollarSign = "\u{24}" // $, Unicode scalar U+0024 
let blackHeart = "\u{2665}" // ♥, Unicode scalar U+2665 
let sparklingHeart = "\u{1F496}" // 💖, Unicode scalar U+1F496

 

#""# 로 된 리터럴을 사용하면 어떠한 특수문자가 있어도 문자 그대로 표시해준다.

 

let str = #"\" \n \u{1F496}"#
print(str)

// 실행결과 
\" \n \u{1F496}

let multiLineStr = #"""
\" 
\n 
\u{1F496}
"""#
print(multiLineStr)

// 실행결과
\" 
\n 
\u{1F496}

 

스위프트에서는 #""# 이것을 확장된 문자열 구분기호 (Extended String Delimiters) 라고 부른다.

빈 문자열 초기화

빈 문자열은 다음과 같은 방식으로 생성할 수 있다.

 

let empty1 = ""
let empty2 = String()

 

두 방식은 동일한 의미를 가지며,
초기값이 없음을 명시적으로 표현하고 싶을 때 자주 사용된다.

문자열 수정과 불변성

문자열 역시 다른 값 타입과 마찬가지로
let과 var에 따라 불변(immutable) 과 가변(mutable) 이 결정된다.

 

let constantString = "Hello"
// constantString += " World" // 컴파일 에러

var variableString = "Hello"
variableString += " World"

 

let으로 선언된 문자열은 생성 이후 변경할 수 없고,
var로 선언된 문자열만 내용을 수정할 수 있다.

값 타입(Value Type)

스위프트의 String 타입은 값 타입에 속한다.
값 타입에 대한 설명은 구조체와 클래스 파트에서 다룰 예정이라 여기서 자세한 설명은 생략한다.

 

요점만 간단히 정리하면 다음과 같다.
값 타입으로 된 값은 변수, 상수에 할당될 때, 함수나 메소드에 전달 될 때 복사되어 전달 된다는 것이다.

 

그래서 String은 값 타입이라 다른 변수 등에 대입하거나, 함수 등에 전달되면 원본은 두고 새 복사본이 생성되어 그 값이 전달된다는 것이고, 이로 인해 한 쪽을 수정하더라도 다른 쪽에는 영향을 끼치지 않는다.

 

다만, 위의 설명에서 복사에 대한 부분은 90% 정도만 맞는 설명인데 그 이유는 다른 챕터에서 설명하도록 하겠다.

2. 유니코드와 문자

유니코드란?

유니코드(Unicode)는 전 세계의 모든 문자를 어느 환경에서든 일관되게 표현할 수 있도록 설계된 국제 표준이다.
이 유니코드는 유니코드 협회(Unicode Consortium)에서 관리하고 제정한다.

 

유니코드는 거의 모든 언어의 문자를 표준화된 형식으로 정의하며,
텍스트 파일이나 웹 페이지 같은 외부 소스에서 문자를 읽고 쓰는 것을 가능하게 해준다.

 

스위프트의 StringCharacter 타입은 이러한 유니코드 표준을 완벽하게 지원한다.

 

여기서는 스위프트가 유니코드를 어떻게 처리하는지에 대해서만 설명할 예정이니, 유니코드 자체에 대해서 더 궁금증이 있다면 각자 찾아보길 바란다.

유니코드 스칼라 (Unicode Scalar)

유니코드에서 각 문자는 하나 이상의 유니코드 스칼라 값(Unicode Scalar Value)으로 표현된다.
유니코드 스칼라는 유니코드 표준에 정의된 가장 기본적인 코드 포인트 단위다.
실제로 글자로 표현될 수 있는 값이라고 이해하면 된다.

 

예를 들어 다음과 같은 문자들은 각각 고유한 유니코드 스칼라 값을 가진다.

 

let scalarA: UnicodeScalar = "A"        // U+0041
let scalarHeart: UnicodeScalar = "❤"    // U+2764

 

스위프트에서 UnicodeScalar는 개별 코드 포인트를 직접 다루고 싶을 때 사용하는 타입이며,
일반적인 문자열 처리에서는 잘 드러나지 않지만 문자열 내부 표현의 가장 낮은 레벨에 해당한다.

 

정리하면, 유니코드로 나타낼 수 있는 모든 문자들이 모인 표가 있고, 그 표에 매핑되는 좌표값을
유니코드 스칼라라고 이해하면 된다.

확장된 그래핌 클러스터 (Extended Grapheme Cluster)

사람이 인식하는 하나의 “문자”는
반드시 하나의 유니코드 스칼라로 이루어져 있는 것은 아니다.

 

여러 개의 유니코드 스칼라가 결합되어 하나의 글자로 보이는 경우도 많다.
이렇게 사람이 인식하는 최소 단위의 문자를 그래핌 클러스터(Grapheme Cluster) 라고 한다.

 

let char: Character = "é"

let eAcute: Character = "\u{E9}" // é
let combinedEAcute: Character = "\u{65}\u{301}" // e + ◌́  → é

 

이 é 문자는 다음 두 가지 방식으로 표현될 수 있다.


• 단일 스칼라: U+00E9
• 결합 형태: U+0065 (e) + U+0301 (´)

 

겉보기에는 완전히 동일하지만, 내부적으로는 서로 다른 스칼라 조합일 수 있다.

 

이 방식은 결합 문자 시퀀스(Combining character sequence)라고 부른다.
U+0301 (´) 같은 문자를 다이어크리틱이라고 부르는데 이 부분에 대한 설명은 이 주제의 범위를 벗어나므로 생략하도록 하고,
저런 기호의 존재 의의는 모든 글자에 저런 기호를 붙여서 유니코드 문자표에 넣을 수 없으니 위와 같은 방식으로 조합해서 간편하게 하기 위함이다.

 

위와 같이 여러 개의 유니코드 스칼라로 이루어져 있지만 실제 나타나는 글자는 하나인 이런 방식을 그래핌 클러스터라고 하는 것이다.

 

그런데 스위프트의 Character 타입의 인스턴스는 하나의 확장된 그래핌 클러스터를 지원한다.
여기서 확장되었다는 의미는 위의 결합 기호 묶음 외의 더 다른 묶음을 지원한다는 뜻이다.

 

그 중에 하나인 Zero Width Joiner(ZWJ)라는 방식을 소개하겠다.

 

let fireWoman = "\u{1F469}\u{200D}\u{1F692}"
print(fireWoman)

// 실행결과  
👩‍🚒

 

fireWoman의 유니코드 각각을 뜯어보면 이렇다.

 

  • 👩 U+1F469 여자
  • ‍ U+200D ZWJ (Zero Width Joiner)
  • 🚒 U+1F692 소방차

👩 + ZWJ + 🚒 이런 구성이 되는데, 이런 패턴들이 유니코드 표준에 등록되어서 알아서 👩‍🚒 이 이모지로 렌더링 되는 것이다.

 

독립적으로 그려지는 둘 이상의 문자를 또 다른 하나의 새로운 상징으로 보이게 하고 싶을 때 쓰는 것이라고 하며 이런 이모지는 직업, 가족, 관계 표현, 커플/연인 표현, 성별 표현, 국가 표현 등에서 사용된다고 한다.

 

그래서 중요한건 스위프트의 String과 Character 타입은 확장된 그래핌 클러스터를 지원하기에
위의 예시처럼 여러 복잡한 유니코드 스칼라들을 묶어서 하나의 문자로 보여주는 것이 가능하다는 것이다.

 

스위프트의 Character 타입은 이러한 차이를 감추고, 항상 사람이 인식하는 문자 단위로 동작하도록 설계되어 있다.

문자열의 길이와 count

위에서 봤듯이 이러한 이유로, 문자열의 “길이”는 단순한 바이트 수나 스칼라 개수와 일치하지 않는다.

 

let word = "👨‍👩‍👧‍👦"
print(word.count) // 1
print(word.unicodeScalars.count) // 7

let word1 = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}"

print(word1) // 👨‍👩‍👧‍👦
print(word == word1) // true

 

위 이모지는 7개의 유니코드 스칼라로 구성되어 있지만,
하나의 그래핌 클러스터로 인식되기 때문에 count는 1을 반환한다.

 

스위프트에서 String.count는 유니코드 스칼라 수가 아니라, 그래핌 클러스터의 개수를 의미한다.

 

이 특성 덕분에 문자열을 사람의 인식 기준으로 안전하게 다룰 수 있지만,
문자열 인덱스를 정수로 직접 접근할 수 없는 이유도 바로 여기에 있다.

 

이 이야기는 다음 섹션에서 다루는 문자열 인덱스와 서브스트링에서 이어서 설명한다.

3. 문자열 인덱스

위에서 살펴본 것 처럼 스위프트의 문자열은 단순한 문자 배열이 아니라 유니코드 그래핌 클러스터를 기반으로 구성된다.

이 특성 때문에 문자열은 배열처럼 정수 인덱스로 접근 할 수 없다.

 

let text: String = "café"
print(text[0]) // 'subscript(_:)' is unavailable: cannot subscript String with an Int, use a String.Index instead.

 

접근하려고 하면 위와 같은 컴파일 오류가 발생한다.

그 이유는 위의 문자들이 단일의 유니코드 스칼라 일 수도 있고, 여러 스칼라가 결합된 그래핌 클러스터 일수도 있기 때문이다.

 

컴파일러 입장에서 n번째 문자가 메모리 상에 어디에 위치하는지 알 수 없다는 것 때문에 발생하는 문제인 것이다.

 

이 때문에 스위프트는 문자열 접근에 정수 인덱스를 허용하지 않고, 대신 전용 인덱스 타입을 사용하도록 설계했다.

문자열 인덱스 (String Indices)

위에서 언급했듯이, 서로 다른 문자들은 저장하는 데 필요한 메모리의 양이 다를 수 있므로, 특정 위치에 어떤 Character가 있는지 알아내려면 해당 문자열의 시작이나 끝에서 부터 유니코드 스칼라 단위로 순회해야 한다.
그래서 Swift 문자열은 정수 인덱스로 접근할 수 없는 것이다.

 

스위프트는 String 타입에 String.Index 라고 하는 전용 인덱스 타입이 정의하여 이를 사용하도록 하였다.
이 인덱스 타입은 문자열 안에 있는 각 Character 타입 값의 위치를 나타낸다.

 

let greeting = "Guten Tag!" 
greeting[greeting.startIndex] // G 
greeting[greeting.index(before: greeting.endIndex)]  // !  
greeting[greeting.index(after: greeting.startIndex)] // u 

let index = greeting.index(greeting.startIndex, offsetBy: 7) 
greeting[index] // a

String.Index는 문자열의 시작과 끝을 알려주는 startIdexendIndex를 제공한다.
String의 메서드 index(before:)index(after:)를 사용하여 주어진 인덱스의 전과 후에 접근할 수 있다.
주어진 인덱스에서 먼 인덱스에 접근하려면 이러한 메서드를 여러번 호출하는 대신 index(_:offsetBy:) 메서드를 사용할 수 있다.

 

참고로 endIndex는 문자열의 끝의 다음을 나타내기 때문에 아래처럼 사용하면 에러를 만날 수 있다.
(C의 char[]가 NULL로 끝나는데 그걸 생각하면 된다)

 

greeting[greeting.endIndex] // Error 
greeting.index(after: greeting.endIndex) // Error

greeting[greeting.index(before: greeting.endIndex)]

 

String은 RandomAccess가 아니다

이러한 연유로 스위프트 String은 Random Access를 지원하지 않는다.
정확히 말하면, 그래핌 클러스터 기반 구조이기 때문에 “n번째 문자로 즉시 이동한다”는 개념 자체가 성립하지 않는다.
실제로 String은 RandomAccessCollection이 아니라 BidirectionalCollection 프로토콜을 채택하고 있다.
(RandomAccessCollection와 BidirectionalCollection 프로토콜에 대해서는 추후 다뤄보도록 하겠다.)

 

그래서 String은 정수 인덱스 대신 String.Index라는 전용 타입을 사용해 양방향 순회를 제공한다.

String.count는 비싼 연산이다

Swift의 String은 내부적으로 UTF-8 기반의 가변 길이 인코딩을 사용한다.
그 안에 들어가는 Character는 그래핌 클러스터이므로 각 문자의 크기가 일정하지 않다.

 

즉, 문자열은 고정 크기 요소로 구성된 배열이 아니라 가변 길이 데이터를 담은 버퍼에 가깝다.
이 때문에 문자열의 개수를 세려면 처음부터 끝까지 그래핌 클러스터 단위로 순회해야 하며, count의 시간 복잡도는 O(n)이 된다.
물론 이런 경우는 문자열이 복잡하고 충분히 긴 경우에만 해당되며 어지간히 짧은 문자열은 O(1)에 수렴하긴 한다.

 

이러한 구조 때문에 String.count는 생각보다 비용이 큰 연산인 것이다.

4. 부분 문자열(Substring)

문자열을 다루다 보면 문자열의 일부를 잘라서 사용해야 하는 경우가 자주 생긴다. 그런데 String 타입에서 제공하는 메소드를 통해 문자열을 잘라내면 그 문자열은 String 타입이 아닌 Substring이라는 타입으로 주어지는 것을 볼 수 있다.

 

이 Substring이란 타입은 무엇일까?

Substring이란?

Substring은 말 그대로 문자열의 일부를 가리키는 타입이다.
부분 문자열을 생성하게 되면 이 부분 문자열은 내부에 별도의 저장공간을 생성하지 않고 원본 문자열의 저장 공간을 공유하고 범위 정보만 따로 기록한다.
그래서 정말 말 그대로 문자열의 일부를 가리킨다고 표현한 것이다.

 

let greeting = "Hello, world!" 
let index = greeting.firstIndex(of: ",") ?? greeting.endIndex 
let beginning = greeting[..<index] // beginning is "Hello" 

// Convert the result to a String for long-term storage. 
let newString = String(beginning)

 

그리고 부분 문자열을 String으로 변환하면 원본 문자열과는 분리된 메모리 공간을 갖게 된다.

의의와 한계

Substring 설계의 목적은 성능 최적화에 있다.
부분 문자열은 원본 문자열의 저장소를 공유하며, 범위 정보만 따로 보관한다. 이 덕분에 별도의 복사 없이 생성할 수 있어 읽기 작업은 효율적이다.

 

또한 String과 거의 동일한 인터페이스를 제공하므로 String과 동일한 경험으로 부분 문자열을 사용할 수 있다.

 

그러나 이러한 저장소 공유 구조는 한계도 함께 가진다.
Substring을 오래 보관하면, 원본 문자열이 더 이상 직접 참조되지 않더라도 그 저장 공간이 함께 유지될 수 있다.
이로 인해 더 이상 필요하지 않은 문자열 데이터가 메모리에 남아 있어, 마치 메모리 누수처럼 보일 수 있다.
스위프트 공식문서에서도 이 점을 강조하고 있으니 부분 문자열은 필요한 경우에만 사용하도록 하자.

 

한편, Substring은 항상 저장소를 공유하는 것은 아니다.
String으로 변환하거나 var로 선언된 Substring을 수정하는 순간에는 새로운 저장 공간이 생성되어 원본과 분리된다. 이는 Swift가 값 타입의 성능을 높이기 위해 사용하는 Copy-On-Write(COW) 전략에 따른 동작이다. 이에 대한 자세한 내용은 추후 다룰 예정이니 참고만 하자.

5. 문자열 보간과 그 외

4번까지는 String 자체에 대해서 이해를 높이는 내용이었다면 여기서는 String 사용법에 대해서 간단하게 알아보도록 하겠다.

문자열 보간(String Interpolation)

문자열 보간(String interpolation)은 상수, 변수, 리터럴, 문자열 리터럴에 값이 포함된 표현식을 혼합해 새로운 String 값을 생성하는 방법이다.
문자열 보간은 한 줄과 여러 줄 문자열 리터럴에서 사용할 수 있다.
문자열 리터럴에 추가하는 방법은 역슬래시(\)를 접두사로 소괄호를 감싸서 추가한다.

 

let multiplier = 3 
let message = "\(multiplier) times 2.5 is \(Double(multiplier) * 2.5)" 
// message is "3 times 2.5 is 7.5"

 

\( ) 안에는 표현식도 넣을 수 있으며, 들어간 표현식은 문자열로 변환되어 결과 문자열에 포함된다.
표현식은 단순 변수뿐 아니라, 연산식이나 함수 호출도 사용 가능하다.

 

print(#"Write an interpolated string in Swift using \(multiplier)."#) 

// 실행결과 
Write an interpolated string in Swift using \(multiplier).

 

6.1에서 언급했듯이 #""# 로 감싼 리터럴은 보간도 무시된다.

 

print(#"6 times 7 is \#(6 * 7)."#)

// 실행결과
6 times 7 is 42.

 

하지만 \( ) 사이에 위의 코드 처럼 # 을 넣어주게 되면 보간이 정상적으로 동작하게 된다.

문자열 관련 메소드

String은 타입이면서 컬렉션이기도 해서 다양한 전용 메소드들을 제공한다.
여기서는 자주 사용하는 것들만 소개하겠다.

 

var text = "Hello, Swift"

// 포함 여부
text.contains("Swift") // true

// 접두사 / 접미사
text.hasPrefix("Hello") // true
text.hasSuffix("Swift") // true

// 대소문자 변환
text.uppercased() // HELLO, SWIFT
text.lowercased() // hello, swift

// 분리 (Split)
text.split(separator: ",")        // ["Hello", " Swift"]

// 치환 (Replace)
text.replacingOccurrences(of: "Swift", with: "World")
// "Hello, World"

// 검색 (Search)
text.firstIndex(of: ",")          // Optional(String.Index)
text.range(of: "Swift")           // Optional<Range<String.Index>>

// 삽입 (Insert)
if let commaIndex = text.firstIndex(of: ",") {
    text.insert("!", at: commaIndex)
}
// "Hello!, Swift"

let endIndex = text.endIndex
text.insert(contentsOf: " 🚀", at: endIndex)
// "Hello!, Swift 🚀"

// 비교 (Equal)
let a = "Swift"
let b = "Swift"

a == b                  // true
a.elementsEqual(b)      // true

 

이 외에도 텍스트 처리를 위한 많은 API가 존재하니 궁금하면 관련 문서를 찾아보도록 하자.
참고로, String을 비교할때 두 String 값(또는 두 Character 값)은 확장된 그래프임 클러스터가 정규적으로 동일한 경우 두 값이 같다고 처리한다.

문자열 변환(Conversion)

/* String -> 다른 타입 */
let number = 1
let text = String(number) // "1"

let doubleNum = 1.0
let text = String(doubleNum) // "1.0" 

/* 다른타입 -> String */
let text = "1"
let num = Int(text) // Optional(1)

let text = "1.0"
let num = Double(text) // Optional(1.0)

let text = "true"
let bVal = Bool(text) // Optional(true)

 

스위프트의 문자열은 다른 기본 데이터 타입으로 변환할 수 있다.
반대로 다른 기본형 타입도 문자열로 변환이 가능하다. 위에서 설명한 보간를 사용하기도 하고, String의 생성자를 이용해서 변환하기도 한다.

 

let text = "a"
let num = Int(text) // nil

let text = "True"
let bVal = Bool(text) // nil

 

문자열을 다른 기본 타입으로 변환할때 주의해야할 점은 위의 예제처럼 바꿀 수 없는 경우, 변환이 실패하기 때문에 반환 결과는 모두 Optional로 반환된다는 점이다.

그래서 위의 성공한 변환 결과도 모두 Optional 타입으로 되어있는 것을 명심하자.

 

추가로, 문자열을 Bool 타입을 변환하는 경우 "true", "false" 이 두 경우 외에는 모두 nil로 실패하니 기억해두자.

'개발 > Swift' 카테고리의 다른 글

7. 구조체와 클래스  (0) 2026.02.26
5. 컬렉션  (1) 2026.01.28
4. 클로저  (0) 2026.01.23
3. 함수  (0) 2026.01.23
2. 제어문  (0) 2026.01.17