programming/Swift(iOS)

[Swift/iOS] DispatchQueue와 Task의 차이 — 두 AI와 대화로 정리한 Swift 동시성

마들브라더 2026. 5. 23. 16:12

회사에서 DispatchQueueTask에 대해 이야기하다가 의문이 생겼다. 신규 코드에서는 DispatchQueue를 쓸 일이 더 이상 없는 걸까. 처음에는 Claude와 대화하면서 개념을 정리해 보았고, 그 결과를 다시 Gemini에게 리뷰받으며 한 번 더 검증했다. 그 다음 다시 또 Claude와 이야기를 이어갔다. 두 AI가 서로 다른 관점에서 짚어준 부분이 있었고, 그 과정에서 처음에는 보이지 않았던 함정과 미묘한 결론에 도달하게 되었다. 그 모든 과정을 시간 순서대로 정리해 본다.


1. Claude와의 첫 대화: 기본 개념 정리

먼저 Claude에게 DispatchQueueTask가 어떻게 다른지 물었다. 답변을 정리하면 다음과 같다.

한 줄 요약

항목 DispatchQueue Task
등장 GCD (오래된 방식) Swift Concurrency (Swift 5.5+)
패러다임 스레드 기반 구조적 동시성 (async/await)

DispatchQueue

GCD(Grand Central Dispatch) 기반으로, 스레드 풀을 직접 관리하는 방식이다.

DispatchQueue.global().async {
    let result = heavyWork()

    DispatchQueue.main.async {
        self.label.text = result  // 메인 스레드로 복귀
    }
}

이 방식의 특징을 보면, 클로저 기반이라 콜백 중첩이 생기기 쉬워서 흔히 말하는 "콜백 지옥"에 빠질 수 있다. 에러 처리가 자연스럽게 통합되지 않고, 취소를 하려면 DispatchWorkItem을 따로 만들어야 한다. 또한 어느 스레드에서 실행되는지 컴파일러가 보장해 주지 못한다.

Task

Swift Concurrency의 async/await 기반 비동기 단위다.

Task {
    let result = await heavyWork()
    await MainActor.run {
        self.label.text = result  // 메인 스레드 명시적 보장
    }
}

// 또는 @MainActor가 붙은 컨텍스트라면 자동으로 메인에서 실행

이쪽은 코드가 동기 코드처럼 위에서 아래로 선형으로 읽히고, try await로 에러 처리가 자연스럽게 통합된다. task.cancel() 한 줄로 취소가 가능하고, @MainActoractor 같은 격리 어노테이션을 통해 스레드 안전성을 컴파일 타임에 검증할 수 있다. 그리고 async let이나 TaskGroup을 쓰면 부모-자식 관계가 명확한 구조적 동시성을 만들 수 있다.

핵심 차이점

항목 DispatchQueue Task
취소 DispatchWorkItem 별도 필요 task.cancel()
에러 전파 불가 (클로저 탈출) try await로 자연스럽게
스레드 보장 런타임에만 알 수 있음 컴파일 타임 검증
메모리 관리 [weak self] 캡처 필요 구조적 스코프로 비교적 안전
중단점 (suspension) 없음 await에서 스레드 양보 가능

여기까지가 일반적인 비교다. 그런데 이 표만 보면 "그럼 이제 Task만 쓰면 되는 거 아닌가" 싶어진다. 그래서 다음 질문을 던졌다.


2. "Task 안 쓰고 DispatchQueue를 꼭 써야만 하는 상황이 있어?"

이 질문에 Claude는 "거의 없지만 몇 가지 예외 상황이 있다"고 답하면서 네 가지 케이스를 제시했다.

Claude가 제시한 케이스 1 — 동기적 직렬 접근이 필요할 때 (Serial Queue)

여러 스레드에서 동시에 같은 데이터에 접근할 때, DispatchQueue.sync를 쓰면 동기적으로 직렬을 보장할 수 있다고 했다.

private let queue = DispatchQueue(label: "com.app.myQueue")

func update(_ value: Int) {
    queue.sync {  // 동기적으로 직렬 보장
        self.data = value
    }
}

Claude는 "Task로는 actor로 대체 가능하지만, 동기 컨텍스트에서 동기적으로 직렬 접근해야 한다면 DispatchQueue가 더 직접적"이라고 덧붙였다.

