-
NSAttributedString Performance OptimizationSwift 2025. 12. 6. 08:59
안녕하세요. 그린입니다 🍏
UITextView나 UILabel에서 수천 줄의 텍스트를 다룰 때 앱이 버벅이거나 메모리가 터지는 경험 있으신가요?
이번 포스팅에서는 NSAttributedString의 성능 최적화에 대해 다뤄보겠습니다.
Apple 엔지니어의 공식 답변을 토대로 방법들을를 정리해봤어요.

Attribute Dictionary는 자동으로 중복 제거
가장 먼저 알아야 할 사실은 NSAttributedString은 내부적으로 동일한 attribute dictionary를 자동으로 uniquify합니다.
Apple 엔지니어의 공식 답변
2015년, Apple의 TextKit 엔지니어 Aki Inoue가 공식 확인해준 대목이 있습니다.
Memory optimization of NSAttributedString
Discussion: Memory optimization of NSAttributedString
cocoa-dev.apple.narkive.com
"Yes, NSAttributedString does unique attribute dictionaries."
실험으로 확인해볼까요?
func testAttributeUniquification() { var array = [NSAttributedString]() let fontManager = NSFontManager.shared // 30개의 NSAttributedString 생성 - 동일한 속성 반복 for index in 1...30 { var font = NSFont(name: "Times", size: 12.0)! font = fontManager.convert(font, toHaveTrait: .boldFontMask) if index % 3 == 0 { font = fontManager.convert(font, toHaveTrait: .italicFontMask) } let para = NSMutableParagraphStyle() para.lineSpacing = 1.4 para.alignment = .center let attrStr = NSAttributedString( string: "Para \(index)", attributes: [.font: font, .paragraphStyle: para] ) array.append(attrStr) } // 중복 확인 var uniqueFonts = Set<NSFont>() var uniqueParas = Set<NSParagraphStyle>() for attrStr in array { attrStr.enumerateAttributes(in: NSRange(location: 0, length: attrStr.length), options: []) { attrs, _, _ in if let font = attrs[.font] as? NSFont { uniqueFonts.insert(font) } if let para = attrs[.paragraphStyle] as? NSParagraphStyle { uniqueParas.insert(para) } } } print("Fonts: \(uniqueFonts.count), Paragraphs: \(uniqueParas.count)") // 결과: Fonts: 2, Paragraphs: 1 // 30개가 아니라 2개/1개만 생성됨! }동일한 attributes를 반복 생성해도 시스템이 알아서 재사용하므로 별도 캐싱 불필요합니다.
NSTextStorage 메모리 증가 방지
NSTextStorage를 subclass할 때 가장 흔한 실수입니다.
이슈 코드
// ❌ 메모리 증가 class MyTextStorage: NSTextStorage { private var storage = NSMutableAttributedString() // ⚠️ 이슈 override var string: String { return storage.string } override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] { return storage.attributes(at: location, effectiveRange: range) } override func replaceCharacters(in range: NSRange, with str: String) { storage.replaceCharacters(in: range, with: str) edited(.editedCharacters, range: range, changeInLength: str.count - range.length) } override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) { storage.setAttributes(attrs, range: range) edited(.editedAttributes, range: range, changeInLength: 0) } }20KB 이상 텍스트 → 메모리 기가바이트 단위 증가
해결 방법
// ✅ 솔루션 class MyTextStorage: NSTextStorage { private var storage = NSTextStorage() // NSTextStorage를 backing store로 override var string: String { return storage.string } override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] { return storage.attributes(at: location, effectiveRange: range) } override func replaceCharacters(in range: NSRange, with str: String) { beginEditing() // 필수 storage.replaceCharacters(in: range, with: str) edited(.editedCharacters, range: range, changeInLength: (str as NSString).length - range.length) endEditing() // 필수 } override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) { beginEditing() storage.setAttributes(attrs, range: range) edited(.editedAttributes, range: range, changeInLength: 0) endEditing() } }핵심 3가지가 아래와 같아요.
- NSTextStorage를 backing store로 사용 (NSAttributedString ❌)
- beginEditing()/endEditing() 반드시 호출
- String → NSString 캐스팅 (Emoji 등 처리)
beginEditing/endEditing로 레이아웃 최적화
이슈
// ❌ 비효율적 - 매번 레이아웃 재계산 func applySyntaxHighlighting() { setAttributes([.foregroundColor: UIColor.blue], range: keywordRange1) // 레이아웃 계산 setAttributes([.foregroundColor: UIColor.blue], range: keywordRange2) // 레이아웃 계산 setAttributes([.foregroundColor: UIColor.green], range: stringRange) // 레이아웃 계산 setAttributes([.foregroundColor: UIColor.gray], range: commentRange) // 레이아웃 계산 }최적화
// ✅ 효율적 - 한 번만 레이아웃 계산 func applySyntaxHighlighting() { beginEditing() // "편집 시작" 플래그 setAttributes([.foregroundColor: UIColor.blue], range: keywordRange1) setAttributes([.foregroundColor: UIColor.blue], range: keywordRange2) setAttributes([.foregroundColor: UIColor.green], range: stringRange) setAttributes([.foregroundColor: UIColor.gray], range: commentRange) endEditing() // 이제 한 번만 레이아웃 재계산 }성능 차이: 4번 계산 → 1번 계산 = 4배 빠름
Syntax Highlighting 커서 점프 해결
NSTextStorage에서 syntax highlighting 구현 시 커서가 문서 끝으로 점프하는 문제가 있습니다.
이슈 원인
class HighlightingTextStorage: NSTextStorage { override func processEditing() { applySyntaxHighlighting() // ⚠️ 여기서 attributes 변경 super.processEditing() // → 커서 점프 } }processEditing() 안에서 attributes를 변경하면 두 번째 layout pass가 시작되며 selection이 엉뚱한 곳으로 이동합니다.
해결 방법: didChangeNotification 활용
class SyntaxHighlighter { private weak var textView: NSTextView? private var subscription: NSObjectProtocol? init(textView: NSTextView) { self.textView = textView // 편집 완료 후에 highlighting self.subscription = NotificationCenter.default.addObserver( forName: NSText.didChangeNotification, object: textView, queue: nil ) { [weak self] _ in self?.applyHighlighting() } } func applyHighlighting() { guard let textStorage = textView?.textStorage else { return } textStorage.beginEditing() // Syntax highlighting 수행 textStorage.endEditing() } }- processEditing() 밖에서 실행 → 별도 layout pass
- Selection 점프 없음
- 코드가 더 명확
enumerateAttribute 최적화
// ❌ 느림 - 모든 attributes 조회 attributedString.enumerateAttributes(in: range, options: []) { attrs, range, stop in if let font = attrs[.font] as? UIFont { // font 처리 } } // ✅ 빠름 - font만 조회 attributedString.enumerateAttribute(.font, in: range, options: []) { value, range, stop in if let font = value as? UIFont { // font 처리 stop.pointee = true // 찾았으면 종료! } }
HTML 변환은 Background에서
// ✅ UI 블로킹 방지 func loadHTML(_ htmlString: String, completion: @escaping (NSAttributedString?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let data = htmlString.data(using: .utf8) let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html ] let attrString = try? NSAttributedString(data: data!, options: options, documentAttributes: nil) DispatchQueue.main.async { completion(attrString) } } }
Copy의 핵심
// Mutable → Immutable 변환 let mutableStr = NSMutableString(string: "Hello") let immutableCopy = mutableStr.copy() as! NSString // 이제 mutate 불가능 (immutableCopy as! NSMutableString).append("World") // 💥 크래시!copy attribute는 안전성 보장이 목적 (성능 최적화 아님)
Conclusion
NSAttributedString 가장 중요화된 최적화 핵심은 네가지로 함축할 수 있습니다.
- Attribute Dictionary 자동 중복 제거 - 별도 캐싱 불필요
- NSTextStorage는 NSTextStorage로 delegate - 메모리 폭발 방지
- beginEditing/endEditing 활용 - 레이아웃 재계산 최소화
- Syntax highlighting은 didChangeNotification - 커서 점프 방지
만약 텍스트 성능 문제가 생기면 이 부분을 먼저 확인해보면 어떨까요?
References
WWDC18 - Videos - Apple Developer
developer.apple.com
NSTextStorage | Apple Developer Documentation
The fundamental storage mechanism of TextKit that contains the text managed by the system.
developer.apple.com
NSAttributedString | Apple Developer Documentation
A string of text that manages data, layout, and stylistic information for ranges of characters to support rendering.
developer.apple.com
Memory optimization of NSAttributedString
Discussion: Memory optimization of NSAttributedString
cocoa-dev.apple.narkive.com
Sub-Classing NSTextStorage Causes Significant Memory Issues
I have a custom UITextView that takes advantage of Apple's TextKit by defining a custom NSTextStorage class, however, when I use my subclass for the custom text view's, text storage (as implemented...
stackoverflow.com
NSAttributedString performance is worse under iOS 8
Under iOS 8 (and 8.1 beta) the performance of creating an NSAttributedString is much worse than 7 (2-3x). This is especially noticeable if you're using multiple instances on the same view, loading 4
stackoverflow.com
'Swift' 카테고리의 다른 글
Embedded Swift Improvements Coming in Swift 6.3 (0) 2025.12.26 Nonexhaustive enums - Swift 6.2.3 (2) 2025.12.20 Swift Closure Capture Semantics (0) 2025.11.15 Swift Build Technologies (0) 2025.11.01 Swift SDK for Android - Android 앱을 Swift로 개발하기 (0) 2025.10.26