Devin.KR

Visual Basic · 심화

LINQ·비동기·설계로 깊어지는 Visual Basic

비동기 프로그래밍 - Async·Await·Task

Task 와 Task(Of T), Async Function, Await 의 흐름, 동기 차단과 교착, ConfigureAwait 개요

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 반복기는 값이 필요한 시점에 다음 요소를 만들어 내는 흐름을 다뤘다. 이번에는 결과를 얻기까지 기다려야 하는 작업을 다룬다. 도서관 서비스가 대출 내역을 읽거나 처리 기록을 저장할 때, 호출한 스레드가 기다리는 동안 계속 멈춰 있어야 하는지 살펴본다.

비동기 프로그래밍(asynchronous programming)은 작업의 완료를 기다리는 흐름을 코드로 표현하는 방법이다. 대출 내역을 가져온 다음 연체료를 계산한다는 업무 순서는 유지하면서, 입출력이 끝날 때까지 스레드를 붙잡지 않도록 작성한다. 이 장의 예제는 실제 서버 대신 짧은 대기로 입출력 경계를 흉내 낸다. 실행 순서와 계산 결과에 집중하기 위한 구성이다.

  • Task와 Task(Of T)가 무엇을 나타내는지 구별한다.
  • Async Function의 반환 형식과 Await 이후의 흐름을 설명한다.
  • 대출 내역 조회와 처리 기록 저장을 비동기 메서드로 연결한다.
  • 동기 차단이 필요한 진입점과 피해야 할 호출 지점을 구별한다.
  • 교착이 발생하는 조건과 ConfigureAwait의 역할을 이해한다.

문제 상황

작은 동네 도서관은 회원 번호를 입력하면 대출 도서와 연체료를 보여 주는 서비스를 운영한다. 기존 코드는 저장소에서 대출 내역을 읽고, 기준일과 반납 예정일을 비교한 뒤, 조회가 끝났다는 기록을 저장한다. 화면을 사용하는 직원은 조회 버튼을 누른 후에도 다른 입력을 할 수 있어야 한다.

저장소 응답이 늦어질 때 동기 호출로 구현된 화면은 함께 멈출 수 있다. 콘솔 앱에서도 기다리는 동안 호출한 스레드를 붙잡는다는 점은 같다. 화면 정지 현상이 없다고 해서 동기 대기가 비동기 대기로 바뀌는 것은 아니다.

해결할 문제는 연체료 계산 자체를 다른 스레드로 옮기는 일이 아니다. 이 예제의 계산은 몇 번의 날짜 비교와 정수 덧셈으로 끝난다. 기다림이 생기는 부분은 대출 내역 조회와 기록 저장이다. 이 두 경계에서 작업의 완료를 비동기로 기다리고, 준비된 값으로 계산을 이어 간다.

프로그램의 출력은 도서관 업무 순서에 맞춘다. 조회 시작, 내역 조회 완료, 도서별 연체료 표시, 기록 저장 완료, 검사 완료 순서로 출력한다. 현재 날짜를 읽지 않고 고정된 기준일을 사용하므로 실행할 때마다 연체 일수와 금액이 같다.

Task는 작업의 완료를 나타낸다

Task는 작업(task)의 완료 상태를 나타내는 객체다. 작업이 아직 진행 중인지, 정상적으로 끝났는지, 예외로 실패했는지를 표현한다. Task 객체를 받았다는 사실만으로 작업이 끝났다고 판단할 수는 없다. 또한 Task 하나가 스레드 하나를 뜻하지도 않는다.

Task와 Task(Of T)는 정상 완료 뒤에 꺼낼 결과값의 유무로 구별한다. 처리 기록을 저장하는 메서드는 완료 여부만 필요하므로 Task를 반환한다. 대출 내역을 읽는 메서드는 내역 배열을 돌려줘야 하므로 Task(Of Loan())을 반환한다.

