Lined Notebook

[NSException initWithCoder:]NSInternalInconsistencyException - Completion handler passed to -[WebViewController webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:] was not called

by 사슴비행기

Firebase Crashlytics에서 해당 오류가 발생하기 시작했다.

이 오류는 생각보다 많이 만나본 오류인데

메모리 구조와 관리, 객체들의 생명주기 및 비동기 등등등... 에 대한 개념 부족으로

급급하게 막고 지나가기만 했었는데

이번에 여유가 생기면서 좀 더 제대로 알고 적용하자 싶어서

공부 및 정리를 하게 된다.

 

공부에는 ChatGPT를 사용했으므로 틀린 설명이 있을 수 있으니

틀린 부분은 언제든 지적해주시면 감사드립니다.

 

일단 이 오류는 어떠한 이유인지는 모르겠으나

runJavaScriptAlertPanelWithMessage 메서드에서 completionHandler가 호출되지 않았기 때문에 나는 오류이다.

그래서 반드시 1회 호출해줘야 앱이 죽지 않는다.

그렇다고 2번 호출해도 되는 것도 아니다. 2번 호출해도 죽는다.

반드시 1회 호출해야 한다.

 

그래서 검색하면 다른 분이 올려 놓으신 CompletionHandlerWrapper 클래스가 있는데

그동안 나는 그 클래스를 마냥 무지성으로 사용해왔다.

이 클래스를 사용하면 어떤 일이 일어나는지 정확히 알아야겠다.

 

일단 기본 이해가 필요한데,

 

* 메모리 구조
컴퓨터 메모리는 크게 4가지로 나눌 수 있다.

• 코드 영역: 실행할 코드가 저장되는 공간.
• 데이터 영역: 전역 변수나 프로그램 시작부터 끝날 때까지 존재하는 데이터가 저장됨.
• 스택(Stack): 함수가 실행될 때 잠시 필요한 데이터가 저장됨.
• 힙(Heap): 우리가 동적으로 생성한 객체나 데이터를 저장하는 공간.

 

여기서 우리가 CompletionHandlerWrapper 클래스를 쓰면서 잘 봐야 할 부분은 스택과 힙니다.

 

* 스택(Stack)
스택은 짧은 시간 동안 메모리를 사용하는 공간.
함수나 메서드가 실행될 때, 그 안에서 사용되는 지역 변수나 임시 데이터가 스택에 저장됨.

그리고 함수가 끝나면 그 데이터는 자동으로 사라짐.

특징:
• 자동으로 관리됨: 함수가 끝나면 스택에 저장된 변수들이 자동으로 사라짐.
• 빠른 속도: 메모리를 빠르게 할당하고 해제할 수 있음.
• 제한된 크기: 스택의 크기는 한정되어 있어, 큰 데이터를 많이 저장할 수 없음.

* 힙(Heap)
힙은 오랫동안 사용될 데이터를 저장하는 공간.
우리가 동적으로 객체를 만들거나 큰 데이터를 처리할 때, 이 데이터는 힙에 저장됨.
힙은 직접 관리해야 함. 즉, 데이터를 다 썼으면 메모리를 해제해야 하는데,

Objective-C와 Swift에서는 **ARC(Automatic Reference Counting)**라는 시스템이 대신 해제를 해줌.

이 시스템이 없으면 우리가 직접 “이 데이터는 이제 필요 없으니까 메모리에서 지워줘”라고 알려줘야 함.

특징:
• 직접 관리: 데이터를 만들 때는 우리가 만들고, 필요 없으면 ARC가 메모리를 자동으로 해제해 줌.
• 오랫동안 유지됨: 힙에 저장된 데이터는 우리가 해제할 때까지 메모리에 남아 있음. -> 순환참조가 일어나는 이유
• 느린 속도: 스택보다 메모리 관리가 복잡해서 조금 느릴 수 있음.

 

메모리 구조와 스택, 힙에 대해서는 대충 알았고,

그러면 우리가 이걸 왜 알아야 하느냐,

 

Objective-C의 Block,

Swift에서는 Closure(람다 같은 것)가 이것과 관련이 있기 때문이다.

 

Objective-C의 Block은 스택에 생성되는데,

copy를 이용해서 힙에 저장할 수 있다.

 

Swift의 Closure는 copy를 사용하지 않아도 자동으로 힙에 저장된다.

그래서 메서드의 코드 블럭 밖에서도(해당 block과 closure를 참조한 곳이 있다면)

