Callback — 정의, 콜백 함수 및 작동 방식

저자: IT Sectr 게시일: 2026-03-17 읽는 시간: 11 분

Callback은 다른 함수에 인수로 전달되어 비동기 작업이 완료된 후 실행되는 함수입니다. 모바일 개발에서 콜백은 네트워크 요청 결과 처리, 데이터베이스 작업 및 애니메이션에 사용됩니다. Apple 문서(2025)에 따르면 Swift의 클로저는 콜백의 기본 형태이며 URLSession, GCD 및 Combine에서 사용됩니다. Android에서는 인터페이스, Kotlin 람다 및 ListenableFuture를 통해 콜백이 구현됩니다.

핵심 포인트

  • Callback — 비동기 실행을 위해 인수로 전달되는 콜백 함수.
  • Swift는 콜백에 @escaping 키워드와 함께 클로저를 사용합니다.
  • Kotlin은 콜백에 람다 표현식과 고차 함수를 사용합니다.
  • Retain cycle — iOS에서 콜백 내 self 캡처 시 메모리 누수.
  • Callback Hell — 중첩된 콜백의 문제로, async/await와 코루틴으로 해결.

Callback이란?

Callback(콜백 함수)은 다른 함수에 전달되어 특정 작업이 완료된 후 호출되는 실행 가능한 코드입니다. 모바일 개발에서 콜백은 비동기 프로그래밍의 기본 메커니즘으로, 메인 스레드를 차단하지 않고 네트워크 요청, 타이머, 애니메이션 및 I/O 작업 완료에 대응할 수 있게 합니다. Swift와 Kotlin은 콜백 생성을 위한 내장 구문 구조를 제공합니다 — 각각 클로저와 람다입니다.

Callback 작동 원리

고차 함수는 다른 함수를 매개변수로 받아들이고 자체 메인 로직을 실행한 후 호출합니다. 제어 흐름은 콜백을 통해 호출자에게 반환되므로 이러한 이름이 붙었습니다. iOS에서는 UIKit(UIView.animate 애니메이션), Foundation(URLSession.dataTask) 및 Combine(sink)에서 콜백이 사용됩니다. Android에서는 View.OnClickListener, Retrofit Callback 및 Room DAO에서 콜백이 사용됩니다. 최신 API는 점점 콜백을 async/await나 코루틴으로 대체하고 있지만, 레거시 코드 및 저수준 API 작업에는 콜백 이해가 필요합니다.

동기 및 비동기 Callback

콜백은 동기(함수 내에서 즉시 호출)와 비동기(나중에 다른 스레드나 큐에서 호출)로 나뉩니다. 동기 콜백은 정렬(비교기) 및 컬렉션 순회에 사용됩니다. 비동기 콜백은 네트워크 요청, 파일 읽기 및 센서 작업에 사용됩니다. 스레딩 이해에 차이가 중요합니다: 동기 콜백은 동일한 스레드에서 실행되고, 비동기 콜백은 디스패처(iOS의 DispatchQueue, Kotlin의 Dispatchers)가 결정한 스레드에서 실행됩니다.

iOS와 Android에서 Callback 작동 방식

두 플랫폼의 콜백 메커니즘은 동일한 원리에 기반합니다: 함수가 일급 객체로 전달되고 실행 시점까지 저장됩니다. 그러나 언어 패러다임의 차이로 구현이 다릅니다. iOS에서 콜백은 주변 컨텍스트에서 변수를 캡처하는 클로저입니다. Android에서 콜백은 대부분 익명 클래스 또는 Kotlin 람다 표현식을 통해 구현되며 FunctionalInterface로 컴파일됩니다.

iOS에서 Callback 생명주기

