복구 가능하면 잡고, 못 하면 위로 넘긴다
코드를 짜다 보면 빨간 줄이 무섭다. 그래서 처음엔 어디든 일이 터질 것 같은 곳마다 try...except를 둘렀다. 외부 API 부르는 곳, DB 조회하는 곳, 값 꺼내 쓰는 곳. 일단 감싸 놓으면 마음이 편하니까. 그런데 그렇게 깔아 놓은 except 블록들이 나중에 발목을 잡았다. 에러가 나긴 났는데 어디서 무슨 일이 있었는지 추적이 안 됐다. except가 예외를 조용히 삼켜 버린 거다.
그래서 규칙을 하나 세웠다. 각 함수는 자기가 책임질 수 있는 예외만 잡는다. 책임질 수 없는 건 잡지 말고 그대로 위로 흘려보낸다. 이걸 책임의 원칙이라고 부르기로 했다.
잡아도 되는 예외: 내가 복구할 수 있는 것
기준은 단순하다. “이걸 잡으면 내가 뭘 해 줄 수 있나?” 답이 있으면 잡는다. 대표적인 게 외부 API 호출이다. 네트워크는 가끔 흔들리니까, 한 번 실패했다고 끝내지 말고 몇 번 더 시도해 볼 수 있다. 이런 건 그 함수가 스스로 대처 가능한 상황이다.
MAX_RETRIES = 3
async def fetch_external_data(url: str):
for attempt in range(MAX_RETRIES):
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.RequestError as e:
print(f"호출 실패 (시도 {attempt + 1}/{MAX_RETRIES}): {e}")
if attempt == MAX_RETRIES - 1:
raise AppException("외부 호출에 최종 실패했습니다.")
재시도하다가 마지막까지 실패하면? 이제 이 함수가 할 수 있는 게 없다. 그래서 잡고 있던 손을 놓고 raise로 위에 넘긴다. 끝까지 책임지는 척하지 않는 게 핵심이다.
안 잡는 예외: 내가 복구할 수 없는 것
반대 경우가 더 많다. 게시물을 찾으러 갔는데 없다. 이건 그 함수가 어찌해 볼 도리가 없는 상황이다. 없는 걸 만들어 낼 수도 없고, 기본값으로 때울 수도 없다. 그러면 잡지 않고 그냥 던진다.
async def get_post_by_id(post_id: int, session: Session) -> Post:
post = await post_repository.get(post_id, session)
if not post:
raise NotFoundException("게시물을 찾을 수 없습니다.")
return post
여기서 try...except로 감싸고 싶은 충동이 생기는데, 참아야 한다. 잡아 봤자 할 수 있는 게 없는 예외를 잡는 건 그냥 에러를 숨기는 짓이다. 이런 도메인 예외는 위로 자연스럽게 전파되도록 둔다.
마지막에 받아 주는 곳
그러면 위로 던진 예외는 누가 받나. 전역 핸들러 한 곳이 받는다. 서비스든 라우터든 처리 안 하고 올려보낸 예외를 여기서 전부 가로채, 클라이언트가 알아먹을 표준 형식으로 바꿔 준다.
핸들러는 두 종류다. AppException을 상속한 도메인 예외(NotFoundException은 404, ForbiddenException은 403처럼)면 그 예외가 들고 있는 상태 코드와 메시지를 그대로 응답에 싣는다. 그 외 예상 못 한 일반 예외는 전부 500으로 묶고, 운영 환경에서는 내부 정보를 마스킹한다. 클라이언트한테 스택 트레이스를 보여 줄 이유는 없으니까.
덕분에 서비스 코드는 깔끔해진다. 응답 포맷을 매번 신경 쓸 필요 없이, “여기서 못 다루는 일이다” 싶으면 의미에 맞는 예외를 raise하면 끝이다. 나머지는 한 곳이 알아서 한다.
정리하면
예외 처리는 결국 책임 분배 문제였다. 복구할 수 있으면 그 자리에서 잡아 복구하고, 못 하면 정직하게 위로 넘긴다. except 블록을 줄였더니 오히려 에러 추적이 쉬워졌다. 무서워서 다 감싸던 때보다, 잡을 것만 잡는 지금이 훨씬 마음 편하다.