반환 형식에 따라 Await으로 얻는 것이 달라진다
반환 형식도서관 작업기다리는 코드완료 뒤 결과
Task처리 기록 저장Await SaveAuditAsync()별도 결과값 없음
Task(Of Loan())대출 내역 조회Dim loans = Await ReadLoansAsync(7)Loan 배열
Task(Of Integer)금액을 반환하는 작업Dim fee = Await GetFeeAsync()Integer 값

반환 형식이 Task인 메서드도 Visual Basic에서는 Function으로 선언한다. 호출자에게 Task 객체를 돌려줘야 하기 때문이다. 결과값이 없다는 이유로 Async Sub를 선택하면 호출자는 그 작업을 Await으로 기다릴 수 없다.

Private Async Function SaveAuditAsync() As Task
    Await Task.Delay(10).ConfigureAwait(False)
End Function

이 메서드의 본문에는 결과값을 돌려주는 Return 문이 없다. 본문이 끝나면 반환된 Task가 정상 완료된다. 반면 Async Function의 반환 형식이 Task(Of Integer)라면 본문에서는 Return 1200처럼 Integer 값을 반환한다. 언어가 그 값을 Task(Of Integer)의 완료 결과로 연결한다.

Async는 메서드 안에서 Await을 사용할 수 있게 하고, 중단과 재개를 지원하는 코드를 컴파일러가 만들도록 한다. Async를 붙였다고 메서드 전체가 자동으로 별도 스레드에서 실행되는 것은 아니다. 일반적인 호출처럼 본문 실행을 시작하며, 완료되지 않은 작업을 Await하는 지점에서 호출자에게 제어를 돌려줄 수 있다.

Await은 업무 순서를 유지하며 기다린다

Await은 기다리는 작업이 정상 완료되면 다음 문장으로 진행한다. Task(Of T)를 기다렸다면 그 결과값을 식의 값으로 얻는다. 작업이 실패했다면 Await 지점에서 예외를 받는다. 따라서 조회 결과를 변수에 넣은 다음 계산하는 코드를 위에서 아래로 읽을 수 있다.

Dim loans As Loan() = Await ReadLoansAsync(7)
Dim total As Integer = CalculateTotal(loans)

두 번째 문장은 조회가 정상적으로 완료된 뒤에 실행된다. Await이 스레드를 계속 붙잡아 둔다는 뜻은 아니다. 기다리는 작업이 아직 끝나지 않았다면 메서드는 진행 상태를 보관하고 호출자에게 제어를 돌려준다. 작업이 끝나면 Await 뒤의 코드가 이어진다.

이미 완료된 Task를 Await하면 그 자리에서 다음 문장으로 진행할 수 있다. 따라서 모든 Await에서 반드시 중단이 일어난다거나, 반드시 다른 스레드로 이동한다고 설명하면 부정확하다. 중요한 것은 완료 전에는 뒤의 업무를 수행하지 않는다는 점과, 미완료 작업을 기다릴 때 호출 스레드를 차단할 필요가 없다는 점이다.

미완료 작업을 Await하면 제어를 돌려주고 완료 뒤에 다음 문장을 실행한다

그림의 중단은 메서드의 논리적 진행이 멈춘다는 뜻이다. 운영체제 스레드 하나가 그 자리에서 잠들어 작업 완료를 기다린다는 뜻이 아니다. 메서드를 이어 실행할 코드인 연속 실행(continuation)은 작업 완료 뒤 적절한 실행 환경에서 수행된다.

완성 코드의 저장소는 Task.Delay로 잠깐 기다린다. 이 메서드는 지정한 대기가 끝났음을 나타내는 Task를 반환한다. 실제 시스템에서는 비동기를 지원하는 파일, 네트워크 또는 데이터베이스 API를 호출할 자리에 해당한다. Task.Delay를 넣는 것만으로 기존의 동기 입출력이 비동기 입출력으로 바뀌지는 않는다.

