Skip to content
This page has been auto-translated and may contain errors.View in English

파일과 예외

실제로 의미 있는 일을 하는 대부분의 프로그램은 파일시스템에 접근합니다. 설정을 읽고, 결과를 쓰고, 데이터를 불러옵니다. 그리고 문제가 발생하면 Python은 **예외(exception)**를 발생시킵니다. 예상치 못한 일이 일어났음을 알리는 신호입니다. 이 장에서는 두 가지를 다룹니다. 파일에서 데이터를 읽고 쓰는 방법, 그리고 충돌하지 않고 오류를 우아하게 처리하는 코드를 작성하는 방법입니다.

파일 입출력과 예외 처리는 프로그램을 견고하게 만드는 두 가지 메커니즘입니다. open()은 파일시스템에 접근할 수 있게 해주고, with는 오류가 발생해도 정리가 이루어지도록 보장합니다. try/except는 특정 예외 타입을 잡아 충돌 대신 우아하게 복구할 수 있게 합니다. 이 둘은 무인으로 실행되는 모든 스크립트의 기반을 형성합니다.

Python의 파일 작업은 OS의 파일 디스크립터 추상화를 통해 이루어집니다. open()은 모드와 버퍼링 설정에 따라 타입이 달라지는 파일 객체를 반환합니다. 컨텍스트 매니저는 __enter____exit__을 통해 리소스 정리를 보장합니다. 예외 처리는 구조화된 언와인딩(structured unwinding)을 사용합니다. 예외가 전파되면 Python은 호출 스택을 거슬러 올라가며 일치하는 except 절을 찾고, 그 과정에서 finally 블록은 무조건 실행됩니다.

파일 열기

open()은 파일을 열고, 그로부터 읽거나 쓸 수 있는 객체를 반환합니다. 경로와 파일로 무엇을 할지(읽기, 쓰기, 추가)를 지정합니다. 사용을 마치면 반드시 파일을 닫아야 하는데, with 문이 이를 자동으로 처리해줍니다.

open(path, mode)는 파일 객체를 반환합니다. 모드 문자열로 접근 방식을 제어합니다. "r"은 읽기, "w"는 쓰기(먼저 잘라냄), "a"는 추가입니다. "b"를 붙이면 바이너리 모드가 됩니다. 텍스트 모드의 기본 인코딩은 시스템 로케일이므로, 이식성을 위해서는 encoding="utf-8"을 명시적으로 지정하세요.

open()은 OS를 호출하여 파일 디스크립터를 할당받고 버퍼링된 I/O 래퍼를 반환합니다. 텍스트 모드는 TextIOWrapper로 감싸며, 이는 인코딩과 보편적인 줄바꿈을 처리합니다. 바이너리 모드는 BufferedReader 또는 BufferedWriter를 반환합니다. encoding 매개변수는 이식성에 매우 중요합니다. 기본값(locale.getpreferredencoding())은 플랫폼마다 다르기 때문입니다. 특별한 이유가 없는 한 텍스트 파일에는 항상 encoding="utf-8"을 지정하세요.

python
f = open("data.txt", "r")    # "r" = 읽기
content = f.read()
f.close()

"r"은 **모드(mode)**입니다.

모드의미
"r"읽기. 파일이 존재해야 함. 기본 모드.
"w"쓰기. 파일을 새로 만들거나 덮어씀.
"a"추가. 기존 내용을 지우지 않고 끝에 덧붙임.
"x"생성. 파일이 이미 존재하면 실패.
"r+"읽기 및 쓰기.
"b"바이너리. 다른 모드에 결합: "rb", "wb".

사용을 마치면 반드시 .close()를 호출하세요. 잊어버리면 파일이 잠긴 채로 남게 되고 데이터 손상을 일으킬 수 있습니다. 이를 안전하게 처리하는 방법이 바로 with 문입니다.

with

