- MemoryLeak을 찾아보자(강한참조순환인가?) - 실전편22025년 01월 16일
- 2료일
- 작성자
- 2025.01.16.:17
나의 앱 볼레또는 여행 날짜가 되면 CLMointior인스턴스에 해당하는 지역의 위도 경도를 넣어 모너터링을 키고 유저가 해당 지역에 가면 푸쉬 알람을 보내주는 기능이 핵심이다. 이전에는 모니터링 이외의 메모리 릭을 살펴봤는데 이번에는 모니터링에서 발생하는 Memory Leak을 살펴볼 예정이다.
저번에 했던 Memory debugger Graph를 다시 열어보자. 막 거미줄마냥 엮여있다.
총 16개로 저번에 1개 고치는데에도 하루종일 사용했는데 이번엔 얼마나 걸릴지 막막하다. 나의 과거를 욕해야지..
일단 이번에는 Command Line Tool사용하여 더 살펴보려 한다. 메모리 그래프 디버거 킨 상태에서 File > Export Memory Graph를 선택하여 스냅샷을 파일로 저장하고 해당하는 것을
vmmap --summary 해당이름.memgraph
로 열어주었다.
VIRTUAL SIZE = 시스템이 해당 메모리 영역을 위해 예약한 가상메모리 공간의 총량. 실제로 사용중인 메모리 크기는 아니다.
RESIDENT SIZE = 현재 실제로 사용중인 메모리 양. (사용중인 물리적메모리)
DIRTY SIZE = 수정된 메모리의 크기, 예를 들어, 캐시 데이터가 변경되었거나 아직 디스크에 기록되지 않은 상태의 메모리.
SWAPPED SIZE = 메모리 디스크로 스왑된 크기
1. MALLOC 관련 데이터
• MALLOC_SMALL, MALLOC_TINY 같은 영역은 동적으로 메모리를 할당할 때 사용.
• 메모리 최적화를 위해 할당된 메모리 크기와 사용 여부를 분석할 수 있다.
• 예: MALLOC_SMALL의 21.3MB가 RESIDENT(사용 중) 상태이므로, 할당된 블록의 크기와 효율성을 점검할 수 있습니다.
2. Stack 사용량 분석
• 스택은 함수 호출에 따라 동적으로 증가 및 감소합니다.
• 예: Stack의 RESIDENT SIZE가 512KB로 적절한 크기로 유지되고 있습니다.
• 만약 이 값이 비정상적으로 크다면, 재귀 함수 호출 또는 비효율적인 스택 사용의 가능성을 의심할 수 있습니다.
3. dyld private memory
• dyld private memory는 동적 라이브러리 로딩과 관련된 메모리입니다.
• 2.5GB로 가장 큰 메모리 영역을 차지하고 있는 점이 눈에 띄었다.
• 이는 앱에서 사용하는 라이브러리의 크기와 관련이 있습니다.
ㄴ 이렇게 봐서는 어디가 문제인지 모르겠다. 그래서 다시 이전에 했던 것처럼 Profile을 해주자.
일단 어떠한 상황에서 메모리릭이 형성되는지를 파악하기위해 수많은 시뮬레이션을 했다.
1. 자동로그인상태에서 현재날짜의 티켓을 만들거나 날짜를 수정하여 위치 모니터링 하는 경우에서는 메모리릭이 발견되지 않았다.
2. 현재 여행중인 날짜를 편집해서 날짜를 미여행 날짜로 바꿀때 위치모니터링이 꺼지지가 않았다.(메모리 릭은 아님)
um..이건 내가 로직을 잘못짜서 발생한 이슈였다. 기존에는 여행을 편집을 완료하면 해당 시작일과 종료일로부터 현재 여행중일때만 위치 모니터링을 켜주는 동작을 하면 그것을 Appfeature에서 보고 켜주었지만 끄는 동작은 없었다.
case .successTicket : guard let startDate = state.startDate, let endDate = state.endDate, let arrivalSpot = state.arrivialSpot else {return .none} if state.mode == .add { return .run { _ in await dismiss() } } else { //편집모드 if Date.isTraveling(startDate: startDate, endDate: endDate) { return .concatenate( .send(.startMonitoring(arrivalSpot)), .send(.dismissView) ) } else if state.isMonitoring { return .run {send in await locationClient.stopMonitoring() await send(.dismissView) } } else { return .run {send in await send(.dismissView)} } }
수정후 코드
이 문제를 해결하기 위해 isMonitoring 상태를 추가하고, 날짜 편집 후 isMonitoring 상태를 확인하여 현재 여행 중인 날짜가 포함되지 않으면 위치 모니터링을 끄는 로직을 추가했습니다. 이때 중요한 점은 여러 여행 중 하나만 날짜가 변경되어 현재 여행 중인 티켓만 영향을 받아야 한다는 점입니다. 모든 여행의 모니터링을 끄는 동작을 방지하기 위해 isMonitoring 상태를 통해 이를 분리 처리했습니다.
기존코드(문제의 코드)
import ComposableArchitecture import CoreLocation import SwiftUI var backgroundActivitySession: CLBackgroundActivitySession? @DependencyClient struct LocationClient { var authorizationStatus: @Sendable () async -> CLAuthorizationStatus = {.denied} var requestauthorzizationStatus: @Sendable () async -> Void var startMonitoring: @Sendable (SpotType) async throws -> AsyncStream<MonitorEvent> var stopMonitoring: @Sendable (SpotType) async -> Void var disableLocationServices: @Sendable () -> Void private static var monitor: CLMonitor? } enum MonitorEvent: Equatable { case didEnterFrameRegion case didEnterBadgeRegion(StickerCodes) } extension LocationClient: DependencyKey { static let liveValue: Self = { let locationManager = LocationManager() return Self( authorizationStatus: { return locationManager.manager.authorizationStatus }, requestauthorzizationStatus: { locationManager.manager.requestAlwaysAuthorization() }, startMonitoring: {spot in AsyncStream { continuation in @Dependency(\.notificationClient.add) var notificationClient Task { backgroundActivitySession = CLBackgroundActivitySession() let spot = spot.spot // 기존 Monitor가 존재하면 먼저 제거 if let existingMonitor = monitor { // existingMonitor.() // 기존 이벤트 제거 monitor = nil } monitor = await CLMonitor(spot.upperString) let frameCondition = CLMonitor.CircularGeographicCondition(center: spot.coordinate, radius: 3000.0) monitor?.add(frameCondition, identifier: "Frame") for landmark in spot.landmarks { let badgeCenter = CLLocationCoordinate2D(latitude: landmark.latitude, longitude: landmark.longtitude) let landmarkCondition = CLMonitor.CircularGeographicCondition(center: badgeCenter, radius: 1000.0) monitor?.add(landmarkCondition, identifier: landmark.badgetype.rawValue) } if let events = monitor?.events { for try await event in events { switch event.state { case .satisfied: if event.identifier == "Frame" { monitor?.remove("Frame") continuation.yield(.didEnterFrameRegion) } else if let badgeType = StickerCodes(rawValue: event.identifier) { monitor?.remove(event.identifier) try await notificationClient(BadgeNotification(id: badgeType.rawValue, stickerImageType: badgeType)) continuation.yield(.didEnterBadgeRegion(badgeType)) } default: break } } } }} }, stopMonitoring: {spottype in let spot = spottype.spot monitor?.remove(spot.upperString) backgroundActivitySession?.invalidate() }, disableLocationServices: { guard let appSettingsURL = URL(string: UIApplication.openSettingsURLString) else { return } DispatchQueue.main.async { UIApplication.shared.open(appSettingsURL) } } ) }() } enum LocationError: Error { case authorizationDenied } extension DependencyValues { var locationClient: LocationClient { get { self[LocationClient.self] } set { self[LocationClient.self] = newValue } } } private class LocationManager: NSObject, CLLocationManagerDelegate { let manager = CLLocationManager() private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>? override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyKilometer manager.pausesLocationUpdatesAutomatically = false manager.allowsBackgroundLocationUpdates = true } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { guard let continuation = authorizationContinuation else { return } authorizationContinuation = nil continuation.resume(returning: manager.authorizationStatus) } }
3. 로그아웃 후 로그인을 하였을때 여행중인 티켓이 있을때 메모리 릭이 생긴다.
-> 로그아웃할때 backgroundActivitySession nil로 하였지만 AsyncStream의 Task가 종료되지 않고 남아있어서 발생하는 이슈였다. 근데 왜 종료되지 않았지? 이는 AsyncStream의 continuation이 yield를 계속 대기하고, 강한 참조순환이 발생하였다. 해당 이유로는 continuation.finish()이 호출이 안되었다. 이로 인해 AsyncStream이 계속 메모리에 남아 있어 관련 리소스 해제되지 않았다.
4. 여행중인 여행티켓을 다른 날짜로 수정하면 메모리 릭이 생긴다.
-> 이것도 마찬가지로 이전 AsyncStream의 Task와 강한참조순환이 일어났다 왜 났을까
기존 코드를 보면 Monitor 객체를 Client의 Static변수로 만들어 주었다. 즉 monitor가 locationClient의 self에 의존하는 형태.
또 보면 locationClient 메서드가 monitor를 사용한다. 즉 강한 순환 참조가 발생한다. monitor는 CLMonitor인스턴스로 외부에서 참조가 유지될수 있었다. stopMonitoring에서 모니터 자체를 nil로 설정하여 해제하는 로직이 없었다.
솔루션
1. LocationActor를 도입하여 monitor, backgroundSession, activeContinuation같은 상태를 관리하도록 분리하였다. 그래서 상태를 액터내부에서 관리하여 리소스 명시적으로 해제할 수 있도록 구현하였다.
2. continutaiton.onTermination을 설정해 스트림이 종료될 때 연관된 Task 취소하도록 했다. 또한 stopMonitoring에서는 명시적으로 스트림을 종료하도록 했다. 그래서 스트림이 종료되면 즉시 해제되도록 메모리 누수를 방지했다.
기존 코드에서는 AsyncStream의 Continuation을 종료(finish())하지 않아 내부의 비동기 작업(Task)이 계속 실행되는 문제가 있었습니다. 이를 해결하기 위해 Task를 명시적으로 변수로 선언하고, Continuation을 Actor의 변수로 관리하도록 변경하였습니다. stopMonitoring() 메서드가 호출되면 Continuation을 종료하고(finish() 호출), onTermination에서 해당 Task를 취소(cancel())하여 모든 비동기 작업을 안전하게 정리하도록 설계하였습니다!!
import ComposableArchitecture import CoreLocation import SwiftUI var backgroundActivitySession: CLBackgroundActivitySession? @DependencyClient struct LocationClient { var authorizationStatus: @Sendable () async -> CLAuthorizationStatus = {.denied} var requestauthorzizationStatus: @Sendable () async -> Void var startMonitoring: @Sendable (SpotType) async throws -> AsyncStream<MonitorEvent> var stopMonitoring: @Sendable () async -> Void var disableLocationServices: @Sendable () -> Void var isMonitoringActive: @Sendable () async -> Bool = { false } @CasePathable enum MonitorEvent: Equatable { case didEnterFrameRegion(String) case didEnterBadgeRegion(StickerCodes) } } extension LocationClient: DependencyKey { static let liveValue: Self = { return Self( authorizationStatus: { await LocationActor.shared.authorizationStatus() }, requestauthorzizationStatus: { await LocationActor.shared.requestAuthorizationStatus() }, startMonitoring: {spot in try await LocationActor.shared.startMonitoring(spot: spot) }, stopMonitoring: { LocationActor.shared.stopMonitoring() }, disableLocationServices: { guard let appSettingsURL = URL(string: UIApplication.openSettingsURLString) else { return } DispatchQueue.main.async { UIApplication.shared.open(appSettingsURL) } }, isMonitoringActive: { LocationActor.shared.isMonitoring() } ) @globalActor final actor LocationActor { static let shared = LocationActor() private var delegate = Delegate() private var monitor: CLMonitor? private var backgroundSession: CLBackgroundActivitySession? private var activeContinuation: AsyncStream<MonitorEvent>.Continuation? private final class Delegate: NSObject, @unchecked Sendable, CLLocationManagerDelegate { let manager = CLLocationManager() var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>? override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyKilometer manager.pausesLocationUpdatesAutomatically = false manager.allowsBackgroundLocationUpdates = true } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { guard let continuation = authorizationContinuation else { return } authorizationContinuation = nil // continuation.resume(returning: manager.authorizationStatus) } } func isMonitoring() -> Bool { guard let monitor = monitor else {return false} return true } func authorizationStatus() -> CLAuthorizationStatus { delegate.manager.authorizationStatus } func requestAuthorizationStatus() { delegate.manager.requestAlwaysAuthorization() } func startMonitoring(spot: SpotType) async throws -> AsyncStream<MonitorEvent> { let stream = AsyncStream<MonitorEvent> { continuation in activeContinuation = continuation backgroundSession = CLBackgroundActivitySession() let task = Task { monitor = await CLMonitor(spot.spot.upperString) let frameCondition = CLMonitor.CircularGeographicCondition( center: spot.spot.coordinate, radius: 3000.0 ) await monitor?.add(frameCondition, identifier: "Frame") for landmark in spot.spot.landmarks { let badgeCenter = CLLocationCoordinate2D( latitude: landmark.latitude, longitude: landmark.longtitude ) let landmarkCondition = CLMonitor.CircularGeographicCondition( center: badgeCenter, radius: 1000.0 ) await monitor?.add(landmarkCondition, identifier: landmark.badgetype.rawValue) } guard let events = await monitor?.events else { continuation.finish() return } for try await event in events { switch event.state { case .satisfied: if event.identifier == "Frame" { await monitor?.remove("Frame") continuation.yield(.didEnterFrameRegion(spot.spot.name)) } else if let badgeType = StickerCodes(rawValue: event.identifier) { await monitor?.remove(event.identifier) continuation.yield(.didEnterBadgeRegion(badgeType)) } default: break } } } continuation.onTermination = { _ in print("마치인디고") task.cancel() } } return stream } func stopMonitoring() { backgroundSession?.invalidate() backgroundSession = nil activeContinuation?.finish() activeContinuation = nil monitor = nil } } }() } enum LocationError: Error { case authorizationDenied } extension DependencyValues { var locationClient: LocationClient { get { self[LocationClient.self] } set { self[LocationClient.self] = newValue } } }
영감을 얻은 것은 TCA공식문서를 보면 Socket통신이 있다. 계속 AsyncStream을 열어두고 응답을 받는 형식이였당
결국 이번엔 내가 강한순환참조 이슈를 저질렀나 했지만 그냥 비동기 통신이 메모리에 계속남아있는 메모리 누수였다..
'SWIFT개발' 카테고리의 다른 글
Preview는 어떻게 그림을 그리는 걸까? is that hotreload? (0) 2025.03.01 ScrollView 꾸미기? Deep Dive (0) 2025.02.22 Memory Leak을 찾아보자 실전편(1) (0) 2025.01.14 Alamofire error code handling (3) 2024.12.17 Preference Key (0) 2024.11.25 다음글이전글이전 글이 없습니다.댓글