출력 순서는 대기 시간의 길이에 기대지 않는다. 조회 Task를 Await한 다음 도서 목록을 출력하고, 저장 Task를 Await한 다음 저장 완료를 출력한다. 컴퓨터가 바쁘거나 대기가 예상보다 길어져도 이 업무 순서는 같다. 반대로 여러 작업을 동시에 시작하면 순서에 관한 별도의 설계가 필요하다. 그 내용은 다음 장에서 다룬다.

예외는 작업을 기다리는 지점에서 처리한다

Async Function 본문에서 처리되지 않은 예외가 발생하면 반환된 Task는 실패 상태가 된다. 호출자가 그 Task를 Await하면 예외가 전달된다. 조회 호출과 Await을 함께 Try 블록에 넣으면 기존의 예외 처리 구문으로 실패를 처리할 수 있다.

Try
    Dim loans As Loan() = Await ReadLoansAsync(99)
Catch ex As KeyNotFoundException
    Console.WriteLine("회원 없음 예외 확인")
End Try

Task를 변수에 받는 것과 그 Task의 완료를 확인하는 것은 다르다. 호출만 해 놓고 기다리지 않으면 호출자는 결과를 사용하거나 실패를 처리할 시점을 놓치기 쉽다. 이 장에서는 시작한 비동기 작업을 모두 Await으로 연결하고, 최종 작업을 진입점에서 기다린다.

동기 차단과 ConfigureAwait의 경계

동기 차단(synchronous blocking)은 호출한 스레드를 붙잡은 채 완료를 기다리는 방식이다. Task.Wait(), Task(Of T).Result, GetAwaiter().GetResult()는 모두 이런 대기를 만들 수 있다. 비동기 메서드 내부에서는 다른 비동기 메서드를 Await으로 연결하는 것이 기본이다.

이 장의 콘솔 앱은 Sub Main을 진입점으로 사용한다. Main이 곧바로 끝나면 진행 중인 작업의 완료를 보장할 수 없으므로, 가장 바깥에서 RunAsync().GetAwaiter().GetResult()를 호출한다. 이 호출은 동기 차단이다. 다만 비동기 본문 곳곳에서 차단하지 않고, 동기 진입점과 비동기 실행 흐름을 연결하는 한 지점에 둔다.

GetAwaiter().GetResult()는 실패한 작업의 예외를 일반적으로 원래 예외 형식으로 전달한다. Wait()와 Result는 작업 실패를 AggregateException으로 감쌀 수 있다. 예외 전달 방식이 다르다고 해서 GetAwaiter().GetResult()가 스레드를 차단하지 않는 것은 아니다.

교착은 서로의 진행을 기다릴 때 생긴다

교착(deadlock)은 진행에 필요한 자원을 서로 기다려 더 나아가지 못하는 상태다. 일부 화면 앱에서는 화면 스레드에서 시작한 Await 이후의 코드가 다시 화면 스레드에서 실행되도록 예약된다. 이 환경에서 화면 스레드가 Result로 작업 완료를 기다리면 문제가 생길 수 있다.

작업은 Await 뒤의 코드를 실행해야 끝난다. 그런데 그 코드는 화면 스레드가 비어야 실행될 수 있다. 화면 스레드는 작업이 끝나야 Result에서 벗어난다. 두 조건이 서로를 기다린다. 동기 차단을 비동기 대기로 바꾸면 화면 스레드는 기다리는 동안 다른 실행을 받아들일 수 있다.

화면 스레드의 동기 차단과 화면 복귀가 서로를 기다리면 교착이 생길 수 있다

일반적인 .NET 콘솔 앱에는 화면 앱과 같은 전용 동기화 컨텍스트(synchronization context)가 기본으로 설치되지 않는다. 따라서 이 장의 Main에서 기다리는 구조는 그림과 같은 화면 복귀 교착 조건을 만들지 않는다. 이 사실을 근거로 모든 환경에서 Result를 사용해도 된다고 판단해서는 안 된다.

ConfigureAwait(False)는 복귀 요구를 줄인다

