Devin.KR

예외 처리와 파일 입출력

개발자KR 조회 2

이 장에서 배우는 것

앞 장에서 주문 목록을 골라내고 합계를 구했다. 그 결과는 프로그램이 종료되면 사라진다. 카페의 다음 근무자가 재고를 이어서 확인하려면 값을 파일에 남겨야 한다. 파일을 읽고 쓰기 시작하면 코드만으로 통제하기 어려운 상황도 만난다. 저장 폴더에 쓰기 권한이 없거나, 주문 파일의 수량이 숫자가 아니거나, 주문량이 남은 재고보다 많을 수 있다.

이 장에서는 예외(exception)를 작업 실패를 전달하는 수단으로 다룬다. 실패를 무조건 감추는 대신, 계속 처리할 수 있는 실패와 현재 작업을 중단해야 하는 실패를 구분한다. 카페 주문을 텍스트 파일에서 읽고 재고를 수정한 뒤 결과를 다시 파일에 기록하면서 자원 정리의 책임까지 살펴본다.

  • try, catch, finally의 실행 순서와 역할을 구분한다.
  • using으로 파일을 사용하는 객체의 수명을 제한한다.
  • File과 Path로 운영체제에 맞는 파일 경로와 입출력 코드를 작성한다.
  • 재고 부족을 사용자 정의 예외로 표현하고, 주문 하나의 실패가 다른 주문에 미치는 범위를 정한다.

문제 상황

동네 카페가 외부 주문을 한 줄씩 적은 파일을 받는다고 가정한다. 한 줄은 음료 이름|수량 형식이다. 오늘 준비한 재고는 라테 5잔과 아메리카노 3잔이다. 파일에는 라테 2잔, 아메리카노 4잔, 수량이 잘못 적힌 모카 주문, 라테 1잔이 차례로 들어 있다.

아메리카노 주문은 형식은 맞지만 재고가 부족하다. 모카 주문의 수량인 ‘둘’은 이 프로그램이 받는 숫자 형식이 아니다. 두 주문을 처리하지 못하더라도 마지막 라테 주문은 처리할 수 있다. 반면 주문 파일 자체를 열 수 없다면 다음 주문을 알아낼 방법이 없으므로 파일 단위 작업을 중단해야 한다. 실패가 발생했다는 사실보다, 어느 범위까지 중단할지를 먼저 결정하는 것이 중요하다.

완성 코드는 실행할 때마다 운영체제의 임시 위치에 전용 폴더를 만들고 같은 주문 파일을 준비한다. 처리 결과를 재고 파일에 저장하고 다시 읽어 확인한 다음 폴더를 지운다. 실제 운영에서는 결과를 남겨야 하지만, 여기서는 반복 실행의 결과를 일정하게 만들고 기존 파일을 건드리지 않도록 작업 범위를 한정한다. 임시 경로의 임의 부분은 출력하지 않는다.

실패를 나누는 try, catch, finally

try는 실패할 수 있는 작업의 범위를 표시한다. 실행 중 예외가 발생하면 그 지점 이후의 나머지 문장은 건너뛰고, 예외를 처리할 수 있는 catch를 찾는다. 예외가 발생한 메서드에 처리기가 없다면 그 메서드를 호출한 쪽으로 탐색이 이어진다. 처리할 수 있는 곳을 찾을 때까지 호출 경계를 넘어가는 것이다.

catch는 예외의 타입을 기준으로 선택된다. 여러 개가 있다면 위에서 아래로 검사하며, 일치하는 첫 번째 블록 하나만 실행한다. 자식 타입도 부모 타입의 처리기에 들어갈 수 있으므로 구체적인 타입을 앞에 두어야 한다. 모든 예외의 기반 타입인 Exception을 먼저 잡으면 뒤의 구체적인 처리기는 도달할 수 없는 코드가 될 수 있다.

