Devin.KR

Objective-C · 기본

Objective-C 문법과 Cocoa 기초

완성 프로젝트 - 사내 도서 대출 관리

클래스 설계(도서·회원·대출 장부), 델리게이트로 연체 알림, 대출 기록 파일 저장·불러오기, 보고서 출력, Swift 에서 이 코드를 부를 때(브리징 헤더·nullability)

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장까지 우리는 클래스, 프로퍼티, 메모리 관리, 문자열과 컬렉션, 프로토콜, 블록, NSError 패턴을 하나씩 익혔다. 이 장에서는 그 조각을 모아 사내 도서 대출 관리 도구를 한 프로그램으로 완성한다. 새 문법을 많이 배우기보다 이미 아는 도구를 어떤 기준으로 나눠 쓰는지 보는 장이다. 마지막에는 이 코드를 Swift 에서 부를 때 필요한 브리징 헤더와 nullability(널 가능성 표기)도 다룬다.

  • 도서, 회원, 대출 한 건, 대출 장부를 서로 다른 클래스로 나누고 각 클래스의 책임을 정한다.
  • 델리게이트(delegate, 일을 대신 맡기는 객체)로 연체 알림을 장부 바깥으로 빼낸다.
  • 대출 기록을 텍스트 파일로 저장하고 불러오되, 실패해도 기존 기록이 망가지지 않게 만든다.
  • 결과를 표준 출력으로 내는 이유를 이해하고, 딕셔너리를 정렬해 결정적인 보고서를 만든다.
  • Swift 가 Objective-C 헤더를 읽는 방식과 nullability 표기의 효과를 설명한다.

문제 상황

작은 회사의 총무 담당자가 사내 책장을 관리한다. 지금까지는 공유 스프레드시트에 누가 어떤 책을 언제 빌렸는지 적었다. 문제는 세 가지다. 반납 기한이 지난 책을 누가 확인해 주지 않으면 그대로 방치된다. 같은 책이 두 사람에게 동시에 대출되는 입력 실수를 막아 주지 못한다. 시트를 다른 도구에서 읽으려 해도 형식이 일정하지 않다.

이 장의 도구는 이 세 가지를 코드로 해결한다. 대출 요청이 들어오면 책과 회원이 등록돼 있는지, 이미 대출 중인지 검사한다. 기준일을 주면 기한이 지난 대출을 찾아 알림을 보낸다. 기록은 한 줄에 대출 한 건씩 파일에 남기고 다시 읽을 수 있다. 날짜 계산은 이 장의 초점이 아니므로 "일 번호"라는 정수로 단순화한다. 1일에 빌리고 대출 기간이 14일이면 기한은 15일이다. 실제 도구라면 이 정수를 NSDate 로 바꾸면 된다.

클래스 설계와 델리게이트

책임을 나누는 기준

클래스를 나눌 때는 "무엇이 바뀌면 이 클래스를 고쳐야 하는가"를 묻는 것이 도움이 된다. 이 프로그램에서는 네 가지 역할이 나온다.

클래스별 책임과 갖는 값
클래스책임갖는 값바뀌는 이유
Book도서 한 권을 나타낸다isbn, title도서 정보 항목이 늘 때
Member회원 한 명을 나타낸다memberId, name회원 정보 항목이 늘 때
Loan대출 한 건과 그 상태를 나타낸다isbn, memberId, 대출일, 기한, 반납 여부대출 기록 항목이 늘 때
LoanLedger규칙 검사, 점검, 저장, 보고서도서·회원 딕셔너리, 대출 배열대출 규칙이 바뀔 때

Book, Member, Loan 은 값을 담는 작은 클래스다. 프로퍼티는 모두 readonly 로 두고 지정 초기화 메서드(designated initializer, 객체가 반드시 거쳐야 하는 초기화 경로)에서 한 번만 채운다. 예외는 Loan 의 반납 여부와 알림 여부로, 대출 후에 바뀌는 상태이므로 읽고 쓸 수 있게 둔다. 인자 없는 init 은 NS_UNAVAILABLE 로 막아서, isbn 이 비어 있는 도서 객체가 만들어지는 길을 컴파일 시점에 없앤다.

LoanLedger 가 도서·회원·대출 기록을 보관하고, 연체 알림은 약한 참조의 델리게이트로 전달한다.

연체 알림을 델리게이트로 빼는 이유

장부가 연체를 발견했을 때 직접 화면에 출력하게 만들 수도 있다. 그러면 장부가 출력 방식까지 알아야 한다. 나중에 메일 발송이나 메신저 알림이 필요해지면 장부를 고쳐야 한다. 델리게이트를 쓰면 장부는 "연체를 발견했다"는 사실만 알리고, 그 뒤의 처리는 프로토콜을 따르는 다른 객체가 맡는다. 이 장의 LoanLedgerDelegate 프로토콜에는 필수 메서드 하나와 선택(@optional) 메서드 하나가 있다. 선택 메서드는 부르기 전에 respondsToSelector: 로 구현 여부를 확인한다.

