Memory Leak을 찾아보자 실전편(1)

2025. 1. 14. 02:32·SWIFT개발일지

앱이 가끔 튕기는 현상을 발견했습니다. 메모리 릭이 원인일 가능성이 높다고 판단해서 본격적인 디버깅에 들어갔어요.

먼저 Edit Scheme에서 두 가지 중요한 옵션을 활성화했습니다:

Malloc Scribble (메모리 오버라이드 감지)

Malloc Scribble은 동적 메모리 할당 시 메모리를 더미값으로 초기화하고, 해제 전까지 해당 메모리 공간에 예상치 못한 쓰기 작업이 발생하는지 감지하는 디버깅 기능입니다

이를 통해 초기화되지 않은 메모리에 접근하거나, 이미 해제된 메모리를 사용하는 문제를 파악할 수 있다.

  • 할당 시: 0xAA로 초기화
  • 해제 시: 0x55로 덮어쓰기
  • 메모리에 접근할 때 이 값을 확인해서 예상하지 못한 읽기/쓰기 작업을 감지해요

어떤 문제들을 잡아낼 수 있을까요?

• Dangling Pointer(해제된 포인터 사용): 이미 해제된 메모리에 접근하거나 쓰기를 시도.

• Uninitialized Memory Access(초기화되지 않은 메모리 접근): 할당받은 메모리를 사용하기 전에 초기화하지 않음.

• Buffer Overflow: 배열 또는 메모리의 범위를 초과하여 쓰기를 시도.

 

Malloc Stack Logging(메모리 누수 및 할당/해제 추적)

각 할당의 호출 스택과 타임스탬프를 기록해서 메모리가 할당된 위치와 시기를 쉽게 추적할 수 있게 해주는 기능입니다.

동작 원리:

  • 모든 메모리 할당과 해제 작업에 대해 스택 추적 정보를 기록합니다
  • 기록된 정보를 분석하여 메모리 누수와 같은 문제를 파악해요
  • 할당된 메모리와 관련된 소스 코드 위치를 역추적해서 문제의 원인을 찾을 수 있어요

활용 가능한 문제들:

  • Memory Leaks: 해제되지 않은 메모리를 추적
  • Double Free: 동일한 메모리를 두 번 이상 해제하려는 시도
  • Dangling Pointer: 특정 포인터가 가리키는 메모리가 어디서 할당되고 해제되었는지 추적

이 두 도구를 켜고 Debug Memory Graph를 통해 힙 영역에 대한 메모리 스냅샷을 찍어봤어요.

Malloc Stack Logging을 사용했기 때문에 각 할당의 역추적 정보도 포함되어 있었습니다.

 

XCode → Product → Profile → Leaks로 들어가서 Instruments를 활용해봤어요:

  • Allocations: 힙과 VM 이벤트를 실시간으로 기록하여 메모리 활동을 실시간으로 확인
  • Leaks: 메모리 릭을 체크

가끔 앱이 튕겨버리는 현상이 발생한다. 이는 앱이 어딘가에서 메모리 릭이 발생하였을거라 생각하고 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()!)
    }
}

여기서 중요한 부분은 createdDate 필드예요.

서버로부터 "2025-01-13 14:47:15" 형태로 받아오지만, Domain Layer의 Model에서는 날짜까지만 필요하기 때문에 "2025.01.13" 형태로 변환해야 했어요.

API 요청 흐름

getAlltravel: { isAccepted in
    let data = try await NetworkManager.request(
        endpoint: TravelRouter.getAllTravel(isAccepted: isAccepted),
        responseType: [TravelResponse].self
    )
    return data.toTicket()
}

Domain Layer에서 사용하는 Ticket 모델로 변환하기 위해 Array Extension을 만들었어요:

extension Array where Element == TravelResponse {
    func toTicket() -> [Ticket] {
        return self.map { $0.toTicket() }
    }
}

[TravelResponse] → [Ticket] 형식으로 매핑해주는 역할이죠.

여기까지가 제가 짠 코드에 대한 설명이에요. 그럼 이제 어디서 메모리 릭이 발생하는지 본격적으로 살펴보겠습니다.

첫 번째 범인: DateFormatter 인스턴스 남용

문제 발견

메모리 그래프를 자세히 들여다보니 CFString이 계속 쌓이고 있는 걸 발견했어요. 그 중에서도 특히 String Extension에서 문자열을 날짜로 변환하는 부분이 의심스러웠습니다.

 

