코딩캣: 코딩하는 고양이.
[번역글] Objectiv-C 블록(block)으로 작업하기(2)
Language/Objective-C & Swift
2021. 9. 22. 15:01

이 글은 애플 측 개발자 문서인 Working with Blocks를 바탕으로 작성한 것입니다. Swift의 ‘클로저(closure)’에 대응하는 Objective-C의 ‘블록(block)’에 대한 내용입니다.

Objectiv-C 블록(block)으로 작업하기

Objective-C 클래스는 데이터 및 이에 관계된 행동으로 구성된 객체를 정의합니다. 때때로 Objective-C에서는 일련의 메소드들보다는 하나의 작업 또는 단위 행동을 표현하는 다른 방법을 찾는 것이 더 나은 경우도 있습니다. 블록(block)은 C, Objective-C 및 C++에서 마치 변수처럼 다른 곳으로 어떤 메소드나 함수 조각을 전달하기 위한 언어 수준에서 정의된 기능입니다. 블록은 Objective-C에서 객체인데 이는 NSArrayNSDictionary 같은 콜렉션으로 추가될 수 있음을 뜻합니다. 블록은 또한 자신을 감싸고 있는 스코프에서 값을 캡쳐할 수 있는 능력을 가지고 있는데 이는 타 프로그래밍 언어에서 ‘클로저(closure)’나 ‘람다(lambda)’와 비슷합니다. 이 장에서는 블록을 선언하고 참조하는 구문에 대해 설명하고 콜렉션 열거와 같은 통상적인 작업에서 블록을 사용하는 방법에 대해 보일 것입니다.

  1. 블록 기본 구문; 블록의 매개변수 및 반환 형식
  2. 블록의 스코프 캡쳐와 strong 순환 참조 해결
  3. 블록 응용 예

 

블록은 자신을 감싸는 스코프로부터 값들을 캡쳐할 수 있다

통상적인 실행 구문 뿐만 아니라 블록도 자신을 감싸는 스코프에 있는 상태를 캡쳐할 수 있는 능력을 갖고 있습니다. 여러분이 어떤 메소드 안에서 블록을 선언했다면, 그 메소드의 스코프 안에서 접근할 수 있는 모든 변수들은 블록에서도 접근이 가능합니다.

// Objective-C
- (void)testMethod {
    int anInteger = 42;
    
    void (^testBlock)(void) = ^{
        NSLog(@"Integer is %i", anInteger);
    };
    
    testBlock();
}

이 예제에서 anInteger는 블록의 바깥에 선언되어 있지만 블록을 정의하고 있는 그 스코프 내에서 캡쳐가 되었기에 블록 내에서도 사용이 가능합니다. 여러분이 명시하지 않는 이상 블록이 정의될 당시의 값만이 캡쳐되는데, 다시 표현하면 여러분이 블록을 선언하고 그 블록을 호출하는 도중에 값이 변경되었다면 이는 반영되지 않습니다.

// Objective-C
- (void)testMethod {
    int anInteger = 42;
    
    void (^testBlock)(void) = ^{
        NSLog(@"Integer is %i", anInteger);
    };
    
    anInteger = 84;
    
    testBlock();
}

블록이 정의되고 변수들이 캡쳐된 이후 그 변수들의 값이 변경되었어도 이는 블록 내에 다시 반영되지 않습니다. 이것은 다음과 같이 출력 결과로 알 수 있습니다.

캡쳐된 변수의 값 변화가 없는 Block 예제 소스.
값 변화가 없는 Block 예제 소소의 실행 결과.

블록은 변수의 원 값을 바꿀 수 없다는 것을 의미하기도 합니다. 마치 const 속성으로 캡쳐되기 때문입니다.

 

저장소를 공유하기 위해 __block 변수를 사용하기

블록 안에서 캡쳐된 변수의 값을 변경할 필요가 있을 때 여러분은 변수 선언문에서 __block 저장소 지정자를 사용할 수 있습니다. 이것은 저장소의 변수가 원 변수 및 그 스코프 내에서 선언된 블록들 사이에서도 공유됨을 의미합니다. 앞서 적었던 예제를 다음과 같이 작성할 수 있습니다.

// Objective-C
- (void)testMethod {
    __block int anInteger = 42;
    
    void (^testBlock)(void) = ^{
        NSLog(@"Integer is: %i", anInteger);
    };
    
    anInteger = 84;
    
    testBlock();
}

anInteger 변수는 __block 지시자로 선언되었기 때문에 이 변수의 저장소는 블록이 정의되는 곳과 공유됩니다. 이것은 다음의 출력 결과로써 확인이 가능합니다.

캡쳐된 변수의 값 변화가 있는 Block 예제 소스.
값 변화가 있는 Block 예제 소소의 실행 결과.

또한 __block 지시자는 블록 내에서 다음과 같이 원 변수의 값을 수정할 수 있음을 뜻합니다.

