프로그래밍을 처음 배울 때 에러 처리는 보통 오류가 발생할 수 있는 코드를 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초 안에 응답이 오지 않으면 시간 초과 에러가 발생한다.
하지만 서버에서는 다음 작업이 이미 진행되었을 수 있다.
클라이언트가 시간 초과를 받았다는 사실은 서버 작업이 취소되었다는 증거가 아니다.
다음 안내는 위험하다.
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"
),
});
결제 정보, 비밀번호, 인증 토큰, 불필요한 개인정보는 기록하지 않아야 한다. 필요한 경우 마스킹하거나 비식별 식별자를 사용한다.
또한 모든 에러를 같은 심각도로 기록하면 실제 장애 신호가 묻힐 수 있다.
에러 로그는 실패의 수집함이 아니다. 장애를 발견하고 원인을 추적하며 복구 결과를 확인할 수 있는 운영 데이터다.
화면에 다음 상태가 표시되었다고 생각해 보자.
setReservationState({
status: "error",
message: "예약에 실패했다.",
});
이 상태는 클라이언트가 특정 요청의 결과를 확인하지 못했다는 의미일 수 있다. 실제 예약 상태를 증명하지는 않는다.
예약의 Source of Truth는 서버와 데이터베이스가 관리하는 최신 예약 기록이다.
type ReservationStatus =
| "pending"
| "confirmed"
| "cancelled"
| "expired";
프론트엔드의 에러 상태, 네트워크 요청의 거부, 서버의 예약 상태는 서로 다른 사실이다.
const latestReservation =
await getReservationByAttemptId(
reservationAttemptId
);
특히 결제나 예약처럼 중복 실행 비용이 큰 작업에서는 화면의 에러를 비즈니스 실패로 간주해서는 안 된다.
에러를 처리할 때는 어떤 계층의 실패를 보고 있는지와 실제 상태를 어디에서 다시 확인할 수 있는지를 함께 판단해야 한다.
예약 요청의 실패는 한 함수 안에서 끝나지 않는다.
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로 보낼 수는 있지만, 그렇게 모인 실패를 같은 의미로 처리해서는 안 된다.
catch가 서로 다른 책임의 실패를 섞고 있지 않은가?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로 이동한다는 설명은 입문 단계에서 충분하다.
하지만 실제 서비스에서는 에러가 잡혔다는 사실보다 그 이후의 판단이 중요하다.
에러 처리는 try-catch를 작성하는 것이 아니다.
실패를 의미와 책임에 따라 분류하고, 적절한 경계에서 복구하거나 전달하며, 사용자와 운영자에게 필요한 다음 행동을 제공하는 설계다.