//
//  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)
    }
}

왜 이게 문제일까요? 🤔

메인 뷰에 들어갈 때마다 getTravel 요청을 하면, 그때마다 DateFormatter() 인스턴스를 생성하게 되는 거예요.

여행 데이터가 10개라면 10번, 100개라면 100번씩 말이죠.

그런데 DateFormatter는 생각보다 매우 무겁고 비용이 많이 드는 객체예요. 내부적으로 다양한 로케일 정보, 시간대 정보, 포맷팅 규칙들을 처리해야 하거든요.

첫 번째 시도: Static 인스턴스 사용

그래서 DateFormatter를 그때마다 생성하지 않고 하나를 생성해놓고 재사용하도록 static method로 구현해봤어요:

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 인스턴스를 재사용하도록 바꿨는데도 여전히 메모리 릭이 발생했어요.

이상하다고 생각해서 메모리 그래프를 다시 확인해봤어요.

의외의 발견

CFString의 Print info를 해보니 2025.1.13이 나오는 거예요. 의구심이 들었습니다. "뭐지? 내가 한 건 여행 Response를 Ticket 형식에 맞도록 저 dateFormatter를 쓴 건 맞는데, 현재 날짜의 Response는 없었는데 왜 현재 날짜가 뜨지?"

이때 깨달았어요. DateFormatter에 대해 더 공부해보니 이 객체는 매우 무겁고 비용이 많이 드는 객체일 뿐만 아니라, 

dateFormat을 매번 설정하는 것 자체가 비효율적이고 시스템 리소스를 많이 소모한다는 사실을 알게 되었어요.

최종 해결: String Slicing 사용

"간단한 문자열 파싱에 이렇게 무거운 도구를 써야 할까?"라는 생각이 들었어요. 그래서 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)"
    }

두 번째 범인: 배열 매핑 시 메모리 누적

DateFormatter 문제를 해결했는데도 여전히 메모리 릭이 발생했어요. 이번엔 다른 곳에서 문제가 생기고 있었습니다.

Array Extension에서 Map했을 때 메모리 누수 발생

기존 Array Extension을 다시 살펴봤어요:

extension Array where Element == TravelResponse {
    func toTicket() -> [Ticket] {
        return self.map { $0.toTicket() }  // 🚨 대량 처리 시 메모리 누적
    }
}

 

원인 분석:

기존에는 [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() }
        }
    }
}

이렇게 autoreleasepool로 감싸주면 각 매핑 작업이 끝난 후 블록 내부에서 생성된 임시 객체들을 즉시 해제하도록 할 수 있어요.

이를 통해 메모리 사용량을 줄일 수 있고, 메모리 부족으로 인한 크래시 위험도 예방할 수 있었습니다.


Closure Context 발견

Swift closure가 값을 캡처해야 할 때 힙의 메모리를 할당해서 캡처를 저장합니다.

메모리 그래프 디버거는 이러한 할당을 클로저 컨텍스트로 표시하는데, 앱 힙의 각 클로저 컨텍스트는 라이브 클로저와 1:1 대응돼요.

메모리 그래프를 보니 closure context가 self를 캡처하여 참조 순환이 생길 가능성이 있었어요.

메모리 그래프 디버거는 이 참조를 강한 캡처로 표시하지만, 클로저 메타데이터에는 변수 이름이 표시되지 않아서 정확히 어떤 변수를 캡처하고 있는지 파악하기가 어려웠어요.

이미지를 보면 완벽한 참조 순환이 형성되어 있어요:

  • swallow 인스턴스가 completion 프로퍼티를 강하게 참조
  • completion 클로저가 swallow 인스턴스를 캡처해서 강하게 참조
  • 결과적으로 둘 다 해제되지 않는 상황이 발생

'SWIFT개발일지' 카테고리의 다른 글

ScrollView 꾸미기? Deep Dive  (0) 2025.02.22
MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편2  (0) 2025.01.16
Alamofire error code handling  (5) 2024.12.17
ShareLink - 개발일기  (1) 2024.11.09
TCA- TestingCode  (1) 2024.10.26
'SWIFT개발일지' 카테고리의 다른 글
  • ScrollView 꾸미기? Deep Dive
  • MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편2
  • Alamofire error code handling
  • ShareLink - 개발일기
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (133)
      • SWIFT개발일지 (28)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (42)
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (2)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
Memory Leak을 찾아보자 실전편(1)
상단으로

티스토리툴바