곧 토스 개발자 컨퍼런스인 'SLASH 22'가 열린다는 광고를 봤다. 한번도 본적이 없었기 때문에 어떻게 진행되나, 궁금해서 작년 영상을 봤다.
당연히 iOS 관련 발표 영상에 먼저 눈이 갔다. '메모리에 남지 않는 문자열'이라는 영상이었다. 메모리와 문자열 둘 다 내가 알고 있는 단어인데, 조합이 신선했다.
중요한 개념들이 꽤 나왔고, 내가 몰랐던 기술도 있었다.
설명을 들어도 헷갈리는 부분도 있어서, 몇번씩 반복해서 봤다.
덕분에 Swift의 문자열과 메모리에 대해 공부하는 좋은 계기가 되었다.
세션 내용은 여기 공개돼있다. 이 글에서 발표 내용을 그대로 설명하진 않는다. 이 발표를 이해하기 위해서 내가 공부한 지식과, 의문점을 주로 얘기하려고 한다.
발표에서 해결하고자 하는 문제를 먼저 설명해야겠다. 토스는 금융 앱이니만큼 사용자의 개인정보 보안에 신경을 써야한다.
앱에서 사용자가 입력한 비밀번호 같은 정보들은 당연하게도 메모리에 문자열(String) 타입으로 저장된다.
그런데 이 메모리에 개인정보의 '흔적'이 남는다면 어떨까? 보안상 취약점이 된다. 해커가 메모리 전체를 Dump를 뜰 수 있다면, 메모리에 남아있는 비밀번호를 볼 수도 있는 것이다.
물론 메모리를 안 사용할 수는 없다. 하지만 메모리에 비밀번호의 흔적을 남기지 않을 수는 있다. 그래서 세션 제목이 '메모리에 남지 않는 문자열'이다.
발표의 내용은 공개되어있으니, 발표의 내용을 따라가기보다는 이 세션을 이해하기 위해서 필요한 배경 지식과 궁금증에 대해서 정리하는 식으로 글을 풀려고 한다.
문자열은 어떻게 메모리에 저장될까?
Swift에서 데이터 타입은 크게 2가지로 나눈다.
값 타입(Value type), 참조 타입(Reference type)이다.
동적으로 할당되는 메모리 영역은 2종류가 있다. 스택(Stack)과 힙(Heap)이다.
Swift 기본 타입은 전부 값 타입(Value type)이다. 스택에 저장된다.
문자열도 마찬가지로 값 타입이다.그럼 스택에 저장되겠지?
하지만 아니다.
문자열(String), 배열(Array) 같이 여러 값이 묶여있는 타입들을 컬렉션(Collection)이라고 한다. Swift의 문자열은 '글자(Character)' 타입이 묶여있는 컬렉션이다.
컬렉션은 값 타입이지만, 동시에 데이터는 Heap에 저장된다. 보통은 스택에 저장되는 게 더 효율적이다. 하지만 이 경우엔 힙에 저장하면 2가지 장점이 있다.
문자열, 배열은 아주 많은 값을 담을 수 있다. String에 몇만 개의 글자가 들어갈 수 있다. Array에 수십, 수백만 개의 데이터가 들어갈 수 있다.
이 값은 프로그램이 실행되면서 이 함수에서 저 함수로, 이 객체에서 저 객체로 자주 주고받는다. 그때마다 할당이 일어난다.
값 타입은 할당 시에 복사를 한다. 값을 전달할 때마다 새로운 메모리를 할당하고, 복사하는 과정이 반복된다. 이 복사 덕분에 값 타입은 '불변성'이라는 큰 메리트가 있다. 하지만 복사가 많이 일어날 수 밖에 없다는 단점도 있다.
Swift는 많은 값이 담길 수 있는 컬렉션 데이터를 Heap에 저장한다. 값을 할당할 때, Heap에 저장된 데이터의 참조만 공유한다. 문자열이나 배열을 새로 할당할 때 복사가 일어나지 않는다. Heap에 있는 데이터에 접근할 수 있는 참조만 늘어날 뿐이다.
물론 값 타입(Value type) 특성인 불변성은 유지해야 한다. 따라서 변경이 되었을 때, 새로운 값으로 복사를 한다.
예를 들어보자. A 문자열을 B에 할당했다. B는 A와 같은 데이터의 참조를 공유한다. 그 다음 B 문자열에 변경이 일어났다. 그러면 B는 참조하는 데이터를 바꾸는 게 아니다. 새로운 복사본을 힙에 만든다. 그 값을 변경한다. A의 값은 아무런 영향도 받지 않는다.
할당할 때 복사가 아닌, 변경할 때 복사가 일어나는 방식이다. 이걸 Copy-on-write(COW)라고 한다. Swift는 COW를 적극적으로 쓴다.
덕분에 참조 타입Reference type의 효율성과 값 타입Value type의 불변성을 둘 다 잡을 수 있다.
Stack 메모리의 특징은, 컴파일을 하는 시점에 미리 메모리를 얼마나 쓰게 될지 확정을 할 수 있어야 한다는 것이다.
Stack은 마치 벽돌을 쌓듯이 메모리 공간을 배정한다. 메모리 공간의 할당도 선형적으로 이뤄진다. 중간중간 빈 공간이 없는 연속적인 할당이다.
Stack 메모리는 Heap에 비해 빠르고 효율적이다. 하지만 단점도 있다.
각 스택에서 얼마만큼의 메모리가 필요한지 미리 확정할 수 있어야 한다. '미리'는 실행하기 전을 의미한다.
Stack은 마치 벽돌을 쌓듯이 차례차례 메모리 공간을 배정한다. 중간중간 빈 공간이 없다. 선형적이고 연속적인 할당이다.
그래서 각 벽돌 안에 들어갈 메모리의 크기를 정확히 알아야 한다.
하나의 Stack이 올라갔을 때 다음 Stack을 바로 쌓을 수 있어야 하니까. 이전 Stack의 크기를 실행하기 전에 알아야 한다. 그래야 어디서부터 어디까지 메모리를 할당할지 알 수 있다.
즉, 실행 중에 갑자기 메모리가 더 필요해도 이미 할당한 메모리를 늘릴 수 없다. Stack의 단점이다.
하지만 문자열, 배열 같은 컬렉션 타입을 생각해보자. 이런 타입의 메모리 크기가 미리 고정이 되어있다면, 상당히 불편하다. 정해진 크기 이상의 데이터를 추가할 수 없기 때문이다.
문자열, 배열에 대한 추가, 삭제는 매우 흔하다. 추가가 얼마나 될지는 실행 로직에 따라 바뀔 수 있다. 이 변경을 다 예상해서 미리 메모리를 할당하는 건 어렵다.
컬렉션 데이터를 Heap에 저장하는 이유다. Heap은 런타임에 얼마든지 데이터를 추가하거나 삭제할 수 있다.
순서대로 쌓이는 선형적인 메모리 구조가 아니기 때문이다. 실행 중에 메모리 공간이 더 필요하면 새로 배정해주고, 참조하는 주소만 바꾼다.
아무튼, 문자열 데이터은 Heap에 저장이 된다.
근데 그게 무슨 문제라는 거지?
SStack이나 Heap은 실행 중에 동적으로 메모리를 할당받고 반납한다. 처음 변수를 생성할 때 메모리가 할당된다. 더 이상 변수를 사용하지 않으면 메모리를 반납한다.
'사용하지 않는다'는 건 어떻게 판단할까?
C 언어처럼 개발자가 수동으로 메모리를 관리해줘야 하는 언어가 있다. 직접 메모리 해제 명령을 넣어줘야 한다.
하지만 Swift은 언어 차원에서 자동으로 메모리를 관리한다. Swift는 '레퍼런스 카운팅'을 통해 '사용하지 않음'을 자동으로 판단한다.
힙에 저장된 데이터를 가리키는 참조의 개수를 센다. 참조의 개수가 0이 되면, 이 데이터의 사용이 끝났다고 판단한다. 메모리를 회수한다.
여기까진 나도 알고 있는 부분이었다. 하지만 'Garbage Value'는 처음 알았다.
이 메모리를 반납하고 나서도, 메모리에 적힌 데이터는 그대로 남아있다. 더 이상 프로세스가 접근할 수는 없지만, 어쨌든 메모리에 바이트 값이 남아있다는 거다. 이걸 Garbage Value라고 한다.
이게 바로 문자열의 '흔적'이다.
왜 추리 만화보면 비슷한 일이 일어나지 않는가? 누군가 휘갈긴 메모를 뜯어냈는데, 그 밑의 종이에 막 색칠을 해서 무슨 글자를 썼었는지 흔적을 알아낸다든지. 도어락을 누르고 나서 묻어있는 지문 흔적을 보고 번호를 알아낸다든지?
우리가 알아채지 못하는 사이에, 문자열은 할당과 해제를 반복하면서 계속 흔적을 남기고 있었다.
만약 해커들이 메모리에 접근할 수 있다면? 흔적을 보고 개인정보를 탈취할 수 있다.
의문이 들었다. 나는 당연히 메모리를 '해제'한다는 건 거기 있는 데이터도 싹 지우는 건 줄 알았다. 그게 아니라 그냥 값을 남겨둔다니...?
마치 PC방 가서 컴퓨터 하고 로그아웃을 안하는 느낌이잖아? 검색을 해보니 다른 언어도 크게 다르지 않은 것 같다. 왜 그런지 이유를 잘 모르겠다. 불필요한 오버헤드라고 생각해서일까?
아무튼, 보안을 위해서는 그 불필요한 오버헤드가 필요하다. 메모리를 쓰고 나서 자동으로 데이터를 지우도록 만드는 것. 이게 발표에서 해결하고자 하는 문제였다.
해결 아이디어는 쉽다. 메모리를 해제하는 시점에, 가비지 밸류를 삭제하는 코드가 실행되도록 하면 된다.
근데... 어떻게 메모리를 해제하는 시점을 알아내지?
토스 팀이 사용한 방법은 'Associated Objects'였다. 이 Associated Objects도 처음 보는 개념이었다.
Associated Objects를 간단히 설명하면, '특정 객체'에 '연관 객체'를 붙여 기능을 추가하는 방법이다. Objective-c 런타임 기능이다.
비유하자면 도청기와 비슷하다. 목표 객체가 어떤 액션을 하게 되면, 연관 객체도 특정한 액션을 실행하도록 만들 수 있다.
Associated Objects는 외부 라이브러리/프레임워크에서 제공하는 타입을 수정하거나 서브클래싱하지 않고 싶을 때 사용한다. 기존 코드나 서브클래싱 없이 새로운 행동을 추가할 때 적절한 기법이다.
Associated Objects를 사용하면 객체가 원래 갖고 있지 않은 클로저나 프로퍼티를 추가할 수 있다. 저장 프로퍼티를 추가하거나, UIKit 타입에 간단하게 클로저를 붙여주고 싶을 때 등등. 우회적인 방법으로 사용하는 사례가 많다.
🔗 Your old friend associated object in swift
🔗 UIButton에 클로저를 추가해 보았습니다
그럼 발표에 나온 코드를 보자.
final class DeallocHooker {
private struct AssocciatedKey {
static var deallocHookcer = "deallocHooker"
}
private let handler: () -> Void
private init(_ handler: @escaping () -> Void) {
self.handler = handler
}
deinit {
handler()
}
static func install(to object: AnyObject, _ handler: @escaping () -> Void) {
objc_setAssociatedObject(
object,
&AssocciatedKey.deallocHookcer,
DeallocHooker(handler),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
이 타입이 문자열에 찰싹 달라붙어 우리가 원하는 뒤처리를 해준다.
install()
메서드에서 특정 객체와 클로저를 파라미터로 받는다. 클로저를 핸들러로 가지는 DeallocHooker를 생성한다. 특정 객체에 붙여준다.
이렇게 하고 나서 만약 붙여준 객체가 '해제'되면? '연관 객체'인 DeallocHooker 인스턴스는 참조가 0이 된다. 같이 해제가 된다.
문자열이 메모리에서 해제된다. 연관 객체인 DeallocHooker도 같이 해제된다.
DeallocHooker는 해제가 되기 전, deinit
을 실행한다. deinit
안에는 handler 클로저가 들어있다.
handler 클로저에는 문자열이 저장돼있던 메모리 위치의 값을 지우는 코드를 넣어준다.
그러면 문자열이 해제될 때, 메모리를 지우는 코드도 같이 실행된다!
요약해보자. 문자열이 해제되면 DeallocHooker의 Handler가 실행된다. String의 코드를 전혀 건드리지 않았다. associated object만 사용했을 뿐이다.
다만 associated object는 obj-c 기능이라, Class (NSObject)의 서브클래스에 대해서만 사용할 수 있다.
따라서 토스 팀은 Struct로 만들어진 String
에 이 방법을 사용하지 못했다. 대신 비슷한 기능이고 Class로 만들어진 NSString
을 사용했다고 한다.
이 부분에서 또 하나의 의문이 생겼다.
왜 굳이 associated object를 썼을까?
왜 NSString을 서브클래싱하거나, NSString을 감싸는 컨테이너 객체를 만들지 않았을까?
컨테이너 객체를 만들면 NSString이 가진 메서드를 사용하지 못하기 때문일까?
NSString을 서브클래싱하면 되지 않았을까? 서브클래싱을 해서 deinit
안에 memset으로 초기화하는 코드를 넣어주면 될 것 같은데.
obj-c 런타임을 사용하는 것보다 (적어도 나에게는) 좀 더 직관적인 해결책으로 보인다.
애플의 NSString 문서에 보면 비슷한 언급이 나온다.
Valid reasons for making a subclass of NSString include providing a different backing store (perhaps for better performance) or implementing some aspect of object behavior differently, such as memory management.
메모리 관리를 커스터마이즈하고 싶다면 NSString을 서브클래싱하는 이유가 될 수 있다고 한다.
우리가 하려는 메모리 흔적 지우기와 매우 통하는 것 같다.하지만 저 이상으로는 아무리 구글링을 해봐도 잘 나오지 않았다.
뭔가 이유가 있었던 걸까? 내 얕은 지식으로는 아직 모르겠다. (혹시 짐작가시는 분은 댓글로 달아주시길.)
또 하나 익숙하지 않은 개념이 '버퍼(Buffer)'였다.
버퍼란 연속적인 메모리 공간을 가리킨다고 한다.
버퍼는 포인터와 관련있다. 버퍼를 가지고 버퍼가 가리키는 메모리 덩어리에 들어있는 값을 읽거나, 변경할 수 있다.
Swift로 코딩을 할 때 이런 포인터를 직접 다룰 일은 거의 없다. 프로그래머가 메모리를 직접 건드리지 않기 때문이다.
하지만 Swift에도 C 언어의 포인터 기능이 있다. UnsafePointer
관련 타입이다. 포인터를 다룰 수 있는 Swift 객체다.
'Unsafe'라는 무시무시한 이름이 붙어있다. Swift 컴파일러가 기본 제공하는 안전 기능이 없다는 뜻이다.
발표 코드를 보자. Int8
타입의 UnsafeMutablePoint를 지정한다. 원하는 bufferSize
만큼 메모리를 할당한다.
let buffer = UnsafeMutablePointer<Int8>.allocate(capacity: bufferSize)
Int8 타입 데이터가 bufferSize만큼 들어갈 만큼의 공간을 할당한다.
그 메모리를 가리키는 포인터를 넘겨준다.
이 포인터를 사용해서 NSString을 생성한다.
동시에 아까 말했던 Hooker의 핸들러에 사용한다. Handler는 NSString이 존재하는 힙의 메모리 주소에 접근할 수 있다. Handler는 memset
이라는 초기화 함수를 쓴다. 해당 영역의 메모리를 모두 0으로 바꿔버린다.
드디어 흔적을 남기지 않는 String을 만들었다!
내 눈에 띈 코드 한 줄. BufferSize를 구하는 부분이다.
메모리 공간을 직접 할당해야하니, 그 사이즈를 구한다.
사이즈는 당연히 우리가 생성하려고 하는 문자열의 사이즈여야 한다.
let bufferSize = string.maximumLengthOfBytes(using: encoding) + 1
String의 마지막에는 끝을 나타내기 위해 'null'문자가 들어간다. 이걸 고려하기 위해 '+1'을 해준다.
사이즈를 구하기 위해 string.count
로 글자 개수를 구하지 않았다. 대신 maximumLengthOfBytes 라는 함수를 썼다.
Swift 문자열의 특징 때문이다.
Swift는 모든 유니코드 글자를 지원한다.
문자를 디지털로 표현하기 위해선 문자에 해당하는 '코드'가 필요하다. 이 코드를 0과 1로 저장해서 문자 데이터를 나타낸다. 이 문자 코드의 전세계 표준이 바로 유니코드다.
유니코드에는 알파벳 외에도 (한글을 포함해서) 수많은 문자가 있다. 유니코드는 특정 숫자 코드 2개를 조합해서 문자를 표현할 수 있도록 지원한다.
예를 들어 é 는 e와 완전히 다른 문자다. 이 문자는 e를 뜻하는 101과 '를 뜻하는 769를 합쳐서 표현할 수 있다.
이모지도 유니코드에 포함된다. 👍를 뜻하는 '128077'과 어두운 피부색을 뜻하는 '127997'을 조합하면 👍🏿 을 표현할 수 있다.
2개의 코드를 조합해 하나의 글자를 만드는 방식이다. 그래핌 클러스터(Grapheme Cluster)라고 부른다.
Swift의 문자열은 이 그래핌 클러스터 글자도 일반 글자와 똑같이 사용할 수 있도록 '표준화(Canonicalization)'한다.
따라서 Swift에서 문자열이 포함하고 있는 데이터(Character)의 갯수와, 메모리에 저장되는 코드 포인트(숫자)의 갯수는 다르다.
말했듯이 문자열은 글자의 콜렉션이다. 하지만 Swift에서는 string[1]
처럼 정수로 원소에 접근할 수 없다. 처음 Swift로 코딩을 하면 매우 당황스러운 부분이다. 이것도 유니코드 표준을 우선시하는 Apple의 디자인 철학과 관련이 있다.
아무튼 이런 이유로 문자열의 글자 수와 문자열이 저장하는 바이트 데이터의 길이는 비례하지 않는다. maximumLengthOfBytes
를 사용해줘야 한다.
흔적이 남지 않는 문자열을 만들었다고 끝이 아니다. 문자열은 앱의 곳곳에서 사용된다. 프로세스가 개인정보를 입력받거나, 출력하는 시점이 여러 군데 있다.
이 때 일반 문자열 타입을 쓴다면? 기껏 흔적을 지우는 효과가 없을 것이다. 문자열로 받는 와중에 어차피 메모리에 흔적이 남게 될 테니까.
iOS에서 앱에서 문자열 입력을 받는 객체는 대부분 UITextField
다.
UITextField
는 사용자가 입력한 값을 문자열 프로퍼티로 들고 있다. 타이핑을 하면서 글자가 바뀔 때마다 문자열 프로퍼티에 변경이 일어난다. 따라서 메모리에 많은 흔적이 남는다.
이 문제는 눈속임(?) 방법으로 해결한다.
입력이 들어오는 순간 원래 데이터는 바로 암호화된 문자열로 저장한 뒤, 저장된 문자열을 의미없는 글자 ('-')로 바꿔버린다.
비밀번호를 입력하는 (secure text entry를 적용한) UITextField
는 내용을 문자열 *** 형태로 표시한다. 비밀번호를 입력했는데도 '---'가 보여서 당황할 일은 없다.
이제 변경이 계속 일어나도 의미없는 흔적만 남는다.
정말 보안이라는 건, 모든 이음새의 구멍 하나하나까지 다 신경을 써야하는구나... 라는 생각이 들었다.
우와 대단해요우~ 구디~~ 정성이 느껴집니다 😁😀