기본 Await은 적절한 컨텍스트가 있으면 그 컨텍스트로 돌아가 다음 코드를 실행하려고 한다. ConfigureAwait(False)는 해당 Await에 대해 호출 환경으로 복귀하려는 기본 동작을 요청하지 않도록 한다. 엄밀하게는 동기화 컨텍스트나 기본이 아닌 작업 스케줄러를 포착하는 동작과 관련된다.

완성 코드의 저장소 메서드는 화면이나 특정 실행 환경에 의존하지 않는다. 따라서 내부 대기에서 ConfigureAwait(False)를 사용한다. 호출 환경에 묶이지 않는 재사용 코드에서 고려할 수 있는 선택이다. 화면의 값을 바꾸는 코드라면 화면 스레드가 필요하므로 같은 판단을 그대로 적용할 수 없다.

ConfigureAwait(False)는 별도 스레드 실행 명령도 아니고, 동기 차단을 허용하는 보증도 아니다. 이미 완료된 작업에서는 이어지는 코드가 현재 스레드에서 실행될 수 있다. 또한 메서드 전체에 영구적으로 적용되는 설정이 아니라 그 Await에 대한 선택이다. 호출자는 자신이 수행하는 Await의 복귀 정책을 별도로 가진다.

완성 코드

새 콘솔 프로젝트의 Program.vb를 다음 코드로 바꾼다. 외부 패키지는 사용하지 않는다. 회원 7의 대출 세 건을 조회하고, 하루 300원의 연체료를 계산한다. 회원 99 조회는 실패 경로를 확인하기 위한 호출이다. 모든 날짜는 코드에 고정한다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections.Generic
Imports System.Threading.Tasks

Module Program
    Private Const DailyFee As Integer = 300

    Sub Main()
        RunAsync().GetAwaiter().GetResult()
    End Sub

    Private Async Function RunAsync() As Task
        Console.WriteLine("대출 조회 시작")

        Dim loans As Loan() = Await ReadLoansAsync(7)
        Console.WriteLine("대출 내역 조회 완료")

        Dim referenceDate As New DateTime(2026, 4, 10)
        Dim total As Integer = 0

        For Each item As Loan In loans
            Dim days As Integer =
                GetOverdueDays(item.DueDate, referenceDate)
            Dim fee As Integer = days * DailyFee
            total += fee
            Console.WriteLine(
                $"{item.Title}: 연체 {days}일, 연체료 {fee}원")
        Next

        Console.WriteLine($"총 연체료: {total}원")

        Await SaveAuditAsync()
        Console.WriteLine("처리 기록 저장 완료")

        Check(loans.Length = 3, "대출 건수")
        Check(total = 1200, "총 연체료")
        Check(GetOverdueDays(
            New DateTime(2026, 4, 12), referenceDate) = 0,
            "예정일 전 연체료")

        Dim missingMemberCaught As Boolean = False

        Try
            Dim ignored As Loan() = Await ReadLoansAsync(99)
        Catch ex As KeyNotFoundException
            missingMemberCaught = True
            Console.WriteLine("회원 없음 예외 확인")
        End Try

        Check(missingMemberCaught, "예외 전달")
        Console.WriteLine("모든 검사 통과")
    End Function

    Private Async Function ReadLoansAsync(
        memberId As Integer) As Task(Of Loan())

        Await Task.Delay(10).ConfigureAwait(False)

        If memberId <> 7 Then
            Throw New KeyNotFoundException("회원이 존재하지 않는다.")
        End If

        Return New Loan() {
            New Loan("작은 정원", New DateTime(2026, 4, 7)),
            New Loan("별을 읽는 밤", New DateTime(2026, 4, 9)),
            New Loan("동네 산책", New DateTime(2026, 4, 12))
        }
    End Function

    Private Async Function SaveAuditAsync() As Task
        Await Task.Delay(10).ConfigureAwait(False)
    End Function

    Private Function GetOverdueDays(
        dueDate As DateTime,
        referenceDate As DateTime) As Integer

        Dim days As Integer = (referenceDate.Date - dueDate.Date).Days
        Return Math.Max(0, days)
    End Function

    Private Sub Check(condition As Boolean, name As String)
        If Not condition Then
            Throw New InvalidOperationException(
                "검사 실패: " & name)
        End If

        Console.WriteLine("검사 통과: " & name)
    End Sub

    Private NotInheritable Class Loan
        Public ReadOnly Property Title As String
        Public ReadOnly Property DueDate As DateTime

        Public Sub New(title As String, dueDate As DateTime)
            Me.Title = title
            Me.DueDate = dueDate
        End Sub
    End Class
