Swift Concurrency를 적용하면서 발생한 동시성 문제
매칭 UseCase에 캐시를 Dictionary로 두고 있었는데, Combine을 Swift Concurrency로 옮기면서 그 cache에 접근하다 간헐적으로 크래시가 발생.
원인은 여러 Task가 await 경계 사이에 같은 딕셔너리를 동시에 읽고 쓴 데이터 경합. 크래시를 해결한 과정과, 캐시 접근을 스레드 안전하게 만드는 두 방법(actor / lock)을 두고 고민한 내용을 정리.
목차
- 기존 구조(Combine과 class 딕셔너리 캐시)
- Swift Concurrency 전환
- 캐시를 스레드 안전하게 관리하는 방법 actor vs lock
- 요청 취소와 예외 처리
기존 구조(Combine과 class 딕셔너리 캐시)
기존 코드는 캐싱을 위해 캐시 프로퍼티가 있고, Combine 기반이었음.
- Combine을 선택한 이유는 RxSwift가 익숙해서, RxSwift와 닮은 애플 퍼스트파티인 Combine을 고름.
public final class DefaultMatchingUseCase: MatchingUseCase {
private let repository: MatchingRepository
typealias Key = MatchingUserQuery
typealias Value = MatchingInfo
private(set) var cache: [Key: Value] = [:]
public init(repository: MatchingRepository) {
self.repository = repository
}
public func execute(query: MatchingUserQuery) -> AnyPublisher<MatchingInfo, RepositoryError> {
if let cachedData = cache[query] {
return Just(cachedData)
.setFailureType(to: RepositoryError.self)
.eraseToAnyPublisher()
} else {
return repository.matchingUser(query: query)
.receive(on: DispatchQueue.main) // 이 뒤 연산자부터 메인에서 실행
.handleEvents(receiveOutput: { [weak self] matchingInfo in
self?.cache[query] = matchingInfo // 그래서 캐시 쓰기가 메인 큐에서 일어남
})
.eraseToAnyPublisher()
}
}
}
cache는 그냥 class에 둔 Dictionary. 포인트는 receive(on: DispatchQueue.main)을 handleEvents 앞에 둔 것. Combine의 receive(on:)은 그 뒤(다운스트림) 연산자부터 스케줄러를 바꾸므로, 캐시를 쓰는 handleEvents가 메인 큐에서 실행됨. 메인 큐는 직렬이라 여러 요청이 와도 캐시 쓰기가 한 번에 하나씩 처리되어 동시 접근이 발생하지 않음.
Swift Concurrency 전환
Combine 인터페이스를 한 번에 지우지 않고, 같은 기능의 async 인터페이스를 나란히 추가해 점진적으로 옮김.
public protocol MatchingRepository {
func matchingUser(query: MatchingUserQuery) -> AnyPublisher<MatchingInfo, RepositoryError>
func matchingUser(query: MatchingUserQuery) async -> Result<MatchingInfo, RepositoryError>
}
async 메소드가 throws가 아니라 Result로 반환하는 이유
- Swift 6부터는
throws에 에러 타입을 지정할 수 있으나(typed throws), 그 이전 버전에서는 못 씀. - 프로젝트를 Swift 6로 올리는 데 시간이 걸려 Swift 5.10 기준으로 작업하려고
Result로 반환.
UseCase에도 async load를 추가.
public func load(query: MatchingUserQuery) async -> Result<MatchingInfo, RepositoryError> {
if let cachedData = cache[query] {
return .success(cachedData)
} else {
let result = await repository.matchingUser(query: query)
if case .success(let matchingInfo) = result {
cache[query] = matchingInfo // await 재개 이후 여기서 쓰기
}
return result
}
}
위 코드에서 장애가 발생하는데 load는 await repository.matchingUser를 기다리는 동안 실행이 중단(suspend)되었다가 재개될 수 있음.
매칭 화면에서 요청이 빠르게 연달아 들어오면(재요청/연타) 여러 load가 동시에 진행되고, 한 쪽이 await로 멈춰 있는 사이 다른 쪽이 cache를 건드림.
Dictionary는 동시 접근에 안전하지 않음. 서로 다른 스레드가 같은 딕셔너리를 동시에 쓰면 내부 버퍼가 깨져 크래시가 남. Combine 시절엔 접근이 메인으로 모여 안 터지던 코드가, async로 오면서 접근 스레드가 흩어져 드러난 것. 데이터 경합이라 항상 재현되지 않고 간헐적으로 터지는 게 특징.
정리하면 크래시의 원인은 Swift Concurrency 자체가 아니라, 여러 곳에서 동시에 접근하는 cache를 아무 보호 없이 둔 것이 문제의 원인
캐시를 스레드 안전하게 관리하는 방법 actor vs lock
cache 접근을 직렬화해서 한 번에 하나씩만 읽고 쓰게 만들면 됨. 두 가지를 두고 고민함.
방법 1: actor
캐시를 actor로 감싸면 언어 차원에서 상태가 격리되어 접근이 직렬화됨.
actor MatchingCache {
private var storage: [MatchingUserQuery: MatchingInfo] = [:]
func value(for query: MatchingUserQuery) -> MatchingInfo? {
storage[query]
}
func insert(_ info: MatchingInfo, for query: MatchingUserQuery) {
storage[query] = info
}
}
간단하고 안전하지만 외부에 `await`을 강요해야 하는 문제점이 있음 (개발자 경험 관점에서 너무 안좋음)
- 캐시를 읽고 쓸 때마다 호출부가
await를 붙여야 함. 단순 조회에도 비동기가 전파됨.
if let cached = await cache.value(for: query) { // 조회에도 await
return .success(cached)
}
actor는 재진입(reentrancy) 덕에await가 스레드를 막지 않아 전통적인 데드락은 잘 안 생김. 대신 그 재진입 때문에 "중단 전후로 상태가 그대로일 것"이라는 가정이 깨질 수 있어, 불변식이 어긋나지 않는지는 따로 봐야 함.Mutex(Synchronization 프레임워크)도 후보였으나 iOS 18+가 필요해 당시 타겟에선 제외.
방법 2: lock으로 감싼 동기 캐시
딕셔너리를 lock으로 감싸면 접근을 직렬화하면서도 API는 동기로 유지됨. 여기서는 OSAllocatedUnfairLock을 사용(iOS 16+, 더 낮은 타겟은 NSLock이나 os_unfair_lock).
final class MatchingCache: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(
initialState: [MatchingUserQuery: MatchingInfo]()
)
func value(for query: MatchingUserQuery) -> MatchingInfo? {
lock.withLock { $0[query] }
}
func insert(_ info: MatchingInfo, for query: MatchingUserQuery) {
lock.withLock { $0[query] = info }
}
}
호출부는 await 없이 그대로 씀.
if let cached = cache.value(for: query) { // await 없음
return .success(cached)
}
선택: lock
캐시 조회/저장은 짧고 빈번한 동기 작업이라, 이걸 위해 호출 체인 전체에 await를 퍼뜨리는 건 과함. lock으로 감싸면 스레드 안전성은 똑같이 얻으면서 API는 동기로 남아 외부에 비동기를 강제하지 않음. 그래서 이 경우엔 lock을 택함.
요청 취소와 예외 처리
크래시와 별개로, async 전환에서 하나 더 손봐야 했던 것. 요청이 연달아 들어올 때 이전 요청의 결과가 뒤늦게 반영되는 문제.
task를 저장 프로퍼티(private var task: Task<Void, Never>?)로 두고, 새 호출마다 이전 Task를 취소한 뒤 재할당.await load이후Task.isCancelled를 확인해, 취소된 요청의 결과로 화면 전환이나 상태 저장이 뒤늦게 일어나는 것을 막음.
task?.cancel()
task = Task { [weak self] in
guard let self else { return }
let result = await inject.useCase.matching.load(query: query)
guard !Task.isCancelled else { return }
switch result {
case let .success(matchingInfo):
inject.coordinator.present(.matchingResult(matchingInfo))
send(action: .saveMBTI(matchingInfo.profile.mbti))
case .failure:
send(action: .alert(.failedMatchingProfile("프로필 조회에 실패했어요")))
}
}
취소는 이미 나간 네트워크 요청을 멈추는 게 아니라, 그 결과의 반영을 막는 역할
실제 실패(네트워크 오류 등)는 Result의 .failure로 분기해 알럿으로 처리.
(참고)
'Project > Funch(넥스터즈)' 카테고리의 다른 글
| 모듈화 리팩토링 과정에서 고민했던 것들 (2) | 2024.09.24 |
|---|---|
| SwiftUI 화면 dismiss 상황에서 흰 화면 나타나는 문제 (1) | 2024.09.22 |
| 지하철 검색 기능에 캐싱 로직 도입하고 테스트로 검증하기 (0) | 2024.09.20 |
| [IT 동아리 Nexters] 24기 프로젝트 회고 (0) | 2024.03.03 |
| iOS Memory Debug Graph 분석해 프로젝트 구조 개선 (0) | 2024.03.01 |