델리게이트 프로퍼티는 반드시 weak 로 선언한다. 알림을 받는 객체가 장부를 strong 으로 들고 있고 장부도 델리게이트를 strong 으로 들면 서로 붙잡아 둘 다 해제되지 않는다. weak 로 두면 장부는 델리게이트의 수명에 관여하지 않는다. 대신 델리게이트 객체를 누군가 strong 으로 붙들고 있어야 한다. 이 장의 main 함수에서는 지역 변수가 그 역할을 한다.

같은 대출을 점검할 때마다 알리면 알림이 반복된다. 그래서 Loan 에 notified 플래그를 두고, 한 번 알린 대출은 건너뛴다. 이 플래그는 파일에 저장하지 않는 일시 상태다.

파일 저장과 보고서

한 줄에 한 건

저장 형식은 isbn,회원번호,대출일,기한,반납여부 를 쉼표로 이은 한 줄이다. 반납 여부는 0 또는 1 이다. 앞 장에서 다룬 NSError 패턴을 그대로 따라 저장과 불러오기 메서드는 BOOL 을 반환하고, 실패 이유는 마지막 인자 NSError ** 로 돌려준다. 성공 여부는 반환값으로 판단한다. 호출자가 error 변수만 보고 판단하면 안 되는 이유는 뒤에서 다시 다룬다.

불러오기는 전부 아니면 전무

파일을 읽다가 5번째 줄에서 형식 오류를 만났다고 하자. 장부의 배열에 앞의 네 건을 이미 넣어 두었다면 장부는 반쯤 바뀐 상태가 된다. 그래서 불러오기는 임시 배열에 대출 객체를 모으고, 모든 줄이 통과한 뒤에야 장부의 배열을 한 번에 교체한다. 중간에 실패하면 장부는 호출 전 상태 그대로다. 또 각 줄의 isbn 과 회원번호가 등록돼 있는지도 검사한다. 이 때문에 불러오기 전에 도서와 회원 목록을 먼저 등록해 두어야 한다.

불러오기는 임시 배열에 모은 뒤 한 번에 교체하고, 형식 오류가 나면 기존 기록을 그대로 둔다.

보고서와 표준 출력

보고서는 대출 한 건당 한 줄, 그리고 회원별 미반납 권수로 구성한다. 회원별 집계는 딕셔너리에 모으는데, 딕셔너리는 순서를 보장하지 않으므로 출력 전에 키를 정렬한다. 정렬하지 않으면 실행할 때마다 줄 순서가 달라질 수 있다.

결과 출력에는 NSLog 대신 printf("%s\n", [문자열 UTF8String]) 을 쓴다. NSLog 는 로그를 남기는 함수여서 줄 앞에 시각과 프로세스 이름·번호가 붙고, 표준 출력이 아니라 표준 오류로 나간다. 보고서를 ./app > report.txt 로 저장하면 파일은 비어 있고 로그만 화면에 남는다. 다른 프로그램이 출력을 이어받아 읽기에도 맞지 않다. 이 장의 Out 함수는 이 출력 방식을 한곳에 모은 것이다.

Swift 에서 이 코드를 부를 때

Objective-C 로 만든 장부를 Swift 앱에서 쓰려면 Swift 컴파일러가 읽을 수 있는 헤더가 필요하다. 이 장의 완성 코드는 한 파일이어서 클래스를 그대로 가져갈 수 없다. 실제 프로젝트에서는 LoanLedger.h 와 LoanLedger.m 으로 나누고, 헤더에 공개할 선언만 둔다. 그다음 Xcode 가 만들어 주는 브리징 헤더(bridging header)에 #import "LoanLedger.h" 한 줄을 적는다. 이 브리징은 Xcode 프로젝트에서 쓰는 방식이며, Objective-C 와 Swift 를 함께 쓰는 환경을 전제로 한다.

// LibraryTool-Bridging-Header.h
#import "LoanLedger.h"

헤더에 nullability 를 적지 않으면 Swift 는 모든 객체 포인터를 Loan! 처럼 "있다고 가정하는 옵셔널"로 읽는다. 컴파일러가 아무것도 검증해 주지 않으므로 nil 이 들어오면 Swift 쪽에서 실행 중에 멈춘다. 헤더를 NS_ASSUME_NONNULL_BEGIN 과 NS_ASSUME_NONNULL_END 로 감싸면 그 안의 포인터는 기본적으로 nil 이 될 수 없다고 선언한 것이 된다. nil 이 될 수 있는 값에만 nullable 을 붙인다.

NS_ASSUME_NONNULL_BEGIN

