Devin.KR

비동기 - async·await 첫걸음

개발자KR 조회 6

이 장에서 배우는 것

앞 장에서 파일에 데이터를 저장하고 읽는 방법과 예외를 처리하는 방법을 살펴보았다. 이번에는 카페의 재고 파일을 읽는 코드를 비동기(asynchronous) 방식으로 바꾼다. 읽기 결과가 필요한 지점까지 작업을 어떻게 전달하고, 완료를 어떻게 기다리는지 확인한다.

비동기는 파일을 읽는 시간을 없애는 기능이 아니다. 완료되지 않은 작업을 기다리는 동안 실행 흐름을 잠시 돌려주고, 작업이 끝난 뒤 필요한 처리를 이어 갈 수 있게 하는 방식이다. 이 장에서는 작업을 여러 개 동시에 실행하는 기법보다 작업 하나의 시작과 완료를 정확하게 연결하는 데 집중한다.

  • Task와 Task<T>가 무엇을 나타내는지 구분한다.
  • async와 await가 메서드의 실행 흐름에 미치는 영향을 설명한다.
  • 재고 파일을 비동기로 읽고 집계 결과를 사용하는 콘솔 프로그램을 작성한다.
  • .Result로 기다릴 때 생기는 차단과 교착 가능성을 이해한다.
  • 작업 완료, 예외 처리, 파일 정리의 순서를 코드로 보장한다.

문제 상황

동네 카페의 콘솔 앱은 판매를 시작하기 전에 메뉴별 판매 가능 수량을 읽는다. 재고 파일에는 아메리카노 12잔, 카페라테 8잔처럼 현재 준비할 수 있는 수량이 들어 있다. 프로그램은 파일을 읽은 뒤 합계를 표시하고 재고 확인을 마쳐야 한다.

파일이 작고 저장 장치가 빠르면 읽기는 짧게 끝난다. 그러나 파일이 네트워크로 연결된 저장 공간에 있거나 저장 장치가 바쁘면 기다리는 시간이 길어질 수 있다. 동기 읽기는 호출한 스레드(thread)를 읽기가 끝날 때까지 붙잡는다. 이때 해당 스레드는 다음 문장을 실행하지 못한다.

비동기 읽기를 사용하면 완료되지 않은 읽기 작업을 객체로 전달받을 수 있다. 프로그램은 결과와 관계없는 안내를 먼저 표시하고, 수량이 필요한 지점에서 완료를 기다린다. 화면이 있는 앱이나 여러 요청을 처리하는 프로그램에서는 이러한 대기 방식이 특히 유용하다.

이번 예제는 외부 파일을 미리 준비하지 않아도 실행되도록 임시 폴더에 같은 재고 데이터를 만든다. 그다음 파일을 읽고 합계를 출력한 뒤 임시 폴더를 정리한다. 파일 크기가 작으므로 실제 대기 시간을 눈으로 확인하기는 어렵다. 목표는 속도 차이를 재는 것이 아니라 완료 시점을 코드로 표현하는 것이다.

Task는 결과가 아니라 작업을 나타낸다

파일을 동기로 읽는 메서드는 읽기를 마친 다음 내용을 반환한다. 반면 비동기 메서드는 완료 상태와 결과를 확인할 수 있는 Task를 반환한다. 호출자는 그 객체를 통해 작업의 완료를 기다리고 실패 여부도 확인한다. 작업 객체를 받았다는 사실과 작업이 끝났다는 사실은 구분해야 한다.

Task는 결과값이 없는 작업을 나타낸다. 파일에 문자열을 쓰는 작업이 여기에 해당한다. 쓰기가 끝났는지는 중요하지만, 완료 결과로 별도의 값을 받을 필요는 없다. Task<T>는 완료되면 T 형식의 결과를 제공하는 작업을 나타낸다. 재고 합계를 구하는 메서드는 Task<int>를 반환하도록 만들 수 있다.

비동기 작업의 반환 형식과 await 이후에 얻는 값
반환 형식의미await 이후
Task결과값 없이 완료되는 작업다음 문장으로 진행한다.
Task<string>문자열을 결과로 제공하는 작업string 값을 얻는다.
Task<string[]>문자열 배열을 결과로 제공하는 작업읽은 줄들이 담긴 배열을 얻는다.
Task<int>정수를 결과로 제공하는 작업int 값을 얻는다.