with open(...)는 파일을 자동으로 관리해주며, 들여쓰기 블록이 끝나면(혹은 오류가 발생해도) 알아서 닫아줍니다. 수동으로 open()/close()를 쓰는 대신 항상 with open(...)을 사용하세요. 더 안전하고 표준적인 방식입니다.

with는 Python의 컨텍스트 매니저(context manager) 문법입니다. 시작 시점에 __enter__를, 종료 시점에 __exit__를 호출하며, 예외가 발생해도 마찬가지로 동작합니다. 파일의 경우 __exit__이 파일 디스크립터를 닫습니다. 이렇게 하면 매번 파일 접근 시 try/finally로 감쌀 필요 없이 정리가 보장됩니다.

with expr as nameexpr.__enter__()를 호출하여 그 결과를 name에 바인딩합니다. 종료 시(정상이든 예외든) expr.__exit__(exc_type, exc_val, tb)이 호출됩니다. __exit__이 참 값을 반환하면 예외가 억제됩니다. 파일 객체는 이 프로토콜을 구현합니다. __exit__self.close()를 호출합니다. 컨텍스트 매니저는 중첩할 수 있습니다. with open(a) as f, open(b) as g:는 여러 파일을 다룰 때 관용적인 표현입니다.

python
with open("data.txt", "r") as f:
    content = f.read()

# 여기서 f는 보장된 방식으로 닫힘

with는 무엇을 하는가?

with는 Python의 컨텍스트 매니저 문법입니다. 설정과 정리 코드를 대신 호출해주며, 여기서는 파일을 열고 안전하게 닫는 역할을 합니다. 내부 동작을 자세히 알 필요는 없습니다. 그냥 open()과 함께 사용하세요.

파일 읽기

읽기에는 세 가지 방법이 있습니다. .read()는 파일 전체를 하나의 문자열로 불러옵니다. .readline()은 한 줄을 읽습니다. 파일 객체를 직접 순회하면 한 줄씩 읽는데, 이는 모든 내용을 메모리에 한꺼번에 올리지 않으므로 큰 파일을 다룰 때 가장 효율적입니다.

.read()는 파일 전체를 메모리에 불러옵니다. .readline()은 줄바꿈 문자를 포함하여 한 줄을 읽습니다. .readlines()는 모든 줄을 리스트로 반환합니다. 파일 객체를 직접 순회하는 것은 큰 파일에 가장 메모리 효율적인 패턴입니다. 전체 내용을 보유하지 않고 버퍼에서 한 줄씩 읽기 때문입니다.

.read()는 EOF까지 읽어 내용을 문자열(또는 바이너리 모드에서는 바이트)로 반환합니다. .readline()\n 또는 EOF까지 읽습니다. 파일 객체를 순회하면 __iter__가 호출되고, 이는 readline()을 반복 호출합니다. O(1) 메모리, 줄 단위 처리가 가능합니다. .readlines()list(file)과 동등하지만 모든 줄을 미리 구체화합니다. 매우 큰 파일에서는 .read()보다 이터레이터 패턴이 선호됩니다.

python
with open("data.txt", "r") as f:
    content = f.read()          # 파일 전체를 하나의 문자열로

with open("data.txt", "r") as f:
    first_line = f.readline()   # 한 줄씩

with open("data.txt", "r") as f:
    lines = f.readlines()       # 각 줄이 "\n"으로 끝나는 리스트

큰 파일의 경우, 한 번에 모두 읽는 것보다 한 줄씩 읽는 것이 더 효율적입니다.

python
with open("big_file.txt", "r") as f:
    for line in f:              # 파일을 직접 순회, 메모리 효율적
        print(line.strip())     # strip()은 끝의 줄바꿈을 제거

파일 객체를 직접 순회하는 것(for line in f)이 큰 파일을 읽는 가장 효율적이고 관용적인 방법입니다.

파일 쓰기