@interface LoanLedger : NSObject
@property (nonatomic, weak, nullable) id<LoanLedgerDelegate> delegate;
- (nullable Loan *)checkOutISBN:(NSString *)isbn
                       memberId:(NSString *)memberId
                            day:(NSInteger)day
                          error:(NSError **)error;
- (nullable Book *)bookForISBN:(NSString *)isbn;
@end

NS_ASSUME_NONNULL_END

Swift 는 이 선언을 아래처럼 읽는다. 마지막 인자가 NSError ** 이고 nullable 객체나 BOOL 을 반환하는 메서드는 Swift 에서 throws 함수가 된다. 실패 표시용 nil 은 throw 로 바뀌므로 반환 타입에서 옵셔널이 사라진다. 메서드 이름이 Swift 규칙에 따라 다듬어지는 경우가 있으므로, 정확한 모양은 Xcode 의 자동 완성으로 확인한다.

let ledger = LoanLedger()
do {
    let loan = try ledger.checkOutISBN("B-001", memberId: "M01", day: 1)
    print(loan.dueDay)
} catch {
    print(error.localizedDescription)
}
Objective-C 선언이 Swift 에서 보이는 모양
Objective-C 선언Swift 에서 보이는 모양의미
NS_ASSUME_NONNULL 안의 NSString *Stringnil 이 될 수 없다
nullable Book *Book?nil 일 수 있어 풀어서 써야 한다
표기 없음Book!검증 없이 있다고 가정한다
마지막 인자 NSError **throwstry 로 부르고 catch 로 받는다
weak 델리게이트옵셔널 약한 참조수명은 호출한 쪽이 책임진다

완성 코드

main.m

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

static NSString *const LedgerErrorDomain = @"example.library.ledger";
static const NSInteger kLoanPeriodDays = 14;

typedef NS_ENUM(NSInteger, LedgerErrorCode) {
    LedgerErrorUnknownBook = 1,
    LedgerErrorUnknownMember,
    LedgerErrorAlreadyLent,
    LedgerErrorNotLent,
    LedgerErrorBadRecord
};

static void Out(NSString *text) {
    printf("%s\n", [text UTF8String]);
}

static NSError *MakeError(LedgerErrorCode code, NSString *message) {
    return [NSError errorWithDomain:LedgerErrorDomain
                               code:code
                           userInfo:@{NSLocalizedDescriptionKey: message}];
}

@interface Book : NSObject
@property (nonatomic, copy, readonly) NSString *isbn;
@property (nonatomic, copy, readonly) NSString *title;
- (instancetype)initWithISBN:(NSString *)isbn title:(NSString *)title NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
@end

@implementation Book
- (instancetype)initWithISBN:(NSString *)isbn title:(NSString *)title {
    self = [super init];
    if (self) {
        _isbn = [isbn copy];
        _title = [title copy];
    }
    return self;
}
@end

@interface Member : NSObject
@property (nonatomic, copy, readonly) NSString *memberId;
@property (nonatomic, copy, readonly) NSString *name;
- (instancetype)initWithMemberId:(NSString *)memberId name:(NSString *)name NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
@end

@implementation Member
- (instancetype)initWithMemberId:(NSString *)memberId name:(NSString *)name {
    self = [super init];
    if (self) {
        _memberId = [memberId copy];
        _name = [name copy];
    }
    return self;
}
@end

@interface Loan : NSObject
@property (nonatomic, copy, readonly) NSString *isbn;
@property (nonatomic, copy, readonly) NSString *memberId;
@property (nonatomic, readonly) NSInteger outDay;
@property (nonatomic, readonly) NSInteger dueDay;
@property (nonatomic) BOOL returned;
@property (nonatomic) BOOL notified;
- (instancetype)initWithISBN:(NSString *)isbn
                    memberId:(NSString *)memberId
                      outDay:(NSInteger)outDay
                      dueDay:(NSInteger)dueDay NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
- (NSString *)record;
@end

@implementation Loan
- (instancetype)initWithISBN:(NSString *)isbn
                    memberId:(NSString *)memberId
                      outDay:(NSInteger)outDay
                      dueDay:(NSInteger)dueDay {
    self = [super init];
    if (self) {
        _isbn = [isbn copy];
        _memberId = [memberId copy];
        _outDay = outDay;
        _dueDay = dueDay;
    }
    return self;
}

- (NSString *)record {
    return [NSString stringWithFormat:@"%@,%@,%ld,%ld,%d",
            _isbn, _memberId, (long)_outDay, (long)_dueDay, _returned ? 1 : 0];
}
@end

@class LoanLedger;

@protocol LoanLedgerDelegate <NSObject>
- (void)ledger:(LoanLedger *)ledger
          loan:(Loan *)loan
      ofMember:(Member *)member
  isLateByDays:(NSInteger)days;
@optional
- (void)ledger:(LoanLedger *)ledger didFinishCheckWithCount:(NSInteger)count;
@end

