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

파일과 예외

실제로 뭔가를 처리하는 프로그램은 대부분 파일 시스템을 다룹니다. 설정 파일을 읽거나, 결과를 저장하거나, 데이터를 불러오는 식입니다. 그리고 뭔가 잘못되면 Python은 **예외(exception)**를 발생시킵니다. 예상치 못한 일이 일어났다는 신호입니다. 이 장에서는 두 가지를 다룹니다. 파일로 데이터를 주고받는 방법과, 프로그램이 죽지 않고 에러를 적절히 처리하도록 코드를 작성하는 방법입니다.

파일 입출력과 예외 처리는 프로그램을 안정적으로 만드는 두 가지 핵심 장치입니다. open()은 파일 시스템에 접근할 수 있게 해주고, with는 에러가 나더라도 파일이 확실히 정리되도록 보장합니다. try/except는 특정 예외 타입을 잡아내서 프로그램이 죽는 대신 복구할 수 있게 해줍니다. 이 둘이 합쳐지면 사람 손을 거치지 않고 돌아가는 스크립트의 기반이 됩니다.

파일과 예외는 스크립트가 지저분한 현실 세계와 마주하는 지점입니다. 존재하지 않는 경로, 가득 찬 디스크, 파싱되지 않는 입력 같은 것들입니다. open()은 파일 객체를 돌려주고, with는 그 안의 코드가 예외를 던지더라도 파일이 반드시 닫히도록 보장합니다. 예외는 Python이 이런 실패를 알리는 방식입니다. 예외가 발생하면 그것이 일어난 지점부터 그곳까지 이어진 호출들을 거슬러 올라가고, 그 과정에서 처음으로 맞는 except가 이를 처리합니다. 이 장이 다루는 핵심은, 실제로 복구할 수 있는 구체적인 실패만 잡아내고 나머지는 크게 드러나게 두라는 원칙입니다. 모든 것을 다 삼켜버려서 조용히 실패하는 스크립트를 만드는 것과는 반대입니다.

파일 열기

open()은 파일을 열고 읽거나 쓸 수 있는 객체를 돌려줍니다. 경로와 파일을 어떻게 다룰지(읽기, 쓰기, 추가)를 알려주면 됩니다. 파일을 다 사용했으면 반드시 닫아야 하는데, with 문을 쓰면 이 작업이 자동으로 이루어집니다.

open(path, mode)은 파일 객체를 돌려줍니다. 모드 문자열이 접근 방식을 결정합니다. "r"은 읽기, "w"는 쓰기(파일을 먼저 비웁니다), "a"는 끝에 이어 붙이기입니다. 파일이 텍스트가 아니라면 "b"를 붙여 바이너리 모드로 엽니다. 처음부터 습관으로 만들어 두면 좋은 것이 하나 있습니다. 텍스트 파일에는 encoding="utf-8"을 넘겨주세요. 이걸 빼면 Python은 해당 컴퓨터의 기본 인코딩을 사용하기 때문에, 내 컴퓨터에서는 잘 읽히던 파일이 다른 사람 컴퓨터에서는 글자가 깨져서 나올 수 있습니다.

open()은 OS에 파일 핸들을 요청하고, 그것을 감싼 파일 객체를 돌려줍니다. 모드는 접근 방식("r", "w", "a")과 텍스트냐 바이트냐("b")를 함께 결정합니다. 사람들이 흔히 놓치는 매개변수는 encoding입니다. 디스크 상의 바이트를 어떤 문자로 대응시킬지 결정하는 값입니다. 지정하지 않으면 Python은 해당 컴퓨터의 로캘 기본값으로 넘어가는데, 이 값은 플랫폼마다 달라서 한 컴퓨터에서 작성한 파일이 다른 컴퓨터에서는 잘못 디코딩될 수 있습니다. 특별한 이유가 없다면 텍스트에는 항상 encoding="utf-8"을 넘기세요. 그러면 이런 예기치 못한 문제가 대부분 사라집니다.

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

여기서 "r"모드입니다.

모드의미
"r"읽기. 파일이 존재해야 합니다. 기본 모드입니다.
"w"쓰기. 파일을 새로 만들거나 덮어씁니다.
"a"추가. 지우지 않고 끝에 덧붙입니다.
"x"생성. 파일이 이미 있으면 실패합니다.
"r+"읽기와 쓰기.
"b"바이너리. 다른 모드에 붙여 씁니다: "rb", "wb".

작업을 마쳤으면 항상 .close()를 호출하세요. 이를 잊으면 파일이 열린 상태로 남고, 아직 기록되지 않고 대기 중이던 데이터를 잃을 수 있습니다. 이를 확실하게 처리하는 방법이 with 문입니다.

Juno파일 열기open(path, mode)는 파일 객체를 건네주고, 모드는 무엇을 하려는지를 나타냅니다. "r"은 읽기, "w"는 쓰기(있던 내용을 지워버립니다), "a"는 끝에 추가하기입니다. 저는 예전에 "a"를 쓰려다 "w"를 눌러서 파일을 하나 날려본 적이 있습니다. 타이핑하기 전에 모드를 한 번 더 떠올려 보세요.
Juno파일 열기open(path, mode)는 파일 객체를 돌려줍니다. "r"은 읽기, "w"는 덮어쓰기, "a"는 추가, 여기에 바이너리용 "b"가 더해집니다. 텍스트 파일에는 encoding="utf-8"을 넘겨서 어떤 컴퓨터에서든 똑같이 읽히게 하고, 닫는 일은 with에게 맡기세요.
Juno파일 열기open()은 OS 핸들을 감싼 파일 객체를 건네줍니다. 모드는 접근 방식과 텍스트/바이트 여부를 정합니다. 옮겨 다니면 말썽을 부리는 설정은 encoding입니다. 지정하지 않으면 그 컴퓨터의 로캘 기본값을 그대로 물려받으니, 텍스트에는 encoding="utf-8"을 습관으로 만드세요. with가 대신해줄 수 있는 일을 굳이 .close()로 직접 처리하지 마세요.