힙에 저장해둔 block 또는 closure를 사용할 수 있는 것이다.

힙에 있는 것은 참조를 해제하기 전까지는 계속 남아있기 때문이다.

대신에 순환참조는 항상 조심할 것!

 

CompletionHandlerWrapper 클래스의 코드를 보면, 

@interface CompletionHandlerWrapper : NSObject

@property (nonatomic, copy) void (^completionHandler)(id);
@property (nonatomic, strong) id defaultValue;
@property (nonatomic, assign) BOOL isHandlerCalled;

- (instancetype)initWithCompletionHandler:(void (^)(id))completionHandler defaultValue:(id)defaultValue;
- (void)respondHandler:(id)value;

@end
#import "CompletionHandlerWrapper.h"

@implementation CompletionHandlerWrapper

- (instancetype)initWithCompletionHandler:(void (^)(id))completionHandler defaultValue:(id)defaultValue {
    self = [super init];
    if (self) {
        _completionHandler = [completionHandler copy];
        _defaultValue = defaultValue;
        _isHandlerCalled = NO;
    }
    return self;
}

- (void)respondHandler:(id)value {
    if (!self.isHandlerCalled && self.completionHandler) {
        self.completionHandler(value);
        self.isHandlerCalled = YES;
        self.completionHandler = nil;
    }
}

- (void)dealloc {
    if (!self.isHandlerCalled && self.completionHandler) {
        [self respondHandler:self.defaultValue];
    }
}

@end

 

이렇게 되어 있고,

이걸 사용하는 부분은

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler {
    CompletionHandlerWrapper *completionHandlerWrapper = [[CompletionHandlerWrapper alloc] initWithCompletionHandler:^(id value) {
            completionHandler();
        } defaultValue:[NSNull null]];
    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:message
                                                                             message:nil
                                                                      preferredStyle:UIAlertControllerStyleAlert];
    [alertController addAction:[UIAlertAction actionWithTitle:@"OK"
                                                        style:UIAlertActionStyleCancel
                                                      handler:^(UIAlertAction *action) {
                                                            dispatch_async(dispatch_get_main_queue(), ^{
                                                                [completionHandlerWrapper respondHandler:nil];
                                                            });
                                                        }]];
    [self presentViewController:alertController animated:YES completion:^{}];
}

 

이렇게 되어 있는데,

이 때 일어나는 일을 살펴보면 아래와 같다.

 


1. 객체 생성
• CompletionHandlerWrapper 객체가 runJavaScriptAlertPanelWithMessage 메서드 내에서 생성.
• 이 객체는 completionHandler 블록을 복사(copy)하여 저장하며, 블록은 힙에 저장된다. 이로 인해 블록이 안전하게 메모리에 남게 됨.

2. UIAlertController 표시
• UIAlertController가 화면에 표시되고, 사용자는 경고창에서 “OK” 버튼을 누를 수 있다.
• 이때, 버튼을 눌렀을 때 실행될 핸들러(UIAlertAction)도 정의됨. 여기서 CompletionHandlerWrapper의 respondHandler 메서드가 호출.

3. OK 버튼 클릭
• 사용자가 “OK” 버튼을 누르면 respondHandler가 호출.
• 이 메서드는 내부적으로 completionHandler를 호출하고, completionHandler가 호출된 후에는 isHandlerCalled 플래그를 YES로 설정.
• completionHandler는 한 번만 실행되며, 그 이후에는 nil로 설정되어 두 번 이상 호출되는 것을 방지함.

4. 객체 해제 (dealloc)
• 만약 completionHandler가 호출되지 않고 CompletionHandlerWrapper 객체가 메모리에서 해제된다면, dealloc 메서드가 호출된다.
• 이때 dealloc 메서드에서는 completionHandler가 호출되지 않았음을 확인하고, defaultValue를 사용하여 completionHandler를 호출.
• 즉, 이렇게 하면, 객체가 메모리에서 해제되기 전에 반드시 completionHandler가 한 번은 호출되도록 보장.

5. 메모리에서 해제
• 모든 작업이 완료되면 객체는 메모리에서 안전하게 해제됨.

 

=> 이 과정에서 중요한 포인트는 completionHandler가 한 번만 호출되도록 보장한다는 것과 객체의 수명이 적절하게 관리되어 참조 해제나 순환 참조 문제를 방지한다는 것이다.

 

메모리 적으로는 어떻게 움직일까?

 

