내가 모든 곳에서 try/catch 사용을 중단한 이유 — 실제로 확장 가능한 에러 핸들링 (Error Handling) 패턴
요약
무분별한 try/catch 사용으로 인한 코드 복잡성과 중복 로깅 문제를 해결하기 위한 에러 핸들링 리팩터링 사례를 다룹니다. 중앙 집중식 에러 경계 구축, 에러 분류, 그리고 Result 타입을 활용한 패턴을 통해 확장 가능한 구조를 제안합니다.
핵심 포인트
- 불필요한 try/catch를 줄여 스택 트레이스와 로그의 가독성 확보
- 중앙 집중식 에러 경계(Error Boundary)를 통한 일관된 처리
- 운영, 프로그래밍, 예상된 에러로의 명확한 분류
- Rust 스타일의 Result 타입을 활용한 예상된 실패 처리
내가 모든 곳에서 try/catch 사용을 중단한 이유 — 실제로 확장 가능한 에러 핸들링 (Error Handling) 패턴
내 앱에는 847개의 try/catch 블록이 있었습니다. 그 중 73%는 catch (error) { console.error(error); throw error; }로 동일했습니다. 나는 에러 핸들링을 또 다른 에러 핸들링으로 감싸고 있었습니다. 코드는 더 안전해진 것이 아니라, 그저 더 시끄러워졌을 뿐입니다.
12개의 마이크로서비스 (microservices) 전체의 에러 핸들링을 리팩터링하며 배운 점은 다음과 같습니다.
안티 패턴 (Anti-Pattern): 방어적 try/catch의 확산
모든 비동기 (async) 함수가 각자의 try/catch로 감싸져 있고, 동일한 에러를 로깅하며, 동일한 에러를 던지고(throw), 세 단계 위에서 동일한 에러를 잡고(catch) 있었습니다. 그 결과는 어땠을까요?
- 아무런 정보도 주지 못하는 스택 트레이스 (Stack traces)
- 중복된 에러 로그 (동일한 에러가 4번 로깅됨)
- 보일러플레이트 (boilerplate) 아래에 파묻혀 버린 실제 복구 로직
나는 이것을 **불안 기반 개발 (anxiety-driven development)**이라고 부릅니다. 각 에러에 대한 계획이 있어서가 아니라, 처리되지 않은 거부 (unhandled rejections)가 두려워 모든 것을 try/catch로 감싸는 것을 말합니다.
실제로 효과가 있었던 방법
1. 중앙 집중식 에러 경계 (Centralized Error Boundary)
847개의 try/catch 블록 대신, 서비스당 하나의 에러 경계 (error boundary)를 구축했습니다.
class ServiceBoundary {
async execute<T>(operation: () => Promise<T>): Promise<T> {
try {
...
하나의 경계. 일관된 분류. 중복 로깅 없음.
2. 모든 것을 잡는 것이 아닌, 에러 분류 (Error Classification)
모든 에러가 동일하지는 않습니다:
- 운영 에러 (Operational errors) (네트워크 타임아웃, 429 rate limit) → 백오프 (backoff)와 함께 재시도
- 프로그래밍 에러 (Programmatic errors) (null 참조, 타입 불일치) → 즉시 실패 (fail fast), 온콜 (on-call) 담당자에게 알림
- 예상된 에러 (Expected errors) (유효성 검사 실패, 찾을 수 없음) → 호출자에게 반환, 던지지(throw) 말 것
분류를 시작하자 try/catch 블록의 60%가 불필요해졌습니다. 유효성 검사 에러는 예외 (exceptions)로 던지는 것이 아니라 결과 (results)로서 반환됩니다.
3. 예상된 실패를 위한 결과 타입 (Result Types)
Rust의 Result<T, E> 패턴에서 빌려왔습니다:
type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E };
실패가 예상되는 경우(사용자를 찾을 수 없음, 유효성 검사 실패, 속도 제한 등)에는 Result를 반환하세요. Throw를 하지 마세요. Throw는 예상치 못한 실패를 위해서만 남겨두어야 합니다.
이를 통해 에러 관련 유닛 테스트(Unit Test)를 40% 줄일 수 있었습니다. 실제로 예외적이지 않은 상황에 대해 "이것이 throw를 하는가?"를 테스트하는 것을 중단했기 때문입니다.
4. 구조화된 에러 메타데이터 (Structured Error Metadata)
이제 모든 에러는 다음 정보를 포함합니다:
interface AppError extends Error {
code: string; // "ENOENT"가 아닌 "USER_NOT_FOUND"
severity: 'info' | 'warn' | 'critical';
...
로그에서 ECONNRESET이 무엇을 의미하는지 더 이상 추측할 필요가 없습니다. 에러가 어디서 삼켜졌는지(swallowed) 찾기 위해 847개의 catch 블록을 뒤질 필요도 없습니다.
5. Let It Crash (때로는 그대로 두기)
가장 어려운 교훈은 다음과 같습니다: 어떤 에러들은 반드시 크래시(Crash)가 나야 합니다. 만약 데이터베이스 커넥션 풀(Connection Pool)이 고갈되었는데, 큐(Queue)가 쌓이는 동안 조용히 10번 재시도하는 것은 회복 탄력성(Resilience)이 아니라 서서히 죽어가는 나선형 추락(Slow death spiral)입니다.
빨리 실패(Fail fast)하고, 오케스트레이터(Orchestrator)가 재시작하게 두며, 누군가에게 알림을 보내세요. 크래시는 기술적으로는 "가동 중(Up)"이지만 기능적으로는 죽어있는 좀비 서비스보다 훨씬 더 정직합니다.
결과
| 지표 | 이전 | 이후 |
|---|---|---|
| try/catch 블록 | 847 | 212 |
| ... |
코드는 단순히 더 깔끔해진 것이 아닙니다. 무엇이 잘못될 수 있는지에 대해 정직하며, 문제가 발생했을 때 무엇을 해야 하는지에 대해 구체적입니다.
여러분의 팀은 에러를 어떻게 처리하시나요? 모든 것을 catch하고 있나요, 아니면 분류하고 대응하고 있나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기