with

with open(...)은 파일을 대신 관리해줍니다. 들여쓰기된 블록이 끝나면, 에러가 나더라도 자동으로 파일을 닫아줍니다. 직접 open()/close()를 쓰는 대신 항상 with open(...)을 사용하세요. 더 안전하고, 표준적인 방식입니다.

with는 Python의 컨텍스트 매니저(context manager) 문법입니다. 블록이 시작될 때 "준비" 호출이 일어나고, 끝날 때 "정리" 호출이 일어나는 작은 규약입니다. 파일의 경우 준비 단계는 열린 파일을 돌려주고, 정리 단계는 그것을 닫습니다. 여기서 얻는 이점은, 블록 안에서 예외가 발생해도 정리 단계가 실행된다는 점입니다. 즉 파일마다 try/finally를 따로 쓰지 않고도 그 정리 효과를 그대로 얻는 셈입니다.

컨텍스트 매니저(with 뒤에 올 수 있는 모든 객체)는 두 가지 동작을 정의합니다. 들어갈 때 할 일과 나갈 때 할 일입니다. with open(...) as f는 들어갈 때의 단계를 실행해서 파일을 f에 묶고, 나갈 때의 단계를 등록해 둡니다. 이 단계는 블록이 정상적으로 끝났든 예외 때문에 끝났든 Python이 실행합니다. 파일의 경우 나갈 때의 단계가 close()입니다. 실전에서 알아두면 좋은 점 두 가지가 있습니다. 하나는 with open(a) as f, open(b) as g:처럼 한 문장에서 여러 파일을 열 수 있고, 둘 다 순서대로 닫힌다는 것입니다. 또 하나는 나갈 때의 단계가 예외를 조용히 삼키도록 선택할 수도 있다는 점입니다. contextlib.suppress(FileNotFoundError) 같은 도우미가 바로 이 방식으로 동작하는데, 컨텍스트 매니저가 그렇게 작성되어 있다면 with 블록이 에러를 조용히 흡수할 수도 있다는 사실을 알고 있어야 합니다.

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

# 여기서 f는 반드시 닫혀 있습니다

with는 Python의 컨텍스트 매니저 문법입니다. 준비와 정리 코드를 대신 실행해 주는데, 여기서는 파일을 열고 확실하게 닫아주는 역할을 합니다. open()과 함께 쓸 때는 내부 동작 방식까지 알 필요는 없습니다.

Junowith 문with open(...) as f는 파일을 열고, 들여쓰기된 블록 안에서 f를 다룰 수 있게 해준 다음, 블록이 끝나면 중간에 뭔가 잘못되더라도 대신 닫아줍니다. 매번 직접 .close()를 호출하는 대신 이걸 쓰세요. 잊어버릴 일이 하나 줄어듭니다.
Junowith 문with는 블록이 열릴 때 준비 동작을, 닫힐 때 정리 동작을 실행하며, 이 정리 동작은 예외가 나도 실행됩니다. 파일의 경우 그 정리 동작이 close()이므로, 직접 작성하지 않고도 try/finally와 같은 안전성을 얻습니다. 직접 open()/close()를 쓰는 것보다 항상 이걸 선호하세요.
Junowith 문 컨텍스트 매니저는 블록이 정상적으로 끝났든 예외를 던졌든 나갈 때 파일을 닫으므로, with는 정리를 위한 손수 작성한 try/finally를 대체합니다. 하나의 with로 여러 개를 동시에 열 수도 있습니다. 나갈 때의 단계가 예외를 억제할 수도 있다는 점을 기억하세요. 바로 contextlib.suppress가 그 용도이며, with 블록이 에러를 삼킬 수 있다는 걸 알아두면 좋습니다.

파일 읽기

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

.read()는 파일 전체를 하나의 문자열로 메모리에 불러옵니다. .readline()은 한 줄(줄바꿈 문자 포함)을 읽습니다. .readlines()는 모든 줄을 리스트로 돌려줍니다. 파일이 클 수도 있다면 for line in f처럼 파일 객체를 직접 순회하세요. 한 번에 한 줄씩만 가져오고 파일 전체를 메모리에 올리지 않으므로, 파일이 얼마나 크든 메모리 사용량이 일정하게 유지됩니다.

.read()는 파일 전체를 하나의 문자열로 돌려주기 때문에, 메모리 비용이 파일 크기에 비례합니다. 설정 파일 정도면 괜찮지만 수 기가바이트짜리 로그 파일이라면 문제가 됩니다. 파일 객체를 순회하는 for line in f는 매 단계마다 한 줄을 읽고 다음 줄로 넘어가기 전에 버리므로, 파일 길이와 무관하게 메모리 사용량이 일정합니다. 크기를 알 수 없는 파일이라면 기본적으로 이 방식을 써야 합니다. .readlines()는 편리해 보이지만 모든 줄을 한 번에 리스트로 만들기 때문에 .read()와 메모리 사용량이 똑같아서, 반복문보다 얻는 게 없습니다. 흔한 메모리 부족 버그를 막는 원칙은 이렇습니다. 먼저 for line in f 반복문을 쓰고, 파일이 작다는 걸 알 때만 .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)이 큰 파일을 읽는 가장 효율적인 방법입니다.

