파일과 예외
실제로 뭔가를 처리하는 프로그램은 대부분 파일 시스템을 다룹니다. 설정 파일을 읽거나, 결과를 저장하거나, 데이터를 불러오는 식입니다. 그리고 뭔가 잘못되면 Python은 **예외(exception)**를 발생시킵니다. 예상치 못한 일이 일어났다는 신호입니다. 이 장에서는 두 가지를 다룹니다. 파일로 데이터를 주고받는 방법과, 프로그램이 죽지 않고 에러를 적절히 처리하도록 코드를 작성하는 방법입니다.
파일 열기
open()은 파일을 열고 읽거나 쓸 수 있는 객체를 돌려줍니다. 경로와 파일을 어떻게 다룰지(읽기, 쓰기, 추가)를 알려주면 됩니다. 파일을 다 사용했으면 반드시 닫아야 하는데, with 문을 쓰면 이 작업이 자동으로 이루어집니다.
f = open("data.txt", "r") # "r" = 읽기
content = f.read()
f.close()여기서 "r"이 모드입니다.
| 모드 | 의미 |
|---|---|
"r" | 읽기. 파일이 존재해야 합니다. 기본 모드입니다. |
"w" | 쓰기. 파일을 새로 만들거나 덮어씁니다. |
"a" | 추가. 지우지 않고 끝에 덧붙입니다. |
"x" | 생성. 파일이 이미 있으면 실패합니다. |
"r+" | 읽기와 쓰기. |
"b" | 바이너리. 다른 모드에 붙여 씁니다: "rb", "wb". |
작업을 마쳤으면 항상 .close()를 호출하세요. 이를 잊으면 파일이 열린 상태로 남고, 아직 기록되지 않고 대기 중이던 데이터를 잃을 수 있습니다. 이를 확실하게 처리하는 방법이 with 문입니다.
open(path, mode)는 파일 객체를 건네주고, 모드는 무엇을 하려는지를 나타냅니다. "r"은 읽기, "w"는 쓰기(있던 내용을 지워버립니다), "a"는 끝에 추가하기입니다. 저는 예전에 "a"를 쓰려다 "w"를 눌러서 파일을 하나 날려본 적이 있습니다. 타이핑하기 전에 모드를 한 번 더 떠올려 보세요. with 문
with open(...)은 파일을 대신 관리해줍니다. 들여쓰기된 블록이 끝나면, 에러가 나더라도 자동으로 파일을 닫아줍니다. 직접 open()/close()를 쓰는 대신 항상 with open(...)을 사용하세요. 더 안전하고, 표준적인 방식입니다.
with open("data.txt", "r") as f:
content = f.read()
# 여기서 f는 반드시 닫혀 있습니다with는 Python의 컨텍스트 매니저 문법입니다. 준비와 정리 코드를 대신 실행해 주는데, 여기서는 파일을 열고 확실하게 닫아주는 역할을 합니다. open()과 함께 쓸 때는 내부 동작 방식까지 알 필요는 없습니다.
with open(...) as f는 파일을 열고, 들여쓰기된 블록 안에서 f를 다룰 수 있게 해준 다음, 블록이 끝나면 중간에 뭔가 잘못되더라도 대신 닫아줍니다. 매번 직접 .close()를 호출하는 대신 이걸 쓰세요. 잊어버릴 일이 하나 줄어듭니다. 파일 읽기
읽는 방법은 세 가지입니다. .read()는 파일 전체를 하나의 문자열로 불러옵니다. .readline()은 한 줄을 읽습니다. 파일 객체를 직접 순회하면 한 줄씩 읽게 되는데, 전체를 한 번에 메모리에 올리지 않기 때문에 큰 파일에는 가장 효율적인 방법입니다.
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"으로 끝나는 리스트큰 파일이라면 한 줄씩 읽는 것이 전체를 한 번에 불러오는 것보다 효율적입니다.
with open("big_file.txt", "r") as f:
for line in f: # 파일을 직접 순회, 메모리 효율적
print(line.strip()) # strip()은 끝의 줄바꿈 문자를 제거합니다파일 객체를 직접 순회하는 방식(for line in f)이 큰 파일을 읽는 가장 효율적인 방법입니다.
.read()는 파일 전체를 하나의 문자열로, .readline()은 한 줄만 돌려줍니다. 파일이 클 수도 있다면 for line in f처럼 반복문을 쓰세요. 한 번에 한 줄씩 읽으므로 전체를 메모리에 올릴 일이 없습니다. 각 줄에 붙은 .strip()은 끝의 줄바꿈 문자를 지웁니다. 파일 쓰기
"w" 모드는 파일이 이미 있으면 전체를 덮어씁니다. "a" 모드는 끝에 이어 붙입니다. .write()는 줄바꿈 문자를 자동으로 넣어주지 않으므로, 각 줄 끝에 "\n"을 직접 넣어야 합니다. 여러 줄을 한 번에 쓰려면 "\n".join()으로 합친 뒤 쓰면 됩니다.
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"을 직접 넣어야 합니다. 여러 줄을 한 번에 쓰려면:
lines = ["첫 번째 줄", "두 번째 줄", "세 번째 줄"]
with open("output.txt", "w") as f:
f.write("\n".join(lines) + "\n")"w"는 파일을 만들거나 덮어쓰고, "a"는 지우지 않고 끝에 이어 붙입니다. .write()는 줄바꿈 문자를 대신 넣어주지 않으니 각 줄 끝에 "\n"을 직접 붙이세요. 여러 줄을 쓸 때는 "\n".join(lines)로 먼저 이어 붙이면 됩니다. 예외
Python이 처리할 수 없는 문제에 부딫히면 **예외(exception)**를 발생시킵니다. 무엇이 잘못됐고 어디서 일어났는지 알려주는 에러입니다. 처리하지 않으면 프로그램이 죽고 트레이스백(traceback)이 출력됩니다. 아래 표는 자주 마주칠 대표적인 예외들을 정리한 것입니다.
자주 마주치는 예외들입니다:
| 예외 | 발생 시점 |
|---|---|
FileNotFoundError | open()이 파일을 찾지 못할 때 |
ValueError | 타입은 맞지만 내용이 잘못된 값을 함수가 받았을 때, 예: int("abc") |
TypeError | 타입 자체가 틀렸을 때, 예: "hello" + 5 |
KeyError | 딕셔너리에 해당 키가 없을 때 |
IndexError | 리스트 인덱스가 범위를 벗어났을 때 |
ZeroDivisionError | 0으로 나눌 때 |
AttributeError | 객체에 해당 속성이나 메서드가 없을 때 |
FileNotFoundError, ValueError, KeyError, TypeError입니다. 처리하지 않으면 프로그램이 멈추고 트레이스백이 출력되는데, 겁이 날 수 있지만 사실은 문제가 어디서 터졌는지 정확히 알려주는 지도일 뿐입니다. try / except
실패할 수도 있는 코드를 try 블록으로 감싸세요. 예외가 발생하면 프로그램이 죽는 대신 맞는 except 블록이 이를 처리합니다. 어떤 예외를 잡을지 구체적으로 명시하세요. 맨몸의 except:로 모든 걸 잡으면 진짜 버그가 숨겨집니다.
try:
value = int("abc")
except ValueError:
print("올바른 숫자가 아닙니다")어떤 예외를 잡을지 구체적으로 명시하세요. 맨몸의 except:로 모든 예외를 잡으면 버그가 숨겨집니다:
# 나쁜 예, 프로그래머의 실수까지 포함해서 모든 걸 잡습니다
try:
result = do_something()
except:
pass
# 좋은 예, 예상하고 실제로 처리할 수 있는 것만 잡습니다
try:
result = do_something()
except FileNotFoundError:
print("파일을 찾을 수 없습니다")try 블록에 넣으면, 프로그램이 죽는 대신 맞는 except가 실행됩니다. 맨몸의 except:는 모든 걸 잡아서 진짜 버그를 가려버리니, ValueError처럼 예상되는 예외의 이름을 명시하세요. 실제로 처리할 수 있는 것만 잡고, 나머지는 드러나게 두세요. 여러 예외 잡기
서로 다른 에러 타입을 각각의 except 블록에서 처리할 수도 있고, 튜플로 묶어서 하나의 블록에서 여러 타입을 잡을 수도 있습니다. as e 부분은 에러 메시지에 접근할 수 있게 해줍니다.
try:
data = int(user_input)
result = 100 / data
except ValueError:
print("숫자가 아닙니다")
except ZeroDivisionError:
print("0으로 나눌 수 없습니다")혹은 튜플로 여러 개를 함께 잡을 수도 있습니다:
except (ValueError, ZeroDivisionError) as e:
print(f"입력 오류: {e}")as e는 예외 객체를 이름에 묶어서 메시지를 확인할 수 있게 해줍니다.
except 블록을 두면 Python은 맞는 첫 번째 것을 실행합니다. 몇 가지를 같은 방식으로 처리하려면 튜플로 나열하세요: except (ValueError, ZeroDivisionError). as e 부분은 에러 객체를 건네주므로 메시지를 출력할 수 있습니다. else와 finally
else는 예외가 발생하지 않았을 때만 실행됩니다. finally는 예외가 있었든 없었든 항상 실행됩니다. finally는 무슨 일이 있어도 반드시 이루어져야 하는 정리 작업에 유용합니다.
try:
with open("data.txt") as f:
content = f.read()
except FileNotFoundError:
print("파일을 찾을 수 없어 기본값을 사용합니다")
content = ""
else:
print("파일을 성공적으로 불러왔습니다")
finally:
print("파일 불러오기 시도 완료") # 항상 실행됩니다finally는 파일에 이미 with를 쓰고 있더라도, 연결을 끊거나 락을 해제하는 등의 정리 작업에 가장 유용합니다.
else는 try가 아무 예외도 던지지 않았을 때만 실행되므로, 성공 경로의 나머지 코드를 넣기 좋은 자리입니다. finally는 예외가 있든 없든 매번 실행되므로, 결과와 무관하게 반드시 해야 하는 정리 작업을 넣는 자리입니다. 파일의 경우 with가 이미 정리를 해주므로, 생각보다 finally를 쓸 일은 많지 않습니다. raise
raise를 사용해서 직접 예외를 발생시킬 수 있습니다. 이렇게 하면 잘못된 값을 조용히 반환하는 대신, 함수가 호출한 쪽에 문제를 명확히 알릴 수 있습니다.
def divide(a, b):
if b == 0:
raise ValueError("0으로 나눌 수 없습니다")
return a / b이렇게 하면 함수가 무엇을 기대하는지 명확해지고, 호출한 쪽에 문제를 분명하게 알릴 수 있습니다.
raise ExceptionType("메시지")는 여러분의 코드가 잘못된 값을 들고 계속 진행하는 대신, 문제를 소리 내어 알리게 해줍니다. "이건 처리할 수 없습니다"라고 함수가 말하는 방식이고, 그러면 호출한 쪽이 그걸 처리합니다. 명확하고 빠른 실패가 훨씬 나중에 드러나는 잘못된 결과보다 낫습니다. 사용자 정의 예외 클래스
더 큰 프로그램에서는 Exception을 상속받아 직접 예외 타입을 정의할 수 있습니다. 이렇게 하면 호출하는 쪽에서 여러분의 특정 에러를 다른 종류의 에러와 구분해서 따로 잡을 수 있습니다.
class InsufficientFundsError(Exception):
pass
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(
f"{amount}을 출금할 수 없습니다, 잔액은 {self.balance}입니다"
)
self.balance -= amounttry:
account.withdraw(1000)
except InsufficientFundsError as e:
print(f"거래가 거부되었습니다: {e}")Exception을 상속받아 여러분만의 에러 타입을 만드세요. 몸통이 비어 있는 클래스 하나만으로 충분히 시작할 수 있습니다. 이렇게 하면 호출하는 쪽에서 내장 에러와 구분되는, 여러분의 것임을 알 수 있는 이름으로 잡을 수 있어서, 여러분 프로그램의 문제가 독립적으로 드러납니다. JSON
JSON은 API, 설정 파일, 데이터 내보내기 등 모든 곳에서 통용되는 형식입니다. Python의 json 모듈이 이를 바로 처리해줍니다. json.load()는 파일 안의 JSON을 Python 딕셔너리나 리스트로 읽어들입니다. json.dump()는 딕셔너리나 리스트를 다시 JSON 형식으로 파일에 씁니다.
파일에서 JSON 읽기:
import json
with open("config.json", "r") as f:
config = json.load(f) # JSON을 Python dict/list로 파싱
print(config["setting"])JSON을 파일에 쓰기:
import json
data = {"name": "민준", "score": 87, "active": True}
with open("output.json", "w") as f:
json.dump(data, f, indent=2) # indent=는 사람이 읽기 쉽게 만듭니다JSON과 Python 타입 대응표:
| JSON | Python |
|---|---|
객체 {} | dict |
배열 [] | list |
문자열 "" | str |
| 숫자 | int 또는 float |
true / false | True / False |
null | None |
파일을 거치지 않고 JSON 문자열과 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.load(f)는 파일 안의 JSON을 Python 딕셔너리와 리스트로 바꾸고, json.dump(data, f)는 이를 다시 써줍니다. s가 붙은 버전인 json.loads와 json.dumps는 파일이 아니라 문자열을 대상으로 같은 일을 합니다. 결과물을 읽기 쉽게 만들려면 indent=2를 추가하세요. 실전 예제
작은 게임을 위한 저장/불러오기 패턴입니다. 상태를 JSON에 쓰고, 다음 실행에서 다시 불러오고, 아직 저장 파일이 없다면 기본값으로 대체합니다:
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("게임을 저장했습니다.")
def load_game() -> dict:
try:
with open(SAVE_FILE, "r") as f:
return json.load(f)
except FileNotFoundError:
print("저장 파일이 없어 새로 시작합니다.")
return {"name": "플레이어", "score": 0, "level": 1}
state = load_game()
state["score"] += 50
save_game(state)