1. 객체 생성 (CompletionHandlerWrapper)

runJavaScriptAlertPanelWithMessage 메서드 내에서 CompletionHandlerWrapper 객체가 생성되면, 이 객체는 힙(Heap) 메모리에 할당. 이는 alloc 메서드를 통해 이루어지며, 강한 참조로 해당 객체를 메모리에 유지한다.

강한 참조가 있는 동안은 이 객체는 메모리에 남아있으며 해제되지 않는다.

(==> runJavaScriptAlertPanelWithMessage 메서드 안에서 생성되지만, 메서드를 벗어난 후에도 힙에 객체가 유지)

 

CompletionHandlerWrapper 객체가 힙에 유지되는 이유

Objective-C에서 ARC를 사용하여 메모리 관리. 
강한 참조가 있는 한, 객체는 메모리에 남아 있게 됨.
즉, 누군가가 CompletionHandlerWrapper 객체에 강하게 참조하고 있다면, 그 객체는 메모리에서 해제되지 않고 유지한다는 것.

runJavaScriptAlertPanelWithMessage 메서드에서 CompletionHandlerWrapper 객체를 생성할 때, 이 객체는 힙에 할당 됨.
힙에 있는 객체는 그 객체를 참조하는 다른 곳에서 필요로 하는 동안 메모리에 남아 있게 됨.

completionHandlerWrapper는 메서드 로컬 변수이지만, 해당 객체는 힙에 할당되어 있고, UIAlertController의 액션 블록에서 참조되기 때문에 여전히 메모리에 유지.
(여기서 블록은 기본적으로 스택에 저장되어서 이상하다고 생각했는데, ARC는 블록이 특정한 상황일 때 힙으로 이동시키는데,
1. 블록이 객체 속성으로 저장될 때 (strong 참조로).
2. 블록이 다른 블록이나 객체로 전달될 때.
3. 비동기 작업에서 사용될 때. (UI작은 대부분이 비동기로 이루어지기 때문에 UIAlertAction의 handler도 힙에 들어가있는 걸 알 수 있다))

즉, 메서드를 벗어나도 힙에 남아 있는 이유
• runJavaScriptAlertPanelWithMessage 메서드를 벗어나더라도, completionHandlerWrapper는 UIAlertController와 그 블록에 의해 강하게 참조되고 있기 때문에 힙에 남아 있다.
• 블록이 실행될 때까지 completionHandlerWrapper는 메모리에서 유지되며, OK 버튼을 누른 후 핸들러가 호출되면 객체에 대한 참조가 해제되어 ARC에 의해 메모리에서 해제된다.

==> 이를 통해 알 수 있는 것은 UIAlertController 안에 UIAlertAction이 포함되어 있다. UIAlertAction의 handler에서 completionHandlerWrapper를 사용하고 있으므로 UIAlertAction이 해제되면 completionHandlerWrapper도 메모리에서 해제된다.

 

2. 블록의 메모리 위치 (completionHandler)

CompletionHandlerWrapper가 초기화될 때, completionHandler 블록을 받는다. Objective-C에서 블록은 기본적으로 스택(Stack) 메모리에 할당되며, 메서드가 종료되면 해당 스택 메모리도 해제될 수 있다.

하지만 [completionHandler copy]를 호출하면, 스택에 있던 블록이 힙으로 복사된다. 이는 해당 블록을 나중에 사용하기 위함이며, 블록의 참조가 강하게 유지된다. 즉, 이 블록은 객체가 해제될 때까지 힙에 남아 있게 됨.

 

3. UIAlertController와 OK 버튼 클릭

경고창(UIAlertController)이 표시되고, 사용자가 OK 버튼을 클릭하면, respondHandler가 호출된다. 이때 completionHandler가 실행 됨.

completionHandler가 호출된 이후에는 isHandlerCalled 플래그가 YES로 변경되고, completionHandlernil로 설정. 이로 인해 더 이상 이 블록에 대한 강한 참조가 유지되지 않으므로, 블록은 메모리에서 해제된다.

 

4. dealloc에서의 동작

만약 사용자가 OK 버튼을 누르지 않고 객체가 해제되려고 하면, dealloc 메서드가 호출.

이때 respondHandler를 통해 completionHandler가 기본값으로 한 번 호출된다. 블록이 호출되면, 블록의 강한 참조가 해제되고, 이후 객체는 메모리에서 해제된다.

 

5. 메모리 해제

