7~9장: 좋은 루틴 · 방어적 프로그래밍 · PPP
한눈에
- 책 7~9장을 한 자리에서 다뤘어요
- 챕터 네비:
세 장을 한 호흡으로 묶어서 읽으면, "루틴 → 방어 → 사전 설계"라는 한 흐름이 잡혀요.
📝 묶음 요약
7~9장은 "코드 한 단위를 어떻게 잘 만들 것인가"를 세 각도로 좁혀 들어가는 묶음이에요. 7장은 좋은 루틴(함수)의 기준을, 8장은 그 루틴이 외부와 만나는 경계에서의 방어 전략을, 9장은 코딩에 들어가기 전 의사코드로 설계를 검증하는 절차를 다뤄요.
- 7장 — 추상화 수준을 맞추고 부작용 경계를 분명히 하라는 원칙은 함수형 컴포넌트와 커스텀 훅에서도 그대로 통해요.
- 8장 — "어디까지 방어할 것인가"는 TypeScript와 Zod가 깔린 환경에서 답이 달라져요. 모든 곳이 아니라 경계에서만 검증하는 바리케이드 전략이 필요해요.
- 9장 — 코드를 쓰기 전 의사코드로 단계를 나열하는 절차는 유효하지만, 2026년 FE에서는 타입 정의가 같은 역할을 더 정직하게 하기도 해요.
큰 그림을 훑었으니, 각 장으로 들어가서 판정·체크·코드·반대 관점을 차례로 짚어 볼게요.
Chapter 7. High-Quality Routines
📖 원문 핵심
루틴을 만드는 가장 중요한 이유: 복잡도를 줄이기 위해서예요. 루틴 안에 세부 구현을 감춰두면, 호출하는 쪽에서는 내부 동작을 몰라도 사용할 수 있어요.
응집도(Cohesion): 루틴 내 연산들이 얼마나 밀접하게 관련돼 있는지를 나타내는 척도예요. 가장 좋은 형태는 루틴이 딱 하나의 일만 수행하는 기능적 응집(Functional Cohesion)이에요.
루틴 이름은 하는 일 전체를 서술해야 해요: 이름이 길고 어색해진다면 루틴 자체에 부작용(side effect)이 있다는 신호이므로, 이름을 억지로 줄이기보다 루틴을 다시 설계하는 게 맞아요.
루틴 길이: 연구 결과들을 종합하면 200줄 이내가 안전한 기준이에요. 길이 자체보다 응집도·중첩 깊이·변수 수 같은 복잡도 지표가 적절한 길이를 결정해요.
매개변수는 7개 이하로: 심리학 연구에 따르면 인간이 동시에 추적할 수 있는 정보 덩어리는 약 7개예요. 그 이상이 필요하다면 루틴 간 결합이 너무 강하다는 설계 신호예요.
✅ 체크리스트
- 함수/훅 이름이 "이것이 무엇을 하는지"를 완전히 설명하나요? 이름 짓기가 어렵다면 함수가 너무 많은 일을 하고 있다는 신호예요.
- 하나의 함수가 하나의 일만 하나요? (기능적 응집도) — fetchAndFormatAndSave() 같은 이름이 나온다면 분리 대상이에요.
- 관련 ESLint:
max-lines-per-function
- 관련 ESLint:
- 함수 파라미터가 7개를 넘지 않나요? 많다면 객체로 묶거나 책임 분리를 검토했나요?
- 관련 ESLint:
max-params
- 관련 ESLint:
- 함수의 반환값이 모든 경로에서 유효한가요? undefined가 암묵적으로 반환되는 경로가 없나요?
- 관련 ESLint:
consistent-return
- 관련 ESLint:
- 부수 효과(side effect)가 있다면 함수 이름에 드러나거나 문서화되어 있나요?
원칙만으로는 모호할 수 있어요. 루틴 설계를 실제 코드로 따라가 볼게요.
💻 React/TS 코드 예제
기본 미션 — 추상화 수준 맞추기 (loadAndShapePayments)
Before ❌
type ApiPayment = {
id: string;
status: 'paid' | 'failed' | 'refunded';
amount: number;
paidAt: string;
customerName: string;
};
export async function loadAndShapePayments(
storeId: string,
q: string,
status: 'all' | ApiPayment['status'],
page: number,
) {
const res = await fetch(`/api/payments?storeId=${storeId}&q=${encodeURIComponent(q)}&status=${status}&page=${page}`);
const json = await res.json();
const items = (json.items ?? []) as ApiPayment[];
const filtered = status === 'all' ? items : items.filter((p) => p.status === status);
const searched = q ? filtered.filter((p) => p.customerName.includes(q)) : filtered;
return searched.map((p) => ({
...p,
amountText: `${p.amount.toLocaleString()}원`,
paidAtText: new Date(p.paidAt).toLocaleString(),
}));
}
Before의 문제
- URL 조립(How) 과 데이터 패칭(What) 이 같은 줄에 있다. encodeURIComponent, 쿼리 문자열 직접 결합은 "어떻게 요청을 보내는가"이고, 함수의 목적은 "결제 내역을 가져온다"이다.
- 배열 필터링(How) 과 뷰 변환(How) 이 패칭 직후에 한 덩어리로 이어진다. "무엇을 하는가"(필터 → 검색 → 포맷)가 구현 디테일에 묻힌다.
After ✅
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
type PaymentStatus = 'paid' | 'failed' | 'refunded';
type StatusFilter = PaymentStatus | 'all';
interface ApiPayment {
id: string;
status: PaymentStatus;
amount: number;
paidAt: string;
customerName: string;
}
interface PaymentView extends ApiPayment {
amountText: string;
paidAtText: string;
}
interface PaymentQuery {
storeId: string;
query?: string;
status?: StatusFilter;
page?: number;
}
// ━━ API 레이어: 서버에서 데이터를 가져온다 ━━
async function fetchPayments(params: PaymentQuery): Promise<ApiPayment[]> {
const searchParams = new URLSearchParams({
storeId: params.storeId,
q: params.query ?? '',
status: params.status ?? 'all',
page: String(params.page ?? 1),
});
const res = await fetch(`/api/payments?${searchParams}`);
const json = await res.json();
return (json.items ?? []) as ApiPayment[];
}
// ━━ 도메인 로직: 필터링 (순수 함수) ━━━━━━━━
function filterByStatus(payments: ApiPayment[], status: StatusFilter): ApiPayment[] {
if (status === 'all') return payments;
return payments.filter((p) => p.status === status);
}
function filterByCustomerName(payments: ApiPayment[], query: string): ApiPayment[] {
if (!query) return payments;
return payments.filter((p) => p.customerName.includes(query));
}
// ━━ 표현 로직: 뷰 모델 변환 (순수 함수) ━━━━
function toPaymentView(payment: ApiPayment): PaymentView {
return {
...payment,
amountText: `${payment.amount.toLocaleString()}원`,
paidAtText: new Date(payment.paidAt).toLocaleString(),
};
}
// ━━ 조합: 이름만 읽으면 흐름이 보인다 ━━━━━
export async function loadPayments(params: PaymentQuery): Promise<PaymentView[]> {
const raw = await fetchPayments(params);
const byStatus = filterByStatus(raw, params.status ?? 'all');
const byName = filterByCustomerName(byStatus, params.query ?? '');
return byName.map(toPaymentView);
}
개선 포인트
- loadPayments의 본문 4줄만 읽으면 "패칭 → 상태 필터 → 이름 검색 → 뷰 변환"이라는 흐름(What) 이 바로 보인다.
- 각 하위 함수가 How를 캡슐화하여, loadPayments는 추상화 수준이 균일하다.
- 파라미터 4개 나열 → PaymentQuery 객체로 묶어서 순서 실수를 방지하고, query/status/page에 기본값 정책이 명시됨.
심화 미션 1 — 부작용 경계 설정 (saveMemoAndNotify)
Before ❌
export async function saveMemoAndNotify(paymentId: string, memo: string) {
await fetch(`/api/payments/${paymentId}/memo`, {
method: 'PUT',
body: JSON.stringify({ memo }),
});
alert('저장 완료');
console.log('memo_saved', { paymentId });
}
Before의 책임 섞임: HTTP 저장·사용자 알림(alert)·로깅이 한 함수에 같이 있다.
After ✅
// ━━ 서비스 레이어: 저장만 담당 ━━━━━━━━━━━━
export async function saveMemo(paymentId: string, memo: string): Promise<void> {
const res = await fetch(`/api/payments/${paymentId}/memo`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ memo }),
});
if (!res.ok) throw new Error(`Failed to save memo: ${res.status}`);
}
// ━━ UI 레이어: 호출자가 알림/로깅을 결정 ━━━
function useMemoSave(paymentId: string) {
const [saving, setSaving] = useState(false);
const save = async (memo: string) => {
setSaving(true);
try {
await saveMemo(paymentId, memo);
toast.success('저장 완료');
logger.info('memo_saved', { paymentId });
} catch (e) {
toast.error('저장에 실패했습니다');
} finally {
setSaving(false);
}
};
return { save, saving };
}
경계 기준: saveMemo는 "메모를 서버에 저장한다"라는 한 문장으로 정의 가능한 순수 서비스 함수. 알림(toast)과 로깅(logger)은 호출하는 UI 레이어(useMemoSave)가 책임진다. 이렇게 하면 같은 saveMemo를 배치 처리에서 알림 없이 호출하거나, 다른 화면에서 다른 메시지로 알림을 보여줄 수 있다.
심화 미션 2 — 오류 모델을 루틴에 녹이기 (refund)
Before ❌
export async function refund(paymentId: string, amount: number) {
const res = await fetch(`/api/refund`, {
method: 'POST',
body: JSON.stringify({ paymentId, amount }),
});
if (!res.ok) throw new Error('refund failed');
return res.json();
}
After ✅
// ━━ 실패 모델: 네트워크 vs 비즈니스 분리 ━━━
type RefundResult =
| { ok: true; refundId: string; refundedAmount: number }
| { ok: false; type: 'network'; message: string }
| { ok: false; type: 'insufficient_balance'; available: number }
| { ok: false; type: 'already_refunded' }
| { ok: false; type: 'unknown'; message: string };
// ━━ API 응답 타입 ━━━━━━━━━━━━━━━━━━━━━━━━━━
interface RefundApiSuccess {
refundId: string;
refundedAmount: number;
}
interface RefundApiError {
code: 'INSUFFICIENT_BALANCE' | 'ALREADY_REFUNDED' | string;
message: string;
available?: number;
}
// ━━ 환불 요청 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
export async function refund(paymentId: string, amount: number): Promise<RefundResult> {
let res: Response;
try {
res = await fetch('/api/refund', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId, amount }),
});
} catch {
return { ok: false, type: 'network', message: '서버에 연결할 수 없습니다' };
}
if (res.ok) {
const data: RefundApiSuccess = await res.json();
return { ok: true, refundId: data.refundId, refundedAmount: data.refundedAmount };
}
const error: RefundApiError = await res.json().catch(() => ({ code: 'UNKNOWN', message: '알 수 없는 오류' }));
switch (error.code) {
case 'INSUFFICIENT_BALANCE':
return { ok: false, type: 'insufficient_balance', available: error.available ?? 0 };
case 'ALREADY_REFUNDED':
return { ok: false, type: 'already_refunded' };
default:
return { ok: false, type: 'unknown', message: error.message };
}
}
// ━━ 호출자: exhaustive checking 가능 ━━━━━━━
async function handleRefund(paymentId: string, amount: number) {
const result = await refund(paymentId, amount);
if (result.ok) {
toast.success(`환불 완료: ${result.refundedAmount.toLocaleString()}원`);
return;
}
switch (result.type) {
case 'network':
toast.error('네트워크 오류. 다시 시도해주세요.');
break;
case 'insufficient_balance':
toast.error(`잔액 부족: ${result.available.toLocaleString()}원까지 가능`);
break;
case 'already_refunded':
toast.error('이미 환불된 결제입니다.');
break;
case 'unknown':
toast.error(result.message);
break;
// TypeScript가 여기서 never 체크 → 새 타입 추가 시 컴파일 에러
default: {
const _exhaustive: never = result;
throw new Error(`Unhandled refund error: ${JSON.stringify(_exhaustive)}`);
}
}
}
개선 포인트
- Before는 throw new Error("refund failed") 하나뿐이라, 호출자가 "잔액 부족인지 / 이미 환불인지 / 네트워크 끊김인지" 구분할 수 없다.
- After는 RefundResult 유니온으로 모든 실패를 타입 레벨에서 열거한다. 호출자가 switch (result.type)으로 분기하면 TypeScript가 빠진 케이스를 잡아준다(never 체크).
- 네트워크 에러(fetch 자체 실패)와 비즈니스 에러(서버 응답 4xx)가 구조적으로 분리되어, "재시도 가능한 오류"와 "사용자에게 안내해야 하는 오류"를 호출자가 쉽게 구분 가능.
😈 Devil's Advocate
좋은 루틴을 만들었다면, 그 루틴이 외부 입력을 어떻게 받아들일지가 다음 질문이에요. 8장으로 이어 볼게요.
Chapter 8. Defensive Programming
📖 원문 핵심
방어적 프로그래밍의 전제: 프로그램에 잘못된 데이터가 들어와도 손상되지 않아야 한다는 아이디어로, "garbage in, garbage out"은 오늘날 기준으로 허술하고 불안전한 프로그램의 징표예요.
Assertion(단언문): 개발 중에 코드가 스스로를 검증하는 수단으로, 절대 발생해서는 안 되는 조건을 체크하며 전제조건과 사후조건을 문서화하는 실행 가능한 명세 역할을 해요. Assertion은 버그를 잡는 것이고 에러 처리 코드는 예상 가능한 이상 상황을 처리하는 것으로, 둘은 목적이 다르므로 혼용해서는 안 돼요.
Robustness vs. Correctness: Correctness는 부정확한 결과를 절대 반환하지 않는 것이고, Robustness는 소프트웨어가 계속 동작하게 하는 것으로, 안전 최우선 시스템은 Correctness를, 소비자용 애플리케이션은 Robustness를 우선시해요.
Exception(예외) 사용 원칙: 예외는 다른 방법으로 처리할 수 없는 진정으로 예외적인 조건에만 사용해야 하며, 예외의 추상화 수준은 해당 루틴 인터페이스 수준과 일치해야 하고 빈 catch 블록은 금지예요.
바리케이드(Barricade) 패턴: 시스템을 "안전 구역"과 "외부 구역"으로 나누어, 경계에서 데이터를 검증하고 정제하면 내부 코드는 데이터가 깨끗하다고 가정할 수 있어 에러 처리 부담이 줄어들어요.
✅ 체크리스트
- 외부 데이터(API 응답, URL 파라미터, 유저 입력)를 받는 지점에 런타임 검증(Zod, Valibot 등)이 있나요?
- "바리케이드" 안쪽(검증된 데이터만 다루는 영역)과 바깥쪽(검증 전 데이터)이 구분되어 있나요?
- Error Boundary가 적절한 단위로 배치되어 있어서, 하나의 컴포넌트 에러가 전체 앱을 죽이지 않나요?
- 개발 환경에서만 동작하는 검증(strict mode, 개발용 경고)을 적극 활용하고 있나요?
- 관련 ESLint:
no-empty
- 관련 ESLint:
- catch 블록이 비어 있는 곳이 없나요? 에러를 삼키지 않고 최소한 로깅하고 있나요?
- 관련 ESLint:
no-empty - 보조 ESLint:
@typescript-eslint/switch-exhaustiveness-check
- 관련 ESLint:
"어디까지 방어할 것인가"는 추상적이에요. 입력 검증·어설션·오류 처리·과도한 방어의 네 각도에서 코드로 풀어 볼게요.
💻 React/TS 코드 예제
기본 미션 1 — 입력 검증 (RefundForm)
Before ❌
function RefundForm() {
const [amount, setAmount] = useState("");
async function handleRefund() {
await fetch("/api/refund", {
method: "POST",
body: JSON.stringify({
amount: Number(amount),
}),
});
}
return (
<div>
<input value={amount} onChange={(e) => setAmount(e.target.value)} />
<button onClick={handleRefund}>환불</button>
</div>
);
}
Before의 문제
- 빈 문자열 → Number("") → 0 → 0원 환불 요청
- 문자 입력 → Number("abc") → NaN → 서버에 NaN 전송
- 음수 입력 → 1000 → 마이너스 환불
- 결제 금액 초과 → 한도 없이 아무 금액이나 전송
After ✅
// ━━ 검증 로직: 순수 함수 ━━━━━━━━━━━━━━━━━━
interface RefundValidation {
valid: boolean;
error?: string;
}
function validateRefundAmount(input: string, maxAmount: number): RefundValidation {
const trimmed = input.trim();
if (!trimmed) {
return { valid: false, error: "금액을 입력해주세요" };
}
const amount = Number(trimmed);
if (Number.isNaN(amount)) {
return { valid: false, error: "숫자만 입력 가능합니다" };
}
if (amount <= 0) {
return { valid: false, error: "0보다 큰 금액을 입력해주세요" };
}
if (amount > maxAmount) {
return { valid: false, error: `최대 ${maxAmount.toLocaleString()}원까지 가능합니다` };
}
return { valid: true };
}
// ━━ API 호출: 검증된 값만 받는다 ━━━━━━━━━━━
async function requestRefund(amount: number): Promise<void> {
const res = await fetch("/api/refund", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error(`Refund failed: ${res.status}`);
}
// ━━ UI: 검증 결과에 따라 피드백 ━━━━━━━━━━━
function RefundForm({ maxAmount }: { maxAmount: number }) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleRefund = async () => {
const validation = validateRefundAmount(input, maxAmount);
if (!validation.valid) {
setError(validation.error ?? null);
return;
}
setError(null);
setSubmitting(true);
try {
await requestRefund(Number(input));
} catch {
setError("환불 처리에 실패했습니다");
} finally {
setSubmitting(false);
}
};
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="환불 금액"
inputMode="numeric"
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleRefund} disabled={submitting}>
환불
</button>
</div>
);
}
기본 미션 2 — 어설션 추가 (calculateDiscount)
Before ❌
function calculateDiscount(price: number, rate: number) {
return price * (1 - rate);
}
After ✅
function calculateDiscount(price: number, rate: number): number {
console.assert(price >= 0, `price must be >= 0, got ${price}`);
console.assert(rate >= 0 && rate <= 1, `rate must be 0~1, got ${rate}`);
return price * (1 - rate);
}
후기 포인트
- console.assert는 조건이 false일 때 콘솔에 에러를 출력하지만 실행을 멈추지 않는다. 이게 핵심인데, 어설션은 "이게 터지면 호출한 쪽 코드에 버그가 있다"는 신호이지, 프로그램을 중단시키는 예외 처리가 아니기 때문이다. rate에 50을 넘긴 개발자가 잘못한 거지, 사용자에게 에러 화면을 보여줄 일이 아니다.
- 프로덕션 빌드에서 console.assert를 strip하는 것도 자연스럽다 — 이미 개발 중에 버그를 잡는 용도이므로.
기본 미션 3 — 오류 처리 (fetchPayments)
Before ❌
async function fetchPayments() {
const res = await fetch('/api/payments');
return res.json();
}
Before의 문제
- 네트워크 단절: fetch 자체가 TypeError를 throw → 처리 없이 상위로 전파.
- 서버 에러 (5xx, 4xx): res.ok가 false인데 체크 없이 res.json() 호출 → 에러 응답을 정상 데이터로 파싱 시도.
- 파싱 실패: 서버가 JSON이 아닌 응답(HTML 에러 페이지 등)을 보내면 res.json()이 throw.
After ✅
interface Payment {
id: string;
amount: number;
status: string;
}
interface PaymentsResponse {
items: Payment[];
}
type FetchResult = { ok: true; payments: Payment[] } | { ok: false; error: 'network' | 'server' | 'parse' };
async function fetchPayments(): Promise<FetchResult> {
let res: Response;
try {
res = await fetch('/api/payments');
} catch {
return { ok: false, error: 'network' };
}
if (!res.ok) {
return { ok: false, error: 'server' };
}
try {
const data: PaymentsResponse = await res.json();
return { ok: true, payments: data.items };
} catch {
return { ok: false, error: 'parse' };
}
}
개선 포인트: After는 세 가지 오류를 FetchResult 유니온으로 구분하여, 호출자가 switch (result.error)로 각각 다르게 대응할 수 있다.
심화 미션 — 어디까지 방어해야 할까? (getUserName A안 vs B안)
Before ❌ (A안 — 과도한 방어)
// A안 — 과도한 방어 ❌
function getUserName(user) {
if (!user) return '';
if (!user.name) return '';
return user.name;
}
After ✅ (B안 — 타입 신뢰)
// B안 — 타입 신뢰 ✅
interface User {
name: string;
}
function getUserName(user: User): string {
return user.name;
}
개선 포인트
- A안의 방어 코드는 "caller가 null이나 undefined를 넘길 수 있다"는 가정을 함수 내부에서 흡수한다. 이렇게 하면 잘못된 호출이 조용히 빈 문자열로 통과되어, 진짜 버그(유저 데이터 로딩 실패 등)가 숨겨진다.
- B안은 TypeScript가 호출 시점에 user: User 타입을 강제한다. null을 넘기면 컴파일 에러가 나서 버그가 호출부에서 바로 드러난다. 타입 시스템이 있는 환경에서 방어적 프로그래밍의 역할은 "함수 내부에서 모든 가능성을 체크"하는 것이 아니라, 타입으로 보장할 수 없는 경계(사용자 입력, 외부 API 응답, JSON 파싱)에서만 수행하는 것이다.
😈 Devil's Advocate
방어 위치를 정했다면, 코딩에 들어가기 직전 한 단계를 더 둘 수 있어요. 9장의 PPP가 그 자리예요.
Chapter 9. The Pseudocode Programming Process
📖 원문 핵심
PPP (Pseudocode Programming Process): 루틴을 슈도코드로 설계한 뒤 그 슈도코드를 주석으로 유지하면서 실제 코드를 채워 넣는 루틴 구성 방법론이에요.
슈도코드 작성 원칙: 특정 언어 문법에 종속되지 않는 자연어로 작성하고, "어떻게"가 아니라 "무엇을"에 집중해 의도 수준에서 기술해야 해요. 일단 코드를 작성하면 설계를 버리기가 심리적으로 훨씬 어려워지므로, 슈도코드 단계에서 충분히 다듬어야 해요.
슈도코드 → 고수준 주석 전환: 슈도코드 각 문장을 코드 내 주석으로 남기고, 그 주석 아래에 실제 구현 코드를 채워 넣는 방식으로 진행해요. 주석 하나가 약 2~10줄의 코드에 대응해요.
코드 확인(Check the Code): 구현 완료 후 정신적 실행(mental walkthrough), 컴파일, 디버거 단계 실행, 테스트를 순서대로 수행하며 각 단계에서 오류를 잡아내야 해요.
코드 이해 의무: 코드가 동작하는 것으로는 충분하지 않으며, 왜 동작하는지 이해하지 못한다면 이해할 때까지 공부하고 대안 설계를 실험해야 해요.
✅ 체크리스트
- 복잡한 컴포넌트/훅을 구현하기 전에 타입 정의(interface/type)를 먼저 작성하여 설계를 검증하고 있나요?
- "어떻게 구현할지" 코드를 바로 쓰기보다, "무엇을 해야 하는지" 단계를 주석이나 TODO로 먼저 나열해 본 적이 있나요?
- 구현 중 기존 라이브러리나 팀 내 유틸에 이미 있는 기능인지 확인하고 있나요?
- 첫 번째 구현에 만족하지 않고, 더 나은 구조가 있는지 한 번 더 검토하나요?
의사코드가 어떻게 코드로 번역되는지, 두 미션으로 따라가 볼게요.
💻 React/TS 코드 예제
기본 미션 — 의사코드로 장바구니 가격 계산
Pseudocode
장바구니 총액 계산:
1. 각 상품의 (가격 × 수량)을 합산하여 소계를 구한다
2. 쿠폰이 있으면:
- 정률이면: 소계 × (1 - 할인율)
- 정액이면: 소계 - 할인액
- 결과가 음수면 0으로 보정
3. 최종 금액을 반환한다
After ✅
interface CartItem {
price: number;
qty: number;
}
type Coupon = { type: 'rate'; value: number } | { type: 'flat'; value: number };
// 1. 소계 합산
function calcSubtotal(items: CartItem[]): number {
let subtotal = 0;
for (const item of items) {
subtotal += item.price * item.qty;
}
return subtotal;
}
// 2. 쿠폰 적용
function applyCoupon(subtotal: number, coupon?: Coupon): number {
if (!coupon) return subtotal;
const discounted = coupon.type === 'rate' ? subtotal * (1 - coupon.value) : subtotal - coupon.value;
return Math.max(discounted, 0);
}
// 3. 최종 금액
function calcCartTotal(items: CartItem[], coupon?: Coupon): number {
const subtotal = calcSubtotal(items);
return applyCoupon(subtotal, coupon);
}
의사코드를 먼저 작성했을 때의 장점: 코드를 쓰기 전에 "1→2→3" 단계가 확정되어 있으므로, 함수 분리 기준이 의사코드의 번호와 1:1 대응된다. "이 함수가 왜 이렇게 나뉘어 있는가?"의 답이 의사코드에 이미 있다.
심화 미션 — 의사코드 기반 리팩터링 (getVisiblePayments)
Before ❌
function getVisiblePayments(payments, status, query) {
let result = payments;
if (status !== 'all') {
result = result.filter((p) => p.status === status);
}
if (query) {
result = result.filter((p) => p.customerName.includes(query));
}
return result;
}
Pseudocode
보이는 결제 목록 구하기:
1. 전체 목록에서 시작
2. 상태 필터가 지정되어 있으면 해당 상태만 남긴다
3. 검색어가 있으면 고객명에 포함된 것만 남긴다
4. 필터링된 결과를 반환한다
After ✅
type PaymentStatus = 'paid' | 'failed' | 'refunded';
type StatusFilter = PaymentStatus | 'all';
interface Payment {
id: string;
status: PaymentStatus;
customerName: string;
amount: number;
}
// 2. 상태 필터
function filterByStatus(payments: Payment[], status: StatusFilter): Payment[] {
if (status === 'all') return payments;
return payments.filter((p) => p.status === status);
}
// 3. 검색어 필터
function filterByCustomerName(payments: Payment[], query: string): Payment[] {
if (!query) return payments;
return payments.filter((p) => p.customerName.includes(query));
}
// 1→2→3→4 조합
function getVisiblePayments(payments: Payment[], status: StatusFilter, query: string): Payment[] {
const byStatus = filterByStatus(payments, status);
const byName = filterByCustomerName(byStatus, query);
return byName;
}
개선 포인트: Before와 로직은 동일하지만, 타입이 추가되고 각 필터 조건이 독립 함수로 분리되어 단독 테스트가 가능해졌다. getVisiblePayments 본문은 의사코드를 그대로 읽는 것과 같다.
😈 Devil's Advocate
원칙은 도구일 뿐이에요. 실무에서 7명이 어떻게 쓰고 있는지 이어서 들어 볼게요.
💬 토론 질문 & 멤버 의견
질문 1. 입력 검증의 책임은 어디에 있나? UI? API 호출 직전? 서버? 셋 다 한다면 무엇이 중복인가?
셋 다 해야 하지만 역할이 달라서 중복이 아니라고 생각함. UI는 사용자에게 즉각적인 피드백을 주기 위함(빨간 테두리, 버튼 비활성화), API 호출 직전은 잘못된 요청을 애초에 안 보내는 게이트, 서버는 최종 방어선임. UI 검증은 사용자가 우회할 수 있으니 신뢰할 수 없고, 서버 검증만 있으면 사용자 경험이 너무 늦게 망가짐. 같은 규칙이지만 목적이 다르기 때문에 중복이 아니라 계층별 책임이라고 봄.
세 레이어 모두 검증하되 책임의 성격이 다름. UI는 사용자 경험 향상/피드백, API 호출 전은 불필요한 네트워크 호출 방지와 명백한 오류 차단, 서버는 시스템 무결성을 보장하는 최종 바리케이드.
셋 다 해야 함. 단 역할이 다름 — UI는 즉각 피드백(우회 가능하니 "안내" 수준), API 호출 전은 잘못된 요청을 보내지 않게 하는 게이트, 서버는 최종 방어선(Postman으로 직접 때릴 수 있으니 무조건 검증).
질문 2. assertion과 예외처리의 차이. 우리는 둘을 어떻게 구분해서 쓰고 있나?
assertion은 "이 코드에 도달했다면 이 조건은 무조건 참이어야 한다"는 개발자의 전제를 명시하는 도구. 터지면 그건 사용자나 외부 환경 문제가 아니라 코드 버그라는 신호. 반면 예외 처리는 서버 다운, 네트워크 끊김, 잘못된 사용자 입력처럼 정상 흐름에서도 충분히 일어날 수 있는 상황을 다룸. 그래서 외부 입력이 들어오는 경계(API 응답, 사용자 입력)에는 예외 처리를, 내부 함수끼리 호출할 때 "이건 당연히 지켜질 거야"라는 전제에는 assertion이 맞다고 생각함. 현업 경험: 500 에러처럼 서버 자체가 죽었거나 복구 불가능한 상황은 에러 페이지로 보내고, 400 에러처럼 사용자 입력이나 비즈니스 규칙 때문에 발생한 복구 가능한 케이스는 모달로 안내함.
(이번 주 추가 공부 주제로 깊이 파봄) 어설션은 프로그래머의 실수(버그)를 잡는 도구. 사용자/외부 환경 문제가 아니라 개발자가 논리를 잘못 짠 것을 잡아냄. 정상적으로 동작하는 프로그램에서는 절대 터지면 안 되고, 프로덕션에서는 비활성화 가능. 예외 처리는 외부 요인에 의한 실패를 다루는 도구 — 서버 다운, 네트워크 끊김처럼 정상 프로그램에서도 충분히 발생할 수 있는 상황. 비유하자면 어설션은 공장 출하 전 품질 검사, 예외는 운전 중 대시보드 경고등. 현업 경험: Medium의 "Assertions are not Exceptions!!"와 Inspired Python 글, Stefan Scherfke의 글을 정리해서 팀에 공유함.
책에서 어설션은 "절대 일어나면 안 되는 상황"을 잡는 용도라고 했으니, 외부 입력이 들어오는 곳에서는 어설션 대신 정상적인 에러 처리를 해야 함. 어설션은 내부 함수끼리 호출할 때 "이건 당연히 지켜질 거야"라는 전제를 명시하는 데 맞는 도구.
질문 3. UI 오류 vs 네트워크 오류. 각자 처리 위치(try/catch / Error Boundary / 상태 기반)의 기준은?
UI 오류는 Error Boundary — 실패할 경우 대체 UI를 보여줄 수 있으니까. 네트워크 오류는 try/catch — 네트워크 응답값의 상태 기반이니까.
UI 오류는 Error Boundary가 가장 적합. try/catch는 이벤트 핸들러나 비동기 함수에서는 동작하지만 렌더링 과정의 에러를 잡지 못함(React 렌더링은 try/catch로 감쌀 수 있는 동기 호출이 아니기 때문). 중요한 설계 판단은 Error Boundary를 어디에 몇 개 배치할 것인가 — 핵심 기능 단위로 배치하는 것이 균형점.
렌더링 중 터지는 에러는 Error Boundary, 이벤트 핸들러/비동기 코드는 따로 처리. 페이지 단위로 Error Boundary 감싸고 fallback에서 "다시 시도" 버튼을 주는 게 무난함. 네트워크 오류는 React Query/SWR의 isError로 관리하고, 401 인증 만료 같은 전역 처리는 axios interceptor/fetch wrapper에서 한 번에 처리. 현업 경험: 과거에 SentryErrorBoundary를 최상단에 올려 렌더링/네트워크 에러를 한 번에 처리했지만, 두 계층이 분리되어 있으니 좋은 방법이 아니었다고 회고.
의견은 저마다 다르지만, 세 장이 한 흐름으로 이어진다는 감각은 공유돼요.