finally는 해당 try를 벗어나는 과정에서 수행할 정리 작업을 적는 곳이다. 예외가 없을 때도, catch에서 예외를 처리했을 때도 실행된다. 일치하는 처리기가 없어 예외가 바깥으로 전달되는 경우에도 일반적인 예외 처리 흐름에서는 실행된다. 다만 운영체제가 프로세스를 강제로 종료하는 상황까지 실행을 보장하는 장치는 아니다.

정상 완료와 처리된 예외의 경로는 모두 finally의 정리 작업으로 이어진다

finally는 성공한 경우에만 하는 일을 넣는 장소가 아니다. 예를 들어 재고 파일 저장을 여기에 두면 주문 읽기에 실패한 경우에도 저장을 시도하게 된다. 성공 후 저장은 try의 정상 실행 경로에 두고, 임시 폴더 삭제처럼 성공 여부와 관계없는 정리를 finally에 둔다.

실패 종류에 따라 처리 범위가 달라진다
상황예외 타입이 예제의 대응
수량이나 줄 구조가 잘못됨FormatException현재 주문을 제외하고 다음 줄 처리
요청 수량보다 재고가 적음StockShortageException재고를 유지하고 다음 줄 처리
파일 읽기·쓰기·삭제 실패IOException파일 단위 작업 중단
파일 또는 폴더 접근 권한 부족UnauthorizedAccessException파일 단위 작업 중단

표의 두 파일 관련 예외는 별도로 처리한다. UnauthorizedAccessException은 IOException의 자식 타입이 아니다. 또한 이 표가 모든 실패를 나열하는 것은 아니다. 코드의 결함까지 넓은 처리기로 붙잡아 정상 처리처럼 보이게 만들지는 않는다.

예외 처리의 위치는 프로그램의 복구 정책을 나타낸다. 반복문 전체를 하나의 try로 감싸면 첫 번째 실패 후 반복문 바깥으로 이동한다. 각 줄의 처리를 반복문 안의 try로 감싸면 실패한 줄만 건너뛸 수 있다. 완성 코드는 안쪽에서 주문 단위 실패를 처리하고, 바깥쪽에서 파일 단위 실패를 처리한다.

파일 경로와 자원의 수명

파일 이름과 파일 경로는 다르다. orders.txt는 이름이고, 어느 폴더의 파일인지까지 지정하면 경로가 된다. 상대 경로는 현재 작업 디렉터리를 기준으로 해석된다. 현재 작업 디렉터리는 소스 파일이 있는 폴더와 같다고 가정할 수 없다. 터미널에서 프로그램을 실행한 위치나 실행 도구의 설정에 따라 달라질 수 있다.

Path.Combine은 폴더와 파일 이름을 경로 규칙에 맞게 결합한다. 예제에서는 Path.Combine(workDirectory, "orders.txt")처럼 사용한다. 문자열 사이에 슬래시를 직접 붙일 필요가 없다. 다만 경로를 결합했다고 파일이나 폴더가 생성되는 것은 아니다. 경로를 계산하는 일과 실제 저장 장치에 접근하는 일을 구분해야 한다.

Path.Combine은 보안 검사기도 아니다. 뒤에 전달한 값이 절대 경로이면 앞부분이 무시될 수 있고, 상위 폴더로 이동하는 경로도 자동으로 차단하지 않는다. 이 예제의 파일 이름은 코드에 고정되어 있다. 외부에서 파일 이름을 받는 기능을 추가한다면 허용할 위치와 이름을 별도로 검증해야 한다.

File.WriteAllLines는 문자열들을 줄 단위로 기록한다. 파일이 없으면 만들고, 이미 있으면 내용을 덮어쓴다. 한 번의 호출 안에서 필요한 파일을 열고 닫으므로 호출자가 따로 닫을 객체를 받지 않는다. File.ReadAllLines도 파일을 읽고 닫은 뒤 모든 줄을 배열로 반환한다. 짧은 설정이나 작은 결과 파일을 다룰 때 쓰기 편하다.

반면 File.OpenText는 파일을 읽는 StreamReader 객체를 반환한다. 이 객체는 읽기가 끝날 때까지 파일 자원을 사용한다. 관리되는 객체의 메모리가 언젠가 회수된다는 사실만으로 파일 자원이 적절한 시점에 정리되지는 않는다. 필요한 작업이 끝난 시점에 Dispose가 호출되도록 수명을 명시해야 한다.

