본문 바로가기
iOS/Combine

[Combine] Future, Deferred

by Bokoo14 2026. 3. 12.

Future

단 하나의 값 또는 에러를 비동기적으로 방출하는 Publisher

주로 콜백 기반 비동기 API를 Combine 스트림으로 래핑할 때 사용한다.

개념

Future  →  값 1개 방출 후 즉시 완료
       또는 에러 방출 후 즉시 종료

 

Publisher  방출 횟수
Just 1번 (동기)
Future 1번 (비동기)
PassthroughSubject N번
[1,2,3].publisher N번

 

시그니처

final class Future<Output, Failure: Error>: Publisher {
    init(_ attemptToFulfill: @escaping (@escaping Promise) -> Void)
}

// Promise의 정의
typealias Promise = (Result<Output, Failure>) -> Void

Promise는 단순히 Result를 받는 클로저 타입

이 클로저를 호출하는 순간 값 또는 에러가 방출된다.

 

기본 사용법

func fetchData() -> Future<String, Error> {
    return Future { promise in
        DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
            promise(.success("데이터 로드 완료"))
            // 또는 실패: promise(.failure(SomeError.networkError))
        }
    }
}

fetchData()
    .sink(
        receiveCompletion: { print("완료: \($0)") },
        receiveValue: { print("값: \($0)") }
    )
    .store(in: &cancellables)

// 출력 (1초 후):
// 값: 데이터 로드 완료
// 완료: finished

 

 

콜백 API를 Combine으로 래핑하기

Future의 가장 핵심적인 사용 목적

// ✅ 기존 콜백 기반 API
func legacyFetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    // 네트워크 요청...
}

// ✅ Future로 래핑
func fetchUser(id: Int) -> Future<User, Error> {
    return Future { promise in
        legacyFetchUser(id: id) { result in
            promise(result)  // Result를 그대로 전달
        }
    }
}

// ✅ Combine 체인에서 사용
fetchUser(id: 42)
    .map { $0.name }
    .receive(on: DispatchQueue.main)
    .sink(
        receiveCompletion: { ... },
        receiveValue: { name in self.userName = name }
    )
    .store(in: &cancellables)

 

 

즉시 실행 (Eager Execution)

Future는 구독 여부와 관계없이 생성 즉시 클로저를 실행한다.

// 구독하지 않아도 클로저 실행됨!
let future = Future<Int, Never> { promise in
    print("클로저 실행됨")  // Future 생성 시점에 즉시 출력
    promise(.success(42))
}

// 아직 .sink 안 했는데도 이미 위 print가 실행된 상태

 

AnyPublisher나 일반 Publisher가 구독 시점에 실행되는 것과 반대

일반 Publisher: 생성 ── ... ── 구독 ── 실행
Future:          생성 = 실행

 

캐싱

즉시 실행의 연장선으로, Future는 Promise 결과를 내부에 캐싱한다.

나중에 구독하더라도 저장된 결과를 즉시 전달함

 

매번 새로 실행되길 원한다면 Deferred { Future { ... } } 패턴을 사용

let future = Future<Int, Never> { promise in
    print("API 호출!")
    promise(.success(42))
}

// 첫 번째 구독
future.sink { print("구독1: \($0)") }.store(in: &cancellables)

// 두 번째 구독 — API 재호출 없이 캐시된 42 전달
future.sink { print("구독2: \($0)") }.store(in: &cancellables)

// 출력:
// API 호출!  ← 한 번만 실행
// 구독1: 42
// 구독2: 42

Deferred

Deferred는 구독이 발생하는 시점에 Publisher를 생성하는 래퍼

"Publisher를 만드는 Publisher"라고 볼 수 있음

 

왜 필요한가: Future의 문제

func makeRequest() -> Future<String, Error> {
    return Future { promise in
        print("요청 실행!")
        networkCall { promise($0) }
    }
}

let publisher = makeRequest()  // ← 여기서 이미 네트워크 요청 발생
// ...
// 실제 구독은 훨씬 나중에
publisher.sink { ... }.store(in: &cancellables)

Future는 생성 즉시 실행되기 때문에, 구독 전에 요청이 나가버리는 문제가 생김

또한 재구독해도 캐시된 결과만 돌아옴

 

Deferred의 동작

let publisher = Deferred {
    Future<String, Error> { promise in
        print("요청 실행!")
        networkCall { promise($0) }
    }
}
// 아직 아무것도 실행 안 됨

publisher.sink { ... }.store(in: &cancellables)
// ← 구독 시점에 비로소 Future 생성 + 실행
Deferred 생성    →  아무것도 안 함
구독 발생        →  클로저 실행 → 내부 Publisher 생성 → 구독 연결

 

 

차이

