Skip to main content

14. 파일 입출력, 예외 처리, 디버깅

지금까지는 주로 메모리 안에서만 살아가는 프로그램을 만들었다. 하지만 현실의 프로그램은 실행이 끝난 뒤에도 데이터를 남겨야 하고, 예상치 못한 실패를 버텨야 하며, 문제가 생겼을 때 원인을 찾아낼 수 있어야 한다.

예를 들어 메모장 프로그램은 글을 파일에 저장해야 하고, 게임은 저장 데이터를 읽어야 하며, 설정 파일이 없을 때는 사용자에게 알려줘야 한다. 그리고 프로그램이 이상하게 동작하면 "왜 틀렸지?"를 추적해야 한다. 이 장에서는 바로 그 세 가지, 파일 입출력, 예외 처리, 디버깅을 함께 익힌다. 실전 프로그램은 "돌아간다"에서 끝나지 않는다. "실패해도 다룰 수 있다"까지 가야 비로소 믿을 만한 프로그램이 된다.

파일 입출력, 예외 처리, 디버깅

파일 입출력이 왜 중요한가

프로그램의 변수는 보통 실행이 끝나면 사라진다. 그래서 중요한 정보는 파일에 저장해야 한다. 파일은 프로그램이 종료된 뒤에도 남아 있으므로, 다음 실행 때 다시 읽어 올 수 있다.1

초보자 입장에서는 파일을 어렵게 느끼기 쉽다. 하지만 생각은 단순하다. cin, cout이 키보드와 화면을 연결하는 통로라면, 파일 스트림은 파일과 연결되는 통로다. 즉, 파일도 "입출력의 대상"일 뿐이다.2

C++에서는 <fstream> 헤더를 통해 파일 스트림을 사용한다. 가장 자주 만나는 클래스는 다음 세 가지다.12

  • std::ifstream: 파일에서 읽기

  • std::ofstream: 파일에 쓰기

  • std::fstream: 읽기와 쓰기를 둘 다 하기

이름이 조금 길어 보여도 뜻은 직관적이다. in, out, 그리고 둘 다 가능한 fstream이다.

텍스트 파일 쓰기

가장 쉬운 시작은 텍스트 파일에 한 줄을 써 보는 것이다.

#include <iostream>
#include <fstream>

int main() {
    std::ofstream outFile("memo.txt");

    if (!outFile.is_open()) {
        std::cerr << "파일을 열 수 없습니다.n";
        return 1;
    }

    outFile << "오늘의 할 일n";
    outFile << "- C++ 공부하기n";
    outFile << "- 산책하기n";

    outFile.close();
    return 0;
}

이 코드는 memo.txt라는 파일을 열고 내용을 기록한다. << 연산자를 쓰는 모습이 cout과 거의 같다는 점이 중요하다. 파일 스트림도 출력 스트림이기 때문이다.2

여기서 꼭 눈여겨볼 부분은 is_open() 검사다. 파일 경로가 잘못되었거나, 권한이 없거나, 환경 문제로 파일을 열지 못할 수도 있다. 초보자는 파일이 "당연히 열릴 것"이라고 가정하기 쉬운데, 실전에서는 그 가정이 자주 깨진다.

또 한 가지, close()는 좋은 습관이다. 스트림 객체가 범위를 벗어나면 자동으로 닫히기도 하지만, 작업이 끝났음을 코드에서 분명히 드러내면 읽기도 쉽고 버퍼가 디스크에 기록되는 시점도 명확해진다.23

텍스트 파일 읽기

이번에는 파일 내용을 읽어 보자.

#include <iostream>
#include <fstream>
#include <string>

int main() {
    std::ifstream inFile("memo.txt");

    if (!inFile.is_open()) {
        std::cerr << "파일을 열 수 없습니다.n";
        return 1;
    }

    std::string line;
    while (std::getline(inFile, line)) {
        std::cout << line << 'n';
    }

    inFile.close();
    return 0;
}