"w" 모드는 파일이 존재하면 전체를 덮어씁니다. "a" 모드는 끝에 추가합니다. .write()는 자동으로 줄바꿈을 추가하지 않으므로, 각 줄의 끝에 "\n"을 명시적으로 포함해야 합니다. 여러 줄을 한꺼번에 쓰려면 "\n".join()으로 합치세요.

.write(s)는 문자열을 쓰고 쓰여진 문자 수를 반환합니다. 줄바꿈을 자동으로 추가하지 않습니다. .writelines(iterable)은 이터러블의 각 문자열을 구분자 없이 씁니다. "w"는 열 때 잘라내고, "a"는 EOF로 이동합니다. 이식성을 위해 플랫폼별 줄 끝을 하드코딩하기보다는 "\n"이나 os.linesep을 사용하세요.

.write()는 버퍼에 쓰며, .flush().close()가 호출될 때까지 데이터가 디스크에 도달하지 않을 수 있습니다. "w" 모드는 열 때 truncate(0)을 호출하여 이전 내용을 모두 파괴합니다. "a" 모드는 매 쓰기 전에 EOF로 이동하므로 (대부분의 OS에서) 동시 추가 작업에 안전합니다. f.writelines()는 각 항목에 대해 f.write()를 호출합니다. 추가 메모리 할당은 없지만 구분자도 추가되지 않습니다.

python
with open("output.txt", "w") as f:
    f.write("Hello, world\n")

with open("output.txt", "a") as f:
    f.write("Another line\n")

"w"는 파일이 존재하면 전체를 덮어씁니다. "a"는 끝에 추가합니다.

f.write()는 자동으로 줄바꿈을 추가하지 않으므로 "\n"을 명시적으로 넣어야 합니다. 여러 줄을 한 번에 쓰려면:

python
lines = ["Line one", "Line two", "Line three"]

with open("output.txt", "w") as f:
    f.write("\n".join(lines) + "\n")

예외

Python이 처리할 수 없는 문제를 만나면 예외를 발생시킵니다. 무엇이, 어디서 잘못되었는지 설명하는 오류입니다. 처리하지 않으면 프로그램이 충돌하고 트레이스백(traceback)을 출력합니다. 아래 표는 자주 마주하게 되는 예외들입니다.

예외는 BaseException에서 상속되는 객체입니다. 사용자 대상의 모든 예외는 Exception에서 상속됩니다. 발생하면 Python은 호출 스택을 거슬러 올라가며 일치하는 except 절을 찾습니다. 트레이스백은 예외 발생 지점부터 진입점까지의 전체 호출 사슬을 보여줍니다.

예외 객체는 타입, 메시지, 그리고 트레이스백 객체를 가리키는 __traceback__ 속성을 가집니다. raise는 새 예외를 생성하고, 인자 없는 raise는 원래 트레이스백을 보존하면서 현재 예외를 다시 발생시킵니다. 예외 체이닝(raise B from A)은 두 예외를 연결하여 트레이스백에 표시합니다. BaseException은 루트이며, KeyboardInterruptSystemExitException이 아니라 여기서 직접 상속됩니다. 그래서 except Exception만으로는 이들을 잡지 못합니다.

자주 마주하는 예외들:

예외발생 시점
FileNotFoundErroropen()이 파일을 찾지 못함
ValueError함수가 올바른 타입이지만 잘못된 내용의 값을 받음 (예: int("abc"))
TypeError타입 자체가 틀림 (예: "hello" + 5)
KeyError딕셔너리 키가 존재하지 않음
IndexError리스트 인덱스가 범위를 벗어남
ZeroDivisionError0으로 나눔
AttributeError객체에 해당 속성이나 메서드가 없음

try / except

실패 가능성이 있는 코드를 try 블록으로 감싸세요. 예외가 발생하면 일치하는 except 블록이 충돌 대신 그것을 처리합니다. 어떤 예외를 잡을지 구체적으로 명시하세요. except:로 모든 것을 잡으면 진짜 버그가 가려집니다.

