가드 클로즈
함수 앞부분에서 실패 조건과 특별한 경우를 검사해 곧바로 반환하거나 예외를 던지고, 정상 경로를 중첩 없이 남기는 제어 흐름 패턴.
· 마지막 업데이트: 2026-10-25 · 17 min read
개요
배송비를 계산하는 함수가 빈 주문은 거절하고, 회원과 50 이상 주문은 무료로, 나머지는 5를 받는다고 하자. 조건마다 if를 열고 다음 검사를 그 안에 넣으면, 가장 흔한 경우인 "배송비 5"는 세 겹 안쪽에 놓이고 빈 주문을 거절하는 처리는 함수 맨 끝의 else로 밀려난다. 조건과 그 결과 사이에 다른 분기가 끼어 있어서, 어느 else가 어느 if의 짝인지 들여쓰기를 세어 봐야 안다. 아래 예제의 ShippingNested가 이 모양이다.
가드 클로즈(guard clause)는 함수 앞부분에서 특별한 경우나 실패 조건을 검사하고, 조건에 걸리면 곧바로 반환하거나 예외를 던지는 문장이다. 가드를 모두 통과한 뒤에 남는 코드를 정상 경로라고 부르며, 함수가 원래 하려던 일이 여기에 있다. 가드마다 조건 바로 아래에 결과가 붙어 있고 else가 없으므로 정상 경로는 들여쓰기 한 단계에 머문다.
가드가 걸러 내는 실패는 두 종류다. 호출 코드가 메서드의 약속을 어긴 경우, 예를 들어 null을 넘긴 경우는 버그이므로 예외를 던진다. 사용자가 빈 사유를 보내거나 이미 배송된 주문을 취소하려는 경우는 정상 운영 중에도 늘 생기는 실패이므로, 오류 코드를 담은 결과 타입으로 돌려준다.
구조
범위 넓힌 예제의 주문 취소 처리를 UML 활동 다이어그램으로 그렸다. 마름모는 흐름이 갈리는 결정 노드이고, 대괄호 안은 그 흐름을 탈 조건이다. 가드에 걸린 요청은 아래로 빠져 오류 코드를 담은 Err를 반환하고 끝나며, [else]를 따라 모든 가드를 통과한 요청만 오른쪽 끝의 취소 처리에 닿는다. 예외를 던지는 null 검사는 그림에서 뺐다.
역사
Kent Beck은 1996년 Smalltalk Best Practice Patterns에 Guard Clause 항목을 두었다. 출판사 목차에서 이 항목은 7장 Formatting에 있고, Rectangular Block과 Conditional Expression 사이에 놓인다.1
Beck은 2007년 Implementation Patterns의 7장 Behavior에서 이 패턴을 다시 다뤘다. 장 서두의 패턴 요약은 가드 클로즈를 지역적인 예외 흐름을 이른 반환(early return)으로 표현하는 방법이라고 적는다.2 본문은 if-then-else가 똑같이 중요한 두 흐름을 표현하는 반면, 가드 클로즈는 한쪽 흐름이 다른 쪽보다 중요한 상황에 맞는다고 구분한다. 조건이 여러 개일 때 특히 유용하다고 하고, 중첩된 조건문이 결함을 낳는다고 쓴다.3
Martin Fowler의 리팩터링 카탈로그에는 "Replace Nested Conditional with Guard Clauses"가 있다. 이 카탈로그는 2018년에 나온 Refactoring 2판을 위한 것이고, 항목은 중첩된 if-else로 지급액을 계산하던 getPayAmount를 조건마다 바로 반환하는 형태로 바꾼 전후 코드를 보여 준다. 이 리팩터링은 조건마다 다른 결과를 돌려주던 코드를, 먼저 끝낼 경우와 마지막까지 남길 경우로 펼쳐 읽게 한다.4
단일 출구 규칙
가드 클로즈는 "루틴에는 입구 하나와 출구 하나만 둔다"는 오래된 규칙과 부딪친다. Beck은 같은 절에서 이 규칙이 한 루틴 안의 여러 위치로 뛰어들고 빠져나가는 데서 생기는 혼란을 막으려던 것이라고 설명한다. 그리고 전역 데이터가 많아 어떤 문장이 실행됐는지 파악하기도 어렵던 FORTRAN이나 어셈블리 프로그램에서는 이 규칙이 타당했지만, 메서드가 작고 데이터가 대부분 지역 변수인 Java에서는 불필요하게 보수적이라고 평가했다.5
출구가 하나여야 정리 코드가 반드시 실행된다는 걱정도 있다. C#에서는 return으로 try 블록을 빠져나가도 finally 블록이 실행되고 컴파일러가 using 문을 try-finally로 바꾸므로, 이른 반환을 해도 자원 정리는 그대로 실행된다.6
가드의 종류와 순서
프로그래밍 오류를 막는 가드는 예외를 던진다. .NET 프레임워크 설계 지침은 잘못된 인자가 멤버에 넘어오면 ArgumentException이나 그 하위 타입을 던지라고 한다.7 이런 검사를 한 줄로 쓰는 도우미도 있다. ArgumentNullException.ThrowIfNull은 .NET 6부터, ArgumentException.ThrowIfNullOrEmpty는 .NET 7부터, ArgumentOutOfRangeException.ThrowIfNegative는 .NET 8부터 있다. ThrowIfNull은 매개변수 이름을 생략하면 CallerArgumentExpression 특성으로 인자 자리에 쓴 식의 텍스트를 예외에 담으며, 범위 넓힌 예제에서 그 텍스트는 command다.8
예상된 업무 실패를 거르는 가드는 결과 타입을 돌려준다. 같은 설계 지침은 오류 코드를 돌려주지 말고 예외로 실패를 보고하라고 하면서도, 가능하면 정상 흐름에 예외를 쓰지 말라고 한다. 흔한 상황에서 실패할 수 있는 멤버에는 예외로 인한 성능 문제를 피하는 방법으로, 조건을 먼저 검사하는 멤버를 따로 두는 Tester-Doer 패턴이나 성공 여부를 bool로 돌려주고 결과는 out 매개변수로 넘기는 Try-Parse 패턴을 고려하라고 권한다.9 Result는 Try-Parse처럼 흔한 실패를 반환값으로 알리지만 실패 이유를 오류 코드로 함께 담으므로, 오류 코드를 돌려주지 말라는 항목과는 여전히 다른 선택이다. 이 선택의 근거와 비용은 결과 타입 문서에서 다룬다.
가드는 실패를 드러내야 하고, 입력을 몰래 고쳐서 통과시키면 안 된다. 빈 사유를 "사유 없음" 같은 기본 문구로 바꾸고 계속 진행하면, 호출자는 요청이 그대로 받아들여졌다고 여기고 나중에는 기록만 보고 누가 사유 없이 취소했는지 구분할 수 없다. 기본값을 채우는 것이 실제 업무 규칙이라면 그 처리는 가드가 아니라 정상 경로에 둔다.
범위 넓힌 예제는 저장소를 보지 않고 판단할 수 있는 입력 검사, 대상 조회, 권한 검사, 업무 규칙 순으로 검사한다. 저렴한 검사를 앞에 두면 어차피 실패할 요청에 조회 비용을 쓰지 않는다. 권한을 업무 규칙보다 먼저 보는 이유는 정보 노출이다. 이 예제에서 bob이 alice의 배송된 주문을 취소하려 할 때 업무 규칙을 먼저 검사하면, bob은 AlreadyShipped를 받고 남의 주문이 배송됐다는 사실을 알게 된다.
왜 사용하는가?
정상 경로가 들여쓰기 한 단계에 남고, 각 실패 조건이 그 결과와 같은 자리에 있다. 가드를 하나 더하거나 빼도 다른 분기의 들여쓰기가 바뀌지 않는다. 가드마다 다른 오류 코드를 돌려주면 결과만 보고 어느 검사에서 멈췄는지 알 수 있고, 테스트도 가드 하나에 입력 하나를 대응시켜 작성할 수 있다.
가드 클로즈로 바꿔도 검사 개수는 그대로다. 한 함수가 검사할 조건이 너무 많다면 검증을 별도 함수나 정책 객체로 나눠야 한다. 두 경로가 똑같이 중요한 분기에는 Beck의 구분대로 if-then-else가 더 잘 맞는다. 가드는 첫 실패에서 멈추므로, 폼 입력처럼 모든 오류를 한 번에 보여 줘야 하는 경우에는 검사를 모두 실행하고 실패를 목록으로 모으는 방식이 필요하다. 또 가드가 보는 것은 검사하는 순간의 상태뿐이어서, 검사와 갱신 사이에 다른 요청이 상태를 바꿀 수 있다면 동시성은 트랜잭션이나 조건부 갱신으로 따로 막아야 한다.
예제
개요의 배송비 계산을 중첩 if 버전과 가드 클로즈 버전으로 각각 쓰고, 같은 입력 네 개에 대해 두 함수의 결과를 비교한다.
using System;
using System.Globalization;
var orders = new (int Items, decimal Subtotal, bool Member)[]
{
(0, 0m, false),
(2, 30m, true),
(3, 80m, false),
(1, 20m, false),
};
foreach (var (items, subtotal, member) in orders)
{
var nested = ShippingNested(items, subtotal, member);
var guarded = ShippingGuarded(items, subtotal, member);
var who = member ? "member" : "guest";
var amount = subtotal.ToString(CultureInfo.InvariantCulture);
Console.WriteLine($"items={items} subtotal={amount} {who}: {guarded} (same: {nested == guarded})");
}
// 중첩 if: 기본 배송비라는 정상 경로가 가장 깊은 곳에 있다.
static string ShippingNested(int items, decimal subtotal, bool member)
{
string result;
if (items > 0)
{
if (!member)
{
if (subtotal < 50m)
{
result = "fee 5";
}
else
{
result = "free (over 50)";
}
}
else
{
result = "free (member)";
}
}
else
{
result = "rejected: empty order";
}
return result;
}
// 가드 클로즈: 특별한 경우를 먼저 처리하고 바로 반환한다.
static string ShippingGuarded(int items, decimal subtotal, bool member)
{
if (items <= 0) return "rejected: empty order";
if (member) return "free (member)";
if (subtotal >= 50m) return "free (over 50)";
return "fee 5";
}items=0 subtotal=0 guest: rejected: empty order (same: True)
items=2 subtotal=30 member: free (member) (same: True)
items=3 subtotal=80 guest: free (over 50) (same: True)
items=1 subtotal=20 guest: fee 5 (same: True)두 함수는 네 입력 모두에서 같은 값을 돌려준다. 가드 버전에서는 빈 주문 거절이 첫 줄에 있고, 기본 배송비 5는 마지막 줄에서 중첩 없이 함수 본문의 첫 단계에 남는다.
범위 넓힌 예제
이 예제는 주문 취소 명령 처리기다. 명령 처리기(command handler)는 "주문 취소" 같은 요청 하나를 받아 검사하고 상태를 바꾸는 객체로, 여기서는 가드가 모이는 자리다. null 명령과 null 사용자는 ThrowIfNull로 예외를 던지고, 나머지 실패는 오류 코드(ErrorCode)와 실패한 단계(Stage)를 담은 Result로 돌려준다. 같은 처리기에 요청 일곱 개를 차례로 보낸 뒤, 마지막으로 null 명령을 넘긴다.
using System;
using System.Collections.Generic;
var orders = new Dictionary<string, Order>
{
["order-1"] = new Order("alice", OrderStatus.Placed),
["order-2"] = new Order("alice", OrderStatus.Shipped),
["order-3"] = new Order("alice", OrderStatus.Placed),
};
var alice = new User("alice", IsAdmin: false);
var bob = new User("bob", IsAdmin: false);
var carol = new User("carol", IsAdmin: true);
var handler = new CancelOrderHandler(orders);
var requests = new (CancelOrder Command, User Actor)[]
{
(new CancelOrder("order-1", "changed mind"), alice),
(new CancelOrder("order-1", "changed mind"), alice),
(new CancelOrder("order-2", "too slow"), bob),
(new CancelOrder("order-2", "customer call"), carol),
(new CancelOrder("order-9", "typo"), alice),
(new CancelOrder("order-3", " "), alice),
(new CancelOrder("order-3", null), alice),
};
foreach (var (command, actor) in requests)
{
var result = handler.Handle(command, actor);
var text = result.Failure is { } f ? $"Err {f.Code} at {f.Stage}" : $"Ok {result.Value}";
Console.WriteLine($"{actor.Id} cancels {command.OrderId}: {text}");
}
// null 명령은 호출 코드의 버그다. 데모에서만 잡아서 출력한다.
try
{
handler.Handle(null!, alice);
}
catch (ArgumentNullException ex)
{
Console.WriteLine($"bug: {ex.GetType().Name} ({ex.ParamName})");
}
enum OrderStatus { Placed, Shipped, Cancelled }
enum ErrorCode { ReasonMissing, OrderNotFound, PermissionDenied, AlreadyShipped, AlreadyCancelled }
enum Stage { Validate, Load, Authorize, Rule }
sealed record User(string Id, bool IsAdmin);
sealed record CancelOrder(string OrderId, string? Reason);
sealed class Order(string ownerId, OrderStatus status)
{
public string OwnerId { get; } = ownerId;
public OrderStatus Status { get; set; } = status;
public string? CancelReason { get; set; }
}
sealed record Failure(ErrorCode Code, Stage Stage);
// Failure가 null이면 성공이다. T가 값 타입이면 실패 결과의 Value에는 default(T)가 들어 있다.
sealed record Result<T>(T? Value, Failure? Failure)
{
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(ErrorCode code, Stage stage) => new(default, new Failure(code, stage));
}
sealed class CancelOrderHandler(Dictionary<string, Order> orders)
{
public Result<OrderStatus> Handle(CancelOrder command, User actor)
{
// 호출 규약 위반은 프로그래밍 오류이므로 예외를 던진다.
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(actor);
// 1. 입력 검사: 저장소를 보지 않아도 판단할 수 있다.
// 빈 사유를 기본 문구로 바꾸지 않고 거절한다.
if (string.IsNullOrWhiteSpace(command.Reason))
return Result<OrderStatus>.Fail(ErrorCode.ReasonMissing, Stage.Validate);
// 2. 대상 조회
if (!orders.TryGetValue(command.OrderId, out var order))
return Result<OrderStatus>.Fail(ErrorCode.OrderNotFound, Stage.Load);
// 3. 권한: 주문 상태를 알려 주기 전에 확인한다.
if (order.OwnerId != actor.Id && !actor.IsAdmin)
return Result<OrderStatus>.Fail(ErrorCode.PermissionDenied, Stage.Authorize);
// 4. 업무 규칙
if (order.Status == OrderStatus.Shipped)
return Result<OrderStatus>.Fail(ErrorCode.AlreadyShipped, Stage.Rule);
if (order.Status == OrderStatus.Cancelled)
return Result<OrderStatus>.Fail(ErrorCode.AlreadyCancelled, Stage.Rule);
// 정상 경로: 모든 가드를 통과한 경우만 여기에 온다.
order.Status = OrderStatus.Cancelled;
order.CancelReason = command.Reason;
return Result<OrderStatus>.Ok(order.Status);
}
}alice cancels order-1: Ok Cancelled
alice cancels order-1: Err AlreadyCancelled at Rule
bob cancels order-2: Err PermissionDenied at Authorize
carol cancels order-2: Err AlreadyShipped at Rule
alice cancels order-9: Err OrderNotFound at Load
alice cancels order-3: Err ReasonMissing at Validate
alice cancels order-3: Err ReasonMissing at Validate
bug: ArgumentNullException (command)첫 요청이 주문을 취소했으므로 같은 요청을 다시 보내면 AlreadyCancelled가 나온다. 사유가 공백뿐인 요청과 사유가 없는 요청은 모두 ReasonMissing으로 거절된다. 대상 조회가 권한 검사보다 앞에 있으므로, 남의 주문 번호를 넣어 본 사용자는 OrderNotFound와 PermissionDenied의 차이로 그 주문이 있는지 알 수 있다. 존재 여부도 숨겨야 한다면 두 경우에 같은 오류 코드를 돌려준다. 처리기는 단일 스레드에서 메모리 사전을 바꾸므로 같은 주문에 대한 동시 요청은 막지 않는다. 데이터베이스라면 상태 검사와 갱신을 "상태가 Placed일 때만 Cancelled로 바꾼다" 같은 조건부 갱신 하나로 묶어야 한다. 예제의 Result는 Failure가 null인지로 성공을 가린다. T가 OrderStatus 같은 값 타입이면 실패한 결과의 Value에도 기본값 Placed가 들어 있으므로, 호출부는 Failure부터 확인해야 한다. record의 생성자가 공개돼 있어서 두 값을 모두 채운 결과도 만들 수 있다.
TypeScript 예제
TypeScript 컴파일러는 가드가 반환한 뒤의 코드에서 변수의 타입을 좁힌다. 핸드북은 선언된 타입을 더 구체적인 타입으로 다듬는 과정을 narrowing이라 부르고, return 뒤에 남은 본문이 어떤 타입일 때는 도달할 수 없다는 사실을 제어 흐름 분석으로 알아낸다고 설명한다.10 아래에서 this.orders.get()의 결과는 Order | undefined이지만 order === undefined 가드 뒤에서는 Order로 취급된다. 결과 타입은 ok 필드로 두 경우를 구분하는 판별 유니온이어서, 호출부의 if (!result.ok) { ...; continue; }도 가드처럼 작동해 그 아래에서 result.value를 쓸 수 있게 한다. Beck은 루프 안의 continue를 가드 클로즈의 변형으로 든다.11 C# 예제의 마지막 null 명령은 뺐다. --strict로 컴파일하면 CancelOrder 자리에 null을 넘기는 호출 자체가 컴파일되지 않는다.
type OrderStatus = "Placed" | "Shipped" | "Cancelled";
type ErrorCode = "ReasonMissing" | "OrderNotFound" | "PermissionDenied" | "AlreadyShipped" | "AlreadyCancelled";
type Stage = "Validate" | "Load" | "Authorize" | "Rule";
interface User { readonly id: string; readonly isAdmin: boolean; }
interface CancelOrder { readonly orderId: string; readonly reason?: string; }
interface Order { readonly ownerId: string; status: OrderStatus; cancelReason: string | null; }
// ok 필드로 성공과 실패를 구분하는 판별 유니온
type Result<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly code: ErrorCode; readonly stage: Stage };
const fail = (code: ErrorCode, stage: Stage): Result<never> => ({ ok: false, code, stage });
class CancelOrderHandler {
constructor(private readonly orders: Map<string, Order>) {}
handle(command: CancelOrder, actor: User): Result<OrderStatus> {
const reason = command.reason; // string | undefined
// 1. 입력 검사: 빈 사유를 기본 문구로 바꾸지 않고 거절한다.
if (reason === undefined || reason.trim() === "") return fail("ReasonMissing", "Validate");
// 여기부터 reason은 string으로 좁혀진다.
const order = this.orders.get(command.orderId); // Order | undefined
// 2. 대상 조회
if (order === undefined) return fail("OrderNotFound", "Load");
// 여기부터 order는 Order로 좁혀진다.
// 3. 권한: 주문 상태를 알려 주기 전에 확인한다.
if (order.ownerId !== actor.id && !actor.isAdmin) return fail("PermissionDenied", "Authorize");
// 4. 업무 규칙
if (order.status === "Shipped") return fail("AlreadyShipped", "Rule");
if (order.status === "Cancelled") return fail("AlreadyCancelled", "Rule");
// 정상 경로: reason은 string, order는 Order로 좁혀져 있어 단언이 필요 없다.
order.status = "Cancelled";
order.cancelReason = reason;
return { ok: true, value: order.status };
}
}
const orders = new Map<string, Order>([
["order-1", { ownerId: "alice", status: "Placed", cancelReason: null }],
["order-2", { ownerId: "alice", status: "Shipped", cancelReason: null }],
["order-3", { ownerId: "alice", status: "Placed", cancelReason: null }],
]);
const alice: User = { id: "alice", isAdmin: false };
const bob: User = { id: "bob", isAdmin: false };
const carol: User = { id: "carol", isAdmin: true };
const handler = new CancelOrderHandler(orders);
const requests: ReadonlyArray<readonly [CancelOrder, User]> = [
[{ orderId: "order-1", reason: "changed mind" }, alice],
[{ orderId: "order-1", reason: "changed mind" }, alice],
[{ orderId: "order-2", reason: "too slow" }, bob],
[{ orderId: "order-2", reason: "customer call" }, carol],
[{ orderId: "order-9", reason: "typo" }, alice],
[{ orderId: "order-3", reason: " " }, alice],
[{ orderId: "order-3" }, alice],
];
for (const [command, actor] of requests) {
const result = handler.handle(command, actor);
if (!result.ok) {
// 이 분기에서는 code와 stage만 쓸 수 있다.
console.log(`${actor.id} cancels ${command.orderId}: Err ${result.code} at ${result.stage}`);
continue;
}
console.log(`${actor.id} cancels ${command.orderId}: Ok ${result.value}`);
}alice cancels order-1: Ok Cancelled
alice cancels order-1: Err AlreadyCancelled at Rule
bob cancels order-2: Err PermissionDenied at Authorize
carol cancels order-2: Err AlreadyShipped at Rule
alice cancels order-9: Err OrderNotFound at Load
alice cancels order-3: Err ReasonMissing at Validate
alice cancels order-3: Err ReasonMissing at Validate출력은 C# 예제에서 마지막 bug 줄을 뺀 것과 같다. order 가드를 지우면 tsc --strict가 'order' is possibly 'undefined' 오류(TS18048)를 내고, reason 가드를 지우면 string | undefined를 string | null 필드에 대입한다는 오류(TS2322)를 낸다. 타입 좁히기는 컴파일 시점의 검사이므로, JSON처럼 실행 중에 들어오는 값은 따로 검증해야 한다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Kent Beck. Smalltalk Best Practice Patterns (1996, 판권 표기 1997). 출판사 페이지. 출간일 1996년 10월 3일. 목차 7장 Formatting의 Guard Clause 항목. ↩
- Kent Beck. Implementation Patterns (2007). 출판사 페이지. 목차 7장 Behavior, Guard Clause. 장 서두 요약은 InformIT 발췌 1쪽. ↩
- Kent Beck. Implementation Patterns 발췌, Guard Clause 절. InformIT (2008). ↩
- Martin Fowler. Replace Nested Conditional with Guard Clauses. Refactoring 카탈로그. 카탈로그가 2판을 위한 것이며 1판 리팩터링도 담고 있다는 설명은 카탈로그 첫 페이지, 출간 연도는 책 소개 페이지. ↩
- Kent Beck. Implementation Patterns 발췌, Guard Clause 절. 단일 입구·단일 출구 규칙에 관한 문단. ↩
- Microsoft Learn. Exception-handling statements. try-finally 절. ↩
- Microsoft Learn, Framework Design Guidelines. Using Standard Exception Types, Exception Throwing. 2판(2008) 내용을 옮긴 문서다. ↩
- Microsoft Learn API 문서. ArgumentNullException.ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty, ArgumentOutOfRangeException.ThrowIfNegative, CallerArgumentExpressionAttribute. 버전은 각 문서의 적용 대상 기준. ↩
- Microsoft Learn, Framework Design Guidelines. Exception Throwing("DO NOT return error codes", "DO NOT use exceptions for the normal flow of control, if possible" 항목), Exceptions and Performance(Tester-Doer, Try-Parse 패턴). ↩
- TypeScript Handbook. Narrowing. Control flow analysis 절. ↩
- Kent Beck. Implementation Patterns 발췌, Guard Clause 절. 루프의 continue 문에 관한 문단. ↩