Guard Clause + Result
검증, 권한, 의존성 실패가 섞인 함수에서 가장 먼저 적용할 수 있는 구조다. 실패 조건을 먼저 닫고, 함수 본문은 정상 흐름으로 남긴다. 실패는 throw가 아니라 Result 데이터로 반환한다.
연습 메모 · 7 min read · Medium
검증, 권한, 의존성 실패가 섞인 함수에서 가장 먼저 적용할 수 있는 구조다. 실패 조건을 먼저 닫고, 함수 본문은 정상 흐름으로 남긴다.
실패는 throw가 아니라 Result 데이터로 반환한다.
이 패턴의 목적은 코드를 짧게 만드는 것이 아니다. 실패 경로를 함수의 계약으로 끌어올리는 것이다. 호출자는 "이 함수가 실패할 수 있는가"를 타입과 반환값에서 바로 본다. 반대로 throw는 시그니처만 봐서는 잘 안 보인다.
코드 리뷰에서 놓치기 좋은 부분이다.
Guard clause는 실패를 위에서 차단한다.
Result는 차단한 실패를 데이터로 만든다. 둘을 같이 쓰면 함수 본문은 "성공했을 때 무엇을 하는가"만 남는다.
Guard clause를 나누는 이유는 단순히 if 문을 보기 좋게 정리하기 위해서만은 아니다.
각 guard clause는 서로 다른 실패 원인을 분리하고, 그 실패를 나중에 ErrorPolicy에 연결하기 위한 분류 지점이다. 그래서 일반적으로 ErrorPolicy와 흔히 같이 쓴다.
예를 들어 값이 없는 실패는 required, 정수가 아닌 실패는 not_integer, 범위를 벗어난 실패는 out_of_range로 나눌 수 있다. 이 구분이 있어야 호출부나 상위 정책 레이어에서 어떤 실패는 400 Bad Request로 응답하고, 어떤 실패는 로그만 남기고, 어떤 실패는 사용자 메시지로 번역할지 결정할 수 있다.
즉 guard clause는 실패를 조기에 닫고, Result는 그 실패를 데이터로 만들며, ErrorPolicy는 그 실패 데이터를 어떻게 처리할지 결정한다.
개인메모: 예상 가능한 도메인 실패는 Result로 반환하고, 예상 밖의 시스템 장애는 예외나 상위 정책 핸들러로 넘긴다.
실패 가능성을 함수의 계약에 드러내고 호출자가 처리 책임을 놓치지 않게 만드는 것이다.
표현
type Result<T> =
// 성공 경로는 실제 값을 가진다.
| { ok: true; value: T }
// 실패 경로는 예외가 아니라 호출자가 처리할 수 있는 데이터다.
| { ok: false; error: string };
function parseLimit(raw: string | null): Result<number> {
// Guard clause 1: 값이 없으면 즉시 닫는다.
// 아래 정상 흐름에서 null, 빈 문자열, 공백 문자열을 계속 신경 쓰지 않게 만든다.
if (raw === null || raw.trim() === '') {
return { ok: false, error: 'limit is required' };
}
const limit = Number(raw);
// Guard clause 2: 숫자로 해석할 수 없는 값을 닫는다.
if (!Number.isFinite(limit)) {
return { ok: false, error: 'limit must be a number' };
}
// Guard clause 3: 숫자이지만 정책상 허용되지 않는 값을 닫는다.
if (!Number.isInteger(limit)) {
return { ok: false, error: 'limit must be an integer' };
}
// Guard clause 4: 도메인 정책 범위를 벗어난 값을 닫는다.
if (limit < 1 || limit > 100) {
return { ok: false, error: 'limit must be between 1 and 100' };
}
// 여기까지 내려오면 정상 흐름만 남는다.
return { ok: true, value: limit };
}
호출부
const result = parseLimit(request.query.limit);
// 실패도 함수 반환값의 일부다.
// try/catch가 아니라 if 문으로 도메인 실패를 처리한다.
if (!result.ok) {
return badRequest(result.error);
}
// 이 아래에서는 TypeScript가 result를 성공 케이스로 좁힌다.
// result.value는 number로 안전하게 사용할 수 있다.
return loadPage({ limit: result.value });
읽는 순서
입력이 비어 있는가?
값으로 변환할 수 있는가?
도메인 정책에 맞는가?
성공 값으로 넘길 수 있는가?
검증 순서가 흐트러지면 에러 메시지도 흐트러진다.
예를 들어 null을 먼저 막지 않고 바로 Number(raw)를 호출하면,
"값이 없음"과 "숫자로 바꿀 수 없음"이 뒤섞인다.
예외와 Result의 경계
Result는 예상 가능한 실패에 쓴다.
사용자가 필수 값을 안 넣음
숫자 범위가 정책에 안 맞음
권한이 없음
외부 API가 정상 응답을 줬지만 비즈니스 조건에 안 맞음
예외는 예상 밖의 실패에 둔다.
DB 연결이 끊김
파일 시스템 권한이 깨짐
코드 불변식이 깨짐
라이브러리가 복구 불가능한 오류를 던짐
사용자 입력 실패를 예외로 던지기 시작하면 정상적인 도메인 흐름이 에러 처리 흐름으로 숨어버린다.
반대로 모든 시스템 장애를 Result로 감싸면 진짜 장애도 평범한 분기처럼 보인다.
둘 다 좋지 않다.
언제 쓰나
요청 파라미터 검증
권한 확인
외부 API 응답 검증
저장 전 정책 검사
작은 UI 판단에는 과할 수 있다. 인증, 저장, 외부 호출 경계처럼 실패 원인을 추적해야 하는 곳에서 비용 대비 효과가 좋다.
잘못된 예제
function parseLimit(raw: string | null) {
const limit = Number(raw);
// 두 실패를 'bad limit' 하나로 뭉개면 호출자는 원인을 알 수 없다.
// 사용자가 값을 안 넣은 것과 정수가 아닌 값을 넣은 것은 다른 실패다.
if (!raw || !Number.isInteger(limit)) {
throw new Error('bad limit');
}
// throw 기반 흐름은 타입만 봐서는 실패 가능성이 잘 드러나지 않는다.
return limit;
}
이 코드는 실패 이유가 한곳으로 뭉개지고, 호출자가 try/catch를 잊으면 흐름을 보지 못한다.
호출부 또한 문제다.parseLimit() 이름만 봐서는 이 함수가 던지는지, 어떤 에러를 던지는지, 어떤 실패를 복구해야 하는지 알기 어렵다.
반면에 Result<number>는 호출자에게 "성공과 실패를 둘 다 처리하라"고 강제한다.
추가로 생각해보기
납품 코드에서는 error: string보다 에러 코드를 분리하는 편이 낫다.
문자열은 화면 표시에는 편하지만, 정책 분기에는 약하기 때문이다. 에러 코드가 있으면 HTTP status, 번역, 로그, 사용자 메시지, 재시도 여부를 나눠 처리하기 쉽다.
1.실패 타입을 코드로 분리한다.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type ParseLimitError =
| { code: 'required'; message: string }
| { code: 'not_number'; message: string }
| { code: 'not_integer'; message: string }
| { code: 'out_of_range'; min: number; max: number; message: string };
2.각 guard clause는 고유한 실패 코드를 반환한다.
function parseLimit(raw: string | null): Result<number, ParseLimitError> {
if (raw === null || raw.trim() === '') {
return {
ok: false,
error: { code: 'required', message: 'limit is required' },
};
}
const limit = Number(raw);
if (!Number.isFinite(limit)) {
return {
ok: false,
error: { code: 'not_number', message: 'limit must be a number' },
};
}
if (!Number.isInteger(limit)) {
return {
ok: false,
error: { code: 'not_integer', message: 'limit must be an integer' },
};
}
if (limit < 1 || limit > 100) {
return {
ok: false,
error: {
code: 'out_of_range',
min: 1,
max: 100,
message: 'limit must be between 1 and 100',
},
};
}
return { ok: true, value: limit };
}
3.ErrorPolicy는 실패 코드를 실제 처리 방식으로 바꾼다.
function handleParseLimitError(error: ParseLimitError) {
switch (error.code) {
case 'required':
case 'not_number':
case 'not_integer':
case 'out_of_range': {
return badRequest({
code: error.code,
message: error.message,
});
}
}
}
호출부는 직접 문자열을 해석하지 않는다.
실패의 의미는 ParseLimitError가 가지고 있고, 실패 처리 방식은 handleParseLimitError()가 결정한다.
const result = parseLimit(request.query.limit);
if (!result.ok) {
return handleParseLimitError(result.error);
}
return loadPage({ limit: result.value });
이렇게 나누면 역할이 분명해진다.
Guard clause = 실패 원인 분리
Result<T, E> = 실패를 반환값으로 표현
ErrorCode = 실패의 종류를 안정적으로 식별
ErrorPolicy = 실패를 어떻게 처리할지 결정
문자열은 화면 표시에는 편하지만 분기에는 약하다.
에러 코드가 있으면 HTTP status, 번역, 로그, 사용자 메시지를 나눠 처리하기 쉽다.
요약
실패가 도메인 흐름의 일부라면 예외가 아니라 데이터로 반환해야 한다.
Guard clause = 실패 원인 분리
Result<T, E> = 실패를 반환값으로 표현
ErrorCode = 실패의 종류를 안정적으로 식별
ErrorPolicy = 실패를 어떻게 처리할지 결정