← 목록으로 돌아가기

TypeScript 에러 처리 설계: try/catch, Result 패턴, 도메인 오류를 구분하는 실무 기준

TypeScript

에러 처리는 실패를 숨기지 않는 설계다

애플리케이션은 항상 실패합니다. 네트워크는 끊기고, 외부 API는 timeout이 나고, 사용자는 잘못된 값을 보내고, 데이터베이스 제약 조건은 요청을 거부합니다. 문제는 실패 자체가 아니라 실패를 어떻게 표현하고 전달하느냐입니다. 모든 오류를 Error 객체 하나로 던지고 최상단에서 "알 수 없는 오류"로 처리하면 사용자도 개발자도 원인을 알 수 없습니다.

TypeScript에서는 타입으로 성공과 실패를 명확히 표현할 수 있습니다. 하지만 JavaScript 런타임의 예외 모델과 섞여 있기 때문에 기준 없이 쓰면 코드가 금방 불안정해집니다. 어떤 실패는 예외로 던져야 하고, 어떤 실패는 값으로 반환해야 하며, 어떤 실패는 사용자에게 안내해야 합니다. 이 경계를 정하는 것이 에러 처리 설계의 시작입니다.

이 글에서는 try/catch를 남발하지 않고, Result 패턴과 도메인 오류 타입을 활용해 실패를 명확히 다루는 방법을 정리합니다. API 레이어, 서비스 레이어, UI 레이어에서 오류를 어떻게 변환하고 노출할지 실무 기준으로 설명합니다.


TypeScript 에러 처리 흐름

1. 모든 실패가 예외는 아니다

예외는 예상하지 못한 흐름을 표현할 때 강력합니다. 데이터베이스 연결 실패, 프로그래밍 버그, 외부 시스템 장애처럼 정상적인 비즈니스 흐름으로 처리하기 어려운 상황에는 throw가 자연스럽습니다. 하지만 사용자가 잘못된 이메일을 입력하거나, 권한이 없거나, 쿠폰이 만료된 상황은 예상 가능한 실패입니다. 이런 오류까지 예외로 던지면 제어 흐름이 흐려집니다.

예상 가능한 실패는 값으로 표현하는 편이 좋습니다. 예를 들어 회원가입 함수는 성공 시 user를 반환하고, 실패 시 EmailAlreadyUsed나 WeakPassword 같은 도메인 오류를 반환할 수 있습니다. 호출자는 타입을 보고 어떤 실패가 가능한지 알 수 있고, UI는 각 실패에 맞는 메시지를 보여줄 수 있습니다.

중요한 것은 오류의 출처를 보존하는 것입니다. 외부 API timeout은 기술 오류이고, 결제 한도 초과는 비즈니스 오류입니다. 둘 다 "결제 실패"로 뭉개면 운영 대응이 어려워집니다. 기술 오류는 재시도와 알림으로, 비즈니스 오류는 사용자 안내로 이어져야 합니다.


2. Result 패턴은 실패를 타입으로 드러낸다

Result 패턴은 함수 반환값을 성공과 실패의 union으로 표현합니다. 예를 들어 { ok: true, value } 또는 { ok: false, error } 형태를 사용할 수 있습니다. 이렇게 하면 호출자는 반드시 ok를 확인해야 value에 접근할 수 있습니다. TypeScript의 discriminated union이 이 흐름을 잘 지원합니다.

이 방식의 장점은 함수 시그니처만 보고 실패 가능성을 알 수 있다는 점입니다. Promise<User>는 실패가 throw될지, 어떤 오류가 올지 알 수 없습니다. 반면 Promise<Result<User, CreateUserError>>는 호출자가 처리해야 할 오류 범위를 드러냅니다. 테스트도 쉬워집니다. 특정 입력에 대해 어떤 오류 타입이 반환되는지 명확히 검증할 수 있습니다.

단점도 있습니다. 모든 함수가 Result를 반환하면 코드가 장황해질 수 있습니다. 그래서 경계를 정해야 합니다. 도메인 로직과 애플리케이션 서비스에서는 Result가 유용하고, 인프라 레이어의 예상 못 한 장애는 예외로 올려보내는 편이 낫습니다. Result는 예외를 완전히 대체하는 도구가 아니라 예상 가능한 실패를 명시하는 도구입니다.


3. 레이어마다 오류를 변환한다

데이터베이스 unique constraint 오류를 UI까지 그대로 올리면 안 됩니다. 인프라 레이어의 오류는 서비스 레이어에서 도메인 오류로 변환해야 합니다. 예를 들어 users_email_key 위반은 EmailAlreadyUsed로 바꾸고, DB connection refused는 InfrastructureUnavailable로 분류합니다. 이렇게 해야 UI와 API 응답이 내부 구현에 의존하지 않습니다.