모든 작업이 끝나면 CompletionHandlerWrapper 객체는 더 이상 강한 참조가 없으므로 ARC에 의해 자동으로 힙에서 해제됨.

이때 블록도 해제되며, 객체와 블록이 모두 메모리에서 안전하게 해제된다.

 

그러면 이 클래스를 이용했다고 하고,

alert이 떠 있는 상태에서 앱이 백그라운드에서 죽거나

예기치 못한 어떠한 이유로 alert의 ok버튼을 누르지 못했는데 alert이 띄워져 있는 viewController가 죽었다면

어떤 일이 일어날까?

 

1. 앱이 백그라운드에 있다가 종료되거나 예기치 못하게 UIAlertController가 뜬 viewController가 해제되는 경우

 

앱이 백그라운드에 들어가거나, UIAlertController가 표시된 상태에서 해당 viewController가 해제될 경우를 생각해보면, 두 가지 중요한 동작이 일어날 수 있다:

 

(1) 앱이 백그라운드에 있다가 종료될 때

앱이 백그라운드 상태에서 시스템 자원 부족으로 종료된다면, 일반적으로 모든 객체가 해제된다. 이 경우, CompletionHandlerWrapper 객체 역시 해제됨.

 

이때, 메모리 관리 방식에 따라 dealloc 메서드가 호출. ARC(Automatic Reference Counting)에 의해 CompletionHandlerWrapper 객체와 그 안의 completionHandler 블록에 대한 모든 강한 참조가 해제되면, dealloc이 호출됨.

dealloc이 호출되면 미리 설정해 둔 로직에 따라 completionHandler가 호출되지 않았다면 defaultValue를 전달하며 한 번 호출하게 되고, 그 후 메모리에서 안전하게 해제.

(강한 참조가 해제된다고 메모리에서 해제되는게 아니기 때문에 completionHandler를 호출해도 문제가 없는건지?)

 

(2) viewController가 예기치 않게 해제될 때

만약 UIAlertController를 표시한 viewController가 어떤 이유로든 해제된다면, 이 viewController가 참조하는 모든 객체에 대한 강한 참조도 해제된다.

 

이 경우 CompletionHandlerWrapper 객체 역시 해제. 마찬가지로 이 객체에 대한 모든 강한 참조가 해제되면 ARC에 의해 자동으로 메모리에서 해제되며, dealloc 메서드가 호출.

dealloc 메서드에서, isHandlerCalled 플래그를 체크하고, 만약 핸들러가 호출되지 않았으면, 기본값으로 completionHandler를 호출하고, 객체는 메모리에서 해제.

 

2. 정리: 메모리 해제 및 dealloc 호출

백그라운드에서 종료될 때: 시스템이 앱을 강제 종료하면 viewController, CompletionHandlerWrapper, completionHandler가 모두 해제되며, 이 과정에서 ARC가 객체의 참조를 해제하고 dealloc이 호출.

viewController가 해제될 때: UIAlertController를 띄운 viewController가 해제되면, 해당 viewController가 소유하고 있는 CompletionHandlerWrapper 객체와 completionHandler도 참조가 해제되어 메모리에서 사라지며, 역시 dealloc이 호출.

 

3. 정확한 dealloc 호출 흐름

1. 백그라운드 종료 또는 viewController 해제 → 2. CompletionHandlerWrapper에 대한 참조 해제 → 3. ARC에 의해 객체 해제 → 4. dealloc 호출 → 5. 핸들러가 호출되지 않았으면 defaultValue로 핸들러 호출 → 6. 객체와 블록이 메모리에서 해제

 

이러한 과정에서 CompletionHandlerWrapper의 수명 주기는 viewController나 시스템 상태에 따라 안전하게 관리된다.

dealloc이 호출되면 정의된 로직에 따라 completionHandler가 안전하게 한 번만 호출되도록 보장된다.

 

 

CompletionHandlerWrapper클래스를 쓸 때 약한 참조를 써야 하는 것 아닐까?

라는 생각이 들어서 이것도 알아보았다.

일단 약한 참조를 쓰는게 좋을 때가 있고 아닐 때가 있는 것 같다.

지금처럼 호출하거나 dealloc하기 전까지 참조가 무조건 유지되어야 하는 경우에는

약한 참조를 사용하게 되면 객체의 수명이 제대로 보장되지 않아서 의도치 않게 메모리에서 해제될 수 있어서 앱이 죽을 수 있다.

 

