<h1><center> 메모리에 남지 않는 문자열 - 토스 Slash 21 </center></h1> ###### tags: `문자열`, `Toss Slash 21 - 안정원님` - ###### date - `2025-03-171 7:21:33.284Z` > [color=#724cd1][name=데릭] > [Toss SLASH 21 - 메모리에 남지 않는 문자열 - 안정원님](https://toss.im/slash-21/sessions/3-6) > [ChatGPT - 조금 더 궁금한 부분 검색]() ## 메모리에 남지 않는 문자열 > 메모리에 문자열이 남지 않는 것이 아니라 원문이 무엇인지, 추적할 수 없게 만드는 것에 이야기한다. ### Garbage Value ![스크린샷 2025-03-17 07.57.02](https://hackmd.io/_uploads/rktHv0Ehye.png) 기본적으로 프로그램은 메모리를 할당받고, 사용하고 해제되는 흐름을 가짐. **Memory Allocate** ![스크린샷 2025-03-17 07.58.21](https://hackmd.io/_uploads/rkw9PAE31l.png) 메모리 할당은 교과서를 빌리는 것에 비유할 수 있다. 빌린 교과서는 필기도 하고 낙서도 할 수 있는데 이는 메모리를 사용하는 것입니다. ![스크린샷 2025-03-17 07.59.53](https://hackmd.io/_uploads/rJQgOC431e.png) 그리고 교과서를 돌려주는 행위는 메모리를 해제하는 것과 동일합니다. ![스크린샷 2025-03-17 08.00.28](https://hackmd.io/_uploads/S1UG_AV2yx.png) 만약 책을 돌려줄 때 필기나 낙서를 지우지 않았다면? -> 흔적이 남는다. -> **메모리도 동일합니다.** 할당받은 메모리를 해제하기 전에 데이터를 지우지 않는다면, 메모리가 해제되더라도 그 위치에 그대로 데이터가 남게 됩니다. 이때 남는 값을 **Garbage Value**라고 합니다. ![스크린샷 2025-03-17 08.01.58](https://hackmd.io/_uploads/BkeO_R421l.png) Swift에서도 동일합니다. 아래 코드를 실행할 때 메모리가 할당되는 모습을 살펴봅시다. ```swift "123456789".map { $0 } ``` `123456789` 부분은 코드에 직접 고정시킨 문자열로 이건 메모리의 데이터 영역에 들어갑니다. ![스크린샷 2025-03-17 08.03.28](https://hackmd.io/_uploads/Sk9au0Vnkl.png) 데이터 영역은 프로그램이 실행될 때 할당되고 프로그램이 종료되면 시스템에 반환됩니다. **위 이미지에서 특이한 점은 무엇일까?** -> 바로 `123456789` 문자열 마지막에 존재하는 **NULL** 문자입니다. **중요** 모든 문자열은 메모리에 저장될 때 문자열의 끝을 표현하기 위해서 NULL 문자가 마지막 자리에 들어가게 됩니다. ![스크린샷 2025-03-17 08.05.38](https://hackmd.io/_uploads/SJ6rtA4nkg.png) 그러다 보니 1부터 9까지의 9자리의 문자열이더라도 10bytes의 공간이 필요하게 됩니다. 다음으로 `map()` 함수를 실행시키는 코드를 말해보겠습니다. ![스크린샷 2025-03-17 08.06.43](https://hackmd.io/_uploads/r115FC431x.png) 이 경우, 각 문자 단위로 메모리 공간에 데이터가 남게 됩니다. ![스크린샷 2025-03-17 08.07.16](https://hackmd.io/_uploads/ryAiFAE3ye.png) 이후에 `map()`의 실행이 끝나면 `map()`에서 사용한 문자들은 메모리에서 해제되게 됩니다. 하지만 값들은 Garbage Value로써 메모리에 남게 됩니다. **Garbage Value가 생기는 것이 어떤 문제가 될까요?** 토스와 같이 보안을 중요시하는 곳에서는 민감한 데이터가 Garbage Value로써 메모리에 남게 된다면 유능한 해커들에게는 정보탈취의 좋은 수단이 될 수 있습니다. ## String in Swift Swift의 String이 메모리상에서 어떻게 표현되는지 간단하게 설명드리겠습니다. ![스크린샷 2025-03-17 08.10.48](https://hackmd.io/_uploads/SJMY50E3kg.png) Swift의 String은 그 자체로 문자열을 표현하지 않고 Heap에 실제 문자열을 담고 있는 Byte Array를 할당해서 들고 있습니다. ![스크린샷 2025-03-17 08.11.38](https://hackmd.io/_uploads/SkE29AV3yx.png) 그러다 보니 메모리에서 String이 해제되면 사용하고 있는 Byte Array의 데이터가 Garbage Value로 메모리에 남게 됩니다. 아래 예제를 보자. ```swift var say = "Hello" say.appending("World") ``` "Hello"라는 문자열에 "World"를 합치는 간단한 코드입니다. 이렇게 문자열을 합치는 경우에는 Byte Array가 어떻게 되는지 살펴봅시다. ![스크린샷 2025-03-17 08.14.22](https://hackmd.io/_uploads/BktLsRN3kx.png) Hello 라는 String은 Byte Array에 "Hello"라는 텍스트를 가지고 있는 모습을 볼 수 있습니다. **여기서 `appending()`이라는 메소드를 호출하게 되면 어떻게 될까요?** ![스크린샷 2025-03-17 08.16.24](https://hackmd.io/_uploads/S1QRjCE2Jg.png) - String은 struct - appending() 메소드는 mutating 키워드가 붙어있음 그렇기에 String은 Copy-on-Write(Cow)에 의해 복사가 이루어지고, `say`라는 변수가 바라보는 String의 포인터도 변경됩니다. 자연스럽게 ARC에 의해서 retain count가 0인 String은 사라지게 됩니다. ![스크린샷 2025-03-17 08.18.28](https://hackmd.io/_uploads/ryRB3CN2yl.png) 그리고는 두 String을 가지고 있는 Byte Array의 크기를 합한 사이즈의 새로운 Byte Array를 할당하고 두 Byte Array를 데이터를 복사해 연결시킵니다. ![스크린샷 2025-03-17 08.20.02](https://hackmd.io/_uploads/rk3jh0E31e.png) 또한, 메모리에 Garbage Value로 `Hello`라는 문자열도 남게되는 것을 알 수 있습니다. **그렇다면 메모리에 남지 않게 하려면 어떻게 해야 될까요?** ![스크린샷 2025-03-17 08.21.15](https://hackmd.io/_uploads/SJrlpAVnJg.png) ### 1. String이 메모리에서 해제될 때 ![스크린샷 2025-03-17 08.21.58](https://hackmd.io/_uploads/B1lX60Ehyl.png) 그 해결책으로 Associated Object를 사용했습니다. Associated Object를 이용하면 runtime에서 객체에 사용자의 프로퍼티를 subclassing 없이 추가할 수 있습니다. --- - Associated Object ? 이게 어떤 부분인지 **NOTE** > 런타임에서 기존 클래스(특히 NSObject를 상속하는 클래스)에 새로운 프로퍼티를 추가할 수 있도록 해주는 기능. 즉, 컴파일 타임이 아니라 런타임에서 특정 객체에 새로운 속성을 동적으로 저장할 수 있는 기능. Objective-C의 objc_setAssociatedObject, objc_getAssociatedObject API를 이용 **Associated Object를 사용할 때 주의할 점** - 객체가 메모리에서 해제될 떄 Associated Object도 자동으로 해제되지 않음 - 객체가 해제될 떄 수동으로 nil 설정 or **objc_removeAssociatedObjects** 호출 - deinit에서 직접 제거하는 것도 좋은 방법 - 런타임 기능이므로 너무 많이 사용하면 유지보수가 어려울 수 있음 - Swift에서 extension + protocol로 해결할 수 있는 경우, 굳이 Associated Object를 사용할 필요 없음 **Associated Object를 사용하는 실제 사례** 1. UIButton에 클릭 이벤트용 closure 추가하기 - UIButton의 addTarget(_:action:for:) Associated Object를 활용한 예제 ```swift import UIKit import ObjectiveC private var actionKey: UInt8 = 0 extension UIButton { typealias ButtonAction = () -> Void var action: ButtonAction? { get { return objc_getAssociatedObject(self, &actionKey) as? ButtonAction } set { objc_setAssociatedObject(self, &actionKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) addTarget(self, action: #selector(handleAction), for: .touchUpInside) } } @objc private func handleAction() { action?() } } // 사용 예제 let button = UIButton() button.action = { print("버튼 클릭됨!") } ``` --- ```swift final class DeallocHooker { typealias Hanlder = () -> Void private struct AssociatedKey { static var deallocHooker = "deallocHooker" } private let handler: Handler private init(_ handler: @escaping Handler) { self.handler = handler } deinit { handler() } static func install(to object: AnyObject, _ handler: @escaping Hanlder) { objc_setAssociatedObject( object, &AssociatedKey.deallockHooker, DeallocHooker(handler), .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } } ``` 이 코드는 deinit을 핸들링 할 수 있는 클래스로 handler 인스턴스를 Associated Objects로 등록하는 역할을 수행하는 메소드를 가지고 있습니다. install()메소드를 호출하면 DeallocHooker를 생성한 뒤, objc_setAssociatedObject()메소드 호출을 통해 object에 등록시킵니다. 이 내용을 그림으로 살펴봅시다. ![스크린샷 2025-03-17 23.23.29](https://hackmd.io/_uploads/S1oPg3S3yl.png) String 객체의 모습이 보이고 DeallocHooker 객체의 모습이 보입니다. - String 객체는 내부적으로 Byte Array를 강한 참조 - DeallocHooker는 handler 클로저를 강한참조 위의 내용에서 String 객체와 DeallocHooker는 아무런 관계가 없습니다. **하지만, 여기서 Associated Objects를 이용해서 String에 강한 참조로 연결시키면 어떻게 될까요?** ![스크린샷 2025-03-17 23.25.42](https://hackmd.io/_uploads/ryGeZ2rhke.png) 위와 같이 String이 DeallocHooker를 강한 참조하게 바뀌었습니다. ![스크린샷 2025-03-17 23.26.23](https://hackmd.io/_uploads/ryKG-2Sh1g.png) **그럼 여기에서 String이 메모리에서 해제되면 어떻게 될까요?** ![스크린샷 2025-03-17 23.26.51](https://hackmd.io/_uploads/BkDE-nB2yg.png) 이어서 강한 참조하고 있던 Byte Array와 DeallocHooker가 해제되게 됩니다. 이전에 작성했던 코드를 다시 살펴보면 ![스크린샷 2025-03-17 23.27.41](https://hackmd.io/_uploads/HyvPb2BhJe.png) DeallocHooker 클래스가 deinit되면, deinit이 불려지면서 handler를 실행하게 됩니다. 결국 메모리가 해제되는 시점에 다른 행동이 가능하게 되었습니다. **그런데! 여기서 한 가지 문제가 발생했습니다.** ![스크린샷 2025-03-17 23.28.39](https://hackmd.io/_uploads/HyWib3B3kl.png) String 타입은 struct이므로, Copy-on-Wirte로 동작하기 때문에 deinit을 추적하는 것이 불가능하고 Associated Objects도 사용할 수 없다는 것입니다. 그래서 다른 선택지로 NSString이나 NSMutableString을 사용하는 방향으로 대체했습니다. ![스크린샷 2025-03-17 23.30.13](https://hackmd.io/_uploads/H1ybGnrh1x.png) 왜냐하면, 이것들은 class로 구현되어 있기 때문입니다. 그 말은 reference type이라는 것으로 해제되는 시점을 판단할 수 있기 때문입니다. 그래서 NSString이나 NSMutableString에 대해 Associated Objects를 이용해 아까 만든 DeallocHooker를 등록한 뒤, 해당 시점에 메모리를 비워주도록 했습니다. ### 2. Byte Buffer를 빈값으로 채워주기 이 과정을 간단한 코드로 작성해보면 다음과 같은 모습이 됩니다. ```swift func safeString(string: NSMutableString) -> NSMutableString { let encoding = String.Encoding.utf8.rawValue let bufferSize = string.maximumLengthOfBytes(using: encoding) + 1 let buffer = UnsafeMutablePointer<Int8>.allocate(capacity: bufferSize) string.getCString(buffer, maxLength: bufferSize - 1, encoding: encoding) let newString = NSMutableString( bytesNoCopy: buffer, length: strlen(buffer), encoding: encoding, freeWhenDone: false ) ?? NSMutableString() DeallocHooker.install(to: newString) { memset(buffer, 0, bufferSize) buffer.deallocate() } return newString } ``` **maximumLengthOfBytes()** > 이 메소드는 문자열이 특정 인코딩일 때, 필요한 최대 크기를 반환합니다. **특정 인코딩일 때 최대 크기를 가져와야 하는 이유가 뭘까요?** ![스크린샷 2025-03-17 23.37.05](https://hackmd.io/_uploads/Hksc7hrnJg.png) UTF-8에서의 한 글자는 데이터로 표현될 때 1byte가 아니라 그 이상의 크기가 될 수 있기 때문입니다. 그러다 보니 메모리를 글자 길이만큼만 할당받는다면 내용이 잘리는 상황이 발생할 수도 있습니다. 다음으로 **UnsafeMutablePointer**로 bufferSize만큼 allocate()해주는 코드를 볼 수 있습니다. ![스크린샷 2025-03-17 23.38.45](https://hackmd.io/_uploads/SkJZE3H3yl.png) 이를 이용하면 직접 힙에서 메모리를 할당받을 수 있게 됩니다. -> 그러면 할당받은 공간에 문자열을 가져와야겠죠? 그래서 getCString() 메소드를 이용해 buffer에 encoding된 문자열을 받아옵니다. 문자열의 마지막 자리는 무조건 NULL이 들어가야 하므로 안전하게 bufferSize의 -1만큼을 최대 길이로 사용합니다. ![스크린샷 2025-03-17 23.40.17](https://hackmd.io/_uploads/r1iLEhHn1g.png) 예를 들어, 만약 Hello라는 String에서 getCString()을 호출한다면 어떻게 될까요? ![스크린샷 2025-03-17 23.40.50](https://hackmd.io/_uploads/BynOEhrnkx.png) Hello와 NULL 문자까지의 데이터가 버퍼에 복사됩니다. 이제 새로 만든 버퍼를 이용해서 문자열을 만들어봅시다. bytesNoCopy로 시작하는 생성자를 이용하면 NSMutableString을 생성할 때 buffer를 복사하지 않고 그대로 사용하도록 만들 수 있습니다. ![스크린샷 2025-03-17 23.42.43](https://hackmd.io/_uploads/rk0Jrhr2kx.png) 만약 bytesNoCopy를 쓰지 않고 buffer를 사용해서 NSMutableString을 만든다면 새로운 Byte array를 만들어 buffer의 데이터를 복사한 뒤 NSMutableString이 사용하게 됩니다. ![스크린샷 2025-03-17 23.43.25](https://hackmd.io/_uploads/H1vMBhBhJl.png) 그렇지만 bytesNoCopy 생성자를 사용한다면, 복사가 일어나지 않고 그 버퍼를 그대로 사용하게 됩니다. 그리고 여기서 strlen()함수를 이용해 문자열의 길이를 계산해서 넣어줍니다. ![스크린샷 2025-03-17 23.44.23](https://hackmd.io/_uploads/ryMLS3rhke.png) strlen() 함수를 실행하면 NULL 문자가 나올 때까지의 길이를 계산하게 됩니다. [스크린샷 2025-03-17 23.44.45](https://hackmd.io/_uploads/r1DvS3BnJg.png) 그러므로 NULL 문자 전까지의 길이인 5를 반환하게 됩니다. freeWhenDone은 NSMutableString이 해제될 때 자동으로 buffer를 메모리에서 해제하는 것에 대한 이야기입니다. 이것은 false로 설정해서 deinit 시점에 버퍼를 초기화해주고 직접 지워주도록 합시다. 마지막으로 위에서 만들었던 DeallocHooker를 이용해 해제되는 시점에 버퍼를 비워주고, 메모리에서 해제합시다. ![스크린샷 2025-03-17 23.46.37](https://hackmd.io/_uploads/H1vRBhSnJe.png) 그러면 이런 모습이 되겠죠? ![스크린샷 2025-03-17 23.46.52](https://hackmd.io/_uploads/HyUJUnr2ke.png) 여기서 NSMutableString이 해제되면 강한 참조가 끊어지면서 DeallocHooker 또한 deinit 되게 됩니다. DeallocHooker는 deinit 시점에 handler를 호출하게 했는데요. ![스크린샷 2025-03-17 23.47.59](https://hackmd.io/_uploads/Bkq782HnJx.png) ![스크린샷 2025-03-17 23.48.24](https://hackmd.io/_uploads/HyVHInS2kg.png) 이 과정에서 버퍼는 비워지게 되고 ![스크린샷 2025-03-17 23.48.38](https://hackmd.io/_uploads/B1W8I2r2yx.png) 버퍼 또한 메모리에서 해제되게 됩니다. ![스크린샷 2025-03-17 23.48.58](https://hackmd.io/_uploads/SyEwIhB3Jl.png) 그리고 handler도 해제되게 됩니다. 이 과정을 통해 메모리에는 원래 문자열이었던 Hello를 찾을 수 없게 됩니다. ### EncryptedString 메모리에 값 자체를 안전하게 들고 있기 위해 사용한다. ![스크린샷 2025-03-17 23.50.32](https://hackmd.io/_uploads/SyG682r21x.png) EncryptedString은 문자열을 암호화해서 평문이 아닌 암호화된 문자열만을 메모리에 들고 있도록 설계되었습니다. 그래서 지금까지의 아이디어를 EncryptedString에 녹여내는 방향으로 구현을 진행했습니다. EncryptedString의 동작은 크게 암호화, 복호화가 있습니다. ![스크린샷 2025-03-17 23.51.44](https://hackmd.io/_uploads/HJ5ZD2rhyg.png) 암호화는 원문을 암호화해서 암호화된 문자열을 만드는 과정입니다. 여기에서 암호화 과정에서 만들어지는 문자열들에 대해 아까 설명드렸던 메모리에서 문자열이 해제될 때 데이터를 지우는 방법들을 적용했습니다. ![스크린샷 2025-03-17 23.52.56](https://hackmd.io/_uploads/SJQIv2r2yg.png) 복호화는 암호화된 문자열을 복호화해서 원문으로 되돌리는 과정입니다. 여기도 동일하게 복호화 과정에서 만들어지는 문자열들에 대해 메모리가 해제될 때 지우는 방법들을 적용했으며, 원문을 리턴할 때도 String이 아닌 NSMutableString으로 되돌려줘서 사용하지 않게 되는 시점에 메모리를 자동으로 비워줄 수 있도록 적용했습니다. **뭔가 헛점이 보이는가?** 머지? ![스크린샷 2025-03-17 23.54.43](https://hackmd.io/_uploads/BJpnP3Hhyx.png) -> 바로 암호화 시점에 들어오는 원문의 존재입니다. 원문은 개발 편의상 String을 사용해야 하기 때문입니다. 개발 편의를 위해 String을 쓴다는 것은EncryptedString의 사용성이 String만큼 올라간다면 String을 대체할 수 있는 사용성을 가질 수 있을 거라고 생각했습니다. 그래서 이 문제를 해결하기 위 EncryptedString자체를 String처럼 쓸 수 있도록 String에서 제공되는 메소드들과 protocol들을 추가 구현했습니다. ![스크린샷 2025-03-17 23.56.44](https://hackmd.io/_uploads/B184u3rnke.png) ## UITextField ![스크린샷 2025-03-20 10.58.08](https://hackmd.io/_uploads/B1cELlKh1l.png) UITextField의 프로퍼티인 text를 통해 가져오는 문자열은 자동으로 사용되지 않아 메모리에서 해제될 때 메모리가 비워집니다. 하지만 UITextField에는 큰 문제가 있는데 ![스크린샷 2025-03-20 10.59.16](https://hackmd.io/_uploads/HyCdLxKhkl.png) 그것은 **바로 텍스트를 타이핑한 후 모든 변화에 대해 메모리에 Garbage Value가 남게 된다는 점입니다.** 만약 순서대로 `Pass`를 입력했다면, 메모리 덤프를 떴을 때 다음과 같은 모습을 보게 됩니다. 보통 UITextField의 값이 안전하게 보호되어야 하는 상황은 비밀번호와 같이 secureTextEntry를 사용해서 입력되는 경우일 것입니다. 그래서 텍스트가 입력될 때마다, 입려된 값을 EncryptedString에 추가해주고 UITextField의 text는 다른 값으로 치환해서 변경된 값이 남더라도 원문을 판단할 수 없도록 해결할 수 있습니다. ![스크린샷 2025-03-20 11.00.31](https://hackmd.io/_uploads/SycpIeKnyl.png) 입력된 값을 `-`로 치환한 경우, 다음과 같이 남도록 만들어 메모리 덤프를 보더라도 원문을 알 수 없게 만들었습니다.