CodeGym /행동 /C# SELF /비동기 vs. 멀티스레딩 ( async/await 및 Thread)

비동기 vs. 멀티스레딩 ( async/await 및 Thread)

C# SELF
레벨 59, 레슨 1
사용 가능

1. 소개

프로그래밍에서 초보자(심지어 경험 많은 개발자도)가 종종 혼동하는 두 개념이 있다. 비슷해 보이지만 실제로는 다른 개념들: 멀티스레딩과 비동기성. 면접에서 이 질문을 자주 던지는 이유는, 이 차이를 이해하느냐에 따라 빠르고 반응성 좋은 코드를 어떻게 써야 할지가 달라지기 때문이다.

자, 어디가 다른지 같이 살펴보자.

멀티스레딩: 많은 손이 동시에 일할 때

멀티스레딩은 여러 스레드를 이용해서 프로그램 작업을 조직하는 방식이다. 스레드는 실행의 흐름, CPU가 명령을 따라가는 별도의 "트랙" 같은 것. 하나의 프로세스(예: 우리의 .NET 애플리케이션)가 여러 스레드를 띄워서 서로 다른 작업을 동시에 수행하게 할 수 있다.

비유: 너가 프로젝트 매니저고, 서로 다른 동료들에게 각자 업무를 맡긴다. 한 명은 보고서를 쓰고, 다른 한 명은 고객에게 전화하고, 또 다른 한 명은 프레젠테이션을 만든다. 모두 동시에 작업한다.

멀티스레딩의 핵심 아이디어: 작업이 실제로 병렬로 일어난다(혹은 CPU 코어가 하나뿐이라도 빠른 컨텍스트 스위칭으로 유사 병렬성처럼 보인다).

비동기성: 기다리는 동안 가만히 있지 않는 능력

비동기성은 긴 작업(예: 네트워크 응답이나 파일 읽기)을 기다리는 동안 프로그램이 다른 일을 할 수 있게 코드를 짜는 방식이다. 비동기 코드는 반드시 여러 스레드를 쓰는 건 아니다! 단지 기다리는 동안 스레드를 블록하지 않는다는 뜻이다.

비유: 커피 머신 앞에서 서서 커피가 떨어지기를 바라지 않는다. 커피 추출을 시작해두고, 그 사이에 이메일 답장하거나 뉴스를 본다. 커피가 준비되면 다시 가져오면 된다.

비동기성의 핵심 아이디어: 긴 작업을 기다리는 동안 멍하니 있지 말고, 유용한 일을 하자.

2. 차이는 뭘까?

비동기성과 멀티스레딩은 자주 함께 쓰여서 더 혼란을 준다. 하지만 실은 서로 다른 질문에 답한다:

  • 멀티스레딩은 사용 가능한 CPU/코어를 이용해 작업을 실제로 병렬화할 때 필요하다.
  • 비동기성은 리소스 대기(I/O: 네트워크, 디스크 등)를 똑똑하게 처리해서 스레드를 블록하지 않게 할 때 필요하다.

둘을 섞을 수는 있지만, 항상 연결되어야 하는 건 아니다.

간단한 시각화

멀티스레딩 (Threads) 비동기성 (Async)
언제 적용? 작업이 CPU를 많이 먹을 때 (CPU-bound: 계산, 렌더링, 배열 처리) 작업이 이벤트를 기다릴 때 (I/O-bound: 네트워크, 디스크, DB)
코드가 뭐함? 프로세서를 돌리고, 여러 스레드를 띄워 실제 병렬 수행 데이터를 기다리는 동안(“대기”) 스레드를 해제해서 다른 일을 시킬 수 있음
무엇을 관리함? 동시에 실행되는 스레드의 수 누가 지금 바쁜지/한가한지, 작업 완료 시 무엇을 할지
전형적인 예 비디오 인코딩, 이미지 렌더링 파일 다운로드, HTTP 요청

예제 1: 멀티스레딩 — 빠르게 계산하기

무거운 작업이 있다고 치자: 큰 수들의 합을 계산해야 한다.


void ComputeSum(long start, long end)
{
    long sum = 0;
    for (long i = start; i <= end; i++)
    {
        sum += i;
    }
    Console.WriteLine($"{start}부터 {end}까지의 합 = {sum}");
}

// 세 개의 작업을 동시에 시작 — 각자 자기 부분을 계산
Thread t1 = new Thread(() => ComputeSum(1, 1000_000_000));
Thread t2 = new Thread(() => ComputeSum(1000_000_001, 2000_000_000));
Thread t3 = new Thread(() => ComputeSum(2000_000_001, 3000_000_000));

t1.Start();
t2.Start();
t3.Start();

// 모든 스레드가 끝날 때까지 기다림
t1.Join();
t2.Join();
t3.Join();

왜 여기서 스레드를 쓰나?
작업이 CPU 중심이기 때문이다. 스레드가 실제로 프로세서를 사용하므로, 멀티코어 머신이면 속도가 빨라진다.

예제 2: 비동기성 — 서버 응답을 기다리기

네트워크는 느리다. 서버 응답을 기다리는 동안 스레드는 "한가"할 수 있다.


// 비동기적으로 웹페이지를 가져옴, 스레드가 블록되지 않음
async Task DownloadPageAsync()
{
    using HttpClient client = new HttpClient();
    string html = await client.GetStringAsync("https://dotnet.microsoft.com/");
    Console.WriteLine(html.Length);
}

왜 비동기성을 쓰나?
"다운로드 시작"을 요청해두고 스레드를 블록하지 않기 위해서다. 데이터가 도착하면 알려주는 식.

3. 비동기성 = 항상 새로운 스레드 실행인가?