using 문은 이 정리를 문법으로 표현한다. using (StreamReader reader = File.OpenText(path)) 다음의 블록을 벗어나면 정리가 수행된다. 읽는 중 예외가 발생해 블록을 벗어나는 경우도 포함한다. 객체를 만드는 단계 자체가 실패했다면 성공적으로 얻은 객체가 없으므로 해당 객체에 대한 정리도 수행되지 않는다.

using 블록을 벗어나 읽기 객체를 정리한 다음 임시 작업 폴더를 삭제한다

using var reader = File.OpenText(path);처럼 선언으로 적을 수도 있다. 이 형태는 현재 범위의 끝에서 정리한다. 간결하지만 파일을 더 오래 열어 둘 수 있다. 완성 코드에서는 주문을 다 읽자마자 파일을 닫는 위치를 분명히 보여 주기 위해 블록 형태를 사용한다.

파일 맨 위의 using System.IO;는 다른 역할이다. 네임스페이스의 타입 이름을 짧게 쓰도록 해 주는 지시문이며 자원을 정리하지 않는다. 같은 단어가 쓰여도 네임스페이스를 가져오는 문법인지, 객체의 수명을 제한하는 문법인지 문맥으로 구분한다.

업무 규칙을 사용자 정의 예외로 표현하기

재고 부족은 파일 형식 오류와 다르다. ‘아메리카노 4잔’은 읽을 수 있는 주문이지만, 남은 재고가 3잔이면 현재 규칙으로 처리할 수 없다. 이를 StockShortageException이라는 타입으로 만들면 호출자는 재고 부족만 골라 처리할 수 있다. 예외 메시지에 ‘재고’라는 단어가 있는지 검색해서 분기할 필요가 없다.

사용자 정의 예외는 Exception을 상속하고 이름 끝에 Exception을 붙인다. 완성 코드의 예외에는 음료 이름, 요청 수량, 남은 재고를 읽기 전용 속성으로 담는다. 메시지는 사람이 읽는 설명이고, 속성은 코드가 판단에 사용할 구조화된 정보다. 화면의 문구가 바뀌어도 속성의 의미는 유지할 수 있다.

재고를 줄이기 전에 수량을 검사하는 순서도 중요하다. 먼저 차감한 뒤 부족 여부를 검사하면 실패한 주문이 재고를 바꿔 놓는다. catch는 이미 실행한 대입을 되돌리지 않는다. 이 예제에서는 검증이 끝난 뒤에만 재고를 변경하여 실패한 주문의 영향이 남지 않게 한다.

예외가 모든 검증의 기본 선택인 것은 아니다. 수량 변환에는 int.TryParse를 사용해 변환 성공 여부를 확인한다. 파일 한 줄을 주문으로 해석하는 메서드는 그 검증 실패를 FormatException으로 전달한다. 이 설계는 여러 종류의 잘못된 줄을 호출자가 한곳에서 처리하게 한다. 잘못된 입력이 매우 잦은 대량 처리라면 성공 여부와 오류 내용을 반환하는 방식도 고려할 수 있다.

사실 관계를 더 확인하려면 Microsoft의 예외 처리 문 참조, using 문 참조, File API 참조를 이용할 수 있다. 다음 코드는 카페 주문 처리 상황에 맞춰 구성한 독립적인 예제다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 내용으로 교체한다. 추가 패키지는 필요 없다. 정상 실행에는 운영체제의 임시 위치에 폴더와 파일을 만들고 지울 권한이 필요하다. 한 번에 한 주문씩 처리하며, 실행 중 다른 프로그램이 이 임시 폴더를 수정하지 않는 상황을 기준으로 한다.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;

try
{
    RunCafe();
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("파일 작업 실패: 접근 권한을 확인해야 한다.");
}
catch (IOException)
{
    Console.WriteLine("파일 작업 실패: 읽기, 쓰기 또는 정리를 마치지 못했다.");
}