예를 들어 Task<int> stockTask는 재고 수량 자체가 아니다. 재고 수량을 구하는 작업을 가리키는 변수다. 수량이 필요하면 int availableCount = await stockTask;처럼 작성한다. 이 문장이 정상적으로 끝났을 때 availableCount에 계산 결과가 들어 있다.

작업은 이미 완료되어 있을 수도 있고 아직 진행 중일 수도 있다. 실패하거나 취소된 상태로 완료될 수도 있다. 따라서 Task를 “나중에 시작할 명령”이라고 이해하면 곤란하다. 비동기 메서드를 호출하면 메서드 실행이 시작되며, 반환된 작업을 기다리기 위해 별도로 Start()를 호출하지 않는다.

작업과 스레드도 같은 개념이 아니다. 작업 객체 하나가 전용 스레드 하나를 뜻하지 않는다. Task는 완료를 표현하는 단위이고, 스레드는 코드를 실행하는 단위다. 비동기 파일 입출력이 내부적으로 어떤 기능을 사용하는지는 운영체제와 구현에 따라 달라질 수 있다. 호출자는 반환된 작업으로 완료를 다루면 된다.

이 구분은 메서드 이름을 읽을 때도 도움이 된다. 비동기 메서드 이름 끝에는 관례상 Async를 붙인다. 이번 예제의 ReadAvailableCountAsync는 판매 가능 수량을 읽는 비동기 메서드라는 뜻이다. 이름만으로 동작이 바뀌지는 않으며, 실제 계약은 반환 형식과 메서드 구현으로 정해진다.

await는 완료와 다음 처리를 연결한다

async는 메서드 안에서 await를 사용할 수 있게 하는 한정자다. 메서드에 이 단어를 붙였다고 해서 본문 전체가 새 스레드에서 실행되지는 않는다. 일반적인 비동기 메서드 호출은 처음부터 실행을 시작하고, 아직 끝나지 않은 작업을 await하는 지점에서 실행을 잠시 멈출 수 있다.

await가 만난 작업이 이미 끝났다면 결과를 확인하고 그대로 다음 문장으로 진행한다. 작업이 아직 끝나지 않았다면 현재 비동기 메서드의 나머지 처리를 나중에 이어 가도록 등록하고 호출자에게 제어를 돌려준다. 작업 완료를 기다리기 위해 현재 스레드를 붙잡아 두지는 않는다.

작업이 끝나면 중단했던 메서드는 await 다음 부분을 이어서 실행한다. 이때 원래 스레드로 돌아온다고 가정해서는 안 된다. 일반적인 콘솔 앱은 특정 화면 스레드로 복귀해야 하는 환경이 아니므로, 이 예제는 어느 스레드가 후속 코드를 실행하는지에 의존하지 않는다.

await는 작업이 끝났으면 계속 진행하고 미완료이면 메서드를 중단했다가 완료 후 이어 간다.

다음 두 문장은 서로 다른 시점을 나타낸다. 첫 문장은 메서드를 호출하고 작업을 받는다. 두 번째 문장은 그 작업의 완료를 확인하고 결과를 꺼낸다. 두 문장 사이에는 재고 수량을 몰라도 할 수 있는 처리를 넣을 수 있다.

Task<int> stockTask = ReadAvailableCountAsync(stockPath);
int availableCount = await stockTask;

이 코드 조각은 뒤의 완성 코드 안에서 사용한다. 호출과 기다리기를 분리할 필요가 없다면 int availableCount = await ReadAvailableCountAsync(stockPath);처럼 한 문장으로 작성해도 된다. 변수를 하나 더 만드는 것 자체가 비동기의 목적은 아니다. 결과가 필요한 지점과 작업의 완료 관계를 분명하게 만드는 것이 중요하다.

비동기 메서드 안에서는 결과 형식을 직접 반환한다. 반환 형식이 Task<int>인 async 메서드라도 본문에서는 return total;처럼 정수를 반환한다. 컴파일러가 메서드의 실행 상태와 완료 결과를 작업으로 전달하는 데 필요한 코드를 만든다. 직접 작업 객체에 결과를 넣는 코드를 작성할 필요가 없다.

