E-1: 예외 처리 — 에러를 받아내고, 직접 일으키기
목차 29
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 @dataclass로 회원·댓글·상태를 단단하게 설계하면서 카테고리 D(객체지향)를 마쳤어요. 짧은 코드로 안전한 객체를 만드는 법을 익혔죠.
그런데 그 객체들, 잘못된 값이 들어오면 어떻게 될까요? 지난 시간 Comment를 만들 때 "빈 댓글을 거부하는 건 다음 시간에" 하고 한 가지를 미뤄뒀어요. 그게 바로 오늘 만날 raise예요.
그리고 더 큰 이야기가 있어요. 지금까지 우리 코드는 에러를 만나면 그냥 죽었어요. int("열")을 하면 빨간 글씨가 쏟아지면서 프로그램이 멈췄죠. A-1부터 봐온 그 빨간 화면들 기억하시죠? 오늘은 그 죽음을 막습니다. 에러를 끝이 아니라 다룰 수 있는 값으로 바꾸는 거예요.
오늘의 여정 — 예외 처리
[에러가 나면 프로그램이 죽는다]
│
▼
try / except ─────────── 에러를 받아낸다 (Step 1~2)
│
▼
예외 계층 · 여러 except ── 어떤 에러인지 구분한다 (Step 3)
│
▼
else / finally ───────── 정상일 때 · 무슨 일이 있어도 (Step 4)
│
▼
raise ────────────────── 내가 직접 에러를 일으킨다 (Step 5)
│
▼
커스텀 예외 · 체이닝 ───── 의미 있는 이름의 에러 (Step 6~7)
│
▼
[회원 저장소를 검증하는 캡스톤] (Step 8)
💡 오늘 수업의 핵심 — "에러는 끝이 아니라, 다룰 수 있는 값이다"
🎯 학습 목표
try/except로 에러를 받아내 프로그램이 죽지 않게 만든다.- 예외도 클래스임을 이해하고, 계층에 맞춰 여러
except를 순서대로 쓴다. raise로 직접 규칙을 강제하고, 의미 있는 이름의 커스텀 예외를 만든다.
Step 1: "프로그램이 죽지 않게 — try와 except"
지금까지 우리가 본 빨간 화면을 하나 떠올려 볼게요. 사용자가 좋아요 수를 숫자로 입력해야 하는데 실수로 "열"이라고 글자를 넣었어요.
# src/instagram/e1_intro.py
number = int("열") # ValueError 가 터지며 프로그램 종료
이 한 줄이 실행되는 순간 프로그램은 그 자리에서 멈춰요. 아래에 아무리 좋은 코드가 있어도 도달조차 못 하죠. 사용자 입장에선 앱이 갑자기 꺼진 거예요.
이럴 때 쓰는 게 try와 except예요. "위험할 수 있는 코드는 try에 넣고, 에러가 나면 except에서 받아라"는 약속이에요.
# src/instagram/e1_intro.py
try:
number = int("열") # 위험한 코드: 숫자가 아닌 문자열
print(f"숫자로 바꿨어요: {number}") # 예외가 나면 이 줄은 실행되지 않는다
except ValueError:
print("숫자로 바꿀 수 없는 값이에요. 다시 입력해 주세요.")
print("프로그램은 계속 진행됩니다.") # try/except 덕분에 죽지 않았다
실행하면 이렇게 나와요.
숫자로 바꿀 수 없는 값이에요. 다시 입력해 주세요.
프로그램은 계속 진행됩니다.
빨간 화면 대신 우리가 준비한 안내 문구가 나오고, 무엇보다 마지막 줄까지 프로그램이 살아서 진행됐어요. int("열")에서 터진 ValueError를 except가 받아낸 덕분이에요.
구조를 그림으로 보면 이래요.
try:
위험할 수 있는 코드 ← 여기서 에러가 나면
except 예외종류:
에러가 났을 때 할 일 ← 곧장 이리로 점프
try 안에서 에러가 안 나면 except는 통째로 건너뛰어요. 에러가 날 때만 except로 뛰어가는 거예요.
💡 한 줄 정리
위험할 수 있는 코드를 try에 넣고 except로 받아내면, 에러가 나도 프로그램이 죽지 않고 계속 진행돼요.
🙋 학생 질문 — "튜터님, try 안에 코드를 다 넣으면 느려지지 않나요?"
좋은 걱정이에요. 결론부터 말하면 거의 신경 안 써도 돼요. try는 예외가 실제로 나지 않으면 비용이 거의 0이에요. 파이썬은 "에러가 안 나는 경우"를 빠르게 통과하도록 만들어져 있거든요.
오히려 매번 if로 "이 값이 숫자일까?"를 일일이 검사하는 것보다, 일단 해보고 안 되면 받아내는 try/except가 더 파이썬다운 방식이에요. 이걸 "허락보다 용서가 쉽다(EAFP)"라고 불러요. 지금은 이름만 기억해 두세요.
Step 2: "어떤 에러인지 콕 집어 — except와 as e"
Step 1에서 except ValueError라고 적었죠. 이렇게 예외 종류를 콕 집으면, 그 종류의 에러만 받아내요. 그리고 잡은 에러가 정확히 무슨 말을 하는지 궁금할 때가 있어요. 그럴 땐 as e를 붙여서 에러를 변수에 담아요.
# src/instagram/e1_specific.py
likes = {"minji": 8500, "jaehoon": 320}
# as e: 잡은 예외 객체를 변수 e 에 담는다 → 메시지를 본다
try:
score = likes["ghost"] # 없는 키 → KeyError
print(score)
except KeyError as e:
print(f"그런 회원이 없어요. (없는 키: {e})")
그런 회원이 없어요. (없는 키: 'ghost')
as e로 받은 e를 출력하니 어떤 키가 문제였는지('ghost')까지 알려줘요. 디버깅할 때 이 메시지가 큰 힌트가 돼요.
여기서 중요한 게 하나 있어요. except는 적어둔 종류의 예외만 잡아요. 만약 다른 종류의 에러가 나면 그냥 통과시켜요. 그러면 그 에러는 받아내지 못하고 프로그램이 다시 죽어요.
# src/instagram/e1_uncaught.py
try:
print(10 / 0) # ZeroDivisionError
except KeyError: # KeyError 만 잡으므로 못 받아낸다
print("이 줄은 실행되지 않는다")
이 코드는 ZeroDivisionError가 났는데 except는 KeyError만 기다리고 있어요. 못 잡으니 그대로 터져요.
Traceback (most recent call last):
File "e1_uncaught.py", line 9, in <module>
print(10 / 0)
~~~^~~
ZeroDivisionError: division by zero
~~~^~~ 화살표가 정확히 10 / 0을 가리키죠. 파이썬 3.14의 친절한 에러 표시예요. 교훈은 분명해요. 잡고 싶은 예외의 종류를 정확히 적어야 받아낼 수 있어요.
💡 한 줄 정리
except 예외종류 as e로 원하는 종류의 에러만 콕 집어 잡고, e로 그 에러의 메시지를 꺼내 볼 수 있어요.
🙋 학생 질문 — "튜터님, 그냥 except 만 쓰고 종류를 안 적으면 다 잡히지 않나요?"
맞아요, except:라고만 쓰면 모든 에러가 잡혀요. 그런데 그게 함정이에요. 오타로 생긴 엉뚱한 에러까지 다 삼켜버려서, 정작 문제가 어디서 났는지 영영 모르게 되거든요.
그래서 "잡을 종류를 정확히 적는다"가 기본 원칙이에요. 정말 넓게 잡아야 할 땐 다음 Step에서 배울 except Exception을 쓰면 돼요. 종류 없는 맨손 except는 가능하면 피하는 게 좋아요.
Step 3: "예외도 클래스다 — 예외 계층과 여러 except"
지금부터 한 꺼풀 더 들어가요. 사실 ValueError, KeyError 같은 예외들은 전부 클래스예요. D-2에서 배운 상속, 기억하시죠? 예외들도 부모-자식 관계로 이어진 가족이에요.
# src/instagram/e1_hierarchy.py
err = ValueError("잘못된 값")
print(isinstance(err, ValueError)) # True
print(isinstance(err, Exception)) # True — ValueError 는 Exception 의 자식
print(isinstance(err, BaseException)) # True — 모든 예외의 최상위
print(issubclass(KeyError, Exception)) # True
print(issubclass(ValueError, KeyError)) # False — 둘은 형제, 부모-자식이 아니다
ValueError로 만든 err가 Exception이기도 하다고 나오죠(isinstance가 True). ValueError가 Exception의 자식이기 때문이에요. 가족 관계를 그림으로 보면 이래요.
BaseException ← 모든 예외의 최상위 (직접 잡을 일은 거의 없음)
└─ Exception ← 우리가 다루는 거의 모든 에러의 부모
├─ ValueError 값이 잘못됨
├─ TypeError 타입이 안 맞음
├─ KeyError 딕셔너리에 없는 키
├─ IndexError 리스트 범위 밖
└─ ZeroDivisionError 0 으로 나눔
이 계층을 알면 강력한 걸 할 수 있어요. 부모로 잡으면 자식들이 전부 걸려요. except Exception은 웬만한 에러를 다 받아낸다는 뜻이에요.
그래서 여러 종류를 다르게 처리하고 싶으면 except를 여러 개 쌓아요. 단, 순서가 중요해요. 자식을 먼저, 부모를 나중에 둬야 해요.
# src/instagram/e1_multi.py
def parse_score(text):
try:
return 100 / int(text)
except ValueError: # 숫자가 아닐 때
return "숫자를 입력해 주세요"
except ZeroDivisionError: # 0 으로 나눌 때
return "0 으로는 나눌 수 없어요"
except Exception as e: # 그 밖의 모든 예외 (부모라서 맨 마지막에)
return f"알 수 없는 오류: {e}"
parse_score("4") → 25.0
parse_score("열") → 숫자를 입력해 주세요
parse_score("0") → 0 으로는 나눌 수 없어요
위에서부터 차례로 맞는 except를 찾아 들어가요. "열"은 int()에서 ValueError가 나니 첫 번째로, "0"은 나눗셈에서 ZeroDivisionError가 나니 두 번째로 가요. 어디에도 안 맞으면 마지막 except Exception이 받아주고요.
⚠️ BaseException까지 잡는 건 피하세요. 거기엔 Ctrl+C로 프로그램을 멈추는 신호 같은 것도 들어 있어서, 그것까지 삼키면 프로그램을 끌 수 없게 돼요. 넓게 잡을 땐 Exception까지만.
💡 한 줄 정리
예외도 상속 관계를 가진 클래스예요. 여러 except는 자식을 먼저, 부모(Exception)를 나중에 두고, 위에서부터 맞는 곳으로 들어가요.
🙋 학생 질문 — "튜터님, 왜 부모(Exception)를 맨 위에 쓰면 안 되나요?"
except Exception을 맨 위에 두면 모든 에러가 거기서 다 잡혀버려요. ValueError든 ZeroDivisionError든 일단 Exception의 자식이니까요.
그러면 아래에 정성껏 적어둔 except ValueError 같은 줄은 영영 실행될 일이 없어요. 도달할 수 없는 죽은 코드가 되는 거예요. 그래서 구체적인 자식부터 먼저 적고, 넓게 받는 부모는 맨 마지막 그물로 두는 거예요.
Step 4: "정상일 때 · 무슨 일이 있어도 — else와 finally"
try/except에는 짝이 둘 더 있어요. else와 finally예요. 넷을 합치면 완전한 구조가 돼요.
try:
위험한 코드
except 예외:
에러가 났을 때
else:
에러가 "없었을" 때만
finally:
무슨 일이 있어도 항상
else는 "try가 무사히 끝났을 때만" 실행돼요. finally는 "성공이든 실패든 항상" 실행되고요.
# src/instagram/e1_else_finally.py
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("0 으로 나눌 수 없어요")
else:
print(f"결과: {result}") # 예외가 없을 때만 실행
finally:
print("계산을 마칩니다") # 성공이든 실패든 항상 실행
divide(10, 2)와 divide(10, 0)을 차례로 부르면 이렇게 나와요.
결과: 5.0
계산을 마칩니다
---
0 으로 나눌 수 없어요
계산을 마칩니다
성공한 divide(10, 2)는 else로 가서 결과를 보여주고, 실패한 divide(10, 0)은 except로 갔어요. 그런데 둘 다 마지막에 "계산을 마칩니다"가 찍혔죠? 그게 바로 finally예요. 성공하든 실패하든 빠짐없이 실행돼요.
finally는 뒷정리에 써요. 예를 들어 파일을 열었으면 무슨 일이 있어도 닫아야 하잖아요. 그런 마무리를 finally에 두면 에러가 나도 빠뜨리지 않아요. 파일을 다루는 법은 다음 시간(E-2)에 배우는데, 그때 이 finally의 정신이 더 편한 문법으로 이어져요.
💡 한 줄 정리
else는 예외가 없었을 때만, finally는 성공이든 실패든 항상 실행돼요. 뒷정리는 finally에 두면 빠지지 않아요.
🙋 학생 질문 — "튜터님, except 안에서도 print 할 수 있는데 finally 가 왜 또 필요해요?"
핵심은 "어떤 경우에도 빠지지 않는다"예요. except는 에러가 났을 때만 실행되고, else는 성공했을 때만 실행돼요. 둘 다 한쪽 경우만 책임지죠.
finally는 양쪽 모두에서, 심지어 try 안에서 return으로 함수를 빠져나가는 순간에도 실행돼요. "이건 무슨 일이 있어도 반드시 해야 한다"는 정리 작업을 안전하게 맡길 수 있는 유일한 곳이에요.
Step 5: "내가 직접 에러를 일으킨다 — raise"
지금까지는 파이썬이 일으킨 에러를 받아내기만 했어요. 이번엔 반대예요. 내가 직접 에러를 일으키는 거예요. 그게 raise예요.
왜 일부러 에러를 낼까요? 규칙을 강제하기 위해서예요. "팔로워 수는 0보다 작을 수 없다" 같은 규칙을 어기면, 조용히 넘어가지 말고 분명하게 "이건 잘못됐다"고 외치는 거예요.
# src/instagram/e1_raise.py
def set_followers(count):
if count < 0:
raise ValueError("팔로워 수는 0 보다 작을 수 없어요")
return count
raise ValueError("메시지")는 우리가 ValueError를 직접 던진다는 뜻이에요. 던진 에러는 파이썬이 낸 에러와 똑같이 동작해요. 그래서 try/except로 받아낼 수 있어요.
try:
set_followers(-5)
except ValueError as e:
print(f"거부됨: {e}")
print(set_followers(8500)) # 정상 값은 그대로 통과
거부됨: 팔로워 수는 0 보다 작을 수 없어요
8500
-5는 규칙을 어겨서 raise로 거부됐고, 8500은 통과했어요. 자, 이제 약속을 지킬 시간이에요. 지난 시간 Comment를 만들 때 "빈 댓글 거부는 다음 시간에" 하고 미뤄뒀던 그 검증, 드디어 채워요.
# src/instagram/comment.py
@dataclass
class Comment:
author: str
text: str
char_count: int = field(init=False)
def __post_init__(self):
self.text = self.text.strip() # 정규화: 앞뒤 공백 제거
if not self.text: # 공백만 있던 댓글은 빈 문자열이 된다
raise ValueError("빈 댓글은 등록할 수 없어요")
self.char_count = len(self.text)
__post_init__은 객체가 만들어진 직후에 불린다고 했죠(D-4). 거기서 공백을 정리한 뒤, 내용이 비었으면 raise로 거부해요. 이제 빈 댓글은 아예 만들어지지 않아요.
try:
Comment("spammer", " ") # 공백만 → 정규화하면 빈 문자열
except ValueError as e:
print(f"댓글 거부됨: {e}")
댓글 거부됨: 빈 댓글은 등록할 수 없어요
객체 스스로가 잘못된 상태로 태어나길 거부하는 거예요. 이게 D-4에서 우리가 심어둔 복선의 회수예요.
💡 한 줄 정리
raise 예외("메시지")로 내가 직접 에러를 일으켜 규칙을 강제할 수 있어요. 던진 에러도 try/except로 똑같이 받아내요.
🙋 학생 질문 — "튜터님, raise 한 에러를 아무도 안 잡으면 어떻게 되나요?"
파이썬이 낸 에러와 똑같이 동작해요. 가까운 try/except가 없으면 함수 밖으로, 또 그 밖으로 계속 올라가요. 끝까지 아무도 안 잡으면 결국 빨간 화면과 함께 프로그램이 멈추죠.
그래서 raise는 "여기서 문제가 생겼다"고 알리는 신호탄이에요. 그 신호를 어디서 받아낼지(except)는 그 함수를 쓰는 쪽이 정해요. 문제를 알리는 쪽과 처리하는 쪽을 깔끔하게 나누는 거예요.
Step 6: "의미 있는 이름의 에러 — 커스텀 예외"
Step 5에서 빈 댓글에 ValueError를 썼어요. 동작은 잘 해요. 그런데 회원을 못 찾았을 때도 ValueError, 이메일이 중복일 때도 ValueError를 쓰면 어떻게 될까요? 나중에 보면 무슨 일로 터진 에러인지 구분이 안 돼요.
그래서 우리만의 예외를 직접 만들어요. 이름이 의도를 말해주게요. 만드는 법은 놀랄 만큼 간단해요. Exception을 상속한 클래스 하나면 끝이에요(D-2의 상속이 여기서 빛나요).
# src/instagram/exceptions.py
class InstagramError(Exception):
"""이 앱에서 발생하는 모든 도메인 예외의 부모."""
class MemberNotFoundError(InstagramError):
"""요청한 회원이 저장소에 없을 때."""
class DuplicateEmailError(InstagramError):
"""이미 등록된 이메일로 다시 가입하려 할 때."""
몸통도 없이 한 줄짜리 docstring만 있죠? 예외 클래스는 보통 이걸로 충분해요. 중요한 건 동작이 아니라 이름이거든요. MemberNotFoundError라는 이름만 봐도 무슨 일이 일어났는지 바로 알 수 있어요.
여기서 설계 하나를 눈여겨보세요. 우리 예외들은 모두 InstagramError라는 공통 부모 아래에 모여 있어요. Step 3에서 배운 계층을 우리가 직접 만든 거예요.
Exception
└─ InstagramError ← 우리 앱의 모든 예외의 부모
├─ MemberNotFoundError 회원을 못 찾음
└─ DuplicateEmailError 이메일 중복
이렇게 묶어두면 좋은 점이 있어요. "우리 앱에서 난 에러는 일단 다 잡자" 싶을 땐 except InstagramError 한 줄로 자식들을 전부 받아낼 수 있어요. 부모로 자식을 잡는 그 원리, 그대로예요.
💡 한 줄 정리
Exception을 상속한 클래스 한 줄로 커스텀 예외를 만들어요. 이름이 의도를 말해주고, 공통 부모로 묶으면 한 번에 골라 잡을 수 있어요.
🙋 학생 질문 — "튜터님, 그냥 ValueError 쓰면 되는데 왜 클래스를 또 만들어요?"
두 가지 이득이 있어요. 첫째는 읽는 사람을 위한 거예요. except ValueError보다 except MemberNotFoundError가 무슨 상황인지 훨씬 분명하게 말해주죠.
둘째는 골라 잡기예요. ValueError로 통일하면 "회원 없음"과 "이메일 중복"을 따로 처리할 수가 없어요. 둘 다 같은 종류니까요. 이름이 다른 커스텀 예외를 쓰면 except MemberNotFoundError와 except DuplicateEmailError로 각각 다르게 대응할 수 있어요.
Step 7: "원래 에러를 잃지 않기 — 예외 체이닝 raise from"
상황을 하나 그려볼게요. 좋아요 수를 딕셔너리에서 꺼내는데 없는 회원이면 KeyError가 나요. 그런데 우리는 이 함수를 쓰는 사람에게 KeyError 대신, 의미가 분명한 MemberNotFoundError를 돌려주고 싶어요.
이럴 때 낮은 수준의 KeyError를 잡아서, 우리 도메인 예외로 바꿔 던져요. 이때 raise ... from을 쓰면 원래 원인을 잃지 않고 보존할 수 있어요.
# src/instagram/e1_chaining.py
def get_likes(username):
try:
return likes[username]
except KeyError as e:
# 원래의 KeyError 를 from e 로 보존하면서, 우리 도메인 예외로 바꿔 던진다
raise MemberNotFoundError(f"회원을 찾을 수 없어요: {username}") from e
try:
get_likes("ghost")
except MemberNotFoundError as e:
print(f"잡은 예외: {e}")
print(f"원래 원인: {e.__cause__!r}") # from 으로 연결된 원래 KeyError
잡은 예외: 회원을 찾을 수 없어요: ghost
원래 원인: KeyError('ghost')
겉으로는 깔끔한 MemberNotFoundError를 받았지만, e.__cause__를 보면 진짜 원인이었던 KeyError('ghost')가 그대로 붙어 있어요. from e가 둘을 사슬처럼 이어준 거예요.
이게 왜 중요할까요? 디버깅 때문이에요. from을 쓰면 에러를 추적할 때 "회원을 못 찾았는데, 그 진짜 원인은 딕셔너리에 키가 없어서였다"는 전체 이야기가 다 남아요. 표면만 바꾸고 원인을 버리면, 정작 문제가 어디서 시작됐는지 놓치게 돼요.
💡 한 줄 정리
낮은 수준의 에러를 도메인 예외로 바꿔 던질 때 raise 새예외 from 원래예외를 쓰면, 표면은 깔끔해지면서 원래 원인도 사슬로 보존돼요.
🙋 학생 질문 — "튜터님, 그냥 raise 만 해도 되는데 from 을 꼭 써야 하나요?"
안 써도 동작은 해요. 하지만 from을 빼면 원래 원인이 흐려져요. 나중에 문제를 추적할 때 "왜 회원을 못 찾았지?"의 진짜 이유(딕셔너리에 키가 없었다)를 잃어버리는 거예요.
from을 쓰면 에러 메시지에 "위 예외가 다음 예외의 직접적인 원인이었다"는 연결고리가 남아요. 표면의 에러와 뿌리의 에러를 둘 다 볼 수 있으니, 디버깅이 훨씬 수월해져요. 습관으로 들여두면 좋아요.
Step 8: "종합 — 커스텀 예외로 회원 저장소 검증"
오늘 배운 걸 한데 모아요. 회원을 보관하는 작은 저장소를 만들 거예요. 이메일을 열쇠 삼아 회원을 담아두고, 두 가지 규칙을 지켜요. 이미 가입한 이메일로 또 가입하면 거부하고(DuplicateEmailError), 없는 회원을 찾으면 분명하게 알려줘요(MemberNotFoundError).
# src/instagram/e1_capstone.py
from instagram.member import Member
from instagram.exceptions import MemberNotFoundError, DuplicateEmailError
class MemberRepository:
def __init__(self):
self._members = {} # 이메일 -> Member
def register(self, email, member):
if email in self._members:
raise DuplicateEmailError(f"이미 가입된 이메일이에요: {email}")
self._members[email] = member
def find(self, email):
try:
return self._members[email]
except KeyError as e:
raise MemberNotFoundError(f"회원을 찾을 수 없어요: {email}") from e
register는 이미 있는 이메일이면 raise로 막아요(Step 5). find는 없는 이메일이면 KeyError를 잡아 MemberNotFoundError로 바꿔 던지되, from e로 원인을 보존하고요(Step 7). 써볼게요.
repo = MemberRepository()
repo.register("minji@insta.com", Member("minji", followers=12000))
print("가입 완료: minji@insta.com")
try:
repo.register("minji@insta.com", Member("minji2")) # 중복 이메일
except DuplicateEmailError as e:
print(f"가입 실패: {e}")
try:
repo.find("ghost@insta.com") # 없는 회원
except MemberNotFoundError as e:
print(f"조회 실패: {e}")
found = repo.find("minji@insta.com") # 정상 조회
print(f"조회 성공: {found.handle} (팔로워 {found.followers})")
가입 완료: minji@insta.com
가입 실패: 이미 가입된 이메일이에요: minji@insta.com
조회 실패: 회원을 찾을 수 없어요: ghost@insta.com
조회 성공: @minji (팔로워 12000)
try/except로 받아내고, raise로 규칙을 강제하고, 커스텀 예외로 의미를 분명히 하고, from으로 원인을 보존하고. 오늘 배운 게 한 흐름으로 맞물려 돌아가요. 잘못된 요청은 분명한 메시지로 거부되고, 정상 요청만 통과하는 단단한 저장소가 됐어요.
💡 한 줄 정리
try/except·raise·커스텀 예외·from을 한데 모으면, 잘못된 요청은 분명한 메시지로 거부하고 정상 요청만 통과시키는 검증된 도메인을 만들 수 있어요.
🙋 학생 질문 — "튜터님, 저장소를 dict 로 만들었는데 진짜 DB 는 언제 배워요?"
지금 만든 저장소는 메모리 안에만 살아 있어요. 프로그램을 끄면 사라지죠. 다음 시간(E-2)에는 이 데이터를 파일에 저장하고 다시 불러오는 법을 배워요. 객체를 JSON 같은 형식으로 파일에 적어두는 거예요.
진짜 데이터베이스는 한참 뒤 후속 과목의 몫이에요. 다만 그때 만나는 "저장소(Repository)"라는 개념의 뼈대를 바로 오늘 우리가 만들어 본 거예요. 지금 익혀두면 그때 훨씬 수월해요.
마무리
오늘은 에러를 대하는 자세를 완전히 바꿨어요. 빨간 화면 앞에서 무력하게 프로그램이 죽는 대신, 에러를 받아내고(try/except), 직접 일으키고(raise), 의미를 입히는(커스텀 예외) 주도권을 손에 쥐었죠.
오늘 배운 핵심 세 가지
- 💡 하나 —
try/except로 위험한 코드를 감싸 에러를 받아내면 프로그램이 죽지 않아요. 예외도 클래스라 계층이 있고, 여러except는 자식 먼저·부모 나중으로 둬요.else는 정상일 때만,finally는 항상 실행되고요. - 💡 둘 —
raise로 내가 직접 에러를 일으켜 규칙을 강제해요. 지난 시간 미뤄둔 "빈 댓글 거부"를__post_init__의raise로 드디어 완성했어요. - 💡 셋 —
Exception을 상속해 의미 있는 이름의 커스텀 예외를 만들고,raise ... from으로 원래 원인을 사슬처럼 보존해요.
다음 시간 예고
오늘 만든 회원 저장소는 메모리 안에서만 살아요. 프로그램을 끄면 다 사라지죠. 다음 시간(E-2: 파일과 데이터)에는 이 데이터를 파일에 저장하고 다시 불러와요. with로 파일을 안전하게 열고, pathlib로 경로를 다루고, json으로 객체를 파일에 적었다가 되살리는 거예요. 오늘 finally에서 이야기한 "무슨 일이 있어도 정리한다"는 정신이, 다음 시간 with 한 줄로 더 편하게 이어져요. 콘솔에만 살던 우리 객체들이 드디어 파일로 영속화돼요.
과제
오늘 배운 예외 처리를 직접 손으로 써볼 차례예요. 코드를 짜면서 막히면 교안의 해당 Step을 다시 펼쳐 보세요.
[기초] 안전한 정수 변환 함수
사용자 입력은 늘 숫자라는 보장이 없어요. safe_int(text, default=0) 함수를 만들어 보세요.
text를 정수로 바꿔서 돌려줘요.- 바꿀 수 없으면(
ValueError) 프로그램을 죽이지 말고default값을 돌려줘요. safe_int("8500")은8500,safe_int("열")은0,safe_int("열", -1)은-1이 나오게 하세요.
힌트: try 안에서 int(text)를 return하고, except ValueError에서 default를 return하면 돼요.
[응용] 커스텀 예외로 빈 캡션 거부하기
게시물 캡션이 비어 있으면 거부하려고 해요. 단, ValueError 말고 우리만의 예외를 써요.
EmptyCaptionError라는 커스텀 예외를 만드세요(InstagramError를 상속).create_post(caption)함수를 만들어, 캡션의 앞뒤 공백을 정리한 뒤 비어 있으면EmptyCaptionError를raise하세요.- 정상 캡션이면 정리된 문자열을 돌려주세요.
힌트: 공백 정리는 caption.strip(), 비었는지는 if not caption:으로 확인해요.
[심화] 팔로우 서비스 — 체이닝과 else/finally
회원을 팔로우하는 서비스를 만들어요. 오늘 배운 걸 최대한 엮어 보세요.
FollowService는 이메일→Member딕셔너리를 들고 있어요.follow(target_email)메서드는 대상 회원을 찾아 팔로워를 1 늘리고 그 수를 돌려줘요.- 대상이 없으면(
KeyError)MemberNotFoundError로 바꿔 던지되from으로 원인을 보존하세요. else에서만 팔로워를 늘리고,finally에서는 시도가 끝났음을 항상 출력하세요.
힌트: try에서 딕셔너리 조회, except KeyError as e에서 raise ... from e, else에서 증가·return, finally에서 출력. 네 짝을 한 메서드에 다 써보는 연습이에요.
생각해볼 주제
1. 에러를 미리 막을까, 일단 해보고 받아낼까
"이 값이 숫자일까?"를 if로 먼저 검사하고 변환할 수도 있고, 일단 int()를 해보고 안 되면 except로 받아낼 수도 있어요. 두 방식은 어떻게 다를까요? 각각 어떤 상황에 더 어울릴지 생각해 보세요.
2. 예외를 잡고 아무것도 안 하면
except로 에러를 잡은 뒤, 아무 처리 없이 pass로 그냥 넘어가는 코드를 종종 봐요. 당장은 프로그램이 안 죽으니 편해 보여요. 그런데 이게 왜 위험할 수 있을까요? 어떤 문제로 이어질지 떠올려 보세요.
3. 커스텀 예외를 어디까지 만들까
상황마다 새 커스텀 예외를 만들면 의미는 분명해져요. 하지만 예외 클래스가 수십 개로 불어나면 그것대로 관리가 부담스러워요. 새 예외를 만들 가치가 있는 경계는 어디일까요? 표준 예외(ValueError 등)로 충분한 때와 나눠서 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 먼저 스스로 코드를 짜본 뒤 펼쳐서 비교해 보세요. 정답이 하나뿐인 건 아니지만, 채점 포인트를 짚으며 "이런 결을 갖추면 좋다"를 같이 봐요.
과제 예시답안
🎯 [과제 1 예시답안] 안전한 정수 변환 함수
채점 포인트
| 항목 | 확인 | 배점 |
|---|---|---|
try 안에서 int(text)를 return |
정상 변환 경로 | ★★★ |
except ValueError에서 default를 return |
실패해도 죽지 않음 | ★★★ |
default 매개변수에 기본값 0 지정 |
호출 시 생략 가능 | ★★☆ |
| 세 가지 호출 결과가 의도대로 | 8500 / 0 / -1 |
★★☆ |
풀이 예시
# src/instagram/e1_safe_int_solution.py
def safe_int(text, default=0):
try:
return int(text)
except ValueError:
return default
print(safe_int("8500")) # 8500
print(safe_int("열")) # 0 (변환 실패 → 기본값)
print(safe_int("열", -1)) # -1 (기본값을 직접 지정)
8500
0
-1
핵심은 두 갈래예요. 변환에 성공하면 try의 return으로 빠져나가고, 실패하면 except의 return으로 빠져나가요. 둘 중 하나는 반드시 값을 돌려주니 함수가 None을 내놓을 일이 없어요.
💡 튜터의 한마디
default를 매개변수로 빼둔 게 좋은 설계예요. C-1에서 배운 기본값 인자 덕분에 safe_int("열")처럼 평소엔 간단히 쓰고, 필요할 때만 safe_int("열", -1)로 다른 기본값을 줄 수 있어요. "안 되면 죽는 대신 합리적인 기본값을 돌려준다"는 패턴은 사용자 입력을 다룰 때 정말 자주 나와요.
🎯 [과제 2 예시답안] 커스텀 예외로 빈 캡션 거부하기
채점 포인트
| 항목 | 확인 | 배점 |
|---|---|---|
EmptyCaptionError가 InstagramError를 상속 |
도메인 예외 가족 | ★★★ |
caption.strip()으로 앞뒤 공백 정리 |
공백만인 캡션도 걸러냄 | ★★☆ |
비어 있으면 raise EmptyCaptionError |
규칙 강제 | ★★★ |
| 정상 캡션이면 정리된 문자열 반환 | 통과 경로 | ★★☆ |
풀이 예시
# src/instagram/e1_caption_solution.py
from instagram.exceptions import InstagramError
class EmptyCaptionError(InstagramError):
"""캡션이 비어 있을 때."""
def create_post(caption):
caption = caption.strip() # 앞뒤 공백 정리
if not caption: # 공백만 있었으면 빈 문자열
raise EmptyCaptionError("캡션이 비어 있어요")
return caption
print(create_post(" 오늘의 일상 ")) # 오늘의 일상
try:
create_post(" ")
except EmptyCaptionError as e:
print(f"작성 실패: {e}")
오늘의 일상
작성 실패: 캡션이 비어 있어요
" "처럼 공백만 있는 입력도 strip() 뒤에는 빈 문자열이 돼서 걸러져요. if not caption:이 빈 문자열을 참으로 보는 걸 활용한 거예요.
💡 튜터의 한마디
ValueError 대신 EmptyCaptionError를 만든 이유를 다시 짚어 볼게요. 나중에 게시물을 만드는 코드가 여러 종류의 실패를 겪을 때, except EmptyCaptionError로 "캡션이 비었을 때"만 콕 집어 다르게 처리할 수 있어요. InstagramError를 부모로 둔 덕분에, 더 넓게 잡고 싶으면 except InstagramError 한 줄로도 받아낼 수 있고요. 이름이 곧 설계예요.
🎯 [과제 3 예시답안] 팔로우 서비스 — 체이닝과 else/finally
채점 포인트
| 항목 | 확인 | 배점 |
|---|---|---|
try에서 딕셔너리 조회 |
위험한 코드 격리 | ★★☆ |
except KeyError as e → raise ... from e |
체이닝으로 원인 보존 | ★★★ |
else에서만 팔로워 +1·반환 |
정상일 때만 | ★★★ |
finally에서 종료 메시지 출력 |
항상 실행 | ★★☆ |
풀이 예시
# src/instagram/e1_follow_solution.py
from instagram.member import Member
from instagram.exceptions import MemberNotFoundError
class FollowService:
def __init__(self, members):
self._members = members # 이메일 -> Member
def follow(self, target_email):
try:
target = self._members[target_email]
except KeyError as e:
raise MemberNotFoundError(f"팔로우할 회원이 없어요: {target_email}") from e
else:
target.followers += 1 # 예외가 없을 때만 팔로워 +1
return target.followers
finally:
print(f"팔로우 시도 종료: {target_email}") # 성공이든 실패든 항상
members = {"minji@insta.com": Member("minji", followers=12000)}
service = FollowService(members)
print(service.follow("minji@insta.com")) # 종료 메시지 → 12001
try:
service.follow("ghost@insta.com")
except MemberNotFoundError as e:
print(f"실패: {e}")
팔로우 시도 종료: minji@insta.com
12001
팔로우 시도 종료: ghost@insta.com
실패: 팔로우할 회원이 없어요: ghost@insta.com
출력 순서를 눈여겨보세요. finally의 "팔로우 시도 종료"가 반환값이나 실패 메시지보다 먼저 찍혀요. finally는 함수가 값을 돌려주거나 예외가 위로 전파되기 직전, 마지막 순간에 반드시 실행되기 때문이에요.
💡 튜터의 한마디
이 한 메서드에 오늘 배운 네 짝(try/except/else/finally)이 다 들어 있어요. 성공했을 때(else)와 실패했을 때(except)의 일을 깔끔하게 나누고, 어느 쪽이든 해야 할 정리(finally)는 따로 뺐죠. 그리고 from e로 "회원이 없었다"는 도메인 메시지 뒤에 진짜 원인 KeyError를 사슬로 남겼어요. 실무의 서비스 코드도 대개 이렇게 만들어져요.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 에러를 미리 막을까, 일단 해보고 받아낼까
문제 상황 요약
같은 일을 두 방식으로 할 수 있어요. 변환 전에 if로 미리 검사하거나(LBYL — 도약 전에 살펴라), 일단 해보고 안 되면 except로 받아내거나(EAFP — 허락보다 용서가 쉽다).
# 방식 A: 미리 검사 (LBYL)
if text.isdigit():
number = int(text)
else:
number = 0
# 방식 B: 일단 해보고 받아내기 (EAFP)
try:
number = int(text)
except ValueError:
number = 0
튜터의 가이드 및 해설
두 방식 다 맞아요. 다만 어울리는 상황이 달라요.
방식 A는 직관적이지만 검사가 완벽하기 어려워요. text.isdigit()은 음수 "-5"나 공백이 섞인 경우를 놓쳐요. 모든 경우를 if로 막으려다 보면 조건이 점점 복잡해지죠. "막았다고 생각했는데 뚫리는" 빈틈이 생기기 쉬워요.
방식 B는 "실제로 해봤더니 실패했다"를 기준으로 삼아요. int()가 받아들이지 못하는 모든 경우를 한 번에 처리하니 빈틈이 적어요. 파이썬은 이 EAFP 방식을 더 권하는 편이에요. 딕셔너리 조회(KeyError)나 형 변환처럼 "해보면 바로 알 수 있는" 일에 특히 잘 맞아요.
반대로, 검사가 간단명료하고 실패가 흔하게 예상되는 경우(예: 사용자에게 다시 입력받기 전 단순 확인)라면 if가 더 읽기 쉬울 때도 있어요. 정답은 상황에 따라 골라 쓰는 거예요.
💡 핵심을 한마디로
"막을 수 있으면 막고(if), 해봐야 아는 일이면 받아낸다(try/except). 파이썬에선 후자가 더 자연스러운 기본값이에요."
🤔 [생각해볼 주제 2] 예외를 잡고 아무것도 안 하면
문제 상황 요약
에러를 잡긴 했는데 아무 처리 없이 넘어가는 코드예요.
try:
위험한_작업()
except Exception:
pass # 잡고 그냥 넘어감
당장은 프로그램이 안 죽으니 편해 보여요. 그런데 이게 왜 위험할까요?
튜터의 가이드 및 해설
이건 "에러 삼키기(error swallowing)"라고 불러요. 가장 큰 문제는 문제가 일어났다는 사실 자체가 사라진다는 거예요.
예를 들어 게시물을 저장하는 작업이 실패했는데 except: pass로 삼켜버리면, 사용자는 "저장됐겠지" 하고 넘어가요. 정작 데이터는 안 들어갔는데도요. 나중에 데이터가 없어진 걸 발견해도, 어디서 무엇이 실패했는지 단서가 하나도 안 남아 있어요.
게다가 except Exception처럼 넓게 잡으면 우리가 예상한 에러뿐 아니라 오타로 생긴 엉뚱한 버그까지 다 삼켜요. 진짜 고쳐야 할 문제가 조용히 묻히는 거예요.
그럼 어떻게 해야 할까요? 최소한 무슨 일이 있었는지 흔적은 남겨야 해요. 메시지를 출력하든, 기본값으로 복구하든, 다시 던지든(raise) 말이죠. "잡았으면 뭔가는 한다"가 원칙이에요. 정말 무시해도 되는 예외라면, 왜 무시하는지 주석으로라도 이유를 남겨두는 게 좋아요.
💡 핵심을 한마디로
"except: pass는 에러를 해결하는 게 아니라 숨기는 거예요. 잡았으면 알리든 복구하든 다시 던지든, 최소한 흔적은 남겨야 해요."
🤔 [생각해볼 주제 3] 커스텀 예외를 어디까지 만들까
문제 상황 요약
상황마다 새 커스텀 예외를 만들면 의미는 분명해져요. 하지만 예외 클래스가 수십 개로 불어나면 관리가 부담스러워요. 새로 만들 가치가 있는 경계는 어디일까요?
튜터의 가이드 및 해설
판단 기준은 "이 예외를 다르게 처리할 사람이 있는가"예요.
커스텀 예외를 만들 가치가 있는 때는, 그 예외를 다른 예외와 구분해서 잡아야 할 때예요. 우리 캡스톤에서 MemberNotFoundError와 DuplicateEmailError를 나눈 건, 호출하는 쪽이 "회원 없음"과 "이메일 중복"에 다르게 반응해야 하기 때문이었죠. 회원이 없으면 가입을 권하고, 이메일이 중복이면 로그인을 안내하는 식으로요.
반대로, 그냥 "값이 잘못됐다" 정도면 표준 ValueError로 충분해요. 굳이 InvalidValueError를 새로 만들 필요가 없어요. 표준 예외가 이미 그 의미를 잘 담고 있고, 다른 개발자도 익숙하니까요.
그래서 실무에선 보통 이렇게 균형을 잡아요. 도메인의 중요한 실패(회원 없음·권한 없음·중복 등)에는 의미 있는 커스텀 예외를 두고, 그 예외들을 InstagramError 같은 공통 부모 아래 묶어요. 단순한 값 오류는 표준 예외에 맡기고요. "구분해서 잡을 일이 있는가"를 매번 물어보면 개수가 적당히 유지돼요.
💡 핵심을 한마디로
"커스텀 예외는 '다르게 잡아야 할 때'만 만들어요. 구분할 이유가 없으면 표준 예외로 충분하고, 만든 것들은 공통 부모로 묶어 관리해요."