static void RunCafe()
{
    string workDirectory =
        Directory.CreateTempSubdirectory("cafe-io-").FullName;

    try
    {
        string ordersPath = Path.Combine(workDirectory, "orders.txt");
        string stockPath = Path.Combine(workDirectory, "stock.txt");

        File.WriteAllLines(ordersPath, new[]
        {
            "라테|2",
            "아메리카노|4",
            "모카|둘",
            "라테|1"
        });

        var stock = new Dictionary<string, int>
        {
            ["라테"] = 5,
            ["아메리카노"] = 3
        };

        Console.WriteLine("주문 처리");

        using (StreamReader reader = File.OpenText(ordersPath))
        {
            int lineNumber = 0;

            while (reader.ReadLine() is string line)
            {
                lineNumber++;

                try
                {
                    var order = ParseOrder(line);
                    ApplyOrder(stock, order.Menu, order.Quantity);
                    Console.WriteLine(
                        $"{lineNumber}행 승인: {order.Menu} {order.Quantity}잔");
                }
                catch (FormatException ex)
                {
                    Console.WriteLine(
                        $"{lineNumber}행 제외: {ex.Message}");
                }
                catch (StockShortageException ex)
                {
                    Console.WriteLine(
                        $"{lineNumber}행 보류: {ex.Menu} 요청 {ex.Requested}잔, 재고 {ex.Available}잔");
                }
            }
        }

        File.WriteAllLines(stockPath, new[]
        {
            "라테|" + stock["라테"].ToString(CultureInfo.InvariantCulture),
            "아메리카노|" + stock["아메리카노"].ToString(CultureInfo.InvariantCulture)
        });

        Console.WriteLine("저장한 재고");
        foreach (string savedLine in File.ReadAllLines(stockPath))
        {
            Console.WriteLine(savedLine);
        }
    }
    finally
    {
        Directory.Delete(workDirectory, recursive: true);
        Console.WriteLine("임시 작업 폴더 정리 완료");
    }
}

static (string Menu, int Quantity) ParseOrder(string line)
{
    string[] parts = line.Split('|');

    if (parts.Length != 2)
    {
        throw new FormatException("주문은 음료|수량 형식이어야 한다.");
    }

    string menu = parts[0].Trim();
    string quantityText = parts[1].Trim();

    if (menu.Length == 0)
    {
        throw new FormatException("음료 이름이 비어 있다.");
    }

    if (!int.TryParse(
            quantityText,
            NumberStyles.None,
            CultureInfo.InvariantCulture,
            out int quantity) || quantity <= 0)
    {
        throw new FormatException("수량은 1 이상의 정수여야 한다.");
    }

    return (menu, quantity);
}

static void ApplyOrder(
    Dictionary<string, int> stock, string menu, int quantity)
{
    if (!stock.TryGetValue(menu, out int available))
    {
        throw new FormatException("등록되지 않은 음료다.");
    }

    if (quantity > available)
    {
        throw new StockShortageException(menu, quantity, available);
    }

    stock[menu] = available - quantity;
}

sealed class StockShortageException : Exception
{
    public string Menu { get; }
    public int Requested { get; }
    public int Available { get; }

    public StockShortageException(
        string menu, int requested, int available)
        : base($"{menu} 주문 수량이 남은 재고보다 많다.")
    {
        Menu = menu;
        Requested = requested;
        Available = available;
    }
}

줄별 해설

첫 네 줄은 사용하는 타입의 네임스페이스를 명시한다. 숫자 변환 규칙을 고정하는 CultureInfo와 NumberStyles는 System.Globalization에 있다. 파일 관련 타입은 System.IO에 있다. 프로젝트가 일부 네임스페이스를 자동으로 가져오더라도 이 코드는 필요한 의존성을 직접 드러낸다.

처음의 try는 전체 작업을 호출하는 경계다. 안쪽에서 처리하지 않은 접근 권한 오류나 입출력 오류가 여기까지 전달된다. 오류의 운영체제별 상세 메시지를 그대로 출력하지 않고 정해진 안내 문구를 출력한다. 다른 타입의 예외는 이 두 처리기로 잡히지 않는다.