@interface LoanLedger : NSObject
@property (nonatomic, weak, nullable) id<LoanLedgerDelegate> delegate;
@property (nonatomic, readonly) NSArray<Loan *> *loans;
- (void)addBook:(Book *)book;
- (void)addMember:(Member *)member;
- (nullable Book *)bookForISBN:(NSString *)isbn;
- (nullable Loan *)checkOutISBN:(NSString *)isbn
                       memberId:(NSString *)memberId
                            day:(NSInteger)day
                          error:(NSError **)error;
- (BOOL)returnISBN:(NSString *)isbn error:(NSError **)error;
- (NSInteger)checkOverdueOnDay:(NSInteger)today;
- (BOOL)saveToPath:(NSString *)path error:(NSError **)error;
- (BOOL)loadFromPath:(NSString *)path error:(NSError **)error;
- (NSString *)reportOnDay:(NSInteger)today;
@end

@interface LoanLedger () {
    NSMutableDictionary<NSString *, Book *> *_books;
    NSMutableDictionary<NSString *, Member *> *_members;
    NSMutableArray<Loan *> *_loans;
}
@end

@implementation LoanLedger

- (instancetype)init {
    self = [super init];
    if (self) {
        _books = [NSMutableDictionary dictionary];
        _members = [NSMutableDictionary dictionary];
        _loans = [NSMutableArray array];
    }
    return self;
}

- (NSArray<Loan *> *)loans {
    return [_loans copy];
}

- (void)addBook:(Book *)book {
    _books[book.isbn] = book;
}

- (void)addMember:(Member *)member {
    _members[member.memberId] = member;
}

- (nullable Book *)bookForISBN:(NSString *)isbn {
    return _books[isbn];
}

- (nullable Loan *)activeLoanForISBN:(NSString *)isbn {
    for (Loan *loan in _loans) {
        if (loan.returned == NO && [loan.isbn isEqualToString:isbn]) {
            return loan;
        }
    }
    return nil;
}

- (nullable Loan *)checkOutISBN:(NSString *)isbn
                       memberId:(NSString *)memberId
                            day:(NSInteger)day
                          error:(NSError **)error {
    NSString *problem = nil;
    LedgerErrorCode code = LedgerErrorUnknownBook;
    if (_books[isbn] == nil) {
        problem = [NSString stringWithFormat:@"등록되지 않은 도서: %@", isbn];
    } else if (_members[memberId] == nil) {
        code = LedgerErrorUnknownMember;
        problem = [NSString stringWithFormat:@"등록되지 않은 회원: %@", memberId];
    } else if ([self activeLoanForISBN:isbn] != nil) {
        code = LedgerErrorAlreadyLent;
        problem = [NSString stringWithFormat:@"이미 대출 중인 도서: %@", isbn];
    }
    if (problem != nil) {
        if (error) {
            *error = MakeError(code, problem);
        }
        return nil;
    }
    Loan *loan = [[Loan alloc] initWithISBN:isbn
                                   memberId:memberId
                                     outDay:day
                                     dueDay:day + kLoanPeriodDays];
    [_loans addObject:loan];
    return loan;
}

- (BOOL)returnISBN:(NSString *)isbn error:(NSError **)error {
    Loan *loan = [self activeLoanForISBN:isbn];
    if (loan == nil) {
        if (error) {
            *error = MakeError(LedgerErrorNotLent,
                               [NSString stringWithFormat:@"대출 중이 아닌 도서: %@", isbn]);
        }
        return NO;
    }
    loan.returned = YES;
    return YES;
}

- (NSInteger)checkOverdueOnDay:(NSInteger)today {
    NSInteger count = 0;
    for (Loan *loan in _loans) {
        Member *member = _members[loan.memberId];
        if (loan.returned || loan.notified || today <= loan.dueDay || member == nil) {
            continue;
        }
        loan.notified = YES;
        count++;
        [self.delegate ledger:self loan:loan ofMember:member isLateByDays:today - loan.dueDay];
    }
    id<LoanLedgerDelegate> target = self.delegate;
    if ([target respondsToSelector:@selector(ledger:didFinishCheckWithCount:)]) {
        [target ledger:self didFinishCheckWithCount:count];
    }
    return count;
}

- (BOOL)saveToPath:(NSString *)path error:(NSError **)error {
    NSMutableString *text = [NSMutableString string];
    for (Loan *loan in _loans) {
        [text appendFormat:@"%@\n", [loan record]];
    }
    return [text writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:error];
}

- (nullable Loan *)loanFromRecord:(NSString *)line {
    NSArray<NSString *> *f = [line componentsSeparatedByString:@","];
    if (f.count == 5 && _books[f[0]] != nil && _members[f[1]] != nil) {
        Loan *loan = [[Loan alloc] initWithISBN:f[0]
                                       memberId:f[1]
                                         outDay:[f[2] integerValue]
                                         dueDay:[f[3] integerValue]];
        loan.returned = [f[4] boolValue];
        return loan;
    }
    return nil;
}