예외도 이 완료 관계를 따라 전달된다. 비동기 메서드 내부에서 파일 읽기가 실패하면 반환된 작업은 실패 상태가 되고, 호출자가 해당 작업을 await할 때 예외를 받는다. 앞 장에서 사용한 try와 catch를 await가 포함된 코드 주위에 두면 된다. 작업을 받아 놓기만 하고 기다리지 않으면 결과뿐 아니라 실패도 놓칠 수 있다.

콘솔 앱의 최상위 문에서도 await를 사용할 수 있다. 별도의 Main 메서드를 직접 선언하지 않아도 컴파일러가 비동기 진입점을 구성한다. 최상위 코드가 기다리는 작업은 프로그램의 실행 흐름에 포함되지만, 시작만 하고 기다리지 않은 모든 작업이 자동으로 완료될 때까지 프로세스를 유지해 주는 것은 아니다.

파일 읽기의 시작과 정리 순서를 정한다

이번 프로그램은 임시 재고 파일에 두 줄을 쓴다. 각 줄은 메뉴 이름과 판매 가능 수량을 쉼표로 구분한다. 파일 형식은 예제에서 직접 정하므로 쉼표가 포함된 메뉴 이름이나 잘못된 숫자는 없다고 가정한다. 일반적인 데이터 파일을 위한 복잡한 형식 검증은 추가하지 않는다.

아메리카노,12
카페라테,8

File.WriteAllTextAsync는 문자열을 파일에 쓰고 Task를 반환한다. 이 쓰기를 먼저 기다려야 읽기를 시작할 때 준비가 끝난 파일을 사용할 수 있다. 비동기 호출이라는 이유로 서로 의존하는 작업의 순서를 생략할 수는 없다.

읽기에는 File.ReadAllLinesAsync를 사용한다. 이 메서드를 기다리면 각 줄이 담긴 문자열 배열을 얻는다. 예제의 집계 메서드는 배열을 순회하며 수량을 더한다. 파일을 기다리는 부분은 비동기이지만, 이미 읽어 온 문자열을 나누고 정수를 더하는 부분은 일반적인 동기 코드다.

모든 줄을 한 번에 읽으므로 파일 전체 내용이 메모리에 올라온다. 소규모 카페 재고 목록에는 간단하게 적용할 수 있지만, 파일이 커질 때는 읽는 방식과 메모리 사용량을 다시 검토해야 한다. 비동기로 바꾸었다고 해서 데이터가 자동으로 조금씩 처리되는 것은 아니다.

임시 폴더는 읽기와 집계가 모두 끝난 뒤 정리한다. 정리 코드는 finally에 두므로 읽기나 숫자 변환에서 예외가 나더라도 실행된다. 다만 폴더 삭제 자체도 권한이나 저장 장치 상태 때문에 실패할 수 있다. 여기서는 정상적인 임시 폴더 접근이 가능한 환경을 전제로 실행 결과를 제시한다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 코드로 바꾼다. 외부 패키지는 필요하지 않다. 임시 폴더 이름은 실행마다 달라지지만 화면에 출력하지 않으므로 정상 실행의 출력 내용과 순서는 같다.

using System;
using System.IO;
using System.Threading.Tasks;

string workDirectory = Path.Combine(
    Path.GetTempPath(),
    "cafe-async-" + Guid.NewGuid().ToString("N"));

Directory.CreateDirectory(workDirectory);
string stockPath = Path.Combine(workDirectory, "stock.txt");

try
{
    await File.WriteAllTextAsync(
        stockPath,
        "아메리카노,12\n카페라테,8\n");

    Console.WriteLine("재고 파일 준비 완료");

    Task<int> stockTask = ReadAvailableCountAsync(stockPath);
    Console.WriteLine("카운터: 주문 접수 안내 표시");

    int availableCount = await stockTask;
    Console.WriteLine($"판매 가능 수량: {availableCount}잔");
    Console.WriteLine("재고 확인 완료");
}
finally
{
    Directory.Delete(workDirectory, recursive: true);
}

static async Task<int> ReadAvailableCountAsync(string path)
{
    string[] lines = await File.ReadAllLinesAsync(path);
    int total = 0;

    foreach (string line in lines)
    {
        string[] fields = line.Split(',');
        int count = int.Parse(fields[1]);
        total += count;
    }

    return total;
}

줄별 해설

using 세 줄은 콘솔 출력과 경로 처리, 작업 형식을 사용할 수 있도록 이름 공간을 가져온다. 프로젝트의 암시적 가져오기 설정에 의존하지 않도록 코드에서 사용하는 이름 공간을 직접 적었다.

