Overview
- 프로그래밍 언어인 Objective-C에 관하여 간략히 조사해 본다.
- Objective-C를 Swift 코드처럼 사용하며 문법에 빠르게 적응해 본다.
사전 지식
💡독자가 C언어와 객체지향 프로그래밍에 관한 경험이 있음을 전제하고 작성한 포스팅이다.
Objective-C
Objective-C는 macOS와 iOS용 소프트웨어를 개발할 때 사용되었던 프로그래밍 언어이다. C언어를 기반으로 OOP와 동적 런타임 기능을 확장하여 개발되었다. C언어의 기본 타입과 흐름 제어 문법을 사용 가능한 특징이 있으며 클래스와 메서드를 정의하는 등 OOP 지원을 위해 Objective-C 만의 문법이 추가되었다.
Hello World
Xcode에서 macOS용 CLI 프로젝트를 생성 후 간단한 Hello World 프로그램을 작성해 보았다.
#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSString *message = @"Hello, World!";
NSLog(@"%@", message);
}
return 0;
}
main
함수의 정의를 살펴보면 C언어의 특징이 보인다. 입출력 인터페이스에 사용된 int
기본 타입과 함수가 정의된 방식이 그 사례이다. 각종 연산자의 사용과 흐름제어문 역시 C언어의 문법과 유사하다.
Objective-C만의 특징도 보인다. NSString 타입은 Objective-C 세계에서 문자열을 표현하는 오브젝트이다. 문자열 리터럴로도 표현할 수 있는데 다른 프로그래밍 언어들과 다르게 @
을 함께 사용해줘야 한다. Objective-C와 친해지기 위해 간단한 코딩을 해보며 배워보자!
C언어 중심 기초 코딩
랜덤 하게 뽑은 숫자만큼 count를 누적해서 배열에 담고 반복문을 통해 데이터를 출력하는 기능을 만들어볼 것이다. 다음은 C언어 함수를 중심으로 작성해 본 Objective-C 코드이다.
NSInteger countRandomNumber(void) {
NSInteger count = 0;
NSInteger randomMaxRange = arc4random() % 101;
while (count<randomMaxRange) {
count += 1;
}
return count;
}
while
문을 통해 반복을 돌리는 문법이나 연산자의 사용법은 익숙할 것이다. arc4random 함수는 랜덤 한 정수값을 뽑아주는 함수이다.
NSInteger
은 C언어의 int 타입의 별칭이기에 기본 타입이다. 애플 아키텍처에 알맞게 정적 메모리 크기가 조정된다. 메모리 레이아웃 관점에서 스택에 정적 메모리 할당되어 생성되기에 포인터가 필요 없다.
NSInteger
를 사용하면 일반적인 연산에는 무리 없으나 컬렉션에 담아줄 때 문제가 발생한다. 다음은 랜덤 하게 생성한 숫자를 배열에 담아주는 Objective-C 코드이다.
NSArray* generateRandomNumbers(void) {
NSMutableArray *numbers = [NSMutableArray array];
for (NSInteger i=0; i<10; i++) {
NSInteger count = countRandomNumber();
NSNumber *number = [NSNumber numberWithInteger:count];
[numbers addObject:number];
}
return numbers;
}
for
문을 통해 반복을 돌리는 문법은 익숙할 것이다. 다만, [NSMutableArray array]
와 같은 표현이 어색할 수 있는데 메서드를 호출하는 Objective-C의 방법이다. 자주 볼 것이기에 익숙해져야 한다.
대표적으로 많이 사용되는 컬렉션으로 Array, Set, Dictionary가 있다. Objective-C 세상에서는 각각 NSArray, NSSet, NSDictionary 타입으로 존재한다. 이들 모두 기본적으로는 Swift 상수처럼 한번 생성되면 컬렉션 내부 데이터를 변경할 수 없다. 각각 수정 가능한 서브 클래스인 NSMutableArray
, NSMutableSet
, NSMutableDictionary
를 사용하여 데이터를 수정하는 메서드를 사용할 수 있다.
앞서 컬렉션에 데이터가 담긴다고 표현했다. 엄밀히 말하자면 오브젝트가 관리되는 것이다. 데이터 관점에서는 오브젝트의 주소값을 관리하고 있는 것이다. 따라서 NSInteger
등과 같은 기본 타입을 컬렉션에 담기 위해 오브젝트로 만들어줘야 한다.
NSNumber
은 숫자형 기본 데이터 타입을 관리하는 오브젝트이다. NSInteger 값을 NSNumber 오브젝트로 감싸서 오브젝트화 시킬 수 있다. NSNumber의 인스턴스로 만든 후에는 NSArray 등의 컬렉션에 넣어줄 수 있으며 함수에서 오브젝트를 반환할 때에는 동적 할당된 메모리 주소값을 넘겨주기에 포인터를 사용해야 한다. 다음은 컬렉션에 담긴 데이터를 출력하는 예시 코드이다.
void print(const NSArray *numbers) {
for (NSObject *object in numbers) {
if ([object isKindOfClass:NSNumber.class]) {
NSNumber *number = (NSNumber*) object;
NSLog(@"%@", number.stringValue);
}
}
}
for
문의 사용법이 약간 달라졌다. 컬렉션의 데이터에 접근할 때는 for-in
문법을 사용할 수 있다. Swift 문법을 보다가 위 코드를 보면 느껴지는 문제점이 있을 것이다. 컴파일 타임에서 타입을 엄격히 검사하는 Swift와 다르게 Objective-C는 많은 부분을 런타임에 의존한다. NSArray와 같은 컬렉션 오브젝트는 오브젝트 주소값을 데이터로 갖고 관리한다. 그래서 어디에서도 NSNumber을 관리하는 NSArray다라는 표현을 찾을 수 없다.
Objective-C 세상의 모든 객체는 NSObject를 상속하여 개발된다. 따라서 컬렉션 내부에 담긴 모든 오브젝트 클래스의 근원으로 올라가면 NSObject이다. 반복문을 통해 컬렉션에서 데이터를 꺼내왔을 때 프로그래머가 찾고자 하는 오브젝트가 맞는지 확인해야 한다. NSObject에서는 클래스 계층 구조를 활용해 런타임에서 타입을 구분할 수 있는 메서드를 제공한다. isKindOfClass
메서드를 사용하여 검사해 볼 수 있다. 최종적으로 코드는 다음과 같이 호출해 볼 수 있다.
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSArray *numbers = generateRandomNumbers();
print(numbers);
}
return 0;
}
객체지향 프로그래밍
Objective-C에서 제공하는 기본 타입뿐만 아니라 세상을 표현하는 새로운 오브젝트를 사용자 정의해보고 싶을 수 있다. Objective-C의 OOP 확장 문법을 사용하여 사용자 정의 타입을 만들 수 있다. Objective-C의 OOP 문법을 사용해 위 코드를 사용자 정의 타입으로 리팩터링 해보고자 한다.
먼저 클래스를 정의하는 방법이다. 클래스는 크게 인터페이스 부분(*.h 파일)과 구현 부분(*.m 파일)으로 나뉜다. 인터페이스 부분에는 클래스에서 사용될 프로퍼티와 메서드의 형식을 정의하고 구현 부분을 통해 메서드에 동작을 붙인다. 다음은 인터페이스 코드의 예시이다.
@interface MyObject : NSObject < /* 프로토콜 선언부 */ >
// 어트리뷰트 선언 예시
@property NSMutableArray *numbers;
// 인스턴스 메서드 예시 (반환 타입) 이름 외부레이블명:(파라미터 타입)내부레이블명 ...
- (void) run;
- (void) print;
// 클래스 메서드 예시 (반환 타입) 이름 외부레이블명:(파라미터 타입)내부레이블명 ...
// + (instancetype) initWithData:(NSData *)data
@end
NSObject를 상속받아서 MyObject라는 클래스를 정의한다. 프로퍼티 속성 값으로 NSMutable 타입의 numbers 포인터 변수를 선언했다. 외부에 노출할 2개의 메서드인 run과 print를 선언했다. 프로토콜을 적용하고 싶다면 NSObject 옆 꺽쇠 부분에 채택할 프로토콜을 명시하면 된다.
인터페이스의 선언부는 @interface
로 열고 @end
로 닫는다.
메서드 정의부를 보면 -
부호를 볼 수 있다. 이는 해당 메서드가 타입 메서드인지 아니면 인스턴스 메서드인지 구분한다. 전자는 +
기호를 후자는 -
기호를 붙여서 메서드를 정의하는 형식이다. 이후에 함수명과 파라미터를 정의할 수 있다. Swift와 유사한 점은 파라미터의 내외부 레이블 명을 선언할 수 있는 점이다.
다음은 MyObject에 구현을 붙이는 코드이다. 두려워할 것 없다. 인터페이스 선언과 동일하게 써두고 중괄호에 구현 내용만 붙여주면 된다.
@implementation MyObject
- (void) run {
[self generateRandomNumbers];
[self print];
}
- (void) print {
for (NSObject *object in self.numbers) {
if ([object isKindOfClass:NSNumber.class]) {
NSNumber *number = (NSNumber*) object;
NSLog(@"%@", number.stringValue);
}
}
}
- (NSInteger) countRandomNumber {
NSInteger count = 0;
const NSInteger maxRange = 101;
NSInteger randomRange = arc4random() % maxRange;
while (count<randomRange) {
count += 1;
}
return count;
}
- (void) generateRandomNumbers {
self.numbers = [NSMutableArray array];
for (NSInteger i=0; i<10; i++) {
NSInteger count = [self countRandomNumber];
NSNumber *number = [NSNumber numberWithInteger:count];
[self.numbers addObject:number];
}
}
@end
C에서 함수를 정의하는 방법이 Objective-C에서 메서드를 정의하는 방식으로 인터페이스가 수정되었다. 또한 Objective-C의 오브젝트의 메서드를 호출할 때 대괄호를 사용한 호출법으로 변경되었다. 내부에 사용된 구현은 C언어 중심 버전과 동일하다. 변경된 메인 함수 부분은 다음과 같다.
int main(int argc, const char * argv[]) {
@autoreleasepool {
MyObject *myObject = [[MyObject alloc] init];
[myObject run];
}
return 0;
}
프로토콜
iOS의 UIKit 프레임워크를 사용하다 보면 delegate 패턴으로 구현된 수많은 프로토콜을 볼 수 있다. 프로토콜은 메서드 규약으로 사용된다. 이전에 구현했던 코드를 프로토콜을 활용한 delegate 패턴으로 수정해 보겠다. 먼저 프로그램의 실행이 완료되는 이벤트를 전달할 delegate 프로토콜을 정의해 보았다.
@protocol MyObjectDelegate <NSObject>
@required // optional로 설정하면 구현을 생략할 수 있다.
- (void) runDidFinished;
@end
MyObject 클래스가 위임자이자 위임받는 자로 구현하도록 설정해 보았다. 프로그램이 종료되면 MyObject는 delegate에게 프로그램 완료 이벤트를 보낸다. delegate는 프로토콜에 구현된 메시지 규약에 맞추어 동작을 구현한다.
@interface MyObject : NSObject <MyObjectDelegate>
@property NSMutableArray *numbers;
// ARC 강한 참조 사이클을 조심!
// 클래스 타입의 익명성을 위해 프로토콜을 사용하기도함!
@property (weak) id<MyObjectDelegate> delegate;
// 기타 메소드 선언
...
@end
@implementation MyObject
- (void) run {
self.delegate = self;
[self generateRandomNumbers];
[self print];
// 본 프로그램이 완료되면 위임자에게 이벤트를 보낸다.
[self.delegate runDidFinished];
}
// 프로토콜을 충족하기 위해 구현한 필수 구현 메소드
- (void)runDidFinished {
NSString *myMessage = @"프로그램 종료시 실행되는 사용자 지정 메시지";
NSLog(@"%@", myMessage);
}
// 기타 메소드 구현
...
@end
프로토콜을 활용한 delegate 패턴 구현시 주의할 점이 있다. ARC 강한 참조 사이클이 발생하지 않도록 delegate 프로퍼티를 weak로 설정해 주는 것이다. 객체지향 측면에서 프로토콜의 특징도 볼 수 있다. 타입으로써 프로토콜을 사용하여 클래스 간 의존성을 줄여준다. 프로토콜에 정해진 규약대로 객체가 통신하기에 클래스 구현 변화에 영향을 덜 받는다.
확장
기존에 있는 클래스를 수정하지 않고 새로운 기능을 덧붙여야 할 수 있다. 예를 들어, NSString은 애플이 Foundation에 구현해 둔 타입이지만 개발자가 원하는 추가 기능을 개발하고 싶을 수 있다. 이때 사용하는 개념이 카테고리이다. Swift의 extension과 유사한 개념이다. NSString의 값을 콘솔에 프린트해 주는 메서드를 추가해 보고자 한다. Xcode의 Objective-C 파일 추가하기 기능을 통해 새로운 NSString 카테고리를 추가해 본다. Swift 익스텐션과 차이점이 있다면 Objective-C 카테고리는 이름을 갖고 있다는 점이다. 필자는 콘솔 출력 기능을 확장한다는 측면으로 Debug
라고 네이밍 해보았다.
// NSString+Debug.h
@interface NSString (Debug)
- (void) debugPrint;
@end
// NSString+Debug.m
@implementation NSString (Debug)
- (void) debugPrint {
NSLog(@"%@", self);
}
@end
클래스를 정의하는 방법과 크게 다르지 않다. 인터페이스와 구현 코드 옆에 소괄호에 카테고리 이름을 적어주는 부분의 차이점이 존재한다. 카테고리를 사용하려면 사용 코드부에서 카테고리 헤더 파일을 불러오면 사용할 수 있다.
#import "NSString+Debug.h"
@implementation MyObject
// ...
- (void)runDidFinished {
NSString *myMessage = @"프로그램 종료시 실행되는 사용자 지정 메시지";
// 카테고리 메소드 사용 예시
[myMessage debugPrint];
}
// ...
@end
블록
블록은 메서드나 함수에 코드 블록의 형태로 행위를 전달할 수 있게 해 준다. Swift에서 클로저로 부르기도 한다. 이런 기능이 왜 필요한지 의문을 가질 수 있다. 보통 운영체제의 동시성을 사용하는 비동기 API의 completionHandler를 구현할 때 많이 사용한다. 이번에는 네트워크 통신을 통해 웹 API에 요청하고 응답받은 JSON 데이터를 Foundation 오브젝트로 파싱 하는 기능을 구현하고자 블록을 사용해 본다. 기존의 코드에 printNetworkResult
메서드를 구현해 보겠다.
@interface MyObject : NSObject
// ...
- (void) printResult;
// ...
@end
@implementation MyCodingObject
// ...
- (void) printResult {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
NSURL *url = [NSURL URLWithString:@"https://baconipsum.com/api/?type=all-meat"];
NSURLSessionDataTask *dataTask = [NSURLSession.sharedSession
dataTaskWithURL:url
completionHandler:^(NSData * _Nullable data,
NSURLResponse * _Nullable response,
NSError * _Nullable error) {
if (error != nil) {
dispatch_semaphore_signal(semaphore);
return;
}
if (![response isKindOfClass:NSHTTPURLResponse.class]) {
dispatch_semaphore_signal(semaphore);
return;
}
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response;
NSRange successStatusCodeRange = NSMakeRange(200, 99);
if (!NSLocationInRange(httpResponse.statusCode, successStatusCodeRange)) {
dispatch_semaphore_signal(semaphore);
return;
}
if (data == nil) {
dispatch_semaphore_signal(semaphore);
return;
}
NSError *decodingError = nil;
NSArray *jsonData = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&decodingError];
if (decodingError != nil) {
return;
}
NSLog(@"%@", jsonData);
dispatch_semaphore_signal(semaphore);
}];
[dataTask resume];
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
}
// ...
@end
URLSession을 활용해 로렘입섬 텍스트를 제공하는 웹 API에 데이터를 요청하고 웹서버로부터 받아온 JSON 데이터를 NSArray로 파싱 하여 콘솔에 출력하고 있다. 네트워킹 코드는 비동기 API로 동작하기 때문에 통신 중 메인 스레드가 종료되지 않도록 동기화시켜 줄 필요가 있다. 위 코드의 예시에서는 디스패치 세마포어를 활용해 동기화시켜주고 있다.
블록의 개념은 URLSession의 dataTask를 호출하는 과정에서 completionHandler에 행위를 전달하는 데 사용되었다. 캐럿(^) 기호화 중괄호로 감싸서 블록 리터럴을 정의한다. 캐럿 기호와 함께 소괄호로 묶인 타입과 변수는 블록의 파라미터를 의미한다. URLSession 내부적으로 통신이 완료되면 completionHandler를 호출하며 파라미터에 데이터를 넘겨주도록 구현되어 있을 것이기에 프로그래머는 파라미터를 통해 통신 결과 데이터를 받아올 수 있다.
블록의 특징이라면 주변에 있는 값들을 캡처한다는 점이다. 따라서 가끔 self 인스턴스를 캡처하는 경우 ARC 강한 참조 사이클 문제가 발생될 수 있기에 약한 참조 버전의 self 포인터 변수를 만들어서 캡처하는 것이 안전하다.
// 예시 코드
XYZBlockKeeper * __weak WeakSelf = self;
self.block = ^{
[weakSelf doSomething]; // 약한 참조 캡처
// 참조 순환을 피하기 위해
}
Swift & Objective-C Interoperation
Swift와 Objective-C 코드는 서로 호환된다. Objective-C에 비하면 Swift는 비교적 역사가 짧다. C언어 중심의 코드를 사용하거나 오래된 Objective-C 코드를 사용하려면 Swift & Objective-C 상호운용성 개념을 사용해야 한다.
필자는 데이터의 암복호화 라이브러리를 사용하기 위해 상호운용성을 적용해야 했다. 예를 들어 대칭키 암복호화 라이브러리인 Common Cryptor는
C언어로 구현되어 있다. 따라서 Swift에서 대칭키 암복호화 기능을 사용하기 위해 C코드를 Objective-C 인터페이스로 랩핑하고 Swift 상호운용성을 통해 Swift 메서드로 불러와야 했다. 아래의 글을 읽기 전에 다음의 애플의 상호운용성 아티클을 먼저 읽고 와야 한다.
이번에는 이제까지 구현한 Objective-C 객체를 Swift로 불러와서 호출해 보고자 다시 MyObject로 돌아와 보겠다. 우선 새로운 Swift CLI 프로젝트를 생성했다. 여기서 objective-C 코드 파일을 생성하면 자동으로 브릿징 헤더 파일과 프로젝트 Build Setting의 설정이 완료된다. 프로젝트 설정용으로 생성한 파일이기에 다시 제거해 준다.
Objective-C 프로젝트에서 작업했던 코드 파일을 Swift 프로젝트에 추가해 준다. Xcode의 좌측 내비게이터에서 마우스 오른쪽 키를 누르면 "Add Files To 프로젝트명" 옵션을 고를 수 있다. 여기서 추가할 파일을 선택해 준다. command 키를 누른 상태로 파일을 선택하면 여러 개의 파일을 중복 선택할 수도 있다. 주의할 점이 있다. 새 프로젝트에 파일을 복사하기 위해 Copy items if needed 옵션을 활성화해 준다. 폴더는 create groups 옵션을 사용해야 Xcode 타깃에 소스 코드를 정상적으로 추가할 수 있다.
이제 개발자가 해야 할 일은 Swift의 세상에 노출할 Objective-C 헤더를 브릿징헤더 파일에 적어주는 것이다. 필자는 다음과 같이 적어보았다.
// (프로젝트명)-Bridging-Header.h
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "MyObject/MyObject.h"
이제 main.swift로 가서 MyObject를 추가한 뒤에 앞서 구현한 랜덤 넘버 코드를 구동시켜 보겠다. 코드는 다음과 같다.
import Foundation
let myObject = MyObject()
myObject.run()
이제 콘솔창에 정상적으로 출력되는 모습을 볼 수 있다.
글을 마치며
지금까지 둘러본 사례를 통해 빙산의 일각을 둘러보는 시간을 가져보았다.
프로그래밍 언어의 역사가 긴 만큼 다양한 버전의 Objective-C 문법과 개념이 존재한다. Swift 개발자가 처음으로 Objective-C를 마주치면 익숙하지 않은 문법에 막연한 두려움을 가질 수 있다. 이 가이드 포스팅은 iOS 프로그래머가 가질 막연한 두려움을 줄여준다면 역할을 다했다고 생각한다.
iOS 개발에 사용되는 Foundation 혹은 UIKit 프레임워크는 Objective-C로 쓰여 있는 경우가 많다. 그러기에 Swift에서 보았던 API와 Objective-C에서 보는 API의 동작 원리는 크게 다르지 않을 것이다. 오브젝트 타입과 메서드 네이밍을 비교해 가며 코딩하면 비교적 빠르게 적응할 수 있을 거라 생각한다.
Reference
' Apple > iOS Dev Challenges' 카테고리의 다른 글
[Challenge] 라이브러리 관리 기술 살펴보기 (0) | 2023.12.19 |
---|---|
[Challenge] 데이터 암복호화 모델 개발 (1) | 2023.12.18 |
[Challenge] SettingsKit 프레임워크 개발 (0) | 2023.06.19 |
[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 15부 - State Restoration (0) | 2023.04.12 |
[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 14부 - Local Notification Action (0) | 2023.04.05 |