비동기 함수가 호출되면 클로저는 캡처된 변수와 함께 힙에 저장됩니다. 작업이 완료되면 GCD 또는 OperationQueue 시스템이 콜백을 적절한 큐(메인 큐 또는 백그라운드 큐)에 배치합니다. 실행 후 강한 참조가 없으면 콜백은 메모리에서 제거됩니다. 캡처 목록([weak self])은 객체 할당 해제 후 보유를 방지합니다. 캡처 목록이 없으면 객체와 콜백이 서로를 참조하는 리테인 사이클이 발생합니다.

swift
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        completion(.success(data))
    }
    task.resume()
}

// [weak self] 사용
fetchData { [weak self] result in
    guard let self else { return }
    switch result {
    case .success(let data):
        self.updateUI(data)
    case .failure(let error):
        self.showError(error)
    }
}

Android에서 Callback 생명주기

Android에서는 인터페이스나 람다를 통해 콜백이 전달됩니다. ExecutorService 또는 코루틴을 통해 비동기 작업을 실행할 때, 백그라운드 작업이 완료될 때까지 콜백이 메모리에 유지됩니다. Kotlin 람다는 외부 변수를 캡처하는 익명 클래스로 컴파일됩니다. JVM에 약한 참조가 없으므로 수동 관리가 필요합니다: onDestroy()에서 콜백 null 처리, Job.cancel()을 통한 코루틴 취소. ViewModel과 LiveData는 아키텍처 컴포넌트 수준에서 이 문제를 해결합니다.

kotlin
interface Callback<T> {
    fun onSuccess(data: T)
    fun onError(error: Throwable)
}

class Repository {
    fun loadData(callback: Callback<List<User>>) {
        thread {
            try {
                val result = api.fetchUsers()
                runOnUiThread { callback.onSuccess(result) }
            } catch (e: Exception) {
                runOnUiThread { callback.onError(e) }
            }
        }
    }
}

// 람다 사용
repository.loadData(object : Callback<List<User>> {
    override fun onSuccess(data: List<User>) { showUsers(data) }
    override fun onError(error: Throwable) { showError(error.message) }
})

Swift와 Kotlin의 Callback 문법

콜백 문법은 언어가 함수를 일급 객체로 다루는 능력에 의해 결정됩니다. Swift에서 클로저는 자동 인수 이름($0, $1)을 가진 간결한 문법을 가집니다. Kotlin에서 람다는 단일 인수에 대해 it을 지원합니다. 차이점은 변수 캡처 처리(Swift의 캡처 목록 vs Kotlin의 가변 참조)와 타입 지정(Result vs Result)에서 나타납니다.

Swift의 Callback: 클로저

Swift 클로저는 다른 함수에 전달되어 사용될 수 있는 독립적인 코드 블록입니다. 클로저는 전역(명명된), 중첩 및 표현식 수준이 될 수 있습니다. @escaping은 함수 반환 후 실행될 클로저를 표시합니다 — 이는 비동기 콜백의 필수 요구사항입니다. @escaping이 없으면 클로저는 함수 본문 내에서만 실행될 수 있습니다. 후행 클로저 문법을 사용하면 괄호 뒤에 클로저를 전달할 수 있습니다: fetchData { result in ... }.

swift
typealias NetworkResult = (Result<[String: Any], Error>) -> Void

func performRequest(
    url: URL,
    then handler: @escaping NetworkResult
) {
    let task = URLSession.shared.dataTask(with: url) { data, _, error in
        handler(Result {
            guard let json = try JSONSerialization.jsonObject(with: data)
            else { throw NetworkError.invalidData }
            return json as! [String: Any]
        })
    }
    task.resume()
}

performRequest(url: url) { result in
    switch result {
    case .success(let json): process(json)
    case .failure(let error): log(error.localizedDescription)
    }
}

Kotlin의 Callback: 람다와 고차 함수

