1. 소개
큰 이미지를 불러오는 예제 기억하지? 로드되는 동안 앱이 "멈춰버리는" 상황이 있었지. 텍스트 파일도 마찬가지야, 특히 파일이 클 때(수십 기가바이트짜리 로그, 거대한 CSV 리포트, 텍스트 형식의 DB 백업 등)는 더 심해.
다음 같은 애플리케이션을 만든다고 상상해봐:
거대한 로그 파일을 파싱해서 에러를 찾는 앱. 이걸 동기적으로 읽으면 UI가 몇 초, 심하면 몇 분 동안 그대로 멈춰버릴 거야. 사용자는 프로그램이 고장난 줄 알게 되지.
데이터를 생성하면서 리포트 파일에 기록하는 경우. 기록이 메인 스레드를 막아버리면, 데이터 생성과 UI 반응성 둘 다 나빠진다.
수천 개의 요청을 처리해야 하는 웹 서버. 각 요청이 파일 읽기/쓰기를 필요로 할 수 있는데, 그게 모두 동기라면 서버 스레드들이 디스크를 기다리느라 놀게 되고, 곧 요청 폭주에 서버가 버벅거리게 돼.
이런 상황에서 비동기 I/O는 단순한 "편의 기능"이 아니라 필수야. 디스크가 '생각'하는 동안 애플리케이션이 놀고 있지 않고 유용한 일을 계속하게 해주거든(예: UI 업데이트, 다른 요청 처리, 계산 등).
기본 개념: async/await와 작업
- 키워드 async는 메서드에 "대기 지점"(await)이 있을 수 있음을 표시해.
- 연산자 await는 비동기 작업(예: 파일 읽기)이 끝날 때까지 제어를 일시적으로 반환해.
- 비동기 메서드는 현재 스레드를 블로킹하지 않고 I/O를 수행해: 데이터가 없을 때에는 스레드가 자유로워.
이게 파일 작업 비동기의 기본 메커니즘이야.
2. 파일용 비동기 메서드
현대 .NET 버전에서는 파일 작업용 주요 클래스들에 거의 다 비동기 버전이 있어. 텍스트 파일에는 보통 다음을 많이 써:
- StreamReader.ReadLineAsync()
- StreamReader.ReadToEndAsync()
- StreamWriter.WriteLineAsync()
- StreamWriter.WriteAsync()
- 그리고 정적 메서드들: File.ReadAllTextAsync(), File.WriteAllTextAsync() 등.
| 읽기 | 쓰기 |
|---|---|
|
|
|
|
|
|
3. 파일 전체를 비동기적으로 읽기
작은 파일(설정 파일, 작은 로그 등)은 한 번에 전부 읽는 경우가 많아. 예제:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "input.txt";
// 파일 전체를 비동기적으로 읽어
string fileContents = await File.ReadAllTextAsync(path);
Console.WriteLine("파일 내용:");
Console.WriteLine(fileContents);
}
}
주의: 이제 Main 메서드는 async Task Main()으로 표시돼 있어. C# 7.1부터 이렇게 할 수 있어. 한 번의 await로 비동기 처리가 되지!
4. 큰 파일을 줄 단위로 비동기 읽기
파일이 정말 크면 메모리에 통째로 올리는 건 별로야. 줄 단위로 읽는 게 낫지:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "biglog.txt";
// 비동기 읽기를 위해 StreamReader 열기
using StreamReader reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
// 여기서 줄을 처리할 수 있어 (예: 에러 찾기)
Console.WriteLine(line);
}
}
}
어떻게 동작하나?
각 await reader.ReadLineAsync() 호출은 스레드를 해제해 — 특히 파일이 네트워크 드라이브나 클라우드에 있을 때 유용해. 수만 줄을 처리하거나 사용자와 병렬로 동작하는 API 서버 같은 상황에서 비동기 처리는 필수적이야.
5. 파일에 줄 비동기 쓰기
리포트 생성 같은 작업에서는 데이터를 비동기적으로 파일에 쓸 수 있어:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "output.txt";
using StreamWriter writer = new StreamWriter(path);
for (int i = 0; i < 5; i++)
{
await writer.WriteLineAsync($"줄 번호 {i + 1}");
}
// 지금 바로 기록을 보장하려면 FlushAsync를 명시적으로 호출할 수 있어
await writer.FlushAsync();
Console.WriteLine("데이터를 비동기적으로 기록했어!");
}
}
FlushAsync()를 항상 호출할 필요는 없어 — StreamWriter가 닫힐 때 버퍼는 자동으로 비워져. 하지만 "지금 당장" 보장이 필요하면 호출하자.
6. 여러 비동기 파일 작업을 동시에 처리하기
예를 들어, 한 파일을 읽으면서 동시에 변환된 내용을 다른 파일에 쓰고 싶다면:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string sourcePath = "even_biggerlog.txt";
string destinationPath = "copy_biggerlog.txt";
using StreamReader reader = new StreamReader(sourcePath);
using StreamWriter writer = new StreamWriter(destinationPath);
string? line;
int linesProcessed = 0;
while ((line = await reader.ReadLineAsync()) != null)
{
// 약간의 마법: 모든 문자를 대문자로 바꿈
string processed = line.ToUpperInvariant();
await writer.WriteLineAsync(processed);
linesProcessed++;
}
Console.WriteLine($"처리된 줄 수: {linesProcessed}");
}
}
여기선 읽기와 쓰기가 모두 비동기적으로 이뤄져. 각 await는 제어를 반환해서 애플리케이션이 다른 일을 할 수 있게 해줘.
7. 실무 사용 예: 어디에 쓰이나?
- 웹 개발 (ASP.NET Core): 파일 업/다운로드가 다른 요청 처리를 막지 않아서 서버가 응답성을 유지해.
- 데스크탑 앱 (WPF, WinForms): 로그 열기나 리포트 저장 시 UI가 "멈추지" 않아.
- 게임 엔진: 리소스(텍스처, 모델) 비동기 로딩으로 애니메이션이나 게임 플레이가 끊기지 않아.
- 빅데이터 처리: 거대한 CSV/JSON/XML을 줄 단위로 파싱해서 메모리 과다 사용 없이 스트리밍 처리 가능.
- 백그라운드 서비스와 데몬: 로깅, 캐싱, 큐 처리 등에서 스레드와 디스크를 효율적으로 사용 가능.
결론: 비동기성은 현대적이고 응답성 좋고 확장 가능한 애플리케이션을 만드는 데 도움이 돼. "블로킹"은 나쁘고, 비동기는 좋아!
8. 주의사항과 베스트 프랙티스
await를 잊지 마! Async 접미사가 붙은 메서드를 기다리지 않고 호출하면 Task 객체가 반환되지만 코드가 바로 다음으로 넘어가서 실행 순서 문제가 생겨.
// 나쁨: await를 깜빡함
FileManager.ReadTextFileAsync("nonexistent.txt"); // 실행은 되지만 Main은 바로 다음 줄로 진행함
Console.WriteLine("파일이 아직 읽히고 있거나 오류가 발생했을 텐데도 바로 실행됐어! 이건 안 좋아!");
컴파일러가 보통 잊은 await에 대해 경고를 주지만, 빌드를 막지는 않아.
using은 IDisposable인 모든 것에 적용: 모든 스트림(FileStream, StreamReader, StreamWriter)은 적절히 해제돼야 해. using 블록이나 C# 8+의 using 선언을 사용해서 확실히 닫고 버퍼를 비우자.
버퍼 크기(bufferSize): StreamReader/StreamWriter는 이미 최적화돼 있지만, 특수한 요구가 있으면 실험해볼 수 있어. 기본값은 괜찮은 값들이고(FileStream에서 자주 4096 바이트를 사용함) 보통은 건드릴 필요 없어.
오류 처리: 비동기 메서드도 예외를 던져. 연산을 try-catch로 감싸라. 예외는 해당 Task에 대한 await가 수행될 때 "증발"해서(전달돼서) 발생한다.
ConfigureAwait: 라이브러리나 GUI 컨텍스트가 필요 없는 웹 시나리오에서는 await SomeAsync().ConfigureAwait(false)를 사용하면 컨텍스트 전환 비용을 줄일 수 있어. 콘솔이나 UI 앱에서는 보통 생략해도 괜찮아.
연습을 많이 해봐 — 곧 async Task가 Console.WriteLine만큼 자연스럽게 느껴질 거야.
9. 자주 발생하는 오류와 중요한 주의사항
만약 await를 사용하지 않고 단순히 Async 접미사의 메서드를 호출하면 Task 객체를 얻게 되지만 결과는 자동으로 기다려지지 않아. 결과를 얻으려면 await하거나(권장) 명시적으로 기다려야 해(보통 권장하지 않음).
동기 메서드에서 비동기 메서드를 기다리려면 스택을 따라 async로 끌어올려야 해. .Result나 .GetAwaiter().GetResult()를 쓰면 데드락이 발생할 수 있으니 주의 — 호출하는 쪽을 async로 바꾸는 게 낫다.
같은 파일을 동시에 읽고 쓰지 마 (비동기라고 해도). 레이스와 데이터 손상이 발생할 수 있어.
비동기는 호출 스레드를 해제해주지만 작업 자체를 빠르게 만들어주진 않아: 디스크나 네트워크가 느리면 비동기여도 느리다 — 단지 UI나 워커 스레드를 블로킹하지 않을 뿐이야.
GO TO FULL VERSION