End Module

SaveAuditAsync는 저장 완료를 기다리는 형태만 보여 주는 모의 구현이다. 실제 파일에 기록을 남기지는 않는다. ReadLoansAsync도 외부 저장소를 읽지 않고 고정 배열을 반환한다. 실제 입출력 API로 바꿀 때는 해당 API가 반환하는 Task를 기다리면 된다.

줄별 해설

Option Strict On은 암시적인 축소 변환과 늦은 바인딩을 제한한다. 비동기 결과의 형식도 명확히 맞춰야 한다. 여기서는 Task(Of Loan())를 기다린 결과를 Loan() 변수에 저장한다. Option Infer On은 지역 변수의 형식 추론을 허용하지만, 주요 변수는 읽기 쉽도록 형식을 직접 표시했다.

Imports System.Threading.Tasks는 Task와 관련된 형식을 사용하기 위한 선언이다. KeyNotFoundException은 System.Collections.Generic에 있으므로 해당 네임스페이스도 가져온다. 별도의 패키지 참조는 필요하지 않다.

Main의 한 줄은 전체 비동기 흐름을 시작하고 완료를 기다린다. RunAsync가 정상 완료되면 Main도 끝난다. RunAsync에서 처리되지 않은 예외가 발생하면 이 지점으로 전달된다. 최상위에서 실패를 숨기는 Catch를 두지 않았으므로 검사 실패는 실행 실패로 드러난다.

RunAsync의 반환 형식은 Task다. 화면에 결과를 출력하고 검사를 수행하지만, 호출자에게 별도의 계산값을 반환하지 않는다. 그래도 호출자는 반환된 Task를 통해 전체 작업이 끝났는지 확인할 수 있다.

ReadLoansAsync를 기다리는 줄은 조회와 결과 사용의 경계다. 메서드 호출의 값은 Task(Of Loan())이고, Await 식의 값은 Loan()이다. 그 다음에 출력하는 “대출 내역 조회 완료”는 조회 작업이 정상 완료됐다는 뜻이다.

referenceDate는 모든 계산에 공통으로 쓰는 기준일이다. DateTime.Today를 사용하지 않으므로 날짜가 바뀌어도 예상 출력이 유지된다. 반납 예정일이 지난 날짜 차이는 각각 3일, 1일, 음수다. 음수는 GetOverdueDays에서 0으로 바뀐다.

For Each 본문은 준비된 배열을 순서대로 읽는다. 여기에는 Await이 없다. 이미 메모리에 있는 세 건의 계산을 비동기 메서드로 만들 필요가 없기 때문이다. 도서 제목의 출력 순서는 반환한 배열의 순서와 같다.

SaveAuditAsync를 기다리는 줄은 결과값 없이 완료만 필요한 예다. 저장 완료 문구는 Await 다음에 있으므로 저장 작업이 실패하면 출력되지 않는다. 이 위치 관계가 코드의 업무 의미를 만든다.

Check 호출 세 개는 조회 건수, 총액, 반납 예정일 전의 연체 일수를 확인한다. 조건이 거짓이면 InvalidOperationException을 던진다. 검사 이름은 실패 원인을 찾을 수 있도록 짧게 붙였다.