Juno파일 읽기.read()는 파일 전체를 하나의 문자열로, .readline()은 한 줄만 돌려줍니다. 파일이 클 수도 있다면 for line in f처럼 반복문을 쓰세요. 한 번에 한 줄씩 읽으므로 전체를 메모리에 올릴 일이 없습니다. 각 줄에 붙은 .strip()은 끝의 줄바꿈 문자를 지웁니다.
Juno파일 읽기.read()는 전체를 한 번에 불러오는데, 작은 파일에는 괜찮지만 큰 파일에는 위험합니다. 기본적으로 for line in f를 쓰세요. 한 줄씩 스트리밍해서 크기와 무관하게 메모리를 일정하게 유지합니다. .readlines()는 깔끔해 보이지만 .read()만큼의 메모리를 씁니다.
Juno파일 읽기.read().readlines() 모두 파일 전체를 메모리에 올려두므로 파일 크기에 비례해 늘어납니다. for line in f 반복문은 한 번에 한 줄만 읽고 버려서 메모리를 일정하게 유지하는데, 이게 바로 크기를 미리 파악하지 못한 파일에서 메모리 부족이라는 뜻밖의 사태를 막아줍니다. 기본은 이 반복문으로, .read()는 파일이 작다는 걸 알 때만 쓰세요.

파일 쓰기

"w" 모드는 파일이 이미 있으면 전체를 덮어씁니다. "a" 모드는 끝에 이어 붙입니다. .write()는 줄바꿈 문자를 자동으로 넣어주지 않으므로, 각 줄 끝에 "\n"을 직접 넣어야 합니다. 여러 줄을 한 번에 쓰려면 "\n".join()으로 합친 뒤 쓰면 됩니다.

.write(s)는 문자열 하나를 쓰는데, print()와 달리 아무것도 덧붙이지 않습니다. 공백도 줄바꿈도 없으니 "\n"을 직접 넣어야 합니다. .writelines(lines)는 리스트에 담긴 각 문자열을 이어서 쓰는데, 여기서도 구분자를 넣어주지 않으므로 줄바꿈 문자가 이미 문자열 안에 들어 있어야 합니다. 모드는 조심하세요. "w"는 파일을 여는 즉시, 아무것도 쓰기 전에 파일을 비워버립니다. 그러니 "a"를 쓰려다가 "w"를 열면 기존 내용이 사라집니다.

쓰기 작업에서 실제 버그를 일으키는 요소가 두 가지 있습니다. 첫째, 쓴 데이터가 곧바로 디스크에 반영되는 것은 아닙니다. OS는 데이터를 버퍼에 담아두고 일정량씩 모아서 기록하며, 모든 데이터가 디스크에 반영되도록 보장하는 것은 .close()(이건 with가 대신 호출해 줍니다)입니다. 오래 실행되는 프로세스가 계속 쓰기만 하고 닫지 않으면, 출력의 끝부분이 버퍼에 남아 있다가 프로세스가 죽을 때 사라질 수 있습니다. 그러니 데이터가 반드시 안전해야 하는 시점에는 파일을 닫거나 .flush()를 호출하세요. 둘째, 모드가 피해 규모를 결정합니다. "w"는 열리는 순간 파일을 비워버리는데, 쓰기 작업이 일어나기도 전입니다. 그러니 "a"를 쓰려다가 "w"를 선택하면 아무 경고 없이 기존 내용이 사라집니다. .writelines(lines)도 구분자를 넣어주지 않으므로 줄바꿈 문자가 이미 문자열에 들어 있어야 합니다.

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 = ["첫 번째 줄", "두 번째 줄", "세 번째 줄"]

with open("output.txt", "w") as f:
    f.write("\n".join(lines) + "\n")
Juno파일 쓰기"w"는 파일을 만들거나 덮어쓰고, "a"는 지우지 않고 끝에 이어 붙입니다. .write()는 줄바꿈 문자를 대신 넣어주지 않으니 각 줄 끝에 "\n"을 직접 붙이세요. 여러 줄을 쓸 때는 "\n".join(lines)로 먼저 이어 붙이면 됩니다.
Juno파일 쓰기"w"는 열리는 순간 파일을 비우고, "a"는 이어 붙입니다. 잘못 고르면 기존 내용이 조용히 사라집니다. .write()는 아무것도 덧붙이지 않으니 "\n"을 직접 넣어야 합니다. .writelines()도 리스트를 쓸 때 구분자를 넣지 않으니 줄바꿈 문자가 문자열 안에 이미 있어야 합니다.
Juno파일 쓰기"w"는 열리는 순간 파일을 비우므로, "a" 대신 이걸 선택하면 아무 경고 없이 기존 내용을 지워버립니다. 쓰기 내용은 .close()(이건 with가 호출합니다)가 실행될 때까지 버퍼에 머무르므로, 계속 쓰기만 하고 닫지 않는 프로세스는 죽을 때 끝부분을 잃을 수 있습니다. .write().writelines()는 줄바꿈 문자를 넣지 않으니 직접 챙기세요.

예외

