예외는 정상 흐름과 오류 흐름을 갈라 놓는다
지금까지 예외를 여러 번 만났습니다.
| 예외 | 언제 |
|---|---|
IndexOutOfRangeException |
배열의 없는 칸을 건드릴 때 |
KeyNotFoundException |
Dictionary 에 없는 키를 꺼낼 때 |
NullReferenceException |
아무것도 안 가리키는 것을 따라갈 때 |
InvalidOperationException |
빈 Stack 에서 꺼낼 때 |
StackOverflowException |
재귀가 너무 깊을 때 |
그때마다 “프로그램이 죽는다” 고만 했습니다. 이제 죽지 않게 하는 방법을 봅니다.
성공 여부를 돌려주면 되지 않나
앞에서 본 TryParse 방식이 있습니다.
if (int.TryParse(input, out int n))
{
...
}
괜찮은 방법인데, 모든 것을 이렇게 하면 코드가 이렇게 됩니다.
if (!TryOpenFile(path, out var file)) return false;
if (!TryReadAll(file, out var text)) return false;
if (!TryParse(text, out var data)) return false;
if (!TrySave(data)) return false;
return true;
하려는 일은 “열고 읽고 해석해서 저장” 네 단계인데, 절반이 실패 확인입니다. 정상 흐름이 오류 처리에 파묻혔습니다.
그리고 실패를 알려도 무엇이 왜 실패했는지는 안 넘어옵니다.
false 하나로는 파일이 없는 것인지 권한이 없는 것인지 모릅니다.
정상 코드는 정상 코드대로 두고, 오류는 따로 모아서 처리하면 됩니다.
try catch 는 이렇게 생겼다
try
{
var file = OpenFile(path);
var text = ReadAll(file);
var data = Parse(text);
Save(data);
}
catch (Exception e)
{
Console.WriteLine("실패: " + e.Message);
}
try 안은 전부 성공한다고 치고 적습니다. 확인이 한 줄도 없습니다.
중간 어디서든 문제가 생기면 그 자리에서 멈추고 catch 로 넘어옵니다.
중간에 터지면 남은 줄을 건너뜁니다. 이게 중요합니다.
Parse 가 실패했는데 Save 가 실행되면 이상한 값이 저장됩니다.
예외는 그 자리에서 멈춰 주니 그런 일이 안 생깁니다.
예외도 객체다
catch (Exception e) 의 e 는 예외 객체입니다. 클래스입니다.
catch (Exception e)
{
Console.WriteLine(e.Message); // 무슨 일인지
Console.WriteLine(e.StackTrace); // 어디서 났는지
}
StackTrace 는 앞에서 본 호출 스택입니다.
어느 메서드가 어느 메서드를 부르다가 여기까지 왔는지가 적혀 있습니다.
버그를 찾을 때 이걸 봅니다.
그리고 예외에도 상속 관계가 있습니다.
Exception
├─ ArgumentException
│ └─ ArgumentNullException
├─ InvalidOperationException
├─ FormatException
└─ ...
Exception 이 전부의 부모입니다. 그래서 catch (Exception e) 는 뭐든 잡습니다.
catch 는 구체적인 것부터 적는다
여러 개를 적으면 위에서부터 맞는 것을 찾습니다.
try
{
...
}
catch (FormatException e)
{
Console.WriteLine("숫자 형식이 아닙니다");
}
catch (Exception e)
{
Console.WriteLine("그 밖의 문제: " + e.Message);
}
순서를 뒤집으면 안 됩니다.
catch (Exception e) { ... } // 여기서 다 잡힙니다
catch (FormatException e) { ... } // 여기는 영원히 실행되지 않습니다
앞에서 else if 순서를 잘못 쓰면 아래가 죽은 코드가 된다고 했습니다. 같은 이야기입니다.
다만 이건 컴파일러가 막아 줍니다. 부모를 먼저 적으면 컴파일 에러입니다.
finally 는 어느 쪽이든 실행된다
파일을 열었으면 닫아야 합니다. 예외가 나도 닫아야 합니다.
FileStream file = null;
try
{
file = File.Open(path, FileMode.Open);
...
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
file?.Close(); // 성공하든 실패하든 실행됩니다
}
finally 는 try 를 어떻게 빠져나가든 실행됩니다.
정상적으로 끝나도, 예외가 나도, 중간에 return 을 해도 실행됩니다.
정리해야 하는 것을 여기 둡니다. 파일, 연결, 잠금 같은 것들입니다.
using 을 쓰면 더 짧습니다.
using (var file = File.Open(path, FileMode.Open))
{
...
} // 블록을 벗어나면 자동으로 닫힙니다
예외를 직접 던질 수 있다
throw 로 던집니다.
void SetAge(int age)
{
if (age < 0)
{
throw new ArgumentException("나이는 음수일 수 없습니다");
}
this.age = age;
}
앞에서 잘못된 값을 조용히 고쳤습니다.
if (value < 0) value = 0; // 조용히 고침
둘 중 무엇이 맞는지는 누구의 잘못이냐에 달렸습니다.
- 사용자가 잘못 입력한 것 → 고치거나 다시 물어본다
- 코드가 잘못 부른 것 → 던진다
SetAge(-5) 를 부른 것은 프로그래머의 실수입니다.
조용히 0으로 고치면 그 실수가 묻히고, 한참 뒤에 이상한 값으로 나타납니다.
던져서 그 자리에서 멈추게 하는 편이 낫습니다.
잡을 수 있을 때만 잡는다
예외를 잡았으면 무언가를 해야 합니다.
try
{
...
}
catch (Exception e)
{
// 아무것도 안 함
}
가장 나쁜 코드입니다. 문제가 있었는데 아무도 모르게 됩니다. 나중에 값이 이상해도 어디서 시작됐는지 찾을 수가 없습니다.
여기서 처리할 수 없으면 안 잡는 편이 낫습니다. 안 잡으면 부른 쪽으로 올라갑니다. 거기서도 안 잡으면 더 올라갑니다. 처리할 수 있는 곳까지 올라가게 두면 됩니다.
기록만 하고 다시 던질 수도 있습니다.
catch (Exception e)
{
logger.Save(e.Message);
throw; // 그대로 다시 던집니다
}
throw; 는 원래 예외를 그대로 올려보냅니다.
throw e; 라고 적으면 StackTrace 가 여기서부터로 덮어써집니다. 세미콜론만 쓰는 편이 낫습니다.
예외로 흐름을 만들지 않는다
예외는 예상 밖의 일에 씁니다. 흔히 일어나는 일에 쓰면 안 됩니다.
try
{
n = int.Parse(input);
}
catch (FormatException)
{
n = 0;
}
if (!int.TryParse(input, out n)) n = 0;
아래쪽이 맞습니다. 사용자가 숫자를 잘못 넣는 것은 예상되는 일입니다.
예상되는 것은 if 로 처리하고, 예외는 예상 밖의 것에 남겨 둡니다.
그리고 예외는 비쌉니다. 던지고 잡는 데 호출 스택을 훑어야 해서 반복문 안에서 자주 일어나면 눈에 띄게 느려집니다.
정리
try안은 전부 성공한다고 치고 적습니다. 확인이 코드를 채우지 않습니다.- 중간에 예외가 나면 남은 줄을 건너뛰고
catch로 갑니다. - 예외도 객체입니다.
Message와StackTrace로 무슨 일인지 어디서 났는지 압니다. - 예외에는 상속 관계가 있습니다.
catch는 구체적인 것부터 적습니다. finally는 어떻게 빠져나가든 실행됩니다. 정리할 것을 여기 둡니다.- 코드가 잘못 부른 것이면
throw로 던집니다. 조용히 고치면 실수가 묻힙니다. - 처리할 수 없으면 잡지 않습니다. 빈
catch가 제일 나쁩니다. - 예상되는 일은
if로, 예상 밖의 일만 예외로 다룹니다.