Path.GetTempPath()는 운영체제의 임시 폴더 경로를 제공한다. 그 아래에 새 식별자를 붙인 작업 폴더 경로를 만든다. macOS와 Linux의 경로 구분자를 코드에 직접 넣지 않고 Path.Combine으로 구성한다. 임시 폴더를 사용하므로 프로젝트 폴더에 재고 파일이 남지 않는다.

Directory.CreateDirectory(workDirectory)는 실제 폴더를 만든다. 그다음 stockPath에 파일 경로를 저장한다. 폴더 생성은 동기 호출이다. 프로그램의 모든 메서드를 비동기 형태로 바꿔야 하는 것은 아니며, 여기서는 파일 쓰기와 읽기의 완료를 비동기로 다룬다.

try 안의 첫 문장은 재고 데이터를 쓰고 완료를 기다린다. 문자열의 \n은 줄바꿈이므로 파일에는 재고 항목 두 개가 들어간다. 마지막 줄바꿈 때문에 ReadAllLinesAsync의 결과에 별도의 빈 항목이 하나 추가되는 것은 아니다.

Console.WriteLine("재고 파일 준비 완료")는 쓰기 작업을 기다린 뒤 실행된다. 따라서 이 안내가 나왔을 때는 파일 쓰기가 정상적으로 완료된 상태다. 파일을 쓰는 작업과 읽는 작업이 서로 경쟁하지 않도록 선행 조건을 확정한 것이다.

ReadAvailableCountAsync(stockPath)를 호출하면 집계 메서드의 실행이 시작된다. 메서드는 파일 읽기를 요청하고, 읽기가 아직 끝나지 않았다면 내부의 await에서 중단될 수 있다. 호출자는 반환된 Task<int>를 stockTask에 저장한다.

다음 줄의 주문 접수 안내는 수량을 사용하지 않는다. 따라서 결과를 기다리기 전에 실행해도 된다. 단, 이 안내가 표시될 때 파일 읽기가 반드시 진행 중이라는 뜻은 아니다. 읽기와 집계가 이미 끝났을 수도 있다. 이 예제는 작업이 얼마나 오래 걸리는지에 대한 가정 없이 같은 결과를 만든다.

int availableCount = await stockTask;는 수량이 필요한 경계다. 작업이 완료되지 않았다면 기다리고, 정상 완료되면 정수 결과를 받는다. 이 줄 다음의 출력은 집계 완료보다 앞서 실행되지 않는다. 뒤이어 재고 확인 완료 문장을 출력한다.

finally의 삭제는 try에서 빠져나올 때 실행된다. 정상 경로에서는 쓰기와 읽기, 집계 결과 사용을 모두 마친 상태다. 파일 읽기가 실패했을 때도 해당 작업의 실패를 확인한 다음 정리 단계로 이동한다. 파일을 사용하는 작업을 남겨 둔 채 폴더를 먼저 지우지 않는 순서가 중요하다.

아래쪽의 ReadAvailableCountAsync는 최상위 코드에서 호출하는 정적 지역 함수다. 매개변수 path로 읽을 파일을 받고, 완료 결과로 정수 하나를 제공한다. 함수 선언을 코드 아래에 두어도 위쪽에서 이름으로 호출할 수 있다.

string[] lines = await File.ReadAllLinesAsync(path);에서 오른쪽 호출의 반환 형식은 Task<string[]>다. await를 적용한 결과가 문자열 배열이므로 왼쪽 변수는 string[]이다. 작업 형식과 결과 형식을 한 줄에서 비교할 수 있다.

반복문은 각 줄을 쉼표로 나눈 뒤 두 번째 항목을 정수로 바꾼다. 12와 8을 더한 total은 20이 된다. 마지막의 return total;은 집계 작업을 결과 20으로 정상 완료시킨다. 읽기 이후의 계산에서 예외가 발생해도 호출자는 await stockTask에서 그 실패를 확인한다.

실행 결과

새 프로젝트가 필요하면 다음 명령을 실행한다. 생성된 Program.cs를 완성 코드로 교체한 다음 실행한다.

dotnet new console --framework net10.0 --name CafeAsync
cd CafeAsync

프로젝트 폴더에서 실행하는 명령은 다음과 같다.

dotnet run

정상 실행 시 프로그램의 출력은 다음과 같다.