std::getline()은 한 줄 전체를 읽는다. 이것이 아주 중요하다. >> 연산자는 공백에서 입력을 끊기 때문에 문장 전체를 읽기엔 불편하다. 예를 들어 Hello C++ World를 >>로 읽으면 Hello만 먼저 읽힌다. 반면 getline()은 줄 끝까지 읽어 준다.23

초보자에게 자주 일어나는 실수는 이렇다. "왜 파일에서 한 단어만 읽히지?" 그럴 때는 높은 확률로 >>를 사용하고 있다. 여러 단어가 들어 있는 줄을 읽고 싶다면 getline()을 먼저 떠올리자.

파일 열기 모드 이해하기

파일을 열 때는 어떻게 열지 정할 수 있다. 이것을 파일 열기 모드라고 한다. 기본만 알아도 실전에서 꽤 오래 버틴다.13

  • std::ios::in: 읽기 모드

  • std::ios::out: 쓰기 모드

  • std::ios::app: 파일 끝에 이어 쓰기

  • std::ios::binary: 바이너리 모드

  • std::ios::trunc: 기존 내용 비우고 새로 쓰기

예를 들어 기존 파일 뒤에 내용을 덧붙이고 싶다면 이렇게 쓴다.

std::ofstream outFile("log.txt", std::ios::app);

이렇게 하면 기존 내용은 유지되고, 새 내용이 파일 맨 뒤에 추가된다. 일기장에 새 페이지를 붙이는 느낌이다.

반대로 그냥 std::ofstream outFile("log.txt");처럼 열면 보통 쓰기 모드로 열리며, 기존 파일 내용을 새로 덮어쓸 수 있다. 초보자가 로그 파일을 만들다가 이전 기록을 날려버리는 일이 자주 생기니 주의하자.13

파일 상태 확인하기

파일 스트림은 현재 상태를 알려 주는 함수도 제공한다. 가장 먼저 기억할 것은 "스트림은 실패할 수 있다"는 사실이다.1

자주 보는 상태 확인 함수는 다음과 같다.

  • is_open(): 파일이 열렸는가

  • fail(): 읽기/쓰기 또는 형식 처리에 실패했는가

  • bad(): 더 심각한 입출력 실패가 있는가

  • eof(): 파일 끝에 도달했는가

  • good(): 전반적으로 문제 없는가

하지만 초보자에게는 너무 많은 상태 함수를 한꺼번에 외우기보다, 먼저 다음 패턴을 익히는 편이 좋다.

std::ifstream file("data.txt");
if (!file) {
    std::cerr << "파일 열기 실패n";
}

스트림 객체는 불리언처럼 검사할 수 있다. 열기 실패나 읽기 실패가 있으면 거짓처럼 동작한다.3

읽기 작업도 마찬가지다.

std::string line;
if (!std::getline(file, line)) {
    std::cerr << "한 줄 읽기 실패 또는 파일 끝 도달n";
}

이런 식으로 "작업 직후 결과를 검사하는 습관"이 중요하다. 문제를 늦게 발견할수록 원인을 찾기 어려워진다.

간단한 예제: 점수 저장하고 불러오기

파일 입출력은 작게 만들어 보면 금방 익숙해진다. 아래 예제는 학생 점수를 저장하고 다시 읽어 오는 프로그램이다.

#include <iostream>
#include <fstream>
#include <string>

int main() {
    {
        std::ofstream outFile("scores.txt");
        if (!outFile) {
            std::cerr << "scores.txt를 열 수 없습니다.n";
            return 1;
        }

        outFile << "Kim 95n";
        outFile << "Lee 88n";
        outFile << "Park 100n";
    }

    {
        std::ifstream inFile("scores.txt");
        if (!inFile) {
            std::cerr << "scores.txt를 읽을 수 없습니다.n";
            return 1;
        }

        std::string name;
        int score;

        while (inFile >> name >> score) {
            std::cout << name << " 학생의 점수: " << score << 'n';
        }
    }

    return 0;
}