try/except는 특정 예외 타입을 가로챕니다. 관련 없는 오류까지 조용히 삼키지 않도록 타입을 지정하세요. as e로 예외를 이름에 바인딩하면 메시지를 살펴볼 수 있습니다. 여러 except 절은 서로 다른 예외 타입을 처리하며, Python은 첫 번째로 호환되는 절을 선택합니다.

except ExceptionType as eisinstance(raised_exception, ExceptionType)이 참이면 매치됩니다. 따라서 Exception을 잡으면 모든 하위 클래스도 잡힙니다. Python은 타입이 일치하는 첫 번째 except 절을 선택하고, 이후 절은 건너뜁니다. except (A, B) as e는 두 타입 중 어느 쪽이든 잡습니다. 무조건적인 except:KeyboardInterruptSystemExit까지 잡으며, 이는 거의 항상 의미가 없습니다.

python
try:
    value = int("abc")
except ValueError:
    print("That's not a valid number")

어떤 예외를 잡을지 구체적으로 지정하세요. except:로 모두 잡으면 버그가 숨겨집니다.

python
# 나쁨, 프로그래머의 실수까지 모두 잡음
try:
    result = do_something()
except:
    pass

# 좋음, 예상하고 실제로 처리할 수 있는 것만 잡음
try:
    result = do_something()
except FileNotFoundError:
    print("File not found")

여러 예외 잡기

서로 다른 오류 타입을 별도의 except 블록으로 처리할 수도 있고, 튜플을 사용해 한 블록에서 여러 타입을 잡을 수도 있습니다. as e 부분은 오류 메시지에 접근할 수 있게 해줍니다.

여러 except 절은 위에서 아래로 평가되며, 첫 번째 매치가 승리합니다. 튜플로 여러 타입을 잡으면 그중 어느 것이든 잡힙니다. as e는 예외 인스턴스에 바인딩하여 메시지, 타입, 트레이스백에 접근할 수 있게 합니다. 가장 구체적인 타입을 먼저, 더 일반적인 타입을 뒤에 두면 가려짐(shadowing)을 피할 수 있습니다.

except (A, B) as e는 두 타입을 모두 잡는 단일 절의 문법적 설탕(syntactic sugar)입니다. as e 바인딩은 except 블록 종료 후 정리됩니다(트레이스백과의 참조 순환을 막기 위해). 추가 컨텍스트와 함께 재발생시키려면 raise ValueError("context") from e를 사용하세요. 이것은 __cause__를 설정하고 트레이스백에 두 예외를 모두 표시합니다.

python
try:
    data = int(user_input)
    result = 100 / data
except ValueError:
    print("Not a number")
except ZeroDivisionError:
    print("Can't divide by zero")

또는 튜플로 여러 개를 한 번에 잡기:

python
except (ValueError, ZeroDivisionError) as e:
    print(f"Input error: {e}")

as e는 예외 객체를 이름에 바인딩하여 메시지를 살펴볼 수 있게 해줍니다.

else와 finally

else는 예외가 발생하지 않은 경우에만 실행됩니다. finally는 예외 발생 여부와 관계없이 항상 실행됩니다. finally는 무슨 일이 있어도 반드시 일어나야 하는 정리 작업에 유용합니다.

else는 "정상 경로" 코드를 try 본문과 분리하여, else 안의 코드가 예외가 발생하지 않았을 때만 실행됨을 명확히 합니다. finally는 정리 보장입니다. 예외가 발생했든, 잡혔든, 재발생했든 실행됩니다. return이나 break를 만나도 실행됩니다.

elsetry 블록이 예외를 발생시키지 않고 완료되면 실행됩니다. finallyreturn, break, continue, 또는 처리되지 않은 예외 이후를 포함하여 무조건 실행됩니다. finally와 호출 코드 모두 return 문이 있다면 finallyreturn이 우선합니다. 파일 핸들이나 데이터베이스 연결에 대해 with를 사용할 수 없을 때 finally는 보조 안전망입니다.

