C# 고급 문법, 클래스부터 LINQ까지
목차
C#에서 데이터를 묶고, 여러 개를 관리하고, 오류에 대비하는 문법을 정리합니다. 클래스와 속성, 상속과 인터페이스, List와 Dictionary, 예외 처리, 람다와 LINQ를 예제로 실행해 봅니다. 마지막에는 지난 글의 성적 계산기를 클래스와 List, LINQ로 다시 만듭니다.
이 글은 C# 기본 문법, 변수부터 메서드까지에서 이어집니다. 지난 글처럼 콘솔 앱 프로젝트의 Program.cs 내용을 예제 코드로 바꾸고, Ctrl+F5(디버그하지 않고 시작, Start Without Debugging)로 실행합니다.
이 글의 예제는 클래스까지 Program.cs 한 파일에 씁니다. 이때 클래스는 실행 코드의 아래에 둡니다. 클래스를 별도 파일로 나누는 방법은 다음 글에서 봅니다.
실행 결과는 .NET 10 SDK로 직접 빌드하고 실행해서 얻은 출력입니다. Visual Studio에서 Ctrl+F5로 실행하면 콘솔 창 끝에 종료 안내 문구가 더 붙을 수 있습니다. 한글과 → 같은 기호는 콘솔에 출력되며, 보이는 모양은 콘솔 글꼴에 따라 조금 다를 수 있습니다. 한국어 Windows에서 런타임 예외 메시지는 영어, 빌드 오류 메시지는 한국어로 나왔습니다.
1. 클래스·객체·속성

지난 글의 성적 계산기는 이름과 점수를 배열 두 개에 따로 담았습니다. 클래스를 쓰면 서로 관련된 데이터와 기능을 하나로 묶을 수 있습니다.
클래스와 객체
클래스는 설계도이고, 객체는 그 설계도로 만든 실제 물건입니다. 설계도 하나로 물건을 여러 개 만들 수 있습니다.
// 클래스와 객체
// 클래스 = 설계도, 객체 = 설계도로 만든 실제 물건
Student s1 = new Student("철수", 3);
Student s2 = new Student("영희", 1);
s1.Introduce();
s2.Introduce();
s2.Grade = 2; // 필드 값 바꾸기
s2.Introduce();
Console.WriteLine("만든 학생 수: " + Student.Count);
// ── 클래스 정의는 최상위 문(실행 코드) 아래에 둡니다 ──
class Student
{
// 필드: 객체가 가진 데이터
public string Name;
public int Grade;
// static 필드: 객체마다가 아니라 클래스에 하나만 있습니다
public static int Count = 0;
// 생성자: new 할 때 실행됩니다
public Student(string name, int grade)
{
Name = name;
Grade = grade;
Count++;
}
// 메서드: 객체가 하는 일
public void Introduce()
{
Console.WriteLine($"안녕하세요, {Grade}학년 {Name}입니다.");
}
}
실행 결과입니다.
안녕하세요, 3학년 철수입니다.
안녕하세요, 1학년 영희입니다.
안녕하세요, 2학년 영희입니다.
만든 학생 수: 2
- 설계도와 물건:
class Student { ... }가 설계도입니다.new Student("철수", 3)처럼new를 쓰면 객체가 만들어집니다.s1과s2는 서로 다른 객체라서 이름과 학년이 따로 있습니다. - 필드:
Name,Grade처럼 객체가 가진 데이터입니다.s2.Grade = 2;는s2의 학년만 바꿉니다. - 생성자: 클래스 이름과 같은 이름의 메서드입니다.
new할 때 실행되고, 받은 값으로 필드를 채웁니다. - 메서드:
Introduce처럼 객체가 하는 일입니다.s1.Introduce()처럼 객체 이름 뒤에 점(.)을 찍어 부릅니다. - static:
Count는 객체마다 있는 것이 아니라 클래스에 하나만 있습니다. 생성자가 실행될 때마다 1씩 늘어서 2가 됐습니다. 그래서Student.Count처럼 클래스 이름으로 읽습니다. - public: 클래스 밖에서도 쓸 수 있다는 뜻입니다. 이 예제의
public필드는 설명을 위한 것입니다. 보통은 다음에 볼 속성을 씁니다.
속성과 접근 제한자
// 속성(프로퍼티)과 접근 제한자
Product p = new Product("키보드", 30000);
Console.WriteLine($"{p.Name}: {p.Price}원");
p.Price = 35000;
Console.WriteLine($"가격 변경 → {p.Price}원");
p.Price = -100; // 잘못된 값은 속성 안에서 막습니다
Console.WriteLine($"음수 입력 후 → {p.Price}원");
p.Stock = 5;
Console.WriteLine($"재고 {p.Stock}개, 재고 금액 {p.TotalValue}원");
// p.price = 0; // private 필드라 밖에서 쓰면 컴파일 오류(CS0122)
// p.Name = "마우스"; // set이 없는 읽기 전용 속성이라 컴파일 오류(CS0200)
class Product
{
// private: 클래스 안에서만 쓸 수 있습니다
private int price;
// 자동 구현 속성 (get만 있으면 생성자에서만 값을 넣을 수 있습니다)
public string Name { get; }
// 자동 구현 속성 (읽기·쓰기)
public int Stock { get; set; }
// 직접 구현한 속성: 값을 넣을 때 검사합니다
public int Price
{
get { return price; }
set
{
if (value < 0)
{
Console.WriteLine("가격은 0원 이상이어야 합니다.");
return;
}
price = value;
}
}
// 계산해서 돌려주는 읽기 전용 속성
public int TotalValue => Price * Stock;
public Product(string name, int price)
{
Name = name;
Price = price;
}
}
실행 결과입니다.
키보드: 30000원
가격 변경 → 35000원
가격은 0원 이상이어야 합니다.
음수 입력 후 → 35000원
재고 5개, 재고 금액 175000원
- private:
private int price;는 클래스 안에서만 쓸 수 있는 필드입니다. 밖에서는Price속성을 거쳐서만 값을 바꿉니다. - get만 있는 속성:
Name { get; }은 읽기만 할 수 있습니다. 값은 생성자에서만 넣을 수 있습니다. - get과 set이 있는 속성:
Stock { get; set; }은 읽고 쓸 수 있습니다. 이렇게 짧게 쓰는 속성을 자동 구현 속성이라고 합니다. - 값을 검사하는 속성:
Price는get과set을 직접 썼습니다.set은 값을 넣을 때 실행되고,value가 넣으려는 값입니다. -100은 0보다 작아서 막혔고, 가격은 35000원 그대로입니다. - 계산하는 속성:
TotalValue => Price * Stock은 읽을 때마다 계산해서 돌려주는 읽기 전용 속성입니다.
필드와 속성의 가장 큰 차이는 set에서 값을 검사할 수 있다는 점입니다. 나중에 Windows Forms에서 쓰는 표(DataGridView)도 클래스의 public 속성을 열로 만듭니다. 그래서 지금부터 데이터는 속성으로 쓰는 습관을 들이면 좋습니다.
예제 아래쪽에 주석으로 막아 둔 두 줄의 주석을 풀면 빌드가 실패합니다.
p.price = 0; // private 필드라 밖에서 쓰면 컴파일 오류(CS0122)
p.Name = "마우스"; // set이 없는 읽기 전용 속성이라 컴파일 오류(CS0200)
Program.cs(14,3): error CS0122: 'Product.price' is inaccessible due to its protection level
Program.cs(15,1): error CS0200: Property or indexer 'Product.Name' cannot be assigned to -- it is read only
명령줄에서 빌드해 받은 실제 메시지이고, 앞쪽 폴더 경로와 끝의 프로젝트 파일 경로만 줄였습니다. CS0122는 보호 수준(private) 때문에 밖에서 쓸 수 없다는 뜻이고, CS0200은 읽기 전용이라 값을 넣을 수 없다는 뜻입니다.
헷갈리기 쉬운 점
- 클래스는 실행 코드 아래에 둡니다. 이 글처럼 한 파일에 쓸 때
class Student를 실행 코드보다 위에 두면 빌드가 실패합니다. 직접 빌드해 보니error CS8803: Top-level statements must precede namespace and type declarations.가 나왔습니다. - 클래스와 객체는 다릅니다. 클래스는 하나지만 객체는 여러 개 만들 수 있습니다.
s2의 학년을 바꿔도s1은 그대로입니다. - static 필드는 클래스 이름으로 읽습니다. 객체마다 있는 값이 아니기 때문에
Student.Count처럼 씁니다. - 필드와 속성은 쓰는 모양이 같습니다.
p.Stock = 5;처럼 둘 다 점(.)으로 읽고 씁니다. 차이는 속성에는get과set이 있어서 값을 검사하거나 계산할 수 있다는 점입니다.
2. 상속과 인터페이스