Python이 처리할 수 없는 문제에 부딫히면 **예외(exception)**를 발생시킵니다. 무엇이 잘못됐고 어디서 일어났는지 알려주는 에러입니다. 처리하지 않으면 프로그램이 죽고 트레이스백(traceback)이 출력됩니다. 아래 표는 자주 마주칠 대표적인 예외들을 정리한 것입니다.

예외는 객체이고, 예외들끼리는 하나의 계층 구조를 이룹니다. FileNotFoundError처럼 구체적인 예외는 더 넓은 범주(OSError)에 속하고, 여러분이 잡게 될 거의 모든 예외는 Exception의 한 종류입니다. 이 계층 구조 덕분에 예외를 잡는 일이 가능해집니다. 상위 타입을 잡으면 그 하위 타입들도 함께 잡힙니다. 예외가 발생하면 Python은 현재 줄을 중단하고 그곳까지 이어진 호출들을 거슬러 올라가며 맞는 except를 찾습니다. 하나도 찾지 못하면 프로그램이 멈추고 트레이스백을 출력합니다. 에러가 발생한 지점부터 최상위까지 이어진 호출 목록입니다.

예외는 클래스 계층(타입들의 계층 구조)을 이루고, 예외를 잡을 때 이 계층 구조가 핵심적인 역할을 합니다. 상위 타입에 대한 except는 그 아래 있는 모든 타입도 함께 잡으므로, except Exception은 넓은 그물이고 except FileNotFoundError는 좁은 그물입니다. 이 계층 구조에서 꼭 기억해 둘 만한 점 하나는, KeyboardInterrupt(사용자가 Ctrl-C를 누른 경우)와 SystemExit(정상적인 종료 요청)이 Exception 아래가 아니라 그 옆에 나란히 있다는 것입니다. 이건 의도된 설계입니다. 즉 넓게 잡는 except Exception도 이 둘은 그대로 통과시킨다는 뜻이고, 그래서 전체를 잡는 catch-all 안에서도 Ctrl-C는 여전히 프로그램을 멈춥니다. 바로 이 이유 때문에 맨몸의 except: 대신 except Exception을 써야 합니다. 맨몸의 except:는 이 둘까지 잡아버려서, 프로그램을 종료하려는 사용자를 그 안에 갇히게 만듭니다.

자주 마주치는 예외들입니다:

예외발생 시점
FileNotFoundErroropen()이 파일을 찾지 못할 때
ValueError타입은 맞지만 내용이 잘못된 값을 함수가 받았을 때, 예: int("abc")
TypeError타입 자체가 틀렸을 때, 예: "hello" + 5
KeyError딕셔너리에 해당 키가 없을 때
IndexError리스트 인덱스가 범위를 벗어났을 때
ZeroDivisionError0으로 나눌 때
AttributeError객체에 해당 속성이나 메서드가 없을 때
Juno예외 예외는 Python이 뭔가 잘못됐다고 알려주는 신호입니다. 자주 마주치게 될 몇 가지는 FileNotFoundError, ValueError, KeyError, TypeError입니다. 처리하지 않으면 프로그램이 멈추고 트레이스백이 출력되는데, 겁이 날 수 있지만 사실은 문제가 어디서 터졌는지 정확히 알려주는 지도일 뿐입니다.
Juno예외 예외는 계층 구조로 배열된 객체이고, 상위 타입을 잡으면 하위 타입도 함께 잡힙니다. 예외가 발생하면 Python은 호출들을 거슬러 올라가며 맞는 except를 찾고, 못 찾으면 트레이스백을 출력합니다. ValueError, KeyError, FileNotFoundError 같은 흔한 이름들을 알고 있으면 알맞은 것을 잡을 수 있습니다.
Juno예외 타입 계층 구조가 핵심입니다. except Exception은 넓은 그물이고, 구체적인 타입은 좁은 그물입니다. KeyboardInterruptSystemExitException 아래가 아니라 그 옆에 있어서, Ctrl-C는 except Exception도 그대로 뚫고 나갑니다. 이게 바로 맨몸의 except: 대신 이걸 써야 하는 이유입니다. 맨몸의 except:는 프로그램을 종료하려는 사용자를 그 안에 갇히게 만듭니다.

try / except

실패할 수도 있는 코드를 try 블록으로 감싸세요. 예외가 발생하면 프로그램이 죽는 대신 맞는 except 블록이 이를 처리합니다. 어떤 예외를 잡을지 구체적으로 명시하세요. 맨몸의 except:로 모든 걸 잡으면 진짜 버그가 숨겨집니다.

try/except는 특정 예외 타입을 잡아서 프로그램이 죽는 대신 처리 코드를 실행합니다. 타입을 명시하는 것이 중요합니다. 실제로 복구할 수 있는 것만 잡아야, 관련 없는 버그는 삼켜지지 않고 크게 드러납니다. 에러 메시지를 읽고 싶을 때는 as e로 예외를 이름에 묶으세요. 서로 다른 타입에 대해 여러 개의 except 절을 나열하면 Python은 맞는 첫 번째 것을 사용합니다.