python
try:
    with open("data.txt") as f:
        content = f.read()
except FileNotFoundError:
    print("File not found, using defaults")
    content = ""
else:
    print("File loaded successfully")
finally:
    print("Done attempting to load file")   # 항상 실행

finally는 파일에 이미 with를 사용하고 있더라도, 연결 닫기나 잠금 해제 같은 정리 작업에 가장 유용합니다.

raise

raise를 사용해 예외를 직접 발생시킬 수도 있습니다. 이는 잘못된 값을 조용히 반환하는 대신, 함수가 호출자에게 문제를 명확히 알릴 수 있게 해줍니다.

raise ExceptionType("message")는 예외를 생성하고 발생시킵니다. 인자 없는 raiseexcept 블록 내에서 현재 예외를 재발생시킵니다. 함수에서 예외를 발생시키면 오류 조건이 명시적이 되어, 호출자가 구체적으로 처리할 수 있습니다.

raise exprexpr을 평가하여 예외 인스턴스를 얻고 __traceback__을 설정합니다. 단독 raise는 트레이스백을 수정하지 않고 현재 예외를 재발생시킵니다. raise B from AB.__cause__ = A로 설정합니다(명시적 체이닝). raise B from None은 컨텍스트 표시를 억제합니다. 잡고 로깅한 후 원래 트레이스백을 보존하며 전파하는 깔끔한 방법은 raise입니다.

python
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

이렇게 하면 함수가 기대하는 것이 명시적이 되고, 문제를 호출자에게 명확히 알릴 수 있습니다.

커스텀 예외 클래스

더 큰 프로그램에서는 Exception을 상속하여 자체 예외 타입을 정의할 수 있습니다. 이렇게 하면 호출자가 다른 종류의 오류와 분리하여 특정 오류를 잡을 수 있습니다.

커스텀 예외는 호출자가 적절한 구체성 수준에서 잡을 수 있는 계층 구조를 만듭니다. Exception을 상속하는 것만으로 보통 충분합니다. 예외 패밀리를 위해서는 기본 예외 클래스를 만들고 특정 오류 모드에 대해 상속하세요. 그러면 호출자가 모든 변형을 처리하기 위해 기본 타입을 잡을 수 있습니다.

커스텀 예외는 BaseException이 아닌 Exception을 상속해야 합니다. 도메인별 필드와 함께 __init__을 추가하면 호출자가 메시지 문자열을 읽기만 하는 것이 아니라 예외를 프로그래밍적으로 검사할 수 있습니다. 예외 계층 구조는 호출자가 구체성 수준을 선택할 수 있게 합니다. except PaymentError는 결제 관련 오류를 모두 잡고, except InsufficientFundsError는 그 특정 경우만 잡습니다.

python
class InsufficientFundsError(Exception):
    pass

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw {amount}, balance is {self.balance}"
            )
        self.balance -= amount
python
try:
    account.withdraw(1000)
except InsufficientFundsError as e:
    print(f"Transaction declined: {e}")

JSON

JSON은 모든 것이 사용하는 포맷입니다. API, 설정 파일, 데이터 내보내기 등에 쓰입니다. Python의 json 모듈이 이를 바로 처리해줍니다. json.load()는 파일에서 JSON을 읽어 Python의 dict나 list로 만듭니다. json.dump()는 Python의 dict나 list를 JSON으로 파일에 씁니다.

json.load()json.dump()는 파일 객체와 함께 작동합니다. json.loads()json.dumps()는 문자열과 함께 작동합니다. dump/dumpsindent= 매개변수는 출력을 사람이 읽기 쉽게 만듭니다. 잘못된 JSON에 대해서는 json.JSONDecodeError(ValueError의 하위 클래스)가 발생합니다.