Claude가 제시한 케이스 2 — DispatchSemaphore / DispatchGroup 활용 시

async/await로 전환이 불가능한 서드파티 레거시 콜백이 여럿일 때 유용하다는 설명이었다.

let group = DispatchGroup()

group.enter()
legacyAPI.fetch { group.leave() }

group.enter()
legacyAPI.upload { group.leave() }

group.notify(queue: .main) {
    print("모두 완료")
}

Claude가 제시한 케이스 3 — asyncAfter로 정밀한 타이밍 지연

DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
    // 딜레이 후 실행
}

Task로 대체 가능하긴 하다고도 덧붙였다.

Task {
    try await Task.sleep(for: .seconds(0.3))
    // ...
}

다만 asyncAfter는 취소 개념 없이 단순 딜레이가 필요할 때 더 간결하다고 했다.

Claude가 제시한 케이스 4 — Objective-C / C 기반 API와 연동

Core Data의 perform, AVFoundation의 특정 큐처럼 프레임워크 자체가 특정 큐를 요구하는 경우다.

context.perform {  // NSManagedObjectContext 전용 큐
    // Core Data 작업
}

3. 케이스를 하나씩 따져보기

답변을 받고 나서 각 케이스가 정말로 DispatchQueue 없이는 안 되는 상황인지 하나씩 반박해 보기로 했다.

"동기 컨텍스트에서 직렬 접근은 GlobalActor로 불가능해?"

케이스 1에 대해 먼저 의문을 던졌다.

나: 동기 컨텍스트에서 동기적으로 직렬 접근해야하면 GlobalActor로 불가능해?

Claude는 "GlobalActor는 async 컨텍스트에서만 격리 보장이 된다"고 답했다.

@globalActor
actor MyActor {
    static let shared = MyActor()
}

@MyActor
var data: Int = 0

외부 동기 컨텍스트에서 접근하려면 이렇게 된다.

// ❌ 동기 컨텍스트에서 접근 불가 — 컴파일 에러
func syncFunc() {
    data = 1  // error: expression is 'async' but is not marked with 'await'
}

// ✅ 올바른 패턴 — 액터로 격리된 함수를 만들어서 await로 호출
@MyActor func setData(_ value: Int) {
    data = value  // 액터 내부이므로 직접 접근 가능
}

func asyncFunc() async {
    await setData(1)  // 함수 호출에 await를 붙이는 것
}

반면 DispatchQueueasync 없이도 동기적으로 직렬을 보장한다.

private let queue = DispatchQueue(label: "serial")

func syncFunc() {
    queue.sync {   // async 필요 없이 동기적으로 직렬 보장
        data = 1
    }
}

즉, async 컨텍스트로 진입 자체가 불가능한 동기 코드에서는 GlobalActor가 작동하지 않는다는 게 Claude의 설명이었다. assumeIsolated라는 방법이 있긴 한데, 실제로 그 액터 위에 있지 않으면 런타임 크래시가 난다.

"프레임워크 강제 큐는 뭔소리야?"

케이스 4 설명이 모호해서 다시 물었다.

나: 프레임워크 강제 큐는 뭔소리야?

Claude는 이전에 든 Core Data 예시는 사실 DispatchQueue가 아니라 자체 메커니즘이라 잘못된 예시였다고 인정하면서 더 정확한 예시를 제시했다.

// AVCaptureVideoDataOutput — 특정 큐를 직접 파라미터로 넘겨야 함
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera"))

// NotificationCenter
NotificationCenter.default.addObserver(
    forName: .something,
    object: nil,
    queue: OperationQueue.main
) { _ in }

// DispatchSource
let source = DispatchSource.makeTimerSource(queue: .global())

이런 API들은 함수 시그니처 자체가 DispatchQueue(또는 OperationQueue)를 인자로 받기 때문에 Task로 대체가 불가능하다.

"동기 컨텍스트에서 Task 생성하고 GlobalActor에 접근하면 되는 거 아님?"

나: 동기 컨텍스트에서 Task 생성하고 global actor에 접근하면 되는 거 아님?

Claude의 답변은 이랬다. "맞습니다. 그렇게 할 수 있어요. 제 설명이 틀렸습니다."

