^(코딩캣)^ = @"코딩"하는 고양이;

[번역글] Thread-safe한 싱글톤(singleton) 패턴과 그 사용법(3) [完]

Language/Objective-C & Swift
2021. 9. 24. 09:41

이 글은 Sachithra Siriwardhane 님의 글 Thread-safe singletons and their usage in Swift를 바탕으로 작성되었습니다.

Thread-safe한 싱글톤(singleton)과 그 사용법

개발자라면 다들 ‘싱글톤 패턴(singleton pattern)’에 대해 알고 있을 것이고 사용해 보셨을 것이며, iOS 어플리케이션에서 구현이 가능하실 것입니다. 이 글은 입문자 또는 싱글톤 디자인 패턴(design pattern)을 적절하게 사용하기 위한 배경 지식 향상을 원하는 분들에게 도움이 되어 드리고자 작성되었습니다. 이 글에서는 다음과 같은 주제들을 다뤄볼 것입니다.

 

동시성 문제 및 thread-safe하게 싱글톤 패턴을 작성하기

Swift에서 let 키워드로 선언되는 식별자는 상수입니다. 그러므로 읽기 전용이면서 thread-safe입니다. var로 선언되는 식별자는 수정이 가능하지만 그 데이터 타입이 특별히 thread-safe하면서 수정 가능하게 지정되지 않았다면 thread-safe하지 않습니다. Swift에서 ArrayDictionary 같은 데이터 타입들은 수정 가능하더라도 thread-safe하지 않습니다.

위에서 언급한 Dictionary는 수정 가능합니다. 또한 여러 쓰레드에서 동시에 읽기만 할 때는 아무 문제가 없습니다. 하지만 다른 쓰레드들이 읽기만 하고 있는데 한 쓰레드가 수정하려 한다면 그 구조가 안전하지 않게 됩니다. 위의 예제에서 보인 싱글톤은 그러한 문제가 발생하는 것을 막아주지 못합니다. 이에 대한 해답은 DispatchQueue에 있습니다. Apple에 따르면 DispatchQueue의 의미는 다음과 같습니다.