RunCafe의 첫 문장은 전용 임시 폴더를 만든다. 만들어진 폴더의 전체 경로를 받은 다음에 정리용 try에 진입한다. 폴더 생성 자체가 실패하면 이 메서드의 finally에는 들어가지 않고 호출자에게 예외가 전달된다. 아직 경로를 얻지 못한 폴더를 삭제하려고 시도하지 않는 구조다.

두 Path.Combine 호출은 주문 파일과 재고 파일을 같은 작업 폴더 안에 배치한다. 이어지는 WriteAllLines가 네 줄의 입력 파일을 만든다. 전용 폴더에 새 파일을 만들기 때문에 이전 실행의 내용이 섞이지 않는다. 이 메서드의 기본 텍스트 인코딩은 UTF-8이며, OpenText로 한글 내용을 읽을 수 있다.

stock에는 두 음료의 시작 재고가 들어 있다. 주문 처리 중 사용하는 값은 이 사전의 값이다. 파일에서 한 줄을 읽을 때마다 재고 파일을 수정하지 않고, 모든 줄의 처리를 마친 뒤 최종 상태를 기록한다. 따라서 주문을 읽는 도중 파일 오류가 나면 재고 저장 문장에는 도달하지 않는다.

using 블록은 읽기 객체가 필요한 범위를 감싼다. ReadLine은 파일 끝에서 null을 반환한다. is string line은 반환값이 문자열인 동안에만 반복문 본문을 실행하고, 그 문자열을 line에 담는다. 빈 줄은 빈 문자열이므로 파일 끝과 다르며, 파싱 과정에서 잘못된 주문으로 처리된다.

lineNumber++는 주문의 승인 여부와 관계없이 읽은 줄을 센다. 그다음 ParseOrder가 이름과 수량을 반환하고 ApplyOrder가 재고를 검사한다. 두 호출이 모두 끝나야 승인 메시지가 출력된다. 검사 도중 예외가 발생하면 승인 메시지는 건너뛴다.

두 안쪽 catch는 현재 줄의 오류만 처리한다. ReadLine 호출은 이 안쪽 try 밖에 있으므로 읽기 자체의 실패는 여기서 처리하지 않는다. 읽기에 실패하면 반복문과 using 블록을 벗어나 읽기 객체를 정리하고, 임시 폴더를 정리하는 경로를 거쳐 바깥 처리기로 이동한다.

재고 파일은 라테, 아메리카노 순서로 명시하여 쓴다. 출력 순서를 사전의 열거 방식에 맡기지 않은 것이다. 숫자를 파일에 기록할 때는 InvariantCulture를 지정하여 실행 환경의 문화권 설정에 의존하지 않도록 한다. 뒤의 ReadAllLines는 실제로 저장된 파일을 다시 읽는다. 화면의 재고 두 줄은 메모리의 값을 다시 조합한 출력이 아니다.

finally에서는 작업 폴더와 그 안의 파일을 함께 지운다. 삭제가 성공한 다음에만 정리 완료 문구를 출력한다. 삭제 실패도 바깥의 파일 오류 처리기로 전달된다. 이 예제에서 저장은 실제로 수행되지만 결과 파일은 실행 종료 후 남지 않는다. 장기 보관 기능으로 바꿀 때는 보관 파일을 임시 정리 대상에서 분리해야 한다.

ParseOrder는 구분자로 나눈 조각이 두 개인지, 이름이 비어 있지 않은지, 수량이 양의 정수인지 차례로 검사한다. 양 끝 공백은 제거하지만 NumberStyles.None을 사용하므로 부호나 천 단위 구분 기호는 받지 않는다. 숫자가 int 범위를 넘어가도 TryParse는 실패한다. 이 입력 계약에서는 +2, 1,000, 0도 제외된다.

