Rust의 Result 타입으로 Node.js 에러 핸들링 개선하기

문제 상황

결제 API 서버를 운영하면서 에러 핸들링이 복잡해지는 문제를 겪었다. try-catch가 중첩되고, 어디서 어떤 에러가 발생할지 타입 레벨에서 알 수 없었다.

try {
  const user = await getUser(userId);
  try {
    const payment = await processPayment(user);
    return payment;
  } catch (e) {
    // 결제 실패 처리
  }
} catch (e) {
  // 유저 조회 실패 처리
}

Rust의 Result 패턴

Rust를 학습하며 Result<T, E> 타입을 알게 됐다. 성공(Ok)과 실패(Err)를 명시적으로 표현하는 방식이 인상적이었다.

TypeScript로 간단히 구현해봤다.

type Result<T, E = Error> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

function Ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

function Err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

적용 사례

async function getUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
  const user = await db.user.findUnique({ where: { id } });
  if (!user) return Err('NOT_FOUND');
  return Ok(user);
}

async function processPayment(userId: string) {
  const userResult = await getUser(userId);
  if (!userResult.ok) {
    if (userResult.error === 'NOT_FOUND') {
      return Err('USER_NOT_FOUND');
    }
    return Err('DB_ERROR');
  }
  
  const user = userResult.value; // 타입 안전
  // 결제 처리
}

결과

  • 함수 시그니처만 봐도 어떤 에러가 발생할 수 있는지 명확함
  • try-catch 중첩 제거
  • 타입스크립트의 union type과 잘 맞아떨어짐

당장 모든 코드를 바꾸진 않았지만, 중요한 비즈니스 로직부터 적용 중이다. Rust를 배우며 얻은 가장 실용적인 인사이트였다.