json.load()는 스트리밍 파서입니다. 파일 객체에서 점진적으로 읽습니다. json.dumps()는 문자열을 반환하고, json.dump().write()를 지원하는 파일류 객체에 씁니다. dump/dumpsdefault= 매개변수는 JSON 직렬화 불가능한 타입을 처리합니다. 직렬화 불가능한 객체를 받아 직렬화 가능한 것을 반환해야 합니다. load/loadsobject_hook는 파싱된 각 객체 dict를 가로채어 커스텀 역직렬화를 가능하게 합니다.

파일에서 JSON 읽기:

python
import json

with open("config.json", "r") as f:
    config = json.load(f)    # JSON을 Python dict/list로 파싱

print(config["setting"])

파일에 JSON 쓰기:

python
import json

data = {"name": "민준", "score": 87, "active": True}

with open("output.json", "w") as f:
    json.dump(data, f, indent=2)    # indent=는 사람이 읽기 쉽게 만듦

JSON과 Python 타입의 대응 관계:

JSONPython
object {}dict
array []list
string ""str
numberint 또는 float
true / falseTrue / False
nullNone

파일을 거치지 않고 JSON 문자열과 Python 객체 사이를 변환하려면:

python
import json

# 문자열에서 Python으로
data = json.loads('{"name": "민준", "score": 87}')

# Python에서 문자열로
text = json.dumps({"name": "민준", "score": 87}, indent=2)

json.load()는 파일 객체에서 읽습니다. json.loads()("s"가 붙은 쪽)는 문자열에서 읽습니다.

실전 적용

간단한 게임을 위한 저장/불러오기 패턴입니다. 상태를 JSON에 쓰고, 다음 실행 시 다시 불러오며, 아직 저장 파일이 없으면 기본값으로 폴백합니다:

python
import json

SAVE_FILE = "save_game.json"

def save_game(player_data: dict) -> None:
    with open(SAVE_FILE, "w") as f:
        json.dump(player_data, f, indent=2)
    print("Game saved.")

def load_game() -> dict:
    try:
        with open(SAVE_FILE, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        print("No save file found, starting fresh.")
        return {"name": "Player", "score": 0, "level": 1}

state = load_game()
state["score"] += 50
save_game(state)

설정 파일을 불러오고 결과를 저장하며, 각 실패 모드에 대해 구체적인 예외 처리를 적용합니다:

python
import json

def load_config(path: str) -> dict:
    try:
        with open(path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in {path}: {e}")

def save_results(results: list[dict], path: str) -> None:
    with open(path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"Saved {len(results)} result(s) to {path}")

config  = load_config("experiment.json")
results = [{"epoch": 1, "loss": 0.82}, {"epoch": 2, "loss": 0.61}]
save_results(results, "results.json")

타임스탬프가 찍힌 항목을 파일에 추가하는 구조화된 로그 작성기와, 예상치 못한 실패를 잡고 기록하는 최상위 핸들러:

python
import json
from datetime import datetime

LOG_FILE = "run.log"

def log(level: str, message: str) -> None:
    ts    = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    entry = f"[{ts}] [{level.upper():7}] {message}\n"
    with open(LOG_FILE, "a") as f:
        f.write(entry)

def process(config_path: str) -> None:
    log("info", f"Starting job, config: {config_path}")
    try:
        with open(config_path) as f:
            config = json.load(f)
        log("info", f"Loaded config: {config}")
    except FileNotFoundError:
        log("error", f"Config not found: {config_path}")
        raise
    except json.JSONDecodeError as e:
        log("error", f"Bad JSON in config: {e}")
        raise

try:
    process("config.json")
except Exception as e:
    log("critical", f"Job failed: {e}")

로깅 후 재발생시키면 호출자를 위한 원래 트레이스백이 보존됩니다. 최상위의 except Exception은 빠져나간 모든 것을 잡아 치명적(critical)으로 기록하고, 프로세스가 깔끔하게 종료될 수 있게 합니다.