ApplyOrder는 등록된 음료인지 확인한 뒤 재고 부족을 검사한다. 이 예제는 알 수 없는 음료도 주문 파일의 유효하지 않은 값으로 보아 FormatException으로 전달한다. 모카 줄은 수량 검사에서 먼저 실패하므로 ‘등록되지 않은 음료’ 메시지까지 도달하지 않는다. 한 줄에 문제가 여러 개 있으면 검사 순서상 먼저 발견한 문제 하나를 보고한다.

마지막 예외 클래스의 생성자는 base로 부모 생성자에 메시지를 전달하고 세 속성을 채운다. 호출자는 메시지를 해석하지 않고 속성을 이용해 보류 문구를 만든다. 이 앱에서 필요한 생성자만 제공한 형태다. 다른 예외를 감싸 전달하는 기능이 필요해지면 원인 예외를 받는 생성자를 추가할 수 있다.

실행 결과

.NET 10 SDK가 설치된 터미널에서 새 프로젝트를 만든다. 생성된 Program.cs를 완성 코드로 바꾼 뒤 실행한다.

dotnet new console --framework net10.0 --name CafeFiles
cd CafeFiles
dotnet run

파일 작업이 정상적으로 수행되는 환경에서 프로그램의 예상 출력은 다음과 같다. 프로젝트 생성과 복원 과정에서 도구가 표시하는 안내는 포함하지 않는다.

주문 처리
1행 승인: 라테 2잔
2행 보류: 아메리카노 요청 4잔, 재고 3잔
3행 제외: 수량은 1 이상의 정수여야 한다.
4행 승인: 라테 1잔
저장한 재고
라테|2
아메리카노|3
임시 작업 폴더 정리 완료

라테는 5잔에서 2잔과 1잔을 차감해 2잔이 남는다. 아메리카노는 요청을 보류했으므로 3잔 그대로다. 잘못된 세 번째 줄 다음에도 네 번째 줄을 처리한다는 점이 주문별 예외 처리의 효과다. 임시 폴더 이름과 실행 시각을 출력하지 않으므로 같은 입력과 정상 파일 작업 조건에서는 같은 결과가 나온다.

실무에서 자주 틀리는 것

다음 코드는 각각의 실수를 설명하는 부분 코드다. 완성 프로그램과 별도로 실행하는 예제가 아니며, 사용한 변수는 해당 작업 범위에 이미 있다고 가정한다.

예외가 나면 빈 catch로 넘긴다

아래 코드는 저장 실패를 지우고 저장 완료를 출력한다. 사용자는 화면의 안내를 믿지만 결과 파일은 만들어지지 않았을 수 있다. 처리한다는 것은 예외를 없애는 일이 아니라 실패 후의 동작을 결정하는 일이다.

try
{
    File.WriteAllLines(stockPath, lines);
}
catch (Exception)
{
}
Console.WriteLine("저장 완료");

성공 메시지를 실제 저장 뒤에 두고, 대응할 수 있는 예외만 잡는다. 여기서는 권한 문제와 입출력 문제에 대해 서로 다른 안내를 제공한다.

try
{
    File.WriteAllLines(stockPath, lines);
    Console.WriteLine("저장 완료");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("저장 실패: 쓰기 권한이 필요하다.");
}
catch (IOException)
{
    Console.WriteLine("저장 실패: 파일 상태를 확인해야 한다.");
}

정상 경로의 마지막에서만 파일을 닫는다

다음 코드는 ReadToEnd가 실패하면 Dispose에 도달하지 못한다. 나중에 객체가 회수되기를 기다리는 동안 파일 자원이 불필요하게 유지될 수 있다.

StreamReader reader = File.OpenText(ordersPath);
string text = reader.ReadToEnd();
reader.Dispose();
Console.WriteLine(text);

정리 책임을 using에 맡기면 정상 완료와 예외 경로 모두 같은 수명 규칙을 따른다. 읽은 문자열은 파일을 닫은 뒤에도 사용할 수 있다.

string text;
using (StreamReader reader = File.OpenText(ordersPath))
{
    text = reader.ReadToEnd();
}
Console.WriteLine(text);