func syncFunc() {
    Task { @MyActor in
        data = 1  // ✅ 가능
    }
}

다만 DispatchQueue.sync와 결정적인 차이가 하나 있다고 했다.

// DispatchQueue.sync — 호출자가 완료까지 블로킹
func syncFunc() {
    queue.sync {
        data = 1
    }
    print(data)  // ✅ 반드시 1 — 위 작업이 끝난 뒤 실행됨
}
// Task — fire-and-forget, 호출자는 바로 리턴
func syncFunc() {
    Task { @MyActor in
        data = 1
    }
    print(data)  // ⚠️ 아직 0일 수 있음 — Task는 비동기로 나중에 실행
}

즉, "동기 컨텍스트에서 작업 완료를 보장하고 다음 줄로 넘어가야 한다"는 요구사항이 있을 때만 DispatchQueue.sync가 의미가 있다는 설명이었다.

"그냥 print(data)를 Task 안으로 넣으면 되잖아"

나: 그냥 print(data)를 Task 안으로 넣으면 되잖아

Claude의 답변. "맞습니다. 그것도 됩니다."

func syncFunc() {
    Task { @MyActor in
        data = 1
        print(data)  // ✅ 반드시 1
    }
}

이 시점에서 Claude는 이렇게 마무리했다. "솔직히 말씀드리면, 제가 처음에 말한 케이스들을 사용자분이 하나씩 다 반박하셨고 전부 맞습니다. 진짜로 DispatchQueue를 써야만 하는 상황은 결국 하나로 좁혀집니다. API 시그니처 자체가 DispatchQueue를 요구하는 경우입니다."

// API 시그니처 자체가 DispatchQueue를 요구하는 경우
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera"))

여기서 Claude와의 대화는 일단락되었다. 결론은 "프레임워크 API가 DispatchQueue 타입을 인자로 강제할 때만 진짜로 필요하다"는 것이었다.


4. Gemini의 등장: "Claude가 너무 쉽게 백기를 들었다"

 대화 내용을 정리해서 Gemini Pro와 이야기를 이어나갔다. 그러자 Gemini가 반례를 들고 나타났다.

Gemini: "Claude가 너무 쉽게 항복했네요. print(data)를 Task 안으로 넣는 건 해당 함수가 단순히 '값을 출력하고 끝나는 Void 함수'일 때만 성립합니다. 하지만 실무에서는 동기 함수(Sync)에서 값을 즉시 반환(return)해야 하는 경우가 많습니다."

반례: 값을 즉시 리턴해야 하는 동기 함수

Task는 비동기로 실행되므로, 동기 함수 내부에서 Task를 돌려 그 값을 즉시 리턴하려고 하면 값을 기다리지 못하고 초기값을 반환해 버린다.

// ❌ Task로는 동기 함수에서 값을 반환할 수 없음
func getSyncData() -> Int {
    var result = 0
    Task { @MyActor in
        result = data  // 비동기로 실행되므로 언제 할당될지 모름
    }
    return result  // ⚠️ 무조건 0이 반환됨. Task가 끝나는 걸 기다릴 수 없음!
}

// ✅ DispatchQueue.sync는 가능함 (호출 스레드를 블로킹하니까)
func getSyncData() -> Int {
    var result = 0
    queue.sync {
        result = data
    }
    return result  // 큐의 작업이 끝나야 다음 줄로 넘어가므로 정확한 값 반환
}

이 부분은 Claude가 놓친 실질적인 케이스였다. 단순히 사이드 이펙트만 일으키는 Void 함수라면 Task { } 안으로 모든 걸 옮기면 되지만, 함수가 값을 반환해야 한다면 그 트릭이 통하지 않는다.

추가로 짚어준 표 수정 사항

Gemini는 표의 메모리 관리 항목도 짚었다. async let이나 TaskGroup 같은 구조적 동시성 안에서는 캡처가 안전하지만, 우리가 예시로 든 Task { ... }는 비구조적 동시성(Unstructured Concurrency)이라서 내부 클로저가 탈출한다. 따라서 안에서 self를 강하게 참조하고 그 Task가 오래 걸리는 네트워크 작업을 수행한다면, 뷰 컨트롤러가 닫혀도 메모리에서 해제되지 않는 Memory Leak이 발생할 수 있다는 지적이었다.