except SomeType은 발생한 예외가 해당 타입이거나 그 하위 타입(계층 구조에서 더 아래에 있는, 더 구체적인 종류)일 때 맞아떨어집니다. 그래서 상위 타입을 잡으면 그 하위 타입들까지 모두 잡히는 것입니다. Python은 각 except를 위에서 아래로 시도하다가 처음 맞는 것에서 멈추므로, 구체적인 것을 먼저, 일반적인 것을 나중에 배치해야 합니다. 넓은 절을 먼저 두면 모든 걸 잡아버려서 그 아래 있는 더 좁은 절들은 실행되지 않습니다. try/except가 버그를 숨기지 않게 하는 원칙은 이렇습니다. 처리할 수 있는 가장 좁은 타입만 잡고, try 블록의 본문은 실제로 예외를 던질 수 있는 그 한 줄로 최소화하고, 나머지는 그냥 전파되도록 두세요. 스무 줄을 감싸는 try는 예상했던 실패 하나를 잡는 대신, 예상치 못한 열아홉 개의 실패를 가려버리게 됩니다.

python
try:
    value = int("abc")
except ValueError:
    print("올바른 숫자가 아닙니다")

어떤 예외를 잡을지 구체적으로 명시하세요. 맨몸의 except:로 모든 예외를 잡으면 버그가 숨겨집니다:

python
# 나쁜 예, 프로그래머의 실수까지 포함해서 모든 걸 잡습니다
try:
    result = do_something()
except:
    pass

# 좋은 예, 예상하고 실제로 처리할 수 있는 것만 잡습니다
try:
    result = do_something()
except FileNotFoundError:
    print("파일을 찾을 수 없습니다")
Junotry / except 실패할 수도 있는 코드를 try 블록에 넣으면, 프로그램이 죽는 대신 맞는 except가 실행됩니다. 맨몸의 except:는 모든 걸 잡아서 진짜 버그를 가려버리니, ValueError처럼 예상되는 예외의 이름을 명시하세요. 실제로 처리할 수 있는 것만 잡고, 나머지는 드러나게 두세요.
Junotry / excepttry/except는 이름이 지정된 타입을 잡아서 프로그램이 죽는 대신 복구하고, as e로 메시지를 얻을 수 있습니다. 처리할 수 있는 구체적인 타입만 잡아야 관련 없는 버그가 그대로 드러납니다. try 본문은 작게 유지하세요. 스무 줄을 감싸면 예상했던 실패 하나가 나머지를 가려버립니다.
Junotry / exceptexcept는 해당 타입과 그 하위 타입에 맞아떨어지고, Python은 맞는 첫 번째 절을 사용하므로 구체적인 것을 먼저, 일반적인 것을 나중에 두세요. 그렇지 않으면 넓은 절이 나머지를 가려버립니다. 가장 좁은 타입만 잡고, try 본문은 실제로 예외를 던질 수 있는 줄로 최소화하고, 나머지는 그냥 전파되게 두세요. 넓은 try는 처리하려던 실패보다 훨씬 많은 것을 가려버립니다.

여러 예외 잡기

서로 다른 에러 타입을 각각의 except 블록에서 처리할 수도 있고, 튜플로 묶어서 하나의 블록에서 여러 타입을 잡을 수도 있습니다. as e 부분은 에러 메시지에 접근할 수 있게 해줍니다.

서로 다른 타입에 대해 except 절을 여러 개 쌓아두면 Python은 위에서 아래로 확인하며 맞는 첫 번째 것을 선택합니다. 여러 타입을 같은 방식으로 처리하려면 튜플로 묶으세요: except (ValueError, ZeroDivisionError). as e는 예외를 이름에 묶어서 메시지를 읽을 수 있게 해줍니다. 순서가 중요합니다. 구체적인 타입을 일반적인 타입보다 먼저 두세요. 그렇지 않으면 위쪽의 넓은 절이 모든 걸 잡아버려서 아래쪽 절들은 실행되지 않습니다.

순서가 함정입니다. Python은 타입이 맞는 첫 번째 except를 선택하고, 넓은 타입은 그보다 좁은 관련 타입들과도 맞아떨어지므로, except ValueError보다 위에 except Exception을 두면 그 ValueError 절은 결코 실행되지 않는 죽은 코드가 됩니다. 구체적인 것을 먼저, 일반적인 것을 나중에 두세요. 같은 방식으로 대응할 타입들은 except (A, B) as e로 묶으세요. 에러를 잡아서 정보를 덧붙인 다음 다시 던질 때는 from을 써서 새 예외를 던지세요: raise ValueError("잘못된 설정") from e. 이렇게 하면 원래 에러가 트레이스백에서 새 에러 아래에 그대로 보이므로, 보고서에는 상위 수준의 의미와 하위 수준의 원인이 하나를 다른 것으로 대체하지 않고 함께 나타납니다.

python
try:
    data = int(user_input)
    result = 100 / data
except ValueError:
    print("숫자가 아닙니다")
except ZeroDivisionError:
    print("0으로 나눌 수 없습니다")

혹은 튜플로 여러 개를 함께 잡을 수도 있습니다:

python
except (ValueError, ZeroDivisionError) as e:
    print(f"입력 오류: {e}")

as e는 예외 객체를 이름에 묶어서 메시지를 확인할 수 있게 해줍니다.