- (BOOL)loadFromPath:(NSString *)path error:(NSError **)error {
    NSString *text = [NSString stringWithContentsOfFile:path
                                               encoding:NSUTF8StringEncoding
                                                  error:error];
    if (text == nil) {
        return NO;
    }
    NSMutableArray<Loan *> *loaded = [NSMutableArray array];
    NSInteger lineNo = 0;
    for (NSString *line in [text componentsSeparatedByString:@"\n"]) {
        lineNo++;
        if (line.length == 0) {
            continue;
        }
        Loan *loan = [self loanFromRecord:line];
        if (loan == nil) {
            if (error) {
                *error = MakeError(LedgerErrorBadRecord,
                                   [NSString stringWithFormat:@"%ld번째 줄을 해석할 수 없다", (long)lineNo]);
            }
            return NO;
        }
        [loaded addObject:loan];
    }
    _loans = loaded;
    return YES;
}

- (NSString *)reportOnDay:(NSInteger)today {
    NSMutableArray<NSString *> *lines = [NSMutableArray array];
    [lines addObject:[NSString stringWithFormat:@"=== 대출 보고서 (%ld일 기준) ===", (long)today]];
    NSMutableDictionary<NSString *, NSNumber *> *active = [NSMutableDictionary dictionary];
    for (Loan *loan in _loans) {
        NSString *status = @"반납";
        if (loan.returned == NO) {
            active[loan.memberId] = @(active[loan.memberId].integerValue + 1);
            if (today > loan.dueDay) {
                status = [NSString stringWithFormat:@"연체 %ld일", (long)(today - loan.dueDay)];
            } else {
                status = @"대출 중";
            }
        }
        [lines addObject:[NSString stringWithFormat:@"%@ | %@ | 기한 %ld일 | %@",
                          _books[loan.isbn].title, _members[loan.memberId].name,
                          (long)loan.dueDay, status]];
    }
    [lines addObject:@"--- 회원별 미반납 ---"];
    NSArray<NSString *> *ids = [_members.allKeys sortedArrayUsingSelector:@selector(compare:)];
    for (NSString *memberId in ids) {
        [lines addObject:[NSString stringWithFormat:@"%@ %@: %ld권", memberId,
                          _members[memberId].name, (long)active[memberId].integerValue]];
    }
    return [lines componentsJoinedByString:@"\n"];
}

@end

@interface ReminderPrinter : NSObject <LoanLedgerDelegate>
@end

@implementation ReminderPrinter
- (void)ledger:(LoanLedger *)ledger
          loan:(Loan *)loan
      ofMember:(Member *)member
  isLateByDays:(NSInteger)days {
    Out([NSString stringWithFormat:@"알림: %@ 님, %@ 반납이 %ld일 지연됐다",
         member.name, [ledger bookForISBN:loan.isbn].title, (long)days]);
}

- (void)ledger:(LoanLedger *)ledger didFinishCheckWithCount:(NSInteger)count {
    Out([NSString stringWithFormat:@"점검 완료: %ld건", (long)count]);
}
@end

static void RegisterCatalog(LoanLedger *ledger) {
    [ledger addBook:[[Book alloc] initWithISBN:@"B-001" title:@"시스템 설계 노트"]];
    [ledger addBook:[[Book alloc] initWithISBN:@"B-002" title:@"데이터 구조 길잡이"]];
    [ledger addBook:[[Book alloc] initWithISBN:@"B-003" title:@"네트워크 실습서"]];
    [ledger addMember:[[Member alloc] initWithMemberId:@"M01" name:@"한서연"]];
    [ledger addMember:[[Member alloc] initWithMemberId:@"M02" name:@"오지후"]];
}

static void TryCheckOut(LoanLedger *ledger, NSString *isbn, NSString *memberId, NSInteger day) {
    NSError *error = nil;
    Loan *loan = [ledger checkOutISBN:isbn memberId:memberId day:day error:&error];
    if (loan == nil) {
        Out([NSString stringWithFormat:@"대출 실패: %@", error.localizedDescription]);
        return;
    }
    Out([NSString stringWithFormat:@"대출 성공: %@ (기한 %ld일)", isbn, (long)loan.dueDay]);
}

NS_ASSUME_NONNULL_END