이 예제는 한 줄에 이름과 점수가 공백으로 구분되어 있으므로 >>로 읽기 좋다. 반대로 문장 전체나 메모처럼 공백이 포함된 데이터를 다룬다면 getline()이 더 잘 맞는다. 즉, 입력 형식에 따라 읽는 방식을 고르면 된다.

예외 처리가 필요한 이유

이제 실패를 다루는 두 번째 도구로 넘어가 보자. 파일 열기 실패, 잘못된 인덱스 접근, 0으로 나누기 같은 문제는 실행 중에 발생한다. 이런 상황을 구조적으로 처리하기 위해 C++는 예외 처리 기능을 제공한다.45

예외 처리는 세 단어로 요약된다.

  • try: 문제가 생길 수 있는 코드를 감싼다.

  • throw: 문제가 생겼다고 알린다.

  • catch: 던져진 예외를 받아 처리한다.

쉽게 말해, try는 "여기 위험 구간이에요", throw는 "문제 발생!", catch는 "좋아, 내가 처리할게"에 가깝다.

try-catch의 기본 구조

가장 단순한 예부터 보자.

#include <iostream>
#include <stdexcept>

int divide(int a, int b) {
    if (b == 0) {
        throw std::runtime_error("0으로 나눌 수 없습니다.");
    }
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 2) << 'n';
        std::cout << divide(10, 0) << 'n';
    }
    catch (const std::exception& e) {
        std::cerr << "오류: " << e.what() << 'n';
    }

    return 0;
}

divide(10, 0)이 호출되면 throw가 실행되고, 그 즉시 현재 지점 아래의 코드는 건너뛰고 catch로 이동한다. catch에서는 e.what()으로 오류 메시지를 얻을 수 있다.5

여기서 중요한 습관이 하나 있다. 표준 예외는 보통 const std::exception&처럼 참조로 받는 것이 좋다. 값으로 받으면 불필요한 복사가 생기고, 다형적인 예외 정보가 잘릴 수도 있다.5

어떤 예외를 던져야 할까

문법적으로는 정수, 문자열, 사용자 정의 객체 등 거의 무엇이든 던질 수 있다. 하지만 초보자 단계에서는 표준 예외 클래스를 우선 사용하는 편이 훨씬 낫다. std::runtime_error, std::invalid_argument, std::out_of_range 같은 이름은 오류의 성격을 더 잘 드러내기 때문이다.5

예를 들어 파일 읽기 함수에서 실패를 예외로 바꾸면 이렇게 쓸 수 있다.

#include <fstream>
#include <string>
#include <stdexcept>

std::string readFirstLine(const std::string& filename) {
    std::ifstream file(filename);
    if (!file) {
        throw std::runtime_error("파일을 열 수 없습니다: " + filename);
    }

    std::string line;
    if (!std::getline(file, line)) {
        throw std::runtime_error("첫 줄을 읽을 수 없습니다.");
    }

    return line;
}

이 함수는 실패 시 조용히 잘못된 값을 돌려주는 대신, "무슨 일이 잘못됐는지"를 분명하게 알린다. 호출하는 쪽에서는 try-catch로 처리하면 된다.

#include <iostream>
#include <exception>

int main() {
    try {
        std::string firstLine = readFirstLine("config.txt");
        std::cout << firstLine << 'n';
    }
    catch (const std::exception& e) {
        std::cerr << "설정 읽기 실패: " << e.what() << 'n';
    }
}

여러 catch 블록과 catch(...)

상황에 따라 예외 종류별로 다르게 처리하고 싶을 수 있다. 그럴 때는 catch를 여러 개 둘 수 있다.45