Kotlin은 다른 함수를 매개변수로 받아들이는 고차 함수를 지원합니다. Callback은 Kotlin에서 (T) -> Unit 타입의 매개변수 또는 반환 값을 위한 (T) -> R을 통해 전달됩니다. Kotlin 코루틴의 suspend 함수는 콜백을 순차 코드로 대체하지만, 콜백은 Java 호환 API 및 Android SDK(View.OnClickListener, TextWatcher)에 남아 있습니다. Kotlin 람다는 자동으로 val 변수를 캡처하며, var 변수에는 가변성 래퍼가 필요합니다.

kotlin
fun <T, R> processWithCallback(
    input: T,
    transform: (T) -> R,
    onResult: (R) -> Unit
) {
    thread {
        val result = transform(input)
        runOnUiThread { onResult(result) }
    }
}

// 람다 예제
processWithCallback(
    input = "Hello",
    transform = { it.length },
    onResult = { length ->
        textView.text = "Length: $length"
    }
)

Retain cycles와 Callback 메모리 누수

Retain cycle(리테인 사이클)은 두 객체가 서로에 대한 강한 참조를 보유하여 메모리 관리자가 이를 해제하지 못하는 상황입니다. Swift에서는 viewController가 클로저를 캡처하고 클로저가 self를 캡처할 때 리테인 사이클이 발생합니다. Kotlin/Java에서는 Activity가 내부 클래스나 람다를 장기 실행 백그라운드 작업에 전달할 때 누수가 발생합니다. WWDC 세션 10216(2024)에 따르면, 부적절한 클로저 관리는 iOS 애플리케이션에서 메모리 누수의 세 번째로 흔한 원인입니다.

Swift의 Retain cycles

Swift는 자동 참조 카운팅(ARC)을 사용하여 참조 카운터가 0이 되면 객체를 해제합니다. 클로저의 캡처 목록[weak self] 또는 [unowned self]는 리테인 사이클을 방지합니다. weak self는 객체 할당 해제 시 nil이 되는 옵셔널 참조를 생성합니다. unowned self는 객체가 클로저보다 오래 사는 것을 가정합니다 — 이 가정을 위반하면 크래시가 발생합니다. 안전한 기본값으로 weak self 사용이 권장됩니다.

swift
class DataController {
    var onDataUpdate: ((String) -> Void)?

    func setupCallback() {
        // Retain cycle!
        onDataUpdate = { text in
            self.process(text)
        }

        // [weak self]로 수정됨
        onDataUpdate = { [weak self] text in
            guard let self else { return }
            self.process(text)
        }
    }

    func process(_ input: String) { }
}

Android의 메모리 누수

Android에서는 Activity나 Fragment가 싱글톤 컴포넌트(예: EventBus 또는 Service)에 리스너를 전달할 때 콜백 메모리 누수가 발생합니다. WeakReference는 가비지 컬렉터가 약한 참조가 있더라도 Activity를 해제할 수 있게 합니다. Lifecycle-aware 컴포넌트(LiveData, Flow)는 자동으로 문제를 해결합니다. Activity 컨텍스트를 캡처하는 Kotlin 람다도 누수를 일으킬 수 있습니다: 람다는 암시적으로 this에 대한 참조를 저장합니다.

kotlin
class SafeCallbackManager {
    private val listeners = mutableListOf<WeakReference<(String) -> Unit>>()

    fun addListener(callback: (String) -> Unit) {
        listeners.add(WeakReference(callback))
    }

    fun notifyAll(data: String) {
        val iterator = listeners.iterator()
        while (iterator.hasNext()) {
            val ref = iterator.next().get()
            if (ref != null) ref(data)
            else iterator.remove()
        }
    }
}

// Fragment에서 사용
manager.addListener { result ->
    // WeakReference가 Fragment를 보유하지 않음
    updateUI(result)
}

Callback Hell과 해결 방법

Callback Hell(파멸의 피라미드라고도 함)은 여러 중첩된 콜백이 깊이 중첩된 코드 구조를 만들어 읽고 디버깅하기 어려운 상황입니다. 각 후속 단계는 이전 단계의 완료를 기다려야 하며, 5-10 수준의 중첩이 발생합니다. 이 문제는 순차적 비동기 작업(데이터 로드 → 파싱 → DB 저장 → UI 업데이트)에서 특징적입니다.