// Objective-C
- (void)testMethod {
    __block int anInteger = 42;
    
    void (^testBlock)(void) = ^{
        NSLog(@"Integer is: %i", anInteger);
        anInteger = 100;
    };
    
    testBlock();
    NSLog(@"Value of original variable is now: %i", anInteger);
}

출력은 다음과 같을 것입니다.

블록 내에서 값 변화가 있는 예제 소스.
블록 내 값 변화가 있는 예제의 실행 결과.

 

블록은 함수나 메소드의 매개변수로서 전달될 수 있다

앞서 살펴본 매개변수들은 블록을 정의한 즉시 호출합니다. 실무에서는 블록을 다른 곳에서 호출시키기 위해 함수나 메소드의 매개변수로 넘기는 경우가 일반적입니다. 예를 들어 블록을 백그라운드에서 호출시키기 위해, 또는 반복적으로 호출되는 작업(예를 들어 콜렉션의 각 원소들을 열거하는 것)을 나타내는 블록을 정의하기 위해 Grand Central Dispatch를 사용하게 될 수도 있습니다. 이 장의 후반부에서는 동시성과 열거에 대해 좀 더 알아보도록 하겠습니다.

블록은 또한 어떤 작업이 완료되었을 때 호출될 코드 조각을 정의하는 ‘콜백(callback)’ 목적으로 사용되기도 합니다. 예를 들어 여러분의 앱은 복잡한 작업을 수행하는 객체를 생성할 때 또한 사용자의 행동에도 반응해야 할 수 있습니다. 웹 서비스에 정보를 요청하는 경우가 그런 예이지요. 특히 웹과 관련된 작업은 상대적으로 소요 시간이 길기 때문에 여러분은 작업이 진행되는 동안 진행율 표시기(progress indicator) 같은 것을 사용자에게 보일 필요가 있습니다. 그리고 작업이 완료되면 진행율 표시기를 숨겨야 합니다.

이와 같은 직업은 델리게이션(delegation)으로써 구현할 수 있습니다. 이를 위해서 여러분은 적절한 델리게이트 프로토콜을 만들어야 하고, 필요한 메소드들을 구현해야 하며, 오래 걸리는 작업에 대한 델리게이트로서 여러분의 객체를 설정해야 하고, 작업이 완료되어 여러분에 객체 안에 만들어 놓은 델리게이트 메소드가 호출될 때까지 기다려야 할 것입니다. 블록은 이와 같은 조치들을 간단하게 만듭니다. 여러분은 작업을 초기화할 때 콜백의 행동을 다음과 같이 정의할 수 있기 때문입니다.

