1. 소개
멀티스레딩은 사무실에서 여러 명의 직원이 병렬로 일하는 것과 비슷합니다: 한 명은 문서를 인쇄하고, 다른 한 명은 고객에게 전화하고, 세 번째는 커피를 타죠(물론 모두 프로그래머라고 치고). 직원이 한 명뿐이면 모든 일을 차례대로 하고 사무실은 지루함과 커피 줄로 가득할 겁니다. 프로그래밍도 마찬가지로 단일 스레드 프로그램은 한 번에 하나의 작업만 수행할 수 있습니다.
예를 들어 애플리케이션이 오래 걸리는 작업을 수행한다고 가정해봅시다 — 인터넷에서 파일을 다운로드하거나 거대한 테이블을 계산하는 경우. 그동안 나머지 기능은 "멈춥니다" — 버튼이 눌리지 않고, 애니메이션이 움직이지 않으며, 팝업에 "응답 없음"이라는 문구가 뜹니다.
멀티스레딩을 사용하면 애플리케이션이 여러 일을 동시에 할 수 있습니다: UI는 반응성을 유지하고, 작업들은 병렬로 실행되며 우리는 "컴퓨터, 또 멈췄어?!" 같은 독백을 하지 않아도 됩니다.
기본 개념과 용어
본격적으로 들어가기 전에 스레드(thread)가 무엇이고 프로세스와 어떻게 다른지 정리합시다.
- 프로세스 (Process): 고유한 주소 공간, 변수, 리소스를 가진 독립된 프로그램입니다. 예를 들어 Windows에서 실행되는 각 애플리케이션은 별도의 프로세스입니다.
- 스레드 (Thread): 프로세스 내부의 실행 단위입니다. 프로세스는 하나 이상의 스레드를 가질 수 있고, 이 스레드들은 동일한 리소스(메모리, 변수)를 공유합니다.
왜 멀티스레딩이 많은 의문을 불러일으킬까?
스레드는 예측 불가능한 녀석들입니다: 언제든 실행을 시작할 수 있고, 데이터를 섞어놓고, 서로를 중간에 끊어버리며 심지어 메모리에서 대혼란을 일으킬 수 있습니다. 마치 교사가 없는 유치원 같다고 느껴진다면 그게 맞습니다! 규율과 스레드 관리가 튼튼해야만 신뢰할 수 있는 멀티스레드 프로그램을 만들 수 있습니다.
2. C#과 .NET에서 멀티스레딩의 역사와 역할
초창기 C#은 단일 스레드였고 프로그램도 단순했습니다. 성능 요구가 커지고, 멀티코어 CPU가 등장하며 UI가 멈추지 않는 애플리케이션 필요성이 생기면서 .NET에 멀티스레딩 도구들이 추가되었습니다. 처음에는 고전적 클래스인 System.Threading.Thread가 있었고, 나중에는 Task(Task), 비동기 메서드(async/await), 데이터 병렬 처리(PLINQ)와 고수준 동기화 프리미티브들이 추가됐습니다.
C#은 멀티스레딩이 낯설지 않은 강력한 플랫폼으로 성장했습니다.
시각적으로: 프로세스와 스레드
간단한 다이어그램은 다음과 같습니다:
+--------------------------------------------------+
| 프로세스 (당신의 프로그램) |
| +-------------+ +-------------+ |
| | 스레드 1 | | 스레드 2 | |
| +-------------+ +-------------+ |
| ... |
| +-------------+ |
| | 스레드 N | |
| +-------------+ |
+--------------------------------------------------+
프로세스 내부의 모든 스레드는 공통 변수와 리소스를 봅니다.
3. C#에서 스레드를 만드는 방법
기본 중의 기본부터 시작합시다: System.Threading 네임스페이스의 Thread 클래스입니다.
예제: 두 번째 스레드 실행하기
예를 들어 1부터 10_000_000까지의 합을 계산하는 오래 걸리는 작업이 있다고 합시다. 계산이 진행되는 동안 메인 스레드는 사용자에게 인사 메시지를 출력합니다.
using System;
using System.Threading;
class Program
{
// 두 번째 작업을 위한 메서드
static void CalculateSum()
{
long sum = 0;
for (int i = 1; i <= 10_000_000; i++)
sum += i;
Console.WriteLine($"[스레드 2] 합계: {sum}");
}
static void Main()
{
// 스레드를 만들고 메서드에 대한 델리게이트를 지정
Thread thread = new Thread(CalculateSum);
thread.Start(); // 두 번째 스레드 시작
// 메인 스레드는 계속 실행
Console.WriteLine("[스레드 1] 안녕! 병렬로 일하고 있어...");
// 종료 전에 두 번째 스레드가 끝날 때까지 기다림
thread.Join();
Console.WriteLine("[스레드 1] 모두 완료!");
}
}
무슨 일이 일어날까?
화면에는 먼저 "[스레드 1] 안녕! 병렬로 일하고 있어..."가 나오고, 계산 스레드가 끝나면 합계가 출력됩니다.
전형적인 문제: 누가 먼저 출력하나
이 코드를 여러 번 실행해보면 출력 순서가 달라질 수 있습니다! 때로는 합계가 먼저 나오고, 때로는 인사말이 먼저 나옵니다. 이게 바로 멀티스레딩의 본질 — 프로그램이 덜 예측 가능해집니다, 마치 월요일 아침 고양이 기분 같죠.
4. 메모리 영역: 스레드가 무엇을 볼까?
프로세스 내의 모든 스레드는 동일한 변수에 접근할 수 있습니다(메서드 로컬 변수가 아니면). 한 스레드에서 변수를 변경하면 다른 스레드들도 그 변경을 보게 됩니다!
예제: 공유 변수
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 1000; i++)
counter++;
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"counter = {counter}");
}
}
counter에 어떤 값이 나올 거라고 기대하나요? 논리적으로는 각 스레드가 1000번씩 증가시키니 2000이 되어야 합니다.
하지만 그렇지 않을 수 있습니다! 여러 번 실행해보면 1782, 1935, 1999 같은 다른 값들이 나올 겁니다.
왜 그럴까요? 이것은 전형적인 Race Condition 문제입니다 — 스레드들이 읽기 -> 증가 -> 쓰기 사이에서 제어를 뺏기고 일부 증가가 사라지는 거죠.
5. 유용한 뉘앙스
스레드는 UI와 어떻게 상호작용하나?
현대의 데스크탑 애플리케이션(WinForms/WPF/MAUI)에서는 메인 스레드가 GUI를 담당합니다. 사용자 동작(클릭, 입력)은 이 스레드에서 처리됩니다. 백그라운드 작업은 다른 스레드에서 실행되어야 하지만 규칙상 다른 스레드에서 직접 UI를 "건드리면" 안 됩니다. 이는 혼란을 막기 위한 설계입니다.
콘솔 애플리케이션에는 이런 제약이 없어서 어느 스레드에서든 Console.WriteLine을 호출할 수 있습니다. 하지만 실제 애플리케이션에서는 적절한 동기화 없이는 UI가 엉망이 될 수 있습니다.
순차성과 병렬성
차이를 정리한 표입니다.
| 단일 스레드 코드 | 멀티스레드 코드 |
|---|---|
| 작업을 순서대로 수행 | 작업을 동시에 수행할 수 있음 |
| 긴 작업 동안 UI가 "멈춤" | UI는 반응성을 유지함 |
| 변수를 읽고 쓰기 쉬움 | 데이터 접근 제어가 필요함 |
| 디버깅이 쉬움 | 디버깅이 어려울 수 있음 |
스레드 작업 시 중요한 점
- 공유 변수는 공통의 위험. 위에서 보았듯 여러 스레드가 같은 변수를 사용하면 동기화 없이는 오류가 발생할 수 있습니다! (자세한 내용은 다음 강의에서.)
- 스레드는 Join()으로 "기다릴" 수 있다. Join() 메서드는 메인 스레드를 백그라운드 스레드가 끝날 때까지 "일시 중단"하게 해줍니다. 결과를 기다려야 할 때 사용하세요.
- 스레드는 두 번 시작할 수 없다. 한 번 실행된 스레드는 다시 Start할 수 없습니다 — 새 Thread 객체를 만들어야 합니다.
- 스레드 종료. 스레드는 그 메서드 실행이 끝나면 종료됩니다. 강제로 스레드를 "죽이는" 것은 좋지 않은 아이디어입니다(예: Abort()는 오래전부터 권장되지 않습니다).
멀티스레딩이 실제로 필요한 경우
- UI 애플리케이션: 백그라운드 로드나 계산 중에 인터페이스가 멈추지 않게 하기 위해.
- 서버와 서비스: 동시에 많은 클라이언트 요청을 처리하기 위해.
- 고성능 계산: 큰 작업(예: 수백만 레코드 처리)을 나눠 병렬로 수행하기 위해.
- 게임, 시뮬레이션, 데이터 처리: 복잡한 시스템을 모델링하면서 성능을 유지하기 위해.
멀티스레딩의 문제들
멀티스레딩은 강력하지만 복잡함을 더합니다:
- Race Condition (레이스 컨디션): 여러 스레드가 동시에 같은 데이터를 변경하면 결과가 명령어 순서에 따라 달라지고 예측 불가능해집니다.
- Deadlock (교착 상태): 스레드들이 서로를 기다리느라 아무도 진행할 수 없는 상황.
- Starvation (기아 상태): 어떤 스레드는 자원 접근에서 계속 배제되어 실행할 기회를 거의 못 받는 상황.
이 강의에서는 멀티스레딩의 주요 문제들을 소개만 했고, 다음 강의들에서 이를 인식하고 피하는 방법을 배울 것입니다. 당분간은 모든 게 "멈춘다"면 버그뿐 아니라 스레드들이 "파티"를 벌이고 있을 수도 있다는 걸 기억하세요.
GO TO FULL VERSION