재고 파일 준비 완료
카운터: 주문 접수 안내 표시
판매 가능 수량: 20잔
재고 확인 완료

파일 읽기가 빨리 끝나도 출력 순서는 바뀌지 않는다. 집계 함수는 화면에 아무것도 출력하지 않고, 화면 출력은 최상위 코드에서 순서대로 수행한다. “재고 파일 준비 완료”는 쓰기 이후에, 판매 가능 수량은 집계 이후에 출력된다.

실행 시간을 출력하거나 스레드 번호를 확인할 필요는 없다. 그런 값은 실행 환경마다 달라질 수 있고 이 코드의 정확성을 결정하지 않는다. 또한 대기가 발생하는 모습을 만들기 위해 임의의 지연을 추가하지 않았다. await는 작업이 즉시 끝나는 경우에도 올바르게 동작해야 한다.

실무에서 자주 틀리는 것

다음 코드는 완성 프로그램의 일부를 바꾸어 비교하는 조각이다. 별도의 실행 프로그램이 아니며, stockPath와 집계 함수는 완성 코드의 것을 사용한다.

.Result로 기다리면 await와 같다고 생각한다

다음 코드는 작업 결과를 가져오지만, 작업이 끝나지 않았다면 호출 스레드를 차단한다. 비동기 호출을 사용한 뒤 기다리는 부분을 다시 동기 방식으로 만든 셈이다.

int availableCount = ReadAvailableCountAsync(stockPath).Result;
Console.WriteLine($"판매 가능 수량: {availableCount}잔");

고친 코드는 완료를 비동기로 기다린다.

int availableCount = await ReadAvailableCountAsync(stockPath);
Console.WriteLine($"판매 가능 수량: {availableCount}잔");

.Result가 언제나 교착(deadlock)을 만드는 것은 아니다. 일반적인 .NET 콘솔 앱에는 화면 앱처럼 특정 스레드로 돌아가야 하는 동기화 컨텍스트(synchronization context)가 기본적으로 없으므로, 이 예제를 .Result로 바꾸면 대개 결과가 출력된다. 그렇더라도 기다리는 스레드가 차단된다는 사실은 같다.

문제는 특정 컨텍스트에서만 후속 코드를 실행할 수 있는 환경이다. 화면 스레드가 .Result로 비동기 메서드의 완료를 기다리고, 그 메서드의 await 이후 코드가 같은 화면 스레드에서 실행되어야 한다면 서로 기다리는 상태가 될 수 있다. 화면 스레드는 작업 완료를 기다리고, 작업은 화면 스레드가 코드를 실행해 주기를 기다린다.

차단된 화면 스레드와 그 스레드가 필요한 후속 코드가 서로 기다리면 작업이 완료되지 못한다.

따라서 콘솔에서 한 번 실행되었다는 이유로 .Result를 일반적인 대기 방법으로 선택하지 않는다. 비동기 호출을 사용하는 흐름에서는 호출자도 작업을 반환하고 await로 기다리는 방식을 유지한다. .Wait() 역시 호출 스레드를 차단하므로 같은 관점에서 살펴야 한다.

작업을 시작한 뒤 완료를 확인하지 않는다

다음 코드는 집계 작업을 받아 놓고 곧바로 완료 메시지를 출력한다. 메시지와 실제 집계 완료 사이에 아무런 연결이 없다.

Task<int> stockTask = ReadAvailableCountAsync(stockPath);
Console.WriteLine("재고 확인 완료");

작업이 매우 빨리 끝나면 문제가 드러나지 않을 수도 있다. 그러나 아직 읽는 중일 때 완료 메시지가 나올 수 있고, 이어서 파일을 정리하면 읽기 작업과 삭제가 겹칠 수 있다. 작업이 실패해도 이 코드에서는 결과를 기다리며 예외를 확인하지 않는다.

고친 코드는 결과를 받은 뒤 완료를 알린다. 완료 메시지를 출력할 자격이 생기는 지점을 await로 표시한 것이다.

Task<int> stockTask = ReadAvailableCountAsync(stockPath);
int availableCount = await stockTask;
Console.WriteLine($"판매 가능 수량: {availableCount}잔");
Console.WriteLine("재고 확인 완료");