질문: 모든 비동기 코드는 항상 새로운 스레드를 띄우는가?
답: 아니다! 비동기성은 종종 추가 스레드를 전혀 요구하지 않는다.

예를 들어 await file.ReadAsync(...)를 호출하면, .NET은 OS 수준에서 비동기 작업을 시작하고 이 호출을 한 스레드는 곧 "해제"되어 스레드 풀로 복귀한다. 작업이 끝나면 스레드 풀의 어떤 자유로운 스레드가 와서 너의 작업을 계속 실행한다.

  • 동기적으로 file.Read(...)를 썼다면 — 그 스레드는 작업이 끝날 때까지 멍하니 기다리고 있었을 것이다.
  • 비동기 코드는 말한다: "프로세서야, 기다리는 동안 다른 일 좀 해줘!"

중요한 예시:


// 스레드가 블록되지 않고, 작업이 준비될 때만 기다림
await Task.Delay(1000); // 그냥 1초 기다리기 — CPU를 차지하지 않음!

멀티스레딩만 있고 비동기성이 없는 경우

때때로 진짜 병렬화가 필요해서 스레드를 쓰는 게 맞다: 무거운 계산, 큰 루프 처리 등이 그런 경우다. 이런 상황에선 비동기성이 계산 자체의 속도를 빠르게 하지는 못한다. CPU는 이미 100%로 바쁜 상태가 된다.

전형적 상황: 큰 파일 처리


// 이 코드는 실제로 CPU를 강하게 사용한다 — 비동기성으로는 속도 향상이 안 됨.
void CalculateHash(string file)
{
    byte[] data = File.ReadAllBytes(file); // 동기적으로 읽음!
    // 해시 계산...
}

속도 올리고 싶으면 — 여러 스레드를 띄워 각 스레드가 자기 파일을 처리하게 하라.

4. 애플리케이션에서 어떻게 보이나?

비동기 작업 (await)

학습용 애플리케이션에서 데이터 로드를 비동기적으로 추가할 수 있다. 예를 들어 환율이나 날씨 정보를 요청한다면 — 비동기적으로 하는 편이 낫다.


async Task GetWeatherAsync(string city)
{
    using HttpClient client = new HttpClient();
    string json = await client.GetStringAsync($"https://api.weather.com/{city}");
    // 응답이 도착하면 계속 작업
    Console.WriteLine($"{city}의 날씨: {json}");
}

속에서는 무슨 일이 일어나나?

await 호출은 너의 메서드를 두 부분으로 "잘라" 놓는다:

  • 비동기 작업을 시작하면 — 호출한 스레드는 "해제"되어 다른 일을 할 수 있다.
  • 데이터가 도착하면 — 스레드 풀의 어떤 자유로운 스레드가 와서 메서드의 나머지 부분을 계속 실행한다.

복잡한 계산을 위한 멀티스레딩

예를 들어 계산기 애플리케이션이 큰 배열을 처리해야 한다면 — 별도 스레드에서 계산을 돌리는 게 합리적이다.


// 큰 작업을 작은 조각으로 나누고, 각 조각을 별도 스레드가 계산한다
List<Thread> threads = new List<Thread>();
for (int i = 0; i < 4; i++)
{
    int rangeStart = i * 1000000;
    int rangeEnd = (i + 1) * 1000000 - 1;
    Thread t = new Thread(() => ComputeSum(rangeStart, rangeEnd));
    threads.Add(t);
    t.Start();
}
// 모든 스레드가 끝날 때까지 기다림
foreach (Thread t in threads) t.Join();

5. 비동기성 작업 시 흔한 실수와 주의점

실수 #1: async를 "속도" 때문에 사용하기.
많은 사람들이 잘못 생각하는 게 있다 — async가 코드를 더 빠르게 만든다고 여기는 것. 그건 틀렸다. 비동기성은 반응성(responsiveness)에 관한 것이지 실행 속도 자체에 관한 건 아니다.

작업이 CPU-bound라면 — 비동기성으로 더 빨라지지 않는다.
작업이 I/O-bound라면 — 비동기성이 유리하다. 스레드가 놀지 않고 다른 일을 할 수 있기 때문.

실수 #2: 스레드를 Wait() 또는 Result로 막기.
비동기 코드에서 절대적으로 피해야 할 것 중 하나는 Task에 대해 Wait()를 호출하거나 Result를 쓰는 것이다. 이건 거의 항상 스레드 블로킹과 데드락으로 이어진다.


// 안 좋은 예! 스레드를 블록하고 문제를 일으킴
var result = GetDataAsync().Result;

async Task<string> GetDataAsync() { /* ... */ return "data"; }

올바른 방법은 await를 쓰고 스레드를 블록하지 않는 것이다.

실수 #3: 비동기성과 UI.
GUI 애플리케이션(WPF, WinForms)에서 가장 큰 문제는 UI 스레드를 멈추지 않게 하는 것이다. 메인 스레드에서 긴 작업이나 블로킹 호출을 실행하면 앱이 얼어버린다. 비동기성을 이용하면 무거운 작업은 백그라운드에서 돌리고 UI는 반응성을 유지할 수 있다.

실수 #4: 비동기 메서드 네이밍 컨벤션 부재.
비동기 메서드 끝에 Async 접미사를 붙이지 않으면 어떤 메서드가 동기인지 비동기인지 헷갈리기 쉽다. 이로 인해 의도치 않게 블로킹을 하거나 호출 실수가 생긴다. 비동기 메서드에는 항상 Async를 붙여라.

2
과제
C# SELF, 레벨 59, 레슨 1
잠금
간단한 비동기 작업 예제
간단한 비동기 작업 예제
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION