에러 처리는 try-catch를 작성하는 것이 아니다

vx_developer·약 5시간 전

개발하다가

목록 보기
30/30
post-thumbnail

프로그래밍을 처음 배울 때 에러 처리는 보통 오류가 발생할 수 있는 코드를 try-catch로 감싸는 것이라고 배운다.

try {
  await createReservation(input);
} catch (error) {
  console.error(error);
}

이 설명은 예외를 포착하고 프로그램이 갑자기 중단되는 것을 막는 기본 원리를 이해하기에 충분하다.

하지만 실제 예약 서비스를 개발하면 에러를 붙잡는 것보다 더 많은 판단이 필요하다.

  • 사용자가 날짜를 잘못 입력한 것과 서버 장애를 같은 에러로 처리해야 하는가?
  • 선택한 시간대가 이미 예약된 것은 실패인가, 예상 가능한 결과인가?
  • 결제는 완료되었지만 예약 저장에 실패하면 어떻게 복구해야 하는가?
  • 네트워크 응답을 받지 못했을 때 예약이 생성되지 않았다고 단정할 수 있는가?
  • 사용자에게 어떤 내용을 보여주고 어떤 정보는 숨겨야 하는가?
  • 운영자는 같은 실패의 원인을 어떻게 추적할 수 있는가?

에러 처리는 단순히 예외를 잡는 문법이 아니다.

에러 처리는 실패를 의미와 책임에 따라 분류하고, 적절한 경계에서 복구하거나 전달하며, 사용자와 운영자에게 필요한 다음 행동을 제공하는 설계다.


모든 실패가 같은 종류의 에러는 아니다

크리스가 예약 생성 기능을 다음과 같이 작성했다고 생각해 보자.

async function submitReservation(
  input: ReservationInput
) {
  try {
    return await createReservation(input);
  } catch {
    return {
      success: false,
      message: "예약에 실패했다.",
    };
  }
}

코드는 예외가 화면까지 전달되는 것을 막는다. 그러나 이 함수가 반환하는 메시지만으로는 실제로 무슨 일이 발생했는지 알 수 없다.

예약 과정에서는 서로 다른 실패가 발생할 수 있다.

  • 날짜 형식이 올바르지 않다.
  • 예약 가능 인원을 초과했다.
  • 선택한 시간대가 방금 다른 사용자에게 예약되었다.
  • 인증 세션이 만료되었다.
  • 데이터베이스 연결이 끊어졌다.
  • 외부 결제 서비스의 응답이 지연되었다.
  • 예약은 생성되었지만 확인 이메일 전송에 실패했다.

이 실패들은 원인도 다르고 사용자가 취해야 할 행동도 다르다.

날짜가 잘못되었다면 입력을 수정해야 한다. 시간대가 이미 예약되었다면 다른 시간을 선택해야 한다. 세션이 만료되었다면 다시 로그인해야 한다. 데이터베이스 장애라면 사용자가 입력을 수정해도 문제가 해결되지 않는다.

따라서 에러를 처리하려면 먼저 실패를 분류해야 한다.

class ValidationError extends Error {
  constructor(
    message: string,
    readonly fieldErrors: Record<
      string,
      string
    >
  ) {
    super(message);
    this.name = "ValidationError";
  }
}

class SlotUnavailableError extends Error {
  constructor(readonly slotId: string) {
    super("Reservation slot is unavailable");
    this.name = "SlotUnavailableError";
  }
}

class AuthenticationError extends Error {
  constructor() {
    super("Authentication is required");
    this.name = "AuthenticationError";
  }
}

class InfrastructureError extends Error {
  constructor(
    message: string,
    options?: ErrorOptions
  ) {
    super(message, options);
    this.name = "InfrastructureError";
  }
}

클래스가 반드시 정답이라는 뜻은 아니다. 구분 가능한 에러 코드나 판별 가능한 결과 타입을 사용할 수도 있다.

중요한 것은 모든 실패를 하나의 "예약에 실패했다"로 축소하지 않는 것이다. 실패의 의미가 구분되어야 복구 방법과 책임도 구분할 수 있다.


예상 가능한 거절과 시스템 오류는 구분해야 한다

선택한 시간대가 이미 예약된 상황을 살펴보자.

async function reserveSlot(
  slotId: string,
  userId: string
) {
  const slot = await findSlot(slotId);

  if (!slot) {
    throw new Error(
      "Reservation failed"
    );
  }

  if (slot.status !== "available") {
    throw new Error(
      "Reservation failed"
    );
  }

  return saveReservation({
    slotId,
    userId,
  });
}

존재하지 않는 시간대와 이미 예약된 시간대가 같은 일반 에러로 표현된다. 상위 계층에서는 둘을 구분할 방법이 없다.

이를 서비스의 의미가 드러나도록 바꿀 수 있다.

class ReservationSlotNotFoundError
  extends Error {
  constructor(readonly slotId: string) {
    super("Reservation slot was not found");
    this.name =
      "ReservationSlotNotFoundError";
  }
}

async function reserveSlot(
  slotId: string,
  userId: string
) {
  const slot = await findSlot(slotId);

  if (!slot) {
    throw new ReservationSlotNotFoundError(
      slotId
    );
  }

  if (slot.status !== "available") {
    throw new SlotUnavailableError(slotId);
  }

  return saveReservation({
    slotId,
    userId,
  });
}

이제 시간대를 찾지 못한 상황과 예약할 수 없는 상황이 구분된다.

그러나 예상 가능한 비즈니스 결과를 반드시 예외로 표현해야 하는 것은 아니다. 판별 가능한 결과 타입으로 모델링할 수도 있다.

type ReservationResult =
  | {
      status: "confirmed";
      reservationId: string;
    }
  | {
      status: "slot_unavailable";
      alternativeSlotIds: string[];
    };

async function reserveSlot(
  slotId: string,
  userId: string
): Promise<ReservationResult> {
  const reserved =
    await tryReserveSlot(slotId, userId);

  if (!reserved) {
    return {
      status: "slot_unavailable",
      alternativeSlotIds:
        await findAlternativeSlots(slotId),
    };
  }

  return {
    status: "confirmed",
    reservationId: reserved.id,
  };
}

시간대 경쟁은 예약 서비스에서 충분히 예상할 수 있는 결과다. 따라서 정상적인 반환값으로 표현하면 호출자가 가능한 결과를 타입으로 확인할 수 있다.

반면 데이터베이스 연결 실패처럼 정상적인 서비스 결과를 만들 수 없는 상황은 예외로 전달하는 편이 자연스럽다.

async function tryReserveSlot(
  slotId: string,
  userId: string
) {
  try {
    return await reservationRepository
      .reserve(slotId, userId);
  } catch (error) {
    if (isUniqueConstraintError(error)) {
      return null;
    }

    throw new InfrastructureError(
      "Could not access reservation storage",
      { cause: error }
    );
  }
}

여기서는 데이터베이스의 고유 제약 조건 충돌을 slot_unavailable이라는 비즈니스 의미로 변환한다. 그 밖의 저장소 오류는 인프라 실패로 전달한다.

에러 설계의 첫 번째 판단은 “예외를 던질 것인가”가 아니다. 이 결과가 서비스에서 예상한 분기인지, 정상적인 결과를 만들 수 없는 실패인지를 판단하는 것이다.


외부 입력은 에러를 잡기 전에 검증해야 한다

사용자가 제출한 예약 데이터는 검증 전까지 신뢰할 수 없다.

type ReservationInput = {
  slotId: string;
  guestCount: number;
  note?: string;
};

TypeScript 타입은 개발 중 코드의 형태를 확인하는 도구다. 브라우저나 외부 클라이언트가 실제로 올바른 값을 보낸다는 보장은 하지 않는다.

다음과 같은 요청도 서버에 도착할 수 있다.

{
  "slotId": "",
  "guestCount": -3,
  "note": 42
}

서버 경계에서 런타임 검증이 필요하다.

const reservationInputSchema =
  z.object({
    slotId: z.string().min(1),
    guestCount: z
      .number()
      .int()
      .min(1)
      .max(8),
    note: z.string().max(500).optional(),
  });

async function handleReservationRequest(
  request: Request
) {
  const body = await request.json();

  const parsed =
    reservationInputSchema.safeParse(body);

  if (!parsed.success) {
    throw new ValidationError(
      "Reservation input is invalid",
      formatFieldErrors(parsed.error)
    );
  }

  return createReservation(parsed.data);
}

검증 실패는 데이터베이스 오류와 다른 종류의 실패다.

  • 검증 실패는 어떤 값을 고쳐야 하는지 사용자에게 알려줄 수 있다.
  • 시스템 장애는 사용자가 입력을 수정해도 해결되지 않는다.
  • 인증 실패는 다시 로그인하는 행동으로 연결된다.
  • 예약 충돌은 다른 시간대를 선택하는 행동으로 연결된다.

에러 처리는 모든 코드를 try-catch로 감싸는 것에서 시작하지 않는다. 서비스 경계에서 신뢰할 수 없는 값을 걸러내고, 실패가 발생할 수 있는 지점을 명확히 만드는 것에서 시작한다.


에러는 처리할 수 있는 경계에서 잡아야 한다

다음 코드는 에러가 발생할 수 있는 모든 함수에서 예외를 붙잡는다.

async function loadSlot(
  slotId: string
) {
  try {
    return await slotRepository.findById(
      slotId
    );
  } catch (error) {
    console.error(error);
    return null;
  }
}

이 구현은 저장소 오류와 “시간대를 찾지 못함”을 모두 null로 바꾼다.

const slot = await loadSlot(slotId);

if (!slot) {
  showMessage(
    "선택한 시간대를 찾을 수 없다."
  );
}

데이터베이스가 중단된 상황에서도 사용자에게 시간대가 존재하지 않는다고 안내하게 된다. 원래 실패의 의미가 사라진 것이다.

현재 계층에서 복구할 방법이 없다면 에러를 억지로 소비하지 않아야 한다.

async function loadSlot(
  slotId: string
) {
  try {
    return await slotRepository.findById(
      slotId
    );
  } catch (error) {
    throw new InfrastructureError(
      "Failed to load reservation slot",
      { cause: error }
    );
  }
}

저장소 계층은 기술적인 오류에 문맥을 추가한다. 서비스 계층은 이를 비즈니스 흐름과 연결한다. HTTP 경계는 에러를 응답으로 변환한다.

async function createReservationRoute(
  request: Request
) {
  try {
    const input =
      await parseReservationInput(request);

    const result =
      await createReservation(input);

    return Response.json(result, {
      status: 201,
    });
  } catch (error) {
    return mapErrorToResponse(error);
  }
}

각 계층의 책임은 다르다.

경계주요 책임
입력 경계형식과 필수 값 검증
도메인·서비스 계층비즈니스 규칙과 예상 가능한 결과 표현
저장소·외부 연동 계층기술 오류에 작업 문맥 추가
API 경계내부 실패를 안전한 응답으로 변환
UI 경계사용자 메시지와 다음 행동으로 변환
운영 경계로그·지표·알림으로 원인 추적

에러는 가장 가까운 곳에서 무조건 잡는 것이 아니다. 의미 있는 결정을 내릴 수 있는 경계에서 잡아야 한다.


catch는 실패를 해결했을 때만 소비해야 한다

크리스가 예약 오류를 기록하기 위해 다음 코드를 작성했다고 생각해 보자.

async function createReservation(
  input: ReservationInput
) {
  try {
    return await reservationRepository
      .create(input);
  } catch (error) {
    logger.error(error);
  }
}

catch에서 값을 반환하지 않았으므로 함수는 undefined로 이행된다.

const reservation =
  await createReservation(input);

showConfirmation(
  reservation.id
);

호출자는 작업이 실패했다는 사실을 알지 못한다. 이후 코드에서 별개의 오류가 발생하거나 잘못된 완료 화면이 표시될 수 있다.

로그만 남기는 것은 복구가 아니다. 상위 계층에서도 실패를 알아야 한다면 다시 던져야 한다.

async function createReservation(
  input: ReservationInput
) {
  try {
    return await reservationRepository
      .create(input);
  } catch (error) {
    logger.error(
      {
        error,
        slotId: input.slotId,
      },
      "Failed to create reservation"
    );

    throw error;
  }
}

반대로 유효한 대체 경로가 있다면 해당 경계에서 실패를 소비할 수 있다.

async function loadAvailability(
  date: string
) {
  try {
    return await availabilityCache.get(
      date
    );
  } catch (error) {
    logger.warn(
      { error, date },
      "Availability cache unavailable"
    );

    return availabilityRepository
      .findByDate(date);
  }
}

캐시 조회가 실패해도 데이터베이스에서 같은 정보를 가져올 수 있다. 이 경우에는 실제 복구 경로가 존재한다.

catch에서 무엇인가를 반환했다는 이유만으로 복구가 이루어지는 것은 아니다.

실패를 소비한다는 것은 에러를 숨기는 것이 아니라, 서비스가 계속 제공할 수 있는 유효한 결과나 대체 행동을 선택했다는 뜻이다.


원래 에러를 보존하면서 서비스 문맥을 추가해야 한다

저장소에서 발생한 오류를 다음과 같이 바꿀 수 있다.

try {
  return await reservationRepository
    .create(input);
} catch {
  throw new Error(
    "Reservation creation failed"
  );
}

새로운 메시지는 추가되었지만 원래 오류가 사라졌다.

운영자는 다음 정보를 확인하지 못할 수 있다.

  • 연결 시간이 초과되었는가?
  • 고유 제약 조건이 충돌했는가?
  • 데이터베이스 인증이 실패했는가?
  • 쿼리 형식에 문제가 있었는가?

cause를 사용하면 상위 수준의 의미와 원래 원인을 함께 유지할 수 있다.

try {
  return await reservationRepository
    .create(input);
} catch (error) {
  throw new InfrastructureError(
    "Failed to persist reservation",
    { cause: error }
  );
}

사용자에게 내부 오류 전체를 공개해야 한다는 뜻은 아니다.

function mapErrorToResponse(
  error: unknown
): Response {
  if (error instanceof ValidationError) {
    return Response.json(
      {
        code: "INVALID_INPUT",
        message:
          "입력한 예약 정보를 확인해 달라.",
        fields: error.fieldErrors,
      },
      { status: 400 }
    );
  }

  if (
    error instanceof SlotUnavailableError
  ) {
    return Response.json(
      {
        code: "SLOT_UNAVAILABLE",
        message:
          "선택한 시간이 방금 예약되었다.",
      },
      { status: 409 }
    );
  }

  logger.error(
    { error },
    "Unhandled reservation error"
  );

  return Response.json(
    {
      code: "INTERNAL_ERROR",
      message:
        "현재 예약을 처리할 수 없다. 잠시 후 다시 시도해 달라.",
    },
    { status: 500 }
  );
}

사용자에게는 안전하고 행동 가능한 메시지를 제공한다. 운영 로그에는 원인과 문맥을 보존한다.

에러 메시지 하나가 사용자 안내와 운영 진단을 동시에 담당하게 해서는 안 된다.


재시도는 모든 실패에 적용하는 해결책이 아니다

네트워크 요청이 실패하면 자동으로 다시 시도하도록 만들 수 있다.

async function retry<T>(
  operation: () => Promise<T>,
  attempts = 3
): Promise<T> {
  let lastError: unknown;

  for (
    let attempt = 1;
    attempt <= attempts;
    attempt++
  ) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
    }
  }

  throw lastError;
}

하지만 모든 실패를 재시도해서는 안 된다.

  • 잘못된 인원 수는 다시 요청해도 올바른 값이 되지 않는다.
  • 만료된 세션은 인증을 갱신하지 않으면 해결되지 않는다.
  • 이미 예약된 시간대는 같은 입력으로 재시도해도 사용할 수 없다.
  • 일시적인 네트워크 단절이나 서버 과부하는 재시도로 복구될 수 있다.

재시도 가능성을 에러 분류에 포함할 수 있다.

type ServiceErrorCode =
  | "INVALID_INPUT"
  | "UNAUTHENTICATED"
  | "SLOT_UNAVAILABLE"
  | "TEMPORARY_UNAVAILABLE"
  | "INTERNAL_ERROR";

type ServiceError = {
  code: ServiceErrorCode;
  retryable: boolean;
  message: string;
};

재시도 가능한 실패라도 즉시 반복하면 장애를 더 악화시킬 수 있다. 일반적으로 재시도 사이에 지연을 두고 점차 늘리는 정책이 필요하다.

const delayByAttempt = [
  300,
  1000,
  3000,
];

for (
  let attempt = 0;
  attempt < delayByAttempt.length;
  attempt++
) {
  try {
    return await loadAvailableSlots(date);
  } catch (error) {
    if (!isRetryable(error)) {
      throw error;
    }

    await delay(delayByAttempt[attempt]);
  }
}

예약 생성처럼 상태를 변경하는 요청에는 더 중요한 문제가 있다. 응답이 사라졌다고 해서 예약 생성이 실패한 것은 아닐 수 있다.

const reservationAttemptId =
  crypto.randomUUID();

await fetch("/api/reservations", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key":
      reservationAttemptId,
  },
  body: JSON.stringify(input),
});

동일한 키로 다시 요청했을 때 같은 예약 결과를 반환할 수 있어야 중복 예약을 막을 수 있다.

재시도는 단순한 반복문이 아니다. 어떤 실패를, 몇 번, 어느 간격으로, 같은 작업으로 인식하면서 다시 실행할지 결정하는 정책이다.


시간 초과는 작업 실패가 아니라 결과를 모르는 상태일 수 있다

클라이언트가 다음과 같이 시간 제한을 적용했다고 생각해 보자.

const result = await Promise.race([
  submitReservation(input),
  rejectAfter(5000),
]);

5초 안에 응답이 오지 않으면 시간 초과 에러가 발생한다.

하지만 서버에서는 다음 작업이 이미 진행되었을 수 있다.

  1. 예약 가능 여부를 확인했다.
  2. 예약 레코드를 생성했다.
  3. 결제를 승인했다.
  4. 응답을 보내는 도중 네트워크가 끊겼다.

클라이언트가 시간 초과를 받았다는 사실은 서버 작업이 취소되었다는 증거가 아니다.

다음 안내는 위험하다.

catch {
  showMessage(
    "예약에 실패했다. 다시 예약해 달라."
  );
}

사용자가 다시 제출하면 중복 예약이나 중복 결제가 발생할 수 있다.

화면에는 확정 실패와 결과 불확실 상태를 구분해 표현해야 한다.

type ReservationViewState =
  | { status: "idle" }
  | { status: "submitting" }
  | {
      status: "confirmed";
      reservationId: string;
    }
  | {
      status: "unavailable";
      alternativeSlotIds: string[];
    }
  | {
      status: "unknown";
      reservationAttemptId: string;
    }
  | {
      status: "error";
      message: string;
    };

unknown 상태에서는 같은 예약을 다시 생성하기보다 기존 시도 결과를 조회한다.

const reservation =
  await getReservationByAttemptId(
    reservationAttemptId
  );

시간 초과는 “실패했다”가 아니라 “제한 시간 안에 결과를 확인하지 못했다”는 뜻이다. 에러 처리는 이 불확실성을 없애지 않고 정확하게 표현해야 한다.


핵심 작업과 후속 작업의 실패 경계를 나눠야 한다

예약 생성 후에는 여러 작업이 이어질 수 있다.

async function completeReservation(
  input: ReservationInput
) {
  const reservation =
    await createReservation(input);

  await sendConfirmationEmail(
    reservation
  );

  await recordAnalyticsEvent(
    reservation
  );

  return reservation;
}

확인 이메일 전송이 실패하면 함수 전체가 거부된다. 하지만 예약은 데이터베이스에 이미 생성되었을 수 있다.

호출자는 이를 예약 실패로 해석해 다시 제출할 수 있다.

예약 생성과 이메일 전송이 같은 의미의 성공이어야 하는지 판단해야 한다.

async function completeReservation(
  input: ReservationInput
) {
  const reservation =
    await createReservation(input);

  await reservationEventQueue.publish({
    type: "reservation.confirmed",
    reservationId: reservation.id,
  });

  return reservation;
}

이 함수의 완료 범위는 다음과 같이 정의된다.

  • 예약이 저장되었다.
  • 후속 작업을 처리할 이벤트가 등록되었다.

별도의 작업자가 이메일과 분석 기록을 처리할 수 있다.

async function handleReservationConfirmed(
  event: ReservationConfirmedEvent
) {
  const results =
    await Promise.allSettled([
      sendConfirmationEmail(
        event.reservationId
      ),
      recordAnalyticsEvent(
        event.reservationId
      ),
    ]);

  await saveFollowUpResults(
    event.reservationId,
    results
  );
}

후속 작업의 실패가 사라지는 것은 아니다. 별도로 기록하고 필요한 작업만 다시 실행할 수 있다.

예약 저장과 이벤트 등록 사이에도 실패 지점이 존재한다. 예약은 저장되었지만 이벤트 등록이 실패하면 확인 이메일이 영원히 발송되지 않을 수 있다. 두 기록을 함께 보장해야 한다면 트랜잭셔널 아웃박스 같은 설계가 필요하다.

에러 처리의 핵심은 모든 작업을 하나의 try-catch에 넣는 것이 아니다. 어떤 실패가 핵심 결과를 무효화하고 어떤 실패가 별도로 복구되어야 하는지 경계를 정하는 것이다.


사용자 메시지는 원인을 나열하는 대신 다음 행동을 안내해야 한다

내부 오류를 그대로 표시하면 사용자에게 도움이 되지 않거나 보안 문제를 만들 수 있다.

showMessage(
  error.stack ??
    "Unknown database error"
);

스택 추적, 테이블 이름, 내부 경로, 외부 서비스 응답에는 운영 정보나 민감한 데이터가 포함될 수 있다.

반대로 모든 실패에 같은 메시지를 표시하는 것도 충분하지 않다.

showMessage(
  "오류가 발생했다."
);

사용자는 기다려야 하는지, 입력을 수정해야 하는지, 다시 로그인해야 하는지 알 수 없다.

에러 코드를 사용자 행동으로 변환할 수 있다.

function getReservationErrorView(
  error: ServiceError
) {
  switch (error.code) {
    case "INVALID_INPUT":
      return {
        message:
          "입력한 예약 정보를 확인해 달라.",
        action: "edit_input",
      };

    case "UNAUTHENTICATED":
      return {
        message:
          "예약을 계속하려면 다시 로그인해야 한다.",
        action: "sign_in",
      };

    case "SLOT_UNAVAILABLE":
      return {
        message:
          "선택한 시간이 방금 예약되었다.",
        action: "choose_another_slot",
      };

    case "TEMPORARY_UNAVAILABLE":
      return {
        message:
          "일시적으로 예약 정보를 불러올 수 없다.",
        action: "retry",
      };

    default:
      return {
        message:
          "예약을 처리하지 못했다. 문제가 계속되면 고객 지원에 문의해 달라.",
        action: "contact_support",
      };
  }
}

좋은 에러 메시지는 기술적인 원인을 모두 설명하지 않는다. 현재 상태를 정확히 알리고 사용자가 선택할 수 있는 다음 행동을 제공한다.


운영 로그는 오류 메시지가 아니라 사건의 문맥을 기록해야 한다

다음 로그는 에러가 발생했다는 사실만 보여준다.

console.error(
  "Reservation failed"
);

운영 중에는 같은 메시지가 수천 번 발생할 수 있다. 원인을 추적하려면 사건을 연결할 정보가 필요하다.

logger.error(
  {
    error,
    requestId,
    reservationAttemptId,
    slotId: input.slotId,
    userId,
    operation:
      "create_reservation",
  },
  "Reservation creation failed"
);

이 로그는 어느 요청과 예약 시도에서 어떤 작업이 실패했는지 추적할 수 있게 한다.

다만 로그에 모든 데이터를 넣어서는 안 된다.

logger.error({
  creditCardNumber:
    input.creditCardNumber,
  accessToken: request.headers.get(
    "Authorization"
  ),
});

결제 정보, 비밀번호, 인증 토큰, 불필요한 개인정보는 기록하지 않아야 한다. 필요한 경우 마스킹하거나 비식별 식별자를 사용한다.

또한 모든 에러를 같은 심각도로 기록하면 실제 장애 신호가 묻힐 수 있다.

  • 입력 검증 실패는 일반적인 사용자 행동일 수 있다.
  • 예약 충돌은 서비스에서 예상한 경쟁 결과일 수 있다.
  • 반복되는 데이터베이스 연결 실패는 즉시 조사해야 할 장애일 수 있다.
  • 이메일 전송 실패 증가는 외부 제공자 문제를 나타낼 수 있다.

에러 로그는 실패의 수집함이 아니다. 장애를 발견하고 원인을 추적하며 복구 결과를 확인할 수 있는 운영 데이터다.


에러의 Source of Truth는 화면 메시지가 아니다

화면에 다음 상태가 표시되었다고 생각해 보자.

setReservationState({
  status: "error",
  message: "예약에 실패했다.",
});

이 상태는 클라이언트가 특정 요청의 결과를 확인하지 못했다는 의미일 수 있다. 실제 예약 상태를 증명하지는 않는다.

예약의 Source of Truth는 서버와 데이터베이스가 관리하는 최신 예약 기록이다.

type ReservationStatus =
  | "pending"
  | "confirmed"
  | "cancelled"
  | "expired";