약한 참조를 사용하면 CompletionHandlerWrapper가 필요할 때 이미 해제되어 있을 가능성이 큼.
OK 버튼을 눌렀을 때 핸들러가 호출되지 않거나, null pointer 접근으로 인해 앱이 크래시될 수 있음.
따라서 CompletionHandlerWrapper는 강한 참조로 관리되어야 함.
약한 참조는 객체가 언제 해제될지 모르기 때문에 핸들러의 정상적인 호출을 보장할 수 없음.

 

또한 지금까지의 CompletionHandlerWrapper의 메모리 생명 주기를 볼 때

UIAlertController와 UIAlertAction의 참조 체인에 따라 ARC가 적절하게 메모리를 해제할 것이기 때문에

순환 참조가 발생할 가능성이 거의 없다.

 

ChatGPT 피셜 :
1. 참조 체인이 일방적:
• CompletionHandlerWrapper 객체는 UIAlertAction의 핸들러 블록에서만 참조되고 있으며, UIAlertAction이나 UIAlertController가 CompletionHandlerWrapper 객체를 다시 참조하는 구조가 아니라면 순환 참조가 발생하지 않습니다.
• UIAlertAction이 해제되면 ARC에 의해 CompletionHandlerWrapper가 자연스럽게 해제되기 때문에, 이 경우 약한 참조를 사용하지 않아도 안전하게 메모리 관리가 가능합니다.
2. 명확한 수명 주기:
• UIAlertController가 해제될 때 CompletionHandlerWrapper도 해제되므로, 이 과정에서 CompletionHandlerWrapper의 참조를 명확히 관리할 수 있습니다.
• completionHandler는 한 번만 실행되도록 보장되기 때문에, 중복 호출이나 메모리 누수 문제가 발생할 가능성도 적습니다.

CompletionHandlerWrapper
는 특정 블록을 한 번만 실행하고, 그 후 객체를 해제하는 구조이기 때문에 
내부에서 약한 참조가 필요한 상황은 적습니다
. 특히 다음과 같은 상황에서는 약한 참조를 굳이 사용할 필요가 없습니다:
    • 순환 참조가 발생하지 않는 구조: 클래스 간 참조가 단방향(한쪽에서만 참조하는 구조)이기 때문에, 순환 참조 문제가 발생하지 않습니다.
• 명확한 해제 시점: UIAlertController가 해제되면 CompletionHandlerWrapper도 자동으로 해제되므로, 추가적인 메모리 관리를 위한 약한 참조는 필요하지 않습니다.

그러나 약한 참조는 순환 참조를 예방하기 위해 여전히 중요한 개념입니다. 예를 들어:

• 강한 참조 순환이 발생하는 경우: 두 객체가 서로를 참조하거나, 객체와 블록이 서로 강하게 참조할 때 발생하는 순환 참조를 방지하려면 weak 키워드가 필요합니다.
• 상위 객체와 하위 객체 간의 참조: 하위 객체에서 상위 객체(self)를 참조하는 경우, 상위 객체가 해제되지 않을 가능성이 있습니다. 이럴 때 약한 참조를 사용하여 순환 참조를 방지할 수 있습니다.

결론

CompletionHandlerWrapper 클래스의 현재 사용 사례에서는 약한 참조가 필요하지 않다고 판단할 수 있습니다. 순환 참조가 발생하지 않고, 명확한 수명 주기 관리가 이루어지기 때문입니다. 따라서, 강한 참조만으로도 충분히 안전하게 메모리 관리를 할 수 있으며, 추가로 weak 참조를 사용할 이유가 없습니다.

하지만 이 결론은 특정 사례에 한정되며, 객체 참조 구조가 조금만 복잡해져도 약한 참조를 적절히 사용하는 것이 중요할 수 있습니다.

 

즉, CompletionHandlerWrapper 객체는 강하게 참조되어야만, UIAlertController가 표시된 동안 핸들러가 안전하게 호출될 수 있다. 약한 참조를 사용하면 객체가 필요할 때 이미 해제되어 있을 수 있으므로, 강한 참조로 메모리 관리를 해야만 의도한 대로 핸들러 호출을 보장할 수 있다.

 

 

일단 여러 내용이 접목되어 있다보니

일단 모두 짜집기해서 넣느라 글이 굉장히 복잡해졌다.

정리하는데에 더 최선이 있다면

글을 수정해야겠다..

블로그의 정보

Beautiful Coding

사슴비행기

활동하기