재고를 바꾼 다음 실패를 검사한다

아래 코드는 보류할 주문 때문에 재고를 음수로 만든다. 호출자가 예외를 잡아도 사전의 값은 이미 바뀌었다. 또한 예외에 담은 재고가 주문 직전의 값과 달라진다.

stock[menu] -= quantity;
if (stock[menu] < 0)
{
    throw new StockShortageException(menu, quantity, stock[menu]);
}

이 부분에서는 음료 등록 여부와 양의 수량 검사가 끝났다고 가정한다. 기존 값을 읽어 검증하고, 검증이 통과했을 때만 수정한다. 같은 원칙은 파일 저장에도 적용된다. 예외를 잡았다고 중간까지 기록된 파일이 자동으로 원상 복구되지는 않는다.

int available = stock[menu];
if (quantity > available)
{
    throw new StockShortageException(menu, quantity, available);
}
stock[menu] = available - quantity;

다시 던질 때 예외 변수를 지정한다

예외를 기록만 하고 처리는 호출자에게 맡겨야 할 수 있다. 이때 throw ex;를 사용하면 스택 추적의 일부가 다시 던진 위치를 기준으로 바뀌어 최초 발생 경로를 확인하기 어려워진다.

catch (IOException ex)
{
    Console.Error.WriteLine(ex.Message);
    throw ex;
}

같은 예외를 다시 전달하려면 catch 안에서 throw;를 사용한다. 기록이나 추가 동작도 필요 없다면 해당 catch를 두지 않고 자연스럽게 전파되도록 하는 편이 간단하다.

catch (IOException ex)
{
    Console.Error.WriteLine(ex.Message);
    throw;
}

정리 코드에서 새 예외가 발생하는 경우도 주의해야 한다. 원래 작업이 실패한 뒤 finally의 삭제마저 실패하면 호출자에게는 삭제 예외가 전달되어 원래 실패를 가릴 수 있다. 완성 코드는 정리 실패도 작업 실패로 알리는 간단한 정책을 택했다. 운영 코드에서는 원래 오류와 정리 오류를 모두 남길 수 있도록 기록 정책을 정해야 한다. finally가 있다는 사실만으로 정리가 성공하거나 원인이 모두 보존되는 것은 아니다.

한눈에 보기

예외 처리와 파일 작업의 책임을 구분한다
도구맡기는 책임기억할 점
try실패를 처리할 작업 범위 지정예외 발생 이후의 문장은 건너뛴다
catch특정 타입의 실패에 대응이미 변경한 상태를 되돌리지는 않는다
finally범위를 벗어날 때 필요한 정리정리 작업 자체도 실패할 수 있다
using정리 가능한 객체의 수명 관리객체의 정리를 보장하는 문법이지 오류 처리기는 아니다
Path.Combine경로 조각 결합폴더 생성이나 입력 검증을 대신하지 않는다
File.WriteAllLines여러 줄을 쓰고 파일 닫기기존 파일 내용을 덮어쓴다
File.OpenText텍스트 읽기 객체 생성반환받은 객체를 정리해야 한다
사용자 정의 예외업무 실패를 구별 가능한 타입으로 전달메시지와 판단용 속성을 분리한다

완성 코드의 처리 순서는 입력 파일 준비, 한 줄 읽기, 주문 검증, 재고 변경, 결과 저장, 임시 자원 정리다. 이 순서를 따라 각 단계에서 실패했을 때 어디로 이동하는지 설명할 수 있으면 예외 처리 범위도 검토할 수 있다. 특히 승인 메시지가 출력되는 위치와 재고가 실제로 바뀌는 위치를 함께 확인해야 한다.