DispatchGroup의 대안으로 적은 withCheckedContinuation도 단일 콜백을 감싸는 용도이지, 여러 비동기 작업을 모아 기다리는 용도는 아니라는 설명도 덧붙였다. 여러 개를 모으는 용도라면 TaskGroup 또는 async let이 더 정확한 대응이다.


5. "그럼 그냥 저 함수를 async로 바꾸면 되잖아?"

Gemini의 반례를 보고 다시 물었다.

나:getSyncData 함수 자체를 async로 만들어서 await로 리턴하면 되는 것 아닌가요?

Gemini는 이때 비동기 전염(Async Infection)이라는 개념을 꺼냈다. 코드 한 곳을 async로 바꾸는 순간, 그 함수를 호출하는 모든 곳도 async가 되어야 하고, 그러면 또 그것을 호출하는 곳들도 전부 async가 되어야 한다. 결국 점 하나 찍는 것 같았던 변경이 코드베이스 전체로 전염되어 퍼져 나간다.

class NetworkRequest {
    // 기존에 존재하던 동기(Sync) 연산 프로퍼티
    var authHeader: String {
        // ❌ 여기서 에러 폭발!
        // cache가 actor가 되었기 때문에 await를 써야 하는데,
        // 일반 동기 프로퍼티 내부에서는 await를 쓸 수 없음!
        let token = await cache.getValue(forKey: "token")
        return "Bearer \(token)"
    }
}

이걸 해결하려면 authHeadervar authHeader: String { get async }로 바꿔야 하고, 그러면 그것을 가져다 쓰던 모든 함수가 async가 되어야 하고, 결국 호출 스택 전체가 도미노처럼 무너진다. 단지 캐시 하나를 actor로 바꿨을 뿐인데 앱 전체의 아키텍처를 뒤집어엎어야 하는 상황이 발생한다는 것이다.


6. "잠깐, cellForRowAt 예시는 안티 패턴이잖아!"

Gemini는 이 비동기 전염을 설명하면서 처음에는 tableView(_:cellForRowAt:)를 예시로 들었다. UIKit의 데이터소스 메서드는 시그니처가 동기적이라 async로 만들 수 없고, 그 안에서 actor에 접근하려면 결국 DispatchQueue.sync로 동기적으로 값을 빼와야 한다는 논리였다.

func tableView(_ tableView: UITableView,
               cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    var cellData: String = ""
    queue.sync {
        cellData = sharedDataManager.readData(at: indexPath.row)
    }
    let cell = UITableViewCell()
    cell.textLabel?.text = cellData
    return cell
}

그런데 여기서 또 다른 의문이 들었다.

나: 야, 저 예시 코드는 애초에 프로덕션 레벨에서는 허용되지 않는 코드잖아. 메인 스레드에서 queue.sync 걸어버리면 사용자가 스크롤 버벅인다고 느끼게 되잖아. 아예 틀린 예 아니야?

이건 단순한 UX 문제가 아니라 더 근본적인 지적이었다. cellForRowAt 안에서 무거운 작업을 처리하려는 시도 자체가 아키텍처적 실수다. DispatchQueue.sync로 블로킹하면 스크롤이 버벅이고, Task { }로 비동기 처리하면 셀이 반환된 뒤에야 데이터가 들어오니 화면이 깜빡인다. 두 도구 모두 이 자리에서는 쓰면 안 되는 것이다.

올바른 패턴은 cellForRowAt이 호출되기 훨씬 전에 async/await로 데이터를 미리 로드해두고, cellForRowAt 안에서는 이미 준비된 배열에서 그냥 꺼내 읽는 것이다.

// ✅ 올바른 구조: cellForRowAt은 이미 준비된 데이터만 읽는다
class MyViewController: UIViewController {
    var items: [Item] = []  // 이미 로드 완료된 데이터

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            items = await loadItems()  // 여기서 비동기로 로드
            tableView.reloadData()
        }
    }

    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // 여기서는 그냥 읽기만 한다. 동시성 도구 불필요.
        let item = items[indexPath.row]
        // ...
    }
}