Swift의 해결책: async/await

Swift 5.5는 비동기 함수(async/await)를 도입하여 비동기 코드를 순차적으로 작성할 수 있게 했습니다. AsyncSequenceAsyncStream은 콜백 기반 반복을 대체합니다. Combine 프레임워크는 중첩 없이 비동기 스트림을 구성하기 위한 flatMap, merge, combineLatest와 같은 연산자를 제공합니다. 그러나 Objective-C API 및 async를 지원하지 않는 타사 라이브러리와 작업하려면 콜백이 여전히 필요합니다.

swift
// 중첩된 콜백 — Callback Hell
loginUser(credentials) { user in
    fetchProfile(user.id) { profile in
        downloadAvatar(profile.avatarUrl) { image in
            cacheImage(image) { success in
                updateUI(user, profile, image)
            }
        }
    }
}

// async/await — 해결책
func loadUserExperience() async throws {
    let user = try await loginUser(credentials)
    let profile = try await fetchProfile(user.id)
    let image = try await downloadAvatar(profile.avatarUrl)
    try await cacheImage(image)
    updateUI(user, profile, image)
}

Kotlin의 해결책: 코루틴과 Flow

Kotlin 코루틴은 순차적 실행을 위해 콜백을 suspend 함수로 대체합니다. Flow는 map, flatMapConcat, combine과 같은 연산자를 가진 콜드 스트림을 제공합니다. CoroutineScope는 컴포넌트가 소멸될 때 실행 중인 모든 코루틴을 취소할 수 있습니다. Room, Retrofit 및 기타 Jetpack 라이브러리는 suspend 함수에 대한 내장 지원을 가지고 있어 표준 작업에서 콜백 필요성을 없앱니다.

kotlin
// 순차적 콜백 — Callback Hell
api.login(credentials) { user ->
    api.fetchProfile(user.id) { profile ->
        api.download(profile.avatarUrl) { bytes ->
            file.save(bytes) { result ->
                textView.text = result.toString()
            }
        }
    }
}

// 코루틴 — 해결책
suspend fun loadUserData() {
    val user = withContext(Dispatchers.IO) { api.login(credentials) }
    val profile = withContext(Dispatchers.IO) { api.fetchProfile(user.id) }
    val bytes = withContext(Dispatchers.IO) { api.download(profile.avatarUrl) }
    withContext(Dispatchers.IO) { file.save(bytes) }
    textView.text = "Done"
}

Callback vs Delegate: 무엇을 선택할까?

CallbackDelegate는 비동기 알림의 두 가지 접근 방식이며, 선택은 아키텍처 요구사항에 따라 달라집니다. Callback은 단일 결과가 있는 일회성 작업에 적합합니다. Delegate는 다양한 메서드 시그니처를 가진 여러 이벤트용으로 설계되었습니다. Apple은 여러 메서드가 있는 복잡한 프로토콜에는 delegate를, 단일 결과가 있는 간단한 클로저에는 callback을 권장합니다. Android에서는 람다 지원으로 인해 대부분의 경우 callback이 delegate를 대체합니다.

Callback을 선택해야 하는 경우

Callback은 단일 결과가 있는 작업에 최적입니다: 네트워크 요청, 파일 읽기, 완료 블록이 있는 애니메이션. 장점: 간결한 문법, 별도의 프로토콜 불필요, 직접적인 컨텍스트 캡처. 단점: 여러 결과(진행, 일시 중지, 취소)에서의 복잡성, 여러 번 전송 불가능(콜백이 두 번 이상 호출될 수 있는 경우 publisher 사용).

Delegate를 선택해야 하는 경우