결과값이 필요 없는 작업이라도 완료 확인이 필요하다면 await해야 한다. 예를 들어 파일 쓰기를 기다리지 않고 프로그램을 끝내면 저장이 완료되었다고 보장할 수 없다. 반환값을 사용하지 않는 것과 작업을 기다리지 않는 것은 서로 다른 결정이다.

일반 비동기 메서드를 async void로 만든다

다음 보조 함수는 파일 내용을 출력하지만 반환 형식이 void다. 호출자는 이 함수의 완료를 await할 수 없다.

static async void ShowStockAsync(string path)
{
    string text = await File.ReadAllTextAsync(path);
    Console.Write(text);
}

async void에서는 실패를 담은 작업도 호출자에게 돌려주지 않는다. 호출문 주위의 try와 catch로 비동기 처리 중 발생한 예외를 받는 구조를 만들 수 없다. 이벤트 처리기처럼 반환 형식이 정해진 특별한 경우를 제외하면 일반 메서드는 Task를 반환하도록 작성한다.

고친 함수는 출력할 데이터는 있어도 호출자에게 돌려줄 결과값은 없으므로 Task를 반환한다.

static async Task ShowStockAsync(string path)
{
    string text = await File.ReadAllTextAsync(path);
    Console.Write(text);
}

호출하는 쪽에서도 다음처럼 기다린다. 메서드의 반환 형식을 고치는 것과 호출부에서 완료를 연결하는 것까지 함께 해야 한다.

await ShowStockAsync(stockPath);

비동기 파일 읽기를 Task.Run으로 한 번 더 감싼다

다음 코드는 실행될 수 있지만, 파일 읽기를 비동기로 만들기 위해 필요한 코드는 아니다. 이미 비동기 작업을 반환하는 집계 메서드에 추가 예약 단계를 넣고 있다.

int availableCount = await Task.Run(
    () => ReadAvailableCountAsync(stockPath));

Task.Run은 스레드 풀(thread pool)에 작업 실행을 예약한다. 시간이 오래 걸리는 계산을 호출 스레드 밖에서 실행하려는 경우에 검토할 수 있다. 그러나 비동기 파일 입출력 메서드를 호출할 때마다 붙이는 표시는 아니다. 이 예제의 문자열 분리와 덧셈은 양이 작으므로 별도 실행을 예약할 이유도 없다.

고친 코드는 집계 메서드를 직접 호출하고 기다린다.

int availableCount = await ReadAvailableCountAsync(stockPath);

파일을 기다리는 시간과 읽은 데이터를 계산하는 시간은 구분해야 한다. 큰 계산을 처리하는 문제가 생기면 그 계산의 실행 위치를 따로 검토한다. async라는 이름만 보고 모든 일이 다른 스레드에서 처리된다고 추측하지 않는 것이 출발점이다.

한눈에 보기

재고 파일을 비동기로 읽을 때 확인할 기준
표현하는 일확인할 점
async메서드 안에서 await를 사용한다.새 스레드 생성을 뜻하지 않는다.
Task<int>정수 결과를 제공할 작업을 나타낸다.정수 결과 자체와 구분한다.
await완료를 기다리고 결과나 예외를 받는다.이미 완료된 작업이면 곧바로 이어 간다.
ReadAllLinesAsync파일의 모든 줄을 비동기로 읽는다.완료 후 문자열 배열을 얻는다.
.Result완료까지 스레드를 차단해 결과를 받는다.실행 환경에 따라 교착 가능성이 있다.
finallytry를 벗어날 때 정리를 수행한다.파일 사용 작업의 완료와 순서를 연결한다.

코드를 검토할 때는 세 지점을 찾으면 된다. 작업을 시작하는 곳, 그 결과가 처음 필요한 곳, 사용한 자원을 정리하는 곳이다. 이번 프로그램에서는 집계 메서드 호출, await stockTask, 임시 폴더 삭제가 각각 그 역할을 맡는다. 이 순서가 분명하면 실행 속도가 달라져도 같은 의미를 유지한다.

