프로그래밍 예외 처리 이해하기: 오류 종류부터 복구와 자원 정리까지
예외 처리란? 프로그램 오류에 대응하는 방법과 try·except 사용법#
예외 처리는 프로그램 실행 중 정상적인 흐름을 계속할 수 없는 상황이 발생했을 때, 그 상황을 확인하고 적절히 대응하는 방법입니다.
사용자가 숫자 대신 문자를 입력하거나, 읽어야 할 파일이 없거나, 서버와의 연결이 끊어질 수 있습니다. 이런 상황을 전혀 고려하지 않으면 실행 중인 작업이 예상치 못한 지점에서 중단됩니다.
하지만 예외 처리를 한다고 해서 모든 오류를 무조건 무시하고 프로그램을 계속 실행해야 한다는 뜻은 아닙니다. 상황에 따라 다시 입력받거나, 작업을 취소하거나, 사용한 자원을 정리한 뒤 오류를 호출한 쪽에 알려야 합니다. 중요한 것은 실패했을 때의 동작을 의도적으로 정하는 것입니다.
이 글은 Python의 try·except·else·finally를 예제로 사용합니다. 예외를 식별하고, 처리 가능한 범위에서 대응하며, 처리할 수 없는 오류는 전달한다는 원리는 다른 언어에도 적용됩니다.
오류와 예외는 어떻게 다른가?#
프로그래밍에서 ‘에러’는 여러 문제를 넓게 가리키는 말입니다. 원인을 찾으려면 어느 단계에서 발생했는지 먼저 구분해야 합니다.
| 종류 | 언제 드러나는가? | 예시 | 주된 대응 |
|---|---|---|---|
| 문법 오류 | 코드를 해석하거나 실행하려는 단계 | 닫지 않은 따옴표, 빠진 콜론 | 코드 문법 수정 |
| 실행 중 예외 | 문법에 맞는 코드가 실행되는 도중 | 0으로 나누기, 없는 파일 열기 | 원인에 따라 처리하거나 전달 |
| 논리 오류 | 코드가 실행되지만 결과가 의도와 다를 때 | 할인율을 잘못 계산 | 입력·계산 과정 추적 및 수정 |
예를 들어 아래 코드는 따옴표가 닫히지 않았으므로 문법 오류가 발생합니다.
# 실행할 수 없는 문법 오류 예시
print("안녕하세요)반면 다음 코드는 문법에는 문제가 없습니다.
number = 0
result = 100 / number실행하는 순간 ZeroDivisionError가 발생합니다. 이런 실행 중 오류를 이 글에서는 주로 예외라고 부릅니다.
프로그램이 끝까지 실행되면서 10% 할인을 9%로 계산했다면 예외가 발생하지 않을 수도 있습니다. 그 경우는 계산 로직을 디버깅해야 합니다. 예외가 없다는 사실만으로 결과가 올바르다고 판단할 수는 없습니다.
예외는 왜 발생할까?#
실행 중 예외의 원인은 다양합니다.
- 입력 문제: 숫자를 기대했는데
"사과"가 들어옴 - 값의 문제: 나누는 수가 0임
- 자료의 범위 문제: 리스트에 없는 인덱스에 접근함
- 외부 환경 문제: 파일이 없거나 네트워크 요청이 실패함
- 코드의 문제: 존재하지 않는 속성을 사용하거나 잘못된 상태에서 메서드를 호출함
같은 예외라도 원인은 다를 수 있습니다. 따라서 예외의 이름만 확인하고 끝내지 말고, 어떤 값으로 어떤 작업을 시도했는지 함께 봐야 합니다.
예외가 발생하면 실행 흐름은 어떻게 바뀔까?#
다음 코드를 살펴보겠습니다.
print("계산 시작")
number = 0
result = 100 / number
print(f"계산 결과: {result}")
print("계산 종료")100 / number에서 ZeroDivisionError가 발생합니다. 그러면 그 아래의 두 print()는 실행되지 않습니다. 해당 범위에서 예외를 처리하지 않으면 예외는 함수를 호출한 쪽으로 전달되고, 끝까지 처리되지 않으면 프로그램의 해당 실행 흐름이 오류 메시지와 함께 종료됩니다.
함수로 나누어 보면 예외의 이동 경로가 더 분명해집니다.
def divide(total, count):
return total / count
def show_average(total, count):
average = divide(total, count)
print(f"평균: {average}")
show_average(100, 0)divide()에서 발생한 예외를 그 함수가 처리하지 않았으므로 show_average()를 거쳐 바깥 호출까지 전달합니다. 오류 화면의 스택 트레이스는 어느 함수가 어떤 함수를 호출했고 어디서 예외가 발생했는지 알려줍니다.
예외 처리에서는 “어디에서든 잡기”보다 어느 단계가 이 문제에 올바르게 대응할 수 있는가를 판단하는 일이 중요합니다.
try와 except: 예상한 예외에 대응하기#
Python에서는 예외가 발생할 가능성이 있는 작업을 try에 넣고, 처리할 예외를 except에 지정합니다.
try:
실행할_코드()
except 특정예외:
예외가_발생했을_때_할_일()try 안에서 예외가 발생하지 않으면 except는 실행되지 않습니다. 지정한 예외가 발생하면 try의 남은 코드는 건너뛰고 해당 except로 이동합니다.
숫자 입력과 0으로 나누기#
사용자에게 입력받은 수로 100을 나누어 보겠습니다.
try:
user_input = input("나눌 숫자를 입력하세요: ")
denominator = int(user_input)
result = 100 / denominator
print(f"계산 결과: {result}")
except ValueError:
print("정수로 변환할 수 있는 값을 입력해 주세요.")
except ZeroDivisionError:
print("0으로는 나눌 수 없습니다.")
print("입력 처리가 끝났습니다.")입력에 따라 실행 흐름이 달라집니다.
| 입력 | 발생하는 일 | 출력되는 안내 |
|---|---|---|
4 |
int("4")와 100 / 4가 성공 |
계산 결과: 25.0 |
사과 |
int("사과")에서 ValueError 발생 |
정수 입력 안내 |
0 |
100 / 0에서 ZeroDivisionError 발생 |
0으로 나눌 수 없다는 안내 |
"사과"를 입력하면 int()에서 예외가 발생하므로 나눗셈까지 진행하지 않습니다. 0을 입력하면 정수 변환은 성공하고 나눗셈에서 예외가 발생합니다.
오류가 발생한 위치와 종류가 다르므로 대응도 구분할 수 있습니다.
왜 예외 종류를 지정해야 할까?#
다음처럼 작성할 수도 있습니다.
try:
denominator = int(input("나눌 숫자: "))
print(100 / denominator)
except:
print("오류가 발생했습니다.")이 코드는 의도한 입력 오류 외에 코드의 다른 문제도 한꺼번에 잡습니다. 예를 들어 나중에 try 안에 데이터 저장 기능을 추가했을 때 발생한 예외까지 단순한 입력 오류처럼 표시할 수 있습니다. 원인을 숨기고 디버깅을 어렵게 만듭니다.
처리할 수 있다고 판단한 예외부터 구체적으로 지정하는 편이 좋습니다.
except ValueError:
print("숫자 형식이 올바르지 않습니다.")
except ZeroDivisionError:
print("0으로는 나눌 수 없습니다.")else와 finally: 성공 후 작업과 마무리 작업#
Python의 예외 처리문에는 else와 finally도 붙일 수 있습니다.
try:
예외가_발생할_수_있는_작업()
except 특정예외:
해당_예외에_대응()
else:
try에서_예외가_없었을_때_실행()
finally:
마지막에_정리할_작업()else: try가 성공했을 때만 실행하기#
try:
number = int(input("양의 정수를 입력하세요: "))
except ValueError:
print("정수를 입력해 주세요.")
else:
print(f"입력한 값의 두 배: {number * 2}")입력값을 정수로 바꾸는 데 성공했을 때만 else가 실행됩니다. 실패 가능성이 있는 변환과 성공 후의 계산을 구분해서 읽을 수 있습니다.
finally: 성공과 실패 뒤에 모두 실행하기#
파일이나 네트워크 연결처럼 사용 후 정리가 필요한 자원이 있습니다. finally는 try에서 예외가 발생하든 그렇지 않든 마무리 작업을 수행할 때 사용합니다.
file = None
try:
file = open("my_log.txt", "w", encoding="utf-8")
file.write("작업을 시작합니다.\n")
result = 10 / 0
file.write(f"결과: {result}\n")
except ZeroDivisionError:
print("계산 중 0으로 나누는 오류가 발생했습니다.")
finally:
if file is not None:
file.close()
print("파일을 닫았습니다.")예외는 result = 10 / 0에서 발생합니다. 그 아래의 file.write()는 실행되지 않지만, finally에서 파일을 닫습니다.
파일 처리에서는 with가 더 간단할 때가 많다#
파일을 안전하게 닫는 목적이라면 Python에서는 보통 with문을 사용합니다.
try:
with open("my_log.txt", "w", encoding="utf-8") as file:
file.write("작업을 시작합니다.\n")
result = 10 / 0
file.write(f"결과: {result}\n")
except ZeroDivisionError:
print("계산 중 오류가 발생했습니다.")with 블록을 벗어날 때 파일이 닫히므로, 파일 닫기를 위해 file = None과 finally를 직접 관리할 필요가 줄어듭니다. 데이터베이스 연결이나 잠금 같은 자원도 해당 라이브러리가 제공하는 문맥 관리자 사용법을 확인할 수 있습니다.
finally는 여전히 여러 종류의 정리 작업을 직접 조정해야 할 때 유용합니다. 자원을 정리할 책임이 있는 위치를 분명히 하는 것이 핵심입니다.
처리할 수 없는 예외는 어떻게 해야 할까?#
예외를 잡았다고 해서 반드시 그 자리에서 정상 처리된 것처럼 끝내야 하는 것은 아닙니다.
예를 들어 주문 금액은 0보다 커야 한다는 규칙이 있다고 하겠습니다.
def calculate_total(price, quantity):
if price < 0:
raise ValueError("가격은 음수일 수 없습니다.")
if quantity <= 0:
raise ValueError("수량은 0보다 커야 합니다.")
return price * quantityraise는 예외를 직접 발생시킵니다. 이 함수는 잘못된 입력으로 계산 결과를 만들어 내지 않고, 호출한 쪽에 어떤 문제가 있는지 알립니다.
try:
total = calculate_total(5_000, 0)
except ValueError as error:
print(f"주문을 처리할 수 없습니다: {error}")실행 결과:
주문을 처리할 수 없습니다: 수량은 0보다 커야 합니다.함수가 직접 복구할 방법을 모른다면 호출한 쪽에서 판단하도록 예외를 전달하는 것이 자연스럽습니다.
기록한 뒤 다시 전달하기#
예외가 발생한 사실을 기록해야 하지만 현재 위치에서 복구할 수는 없을 수도 있습니다.
import logging
logger = logging.getLogger(__name__)
def load_order(order_id):
try:
return read_order_from_storage(order_id)
except OSError:
logger.exception("주문 조회 중 저장소 오류: order_id=%s", order_id)
raise이 예시는 read_order_from_storage()라는 별도 함수가 있다고 가정한 구조입니다. logger.exception()은 현재 처리 중인 예외의 스택 정보를 기록하고, 인자 없는 raise는 그 예외를 다시 전달합니다.
단, 여러 계층에서 동일한 예외를 반복해서 기록하면 로그가 중복될 수 있습니다. 어디에서 기록하고 어디에서 사용자에게 안내할지 역할을 나누는 편이 좋습니다.
예외를 조용히 무시하면 어떤 문제가 생길까?#
다음 코드는 실행은 이어질 수 있지만 실패 원인을 숨깁니다.
try:
save_payment_result()
except:
pass결제 결과 저장에 실패해도 아무 기록이 없습니다. 호출한 쪽은 저장이 성공한 것으로 오해할 수 있습니다.
실패 시 할 일을 분명히 정해야 합니다.
- 다시 시도할 수 있는 상황인가?
- 사용자에게 실패를 알려야 하는가?
- 현재 작업을 취소해야 하는가?
- 호출한 쪽으로 예외를 전달해야 하는가?
- 원인을 조사할 수 있도록 어떤 정보를 기록해야 하는가?
특히 결제, 재고, 파일 저장처럼 결과가 남아야 하는 작업에서 예외를 숨기면 화면의 성공 안내와 실제 저장 상태가 달라질 수 있습니다.
무조건 except Exception을 쓰면 안 될까?#
모든 except Exception이 잘못된 것은 아닙니다. 요청 하나의 실패를 기록하고 다음 요청을 계속 받아야 하는 서버의 경계, 작업 큐의 실행 경계처럼 전체 작업을 관리하는 위치에서는 넓게 잡을 수 있습니다.
다만 그 경우에도 오류를 정상 결과로 둔갑시키지 말고, 로그·실패 상태·호출자에 대한 응답을 명확히 해야 합니다.
try:
process_job(job)
except Exception:
logger.exception("작업 처리 실패: job_id=%s", job.id)
mark_job_as_failed(job.id)이 구조는 process_job(), mark_job_as_failed()가 있다고 가정한 운영 코드의 형태입니다. 예외를 잡은 뒤 작업을 실패로 기록할 책임이 있는 위치에서만 의미가 있습니다.
예외 처리와 입력 검사는 어떻게 다를까?#
오류가 발생하기 전에 값이 규칙에 맞는지 확인할 수도 있습니다.
text = input("나눌 숫자를 입력하세요: ")
if not text.isdecimal():
print("0 이상의 정수를 입력해 주세요.")
else:
denominator = int(text)
if denominator == 0:
print("0으로는 나눌 수 없습니다.")
else:
print(100 / denominator)이 예제는 입력을 먼저 검사합니다. 그러나 isdecimal()의 판단 기준이 서비스에서 허용하려는 모든 숫자 표현과 같지는 않습니다. 예를 들어 음수나 소수까지 받으려면 다른 설계가 필요합니다.
입력 검사는 사용자에게 미리 친절한 안내를 제공하는 데 유용합니다. 예외 처리는 실행 중 실제 작업이 실패했을 때 대응하는 데 필요합니다. 파일이 존재하는지 검사한 직후 다른 프로그램이 파일을 지울 수도 있으므로, 사전 검사만으로 모든 실패를 막을 수는 없습니다.
실무에서는 두 방법을 목적에 맞게 함께 사용합니다.
자주 만나는 Python 예외#
| 예외 | 대표적인 발생 상황 | 먼저 확인할 내용 |
|---|---|---|
ValueError |
"사과"를 int()로 변환 |
입력값과 변환 규칙 |
ZeroDivisionError |
0으로 나눗셈 | 분모가 만들어진 과정 |
IndexError |
리스트 범위 밖의 인덱스 사용 | 리스트 길이와 인덱스 |
KeyError |
딕셔너리에 없는 키에 직접 접근 | 실제 키와 누락 가능성 |
FileNotFoundError |
없는 경로의 파일 열기 | 경로와 작업 디렉터리 |
PermissionError |
접근 권한이 없는 파일 작업 | 실행 계정과 파일 권한 |
TypeError |
맞지 않는 인자나 자료형 사용 | 함수의 입력 형태 |
예외 이름은 문제를 좁혀 주지만, 이름만으로 원인을 확정할 수는 없습니다. 예를 들어 FileNotFoundError는 파일 자체가 없어서일 수도 있고, 파일 경로를 현재 작업 디렉터리 기준으로 잘못 작성해서일 수도 있습니다.
실무에서 예외 처리 코드를 읽고 작성하는 순서#
처음부터 모든 실패를 예상하려고 하면 코드가 복잡해집니다. 실제 작업을 하나씩 따라가며 판단해 보세요.
1. 어떤 작업이 실패할 수 있는지 찾는다#
파일 열기, 입력 변환, 네트워크 요청, 데이터 저장처럼 외부 값과 환경에 의존하는 지점을 표시합니다.
2. 실패를 현재 위치에서 처리할 수 있는지 판단한다#
사용자 입력 화면은 “숫자를 다시 입력해 주세요”라고 안내할 수 있습니다. 반면 낮은 수준의 파일 읽기 함수는 어떤 화면을 보여줘야 할지 모를 수 있습니다. 그 경우에는 예외를 호출한 쪽으로 전달하는 편이 자연스럽습니다.
3. 처리할 예외를 가능한 한 구체적으로 지정한다#
숫자 변환 실패를 처리하려면 ValueError, 파일이 없을 때 대체 값을 사용하려면 FileNotFoundError처럼 해당 상황에 맞는 예외를 확인합니다.
4. 실패 후 상태를 확인한다#
예외가 발생한 뒤에도 프로그램을 계속할 수 있는지 살펴봅니다. 주문 저장에 실패했는데 “주문 완료”를 출력해서는 안 됩니다.
5. 원인을 추적할 수 있게 기록한다#
사용자에게는 이해할 수 있는 안내를 보여주고, 개발자가 조사할 때 필요한 오류 종류와 맥락은 로그에 남깁니다. 비밀번호나 결제 정보처럼 민감한 값은 로그에 그대로 기록하지 않도록 주의합니다.
직접 확인해 보기#
다음 함수는 가격 문자열을 정수로 바꾸고 수량을 곱합니다.
def calculate_order_total(price_text, quantity):
price = int(price_text)
if price < 0:
raise ValueError("가격은 음수일 수 없습니다.")
if quantity <= 0:
raise ValueError("수량은 0보다 커야 합니다.")
return price * quantity각 호출의 결과를 예상해 보세요.
calculate_order_total("5000", 2)
calculate_order_total("오천", 2)
calculate_order_total("5000", 0)- 첫 번째는
10000을 반환합니다. - 두 번째는
int("오천")에서ValueError가 발생합니다. - 세 번째는 수량 검사에서 직접 발생시킨
ValueError가 전달됩니다.
둘 다 ValueError이지만 어디에서, 왜 발생했는지는 다릅니다. 호출하는 쪽에서 어떤 안내가 필요한지 결정하려면 예외가 생긴 작업과 메시지까지 살펴봐야 합니다.
정리#
예외 처리는 실행 중 발생한 실패에 대해 프로그램이 어떻게 반응할지 정하는 방법입니다.
try에는 실패할 수 있는 작업을 넣습니다.except에서는 처리할 수 있는 예외에 맞춰 대응합니다.else는try에서 예외가 없었을 때 실행합니다.finally는 성공과 실패 뒤의 정리 작업에 사용합니다.raise는 잘못된 상태를 호출한 쪽에 예외로 알립니다.
좋은 예외 처리의 기준은 프로그램이 무조건 멈추지 않는 것이 아닙니다. 실패 사실을 숨기지 않고, 처리할 수 있는 곳에서 적절히 대응하며, 그 뒤의 데이터와 프로그램 상태를 신뢰할 수 있게 만드는 것입니다.