try {
    // 예외가 발생할 수 있는 코드
}
catch (const std::out_of_range& e) {
    std::cerr << "범위를 벗어났습니다: " << e.what() << 'n';
}
catch (const std::invalid_argument& e) {
    std::cerr << "잘못된 인자입니다: " << e.what() << 'n';
}
catch (const std::exception& e) {
    std::cerr << "기타 표준 예외: " << e.what() << 'n';
}
catch (...) {
    std::cerr << "알 수 없는 예외가 발생했습니다.n";
}

순서도 중요하다. 더 구체적인 예외를 먼저, 더 넓은 예외를 나중에 둬야 한다. std::exception을 맨 위에 두면 그 아래의 세부 예외가 설 자리를 잃는다.5

catch (...)는 만능 그물망 같지만, 남발하면 예외의 정체를 알기 어려워진다. 정말 마지막 안전망이 필요할 때만 두는 편이 좋다.

예외와 if문은 어떻게 다를까

초보자는 종종 이런 의문을 가진다. "그냥 if로 검사하면 되지 않나요?" 맞다. 많은 경우 if로 충분하다.

예를 들어 사용자가 1부터 10까지 입력해야 한다면, 단순 검사는 if가 더 직관적이다. 반면 함수 여러 개를 거치며 깊은 호출 흐름을 타고 올라가야 하는 실패는 예외가 편할 수 있다. 즉, 예외는 "정상 흐름의 일부"보다는 "비정상 상황의 전달"에 더 어울린다.5

초보자용 기준으로 정리하면 이렇다.

  • 단순 입력 검증은 if

  • 파일 열기 실패처럼 외부 환경 문제는 if 또는 예외

  • 함수 여러 층을 지나며 실패를 전파해야 하면 예외가 유리

예외를 남발할 필요는 없지만, 실패를 무시하는 습관은 더 위험하다.

예외가 발생해도 자원이 안전해야 한다

예외를 배우면 꼭 함께 떠올려야 할 것이 있다. 바로 자원 관리다. 만약 new로 메모리를 할당한 뒤 예외가 발생하면, delete가 실행되지 못해 메모리 누수가 생길 수 있다. 그래서 C++에서는 자원을 객체에 맡기고, 객체가 사라질 때 자동으로 정리되게 만드는 방식이 중요하다. 이런 생각이 이후 장에서 배울 스마트 포인터와도 이어진다.5

지금 단계에서는 원칙만 기억하자. "예외가 나더라도 정리가 되도록 코드를 짜자." 파일 스트림 객체가 범위를 벗어나면 자동으로 닫히는 것도 이런 안전한 설계의 한 예다.2

디버깅은 버그와 대화하는 기술이다

이제 세 번째 주제, 디버깅으로 가 보자. 디버깅은 오류를 찾고 고치는 과정이다. 프로그램이 아예 크래시할 수도 있고, 멀쩡히 실행되는데 결과만 틀릴 수도 있다. 둘 다 디버깅의 대상이다.67

초보자는 종종 디버깅을 "감으로 찍기"라고 생각한다. 하지만 좋은 디버깅은 추측 게임이 아니라 확인의 연속이다.

먼저 스스로에게 두 가지를 묻자.

  • 원래 무엇이 일어나길 기대했는가?

  • 실제로는 무엇이 일어났는가?

이 두 질문이 분명해야 문제를 좁힐 수 있다.7

버그의 세 가지 얼굴

디버깅할 때 버그를 크게 세 부류로 나누면 머리가 맑아진다.

  • 컴파일 오류: 아예 빌드가 되지 않는다.

  • 런타임 오류: 실행 중에 터진다.

  • 논리 오류: 실행은 되지만 결과가 틀리다.

예를 들어 세미콜론을 빼먹은 것은 컴파일 오류다. 널 포인터를 역참조해 프로그램이 죽는 것은 런타임 오류다. 평균을 계산했는데 이상한 값이 나오는 것은 논리 오류다. 종류를 구분하면 접근 방식도 달라진다.

첫 번째 습관: 에러 메시지를 끝까지 읽기