Delegate는 여러 필수 및 선택적 메서드가 있는 프로토콜에 적합합니다: UITableViewDelegate, CLLocationManagerDelegate, Bluetooth 연결. 장점: 각 메서드의 명확한 타입 지정, 프로토콜을 통한 문서화, @objc optional을 통한 선택적 메서드 지원. 단점: 보일러플레이트 코드, delegate에 대한 약한 참조 필수(weak var delegate), 컨텍스트 캡처의 복잡성.

자주 묻는 질문

Callback과 고차 함수의 차이점은 무엇인가요?

Callback은 고차 함수의 특수한 경우입니다. 고차 함수는 다른 함수를 인수로 받거나 반환합니다. Callback은 작업 완료 후 비동기 실행을 위해 특별히 전달되는 함수입니다. 모든 콜백은 고차 함수를 통해 구현되지만, 모든 고차 함수가 콜백인 것은 아닙니다.

Callback을 여러 번 호출할 수 있나요?

관례에 따라 콜백은 정확히 한 번만 호출되어야 합니다 — success 또는 failure 중 하나입니다. 동일한 콜백의 여러 번 호출은 설계 오류로 간주됩니다. 여러 이벤트(진행, 데이터 스트림)에는 Observable, Publisher 또는 Flow를 사용하세요 — 여러 값 방출을 지원합니다. 일부 API는 이 규칙을 위반하여 찾기 어려운 버그를 발생시킵니다.

Swift의 후행 클로저(trailing closure)란?

Trailing closure는 함수 호출 괄호 뒤에 클로저를 전달할 수 있게 하는 Swift의 문법적 설탕입니다. 함수가 마지막 인수로 클로저를 받는 경우 괄호 밖에 배치할 수 있습니다: fetchData { result in ... }. 여러 클로저의 경우 trailing closure는 마지막 클로저에만 적용되며, 나머지는 괄호 안에 이름을 지정합니다. 이는 콜백 기반 API의 가독성을 향상시킵니다.

Android에서 콜백 메모리 누수를 방지하는 방법은?

장기 실행 리스너에는 WeakReference를 사용하고, onDestroy()에서 Job.cancel()을 통해 코루틴을 취소하고, 자동 취소를 위해 lifecycleScope를 사용하세요. ViewModel + LiveData/Flow는 아키텍처 수준에서 문제를 해결합니다. 정적 콜백에 Activity 컨텍스트를 전달하지 마세요 — Application context를 사용하세요. Kotlin 람다는 암시적으로 this를 캡처하므로 메모리 프로파일러로 확인하세요.

Async/await가 콜백을 완전히 대체할까요?

Async/await는 순차적 비동기 코드의 콜백을 대체하지만, 이벤트 중심 아키텍처는 대체하지 않습니다. 콜백은 시스템 API(View.OnClickListener, URLSession delegates), 진행 콜백 및 타사 라이브러리에 남아 있습니다. 하위 호환성으로 인해 완전한 대체는 불가능합니다. 현대적 전략은 콜백 래퍼(Swift의 continuation, Kotlin의 suspendCancellableCoroutine)와 함께 async/await를 사용하는 것입니다.

요약

  • Callback — 작업 완료 후 비동기 실행을 위해 인수로 전달되는 콜백 함수.
  • Swift는 @escaping, 캡처 목록 [weak self] 및 후행 클로저 문법으로 클로저를 통해 콜백을 구현.
  • Kotlin은 비동기를 위해 람다, 고차 함수 및 코루틴 suspend 함수를 사용.
  • Retain cycle은 iOS에서 캡처 목록으로 방지; Android에서는 WeakReference와 lifecycle-aware 컴포넌트로 방지.
  • Callback Hell은 Swift에서 async/await, Kotlin에서 Flow와 코루틴으로 해결.
  • Delegate는 여러 메서드의 프로토콜에 콜백보다 선호; 일회성 작업에는 콜백.
  • 단순 비동기 작업에는 콜백, 순차 체인에는 async/await, 여러 이벤트에는 delegate 사용.

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기