여러분의 앱의 메인 쓰레드 또는 백그라운드 쓰레드에서 작업의 동기 또는 동시적 실행을 관리하는 객체(An object that manages the execution of tasks serially or concurrently on your app's main thread or on a background thread).

DispatchQueue에 대한 자세한 정보는 다음 Apple 문서를 참조하시기 바랍니다: https://developer.apple.com/documentation/dispatch/dispatchqueue

 

Concurrent Dispatch Queue

Concurrent Dispatch Queue는 여러 개의 작업들을 한 번에 실행하는데, 호출되는 순서는 작업이 큐(queue)에 추가된 순서에 따릅니다. 하지만 각 작업은 병렬로 실행 중이기 때문에 종료되는 순서는 제각각이 될 수 있습니다. DispatchQueue는 작업들이 실행되고 있는 쓰레드(thread)들을 관리하는 역할을 합니다.

위 예제에서 동시 사용은 어떻게 표현할 수 있을까요? 예시를 위해 동시적 실행은 다음과 같이 단위 테스트로 작성될 수 있습니다.

// Swift
func testConcurrentUsage throws {
    let concurrentLabel = "concurrentQueue"
    let concurrentQueue = DispatchQueue(label: concurrentLabel, attributes: .concurrent)
    let iterations = 100
    
    for currentIteration in 1 ... iterations {
        concurrentQueue.async {
            let key = "\(currentIteration)"
            EventLogger.shared.writeToLog(key: key, content: "iteration = \(currentIteration)")
        }
    }
    
    while EventLogger.shared.readLog(for: "\(iterations)") != String("iteration = \(currentIteration)") {
        // a blocker
    }
    
    expect.fulfill()
    
    waitForExpectations(timeout: 5) { (error) in
        XCTAssertNil(error, "Concurrent expectation failed")
    }
}

위 코드는 이 글에서 앞서 작성한 싱글톤을 동시적으로 사용해보기 위해 작성된 단위 테스트를 보여주고 있습니다. 먼저 concurrentQueuewriteToLog 함수를 동시에 병렬로 여러 번 호출할 수 있도록 만들어졌습니다. 그리고 for 반복문에서 writeToLog에 대한 작업을 큐에 50회 추가하는데, 그 결과 EventLogger 내부의 Dictionary에는 항목들이 추가됩니다. 이와 동시에 readLog 메소드가 반복적으로 호출되면서 읽기와 쓰기 작업이 동시적으로 적절하게 실행되고 있는지 확인하게 됩니다. 하지만...

// Swift
final public class EventLogger {
    // ...
    public func readLog(for key: String) -> String? {
        return eventsFired[key] as? String // Thread 1: EXC_BAD_ACCESS
    }
    // ...
}

앱이 실행 중에 충돌합니다. 현재 작업이 읽기인지 쓰기인지와는 무관하게 writeToLogreadLog 중 하나에서 충돌이 계속됩니다. 근본적인 문제는 Dictionary라는 데이터 타입 자체가 thread-safe하지 않기 때문입니다. 그리고 현재와 같은 구현은 동시 접근에 대해 올바르게 방호해주지 못하고 있습니다.

 

싱글톤을 thread-safe하게 만들기

Serial Dispatch Queue는 오직 한 번에 한 작업만을 실행시킵니다. 대부분의 경우 시리얼 큐(Serial Queue)는 데이터 폭주가 발생하는 것을 막으려고 특정 값이나 자원을 동기적으로 접근하기 위해 사용됩니다.

병렬이면서 동시적인 읽기와 쓰기 작업은 데이터 충돌을 일그이고 결과적으로 앱이 충돌하게 만듭니다. 그래서 읽기와 쓰기 작업이 병렬로 발생하지 않도록 확인해 주는 것이 해결책이 될 수 있습니다. 다시 표현하면, 읽기와 쓰기가 동시에 발생하는 것을 막는 것이 이 문제 해결책으로 보입니다. 그런 의미에서 serial queue는 한 번에 한 작업만이 호출되는 것을 보장해 줍니다.

// Swift
final public class EventLogger {
    // ...
    private func readLog(for key: String) -> String? {
        var value: String? = nil
        serialQueue.sync {
            value = eventsFired[key] as? String
        }
        return value
    }
    
    private func writeLog(key: String, content: Any) {
        serialQueue.sync {
            eventFired[key] = content
        }
    }
}
// Objective-C
@implementation EventLogger {
    // ...
    dispatch_queue_t serialQueue;
}
// ...

- (instancetype)init {
    if (self = [super init]) {
        // ...
        self->serialQueue = dispatch_queue_create("kr.codingcat.EventLoggerExample", NULL);
    }
    return self;
}


- (NSString *)readLogForKey:(nonnull NSString *)key {
    __block NSString * value = nil;
    
    dispatch_sync(serialQueue, ^{
        value = [self->eventFired valueForKey:key];
    });
    
    return value;
}

- (void)writeLogForKey:(nonnull NSString *)key andContent:(nullable id)content {
    dispatch_sync(serialQueue, ^{
        [self->eventFired setValue:content forKey:key];
    });
}
// ...
@end

읽기와 작업 모두 시리얼 큐 안에 추가되어서 순서대로 실행되는 것이 보장됩니다. 하지만 병렬로 처리되던 작업들 일부가 이와 같이 특정 부분에서 직렬로 실행되는 구조라면 성능 문제가 발생할 수 있기에 이 방법을 사용할 때는 추가적인 최적화 작업이 필요합니다.

 

읽기-쓰기 잠금

위키백과 “Readers-Writer lock”에 따르면, Readers-Writer Lock이란 읽기 전용 작업에 대해서는 동시 접근을 허용하고, 쓰기 작업을 할 때는 배타적인 접근 권한을 부여하는 방식을 뜻합니다. 즉 어떤 데이터가 있을 때 그것을 읽는 것이라면 여러 쓰레드에서 동시에 읽어가도 상관 없지만, 그 데이터를 수정하거나 기록하는 작업이라면 배타적인 권한을 얻을 때까지 기다려야 한다는 것입니다. 쓰기 작업이 데이터를 기록하고자 할 때 그 외 쓰기가 필요한 다른 쓰레드 및 읽기 작업만 하려 했던 쓰레드들까지도 이 쓰기 작업이 완료될 때까지 일시 중지 됩니다. 여기서 중요한 개념 두 가지가 등장합니다.

데이터 경쟁(data race) 여러 쓰레드에서 동일한 메모리를 동기화 메커니즘도 갖춰지지 않은 채 접근하려 할 때 발생합니다. 그리고 접근하려는 쓰레드 중 최소한 하나는 쓰기 작업이 포함될 때 발생합니다. 예를 들어 메인 쓰레드에서 어떤 Dictionary로부터 값을 읽고 있을 때, 동시에 백그라운드 쓰레드에서는 이 Dictionary를 수정하려 드는 경우입니다.

장벽(barrier) 장벽은 동기적으로 쓰기 작동을 하기 위해 사용되는 concurrent queue입니다. barrier 플래그(flag)를 지시함으로써 특정 자원이나 값을 thread-safe한 방식으로 접근하는 것이 가능합니다. 그러므로 동시적으로 읽기 작업을 하면서 얻는 이점을 유지하면서도 동기적으로 쓰기 작업도 가능합니다.

 

데이터 경쟁을 예방하면서도 병렬 처리도 수행하기 위해 concurrent queue를 사용하면 이점을 얻는 경우들이 있습니다. 장벽은 여기서 필요하게 됩니다. concurrent queue를 사용하면 Dictionary에 대한 읽기 및 쓰기에 대한 작업이 병렬로 발생할 수 없습니다. 때문에 동시적인 읽기 작업은 성능 향상으로 이어질 수 있습니다.

읽기 작업의 호출을 직렬화하는 코드는 제거될 수 있지만 Dictionary의 내용이 이미 수정되고 있을 때 동시에 다른 쓰레드에서 읽기 및 쓰기될 수는 없고, Dictionary 내용의 수정 작업은 다른 쓰레드에서 읽기 및 쓰기를 시도하기 전에 완료되어야만 합니다. Dispatch 장벽 및 Grand Central Dispatch를 사용하면 이를 해결할 수 있습니다.

여기서 장벽 또는 ‘배리어(barrier)’는 동시적 큐와 비동시적 큐 사이를 전환하면서 ‘배리어 블록(barrier block)’에 있는 코드 실행이 끝날 때까지 비동시적(serial) 큐로 작동되다가 배리어 블록을 벗어나면 동시적 큐로 전환되는 특징이 있습니다. 위 싱글톤 클래스에서 비동시적 큐는 동시적 큐로 대체되고 writeToLog 메소드가 Dictionary를 수정할 때 배리어 블록이 설정됩니다. 여기서 writeToLog 메소드에 있을 배리어 블록은 이전에 호출되었던 모든 블록들이 다 종료될때까지 기다리게 되는데, 이렇게 하면 데이터 경쟁이나 데이터 충돌이 예방됩니다. readLog 메소드는 읽기만을 할 것이므로 동시적 큐에 의해 작동되며 병렬로 작동됩니다.

// Swift
import Foundation

final public class EventLogger {
    public static let shared = EventLogger()
    
    private var eventFired: [String:Any] = ["initialization":"Property initialized"]
    private let concurrentLabel = "concurrentQueue"
    private let concurrentQueue = DispatchQueue(label: concurrentLabel, attribute: .concurrent)
    
    private init() {
    }
    
    public func readLog(for key: String) -> String? {
        var value: String?
        concurrentQueue.sync {
            // 동시에 실행될 수 있는 부분
            value = eventFired[key] as? string
        }
        return value
    }
    
    public func writeToLog(key: String, content: Any) {
        concurrentQueue.async(flags: .barrier) {
            // 동기적으로 실행되어야 하는 부분
            self.eventFired[key] = content
        }
    }
}

이제 이 싱글톤 패턴은 thread-saf하면서도 성능도 최적화되었습니다.

카테고리 “Language/Objective-C & Swift”
more...