연습 문제

  1. 주문 파일의 두 번째 줄을 아메리카노|3으로 바꾸면 어떤 주문 메시지와 재고 줄이 달라지는지 적어 본다. 재고 부족 검사에서 >와 >= 중 어느 연산자가 맞는지도 설명한다.
  2. 네 번째 주문 뒤에 라테|0과 모카|1을 이 순서로 추가한다. 새로 출력될 두 줄과 최종 재고를 예상하고, 두 주문이 실패하는 위치를 각각 설명한다.
  3. File.ReadAllLines(stockPath)를 사용한 재고 출력 부분을 File.OpenText와 using 블록으로 바꾼다. 기존 출력 순서와 내용을 유지한다.
  4. 첫 번째 주문을 처리하기 위해 ReadLine을 호출하던 중 IOException이 발생했다고 가정한다. 읽기 객체 정리, 폴더 삭제, 바깥 catch의 실행 순서를 설명한다. 폴더 삭제도 실패한다면 무엇이 달라지는지 적는다.

정답과 해설

1. 남은 재고와 같은 수량은 승인한다

두 번째 주문의 요청량이 3잔이면 재고 3잔으로 처리할 수 있다. 따라서 quantity > available이 맞다. >=로 바꾸면 재고를 모두 사용하는 정상 주문도 보류한다. 달라지는 줄은 다음과 같고, 나머지 출력은 그대로다.

2행 승인: 아메리카노 3잔
아메리카노|0

사용 가능한 수량의 경계에서 조건식을 확인하면 비교 연산자 실수를 찾기 쉽다. 재고보다 하나 적은 수량, 같은 수량, 하나 많은 수량을 생각해 보는 방식이 유용하다.

2. 수량 검증과 음료 검증의 위치가 다르다

다섯 번째 줄은 TryParse 자체는 성공하지만 quantity <= 0 조건에 걸린다. 따라서 ParseOrder에서 실패한다. 여섯 번째 줄은 형식과 수량 검사를 통과한 뒤 ApplyOrder의 TryGetValue가 실패한다. 추가되는 주문 메시지는 다음과 같다.

5행 제외: 수량은 1 이상의 정수여야 한다.
6행 제외: 등록되지 않은 음료다.

두 경우 모두 재고 대입 문장에 도달하지 않는다. 원래 입력을 기준으로 라테는 2잔, 아메리카노는 3잔 그대로다. 같은 FormatException을 사용하지만 발생 위치와 이유는 다르다.

3. 재고도 한 줄씩 읽는다

기존의 foreach 부분을 다음 블록으로 교체한다. 바로 앞의 ‘저장한 재고’ 출력은 유지한다. 줄 순서대로 읽어 출력하므로 결과는 같고, 블록이 끝나면 읽기 객체가 정리된다.

using (StreamReader reader = File.OpenText(stockPath))
{
    while (reader.ReadLine() is string savedLine)
    {
        Console.WriteLine(savedLine);
    }
}

앞서 주문 파일에 사용했던 reader는 별도의 using 블록 안에 선언되어 있다. 그 범위가 끝났으므로 이 위치에서 같은 이름을 사용할 수 있다. 큰 파일에서는 모든 줄을 배열에 담는 방식과 한 줄씩 읽는 방식의 메모리 사용 차이도 고려해야 한다.

4. 안쪽 자원을 먼저 정리하고 바깥으로 이동한다

읽기 객체의 정리와 폴더 삭제가 성공한다고 가정하면, ReadLine에서 시작한 예외는 먼저 using 블록을 벗어나게 한다. 읽기 객체가 정리되고, RunCafe의 finally에서 폴더를 삭제한다. 정리 완료 문구를 출력한 뒤 최초의 입출력 예외가 바깥 catch에 도달한다. 이때 재고 저장과 재고 출력은 건너뛴다.

주문 처리
임시 작업 폴더 정리 완료
파일 작업 실패: 읽기, 쓰기 또는 정리를 마치지 못했다.

폴더 삭제에서 새 예외가 발생하면 정리 완료 문구는 출력되지 않는다. 바깥으로 전달되는 예외도 삭제에서 발생한 예외가 된다. 삭제 오류가 IOException이면 입출력 실패 문구가, UnauthorizedAccessException이면 권한 안내가 출력된다. 원래 읽기 오류를 함께 보존하려면 별도의 기록이나 두 오류를 함께 전달하는 설계가 필요하다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.