초보자가 가장 많이 놓치는 디버깅 도구는 사실 컴파일러 메시지다. 에러 메시지는 길고 무섭게 보이지만, 대부분 파일 이름, 줄 번호, 무엇이 잘못되었는지 힌트를 준다.7

예를 들어 이런 메시지가 있다고 하자.

main.cpp:12:5: error: 'cout' was not declared in this scope

이 말은 대충 "main.cpp의 12번째 줄 근처에서 cout를 모르겠다고 한다"는 뜻이다. 그럼 보통 <iostream>을 빼먹었거나 std::cout를 쓰지 않은 것이다.

에러가 여러 개 떠도 너무 겁먹지 말자. 첫 번째 에러를 고치면 뒤에 따라온 연쇄 에러가 함께 사라지는 경우가 많다.

두 번째 습관: 출력으로 확인하기

가장 쉬운 디버깅은 중간 값을 출력해 보는 것이다. 이것을 흔히 print debugging이라고 한다.6

#include <iostream>

int main() {
    int x = 10;
    int y = 0;

    std::cout << "나누기 직전n";
    int z = x / y;
    std::cout << "나누기 직후n";

    return 0;
}

이 프로그램에서 나누기 직후가 출력되지 않는다면, 문제 지점은 x / y 주변이라는 뜻이다. 아주 원시적으로 보이지만, 놀랄 만큼 자주 통한다.

논리 오류를 찾을 때도 유용하다.

int sum = 0;
for (int i = 0; i <= 5; ++i) {
    std::cout << "i = " << i << ", sum = " << sum << 'n';
    sum += i;
}

이렇게 찍어 보면 반복문의 범위가 맞는지, 값이 언제 이상해지는지 금방 보인다. 버그는 어둠을 좋아하지만, 출력문은 손전등 역할을 한다.

세 번째 습관: 디버거로 한 줄씩 따라가기

출력만으로 부족할 때는 디버거를 사용한다. 디버거는 프로그램을 멈추고, 한 줄씩 실행하고, 변수 값을 들여다보게 해 주는 도구다. Visual Studio, VS Code, Code::Blocks 같은 IDE에는 보통 디버거가 들어 있다.67

디버거에서 가장 중요한 기능은 다음 세 가지다.

  • 중단점(Breakpoint): 여기서 잠깐 멈춰

  • 한 줄 실행(Step over / Step into): 천천히 가 보자

  • 변수 관찰(Watch): 지금 값이 얼마지?

예를 들어 평균 점수를 계산하는 함수가 이상하다면, 결과를 추측만 하지 말고 중단점을 걸어서 sum, count, average 값이 어떻게 바뀌는지 직접 보자. 디버깅은 코드와 말싸움하는 일이 아니라, 증거를 수집하는 일이다.7

네 번째 습관: 문제를 작게 만들기

코드가 길수록 버그는 숨어 있기 쉽다. 그럴 때는 문제를 가능한 한 작은 예제로 줄여 보자.7

예를 들어 파일 저장, 메뉴 처리, 정렬, 출력이 한 프로그램에 다 섞여 있는데 이상한 결과가 나온다면, 먼저 파일 저장 부분만 남겨 테스트한다. 그다음 읽기 부분만, 그다음 계산 부분만 확인한다.

복잡한 문제를 작은 블록으로 잘라 보는 것은 초보자에게 특히 강력하다. 큰 프로그램을 통째로 이해하려다 지치는 대신, 작은 단위에서 "여기는 정상"이라고 하나씩 체크할 수 있기 때문이다.

초보자가 자주 하는 디버깅 실수

디버깅을 어렵게 만드는 흔한 습관도 있다.

  • 추측만 하고 확인하지 않는다.

  • 코드를 여러 군데 동시에 고친다.

  • 경고 메시지를 무시한다.

  • 입력값이 항상 정상일 거라고 믿는다.

  • 어제 잘 됐으니 오늘도 같을 거라고 생각한다.