// Objective-C
- (IBAction)fetchRemoteInformation:(id)sender {
    [self showProgressIndicator];
    
    XYZWebTask * task = /* ... */;
    
    [task beginTaskWithCallbackBlock:^{
        [self hideProgressIndicator];
    |;
}

위 예제는 진행율 표시기를 나타내는 메소드를 호출한 다음에 작업을 생성하고 시작합니다. 콜백 블록은 작업이 완료된 후 실행될 코드를 지정하고 있는데, 이 경우 진행율 표시기를 숨깁니다. 블록 내에서 hideProgressIndicator 메소드가 호출되기 위하여 self가 캡쳐되고 있음을 주목하기 바랍니다. 이 글의 후반부에서 다시 설명하겠지만 strong 참조 순환이 발생하기 쉽기 때문에 self를 캡쳐할 때는 특별히 주의해야 합니다.

소스 코드의 가독성을 위해 블록은 작업이 발생하기 전 또는 발생한 후에 실행될 것으로 예측되는 그 장소에 적혀있어야 합니다. 이렇게 하면 내부적으로 호출되고 수행되는 작업들을 찾아가기 위해 델리게이트 메소드들을 따라 추적할 필요가 없게 됩니다.

이 예제에서 beginTaskWithCallbackBlock 메소드의 선언은 다음과 같이 생겼을 것입니다.

- (void)beginTaskWithCallbackBlock:(void (^)(void))callbackBlock;

(void (^)(void)) 타입은 매개변수가 없고 반환 형식도 없는 블록을 뜻합니다. 이 메소드의 내부 구현에서 블록 호출은 다음과 같을 것입니다.

// Objective-C
- (void)beginTaskWithCallbackBlock:(void (^)(void))callbackBlock {
    // ...
    callbackBlock();
}

하나 이상의 매개변수를 받는 블록에 대한 타입도 다음과 같이 지정 가능합니다.

// Objective-C
- (void)doSomethingWithBlock:(void (^)(double, double))block {
    // ...
    block(21.0, 2.0);
}

 

블록은 항상 메소드의 마지막 매개변수가 되어야 한다

메소드에서 블록형 매개변수는 하나만 받는 것이 가장 좋은 실무입니다. 메소드가 블록형이 아닌 매개변수도 함께 받을 경우 블록 매개변수는 맨 마지막에 와야 합니다.

- (void)beginTaskWithName:(NSString *)name completion:(void (^)(void))callback;

이렇게 선언된 메소드는 호출 시 블록을 인라인으로 작성할 때 그 문장을 읽기 좋게 만듭니다.

// Objective-C
[self beginTaskWithName:@"MyTask" completion:^{
    NSLog(@"The task is complete.");
}];

 

블록 구문을 단순화하기 위하여 타입 재정의를 사용하기

동일한 시그니처를 갖는 블록을 여러 번 사용한다면 여러분은 그 시그니처에 대해 여러분만의 타입으로 재정의할 수 있습니다. 예를 들어 다음과 같이 매개변수도 없고 반환 형식도 없는 타입을 재정의합니다.

typedef void (^XYZSimpleBlock)(void);

그 다음 여러분은 블록 변수나 블록 매개변수를 사용할 때 이 타입을 적을 수 있습니다.

// Objective-C
XYZSimpleBlock anotherBlock = ^{
    // ...
};

- (void)beginFetchWithCallbackBlock:(XYZSimpleBlock)callbackBlock {
    // ...
}

특히 재정의된 블록 타입은 블록 자체를 반환하거나 블록이 매개변수로서 또 다른 어떤 블록을 받을 때 유용합니다.

// Objective-C
void (^(^complexBlock)(void (^)(void)))(void) = ^(void ^(aBlock)(void)) {
    // ...
    return ^{
        // ...
    };
};

complexBlock 변수는 불록을 매개변수로 받으면서 그 반환 형식 또한 블록인 어떤 블록을 참조하고 있습니다. 타입 재정의를 사용하면 이러한 소스 코드를 보다 가독성 있게 작성할 수 있습니다.

// Objective-C
XYZSimpleBlock (^betterBlock)(XYZSimpleBlock) = ^(XYZSimpleBlock){
    // ...
    return ^{
        // ...
    };
};

 

객체는 블록을 기억해 두기 위해 프로퍼티를 사용한다.

블록을 기억해 둘(keep track of) 목적으로 프로퍼티를 정의하는 구문은 블록 변수를 정의하는 구문과 유사합니다.

// Objective-C
@interface XYZObject : NSObject
@property (copy) void (^blockProperty)(void);
@end

참고 스코프를 벗어나도 그 블록이 캡쳐하고 있던 상태를 유지하기 위해서는 프로퍼티 속성으로 copy를 지정해야 합니다. 이것은 여러분이 ARC를 사용할 때 염려하던 것과는 경우가 다릅니다.

블록 프로퍼티도 다른 블록 변수들과 마찬가지로 대입되고 호출됩니다.

// Objective-C
[self setBlockProperty:^{
    // ...
}];
[self blockProperty]();

또한 다음과 같이 재정의된 타입을 사용하는 것도 됩니다.

// Objective-C
typedef void (^XYZSimpleBlock)(void);

@interface XYZObject : NSObject@
@property (copy) XYZSimpleBlock blockProperty;
@end

 

self를 캡쳐할 때 strong 순환 참조를 피하기

콜백 블록과 같이 여러분이 블록 안으로 self를 캡쳐할 때, 메모리 관리 구현 방법을 고려하는 것이 중요합니다. 블록은 캡쳐된 객체에 대해 strong 참조를 유지합니다. 이는 self도 마찬가지입니다. 예를 들어 self를 캡쳐하는 어떤 블록에 대한 프로퍼티가 copy 속성을 가지고 있다면 결국 strong 순환 참조가 발생하게 됩니다.

// Objective-C
@interface XYZBlockKeepter : NSObject
@property (copy) void (^block)(void);
@end
@implementation XYZBlockKeeper
@synthesize block;
- (void)configureBlock {
    [self setBlock:^{
        [self doSomething]; // self에 대한 strong 참조
        // 이후 strong 순환 참조 발생
    }];
}
@end

이런 간단한 소스 코드라면 컴파일러는 여러분에게 경고해 줄 것입니다. 그러나 복잡한 예제라면 객체들 사이에 여러 strong 참조가 얽히게 되어서 컴파일러의 진단이 어려울 수 있습니다. 이런 문제를 피하기 위해 self에 대해서는 다음과 같이 weak 참조를 하도록 지정합니다.

// Objective-C
- (void)configureBlock {
    XYZBlockKeeper * __weak weakSelf = self;
    [self setBlock:^{
        [weakSelf doSomething]; // weak 참조를 캡쳐해 왔음
        // 순환 참조를 피할 수 있음
    }];
}

self를 weak 포인터로 참조한 변수를 캡쳐함으로써 블록은 XYZBlockKeeper 객체를 거꾸로 strong 참조하지 않게 됩니다. 블록이 호출되기 전에 객체가 소멸되었다면 weakSelf 포인터는 단순히 nil을 가리키게 될 것입니다.

 

'Language/Objective-C & Swift' 카테고리의 다른 글
더 보기...
태그 : 
댓글