cellForRowAt은 "DispatchQueue냐 Task냐"라는 질문이 성립하는 자리가 아니다. 이건 언제 데이터를 로드할 것인가라는 설계의 문제이지 동시성 도구를 선택하는 문제가 아니다. Gemini도 이 지적을 받아들이고 "스레드 안전한 인메모리 캐시" 예시로 정정했다.

class ThreadSafeCache {
    private var storage: [String: String] = [:]
    private let queue = DispatchQueue(label: "com.app.cacheQueue")

    // 무조건 값을 즉시 리턴해야 하는 동기 함수 (프로퍼티 게터도 동일)
    func getValue(forKey key: String) -> String? {
        var result: String?

        // ✅ 큐의 작업이 끝날 때까지 스레드를 아주 잠깐(µs) 블로킹하고 안전하게 값을 빼옴
        queue.sync {
            result = storage[key]
        }

        return result
    }
}

이쪽은 메인 스레드에서 호출되는 게 아니라 백그라운드에서 호출되는 가벼운 캐시 조회라, queue.sync로 짧게 블로킹하는 게 실용적으로 허용될 수 있는 케이스다.


7. "그럼 그냥 actor로 만들면 되잖아?"

다시 질문을 던졌다.

나: 아무리 생각해도 이상한데, 이 클래스 자체가 그냥 actor면 다 해결되는 거 아냐?

Gemini의 답. "맞습니다. 새로 짜는 코드라면 actor로 설계하는 것이 100% 정답입니다."

actor ThreadSafeCache {
    private var storage: [String: String] = [:]

    func getValue(forKey key: String) -> String? {
        return storage[key]
    }
}

이렇게 만들면 호출 측에서는 let token = await cache.getValue(forKey: "token") 형태로 쓰면 된다. DispatchQueue.sync도 필요 없고 락도 필요 없다.

그렇다면 왜 DispatchQueue.sync가 필요하다고 했을까. 바로 앞에서 설명한 비동기 전염 때문이다. 새로 만드는 캐시를 기존 레거시 시스템의 동기 프로퍼티에서 당장 써야 한다면, 그 인터페이스를 async로 못 바꾸는 상황이라면, 어쩔 수 없이 어댑터로 DispatchQueue.sync(혹은 락)를 끼워 넣을 수밖에 없다는 이야기였다.

즉 이 케이스도 결국 "신규 코드 안에서 필요한 것"이 아니라 "레거시 동기 인터페이스와의 어댑터"로서 필요한 것이었다.


8. 그럼 신규 코드에서는 진짜로 DispatchQueue를 영원히 안 써도 되는 거야?

나: 그러니까 현 시점에서 새로 짜는 코드에서 DispatchQueue를 꼭 써야 하는 순간이 있는 거야 없는 거야?

Gemini의 답을 정리하면 이렇다. 내가 통제할 수 있는 비즈니스 로직 안에서는 0%다. 비동기 처리와 네트워킹은 Taskasync/await로 완벽히 커버되고, 스레드 안전한 객체는 처음부터 actor로 설계하면 되고, 어쩔 수 없이 동기 함수에서 락을 걸어야 하더라도 이제는 DispatchQueue.sync 대신 OSAllocatedUnfairLock이나 Swift 6의 Mutex를 쓰는 게 표준이 되었다.

오직 남는 건 애플이 제공하는 프레임워크 API의 매개변수 자체가 DispatchQueue 타입을 강제하는 경우다. 가장 대표적인 게 하드웨어 제어나 미디어 처리(AVFoundation, CoreBluetooth, Network framework 등)다.

let videoOutput = AVCaptureVideoDataOutput()
let cameraQueue = DispatchQueue(label: "com.myApp.cameraQueue")

// ❌ 시스템 API 시그니처 자체가 DispatchQueue를 요구함. Task로는 죽어도 못 넣음.
videoOutput.setSampleBufferDelegate(self, queue: cameraQueue)

이런 API들은 내부적으로 C 언어 기반의 로우레벨 콜백을 사용하거나 수십 년 된 레거시 구조로 얽혀 있어서, 애플이 직접 async/await용 래퍼 API를 만들어주지 않는 이상 우리에게 선택권이 없다.