특히 경고는 무시하지 말자. 경고는 "아직 안 터졌지만 냄새가 난다"는 신호인 경우가 많다.

컴파일 옵션도 디버깅 도구다

터미널에서 컴파일한다면 경고와 디버그 정보를 켜는 습관이 좋다.

g++ -Wall -Wextra -g main.cpp -o app

-Wall, -Wextra는 더 많은 경고를 보여 주고, -g는 디버거가 소스 코드와 변수 정보를 잘 볼 수 있게 도와준다. 초보자일수록 "조용한 컴파일"보다 "친절한 컴파일"이 훨씬 낫다.

파일, 예외, 디버깅을 한 번에 보는 실전 예제

마지막으로 세 주제를 묶어 보자. 아래 프로그램은 숫자가 들어 있는 파일을 읽어 합계를 계산한다. 파일이 없으면 예외를 던지고, 숫자가 아닌 값이 있으면 오류를 알린다.

#include <iostream>
#include <fstream>
#include <string>
#include <stdexcept>

int readAndSum(const std::string& filename) {
    std::ifstream file(filename);
    if (!file) {
        throw std::runtime_error("파일을 열 수 없습니다: " + filename);
    }

    int sum = 0;
    int value;

    while (file >> value) {
        sum += value;
    }

    if (!file.eof()) {
        throw std::runtime_error("숫자가 아닌 데이터가 포함되어 있습니다.");
    }

    return sum;
}

int main() {
    try {
        int result = readAndSum("numbers.txt");
        std::cout << "합계: " << result << 'n';
    }
    catch (const std::exception& e) {
        std::cerr << "오류 발생: " << e.what() << 'n';
        return 1;
    }

    return 0;
}

이 코드를 디버깅한다면 이렇게 접근할 수 있다.

먼저 numbers.txt가 실제로 있는지 확인한다. 없다면 파일 열기 예외가 날 것이다. 파일이 있는데도 실패하면 경로를 의심한다. 그다음 파일 안에 정말 숫자만 들어 있는지 본다. 만약 10 20 apple 30처럼 되어 있다면 file >> value가 중간에 실패하고, eof()가 아닌 상태에서 루프가 끝나므로 예외가 발생한다. 필요하다면 루프 안에서 sum과 value를 출력하거나, 디버거로 읽기 직후 값을 확인하면 된다.

이런 식으로 파일 입출력, 오류 처리, 디버깅은 따로 노는 주제가 아니다. 셋은 늘 함께 움직인다.

좋은 오류 처리와 디버깅 습관 정리

이 장의 핵심은 문법보다 태도에 있다. 실용적인 C++ 프로그램을 만들려면 다음 습관이 중요하다.

  • 파일은 열기 전에 성공을 가정하지 말고, 연 뒤에 확인하기

  • 읽기와 쓰기 직후 결과 검사하기

  • 실패를 조용히 무시하지 말고, 메시지나 예외로 드러내기

  • 예외는 const std::exception&로 받는 습관 들이기

  • 버그가 나면 추측보다 출력과 디버거로 확인하기

  • 에러 메시지와 경고를 끝까지 읽기

프로그래밍은 실수하지 않는 사람이 잘하는 것이 아니다. 실수를 빨리 발견하고, 안전하게 처리하고, 차분히 고치는 사람이 잘한다. 파일 입출력으로 프로그램은 세상과 연결되고, 예외 처리로 실패를 버티며, 디버깅으로 문제를 이해하게 된다. 이 세 가지를 익히면 C++ 코드가 한 단계 더 현실적인 프로그램으로 변하기 시작한다.

참고

1Input/output with files - C++ Users

228.6 — Basic file I/O

3C++ Files

4Handling exceptions

5Exception Handling in C++

6C++ Debugging

7Debugging code for absolute beginners - Visual Studio (Windows) | Microsoft Learn

책 목록

초보자를 위한 C++ 수업: 기초부터 현대 C++까지 한 권으로 끝내기

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기