회원 99를 조회하는 Try 블록은 비동기 예외 전달을 확인한다. ignored 변수는 정상 반환이 발생했을 때 받을 결과다. 예상한 예외가 발생하면 결과를 대입하는 단계까지 가지 않는다. 예외가 발생하지 않으면 missingMemberCaught가 False로 남고 마지막 검사가 실패한다.

ReadLoansAsync의 Return은 Task 객체를 직접 만들어 반환하지 않는다. 이 메서드가 Async Function이므로 본문은 Loan() 값을 반환하고, 컴파일러가 작업의 결과와 연결한다. 같은 원리로 SaveAuditAsync는 본문 종료로 Task의 정상 완료를 표현한다.

ConfigureAwait(False)는 두 모의 저장소 메서드 내부에만 있다. RunAsync의 Await까지 같은 선택을 강제하지 않는다. 호출자와 저장소가 각각 자신에게 필요한 실행 환경을 판단하는 구조다.

GetOverdueDays는 날짜의 시간 부분을 제외하고 차이를 계산한다. DateTime을 뺀 결과는 TimeSpan이며, 이 코드에서는 두 값이 자정이므로 Days로 일수를 얻는다. Math.Max는 아직 예정일이 오지 않은 대출의 음수 차이를 0으로 만든다.

Loan 클래스는 조회 결과를 담는다. 이 장에서 필요한 제목과 예정일만 두었다. 실제 서비스의 회원 정보, 예약 순번, 반납 처리는 비동기 흐름을 이해하는 데 필요하지 않으므로 포함하지 않았다.

실행 결과

.NET 10 SDK가 설치된 macOS 또는 Linux 터미널에서 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 바꾼 뒤 실행한다.

dotnet new console -lang VB -n LibraryAsync
cd LibraryAsync
dotnet run

프로그램의 예상 출력은 다음과 같다. 경과 시간, 스레드 번호, 현재 시각은 출력하지 않는다.

대출 조회 시작
대출 내역 조회 완료
작은 정원: 연체 3일, 연체료 900원
별을 읽는 밤: 연체 1일, 연체료 300원
동네 산책: 연체 0일, 연체료 0원
총 연체료: 1200원
처리 기록 저장 완료
검사 통과: 대출 건수
검사 통과: 총 연체료
검사 통과: 예정일 전 연체료
회원 없음 예외 확인
검사 통과: 예외 전달
모든 검사 통과

콘솔 안의 검사는 동작을 눈으로만 비교하지 않도록 돕는다. 다만 특정 스레드에서 재개됐는지나 실제 파일이 저장됐는지는 검사하지 않는다. 모의 구현이 보장하는 범위는 조회 결과, 연체료 계산, 작업의 순서, 예외 전달이다.

별도 테스트 프로젝트로 옮길 때는 xUnit 테스트 메서드가 Task를 반환하도록 하고, 검사 대상 메서드를 Await한 뒤 결과를 단언하는 구성을 사용할 수 있다. 이 장에서는 테스트 프로젝트와 패키지를 추가하지 않는다. 검사를 위한 비동기 흐름도 본문 코드와 같은 원리로 연결된다는 점만 기억하면 된다.

실무에서 자주 틀리는 것

작업 호출 뒤 곧바로 완료라고 출력한다

다음 코드는 작업을 시작한 뒤 완료를 기다리지 않는다. saved가 미완료 상태여도 다음 문장이 실행된다. 저장 중 예외가 발생하면 이 코드에는 그 실패를 처리하는 기다림도 없다.

' 잘못된 흐름
Dim saved As Task = SaveAuditAsync()
Console.WriteLine("처리 기록 저장 완료")

완료를 나타내는 출력은 Await 다음에 둔다. 아래 코드는 Async Function 내부에 작성한다.

' 고친 흐름
Await SaveAuditAsync()
Console.WriteLine("처리 기록 저장 완료")

메서드 이름이 Async로 끝난다는 사실은 호출자가 완료를 기다렸다는 뜻이 아니다. 호출자가 반환된 Task를 어떻게 다루는지 확인해야 한다.

비동기 메서드 안에서 Result로 기다린다