// Future 단독 — 생성 즉시 실행, 결과 캐싱
let future = Future<Int, Never> { promise in
    print("실행: \(Date())")
    promise(.success(Int.random(in: 1...100)))
}

future.sink { print("구독1: \($0)") }.store(in: &cancellables)
future.sink { print("구독2: \($0)") }.store(in: &cancellables)

// 출력:
// 실행: 2024-01-01 00:00:00  ← 한 번만
// 구독1: 42
// 구독2: 42  ← 캐시된 값 재사용


// Deferred + Future — 구독마다 새로 실행
let deferred = Deferred {
    Future<Int, Never> { promise in
        print("실행: \(Date())")
        promise(.success(Int.random(in: 1...100)))
    }
}

deferred.sink { print("구독1: \($0)") }.store(in: &cancellables)
deferred.sink { print("구독2: \($0)") }.store(in: &cancellables)

// 출력:
// 실행: 2024-01-01 00:00:00  ← 구독1
// 구독1: 42
// 실행: 2024-01-01 00:00:01  ← 구독2 (새로 실행)
// 구독2: 87

 

 

시그니처

struct Deferred<DeferredPublisher: Publisher>: Publisher {
    init(_ createPublisher: @escaping () -> DeferredPublisher)
}

클로저가 반환하는 타입이 곧 Publisher 타입이 된다. Future뿐 아니라 어떤 Publisher든 감쌀 수 있음

 

Future 외에도 활용 가능

// 구독 시점의 현재 값을 캡처하고 싶을 때
var currentUserId = 1

let publisher = Deferred {
    Just(currentUserId)  // 구독 시점의 currentUserId를 캡처
}

currentUserId = 99

publisher.sink { print($0) }
// 출력: 99  ← 구독 시점 값 반영
// 조건에 따라 다른 Publisher를 반환할 때
let publisher = Deferred { () -> AnyPublisher<String, Error> in
    if isLoggedIn {
        return fetchProfile().eraseToAnyPublisher()
    } else {
        return Fail(error: AuthError.notLoggedIn).eraseToAnyPublisher()
    }
}

 

 

retry와 조합

retry는 실패 시 Publisher를 재구독한다.

Future 단독이면 캐시된 실패가 반복되지만, Deferred로 감싸면 매번 새 요청을 보낸다.

// ❌ retry가 의미 없음 — 캐시된 에러를 반복 전달
Future<Data, Error> { promise in
    networkCall { promise($0) }
}
.retry(3)

// ✅ retry마다 실제로 새 요청 발생
Deferred {
    Future<Data, Error> { promise in
        networkCall { promise($0) }
    }
}
.retry(3)

 

정리

  Future  Deferred { Future }
실행 시점 생성 즉시 구독 시점
재구독 캐시된 결과 반환 매번 새로 실행
retry 호환
용도 단발성 비동기 작업 구독마다 독립 실행이 필요한 작업

Deferred는 "실행을 구독 시점까지 미루고 싶다"는 의도를 명확하게 표현하는 래퍼다.

Future와 함께 쓰는 경우가 많지만, 본질적으로는 어떤 Publisher의 생성 타이밍이든 지연시킬 수 있는 도구다.


Deferred + Future 패턴

구독할 때마다 새로 실행되어야 하는 경우 (예: 매번 새 API 요청)에 사용

// ❌ Future만 사용 — 항상 캐시된 결과 반환
let cachedFuture = Future<Int, Never> { promise in
    promise(.success(Int.random(in: 1...100)))
}

// ✅ Deferred로 감싸기 — 구독마다 새로 실행
let freshFuture = Deferred {
    Future<Int, Never> { promise in
        promise(.success(Int.random(in: 1...100)))
    }
}

 

flatMap과 함께 쓰는 패턴

이벤트 스트림에서 각 값마다 비동기 작업을 체이닝할 때 자주 사용

let userIds = [1, 2, 3].publisher

userIds
    .flatMap { id in
        Deferred { Future { promise in
            fetchUser(id: id) { result in promise(result) }
        }}
    }
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { user in print(user.name) }
    )
    .store(in: &cancellables)

 

정리

특성 내용
방출 횟수 정확히 1번
실행 시점 생성 즉시 (Eager)
결과 캐싱 ✅ (재구독 시 재실행 없음)
주요 용도 콜백 API → Combine 래핑
매번 새 실행 필요 시 Deferred { Future { } }

 

Future: 즉시 실행, 결과 캐싱

콜백 기반 비동기 코드를 Combine 스트림으로 연결하는 용도로 주로 쓰이며, 구독마다 새로 실행되어야 하는 경우엔 Deferred와 조합해야 한다.

 
 
 
 
 

'iOS > Combine' 카테고리의 다른 글

[Combine] .handleEvents  (0) 2026.03.12
[Combine] .removeDuplicates()  (0) 2026.03.11