프론트엔드의 에러 상태, 네트워크 요청의 거부, 서버의 예약 상태는 서로 다른 사실이다.

const latestReservation =
  await getReservationByAttemptId(
    reservationAttemptId
  );

특히 결제나 예약처럼 중복 실행 비용이 큰 작업에서는 화면의 에러를 비즈니스 실패로 간주해서는 안 된다.

  • 화면 에러는 응답을 받지 못했다는 뜻일 수 있다.
  • API 에러는 특정 처리를 완료하지 못했다는 뜻일 수 있다.
  • 데이터베이스의 예약 상태는 실제로 저장된 최신 사실을 나타낸다.
  • 외부 결제 제공자가 최종 상태를 관리한다면 별도의 동기화가 필요할 수 있다.

에러를 처리할 때는 어떤 계층의 실패를 보고 있는지와 실제 상태를 어디에서 다시 확인할 수 있는지를 함께 판단해야 한다.


예약 실패는 서비스 전체를 이동한다

예약 요청의 실패는 한 함수 안에서 끝나지 않는다.

flowchart LR
    A[예약 폼 제출] --> B[클라이언트 입력 검증]
    B --> C[예약 시도 ID 생성]
    C --> D[API 요청]
    D --> E[서버 입력 검증]
    E --> F[인증과 권한 확인]
    F --> G[예약 가능 여부 확인]
    G --> H{예약 가능 여부}
    H -->|가능| I[예약 저장]
    H -->|불가능| J[대체 시간 안내]
    I --> K[후속 이벤트 등록]
    K --> L[확인 이메일 발송]
    I --> M[예약 완료 응답]
    D -. 응답 확인 실패 .-> N[예약 상태 조회]
    E -. 잘못된 입력 .-> O[필드 오류 표시]
    I -. 저장소 장애 .-> P[운영 로그와 일시적 오류 응답]

각 단계에서는 다른 책임을 가진 실패가 발생한다.

  • 클라이언트 검증은 빠른 피드백을 제공한다.
  • 서버 검증은 신뢰할 수 없는 입력이 시스템에 들어오는 것을 막는다.
  • 인증 실패는 로그인 흐름으로 연결된다.
  • 예약 충돌은 다른 시간 선택으로 연결된다.
  • 저장소 장애는 안전한 오류 응답과 운영 알림으로 연결된다.
  • 응답 단절은 예약 상태 재조회로 연결된다.
  • 이메일 실패는 예약 결과와 분리해 다시 처리할 수 있다.

모든 실패를 하나의 catch로 보낼 수는 있지만, 그렇게 모인 실패를 같은 의미로 처리해서는 안 된다.


에러 처리를 설계하기 전에 물어봐야 할 질문

실패의 의미를 구분했는가

  1. 이 실패는 사용자가 수정할 수 있는 입력 문제인가?
  2. 서비스에서 예상한 비즈니스 결과인가?
  3. 정상적인 결과를 만들 수 없는 시스템 장애인가?
  4. 작업이 실패한 것인가, 결과를 확인하지 못한 것인가?
  5. 내부 오류와 외부 서비스 오류를 구분할 수 있는가?

적절한 경계에서 처리하고 있는가

  1. 현재 계층에서 실제로 복구할 수 있는가?
  2. 에러를 기록한 뒤 상위 계층에 다시 전달해야 하는가?
  3. 하위 계층의 기술 오류를 서비스 의미로 변환해야 하는가?
  4. 하나의 catch가 서로 다른 책임의 실패를 섞고 있지 않은가?
  5. 핵심 작업과 후속 작업의 실패 경계가 분명한가?

복구 정책이 안전한가

  1. 이 에러는 같은 입력으로 재시도하면 해결될 수 있는가?
  2. 재시도 횟수와 간격은 제한되어 있는가?
  3. 재시도가 중복 예약을 만들지 않도록 멱등성을 보장하는가?
  4. 시간 초과 이후 실제 작업 상태를 다시 확인할 수 있는가?
  5. 대체 경로가 실제로 유효한 결과를 제공하는가?

사용자에게 올바르게 전달하는가

  1. 사용자가 현재 상태를 이해할 수 있는가?
  2. 수정, 로그인, 재시도, 상태 조회 중 어떤 행동이 필요한가?
  3. 확정된 실패와 결과 불확실 상태를 구분하는가?
  4. 내부 구현 정보나 민감한 데이터가 노출되지 않는가?
  5. 사용자 메시지가 실제 서버 상태와 일치하는가?