다음 코드는 조회 동안 현재 스레드를 차단한다. 화면처럼 특정 컨텍스트로 복귀해야 하는 환경에서는 교착 조건을 만들 수도 있다.

' 잘못된 흐름
Private Async Function ShowLoansAsync() As Task
    Dim loans As Loan() = ReadLoansAsync(7).Result
    Console.WriteLine($"대출 건수: {loans.Length}")
    Await SaveAuditAsync()
End Function

비동기 흐름 안에서는 Await으로 결과를 받는다. Main의 동기 진입점에서 사용하는 한 번의 차단과 서비스 내부의 차단을 구별한다.

' 고친 흐름
Private Async Function ShowLoansAsync() As Task
    Dim loans As Loan() = Await ReadLoansAsync(7)
    Console.WriteLine($"대출 건수: {loans.Length}")
    Await SaveAuditAsync()
End Function

결과값이 없다는 이유로 Async Sub를 만든다

Async Sub는 호출자에게 기다릴 Task를 반환하지 않는다. 다음 저장 메서드는 일반 호출자가 완료 시점을 Await으로 연결할 수 없으며, 예외 처리 방식도 Task 반환 메서드와 다르다.

' 일반 서비스 메서드로는 부적절한 선언
Private Async Sub SaveAudit()
    Await Task.Delay(10)
End Sub

일반 서비스 메서드는 결과값이 없어도 Task를 반환한다. Async Sub는 반환 형식을 바꿀 수 없는 이벤트 처리기 같은 제한된 자리에서 사용한다.

' 고친 선언
Private Async Function SaveAuditAsync() As Task
    Await Task.Delay(10).ConfigureAwait(False)
End Function

ConfigureAwait(False)를 별도 스레드 실행으로 해석한다

아래 코드는 주석의 기대가 잘못됐다. 이미 완료된 Task를 기다리는 것만으로 계산이 별도 스레드로 옮겨지지 않는다. ConfigureAwait(False)는 계산량을 줄이거나 실행 자원을 추가하지 않는다.

' 잘못된 기대
Await Task.CompletedTask.ConfigureAwait(False)
' 이제 별도 스레드에서 계산한다고 가정한다.
Dim total As Integer = 3 * DailyFee

작은 계산은 직접 수행하고, 실제 기다림이 있는 위치에서 Await을 사용한다. 다음 코드는 배열을 받은 뒤 필요한 계산을 수행한다.

' 고친 흐름
Dim loans As Loan() = Await ReadLoansAsync(7)
Dim total As Integer = 0

For Each item As Loan In loans
    total += GetOverdueDays(
        item.DueDate, New DateTime(2026, 4, 10)) * DailyFee
Next

계산량이 커서 실행 자원 배분이 필요한 경우는 별도로 판단해야 한다. 입출력을 기다리는 코드와 계산 작업을 동시에 수행하는 설계는 같은 문제가 아니다.

한눈에 보기

비동기 흐름을 작성할 때 판단해야 할 경계
요소의미예제의 위치주의점
Task결과값 없는 완료SaveAuditAsync스레드 자체가 아니다
Task(Of T)T 결과를 가진 완료ReadLoansAsyncAwait 전에는 결과와 구별한다
Async Function중단과 재개를 지원하는 함수RunAsync전체를 다른 스레드로 보내지 않는다
Await완료 후 결과 또는 예외를 받음조회와 저장 호출완료된 작업이면 바로 이어질 수 있다
GetAwaiter().GetResult()동기로 완료를 기다림Sub Main호출 스레드를 차단한다
ConfigureAwait(False)포착한 환경으로 복귀하는 요구를 줄임모의 저장소 내부각 Await에 적용하는 선택이다

메서드의 결과가 필요한지 먼저 판단해 Task와 Task(Of T)를 고른다. 다음으로 작업을 기다려야 하는 위치에 Await을 둔다. 마지막으로 그 뒤의 코드가 특정 실행 환경을 필요로 하는지 살펴본다. 비동기 문법을 먼저 추가하기보다 업무의 완료 경계를 먼저 정하면 코드의 의미가 분명해진다.