상속: 부모의 기능 물려받기
상속은 이미 있는 클래스(부모)의 기능을 새 클래스(자식)가 물려받는 것입니다.
// 상속: 부모 클래스의 기능을 물려받기
Animal[] animals =
{
new Dog("바둑이"),
new Cat("나비"),
new Dog("초코")
};
foreach (Animal a in animals)
{
a.Speak(); // 같은 메서드를 불러도 실제 객체에 따라 다르게 동작합니다
}
Dog d = new Dog("뽀삐");
d.Eat(); // 부모(Animal)에게 물려받은 메서드
d.Fetch(); // Dog에만 있는 메서드
class Animal
{
public string Name { get; }
public Animal(string name)
{
Name = name;
}
public void Eat()
{
Console.WriteLine($"{Name}이(가) 밥을 먹습니다.");
}
// virtual: 자식 클래스가 바꿔 쓸 수 있습니다
public virtual void Speak()
{
Console.WriteLine($"{Name}: ...");
}
}
// Dog는 Animal을 상속합니다
class Dog : Animal
{
// base(name): 부모 생성자에 이름을 넘깁니다
public Dog(string name) : base(name)
{
}
// override: 부모의 Speak를 다시 정의합니다
public override void Speak()
{
Console.WriteLine($"{Name}: 멍멍!");
}
public void Fetch()
{
Console.WriteLine($"{Name}이(가) 공을 물어 옵니다.");
}
}
class Cat : Animal
{
public Cat(string name) : base(name)
{
}
public override void Speak()
{
Console.WriteLine($"{Name}: 야옹~");
}
}
실행 결과입니다.
바둑이: 멍멍!
나비: 야옹~
초코: 멍멍!
뽀삐이(가) 밥을 먹습니다.
뽀삐이(가) 공을 물어 옵니다.
- 상속하는 법:
class Dog : Animal은 "Dog는 Animal을 상속한다"는 뜻입니다. Animal이 부모, Dog가 자식입니다. - 물려받기:
Eat()은 Animal에만 적혀 있지만 Dog 객체d에서 부를 수 있습니다. - 바꿔 쓰기: 부모 메서드에
virtual을 붙이면 자식이 바꿔 쓸 수 있습니다. 자식은override를 붙여 다시 정의합니다. - 부모 생성자 부르기:
: base(name)은 부모(Animal)의 생성자에 이름을 넘깁니다. - 부모 타입으로 함께 다루기:
Animal[]배열에 Dog와 Cat을 함께 넣었습니다.a.Speak()는 같은 한 줄인데, 실제 객체가 Dog면 "멍멍!", Cat이면 "야옹~"이 나왔습니다. - 자식만의 기능:
Fetch()는 Dog에만 있는 메서드입니다.
나중에 Windows Forms로 화면 프로그램을 만들 때 나오는 Form1도 Form을 상속한 클래스입니다.
인터페이스: 꼭 만들 기능의 약속
인터페이스는 "이런 기능을 꼭 만들어야 한다"는 약속입니다.
// 인터페이스: "이런 기능이 꼭 있어야 한다"는 약속
IShape[] shapes =
{
new Circle(3),
new Rectangle(4, 5),
new Circle(1.5)
};
double total = 0;
foreach (IShape shape in shapes)
{
Console.WriteLine($"{shape.Name} 넓이: {shape.GetArea():F2}");
total += shape.GetArea();
}
Console.WriteLine($"전체 넓이: {total:F2}");
// 인터페이스 이름은 보통 I로 시작합니다
interface IShape
{
string Name { get; }
double GetArea();
}
// 인터페이스를 구현하는 클래스는 약속한 멤버를 모두 만들어야 합니다
class Circle : IShape
{
private double radius;
public Circle(double radius)
{
this.radius = radius;
}
public string Name => "원";
public double GetArea()
{
return Math.PI * radius * radius;
}
}
class Rectangle : IShape
{
private double width;
private double height;
public Rectangle(double width, double height)
{
this.width = width;
this.height = height;
}
public string Name => "직사각형";
public double GetArea()
{
return width * height;
}
}
실행 결과입니다.
원 넓이: 28.27
직사각형 넓이: 20.00
원 넓이: 7.07
전체 넓이: 55.34
- 인터페이스 만들기:
interface IShape안에는 속성Name과 메서드GetArea()의 모양만 있고 내용은 없습니다. 인터페이스 이름은 보통I로 시작합니다. - 구현하기:
class Circle : IShape는 IShape의 약속을 지키는 클래스입니다. 약속한Name과GetArea()를 모두 만들어야 합니다. - 함께 다루기:
IShape[]배열에 Circle과 Rectangle을 함께 넣고,shape.GetArea()를 부르면 각 클래스의 계산 방법으로 넓이를 구합니다. - this:
this.radius = radius;의this.radius는 "이 객체의 필드"이고, 오른쪽radius는 생성자의 매개변수입니다. 이름이 같을 때 이렇게 구분합니다. - F2:
{shape.GetArea():F2}는 소수 둘째 자리까지 출력합니다. 원의 넓이는Math.PI(원주율)로 계산했습니다.
약속한 멤버를 빼먹으면 어떻게 될까요? Circle에서 GetArea() 메서드를 지우고 빌드해 봤습니다.
class Circle : IShape
{
private double radius;
public Circle(double radius)
{
this.radius = radius;
}
public string Name => "원";
}
Program.cs(25,16): error CS0535: 'Circle' does not implement interface member 'IShape.GetArea()'
"Circle이 IShape의 GetArea()를 구현하지 않았다"는 뜻입니다. 이렇게 인터페이스는 빠진 기능을 빌드할 때 알려 줍니다(경로는 줄였습니다).
상속과 인터페이스 비교
| 구분 | 상속 | 인터페이스 |
|---|---|---|
| 받는 것 | 부모의 기능을 물려받음 | 만들어야 할 멤버를 약속함 |
| 몇 개까지 | 부모 클래스는 하나만 | 여러 개 구현 가능 |
| 예 | Dog : Animal |
Circle : IShape |
헷갈리기 쉬운 점
- 상속과 인터페이스 모두 콜론(:)을 씁니다.
Dog : Animal은 상속이고Circle : IShape는 인터페이스 구현입니다. 이름이I로 시작하면 보통 인터페이스입니다. - virtual과 override는 짝입니다. 부모는
virtual로 바꿔 써도 된다고 허락하고, 자식은override로 바꿔 씁니다. - 부모는 하나뿐입니다. 클래스는 부모 클래스를 하나만 가질 수 있습니다. 인터페이스는 여러 개 구현할 수 있습니다.
- "뽀삐이(가)"는 오류가 아닙니다. 코드에
$"{Name}이(가) ..."라고 적은 글자가 그대로 나온 것입니다. 받침이 있는 이름 뒤에서는 어색해 보입니다. 이름에 맞는 조사를 고르는 코드는 나중에 DLL을 만드는 글에서 다뤄 볼 예정입니다.
3. List와 Dictionary
![List와 Dictionary 그림(번호로 찾기 vs 키로 찾기). 위쪽은 List<string>의 번호 칸 [0] 사과, [1] 딸기, [2] 포도(fruits[0] → 사과, 번호는 0부터)와 Dictionary의 키 → 값 칸 사과 → 12, 포도 → 7(stock['사과'] → 12, 이름표(키)로 찾음). 가운데는 자주 쓰는 기능 표(L = List, D = Dictionary): 추가 fruits.Add('사과'); / stock.Add('사과', 10);, 읽기 fruits[0] / stock['사과'], 개수 fruits.Count / stock.Count, 있나? fruits.Contains('포도') / stock.ContainsKey('수박'), 지우기 fruits.Remove('바나나'); / stock.Remove('배');, 넣거나 바꾸기 stock['포도'] = 7;, 그리고 「List 개수는 Count(배열은 Length)」 메모. 아래는 Dictionary 주의 세 가지: 없는 키 stock['수박']을 바로 읽으면 KeyNotFoundException → ContainsKey나 TryGetValue로 먼저 확인, 이미 있는 키를 stock.Add('사과', 1)로 Add하면 ArgumentException → stock['포도'] = 7; 처럼 대입, foreach로 꺼내는 순서는 정해져 있지 않음(공식 문서) → 순서가 필요하면 정렬해서 출력](https://blog.kakaocdn.net/dna/d4PH5i/dJMcaaG9W6g/AAAAAAAAAAAAAAAAAAAAAC9YQkQadtulvS7Q7P9C8UUytK7McgQZt9YZLwF3kjVG/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1793458799&allow_ip=&allow_referer=&signature=mU4CSszQj%2FqAB%2BeUtp064zY%2FgEw%3D)
배열은 만들 때 크기가 정해집니다. 개수가 늘었다 줄었다 하는 목록에는 List를, 이름표(키)로 값을 찾을 때는 Dictionary를 씁니다.
List: 크기가 늘어나는 목록
// List<T>: 크기가 자동으로 늘어나는 목록
List<string> fruits = new List<string>();
fruits.Add("사과");
fruits.Add("바나나");
fruits.Add("포도");
Console.WriteLine("개수: " + fruits.Count);
Console.WriteLine("첫 번째: " + fruits[0]);
fruits.Insert(1, "딸기"); // 1번 자리에 끼워 넣기
fruits.Remove("바나나"); // 값으로 지우기
Console.WriteLine("목록: " + string.Join(", ", fruits));
Console.WriteLine("포도가 있나요? " + fruits.Contains("포도"));
fruits.Sort();
Console.WriteLine("정렬: " + string.Join(", ", fruits));
// 처음부터 값을 넣어 만들기
List<int> numbers = new List<int> { 5, 3, 8, 1 };
numbers.Add(10);
int sum = 0;
foreach (int n in numbers)
{
sum += n;
}
Console.WriteLine($"numbers 합계: {sum}");
// 클래스 객체도 담을 수 있습니다
List<Member> members = new List<Member>();
members.Add(new Member("철수", 20));
members.Add(new Member("영희", 22));
foreach (Member m in members)
{
Console.WriteLine($"{m.Name} ({m.Age}세)");
}
class Member
{
public string Name { get; set; }
public int Age { get; set; }
public Member(string name, int age)
{
Name = name;
Age = age;
}
}
실행 결과입니다.
개수: 3
첫 번째: 사과
목록: 사과, 딸기, 포도
포도가 있나요? True
정렬: 딸기, 사과, 포도
numbers 합계: 27
철수 (20세)
영희 (22세)
- 만들기:
List<string>의< >안에는 담을 자료형을 씁니다.List<int>는 정수 목록입니다. - 추가와 개수:
Add로 값을 계속 넣을 수 있습니다. 개수는Count입니다. - 번호로 읽기:
fruits[0]처럼 배열과 같이 0부터 시작하는 번호로 읽습니다. - 끼워 넣기와 지우기:
Insert(1, "딸기")는 1번 자리에 끼워 넣고,Remove("바나나")는 그 값을 찾아 지웁니다. - 확인과 정렬:
Contains는 값이 있는지 알려 주고,Sort는 정렬합니다. 한국어 문화권(ko-KR) 설정으로 실행해서 가나다순(딸기, 사과, 포도)으로 나왔습니다. - 처음 값 넣기:
new List<int> { 5, 3, 8, 1 }처럼 만들 때 값을 바로 넣을 수 있습니다. - 객체 담기:
List<Member>처럼 직접 만든 클래스의 객체도 담을 수 있습니다.
나중에 Windows Forms에서 표를 다룰 때는 List 대신 BindingList를 씁니다. 쓰는 법은 거의 같습니다.
Dictionary: 키로 값을 찾는 사전
// Dictionary<TKey, TValue>: 키로 값을 찾는 사전
Dictionary<string, int> stock = new Dictionary<string, int>();
stock.Add("사과", 10);
stock.Add("배", 5);
stock["포도"] = 7; // 없는 키면 새로 추가
stock["사과"] = 12; // 있는 키면 값 바꾸기
Console.WriteLine("사과 재고: " + stock["사과"]);
Console.WriteLine("항목 수: " + stock.Count);
// 키가 있는지 먼저 확인하기
if (stock.ContainsKey("수박"))
{
Console.WriteLine("수박 재고: " + stock["수박"]);
}
else
{
Console.WriteLine("수박은 없습니다.");
}
// TryGetValue: 확인과 꺼내기를 한 번에
if (stock.TryGetValue("배", out int pearCount))
{
Console.WriteLine("배 재고: " + pearCount);
}
stock.Remove("배");
// 전체 출력
foreach (KeyValuePair<string, int> item in stock)
{
Console.WriteLine($"{item.Key}: {item.Value}개");
}
// stock["수박"]처럼 없는 키를 바로 읽으면 KeyNotFoundException이 납니다
// stock.Add("사과", 1)처럼 이미 있는 키를 Add하면 ArgumentException이 납니다
실행 결과입니다.
사과 재고: 12
항목 수: 3
수박은 없습니다.
배 재고: 5
사과: 12개
포도: 7개
- 만들기:
Dictionary<string, int>는 키가string, 값이int인 사전입니다. - 넣기:
Add("사과", 10)으로 넣습니다.stock["포도"] = 7처럼 대괄호에 키를 쓰고 대입하면, 없는 키는 새로 추가하고 있는 키는 값을 바꿉니다. 그래서 사과는 12가 됐습니다. - 확인하기:
ContainsKey는 키가 있는지 알려 줍니다. - 확인하고 꺼내기:
TryGetValue는 키가 있으면true를 돌려주고 값을out변수에 넣습니다. 지난 글의int.TryParse와 쓰는 모양이 비슷합니다. - 지우기:
Remove("배")로 키와 값을 함께 지웁니다. - 전체 꺼내기:
foreach로 꺼내면 한 항목이KeyValuePair이고,Key와Value로 키와 값을 읽습니다.
헷갈리기 쉬운 점
- 개수는 Count입니다. 배열은
Length, List와 Dictionary는Count를 씁니다. - 없는 키를 바로 읽으면 예외가 납니다. 예제 맨 아래 주석처럼 없는 키
"수박"을stock["수박"]으로 바로 읽어 봤습니다.KeyNotFoundException이 나고 프로그램이 멈췄습니다. 메시지는 "The given key '수박' was not present in the dictionary."였습니다. 그래서ContainsKey나TryGetValue로 먼저 확인합니다. - 이미 있는 키를 Add하면 예외가 납니다.
stock.Add("사과", 1);을 실행하면ArgumentException이 나고, 메시지는 "An item with the same key has already been added. Key: 사과"였습니다. 값을 바꿀 때는stock["포도"] = 7;처럼 대입합니다. - Dictionary의 순서를 믿지 않습니다. 이번 실행에서는 넣은 순서대로 나왔습니다. 하지만 공식 문서는 항목이 나오는 순서가 정해져 있지 않다고 설명합니다. 순서가 필요하면 정렬해서 출력합니다. 종합 예제에서 그렇게 합니다.
4. 예외 처리
![예외 처리 그림(오류가 나도 프로그램이 멈추지 않게). 위쪽은 try → catch → finally 흐름: int[] data = { 1, 2, 3 };에서 try 블록의 Console.WriteLine(data[5]);가 예외 발생(예외가 나면 아래 줄은 건너뜀) → catch (IndexOutOfRangeException)에서 「배열 범위를 벗어났습니다.」 → 그다음 항상 finally에서 「finally는 오류가 있든 없든 항상 실행됩니다.」, 예외가 없으면 catch는 건너뛰고 finally만 실행한다는 메모. 가운데는 자주 만나는 예외 네 가지 카드: FormatException(int.Parse('열두') → 숫자 형식이 아닙니다.), DivideByZeroException(b가 0일 때 a / b → 0으로 나눌 수 없습니다.), IndexOutOfRangeException(data[5] → 배열 범위를 벗어났습니다.), OverflowException(int.Parse('99999999999') → 예외 종류: OverflowException)과 「catch (Exception ex)는 모든 예외를 받음(Exception = 모든 예외의 부모)」. 아래는 throw 직접 예외 던지기: Withdraw(10000, 30000);, catch (InvalidOperationException ex), Console.WriteLine('오류: ' + ex.Message); → 오류: 잔액이 부족합니다.(Withdraw 안에서 잔액보다 많으면 throw), 입력 검사는 예외보다 TryParse가 먼저(지난 글). 친구가 「오류 나면 그냥 꺼지면 안 돼?」라고 묻고 선배가 「catch로 받으면 끝까지 실행돼!」라고 답하며, 맨 아래 「→ 프로그램이 끝까지 실행되었습니다.」](https://blog.kakaocdn.net/dna/q30CL/dJMcahTxN9f/AAAAAAAAAAAAAAAAAAAAANNflenywrT6ZXuYlVLgaqR0ISJv1qoU4JkVoR5T2ZG-/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1793458799&allow_ip=&allow_referer=&signature=eDqcmEVzBYSDUUK%2FpJZJpeg3pKc%3D)
지난 글에서 int.Parse("abc")가 프로그램을 멈추는 것을 봤습니다. 이렇게 실행 중에 생기는 문제를 예외라고 합니다. 예외를 처리하면 프로그램이 멈추지 않고 계속 실행됩니다.
try, catch, finally
// 예외 처리: 실행 중 오류가 나도 프로그램이 멈추지 않게 하기
// 1) 숫자로 바꿀 수 없는 문자열
try
{
int n = int.Parse("열두");
Console.WriteLine(n);
}
catch (FormatException)
{
Console.WriteLine("숫자 형식이 아닙니다.");
}
// 2) 0으로 나누기
try
{
int a = 10;
int b = 0;
Console.WriteLine(a / b);
}
catch (DivideByZeroException)
{
Console.WriteLine("0으로 나눌 수 없습니다.");
}
// 3) 배열 범위를 벗어남 + finally
int[] data = { 1, 2, 3 };
try
{
Console.WriteLine(data[5]);
}
catch (IndexOutOfRangeException)
{
Console.WriteLine("배열 범위를 벗어났습니다.");
}
finally
{
Console.WriteLine("finally는 오류가 있든 없든 항상 실행됩니다.");
}
실행 결과입니다.
숫자 형식이 아닙니다.
0으로 나눌 수 없습니다.
배열 범위를 벗어났습니다.
finally는 오류가 있든 없든 항상 실행됩니다.
- try: 예외가 날 수 있는 코드를 넣습니다. 안에서 예외가 나면 그 아래 줄은 건너뛰고
catch로 갑니다. 그래서 1)의Console.WriteLine(n);은 실행되지 않았습니다. - catch: 괄호 안에 받을 예외의 종류를 씁니다.
catch (FormatException)은 형식 오류만 받습니다. - finally: 예외가 나든 안 나든 마지막에 항상 실행됩니다. 예외가 없으면
catch는 건너뛰고finally만 실행됩니다.
자주 만나는 예외
| 예외 | 언제 나나 | 기본 메시지 |
|---|---|---|
FormatException |
int.Parse("열두")처럼 숫자 형식이 아닐 때 |
The input string '열두' was not in a correct format. |
DivideByZeroException |
정수를 0으로 나눌 때 | Attempted to divide by zero. |
IndexOutOfRangeException |
배열 범위 밖의 번호를 쓸 때 | Index was outside the bounds of the array. |
OverflowException |
int.Parse("99999999999")처럼 int 범위를 넘을 때 |
Value was either too large or too small for an Int32. |
기본 메시지는 예외의 Message를 따로 출력해서 확인한 것입니다. 한국어 Windows(ko-KR)에서도 영어로 나왔습니다.
throw로 직접 예외 던지기
// 예외 처리: 실행 중 오류가 나도 프로그램이 멈추지 않게 하기
// 4) 직접 예외 던지기(throw)와 받기
try
{
Withdraw(10000, 30000);
}
catch (InvalidOperationException ex)
{
Console.WriteLine("오류: " + ex.Message);
}
// 5) 어떤 예외든 받기 (Exception은 모든 예외의 부모)
try
{
int big = int.Parse("99999999999"); // int 범위를 넘는 수
Console.WriteLine(big);
}
catch (Exception ex)
{
Console.WriteLine("예외 종류: " + ex.GetType().Name);
}
Console.WriteLine("프로그램이 끝까지 실행되었습니다.");
int Withdraw(int balance, int amount)
{
if (amount > balance)
{
throw new InvalidOperationException("잔액이 부족합니다.");
}
return balance - amount;
}
실행 결과입니다.
오류: 잔액이 부족합니다.
예외 종류: OverflowException
프로그램이 끝까지 실행되었습니다.
- throw:
throw new InvalidOperationException("잔액이 부족합니다.")처럼 직접 예외를 만들어 던질 수 있습니다.Withdraw메서드 안에서 던진 예외를, 메서드를 부른 쪽의catch가 받았습니다. - ex.Message:
catch (InvalidOperationException ex)처럼 변수 이름을 붙이면 예외의 정보를 쓸 수 있습니다. 직접 만든 메시지는 적은 그대로 한국어로 나옵니다. - Exception: 모든 예외의 부모입니다.
catch (Exception ex)는 어떤 예외든 받습니다.ex.GetType().Name으로 실제 예외 종류를 확인했더니OverflowException이었습니다. - 예외를 모두 받았기 때문에 마지막 줄 "프로그램이 끝까지 실행되었습니다."까지 출력됐습니다.
나중에 Windows Forms로 통장 프로그램을 만들 때, 잔액보다 많이 출금하면 이렇게 예외를 던지는 방식을 씁니다.
헷갈리기 쉬운 점
- 입력 검사는 TryParse가 먼저입니다. 숫자가 아닐 수 있는 입력은 지난 글의
int.TryParse로 검사합니다. 예외 처리는 정말 예상하지 못한 상황에 대비할 때 씁니다. - try 안에서 예외가 난 줄의 아래는 실행되지 않습니다. 실행되지 않으면 곤란한 정리 작업은
finally에 넣습니다. - catch에는 받을 예외의 종류를 적습니다.
catch (FormatException)은 형식 오류만 받습니다. 예제처럼 예외마다catch를 나누면 종류마다 다른 메시지를 보여 줄 수 있습니다. - 직접 만든 메시지와 기본 메시지는 다릅니다.
throw할 때 적은 "잔액이 부족합니다."는 그대로 나오고, .NET이 만든 기본 메시지는 위 표처럼 영어로 나왔습니다.
5. 람다와 LINQ 기초

람다식: 이름 없는 짧은 메서드
// 람다식: 이름 없는 짧은 메서드 (매개변수) => 식
// Func<입력, 결과>: 값을 돌려주는 람다
Func<int, int> square = x => x * x;
Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine("5의 제곱: " + square(5));
Console.WriteLine("3 + 4 = " + add(3, 4));
// Action<입력>: 값을 돌려주지 않는 람다
Action<string> hello = name => Console.WriteLine($"안녕, {name}!");
hello("철수");
// 여러 줄 람다는 { }와 return을 씁니다
Func<int, string> check = n =>
{
if (n >= 0)
{
return "0 이상";
}
return "음수";
};
Console.WriteLine(check(-3));
실행 결과입니다.
5의 제곱: 25
3 + 4 = 7
안녕, 철수!
음수
- 읽는 법:
x => x * x는 "x를 받아서 x * x를 돌려준다"는 뜻입니다.=>왼쪽이 받는 값(매개변수), 오른쪽이 돌려줄 값입니다. - 매개변수가 두 개면:
(a, b) => a + b처럼 괄호로 묶습니다. - Func: 값을 돌려주는 람다를 담습니다.
Func<int, int, int>는int두 개를 받아int를 돌려줍니다. 마지막 자료형이 돌려주는 값의 자료형입니다. - Action: 값을 돌려주지 않는 람다를 담습니다.
Action<string>은string하나를 받기만 합니다. - 여러 줄 람다:
{ }로 감싸고return으로 값을 돌려줍니다.
List 메서드에 람다 넘기기
// List의 메서드에 람다를 넘기기
List<int> numbers = new List<int> { 3, 8, 1, 6, 10, 7 };
List<int> evens = numbers.FindAll(n => n % 2 == 0); // 조건에 맞는 것 모두
Console.WriteLine("짝수: " + string.Join(", ", evens));
int firstBig = numbers.Find(n => n > 5); // 조건에 맞는 첫 번째
Console.WriteLine("5보다 큰 첫 수: " + firstBig);
numbers.RemoveAll(n => n < 5); // 조건에 맞는 것 지우기
Console.WriteLine("5 미만 삭제 후: " + string.Join(", ", numbers));
numbers.ForEach(n => Console.Write(n * 10 + " ")); // 하나씩 실행
Console.WriteLine();
실행 결과입니다. 마지막 줄 끝에는 공백이 하나 있습니다.
짝수: 8, 6, 10
5보다 큰 첫 수: 8
5 미만 삭제 후: 8, 6, 10, 7
80 60 100 70
- FindAll: 조건에 맞는 값을 모두 찾아 새 List로 돌려줍니다.
- Find: 조건에 맞는 첫 번째 값을 돌려줍니다. 3, 8, … 중에서 5보다 큰 첫 수는 8입니다.
- RemoveAll: 조건에 맞는 값을 모두 지웁니다. 3과 1이 지워졌습니다.
- ForEach: 값마다 람다를 한 번씩 실행합니다.
람다 덕분에 "짝수인가", "5보다 큰가" 같은 조건을 메서드에 바로 넘길 수 있습니다.
LINQ로 고르고, 정렬하고, 계산하기
LINQ는 목록에서 원하는 것을 고르고, 정렬하고, 계산하는 메서드 모음입니다. 대부분 람다로 조건을 넘깁니다.
// LINQ 기초: 목록에서 고르고, 정렬하고, 계산하기
List<Student> students = new List<Student>
{
new Student("철수", 92, "1반"),
new Student("영희", 78, "2반"),
new Student("민수", 85, "1반"),
new Student("지은", 64, "2반"),
new Student("하늘", 97, "1반")
};
// Where: 조건으로 고르기
var passed = students.Where(s => s.Score >= 80);
Console.WriteLine("80점 이상: " + string.Join(", ", passed.Select(s => s.Name)));
// OrderByDescending: 점수 높은 순으로 정렬
Console.WriteLine("[점수 순위]");
var ranking = students.OrderByDescending(s => s.Score).ToList();
for (int i = 0; i < ranking.Count; i++)
{
Console.WriteLine($"{i + 1}등 {ranking[i].Name} {ranking[i].Score}점");
}
// Select: 원하는 값만 뽑기
List<string> names = students.Select(s => s.Name).ToList();
Console.WriteLine("이름만: " + string.Join(", ", names));
// 집계: Count, Sum, Average, Max, Min
Console.WriteLine($"학생 수: {students.Count()}");
Console.WriteLine($"총점: {students.Sum(s => s.Score)}");
Console.WriteLine($"평균: {students.Average(s => s.Score):F1}");
Console.WriteLine($"최고점: {students.Max(s => s.Score)}");
// First / FirstOrDefault: 조건에 맞는 첫 번째
Student top = students.First(s => s.ClassName == "2반");
Console.WriteLine("2반 첫 학생: " + top.Name);
Student? none = students.FirstOrDefault(s => s.Score > 100);
Console.WriteLine("100점 넘는 학생: " + (none == null ? "없음" : none.Name));
// Any: 하나라도 있으면 true
Console.WriteLine("60점 미만이 있나요? " + students.Any(s => s.Score < 60));
// 여러 개를 이어서 쓰기: 1반 학생을 점수 순으로
var class1 = students
.Where(s => s.ClassName == "1반")
.OrderByDescending(s => s.Score)
.Select(s => $"{s.Name}({s.Score})");
Console.WriteLine("1반: " + string.Join(", ", class1));
class Student
{
public string Name { get; }
public int Score { get; }
public string ClassName { get; }
public Student(string name, int score, string className)
{
Name = name;
Score = score;
ClassName = className;
}
}
실행 결과입니다.
80점 이상: 철수, 민수, 하늘
[점수 순위]
1등 하늘 97점
2등 철수 92점
3등 민수 85점
4등 영희 78점
5등 지은 64점
이름만: 철수, 영희, 민수, 지은, 하늘
학생 수: 5
총점: 416
평균: 83.2
최고점: 97
2반 첫 학생: 영희
100점 넘는 학생: 없음
60점 미만이 있나요? False
1반: 하늘(97), 철수(92), 민수(85)
- Where: 조건에 맞는 것만 고릅니다. 80점 이상인 철수, 민수, 하늘이 남았습니다.
- OrderByDescending: 큰 값부터 정렬합니다. 작은 값부터 정렬할 때는
OrderBy를 씁니다. 뒤에ToList()를 붙이면 결과를 List로 만들어서ranking[i]처럼 번호로 읽을 수 있습니다. - Select: 객체에서 원하는 값만 뽑습니다.
s => s.Name은 이름만 뽑습니다. - 집계:
Count(),Sum,Average,Max로 개수, 합계, 평균, 최댓값을 한 줄로 구합니다. 평균은:F1로 소수 첫째 자리까지 출력했습니다. - First와 FirstOrDefault: 조건에 맞는 첫 번째를 돌려줍니다. 100점 넘는 학생은 없어서
FirstOrDefault가null을 돌려줬고, "없음"이 출력됐습니다.Student?의?는null이 들어올 수 있다는 표시입니다. - Any: 조건에 맞는 것이 하나라도 있으면
true입니다. 60점 미만은 없어서False입니다. - 이어 쓰기:
Where→OrderByDescending→Select처럼 점(.)으로 이어 쓰면, 1반만 골라 점수 순으로 정렬한 뒤 글자로 바꿉니다.
LINQ 메서드를 쓰려면 원래 using System.Linq;가 필요합니다. 하지만 콘솔 앱 프로젝트는 작업환경 글에서 본 <ImplicitUsings>enable</ImplicitUsings> 설정 덕분에 따로 쓰지 않아도 됩니다.
헷갈리기 쉬운 점
- First는 없으면 예외, FirstOrDefault는 없으면 null입니다. 조건에 맞는 것이 없을 때
First는InvalidOperationException을 냅니다. 없을 수도 있으면FirstOrDefault를 쓰고null인지 확인합니다. - List의 Find는 못 찾으면 기본값을 돌려줍니다.
List<int>에서는 0입니다. 그래서 0이 실제로 찾은 값인지, 못 찾은 것인지 구분해야 할 때는 조심합니다. - Count와 Count()는 다릅니다.
fruits.Count는 List의 개수 속성이라 괄호가 없습니다.students.Count()는 LINQ 메서드라 괄호가 있습니다. 둘 다 개수를 돌려줍니다. - => 기호는 두 곳에 나옵니다. 람다
x => x * x와 속성TotalValue => Price * Stock입니다. 둘 다 "오른쪽 식의 값을 돌려준다"고 읽으면 됩니다. - from으로 시작하는 LINQ도 있습니다.
from s in students where ...모양을 쿼리 구문이라고 합니다. 이 글은 점(.)으로 이어 쓰는 메서드 구문만 씁니다.
6. 종합 예제: 성적 계산기 다시 만들기
![종합 예제 그림(성적 계산기 다시 만들기, 지난 글 프로그램을 클래스·List·LINQ로). 위쪽은 「지난 글 → 이번 글, 무엇이 바뀌었나」 표: 이름·점수(이전 배열 두 개를 번호로 짝지음 → 이번 Student 객체 하나로 묶음), 학점(이전 switch 메서드 → 이번 읽기 전용 속성 Grade), 평균·1등(이전 for 반복문 → 이번 LINQ 한 줄), 잘못된 입력(이전 처리 없음 → 이번 예외로 걸러 건너뜀), 학점별 인원(이전 없음 → 이번 Dictionary로 세기). 가운데는 핵심 코드 여섯 줄(public string Grade, if (Score >= 90) return 'A';, students.Average(s => s.Score), OrderByDescending(s => s.Score).First(), catch (FormatException), catch (ArgumentOutOfRangeException ex))과 「흰색 = 속성 · 파랑 = LINQ · 주황 = 예외 처리」. 아래는 실행 결과(실제 출력, 이름·점수 사이는 탭): [건너뜀] 오류: '백점'은(는) 숫자가 아닙니다., [건너뜀] 하늘: score 값이 0~100 범위를 벗어났습니다., === 성적표 === 철수 92점 A, 영희 78점 C, 민수 85점 B, 지은 64점 D, 평균: 79.8점, 1등: 철수 (92점), A·B·C·D 각 1명, 그리고 「평균 79.8 · 1등 철수: 지난 글과 같은 결과」. 친구가 「결과는 지난번이랑 똑같네?」라고 묻고 초보가 「잘못된 두 명은 건너뛰었어요!」라고 답함](https://blog.kakaocdn.net/dna/kzIED/dJMcaaG9W6l/AAAAAAAAAAAAAAAAAAAAAPYTEHTxKe_EvPV8v79AH4LdKshlqK0NhSZSSfKWzQiw/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1793458799&allow_ip=&allow_referer=&signature=jk1KlzOLC%2Bqscoyiz4ZfEN5D30k%3D)
지난 글의 성적 계산기를 이번 글에서 배운 클래스, 속성, List, Dictionary, 예외, LINQ로 다시 만듭니다. 같은 학생 네 명에 잘못된 데이터 두 개를 더했습니다.
// 종합 예제: 지난 글의 성적 계산기를 클래스·List·LINQ로 다시 만들기
List<Student> students = new List<Student>();
AddStudent("철수", "92");
AddStudent("영희", "78");
AddStudent("민수", "85");
AddStudent("지은", "64");
AddStudent("오류", "백점"); // 숫자가 아니라 예외가 납니다
AddStudent("하늘", "120"); // 범위를 벗어나 예외가 납니다
Console.WriteLine("=== 성적표 ===");
foreach (Student s in students)
{
Console.WriteLine($"{s.Name}\t{s.Score}점\t{s.Grade}");
}
Console.WriteLine("==============");
Console.WriteLine($"평균: {students.Average(s => s.Score):F1}점");
Student best = students.OrderByDescending(s => s.Score).First();
Console.WriteLine($"1등: {best.Name} ({best.Score}점)");
// 학점별 인원 (Dictionary)
Dictionary<string, int> gradeCount = new Dictionary<string, int>();
foreach (Student s in students)
{
if (gradeCount.ContainsKey(s.Grade))
{
gradeCount[s.Grade]++;
}
else
{
gradeCount[s.Grade] = 1;
}
}
foreach (var pair in gradeCount.OrderBy(p => p.Key)) // 학점 순으로 정렬
{
Console.WriteLine($"{pair.Key}: {pair.Value}명");
}
void AddStudent(string name, string scoreText)
{
try
{
int score = int.Parse(scoreText);
students.Add(new Student(name, score));
}
catch (FormatException)
{
Console.WriteLine($"[건너뜀] {name}: '{scoreText}'은(는) 숫자가 아닙니다.");
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"[건너뜀] {name}: {ex.ParamName} 값이 0~100 범위를 벗어났습니다.");
}
}
class Student
{
public string Name { get; }
public int Score { get; }
public Student(string name, int score)
{
if (score < 0 || score > 100)
{
throw new ArgumentOutOfRangeException(nameof(score));
}
Name = name;
Score = score;
}
// 점수로 학점을 계산하는 읽기 전용 속성
public string Grade
{
get
{
if (Score >= 90) return "A";
if (Score >= 80) return "B";
if (Score >= 70) return "C";
if (Score >= 60) return "D";
return "F";
}
}
}
실행 결과입니다. 이름, 점수, 학점 사이는 탭 문자입니다.
[건너뜀] 오류: '백점'은(는) 숫자가 아닙니다.
[건너뜀] 하늘: score 값이 0~100 범위를 벗어났습니다.
=== 성적표 ===
철수 92점 A
영희 78점 C
민수 85점 B
지은 64점 D
==============
평균: 79.8점
1등: 철수 (92점)
A: 1명
B: 1명
C: 1명
D: 1명
무엇이 바뀌었나
| 항목 | 지난 글 | 이번 글 |
|---|---|---|
| 이름·점수 | 배열 두 개를 번호로 짝지음 | Student 객체 하나로 묶음 |
| 학점 | switch를 쓴 메서드 |
읽기 전용 속성 Grade |
| 평균·1등 | for 반복문 |
LINQ 한 줄 |
| 잘못된 입력 | 처리 없음 | 예외로 걸러 건너뜀 |
| 학점별 인원 | 없음 | Dictionary로 세기 |
코드 살펴보기
- AddStudent: 점수를 글자로 받아
int.Parse로 바꾸고,Student객체를 만들어 목록에 넣습니다. 두 줄 모두try안에 있습니다. - '백점': 숫자가 아니라서
int.Parse에서FormatException이 났고, 첫 번째catch가 받았습니다. - '120': 숫자로는 바뀌지만,
Student생성자가 0~100 범위를 검사해서throw new ArgumentOutOfRangeException(nameof(score))로 예외를 던졌습니다.nameof(score)는 변수 이름score를 글자로 바꿔 줍니다. 그래서catch에서ex.ParamName을 출력하자score가 나왔습니다. - Grade 속성: 점수를 보고 학점을 계산해 돌려주는 읽기 전용 속성입니다.
if (Score >= 90) return "A";처럼 실행할 문장이 한 줄이면 중괄호를 생략할 수도 있습니다. 이 예제에서만 짧게 쓰려고 생략했습니다. - 평균과 1등:
students.Average(s => s.Score)로 평균을,OrderByDescending(s => s.Score).First()로 점수가 가장 높은 학생을 구했습니다. - 학점별 인원: Dictionary에 학점이 이미 있으면 1을 더하고, 없으면 1을 넣습니다. Dictionary의 순서는 정해져 있지 않으므로
OrderBy(p => p.Key)로 학점 순으로 정렬해서 출력했습니다. - 평균 79.8점, 1등 철수는 지난 글의 결과와 같습니다. 잘못된 두 명은 목록에 들어가지 않았기 때문입니다.
헷갈리기 쉬운 점
- 잘못된 값은 객체를 만들 때 막습니다. 생성자에서 예외를 던지면 객체가 만들어지지 않고, 목록에도 들어가지 않습니다. 그래서 하늘은 성적표에 없습니다.
- '백점'과 '120'은 다른 예외입니다. 숫자가 아닌 것은
FormatException, 범위를 벗어난 것은ArgumentOutOfRangeException입니다. 그래서catch를 두 개 두었습니다. - 중괄호를 생략한 if는 한 줄에만 해당합니다. 실행할 문장이 두 줄 이상이면 중괄호로 감쌉니다. 헷갈리면 지난 글처럼 늘 중괄호를 쓰면 됩니다.
마치며
이번 글에서는 C# 고급 문법을 클래스부터 LINQ까지 정리했습니다.
- 클래스는 설계도, 객체는
new로 만든 실제 물건입니다. 데이터는set에서 검사할 수 있는 속성으로 씁니다. - 상속은 부모의 기능을 물려받고, 인터페이스는 꼭 만들 기능을 약속합니다.
- List는 크기가 늘어나는 목록이고, Dictionary는 키로 값을 찾습니다. 없는 키와 겹치는 키는 예외가 납니다.
try,catch,finally로 예외를 받아서 프로그램이 멈추지 않게 하고,throw로 직접 예외를 던집니다.- 람다는 이름 없는 짧은 메서드이고, LINQ는 람다로 목록을 고르고 정렬하고 계산합니다.
- 지난 글의 성적 계산기를 클래스, List, LINQ, 예외로 다시 만들었습니다.
다음 글은 「Visual Studio 콘솔앱 만들기, 파일 나누기와 디버깅」입니다. 이 글에서 한 파일에 함께 쓰던 클래스를 파일로 나누고, 솔루션과 프로젝트의 구조, Main 메서드, 중단점을 이용한 디버깅을 다룹니다.
참고 자료
'C#' 카테고리의 다른 글
| Windows Forms 시작하기, 폼 만들기와 도구 상자 컨트롤 (0) | 2026.10.06 |
|---|---|
| C# 콘솔 입출력, ReadLine과 출력 서식 (0) | 2026.10.06 |
| Visual Studio 콘솔앱 만들기, 파일 나누기와 디버깅 (0) | 2026.10.06 |
| C# 기본 문법, 변수부터 메서드까지 (0) | 2026.10.06 |
| C# 작업환경, Visual Studio 2026 설치하기 (0) | 2026.10.06 |