Juno여러 예외 잡기 각 에러 타입마다 별도의 except 블록을 두면 Python은 맞는 첫 번째 것을 실행합니다. 몇 가지를 같은 방식으로 처리하려면 튜플로 나열하세요: except (ValueError, ZeroDivisionError). as e 부분은 에러 객체를 건네주므로 메시지를 출력할 수 있습니다.
Juno여러 예외 잡기 여러 개의 except 절은 위에서 아래로 실행되며 맞는 첫 번째 것이 선택되므로, 구체적인 타입을 일반적인 타입보다 먼저 나열해야 합니다. 그렇지 않으면 넓은 절이 나머지를 가려버립니다. 튜플 except (A, B)는 하나의 블록으로 여러 타입을 처리합니다. as e는 예외를 이름에 묶어 메시지를 읽게 해줍니다.
Juno여러 예외 잡기 구체적인 절을 먼저 두세요. 넓은 타입이 좁은 타입 위에 있으면 그 좁은 절은 죽은 코드가 됩니다. 같은 방식으로 대응할 타입들은 except (A, B) as e로 묶으세요. 정보를 덧붙여 다시 던질 때는 raise NewError("...") from e를 써서, 원래 원인이 트레이스백에서 새 에러 아래에 계속 보이게 하고 사라지지 않게 하세요.

else와 finally

else는 예외가 발생하지 않았을 때만 실행됩니다. finally는 예외가 있었든 없었든 항상 실행됩니다. finally는 무슨 일이 있어도 반드시 이루어져야 하는 정리 작업에 유용합니다.

else에는 try 본문이 아무 예외도 던지지 않았을 때만 실행되어야 하는 코드를 넣습니다. 이걸 try 밖에 두면 의도가 명확해집니다. 성공 경로의 코드가 실수로 except 절에 잡히지 않게 하는 것입니다. finally는 정리 작업을 보장하는 장치로, 성공했든, 예외가 났든, 그 예외가 잡혔든, 심지어 return을 만났든 상관없이 실행됩니다. 그래서 결과와 무관하게 반드시 해제해야 하는 자원을 정리하는 데 딱 맞는 자리입니다.

elsetry 본문이 정상적으로 끝났을 때만 실행되며, 이를 통해 성공 경로의 코드가 try 밖에 있게 되어 실수로 여러분의 except 절 중 하나를 건드리지 않게 됩니다. finally는 조건 없이 실행됩니다. 성공한 뒤에도, 잡힌 예외 뒤에도, 잡히지 않고 위로 전파되는 예외의 도중에도, 심지어 try 안의 return 뒤에도 실행됩니다. 이 마지막 경우에는 알아둘 만한 함정이 숨어 있습니다. finally 안의 returntryreturn을 덮어쓸 뿐만 아니라, 더 나쁘게는 전파되던 예외를 조용히 삼켜버려서 에러가 아무 흔적도 없이 사라져 버립니다. finally는 정리 작업에만 쓰고 절대 그 안에서 return하지 마세요. 파일의 경우 with가 이미 이 보장을 해주므로, 컨텍스트 매니저가 없는 자원을 다룰 때 주로 finally를 쓰게 될 것입니다.

python
try:
    with open("data.txt") as f:
        content = f.read()
except FileNotFoundError:
    print("파일을 찾을 수 없어 기본값을 사용합니다")
    content = ""
else:
    print("파일을 성공적으로 불러왔습니다")
finally:
    print("파일 불러오기 시도 완료")   # 항상 실행됩니다

finally는 파일에 이미 with를 쓰고 있더라도, 연결을 끊거나 락을 해제하는 등의 정리 작업에 가장 유용합니다.

Junoelse와 finallyelsetry가 아무 예외도 던지지 않았을 때만 실행되므로, 성공 경로의 나머지 코드를 넣기 좋은 자리입니다. finally는 예외가 있든 없든 매번 실행되므로, 결과와 무관하게 반드시 해야 하는 정리 작업을 넣는 자리입니다. 파일의 경우 with가 이미 정리를 해주므로, 생각보다 finally를 쓸 일은 많지 않습니다.
Junoelse와 finallyelse는 정상적인 경로에서만 실행되므로, 성공 경로 코드를 try 밖에 두어 여러분의 except를 건드리지 않게 합니다. finallyreturn을 지나서도 무조건 실행되므로 정리 작업을 보장하는 장치입니다. 파일의 경우 with가 이미 이걸 해주니, finally는 컨텍스트 매니저가 없는 자원에 아껴 쓰세요.
Junoelse와 finallyelse는 정상 경로의 코드를 try 밖에 두어 여러분의 except 절을 건드리지 않게 하는 자리입니다. finally는 무슨 일이 있어도 실행되지만, 그 안에서 절대 return하지 마세요. 그렇게 하면 전파되던 예외를 덮어써서 에러가 조용히 사라져 버립니다. finally는 정리 작업에만, 대부분은 with에 의지할 수 없을 때만 쓰세요.

raise

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

raise ExceptionType("메시지")는 여러분의 코드에서 예외를 던지는 방법으로, 함수가 잘못된 값으로 억지로 계속 진행하는 대신 나쁜 입력을 알리는 방식입니다. except 블록 안에서 아무 인자 없이 그냥 raise만 쓰면 방금 잡은 예외를 다시 던지는데, 로그를 남기거나 뭔가 반응한 다음에도 그 예외가 계속 위로 전파되게 하고 싶을 때 유용합니다. 의도적으로 예외를 던지면 함수의 실패 방식이 그 함수의 명세의 일부가 되므로, 호출하는 쪽에서 이름으로 잡아서 처리할 수 있습니다.