9. 그런데 다시 한 번 따져보면, 그 "어댑터" 케이스도 사실은…

여기서 다시 한 번 짚어볼 필요가 있다. Gemini가 말한 두 번째 케이스, 즉 "레거시 동기 인터페이스 안에서 어댑터로 DispatchQueue.sync를 쓰는" 경우다. 이건 정확히 말하면 신규 코드의 문제가 아니다. 기존 레거시가 강제하는 동기 계약(contract)에 신규 코드가 어쩔 수 없이 맞춰주는 상황이다.

처음에 정리하려고 했던 질문은 "신규 코드에서 DispatchQueue를 쓸 필요가 있느냐"였다. 그 관점에서 보면 비동기 전염을 막기 위한 어댑터 케이스도 결국 "내가 통제하지 못하는 외부 동기 계약 때문에 어쩔 수 없이"라는 단서가 붙는다. 그리고 이 단서가 붙는 순간, 그건 "신규 코드가 자기 영역 안에서 DispatchQueue가 필요한 경우"가 아니라 "외부와의 경계면에서 어쩔 수 없이 다리를 놓아야 하는 경우"가 된다.

또 하나 짚을 점은, 그 경계면에서조차도 이제는 DispatchQueue.sync보다 OSAllocatedUnfairLock(iOS 16+) 혹은 Swift 6의 Mutex가 더 적합한 도구라는 것이다. DispatchQueue.sync는 같은 직렬 큐에서 자기 자신을 호출하면 데드락이 발생하는 함정이 있고, 단순 상호 배제(mutual exclusion) 용도라면 락 프리미티브가 더 가볍고 안전하다.


10. 그래서 최종 결론은

논의를 다 거치고 나면 이렇게 정리된다.

내가 완전히 통제하는 코드, 즉 신규 프로젝트의 비즈니스 로직 안에서는 DispatchQueue를 더 이상 꺼낼 일이 없다. 비동기 작업은 Taskasync/await, 직렬 접근 보호는 actor, 메인 스레드 보장은 @MainActor, 딜레이는 Task.sleep, 여러 비동기 작업 묶기는 TaskGroup이나 async let, 외부 콜백 다리 놓기는 withCheckedContinuation, 단순 상호 배제가 필요하다면 OSAllocatedUnfairLock이나 Swift 6의 Mutex. DispatchQueue가 차지했던 자리를 생태계 전체가 메웠다.

그럼에도 불구하고 DispatchQueue가 진짜로 필요한 유일한 순간은 애플이 만든 프레임워크 API가 시그니처에서 DispatchQueue 타입을 직접 요구하는 경우다. AVFoundation의 setSampleBufferDelegate, DispatchSource 계열, NotificationCenter의 큐 파라미터 같은 자리들이다. 이건 도구 선택의 문제가 아니라 단순히 통행료다.

처음에 Claude가 백기를 들었던 "API 시그니처 강제 케이스"라는 결론은 사실 거의 정확했다. Gemini가 추가로 제기한 "동기 함수에서 값을 즉시 반환해야 할 때" 같은 케이스도, 끝까지 파고들어 가면 "내가 통제하지 못하는 외부 동기 계약과의 경계면" 이야기였고, 그 경계면조차도 신규 코드 그 자체의 문제는 아니다.

그리고 cellForRowAt 같은 예시는 사실 이 논의의 무대에 올라올 자격조차 없는 케이스였다. 그건 동시성 도구의 문제가 아니라 데이터 로딩 타이밍의 설계 문제다. DispatchQueue를 쓰든 Task를 쓰든 둘 다 답이 아니고, 답은 "그 메서드 안에서 무거운 작업을 하지 않는다"이다.

두 AI와 이야기를 해보니 막연했던 Concurrency의 경계가 훨씬 명확해졌다. 무작정 Task만 고집하지도 말고, 그렇다고 DispatchQueue가 아직 필요한 자리가 있다고 막연하게 끌고 가지도 말 것. 상황을 정확히 분해하고, 정말로 그 도구가 아니면 안 되는 자리인지 따져보는 것. 그게 결국 동시성을 제대로 다루는 길이라는 걸 다시 한번 확인했다.


이 글은 Claude와 Gemini Pro와의 실제 대화를 바탕으로 정리했습니다.