예외는 정상 흐름과 오류 흐름을 갈라 놓는다
Esc

    예외는 정상 흐름과 오류 흐름을 갈라 놓는다

    –C#기초

    지금까지 예외를 여러 번 만났습니다.

    예외 언제
    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 로, 예상 밖의 일만 예외로 다룹니다.