raise SomeError("메시지")는 실패를 의도적으로 알리는 것으로, 함수가 감당할 수 없는 입력을 받았을 때 취해야 할 옳은 행동입니다. 크게 거부하는 것이 나중에 미스터리로 드러날 잘못된 결과를 반환하는 것보다 낫습니다. except 안에서 아무 인자 없는 raise는 원래의 트레이스백을 그대로 유지한 채 방금 잡은 예외를 다시 던지므로, 어디서 왔는지 지우지 않고 로그를 남긴 뒤 다시 던질 수 있습니다. 저수준 에러를 도메인 에러로 바꾸고 싶다면 from을 쓰세요. raise ConfigError("잘못된 설정") from e는 원래 에러를 보고서에서 새 에러 아래에 그대로 유지하므로, 읽는 사람은 의미와 원인을 둘 다 보게 됩니다. 원칙은 이렇습니다. 다시 던질 때는 정보를 더하기 위해서만 하고, 절대 숨기기 위해 하지 마세요.

python
def divide(a, b):
    if b == 0:
        raise ValueError("0으로 나눌 수 없습니다")
    return a / b

이렇게 하면 함수가 무엇을 기대하는지 명확해지고, 호출한 쪽에 문제를 분명하게 알릴 수 있습니다.

Junoraiseraise ExceptionType("메시지")는 여러분의 코드가 잘못된 값을 들고 계속 진행하는 대신, 문제를 소리 내어 알리게 해줍니다. "이건 처리할 수 없습니다"라고 함수가 말하는 방식이고, 그러면 호출한 쪽이 그걸 처리합니다. 명확하고 빠른 실패가 훨씬 나중에 드러나는 잘못된 결과보다 낫습니다.
Junoraiseraise ExceptionType("메시지")는 여러분의 코드에서 실패를 알리는 방법으로, 함수의 에러 상황을 호출하는 쪽이 그 함수를 쓰는 방식의 일부로 만듭니다. except 안의 아무 인자 없는 raise는 원래 트레이스백을 유지한 채 잡은 예외를 다시 던지므로, 로그를 남기면서도 계속 위로 전파되게 할 수 있습니다.
Junoraise 입력을 감당할 수 없을 때는 의도적으로 예외를 던지세요. 크게 거부하는 것이 나중에 미스터리로 드러날 잘못된 결과보다 낫습니다. 아무 인자 없는 raise는 원래 트레이스백을 그대로 유지한 채 다시 던집니다. 저수준 에러를 도메인 에러로 바꾸려면 raise DomainError("...") from e를 써서 원인이 계속 보이게 하세요. 다시 던지는 건 정보를 더하기 위해서만, 절대 묻어버리기 위해서가 아닙니다.

사용자 정의 예외 클래스

더 큰 프로그램에서는 Exception을 상속받아 직접 예외 타입을 정의할 수 있습니다. 이렇게 하면 호출하는 쪽에서 여러분의 특정 에러를 다른 종류의 에러와 구분해서 따로 잡을 수 있습니다.

사용자 정의 예외는 Exception을 상속받은 여러분만의 클래스입니다(새로운 종류의 예외가 되어, 같은 계층 구조 안으로 들어갑니다). 이렇게 하면 호출하는 쪽에서 내장 에러와 구분해서 정확하게 이름으로 잡을 수 있다는 이점이 생깁니다. 서로 관련된 여러 실패를 다룬다면 베이스 클래스 하나를 만들고 각 구체적인 경우마다 서브클래스를 만드세요. 호출하는 쪽은 베이스 클래스를 잡아서 그룹 전체를 처리하거나, 서브클래스를 잡아서 하나만 골라낼 수 있습니다.

BaseException(Ctrl-C나 종료까지도 포함하는 최상위 루트라서, 여러분의 에러를 그것과 같은 그룹으로 묶고 싶지는 않을 것입니다)이 아니라 Exception을 상속하세요. ValueError를 재활용하는 대신 직접 타입을 정의하는 이유는 계층 구조에 있습니다. PaymentError라는 베이스를 만들고 각 상황마다 서브클래스(InsufficientFundsError, CardDeclinedError)를 만들면, 호출하는 쪽은 메시지 문자열을 분석할 필요 없이 PaymentError를 잡아서 카테고리 전체를 처리하거나 서브클래스를 잡아서 한 가지 경우만 처리할 수 있습니다. 관련 필드(계정, 금액)를 담은 __init__을 클래스에 두면, 처리 코드가 텍스트를 긁어내는 대신 무슨 일이 있었는지 코드로 검사할 수 있게 됩니다. 그러면 에러가 단지 출력만 되는 것이 아니라 프로그램이 행동으로 대응할 수 있는 대상이 됩니다.

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"{amount}을 출금할 수 없습니다, 잔액은 {self.balance}입니다"
            )
        self.balance -= amount
python
try:
    account.withdraw(1000)
except InsufficientFundsError as e:
    print(f"거래가 거부되었습니다: {e}")
Juno사용자 정의 예외 클래스Exception을 상속받아 여러분만의 에러 타입을 만드세요. 몸통이 비어 있는 클래스 하나만으로 충분히 시작할 수 있습니다. 이렇게 하면 호출하는 쪽에서 내장 에러와 구분되는, 여러분의 것임을 알 수 있는 이름으로 잡을 수 있어서, 여러분 프로그램의 문제가 독립적으로 드러납니다.
Juno사용자 정의 예외 클래스 사용자 정의 예외는 Exception을 상속받은 클래스로, 호출하는 쪽에 정확하게 잡을 수 있는 이름을 제공합니다. 관련된 실패들은 베이스 클래스 하나를 만들고 각 경우마다 서브클래스로 만드세요. 그룹 전체는 베이스로, 한 가지는 서브클래스로 잡으면 됩니다. 메시지 문자열 이상의 세부 정보를 담을 필드도 추가할 수 있습니다.
Juno사용자 정의 예외 클래스BaseException이 아니라 Exception을 상속받아야 여러분의 에러가 Ctrl-C와 같은 그룹에 묶이지 않습니다. 직접 타입을 정의하는 이유는 계층 구조에 있습니다. PaymentError 베이스에 구체적인 서브클래스들을 두면 호출하는 쪽이 메시지 텍스트를 긁어낼 필요 없이 카테고리나 한 가지 경우를 처리할 수 있습니다. 처리 코드가 문자열이 아니라 데이터로 행동하도록 관련 필드를 __init__에 넣으세요.