공식 자료는 언어와 실행 동작을 확인하는 근거로 참고할 수 있다. Visual Basic Async 참조, Await 연산자 참조, Task.ConfigureAwait API 참조에서 세부 사항을 확인할 수 있다.

연습 문제

  1. 총 연체료를 계산하는 부분을 CalculateTotal 함수로 분리하라. Loan 배열과 기준일을 받고 Integer를 반환하게 한다. 이 함수를 Async로 만들 필요가 있는지도 설명하라.
  2. SaveAuditAsync 호출 뒤에만 “업무 종료”를 출력하는 FinishAsync 함수를 작성하라. 반환 형식을 선택한 이유를 설명하라.
  3. ReadLoansAsync(99)를 호출해 Task 변수에 저장한 뒤, 같은 Try 블록에서 그 변수를 Await하는 코드로 예외 검사를 바꿔라. 예외가 전달되는 지점을 설명하라.
  4. 기준일을 2026년 4월 9일로 바꾸면 도서별 연체 일수와 총 연체료가 어떻게 바뀌는지 계산하라. 콘솔 검사에서 함께 바꿔야 할 값을 찾아라.

정답과 해설

계산 함수 분리

이미 조회된 배열을 순회하는 계산에는 기다릴 입출력이 없다. 일반 Function으로 작성한다. 다음 함수는 Module Program 안에 추가한다.

Private Function CalculateTotal(
    loans As Loan(),
    referenceDate As DateTime) As Integer

    Dim total As Integer = 0

    For Each item As Loan In loans
        total += GetOverdueDays(
            item.DueDate, referenceDate) * DailyFee
    Next

    Return total
End Function

RunAsync에서는 조회를 Await한 뒤 이 함수를 호출할 수 있다. 도서별 출력까지 유지한다면 출력용 반복문에서는 total을 다시 누적하지 않도록 수정한다. 같은 금액을 두 번 더하지 않는지 확인해야 한다.

완료 뒤의 출력 연결

Private Async Function FinishAsync() As Task
    Await SaveAuditAsync()
    Console.WriteLine("업무 종료")
End Function

반환할 업무 결과값은 없지만 호출자가 완료를 기다릴 수 있어야 하므로 Task를 반환한다. 저장에 실패하면 Await에서 예외가 전달되어 “업무 종료”는 출력되지 않는다. 호출자는 Await FinishAsync()로 이 흐름을 연결한다.

Task 저장과 예외 관찰

Dim missingMemberCaught As Boolean = False

Try
    Dim pending As Task(Of Loan()) = ReadLoansAsync(99)
    Dim ignored As Loan() = Await pending
Catch ex As KeyNotFoundException
    missingMemberCaught = True
    Console.WriteLine("회원 없음 예외 확인")
End Try

Check(missingMemberCaught, "예외 전달")

pending은 결과 배열이 아니라 결과 배열을 제공할 작업이다. 이 Async Function 본문의 예외는 작업 실패로 기록되고 Await pending에서 전달된다. Task를 변수에 저장해도 예외 처리 원리는 같으며, 변수를 받은 뒤 기다림을 빠뜨리지 않는 것이 중요하다.

기준일 변경

“작은 정원”은 2일 연체로 600원이다. “별을 읽는 밤”은 예정일과 기준일이 같으므로 0원이다. “동네 산책”도 예정일 전이므로 0원이다. 총액은 600원이며 총 연체료 검사의 기대값을 1200에서 600으로 바꾼다. 대출 건수 3과 예정일 전 연체 일수 0 검사는 그대로 성립한다.

이 장의 실행은 작업을 하나씩 기다려 업무 순서를 보존한다. 다음 장에서는 이 흐름에 취소 요청, 시간 제한, 여러 작업의 동시 수행이 들어올 때 완료와 실패를 어떻게 다룰지 살펴본다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.