F#의 Async 프로그래밍
F#의 Async 프로그래밍 (Tutorials/Async)
비동기 프로그래밍은 현대 애플리케이션에서 빼놓을 수 없는 메커니즘이에요. 비동기가 왜 필요한지, F#에서 비동기 계산과 task를 어떻게 다루는지 차근차근 살펴볼게요. async { }와 task { } 두 가지 모델, 그리고 이를 시작·병렬화·순차 실행하는 핵심 Async 모듈 함수들을 중심으로 설명해요.
출처
- 원문: Async programming in F# - Microsoft Learn
- 활용: 데이터스케쳐스 위키 tech-stack.languages.fsharp
본문
비동기 프로그래밍은 여러 이유로 현대 애플리케이션에 꼭 필요한 메커니즘이에요. 대부분의 개발자가 만나게 되는 대표적인 용도는 두 가지예요.
- 요청 처리가 외부 시스템이나 서비스의 입력을 기다리는 동안 점유하는 시스템 자원을 최소화하면서, 상당히 많은 동시 요청을 처리할 수 있는 서버 프로세스를 만드는 것
- 백그라운드 작업을 진행하면서도 UI나 메인 스레드를 계속 반응성 있게 유지하는 것
백그라운드 작업이 흔히 여러 스레드를 사용하긴 하지만, 비동기(asynchrony)와 멀티스레딩은 별개의 개념으로 보는 게 중요해요. 실제로 둘은 서로 다른 관심사이고, 하나가 다른 하나를 함의하지 않아요. 이 글에서 그 구분을 더 자세히 설명할게요.
비동기의 정의
앞서 이야기한 "비동기는 여러 스레드를 쓰는 것과 무관하다"는 점을 조금 더 풀어볼게요. 서로 관련돼 보이지만 엄밀히는 독립적인 개념이 세 가지 있어요.
- 동시성(Concurrency): 여러 계산이 겹치는 시간 구간에 실행되는 것.
- 병렬성(Parallelism): 여러 계산이나 하나의 계산의 여러 부분이 정확히 동시에 실행되는 것.
- 비동기(Asynchrony): 하나 이상의 계산이 메인 프로그램 흐름과 분리되어 실행될 수 있는 것.
세 개념 모두 서로 직교하지만, 특히 함께 사용할 때 쉽게 혼동돼요. 예를 들어 여러 비동기 계산을 병렬로 실행해야 할 수도 있는데, 이 관계가 병렬성과 비동기가 서로를 함의한다는 뜻은 아니에요.
"asynchronous"라는 단어의 어원을 보면 두 부분으로 나눌 수 있어요.
- "a", 즉 "아닌(not)".
- "synchronous", 즉 "같은 시간에(at the same time)".
이 둘을 합치면 "asynchronous"는 "같은 시간에 하지 않음"이라는 뜻이 돼요. 그게 전부예요! 이 정의에는 동시성이나 병렬성이 함의돼 있지 않아요. 실제로도 마찬가지예요.
실용적으로 보면 F#의 비동기 계산은 메인 프로그램 흐름과 무관하게 실행되도록 스케줄링돼요. 하지만 이 독립적인 실행이 동시성이나 병렬성을 뜻하지는 않고, 계산이 항상 백그라운드에서 일어난다는 뜻도 아니에요. 사실 비동기 계산은 계산의 성격과 실행 환경에 따라 동기적으로 실행될 수도 있어요.
여기서 핵심은 비동기 계산이 메인 프로그램 흐름과 독립적이라는 점이에요. 비동기 계산이 언제 어떻게 실행될지에 대한 보장은 거의 없지만, 이를 조정하고 스케줄링하는 방법은 있어요. 이 글의 나머지에서는 F# 비동기의 핵심 개념과 F#에 내장된 타입·함수·표현식을 어떻게 쓰는지 살펴볼게요.
핵심 개념
F#에서 비동기 프로그래밍은 비동기 계산(async computations)과 task라는 두 가지 핵심 개념을 중심으로 이뤄져요.
Async<'T>타입과async { }표현식: 시작하면 task를 형성할 수 있는, 합성(composable) 가능한 비동기 계산을 나타내요.Task<'T>타입과task { }표현식: 실행 중인 .NET task를 나타내요.
일반적으로 task를 사용하는 .NET 라이브러리와 상호 운용하거나, 비동기 코드의 꼬리 호출(tailcall)이나 암시적 취소 토큰 전파에 의존하지 않을 때는 새 코드에서 async {…}보다 task {…}를 쓰는 걸 고려해 볼게요.
async의 핵심 개념
"async" 프로그래밍의 기본 개념은 다음 예제에서 확인할 수 있어요.
open System
open System.IO
// 'async'를 사용한 파일 비동기 읽기
let printTotalFileBytesUsingAsync (path: string) =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
printTotalFileBytesUsingAsync "path-to-file.txt"
|> Async.RunSynchronously
Console.Read() |> ignore
0
예제에서 printTotalFileBytesUsingAsync 함수의 타입은 string -> Async<unit>이에요. 이 함수를 호출한다고 해서 비동기 계산이 실제로 실행되지는 않아요. 대신 비동기로 실행할 작업의 명세 역할을 하는 Async<unit>을 반환할 뿐이에요. 함수 본문에서는 Async.AwaitTask를 호출하는데, 이 함수가 ReadAllBytesAsync의 결과를 적절한 타입으로 변환해 줘요.
또 하나 중요한 줄은 Async.RunSynchronously 호출이에요. 이 함수는 F# 비동기 계산을 실제로 실행하고 싶을 때 호출해야 하는 Async 모듈의 시작 함수 중 하나예요.
이 점이 C#/Visual Basic 스타일의 async 프로그래밍과의 근본적인 차이예요. F#에서는 비동기 계산을 Cold task(아직 시작되지 않은 작업)로 생각할 수 있어요. 실제로 실행하려면 명시적으로 시작해야 해요. 이 방식에는 장점이 있는데, C#이나 Visual Basic보다 비동기 작업을 훨씬 쉽게 결합하고 순차화할 수 있다는 점이에요.
비동기 계산 결합하기
앞선 예제를 바탕으로 계산을 결합하는 예제를 볼게요.
open System
open System.IO
let printTotalFileBytes path =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
argv
|> Seq.map printTotalFileBytes
|> Async.Parallel
|> Async.Ignore
|> Async.RunSynchronously
0
보시다시피 main 함수에는 요소가 꽤 추가됐어요. 개념적으로 다음과 같은 일을 해요.
Seq.map으로 명령줄 인자를Async<unit>계산들의 시퀀스로 변환해요.- 실행될 때
printTotalFileBytes계산들을 병렬로 스케줄링하고 실행하는Async<'T[]>을 만들어요. - 병렬 계산을 실행하고 그 결과(
unit[])를 무시할Async<unit>을 만들어요. Async.RunSynchronously로 전체 합성 계산을 명시적으로 실행하고, 완료될 때까지 블로킹해요.
프로그램이 실행되면 각 명령줄 인자에 대해 printTotalFileBytes가 병렬로 실행돼요. 비동기 계산은 프로그램 흐름과 독립적으로 실행되므로, 정보를 출력하고 실행을 끝내는 순서가 정해져 있지 않아요. 계산들은 병렬로 스케줄링되지만 실행 순서는 보장되지 않아요.
비동기 계산 순차 실행하기
Async<'T>는 이미 실행 중인 task가 아니라 작업의 명세이기 때문에 더 복잡한 변환을 쉽게 할 수 있어요. 다음은 일련의 async 계산을 하나씩 차례로 실행하도록 순차화하는 예제예요.
let printTotalFileBytes path =
async {
let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
argv
|> Seq.map printTotalFileBytes
|> Async.Sequential
|> Async.Ignore
|> Async.RunSynchronously
|> ignore
이 방식은 printTotalFileBytes를 병렬로 스케줄링하는 대신 argv의 요소 순서대로 실행하도록 스케줄링해요. 각 후속 작업은 앞선 계산이 실행을 마친 뒤에야 스케줄링되므로, 계산들이 실행이 겹치지 않게 순차화돼요.
중요한 Async 모듈 함수들
F#에서 async 코드를 작성할 때는 보통 계산 스케줄링을 처리해 주는 프레임워크를 사용하게 돼요. 하지만 항상 그런 것은 아니라서, 비동기 작업을 스케줄링하는 데 쓸 수 있는 다양한 함수를 이해해 두는 게 좋아요.
F# 비동기 계산은 이미 실행 중인 작업의 표현이 아니라 작업의 명세이기 때문에, 시작 함수로 명시적으로 시작해야 해요. 상황에 따라 유용한 Async 시작 메서드가 많아요. 이 절에서는 비교적 흔히 쓰는 시작 함수 몇 가지를 설명할게요.
Async.StartChild
비동기 계산 안에서 하위 계산(child computation)을 시작해요. 이를 통해 여러 비동기 계산을 동시에 실행할 수 있어요. 하위 계산은 부모 계산과 취소 토큰을 공유해요. 부모 계산이 취소되면 하위 계산도 함께 취소돼요.
시그니처:
computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>
언제 쓸까요?
- 여러 비동기 계산을 하나씩이 아니라 동시에 실행하고 싶지만 병렬로 스케줄링하고 싶지 않을 때.
- 하위 계산의 수명을 부모 계산의 수명에 묶고 싶을 때.
주의할 점:
Async.StartChild로 여러 계산을 시작하는 것은 병렬로 스케줄링하는 것과 같지 않아요. 계산을 병렬로 스케줄링하고 싶다면Async.Parallel을 쓰세요.- 부모 계산을 취소하면 부모가 시작한 모든 하위 계산의 취소가 촉발돼요.
Async.StartImmediate
비동기 계산을 현재 OS 스레드에서 즉시 시작해서 실행해요. 계산 중 호출 스레드(calling thread)에서 무언가를 업데이트해야 할 때 유용해요. 예를 들어 비동기 계산이 UI(진행 바 업데이트 등)를 갱신해야 한다면 Async.StartImmediate를 써야 해요.
시그니처:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
언제 쓸까요?
- 비동기 계산 도중 호출 스레드에서 무언가를 업데이트해야 할 때.
주의할 점:
- 비동기 계산 안의 코드는 스케줄링된 스레드에서 실행돼요. 그 스레드가 UI 스레드처럼 어떤 면에서 민감한 스레드라면 문제가 될 수 있어요. 그런 경우
Async.StartImmediate는 부적절할 가능성이 커요.
Async.StartAsTask
계산을 스레드 풀에서 실행해요. 계산이 종료되면(결과 생성, 예외 발생, 또는 취소) 해당 상태에서 완료되는 Task<TResult>를 반환해요. 취소 토큰이 제공되지 않으면 기본 취소 토큰을 사용해요.
시그니처:
computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>
언제 쓸까요?
- 비동기 계산의 결과를 나타내는
Task<TResult>를 반환하는 .NET API를 호출해야 할 때.
주의할 점:
- 이 호출은 추가
Task객체를 할당하므로, 자주 사용하면 오버헤드가 늘어날 수 있어요.
Async.Parallel
비동기 계산들의 시퀀스를 병렬로 실행하도록 스케줄링하고, 제공된 순서대로 결과의 배열을 만들어요. 병렬성 정도는 maxDegreeOfParallelism 매개변수를 지정해서 선택적으로 조정/제한할 수 있어요.
시그니처:
computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>
언제 쓸까요?
- 여러 계산을 동시에 실행해야 하고 실행 순서에 의존하지 않을 때.
- 병렬로 스케줄링된 계산들의 결과가 모두 완료될 때까지 기다려도 될 때.
주의할 점:
- 모든 계산이 끝나야 결과값 배열에 접근할 수 있어요.
- 계산들은 스케줄링되는 대로 실행돼요. 따라서 실행 순서에 의존할 수 없어요.
Async.Sequential
비동기 계산들의 시퀀스를 전달된 순서대로 실행하도록 스케줄링해요. 첫 번째 계산이 실행되고, 그다음 계산이 실행되는 식이에요. 어떤 계산도 병렬로 실행되지 않아요.
시그니처:
computations: seq<Async<'T>> -> Async<'T[]>
언제 쓸까요?
- 여러 계산을 순서대로 실행해야 할 때.
주의할 점:
- 모든 계산이 끝나야 결과값 배열에 접근할 수 있어요.
- 계산들이 전달된 순서대로 실행되므로, 결과가 반환되기까지 시간이 더 걸릴 수 있어요.
Async.AwaitTask
주어진 Task<TResult>가 완료될 때까지 기다렸다가 그 결과를 Async<'T>로 반환하는 비동기 계산을 만들어요.
시그니처:
task: Task<'T> -> Async<'T>
언제 쓸까요?
- F# 비동기 계산 안에서
Task<TResult>를 반환하는 .NET API를 사용할 때.
주의할 점:
- 예외는 Task Parallel Library의 관례에 따라
AggregateException으로 감싸져요. 이 동작은 F# async가 일반적으로 예외를 드러내는 방식과 달라요.
Async.Catch
주어진 Async<'T>를 실행하는 비동기 계산을 만들고, Async<Choice<'T, exn>>을 반환해요. 주어진 Async<'T>가 성공적으로 완료되면 결과값과 함께 Choice1Of2가 반환돼요. 완료 전에 예외가 발생하면 발생한 예외와 함께 Choice2of2가 반환돼요. 만약 이 함수를 여러 계산으로 합성된 비동기 계산에 사용했는데 그중 하나가 예외를 던지면, 그 계산 전체가 완전히 중단돼요.
시그니처:
computation: Async<'T> -> Async<Choice<'T, exn>>
언제 쓸까요?
- 예외로 실패할 수 있는 비동기 작업을 수행하면서, 그 예외를 호출자(caller) 측에서 처리하고 싶을 때.
주의할 점:
- 결합되거나 순차화된 비동기 계산을 사용할 때, 그 "내부" 계산 중 하나가 예외를 던지면 계산 전체가 완전히 중단돼요.
Async.Ignore
주어진 계산을 실행하되 결과를 버리는 비동기 계산을 만들어요.
시그니처:
computation: Async<'T> -> Async<unit>
언제 쓸까요?
- 결과가 필요 없는 비동기 계산이 있을 때. 비동기가 아닌 코드의
ignore함수와 비슷한 역할이에요.
주의할 점:
Async.Start나 다른Async<unit>을 요구하는 함수를 쓰려고Async.Ignore를 써야 한다면, 결과를 버려도 괜찮은지 생각해 보세요. 타입 시그니처에 맞추려고 결과를 무작정 버리는 건 피하는 게 좋아요.
Async.RunSynchronously
비동기 계산을 실행하고 호출 스레드(calling thread)에서 결과를 기다려요. 계산이 예외를 만들면 그 예외를 전파해요. 이 호출은 블로킹 호출이에요.
시그니처:
computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T
언제 쓸까요?
- 필요하다면, 애플리케이션에서 단 한 번 — 실행 파일의 진입점에서만 사용할 것.
- 성능을 신경 쓰지 않고 다른 비동기 작업들을 한 번에 실행하고 싶을 때.
주의할 점:
Async.RunSynchronously를 호출하면 실행이 완료될 때까지 호출 스레드가 블로킹돼요.
Async.Start
스레드 풀에서 unit을 반환하는 비동기 계산을 시작해요. 완료를 기다리거나 예외 결과를 관찰하지 않아요. Async.Start로 시작된 중첩 계산들은 이를 호출한 부모 계산과 독립적으로 시작돼요. 그 수명이 어떤 부모 계산에도 묶여 있지 않아요. 부모 계산이 취소돼도 하위 계산은 취소되지 않아요.
시그니처:
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
다음의 경우에만 사용하세요.
- 결과를 만들지 않거나 결과 처리가 필요 없는 비동기 계산이 있을 때.
- 비동기 계산의 완료 시점을 알 필요가 없을 때.
- 비동기 계산이 어떤 스레드에서 실행되는지 상관없을 때.
- 실행으로 인한 예외를 인지하거나 보고할 필요가 없을 때.
주의할 점:
Async.Start로 시작된 계산에서 발생한 예외는 호출자에게 전파되지 않아요. 호출 스택은 완전히 풀려요(unwound).Async.Start로 시작된 어떤 작업(printfn호출 등)도 프로그램 실행의 메인 스레드에서 그 효과를 일으키지 않아요.
.NET과 상호 운용하기
async { } 프로그래밍을 사용한다면 async/await 스타일의 비동기 프로그래밍을 쓰는 .NET 라이브러리나 C# 코드베이스와 상호 운용해야 할 수도 있어요. C#과 대부분의 .NET 라이브러리는 핵심 추상화로 Task<TResult>와 Task 타입을 사용하기 때문에, 이것이 F# 비동기 코드를 작성하는 방식을 바꿀 수 있어요.
한 가지 방법은 task { }를 써서 .NET task를 직접 작성하는 거예요. 또는 Async.AwaitTask 함수로 .NET 비동기 계산을 기다릴 수도 있어요.
let getValueFromLibrary param =
async {
let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
return value
}
Async.StartAsTask 함수를 쓰면 비동기 계산을 .NET 호출자에게 넘길 수 있어요.
let computationForCaller param =
async {
let! result = getAsyncResult param
return result
} |> Async.StartAsTask
값을 반환하지 않는 .NET 비동기 계산(Task를 사용하는 API)과 작업하려면, Async<'T>를 Task로 변환하는 추가 함수가 필요할 수 있어요.
module Async =
// Async<unit> -> Task
let startTaskFromAsyncUnit (comp: Async<unit>) =
Async.StartAsTask comp :> Task
입력으로 Task를 받는 Async.AwaitTask가 이미 있어요. 이 함수와 앞서 정의한 startTaskFromAsyncUnit 함수를 함께 쓰면 F# async 계산에서 Task 타입을 시작하고 기다릴 수 있어요.
F#에서 .NET task 직접 작성하기
F#에서는 task { }로 task를 직접 작성할 수 있어요. 예를 들어 볼게요.
open System
open System.IO
/// 'task'를 사용한 파일 비동기 읽기
let printTotalFileBytesUsingTasks (path: string) =
task {
let! bytes = File.ReadAllBytesAsync(path)
let fileName = Path.GetFileName(path)
printfn $"File {fileName} has %d{bytes.Length} bytes"
}
[<EntryPoint>]
let main argv =
let task = printTotalFileBytesUsingTasks "path-to-file.txt"
task.Wait()
Console.Read() |> ignore
0
예제에서 printTotalFileBytesUsingTasks 함수의 타입은 string -> Task<unit>이에요. 이 함수를 호출하면 task 실행이 시작돼요. task.Wait() 호출은 task가 완료될 때까지 기다려요.
멀티스레딩과의 관계
이 글 전반에 걸쳐 스레딩이 언급됐지만, 두 가지 중요한 점을 기억해야 해요.
- 명시적으로 현재 스레드에서 시작하지 않는 한, 비동기 계산과 스레드 사이에는 어떤 선호 관계(affinity)도 없어요.
- F#의 비동기 프로그래밍은 멀티스레딩의 추상화가 아니에요.
예를 들어 계산은 작업의 성격에 따라 실제로 호출자의 스레드에서 실행될 수도 있어요. 계산이 스레드 사이를 "점프"하면서, "대기" 구간(예: 네트워크 호출이 전송 중일 때) 사이에 유용한 작업을 하느라 각 스레드를 잠깐씩 빌려 쓸 수도 있어요.
F#은 현재 스레드에서(또는 명시적으로 현재 스레드가 아닌 곳에서) 비동기 계산을 시작할 수 있는 기능을 일부 제공하지만, 일반적으로 비동기는 특정 스레딩 전략과 연결되어 있지 않아요.