운영 중 추적할 수 있는가

  1. 요청 ID나 작업 ID로 전체 흐름을 연결할 수 있는가?
  2. 원래 에러와 원인 체인이 보존되는가?
  3. 로그에 작업명과 필요한 비즈니스 문맥이 포함되는가?
  4. 민감한 정보가 로그에 기록되지 않는가?
  5. 반복되는 실패를 지표와 알림으로 발견할 수 있는가?
  6. 실패한 후속 작업을 저장하고 다시 처리할 수 있는가?

흔한 실수는 실패의 의미를 지운다

모든 코드를 하나의 try-catch로 감싼다

try {
  validateInput(input);
  authenticateUser(request);
  await createReservation(input);
  await sendConfirmationEmail(input);
} catch {
  showMessage("예약에 실패했다.");
}

어느 단계가 실패했는지와 예약이 실제로 생성되었는지 알 수 없다.

실패의 책임과 복구 방법에 따라 경계를 나눠야 한다.


에러를 기록한 뒤 그대로 숨긴다

catch (error) {
  logger.error(error);
}

호출자는 작업이 성공한 것으로 오해할 수 있다.

복구하지 않았다면 에러를 다시 전달하거나 명시적인 실패 결과를 반환해야 한다.


모든 실패를 예외로 표현한다

if (!slot.available) {
  throw new Error(
    "Reservation failed"
  );
}

예약 충돌처럼 예상 가능한 결과는 판별 가능한 결과 타입으로 표현할 수 있다.


모든 실패에 자동 재시도를 적용한다

return retry(
  () => createReservation(input),
  5
);

입력 오류와 예약 충돌은 반복해도 해결되지 않는다. 상태 변경 요청은 멱등성 없이 재시도하면 중복 작업을 만들 수 있다.


시간 초과를 확정 실패로 처리한다

catch {
  showMessage(
    "예약이 생성되지 않았다."
  );
}

응답만 사라졌고 서버에서는 예약이 생성되었을 수 있다. 작업 ID로 실제 상태를 조회해야 한다.


내부 오류를 사용자에게 그대로 보여준다

showMessage(String(error));

내부 구조나 민감한 정보가 노출될 수 있다. 사용자 메시지와 운영 로그를 분리해야 한다.


catch에서 임의의 성공 값을 반환한다

const reservation =
  await createReservation(input)
    .catch(() => ({
      id: "unknown",
      status: "confirmed",
    }));

실패가 정상적인 성공 결과로 바뀐다. 유효한 복구 정책이 없다면 실패를 숨겨서는 안 된다.


에러 처리의 핵심은 실패 이후의 책임을 정하는 데 있다

프로그래밍을 처음 배울 때는 다음 코드로 에러 처리의 기본 동작을 이해할 수 있다.

try {
  await createReservation(input);
} catch (error) {
  console.error(error);
}

예외가 발생하면 catch로 이동한다는 설명은 입문 단계에서 충분하다.

하지만 실제 서비스에서는 에러가 잡혔다는 사실보다 그 이후의 판단이 중요하다.

  • 실패가 예상 가능한 결과인지 시스템 장애인지 구분했는가?
  • 검증 실패와 예약 충돌을 같은 에러로 취급하지 않는가?
  • 에러를 처리할 수 있는 경계에서 잡고 있는가?
  • 복구하지 못한 실패가 조용히 사라지지 않는가?
  • 재시도가 안전하며 중복 작업을 만들지 않는가?
  • 시간 초과와 확정 실패를 구분하는가?
  • 핵심 예약과 이메일 같은 후속 작업의 실패를 분리했는가?
  • 사용자에게 정확한 상태와 다음 행동을 제공하는가?
  • 운영자가 원인과 영향 범위를 추적할 수 있는가?
  • 화면의 에러가 실제 예약 상태의 Source of Truth가 아님을 고려했는가?

에러 처리는 try-catch를 작성하는 것이 아니다.

실패를 의미와 책임에 따라 분류하고, 적절한 경계에서 복구하거나 전달하며, 사용자와 운영자에게 필요한 다음 행동을 제공하는 설계다.

profile
Vision eXperience Developer

0개의 댓글