API 레이어에서는 도메인 오류를 HTTP 상태 코드와 응답 body로 변환합니다. ValidationError는 400, Unauthorized는 401, Forbidden은 403, NotFound는 404, Conflict는 409가 자연스럽습니다. 하지만 상태 코드만으로 충분하지 않습니다. 클라이언트가 필드별 오류를 보여줄 수 있도록 code와 message, field 정보를 함께 내려주는 것이 좋습니다.

UI 레이어에서는 기술 메시지를 사용자 메시지로 바꿉니다. "Unique constraint violation"은 사용자에게 아무 의미가 없습니다. "이미 사용 중인 이메일입니다"처럼 행동 가능한 문장으로 바꿔야 합니다. 반대로 운영 로그에는 원본 오류와 stack, requestId를 남겨야 합니다. 사용자 메시지와 운영 진단 정보는 서로 다른 채널입니다.


4. 실무 체크리스트

  • 예상 가능한 비즈니스 실패와 예상 못 한 기술 장애를 구분했는가
  • 도메인 함수의 실패 가능성이 타입에 드러나는가
  • 인프라 오류가 UI나 API 응답에 그대로 노출되지 않는가
  • 오류 code가 문서화되어 클라이언트가 안정적으로 처리할 수 있는가
  • 사용자 메시지와 운영 로그 메시지를 분리했는가
  • catch 블록에서 오류를 삼키지 않고 requestId와 함께 기록하는가
  • 재시도 가능한 오류와 즉시 실패해야 하는 오류가 구분되는가

5. 오류 타입은 계층별로 작게 유지한다

오류 타입을 너무 크게 만들면 결국 아무도 구분하지 않습니다. AppError 하나에 code 문자열만 넣는 방식은 시작은 쉽지만, 시간이 지나면 어떤 code가 어디서 발생하는지 추적하기 어려워집니다. 반대로 모든 함수마다 별도 오류 타입을 만들면 호출자가 지나치게 많은 case를 처리해야 합니다. 계층별로 적당한 범위를 잡는 것이 중요합니다.

도메인 레이어에서는 비즈니스 의미가 있는 오류를 둡니다. EmailAlreadyUsed, OrderAlreadyPaid, InsufficientBalance처럼 제품 규칙과 연결된 이름이 좋습니다. 인프라 레이어에서는 DatabaseUnavailable, ExternalTimeout, RateLimited처럼 기술 원인을 표현합니다. API 레이어에서는 이를 HTTP와 클라이언트 code로 변환합니다.

오류 타입에는 사용자를 위한 메시지를 직접 넣지 않는 편이 좋습니다. 도메인 오류는 의미를 표현하고, UI나 API adapter가 메시지를 결정합니다. 그래야 다국어, 채널별 문구, 관리자 화면과 사용자 화면의 표현 차이를 관리하기 쉽습니다. 같은 오류라도 운영자에게는 상세 원인이 필요하고, 사용자에게는 다음 행동이 필요합니다.


6. unknown catch를 안전하게 다룬다

TypeScript에서 catch로 잡힌 값은 실제로 Error가 아닐 수 있습니다. 문자열, 객체, null도 throw될 수 있습니다. 그래서 catch (error) 안에서 바로 error.message에 접근하는 코드는 안전하지 않습니다. 먼저 error instanceof Error인지 확인하고, 아니면 안전한 fallback으로 변환해야 합니다.

공통 normalizeError 함수를 두면 도움이 됩니다. unknown을 받아 name, message, stack, cause를 가진 내부 오류 형태로 바꾸고, 로그에는 원본 타입 정보도 남깁니다. 외부 라이브러리 오류는 구조가 제각각이므로 adapter에서 한 번 정리해야 합니다. 특히 fetch 실패, Axios 오류, database driver 오류는 retry 가능 여부와 status code를 추출하는 로직이 필요합니다.

catch 블록에서 가장 위험한 패턴은 오류를 삼키는 것입니다. 실패했는데 기본값을 반환하면 시스템은 조용히 잘못된 상태가 됩니다. fallback을 쓰더라도 warn 로그와 metric을 남겨야 합니다. 사용자를 보호하기 위한 fallback과 버그를 숨기는 fallback은 다릅니다.


7. 테스트는 실패 경로를 먼저 고정한다

에러 처리 코드는 성공 경로보다 테스트가 더 중요합니다. 네트워크 timeout, validation 실패, 권한 없음, 중복 데이터, 외부 API 429, 데이터베이스 장애를 각각 테스트해야 합니다. 실패가 어떤 타입으로 표현되는지, HTTP 상태와 code가 무엇인지, 사용자 메시지가 어떻게 매핑되는지 고정해 두면 나중에 리팩터링이 쉬워집니다.