JSON

JSON은 API, 설정 파일, 데이터 내보내기 등 모든 곳에서 통용되는 형식입니다. Python의 json 모듈이 이를 바로 처리해줍니다. json.load()는 파일 안의 JSON을 Python 딕셔너리리스트로 읽어들입니다. json.dump()는 딕셔너리나 리스트를 다시 JSON 형식으로 파일에 씁니다.

이름들은 무엇을 다루느냐에 따라 나뉩니다. json.load()/json.dump()는 파일 객체를 다루고, json.loads()/json.dumps()(끝에 s가 붙은 것들)는 메모리 안의 문자열을 다룹니다. 사람이 읽을 결과물이라면 dump/dumpsindent=2를 넘기세요. 그러지 않으면 한 줄로 뭉쳐 나옵니다. 유효하지 않은 JSON은 json.JSONDecodeError를 발생시키는데, 이는 ValueError의 한 종류이므로 ValueError를 잡는 처리기라면 이미 이것도 잡을 수 있습니다.

네 개의 이름은 두 가지 연산과 두 가지 대상을 조합한 것입니다. load/dump는 파일용, loads/dumps는 문자열용입니다(s는 "string"의 의미입니다). 실전에서 사람들을 곤경에 빠뜨리는 세부 사항은 JSON이 표현할 수 없는 것들입니다. JSON에는 날짜, set, Decimal 같은 개념이 없어서, json.dumps(datetime.now())TypeError를 던집니다. 이를 다루는 방법은 default=를 넘기는 것입니다. dump/dumps가 알지 못하는 값을 만날 때마다 호출하는 함수인데, JSON이 담을 수 있는 것(보통은 str(value))을 돌려줘야 합니다. 알아둘 만한 또 다른 규칙은, 숫자는 왕복하면서 float이 된다는 것입니다. 그래서 정확성을 위해 Decimal로 작성했던 값도 다시 읽으면 float이 되어 정확성을 잃습니다. 돈이나 정확한 소수점이 필요한 값은 절대 평범한 JSON 숫자로 저장하지 마세요(숫자 장을 참고하세요).

파일에서 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
객체 {}dict
배열 []list
문자열 ""str
숫자int 또는 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가 붙은 것)는 문자열에서 읽습니다.

JunoJSONjson.load(f)는 파일 안의 JSON을 Python 딕셔너리와 리스트로 바꾸고, json.dump(data, f)는 이를 다시 써줍니다. s가 붙은 버전인 json.loadsjson.dumps는 파일이 아니라 문자열을 대상으로 같은 일을 합니다. 결과물을 읽기 쉽게 만들려면 indent=2를 추가하세요.
JunoJSONload/dump는 파일을, loads/dumps는 문자열을 다루며 s는 문자열(string)을 뜻합니다. indent=2는 사람이 읽기 쉽게 만들어줍니다. 잘못된 JSON은 json.JSONDecodeError를 던지는데, 이는 ValueError이므로 ValueError를 잡으면 이미 파싱 실패까지 포함됩니다.
JunoJSON 두 가지 연산과 두 가지 대상의 조합입니다. load/dump는 파일용, loads/dumps는 문자열용입니다. JSON에는 날짜, set, Decimal이 없어서 이런 값을 dump하면 default=를 넘기지 않는 한 TypeError가 발생합니다. 그리고 숫자는 왕복하면서 float이 되므로, 돈은 절대 평범한 JSON 숫자로 저장하지 마세요. 다시 읽어올 때 정확성을 잃습니다.

실전 예제

작은 게임을 위한 저장/불러오기 패턴입니다. 상태를 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("게임을 저장했습니다.")

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)

설정 파일을 불러오고 결과를 저장하는 예제로, 각 실패 상황마다 구체적으로 예외를 처리합니다:

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"설정 파일을 찾을 수 없습니다: {path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"{path}에 잘못된 JSON이 있습니다: {e}")

def save_results(results: list[dict], path: str) -> None:
    with open(path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"{len(results)}개의 결과를 {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"작업 시작, 설정: {config_path}")
    try:
        with open(config_path) as f:
            config = json.load(f)
        log("info", f"설정을 불러왔습니다: {config}")
    except FileNotFoundError:
        log("error", f"설정 파일을 찾을 수 없습니다: {config_path}")
        raise
    except json.JSONDecodeError as e:
        log("error", f"설정 파일의 JSON이 잘못되었습니다: {e}")
        raise

try:
    process("config.json")
except Exception as e:
    log("critical", f"작업이 실패했습니다: {e}")

로그를 남긴 뒤 다시 던지면 호출한 쪽에서 원래 트레이스백을 그대로 보존할 수 있습니다. 최상위의 except Exception은 빠져나간 것을 모두 잡아서 critical로 로그를 남기고, 프로세스가 깔끔하게 종료되도록 합니다.