int main(void) {
    @autoreleasepool {
        LoanLedger *ledger = [[LoanLedger alloc] init];
        RegisterCatalog(ledger);
        TryCheckOut(ledger, @"B-001", @"M01", 1);
        TryCheckOut(ledger, @"B-002", @"M02", 3);
        TryCheckOut(ledger, @"B-003", @"M01", 5);
        TryCheckOut(ledger, @"B-001", @"M02", 6);
        TryCheckOut(ledger, @"B-009", @"M01", 6);

        NSError *error = nil;
        if ([ledger returnISBN:@"B-002" error:&error]) {
            Out(@"반납 처리: B-002");
        }

        int pid = [[NSProcessInfo processInfo] processIdentifier];
        NSString *path = [NSTemporaryDirectory()
            stringByAppendingPathComponent:[NSString stringWithFormat:@"loans-%d.txt", pid]];
        NSString *badPath = [NSTemporaryDirectory()
            stringByAppendingPathComponent:[NSString stringWithFormat:@"loans-%d-bad.txt", pid]];
        if ([ledger saveToPath:path error:&error]) {
            Out(@"저장: 성공");
        }

        LoanLedger *restored = [[LoanLedger alloc] init];
        RegisterCatalog(restored);
        ReminderPrinter *printer = [[ReminderPrinter alloc] init];
        restored.delegate = printer;
        if ([restored loadFromPath:path error:&error]) {
            Out([NSString stringWithFormat:@"불러오기: %lu건", (unsigned long)restored.loans.count]);
        }

        [@"B-001,M01,1\n" writeToFile:badPath atomically:YES encoding:NSUTF8StringEncoding error:nil];
        if ([restored loadFromPath:badPath error:&error] == NO) {
            Out([NSString stringWithFormat:@"손상된 파일: %@", error.localizedDescription]);
            Out([NSString stringWithFormat:@"기존 기록 유지: %lu건", (unsigned long)restored.loans.count]);
        }

        [restored checkOverdueOnDay:20];
        NSInteger again = [restored checkOverdueOnDay:21];
        Out([NSString stringWithFormat:@"재점검 신규 알림: %ld건", (long)again]);
        Out([restored reportOnDay:20]);

        [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
        [[NSFileManager defaultManager] removeItemAtPath:badPath error:nil];
    }
    return 0;
}

줄별 해설

  • NS_ASSUME_NONNULL_BEGIN 부터 END 까지는 포인터를 기본적으로 nil 불가로 본다는 선언이다. 한 파일 안에서도 쓸 수 있으며, 일부 선언에만 nullability 를 적었을 때 나오는 경고를 막는 효과도 있다. 그래서 main 은 인자 없는 형태로 영역 밖에 두었다.
  • Out 은 문자열을 UTF-8 로 바꿔 printf 로 내보낸다. 모든 결과 출력이 이 함수를 거치므로 출력 방식을 바꾸고 싶을 때 한 곳만 고치면 된다.
  • MakeError 는 오류 도메인, 코드, 설명 문장을 묶어 NSError 를 만든다. 오류 코드는 NS_ENUM 으로 이름을 붙여 숫자를 직접 쓰지 않게 했다.
  • Book, Member, Loan 의 초기화 메서드는 [super init] 결과를 self 에 받고 nil 이 아닐 때만 ivar 를 채운다. 문자열 프로퍼티는 copy 로 선언했으므로 대입할 때도 copy 로 복사한다. NSMutableString 이 들어와도 나중에 값이 바뀌지 않게 하려는 것이다.
  • Loan 의 record 는 저장용 한 줄을 만든다. %ld 에 맞추려고 NSInteger 를 (long) 으로 바꿨다.
  • 프로토콜 앞의 @class LoanLedger; 는 전방 선언이다. 프로토콜 메서드가 LoanLedger 타입을 쓰지만 클래스 본체는 아래에서 선언되기 때문이다.
  • 클래스 확장 @interface LoanLedger () 에는 외부에 보이지 않는 ivar 세 개가 있다. 공개 프로퍼티 loans 는 getter 를 직접 구현해서 배열 복사본을 돌려준다. 바깥에서 장부 내부 배열을 수정하지 못하게 하는 장치다.
  • checkOutISBN:... 은 검사를 도서, 회원, 중복 대출 순서로 한다. 문제가 있으면 problem 에 문장을 담고, 마지막에 한 번만 error 를 채우고 nil 을 돌려준다. if (error) 는 호출자가 NULL 을 넘긴 경우를 위한 검사다.
  • checkOverdueOnDay: 는 반납됐거나 이미 알렸거나 기한 이내인 대출을 건너뛴다. 나머지는 알림 플래그를 켜고 델리게이트를 부른다. self.delegate 가 nil 이어도 메시지는 무시되므로 델리게이트가 없는 장부도 안전하다. 선택 메서드는 respondsToSelector: 로 확인한 뒤 부른다.
  • saveToPath: 는 대출마다 한 줄씩 이어 붙인 문자열을 writeToFile:atomically:encoding:error: 로 쓴다. atomically 가 YES 이면 임시 파일에 먼저 쓴 뒤 바꿔치기해서 쓰다 중단된 파일이 남지 않는다.
  • loanFromRecord: 는 쉼표로 나눈 조각이 다섯 개이고 도서와 회원이 모두 등록돼 있을 때만 Loan 을 만든다. 하나라도 어긋나면 nil 을 돌려준다.
  • loadFromPath: 는 임시 배열 loaded 에 모으다가 모든 줄이 통과하면 _loans = loaded; 로 한 번에 교체한다. 빈 줄은 마지막 개행 뒤에 생기는 빈 조각을 건너뛰기 위한 것이다.
  • reportOnDay: 는 미반납 권수를 딕셔너리에 모으고, 출력할 때는 _members.allKeys 를 정렬해서 순서를 고정한다. 없는 키의 NSNumber 는 nil 이고, nil 에 integerValue 를 보내면 0 이 되므로 대출이 없는 회원도 0권으로 나온다.
  • ReminderPrinter 는 프로토콜을 채택한 별도 클래스다. 장부가 어떤 출력을 하는지 모르는 채로 알림이 화면에 나온다.
  • main 에서 printer 는 지역 변수로 strong 참조를 유지한다. 이 줄이 없고 restored.delegate = [[ReminderPrinter alloc] init]; 처럼 쓰면 객체가 즉시 해제돼 알림이 나오지 않는다.
  • 손상 파일 실험은 쉼표 조각이 세 개뿐인 줄을 파일로 만들어 불러온다. 실패를 보고한 뒤에도 restored.loans.count 가 3 으로 남아 있는 것이 교체 방식의 효과다.
  • 같은 기준일로 점검을 두 번 부르면 두 번째는 0건이다. 알림 플래그가 이미 켜져 있기 때문이다.
  • 마지막 두 줄은 임시 파일을 지운다. 결과를 확인한 뒤 디스크에 아무것도 남기지 않기 위해서다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation main.m -o app
$ ./app
대출 성공: B-001 (기한 15일)
대출 성공: B-002 (기한 17일)
대출 성공: B-003 (기한 19일)
대출 실패: 이미 대출 중인 도서: B-001
대출 실패: 등록되지 않은 도서: B-009
반납 처리: B-002
저장: 성공
불러오기: 3건
손상된 파일: 1번째 줄을 해석할 수 없다
기존 기록 유지: 3건
알림: 한서연 님, 시스템 설계 노트 반납이 5일 지연됐다
알림: 한서연 님, 네트워크 실습서 반납이 1일 지연됐다
점검 완료: 2건
점검 완료: 0건
재점검 신규 알림: 0건
=== 대출 보고서 (20일 기준) ===
시스템 설계 노트 | 한서연 | 기한 15일 | 연체 5일
데이터 구조 길잡이 | 오지후 | 기한 17일 | 반납
네트워크 실습서 | 한서연 | 기한 19일 | 연체 1일
--- 회원별 미반납 ---
M01 한서연: 2권
M02 오지후: 0권

실무에서 자주 틀리는 것

델리게이트를 strong 으로 선언한다

틀린 코드:

@property (nonatomic, strong, nullable) id<LoanLedgerDelegate> delegate;

알림을 받는 객체가 장부를 프로퍼티로 들고 있다면, 서로 strong 으로 붙잡아 둘 다 해제되지 않는다. 델리게이트는 weak 로 선언한다.

@property (nonatomic, weak, nullable) id<LoanLedgerDelegate> delegate;

error 변수만 보고 성공 여부를 판단한다

틀린 코드:

NSError *error = nil;
[ledger saveToPath:path error:&error];
if (error == nil) { Out(@"저장: 성공"); }

NSError 패턴에서 성공은 반환값으로 알린다. 성공한 호출에서 error 에 값이 채워질 수도 있고, 실패했는데 구현이 error 를 채우지 않을 수도 있다. 반환값을 먼저 확인하고, 실패했을 때만 error 를 읽는다.

if ([ledger saveToPath:path error:&error]) {
    Out(@"저장: 성공");
} else {
    Out(error.localizedDescription);
}

헤더에 nullability 를 적지 않는다

틀린 코드:

@interface LoanLedger : NSObject
- (Loan *)checkOutISBN:(NSString *)isbn memberId:(NSString *)memberId
                   day:(NSInteger)day error:(NSError **)error;
@end

이 헤더를 Swift 가 읽으면 인자와 반환값이 String!, Loan! 로 보인다. 실제로는 실패 때 nil 을 돌려주는데 Swift 쪽에서는 그 사실을 알 수 없다. 헤더 전체를 NS_ASSUME_NONNULL 로 감싸고 nil 이 나올 수 있는 반환값에만 nullable 을 붙인다.

NS_ASSUME_NONNULL_BEGIN
@interface LoanLedger : NSObject
- (nullable Loan *)checkOutISBN:(NSString *)isbn memberId:(NSString *)memberId
                            day:(NSInteger)day error:(NSError **)error;
@end
NS_ASSUME_NONNULL_END

보고서를 NSLog 로 출력한다

틀린 코드:

NSLog(@"%@", [ledger reportOnDay:20]);

이 줄은 시각과 프로세스 정보가 붙은 채 표준 오류로 나간다. 출력을 파일로 돌리거나 다른 프로그램에 연결해도 보고서가 들어가지 않고, 실행할 때마다 시각이 달라져 결과를 비교할 수도 없다. 결과는 표준 출력으로 내고 NSLog 는 진단 메시지에만 쓴다.

Out([ledger reportOnDay:20]);

한눈에 보기

이 장의 설계 결정과 이유
주제선택이유
값 클래스readonly 프로퍼티와 지정 초기화 메서드값이 빠진 객체를 만들 수 없게 한다
연체 알림weak 델리게이트와 notified 플래그출력 방식을 분리하고 순환 참조와 중복 알림을 막는다
저장 형식한 줄에 한 건, 쉼표 구분사람이 읽기 쉽고 다른 도구도 읽을 수 있다
불러오기임시 배열에 모은 뒤 교체실패해도 기존 기록이 유지된다
결과 출력printf 와 UTF8String표준 출력으로 나가고 시각이 붙지 않는다
Swift 연동브리징 헤더와 NS_ASSUME_NONNULLSwift 가 옵셔널 여부와 throws 를 정확히 안다

연습 문제

  1. 대출 기간을 14일에서 7일로 바꾸려 한다. 코드의 어느 부분만 고치면 되는지 쓰고, 그 결과 실행 결과의 어떤 줄이 달라지는지 설명하라.
  2. checkOutISBN:... 에 "한 회원이 동시에 두 권까지만 빌릴 수 있다"는 규칙을 추가하려 한다. 어떤 오류 코드를 새로 두고, 어느 검사 다음에 어떤 조건을 넣겠는가. 코드를 써라.
  3. ReminderPrinter 대신 연체 건수만 세는 OverdueCounter 클래스를 델리게이트로 만들려 한다. 필수로 구현해야 하는 메서드는 무엇이고, 선택 메서드를 구현하지 않으면 어떤 일이 생기는가.
  4. loadFromPath: 에서 임시 배열 없이 _loans 에 바로 addObject: 하면 손상된 파일을 불러온 뒤 장부는 어떤 상태가 되는가. 이 장의 손상 파일 실험을 기준으로 설명하라.

정답과 해설

  1. kLoanPeriodDays 상수의 값만 7 로 고치면 된다. 기한 계산이 day + kLoanPeriodDays 한 곳에서만 일어나기 때문이다. 기한이 1일 대출은 8일, 3일 대출은 10일, 5일 대출은 12일이 된다. 보고서의 기한과 연체 일수, 알림의 지연 일수가 모두 달라진다. 다만 저장 파일에는 기한이 숫자로 들어 있어서, 이미 저장된 기록은 상수를 바꿔도 그대로다. 값을 상수 하나로 모아 둔 설계의 장점이자 한계다.
  2. 열거형에 LedgerErrorLimitExceeded 를 추가하고, 중복 대출 검사 다음에 회원의 미반납 대출 수를 센다. 예를 들면 다음과 같다.
    } else if ([self activeCountForMember:memberId] >= 2) {
        code = LedgerErrorLimitExceeded;
        problem = [NSString stringWithFormat:@"대출 한도 초과: %@", memberId];
    }
    
    activeCountForMember: 는 _loans 를 돌며 반납되지 않은 대출 중 회원번호가 같은 것을 세는 비공개 메서드다. 오류를 만드는 마지막 부분은 이미 한 곳에 모여 있으므로 그대로 둔다.
  3. 필수 메서드는 ledger:loan:ofMember:isLateByDays: 하나다. 안에서 인스턴스 변수에 1 씩 더하면 된다. ledger:didFinishCheckWithCount: 는 선택이므로 구현하지 않아도 컴파일되고, 장부가 respondsToSelector: 로 확인한 뒤 건너뛰므로 실행 중에도 아무 일이 생기지 않는다. 다만 필수 메서드를 구현하지 않으면 컴파일러가 경고한다.
  4. 손상된 줄은 첫 줄이므로 이 실험에서는 바뀌는 것이 없어 보일 수 있다. 그러나 앞쪽 줄이 정상이고 뒤쪽 줄이 손상된 파일이라면, 장부에는 정상인 앞쪽 대출만 추가된 채 실패가 보고된다. 호출자는 실패했다고 알지만 장부는 이전 상태도 파일 내용도 아닌 중간 상태가 되고, 같은 파일을 다시 불러오면 앞쪽 기록이 중복된다. 임시 배열을 쓰면 성공할 때만 장부가 바뀌므로 이 문제가 생기지 않는다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.