Result 패턴을 쓰면 실패 테스트가 명확해집니다. 함수가 throw하지 않고 { ok: false }를 반환하는지 확인할 수 있고, error.type별 처리를 exhaustiveness check로 강제할 수 있습니다. switch 문에서 never 체크를 사용하면 새로운 오류 타입을 추가했을 때 처리 누락을 컴파일 단계에서 발견할 수 있습니다.

운영에서도 실패 경로를 관측해야 합니다. 오류 code별 발생량, retry 성공률, fallback 사용량, 사용자에게 노출된 오류 메시지 수를 보면 어떤 실패가 실제로 많은지 알 수 있습니다. 에러 처리는 코드 설계이면서 제품 품질 지표입니다.


8. 재시도 정책은 오류 타입과 붙어 있어야 한다

재시도는 모든 오류에 적용할 수 없습니다. 네트워크 일시 오류나 503은 재시도 가치가 있지만, validation 실패나 권한 없음은 재시도해도 성공하지 않습니다. 오류 타입에 retryable 속성이나 분류를 두면 호출자가 임의로 판단하지 않아도 됩니다. 특히 queue worker나 외부 API client에서는 이 구분이 중요합니다.

재시도 가능한 오류라도 무한 재시도는 위험합니다. 최대 횟수, backoff, jitter, deadline을 함께 둬야 합니다. 같은 외부 API가 장애일 때 모든 요청이 동시에 재시도하면 장애를 더 키울 수 있습니다. 오류 타입은 사용자 메시지뿐 아니라 시스템의 다음 행동을 결정합니다.

에러 처리 문서에는 각 오류 code의 의미, HTTP 매핑, 재시도 가능 여부, 사용자 노출 메시지, 운영 알림 여부를 함께 적는 것이 좋습니다. 이 표가 있으면 API 소비자와 프론트엔드, 운영팀이 같은 언어로 실패를 이해할 수 있습니다.


9. 외부 경계에서는 오류를 한 번 감싼다

외부 API, 데이터베이스, 파일 시스템, 메시지 브로커 같은 경계에서는 라이브러리 오류를 그대로 내부로 퍼뜨리지 않는 편이 좋습니다. 라이브러리를 교체하면 오류 형태가 바뀌고, 호출부가 그 구현에 묶입니다. adapter 계층에서 외부 오류를 내부 오류 타입으로 변환하면 도메인 로직은 안정적인 계약만 다루게 됩니다.

예를 들어 결제 provider가 timeout을 던지면 PaymentProviderTimeout으로 변환하고, 카드 거절 응답은 PaymentDeclined로 변환합니다. 둘 다 결제 실패처럼 보일 수 있지만 재시도 가능성과 사용자 메시지는 다릅니다. 이 구분이 있어야 retry, alert, UI 안내가 정확해집니다.

오류를 감쌀 때 원본 cause를 잃지 않아야 합니다. 운영 로그에는 provider status, response code, requestId, cause stack을 남기고, 사용자 응답에는 안전한 code와 메시지만 보냅니다. 내부 진단 정보와 외부 노출 정보를 분리하는 것이 핵심입니다.


10. 타입 안정성은 런타임 검증과 함께 간다

TypeScript 타입은 컴파일 시점의 약속입니다. 외부 API 응답, 사용자 입력, 메시지 큐 payload는 런타임에 실제로 검증해야 합니다. 타입 단언으로 unknown을 도메인 타입으로 바꾸면 실패가 늦게 드러납니다. Zod 같은 런타임 스키마로 경계 입력을 검증하고, 실패하면 명확한 ParseError나 ValidationError로 변환하는 편이 안전합니다.

타입과 런타임 검증이 함께 있으면 에러 처리가 더 단단해집니다. 내부 로직은 신뢰 가능한 타입을 다루고, 외부 경계에서는 실패를 명시적으로 처리할 수 있습니다.


결론: 좋은 에러 처리는 다음 행동을 만든다

에러 처리는 실패를 없애는 일이 아닙니다. 실패가 발생했을 때 사용자, 개발자, 운영자가 각각 무엇을 해야 하는지 알 수 있게 만드는 일입니다. TypeScript의 타입 시스템은 이 경계를 코드에 드러낼 수 있게 해줍니다.

예외는 예상 못 한 장애에, Result는 예상 가능한 도메인 실패에 사용하면 오류 흐름이 훨씬 명확해집니다. 실패를 타입과 메시지, 로그로 제대로 표현하는 시스템은 장애가 났을 때 더 빨리 회복합니다.