- Memory Leak을 찾아보자 실전편(1)2025년 01월 14일
- 2료일
- 작성자
- 2025.01.14.:32
이전글에서 weak var을 설명하며 지연된 할당해제에 관해 글을 썼다. 이번엔 실제 내 프로젝트를 보며 어디서 메모리 릭이 발생하는지 찾아보자.
가장 먼저 edit Scheme에서 Malloc Scribble과 Malloc Stack Logging을 켜주었다.
Malloc Scribble(메모리 오버라이드 감지)
Malloc Scribble은 동적 메모리 할당 시 메모리를 더미값으로 초기화하고, 해제 전까지 해당 메모리 공간에 예상치 못한 쓰기 작업이 발생하는지 감지하는 디버깅 기능. 이를 통해 초기화되지 않은 메모리에 접근하거나, 이미 해제된 메모리를 사용하는 문제를 파악할 수 있다.
동작 원리
• 동적 메모리 할당 시, 메모리를 특정 패턴 값으로 채운다
• 일반적으로 0xAA로 초기화.
• 메모리를 해제할 때는 0x55로 덮어쓴다
• 메모리에 접근할 때, 이 값을 확인하여 예상하지 못한 읽기/쓰기 작업을 감지.
• 예: 해제된 메모리(0x55) 또는 초기화되지 않은 메모리(0xAA)에 접근 시 경고 또는 크래시 발생.
활용
Malloc Scribble은 다음과 같은 문제를 디버깅하는 데 유용:
• Dangling Pointer(해제된 포인터 사용): 이미 해제된 메모리에 접근하거나 쓰기를 시도.
• Uninitialized Memory Access(초기화되지 않은 메모리 접근): 할당받은 메모리를 사용하기 전에 초기화하지 않음.
• Buffer Overflow: 배열 또는 메모리의 범위를 초과하여 쓰기를 시도.
Malloc Stack Logging(메모리 누수 및 할당/해제 추적)
각 할당의 호출 스택과 타임스탬프를 기록. 메모리가 할당된 위치와 시기를 쉽게 추적 가능하다.
동작 원리
• 모든 메모리 할당과 해제 작업에 대해 스택 추적 정보를 기록합니다.
• 기록된 정보를 분석하여 메모리 누수와 같은 문제를 파악.
• 예: 특정 메모리가 할당된 후 해제되지 않았거나, 동일한 메모리를 두 번 해제한 경우.
• 할당된 메모리와 관련된 소스 코드 위치를 역추적하여 문제의 원인을 찾을 수 있습니다.
활용
Malloc Stack Logging은 다음과 같은 문제를 디버깅하는 데 적합:
• Memory Leaks(메모리 누수): 해제되지 않은 메모리를 추적.
• Double Free(이중 해제): 동일한 메모리를 두 번 이상 해제하려는 시도.
• Dangling Pointer: 특정 포인터가 가리키는 메모리가 어디서 할당되고 해제되었는지 추적.
이제 Debug Memory Graph를 통해 힙의 영역에 대한 메모리 스냅샷을 찍어보자.
malloc Stack Loggin을 사용하였기에 각 할당의 역추적이 포함된다
앱의 힙을 프로파일링 하기 위해 XCode -> Product -> Profile -> Leak을 들어가보면 Allocations(힙과 VM이벤트를 실시간으로 기록하여 활동을 실시간으로 확인), Leak(메모리 릭체크)
가끔 앱이 튕겨버리는 현상이 발생한다. 이는 앱이 어딘가에서 메모리 릭이 발생하였을거라 생각하고 instruments에서 leaks를 테스트 해보았다.
같은 시나리오로 이전의 debug memory graph를 통해 보았을때 메모리 릭이 같게나온다. 하지만 보면 어떠한 강한 참조도 없는 것을 볼 수 있다. 즉 이 경우는 메모리 자리를 차지는 객체가 계속 쌓인다보면 된다.
내 앱은 메인뷰에 onAppear할때마다 모든 여행을 조회하여 뷰에 보여준다.
struct TravelResponse: Decodable { let travelID: Int let ticketInfo: TicketInfoDTO let departure: String let arrive: String let keyword: String let startDate: String let endDate: String let members: [Member] let status: String? let editable: Int? let createdDate: String enum CodingKeys: String, CodingKey { case travelID = "travel_id" case ticketInfo = "ticket_info" case startDate = "start_date" case endDate = "end_date" case departure, arrive, keyword,members, status case editable = "editable_user_id" case createdDate = "created_date" } func toTicket() -> Ticket { let participants = members.map {$0.toModel()} let keywordStrings = keyword.split(separator: ",") let mappedKeywords = keywordStrings.map{$0.trimmingCharacters(in: .whitespaces)}.compactMap { Keywords.fromKoreanString($0) } return .init( travelID: travelID, departaure: SpotType.fromUpperString(departure) ?? .seoul, arrival: SpotType.fromUpperString(arrive) ?? .seoul, startDate: startDate.toDate() ?? Date(), endDate: endDate.toDate() ?? Date(), participant: participants, keywords: mappedKeywords, editableID: editable,fullSizeURL: URL(string: ticketInfo.ticketFull)!, smallSizeURL: URL(string: ticketInfo.ticketSmall)!,createDate: createdDate.convertToFormattedDate()!) } }
해당하는 것이 여행리스폰스 DTO이다. "created_date" : "2025-01-13 14:47:15", 서버로부터 이렇게 받아오지만 Domain Layer의 Model에서는 날짜까지만 필요하기에
func convertToFormattedDate() -> String? { let inputFormatter = DateFormatter() inputFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" inputFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 let outputFormatter = DateFormatter() outputFormatter.dateFormat = "yyyy.MM.dd" outputFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 outputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 guard let date = inputFormatter.date(from: self) else { return nil } return outputFormatter.string(from: date) }
String Extension에서 convertToFormattedDate()를 통해 input형태를 ouput형태로 변환하였다.
그 후 API요청을 통해 서버로부터 [TravelResponse]형태로 디코딩해서 가져온후
getAlltravel: { isAccepted in let data = try await NetworkManager.request(endpoint: TravelRouter.getAllTravel(isAccepted: isAccepted), responseType: [TravelResponse].self) return data.toTicket() },
Domain Layer에서 사용하는 Ticket 모델로 변환하기 위해
import Foundation extension Array where Element == TravelResponse { func toTicket() -> [Ticket] { return self.map { $0.toTicket() } } }
Array Extension을 만들어 [TravelResponse] => [Ticket]형식으로 매핑을 해주었다.
여기까지가 내가 짠 코드에 대한 설명이다. 그러면 이제 어디서 메모리릭이 발생하는지 살펴보자.
// // String+.swift // Boleto // // Created by Sunho on 9/24/24. // import Foundation extension String { func toDate(format: String = "yyyy-MM-dd") -> Date? { let dateFormatter = DateFormatter() dateFormatter.dateFormat = format dateFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 return dateFormatter.date(from: self) } func isoToDate() -> Date? { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" dateFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 return dateFormatter.date(from: self) } func convertToFormattedDate() -> String? { let inputFormatter = DateFormatter() inputFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" inputFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 let outputFormatter = DateFormatter() outputFormatter.dateFormat = "yyyy.MM.dd" outputFormatter.locale = Locale(identifier: "ko_KR") // 한국 로케일 outputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // 한국 시간대 guard let date = inputFormatter.date(from: self) else { return nil } return outputFormatter.string(from: date) } }
그중 특히 String+에서 내가 스트링을 날짜로 변환하는 쪽을 보자. 여기서 메소드가 호출될때마다 DateFormatter()인스턴스를 생성하게 된다.
1. 첫번째 문제점: 쌓이는 인스턴스
즉 메인뷰에 올때마다 getTravel요청하면 그때마다 DateFormatter()인스턴스를 생성하게된다.
import Foundation extension String { private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.timeZone = TimeZone(identifier: "Asia/Seoul") return formatter }() static func toDate(from dateString: String, format: String = "yyyy-MM-dd") -> Date? { dateFormatter.dateFormat = format return dateFormatter.date(from: dateString) } static func convertToFormattedDate(from dateString:String) -> String? { dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" guard let date = dateFormatter.date(from: dateString) else { return nil } dateFormatter.dateFormat = "yyyy.MM.dd" return dateFormatter.string(from: date) } }
그래서 DateFormatter를 그때마다 생성하지 않고 하나를 생성해놓고 그것을 사용하도록 static method로 구현하도록 바꿨다.
안타까운것은 그래도 메모리 릭이 발생한다. 계속해서 쌓인다. CFString이... 위의 메모리 디버그 그래프의 CFString의 Print info를 해보면 2025.1.13이 뜬다. 의구심이 들었다. 뭐지? 내가 한것은 여행 Response를 Ticket형식에 맞도록 저 dateFormatter를 쓴것은 맞으나 현재 날짜의 Response는 없었는데 현재 날짜가 뜬 것이다. DateFormatter에 대해 공부를 해보니 매우 무겁고 비용이 많이 드는 객체다. dateFormat을 매번 설정하는 것 자체가 비효율이라 시스템 리소스를 많이 소모한다. 그래서 String Slicing 형식으로 바꾸어서 진행을 하였더니 계속쌓이는 현상을 막을 수 있었다.
static func convertToFormattedDate(from dateString:String) -> String? { let year = String(dateString.prefix(4)) let month = String(dateString[dateString.index(dateString.startIndex, offsetBy: 5)..<dateString.index(dateString.startIndex, offsetBy: 7)]) let day = String(dateString[dateString.index(dateString.startIndex, offsetBy: 8)..<dateString.index(dateString.startIndex, offsetBy: 10)]) // yyyy.MM.dd 형식으로 조합 return "\(year).\(month).\(day)" }
2. Array Extension에서 Map했을때 메모리 누수 발생.
기존에는 [TravelResponse] 배열의 각 객체가 autoreleasepool의 관리 하에 있지 않기 때문에, 반복 작업이 끝날 때까지 메모리에 남아 있게 되었다. 이로 인해 대량의 TravelResponse 객체를 처리할 때 메모리 사용량이 급격히 증가였는데, 이를 방지하기 위해 autoreleasepool을 사용하여 각 매핑 작업이 끝난 후 블록 내부에서 생성된 임의 객체들을 즉시 해제하도록 수정. 이렇게 하면 메모리 사용량을 줄일 수 있고, 메모리 부족으로 인한 크래시 위험도 예방할 수 있다.
autoreleasepool이 먼데?
반복적으로 많은 객체를 생성하고 사용할 경우, 기본 RunLoop의 autoreleasepool은 작업이 끝날 때까지 객체를 메모리에 유지합니다. 이로 인해 대량의 객체가 생성되면 메모리 사용량이 급격히 증가하고, 경우에 따라 메모리 부족으로 앱이 크래시할 수도 있습니다 수동으로 autoreleasepool을 적용하면 블록 실행이 끝날 때마다 사용이 끝난 객체를 즉시 해제할 수 있습니다.
swift는 ARC를 사용하여 객체 수명을 관리하지만 Objective-C 객체와 상호작용하거나 대량의 객체를 처리하는 반복 작업에서는
autoreleasepool을 사용해 메모리 관리 최적화를 수행해야 할 때가 있습니다extension Array where Element == TravelResponse { func toTicket() -> [Ticket] { return autoreleasepool { () -> [Ticket] in self.map { $0.toTicket() } } } }
swift closure가 값을 캡쳐해야할때 힙의 메모리를 할당해 캡처를 저장한다.메모리 그래프 디버거는 이러한 할당을 클로저 컨텍스트로 표시하는데 앱 힙의 각 클로저 컨텍스트는 라이브 클로저와 1:1 대응된다.
이렇게 보면 closure Context가 자체적으로 swallow를 캡쳐하여 참조순환이 생길 수 있다. 메모리 그래프 디버거는 이 참조를 강한 캡쳐로 표시하지만 클로저 메타데이터에는 변수 이름이 표시되지 않는다.
자 여기까지는 강한 참조순환이외에 메모리가 남아있어 발생하는 메모리 릭이였다. 여정도만 있었으면 좋앗겠지만 안타깝지만 여전히 메모리릭이 나오는 케이스를 발견했다. 해당 글은 다음글에서...ㅠ
'SWIFT개발' 카테고리의 다른 글
ScrollView 꾸미기? Deep Dive (0) 2025.02.22 MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편2 (0) 2025.01.16 Alamofire error code handling (3) 2024.12.17 Preference Key (0) 2024.11.25 ShareLink - 개발일기 (1) 2024.11.09 다음글이전글이전 글이 없습니다.댓글