연습 문제

  1. 완성 코드의 재고 데이터에 바닐라라테,5를 세 번째 줄로 추가하라. 변경할 문자열과 프로그램의 전체 출력을 쓰라. 집계 함수도 수정해야 하는지 설명하라.
  2. stockTask의 형식과 await stockTask의 결과 형식을 각각 쓰라. 주문 접수 안내가 표시되는 순간에 집계 작업이 반드시 미완료 상태인지 설명하라.
  3. 완성 코드의 집계 호출부터 재고 확인 완료 출력까지를 작은 try와 catch로 감싸라. 읽을 경로를 임시 폴더 안의 missing.txt로 바꾸고, FileNotFoundException이 발생하면 재고 파일을 찾을 수 없다.를 출력하라. 바깥쪽 finally는 유지하라.
  4. “콘솔 앱에서 .Result로 결과가 출력되었으므로 화면 앱에서도 안전하게 사용할 수 있다”라는 판단의 문제를 설명하라. 교착이 생기는 상황에서 각각 무엇이 무엇을 기다리는지 쓰라.

정답과 해설

1. 데이터 한 줄 추가

File.WriteAllTextAsync의 두 번째 인수를 다음 문자열로 바꾼다.

"아메리카노,12\n카페라테,8\n바닐라라테,5\n"

집계 함수는 모든 줄을 순회하므로 수정할 필요가 없다. 새 합계는 25이며 전체 출력은 다음과 같다.

재고 파일 준비 완료
카운터: 주문 접수 안내 표시
판매 가능 수량: 25잔
재고 확인 완료

비동기 흐름과 데이터 집계 규칙은 별개다. 항목 수가 늘어도 작업을 받고 기다리는 구조는 그대로다. 바뀌는 것은 파일 내용과 그 내용을 계산한 결과다.

2. 작업 형식과 결과 형식

stockTask의 형식은 Task<int>이고, await stockTask가 정상 완료되어 제공하는 결과 형식은 int다. 전자는 작업의 완료를 다루기 위한 객체이고 후자는 그 작업이 계산한 수량이다.

안내를 출력할 때 작업이 미완료 상태라고 단정할 수는 없다. 작은 파일은 읽기와 집계가 이미 끝났을 수 있다. 작업 상태와 관계없이 안내 다음에 수량이 출력되는 이유는 집계 함수가 직접 출력하지 않고, 최상위 코드가 정해진 순서대로 출력하기 때문이다.

3. await 주위에서 파일 누락 처리

지정한 구간을 다음 코드 조각으로 교체한다. 바깥쪽 파일 준비 코드와 finally는 그대로 둔다.

try
{
    string missingPath = Path.Combine(workDirectory, "missing.txt");
    Task<int> stockTask = ReadAvailableCountAsync(missingPath);
    Console.WriteLine("카운터: 주문 접수 안내 표시");

    int availableCount = await stockTask;
    Console.WriteLine($"판매 가능 수량: {availableCount}잔");
    Console.WriteLine("재고 확인 완료");
}
catch (FileNotFoundException)
{
    Console.WriteLine("재고 파일을 찾을 수 없다.");
}

정상적으로 접근 가능한 임시 폴더에서 실행하면 출력은 다음과 같다.

재고 파일 준비 완료
카운터: 주문 접수 안내 표시
재고 파일을 찾을 수 없다.

집계 함수는 async Task<int> 메서드이므로 내부의 파일 읽기 실패는 반환된 작업을 통해 전달된다. 호출자는 await stockTask에서 예외를 받고 catch로 이동한다. 수량 출력과 재고 확인 완료 출력은 건너뛴다. 이후 바깥쪽 finally가 준비해 둔 재고 파일과 임시 폴더를 정리한다.

4. 실행 환경에 따라 달라지는 교착 가능성

일반적인 콘솔 앱에서 결과가 출력되었다는 사실은 그 실행에서 교착이 발생하지 않았음을 보여 준다. 다른 실행 환경에서도 같은 대기 방식이 적절하다는 근거는 되지 않는다.

화면 앱에서 특정 화면 스레드로 돌아와야 하는 비동기 메서드를 생각해 보자. 화면 스레드가 .Result를 호출하면 메서드의 작업 완료를 기다리며 차단된다. 한편 그 작업이 완료되려면 await 이후 코드가 같은 화면 스레드에서 실행되어야 한다. 스레드는 작업을 기다리고 작업의 후속 코드는 스레드를 기다리는 관계가 형성된다.

이 상황에서는 호출부도 await를 사용해 화면 스레드가 다른 처리를 수행할 수 있도록 제어를 돌려주는 구조가 필요하다. 다음 종합 실습에서도 파일을 읽는 메서드의 반환 형식뿐 아니라, 그 메서드를 호출한 코드가 완료를 어떻게 확인하는지 함께 살펴본다.

댓글 0

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

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