diff --git "a/ch10-\354\241\260\352\261\264\353\266\200_\353\241\234\354\247\201_\352\260\204\354\206\214\355\231\224/\353\260\225\354\204\240\355\231\224.md" "b/ch10-\354\241\260\352\261\264\353\266\200_\353\241\234\354\247\201_\352\260\204\354\206\214\355\231\224/\353\260\225\354\204\240\355\231\224.md" new file mode 100644 index 0000000..0855aad --- /dev/null +++ "b/ch10-\354\241\260\352\261\264\353\266\200_\353\241\234\354\247\201_\352\260\204\354\206\214\355\231\224/\353\260\225\354\204\240\355\231\224.md" @@ -0,0 +1,1042 @@ +# 10장 조건부 로직 간소화 + +1. 복잡한 조건문에는 **조건문 분해하기** +2. 논리적 조합을 명확하게 다듬는 데는 **중복 조건식 통합하기** +3. 함수의 핵심 로직에 들어가기 앞서 무언가를 검사해야 할 때는 **중첩 조건문을 보호 구문으로 바꾸기** +4. 똑같은 분기 로직(주로 switch문)이 여러 곳에 등장한다면 **조건부 로직을 다향성으로 바꾸기** +5. 특이 케이스를 처리하는 조건부 로직이 거의 똑같다면 **특이 케이스 추가하기(널 객체 추가하기)**를 적용해 중복 줄이기 +6. 프로그램의 상태를 확인하고 그 결과에 다라 다르게 동작해야 하는 상황이면 **어서션 추가하기** +7. 제어 플래그를 이용해 코드 동작 흐름을 변경하는 코드는 **제어 플래그를 탈출문으로 바꾸기**를 적용해 간소화 + +## (1) 조건문 분해하기 + +복잡한 조건부 로직은 코드를 이해하기 어렵게 만드는 주요 원인 중 하나이다. 긴 함수가 문제인 것처럼, 긴 조건문도 동일한 문제를 야기한다다. 조건을 검사하고 분기하는 코드는 **"무엇을 하는지"**는 알려주지만 **"왜 하는지"**는 알려주지 않는다. + +거대한 코드 블록이 주어지면 코드를 부위별로 분해한 다음 해체된 코드 덩어리들을 각 덩어리의 의도를 살린 이름의 함수 호출로 바꿔주자. 그러면 전체적인 의도가 더 잘 드러난다. 이렇게 하면 해당 조건이 무엇인지 강조하고, 그래서 무엇을 분기했는지가 명백해진다. 분기한 이유 역시 더 명확해진다. + +**적용 시점** + +- 조건식이 복잡하여 한눈에 이해하기 어려울 때 +- 조건의 의도가 불분명할 때 +- then/else 절의 로직이 길어 전체 흐름 파악이 어려울 때 + +### 예제 - 요금 계산 + +```tsx +// ❌ 리팩토링 전 +function calculateCharge_before(date: Date, quantity: number, plan: Plan): number { + let charge: number; + + if (date.getMonth() >= 6 && date.getMonth() <= 8) { + // 여름철 요금 + charge = quantity * plan.summerRate; + } else { + // 일반 요금 + charge = quantity * plan.regularRate + plan.regularServiceCharge; + } + + return charge; +} + +// ✅ 리팩토링 후 +function calculateCharge(date: Date, quantity: number, plan: Plan): number { + return isSummer(date) + ? summerCharge(quantity, plan) + : regularCharge(quantity, plan); +} + +function isSummer(date: Date): boolean { + return date.getMonth() >= 6 && date.getMonth() <= 8; +} + +function summerCharge(quantity: number, plan: Plan): number { + return quantity * plan.summerRate; +} + +function regularCharge(quantity: number, plan: Plan): number { + return quantity * plan.regularRate + plan.regularServiceCharge; +} +``` + +## (2) 조건식 통합하기 + +비슷한 결과를 내는 조건 검사가 여러 개 나열되어 있으면, 하나로 통합하는 것이 좋다. 'and' 또는 'or' 연산자를 사용해 **여러 조건을 하나로 합치면** 다음과 같은 이점이 있다. + + - 여러 조건을 통합함으로써 **"하나의 일관된 검사"**임을 보여줌 + - 함수 추출의 발판이 됨 → 코드의 의도가 더 명확해짐 + +하나의 검사라고 할 수 없는, 독립된 검사들이라고 판단되면 이 리팩터링을 해서는 안 된다. + +### 예제 + +```tsx +interface Employee { + seniority: number; // 근속 연수 + monthsDisabled: number; // 장애 기간(월) + isPartTime: boolean; // 파트타임 여부 + onVacation: boolean; // 휴가 중 여부 +} + +// ❌ 리팩토링 전: 흩어진 조건들 +function disabilityAmount_before(employee: Employee): number { + if (employee.seniority < 2) return 0; + if (employee.monthsDisabled > 12) return 0; + if (employee.isPartTime) return 0; + + // 장애 수당 계산 로직 + return employee.monthsDisabled * 100000; +} + +// ✅ 1단계: 조건식 통합 (or 사용) +function disabilityAmount_step1(employee: Employee): number { + if (employee.seniority < 2 || + employee.monthsDisabled > 12 || + employee.isPartTime) { + return 0; + } + return employee.monthsDisabled * 100000; +} + +// ✅ 2단계: 함수 추출로 의도 명확화 +function disabilityAmount(employee: Employee): number { + if (isNotEligibleForDisability(employee)) return 0; + return employee.monthsDisabled * 100000; +} + +function isNotEligibleForDisability(employee: Employee): boolean { + return employee.seniority < 2 || + employee.monthsDisabled > 12 || + employee.isPartTime; +} +``` + +## (3) 중첩 조건문을 보호 구문으로 바꾸기 + +**보호 구문(Guard Clause)**은 함수 초반에 특정 조건을 검사하여 조건이 맞으면 즉시 반환하는 패턴이다. 중첩된 조건문 대신 보호 구문을 사용하면 코드의 의도가 더 명확해지고, 들여쓰기 깊이가 줄어들어 가독성이 향상된다. + +조건문은 크게 두 가지 형태로 사용된다. + +1. 두 경로 모두 정상 동작: 두 갈래가 동등하게 중요 → if-else 사용 +2. 한쪽이 정상, 한쪽이 비정상: 비정상 조건을 먼저 검사하고 빠져나감 → 보호 구문 사용 + + +중첨 조건문을 보호 구문으로 바꾸기 리팩터링의 핵심은 의도를 부각하는 데 있다. +if-then-else 구조를 사용할 때 if절과 else절에 똑같은 무게를 두어, 코드를 읽는 이에게 양 갈래가 똑같이 중요하다는 뜻을 전달한다. 이와 달리, 보호 구문은 "이건 함수의 핵심이 아니니 무언가 조치를 취한 후 나가라"는 의도를 전달한다. + +### 예제 - 직원 급여 계산 + +```tsx +interface Employee { + isSeparated: boolean; // 퇴사 여부 + isRetired: boolean; // 은퇴 여부 +} + +// ❌ 리팩토링 전 +function payAmount_before(employee: Employee): { amount: number; reasonCode: string } { + let result: { amount: number; reasonCode: string }; + + if (employee.isSeparated) { // 퇴사한 직원인가? + result = { amount: 0, reasonCode: "SEP" }; + } else { + if (employee.isRetired) { // 은퇴한 직원인가? + result = { amount: 0, reasonCode: "RET" }; + } else { + // 급여 계산 로직 + result = someFinalComputation(); + } + } + return result; +} + +// ✅ 1단계: 최상위 조건을 보호 구문으로 변경 +function payAmount_step1(employee: Employee): { amount: number; reasonCode: string } { + let result: { amount: number; reasonCode: string }; + + if (employee.isSeparated) { + return { amount: 0, reasonCode: "SEP" }; + } + if (employee.isRetired) { + result = { amount: 0, reasonCode: "RET" }; + } else { + result = someFinalComputation(); + } + return result; +} + +// ✅ 2단계: 다음 조건도 보호 구문으로 변경 +function payAmount_step2(employee: Employee): { amount: number; reasonCode: string } { + let result: { amount: number; reasonCode: string }; + + if (employee.isSeparated) { + return { amount: 0, reasonCode: "SEP" }; + } + if (employee.isRetired) { + return { amount: 0, reasonCode: "RET" }; + } + result = someFinalComputation(); + return result; +} + +// ✅ 최종: 불필요한 result 변수 제거 +function payAmount(employee: Employee): { amount: number; reasonCode: string } { + if (employee.isSeparated) return { amount: 0, reasonCode: "SEP" }; + if (employee.isRetired) return { amount: 0, reasonCode: "RET" }; + + return someFinalComputation(); +} + +function someFinalComputation(): { amount: number; reasonCode: string } { + // 실제 급여 계산 로직 + return { amount: 1000000, reasonCode: "REG" }; +} +``` + +### 예제 - 조건 반대로 만들기 + +이 리팩터링을 수행할 때는 조건식을 반대로 만들어 적용하는 경우도 많다. + +```tsx + +interface Instrument { + capital: number; + interestRate: number; + duration: number; + income: number; + adjustmentFactor: number; +} + +// ❌ 리팩토링 전: 조건이 참일 때 작업을 수행하는 형태 +function adjustedCapital_before(anInstrument: Instrument): number { + let result = 0; + + if (anInstrument.capital > 0) { + if (anInstrument.interestRate > 0 && anInstrument.duration > 0) { + result = (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; + } + } + return result; +} + +// ✅ 1단계: 첫 번째 조건 역전 (capital > 0 → capital <= 0) +function adjustedCapital_step1(anInstrument: Instrument): number { + let result = 0; + + if (anInstrument.capital <= 0) { + return result; + } + if (anInstrument.interestRate > 0 && anInstrument.duration > 0) { + result = (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; + } + return result; +} + +// ✅ 2단계: 두 번째 조건도 역전 +function adjustedCapital_step2(anInstrument: Instrument): number { + let result = 0; + + if (anInstrument.capital <= 0) { + return result; + } + if (!(anInstrument.interestRate > 0 && anInstrument.duration > 0)) { + return result; + } + result = (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; + return result; +} + +// ✅ 3단계: 조건식 간소화 (드모르간 법칙 적용) +// !(A && B) → !A || !B +function adjustedCapital_step3(anInstrument: Instrument): number { + let result = 0; + + if (anInstrument.capital <= 0) { + return result; + } + if (anInstrument.interestRate <= 0 || anInstrument.duration <= 0) { + return result; + } + result = (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; + return result; +} + +// ✅ 4단계: 같은 결과를 반환하는 보호 구문 통합 +function adjustedCapital_step4(anInstrument: Instrument): number { + let result = 0; + + if (anInstrument.capital <= 0 + || anInstrument.interestRate <= 0 + || anInstrument.duration <= 0) { + return result; + } + result = (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; + return result; +} + +// ✅ 최종: 불필요한 result 변수 제거 및 정리 +function adjustedCapital(anInstrument: Instrument): number { + if (anInstrument.capital <= 0 + || anInstrument.interestRate <= 0 + || anInstrument.duration <= 0) { + return 0; + } + + return (anInstrument.income / anInstrument.duration) + * anInstrument.adjustmentFactor; +} +``` + +## (4) 조건부 로직을 다형성으로 바꾸기 + +복잡한 조건부 로직은 프로그램을 이해하기 어렵게 만든다. 클래스와 다형성을 활용하면 이를 더 명확하게 분리할 수 있다. 조건부 로직을 다형성으로 바꾸기는 타입에 따라 분기하는 switch문이나 if-else 체인을 클래스의 다형성으로 대체하는 리팩토링 기법이다. 각 타입별 로직을 별도의 클래스(또는 서브클래스)로 분리하여 조건문 없이 동작을 다르게 한다. + +- 타입을 기준으로 분기하는 switch문이 여러 곳에 등장할 때 유용 +- 기본 동작이 있고, 일부 타입만 특수한 동작을 할 때 (변형 동작) 유용 + +조건문은 직관적이지만, 클래스 구조를 활용하면 **"이 로직은 타입별로 다르게 동작한다"**는 의도를 명확히 전달할 수 있다. + +### 예제 - 결제 수단별 처리 - 클래스와 다형성을 활용 + +```tsx +interface PaymentData { + type: "card" | "bank" | "kakao" | "naver" | "toss"; + amount: number; + cardNumber?: string; + bankCode?: string; + accountNumber?: string; +} + +// ❌ 리팩토링 전: 결제 수단마다 switch문 반복 +function processPayment_before(payment: PaymentData): Promise { + switch (payment.type) { + case "card": + console.log(`카드 결제: ${payment.cardNumber}`); + return Promise.resolve(true); + case "bank": + console.log(`계좌이체: ${payment.bankCode}-${payment.accountNumber}`); + return Promise.resolve(true); + case "kakao": + console.log("카카오페이 결제 요청"); + return Promise.resolve(true); + // ... 계속 추가 + default: + throw new Error("지원하지 않는 결제 수단"); + } +} + +function calculateFee_before(payment: PaymentData): number { + switch (payment.type) { + case "card": + return payment.amount * 0.025; // 2.5% + case "bank": + return 300; // 고정 수수료 + case "kakao": + return payment.amount * 0.02; // 2% + case "naver": + return payment.amount * 0.02; + case "toss": + return payment.amount * 0.018; // 1.8% + default: + return 0; + } +} + +function getPaymentLabel_before(payment: PaymentData): string { + switch (payment.type) { + case "card": return "신용/체크카드"; + case "bank": return "계좌이체"; + case "kakao": return "카카오페이"; + case "naver": return "네이버페이"; + case "toss": return "토스페이"; + default: return "기타"; + } +} + + +// ✅ 리팩토링 후: 다형성 활용 + +abstract class PaymentMethod { + protected data: PaymentData; + + constructor(data: PaymentData) { + this.data = data; + } + + abstract process(): Promise; + abstract get fee(): number; + abstract get label(): string; + + // 공통 로직은 슈퍼클래스에 + get amount(): number { + return this.data.amount; + } + + get totalAmount(): number { + return this.amount + this.fee; + } +} + +class CardPayment extends PaymentMethod { + async process(): Promise { + console.log(`카드 결제: ${this.data.cardNumber}`); + // PG사 API 호출 로직 + return true; + } + + get fee(): number { + return this.data.amount * 0.025; + } + + get label(): string { + return "신용/체크카드"; + } +} + +class BankTransfer extends PaymentMethod { + async process(): Promise { + console.log(`계좌이체: ${this.data.bankCode}-${this.data.accountNumber}`); + return true; + } + + get fee(): number { + return 300; // 고정 수수료 + } + + get label(): string { + return "계좌이체"; + } +} + +class KakaoPayment extends PaymentMethod { + async process(): Promise { + console.log("카카오페이 결제 요청"); + // 카카오페이 SDK 호출 + return true; + } + + get fee(): number { + return this.data.amount * 0.02; + } + + get label(): string { + return "카카오페이"; + } +} + +class NaverPayment extends PaymentMethod { + async process(): Promise { + console.log("네이버페이 결제 요청"); + return true; + } + + get fee(): number { + return this.data.amount * 0.02; + } + + get label(): string { + return "네이버페이"; + } +} + +class TossPayment extends PaymentMethod { + async process(): Promise { + console.log("토스페이 결제 요청"); + return true; + } + + get fee(): number { + return this.data.amount * 0.018; + } + + get label(): string { + return "토스페이"; + } +} + +// 팩토리 함수 +function createPaymentMethod(data: PaymentData): PaymentMethod { + const paymentClasses: Record PaymentMethod> = { + card: CardPayment, + bank: BankTransfer, + kakao: KakaoPayment, + naver: NaverPayment, + toss: TossPayment, + }; + + const PaymentClass = paymentClasses[data.type]; + if (!PaymentClass) { + throw new Error("지원하지 않는 결제 수단"); + } + return new PaymentClass(data); +} + +// 클라이언트 코드: switch문 없이 깔끔 +async function checkout(paymentData: PaymentData) { + const payment = createPaymentMethod(paymentData); + + console.log(`결제 수단: ${payment.label}`); + console.log(`결제 금액: ${payment.amount}원`); + console.log(`수수료: ${payment.fee}원`); + console.log(`총 금액: ${payment.totalAmount}원`); + + const success = await payment.process(); + return success; +} + + +// ============================================ +// React 컴포넌트에서 활용 +// ============================================ + +// 결제 선택 UI에서도 다형성 활용 +const PaymentSelector = ({ payments }: { payments: PaymentData[] }) => { + return ( +
+ {payments.map(data => { + const payment = createPaymentMethod(data); + return ( +
+ {payment.label} + 수수료: {payment.fee.toLocaleString()}원 +
+ ); + })} +
+ ); +}; + + +// ============================================ +// 새 결제 수단 추가 시 +// ============================================ + +// 1. 새 클래스만 추가하면 됨 +class ApplePayment extends PaymentMethod { + async process(): Promise { + console.log("Apple Pay 결제 요청"); + return true; + } + + get fee(): number { + return this.data.amount * 0.015; + } + + get label(): string { + return "Apple Pay"; + } +} + +// 2. 팩토리에 등록 +// paymentClasses["apple"] = ApplePayment; + +// 3. 기존 코드(checkout, PaymentSelector 등) 수정 필요 없음! +``` + +### 예제 - 결제 수단별 처리 - 함수형 스타일 + +```tsx + +interface PaymentData { + type: "card" | "bank" | "kakao" | "naver" | "toss"; + amount: number; + cardNumber?: string; + bankCode?: string; + accountNumber?: string; +} + +// 설정만 분리하고, 로직은 공통 함수로 +const paymentConfig = { + card: { + label: "신용/체크카드", + feeRate: 0.025, + feeType: "percentage" as const, + }, + bank: { + label: "계좌이체", + feeRate: 300, + feeType: "fixed" as const, + }, + kakao: { + label: "카카오페이", + feeRate: 0.02, + feeType: "percentage" as const, + }, + naver: { + label: "네이버페이", + feeRate: 0.02, + feeType: "percentage" as const, + }, + toss: { + label: "토스페이", + feeRate: 0.018, + feeType: "percentage" as const, + }, +} as const; + +type PaymentType = keyof typeof paymentConfig; + +// 순수 함수들 +const getLabel = (type: PaymentType) => paymentConfig[type].label; + +const calculateFee = (type: PaymentType, amount: number) => { + const config = paymentConfig[type]; + return config.feeType === "fixed" + ? config.feeRate + : amount * config.feeRate; +}; + +const getTotalAmount = (type: PaymentType, amount: number) => + amount + calculateFee(type, amount); + +// process 로직만 별도 (부수효과가 있으므로) +const processHandlers: Record Promise> = { + card: async (data) => { + console.log(`카드 결제: ${data.cardNumber}`); + // PG사 API 호출 + return true; + }, + bank: async (data) => { + console.log(`계좌이체: ${data.bankCode}-${data.accountNumber}`); + return true; + }, + kakao: async () => { + console.log("카카오페이 결제 요청"); + return true; + }, + naver: async () => { + console.log("네이버페이 결제 요청"); + return true; + }, + toss: async () => { + console.log("토스페이 결제 요청"); + return true; + }, +}; + +const processPayment = (data: PaymentData) => processHandlers[data.type](data); + + +// ============================================ +// React 컴포넌트에서 활용 +// ============================================ + +import { useState } from 'react'; + +// 커스텀 훅으로 결제 로직 캡슐화 +function usePayment(data: PaymentData) { + const [isProcessing, setIsProcessing] = useState(false); + + const payment = { + label: getLabel(data.type), + fee: calculateFee(data.type, data.amount), + amount: data.amount, + totalAmount: getTotalAmount(data.type, data.amount), + }; + + const process = async () => { + setIsProcessing(true); + try { + return await processPayment(data); + } finally { + setIsProcessing(false); + } + }; + + return { ...payment, process, isProcessing }; +} + +// 컴포넌트 +function PaymentSelector({ payments }: { payments: PaymentData[] }) { + return ( +
+ {payments.map(data => ( + + ))} +
+ ); +} + +function PaymentOption({ data }: { data: PaymentData }) { + const { label, fee, totalAmount, process, isProcessing } = usePayment(data); + + return ( +
+ {label} + 수수료: {fee.toLocaleString()}원 + 총액: {totalAmount.toLocaleString()}원 + +
+ ); +} + + +// ============================================ +// 새 결제 수단 추가하기 +// ============================================ + +// 1. paymentConfig에 설정 추가 +// apple: { +// label: "Apple Pay", +// feeRate: 0.015, +// feeType: "percentage" as const, +// }, + +// 2. processHandlers에 핸들러 추가 +// apple: async () => { +// console.log("Apple Pay 결제 요청"); +// return true; +// }, + +// 3. 타입에 "apple" 추가 +// type: "card" | "bank" | ... | "apple" + +// 4. 끝! 기존 컴포넌트와 함수 수정 불필요 + + +// ============================================ +// 타입 안전하게 확장 가능한 구조 +// ============================================ + +// 설정과 핸들러가 같은 키를 가지도록 타입으로 강제 +type PaymentConfig = typeof paymentConfig; +type PaymentTypes = keyof PaymentConfig; + +// 새 결제수단 추가 시 핸들러 누락을 컴파일 타임에 잡아냄 +const _typeCheck: Record Promise> = processHandlers; +``` + +## (5) 특이 케이스 추가하기 - (특이 케이스 패턴, 널 객체 패턴) + +데이터 구조의 특정 값 확인 후 같은 동작을 수행하는 중복 코드가 있는 경우, 이를 한 곳에 모아 처리하는 것이 좋다. 특이 케이스 객체를 만들면 클라이언트 코드에서 반복되는 조건문을 없앨 수 있다. + +특이 케이스 패턴은 특정 값(null, "미확인", "기본값" 등)을 검사하는 조건문이 여러 곳에 반복될 때, 해당 케이스를 처리하는 별도의 객체를 만들어 조건문을 제거하는 기법이다. **널 객체 패턴(Null Object Pattern)**은 이 패턴의 대표적인 예시이다. + +특이 케이스는 여러 형태로 표현할 수 있다. 특이 케이스 객체에서 단순히 데이터를 읽기만 한다면 반환할 값들을 담은 리터럴 객체 형태로 준비하면 된다. 그 이상의 어떤 동작을 수행해야 한다면 필요한 메서드들을 담은 객체를 생성하면 된다. 특이 케이스 객체는 이를 캡슐화한 클래스가 반환하도록 만들 수도 있고, 변환을 거쳐 데이터 구조에 추가시키는 형태도 될 수 있다. + +### 예제 리팩토링 단계 요약 + +#### 1단계: 문제 인식 + +"unknown" 고객을 처리해야 하는 클라이언트가 여러 개 발견됐다. 많은 곳에서 이뤄지는 이 특이 케이스 검사과 공통된 반응이 특이 케이스 객체를 도입할 때임을 말해준다. + +```typescript +// "unknown" 검사가 곳곳에 반복됨 +if (customer === "unknown") customerName = "거주자"; +if (customer === "unknown") plan = basicPlan; +if (customer === "unknown") weeks = 0; +``` + +#### 2단계: Customer 클래스 생성 + +"unknown" 고객인지를 나타내는 메서드를 고객 클래스에 추가한다. + +```typescript +class Customer { + get name() { return this._data.name; } + get billingPlan() { return this._data.billingPlan; } + get isUnknown() { return false; } +} +``` + +#### 3단계: UnknownCustomer 클래스 생성 + +"unknown" 고객 전용 클래스를 만든다. + +class UnknownCustomer { + get isUnknown() { return true; } +} + +#### 4단계: Site에서 적절한 객체 반환 + +특이 케이스 일때 Site 클래스가 UnknownCustomer 객체를 반환하도록 수정한다. + +```typescript +class Site { + get customer() { + if (this._customer === "unknown") { + return new UnknownCustomer(); // 특이 케이스 객체 + } + return new Customer(this._customer); + } +} +``` +#### 5단계: 특이 케이스 검사를 기본값으로 대체 + +각 클라이언트에서 수행하는 특이 케이스 검사를 일반적인 기본값으로 대체하고 메서드를 추가한다. + +```typescript +class UnknownCustomer { + get isUnknown() { return true; } + get name() { return "거주자"; } // 기본값 이동 + get billingPlan() { return basicPlan; } // 기본값 이동 + get paymentHistory() { return { weeksDelinquentInLastYear: 0 }; } +} +``` + +#### 6단계: 클라이언트 코드 정리 + +```typescript +// Before +const name = customer === "unknown" ? "거주자" : customer.name; + +// After (조건문 제거!) +const name = customer.name; +``` + +## (6) 어서션 추가하기 + +**어서션(Assertion)**은 코드가 특정 조건을 항상 만족해야 한다는 가정을 명시적으로 표현하는 기법이다. 어서션은 프로그램의 상태를 검증하고, 가정이 틀렸을 때 즉시 오류를 발생시켜 버그를 빠르게 발견하도록 돕는다. + +코드의 특정 부분에서 프로그램 상태에 대해 가정을 세울 때가 많다. 예를 들어 제곱근 계산은 입력이 양수일 때만 정상 동작하거나, 객체에서 여러 필드 중 최소 하나에는 값이 들어있어야 정상 동작하는 경우가 있을 수 있다. 이런 가정이 코드에 명시적으로 드러나지 않으면 코드를 이해하기 어려워지고, 가정이 깨졌을 때 원인 파악이 어려우며, 버그가 발생한 곳과 실제 문제가 드러나는 곳이 멀어진다. + +어서션은 이런 가정을 명시적으로 선언하고, 가정이 어긋나면 즉시 실패하게 만든다. + +**적용 시점** +- 코드가 특정 조건을 가정하고 있을 때 +- 주석으로 가정을 설명하고 있을 때 → 어서션으로 대체 +- 디버깅 시 "이 값은 절대 이럴 리 없는데..."라고 생각할 때 + +**주의사항** +- 어서션은 프로그램 로직에 영향을 주면 안 됨 (제거해도 동작이 같아야 함) +- 어서션 실패는 프로그래머의 실수를 의미 (사용자 입력 검증용이 아님) +- 외부 데이터 검증에는 일반적인 조건문과 예외 처리를 사용 + +### 예제 + +```tsx +// ============================================ +// 배경 +// ============================================ + +// 고객에게 할인율을 적용하는 간단한 예제 +// 할인율은 항상 양수라는 가정이 깔려 있다 +// 이 가정이 코드에 명시적으로 드러나지 않는 것이 문제 + + +// ============================================ +// 리팩토링 전: 가정이 암묵적 +// ============================================ + +class Customer_Before { + private discountRate: number = 0; + + applyDiscount(aNumber: number): number { + // 할인율이 항상 양수라는 가정 하에 동작 + // 하지만 이 가정이 코드에 드러나지 않음 + return (this.discountRate > 0) + ? aNumber - (aNumber * this.discountRate) + : aNumber; + } + + set setDiscountRate(aNumber: number) { + this.discountRate = aNumber; + } +} + +// 문제점: +// 1. discountRate가 음수면 어떻게 될까? +// → 조건문 때문에 할인이 적용되지 않음 (의도한 것인가?) +// 2. 할인율이 양수여야 한다는 가정이 어디에도 명시되지 않음 +// 3. 누군가 실수로 음수를 설정하면 버그 원인 파악이 어려움 + + +// ============================================ +// 리팩토링 후: 어서션으로 가정 명시 +// ============================================ + +class Customer { + private discountRate: number = 0; + + applyDiscount(aNumber: number): number { + // 이제 discountRate가 항상 양수임을 확신할 수 있음 + // 어서션이 이를 보장하기 때문 + return (this.discountRate > 0) + ? aNumber - (aNumber * this.discountRate) + : aNumber; + } + + set setDiscountRate(aNumber: number) { + // ✅ 어서션 추가: 할인율은 항상 양수여야 함 + assert(aNumber >= 0); + this.discountRate = aNumber; + } +} + + +// ============================================ +// assert 함수 + +// asserts condition은 TypeScript에게 "이 함수가 정상 반환하면 condition이 true임을 보장한다"고 알려줍니다. +// ============================================ + +function assert(condition: boolean, message?: string): asserts condition { + if (!condition) { + throw new Error(message ?? "Assertion failed"); + } +} + + +// ============================================ +// 동작 확인 +// ============================================ + +const customer = new Customer(); + +// 정상 케이스: 10% 할인 +customer.setDiscountRate = 0.1; +console.log(customer.applyDiscount(1000)); // 900 + +// 정상 케이스: 할인 없음 +customer.setDiscountRate = 0; +console.log(customer.applyDiscount(1000)); // 1000 + +// ❌ 어서션 실패: 음수 할인율 +try { + customer.setDiscountRate = -0.1; +} catch (e) { + console.error("어서션 실패:", e.message); + // Assertion failed +} + + +// ============================================ +// 어서션이 없었다면 발생할 수 있는 문제 +// ============================================ + +/* +시나리오: 개발자가 실수로 음수 할인율을 설정 + +const customer = new Customer_Before(); +customer.setDiscountRate = -0.1; // 실수로 음수 설정 + +// applyDiscount 호출 +const result = customer.applyDiscount(1000); +// discountRate가 -0.1이므로 (discountRate > 0)은 false +// 따라서 할인이 적용되지 않고 1000 반환 + +// 문제점: +// 1. 오류가 발생하지 않음 (조용히 실패) +// 2. 할인이 적용되지 않은 것이 의도인지 버그인지 알 수 없음 +// 3. 나중에 "왜 할인이 안 됐지?" 하고 디버깅해야 함 +// 4. 버그의 원인(음수 설정)과 증상(할인 미적용)이 멀리 떨어져 있음 + +어서션이 있다면: +// setDiscountRate 시점에 즉시 오류 발생 +// "여기서 음수가 들어왔구나" 바로 파악 가능 +*/ +``` + +#### (+) assert 프로덕션 환경 처리 전략 + + +```typescript +function assert(condition: boolean, message?: string): asserts condition { + if (!condition) { + const errorMessage = message ?? "Assertion failed"; + + // 1. 항상 로깅 + console.error(`[Assertion] ${errorMessage}`); + + // 2. 모니터링 서비스로 전송 + // Sentry.captureMessage(errorMessage); + + // 3. 개발 환경에서만 throw + if (process.env.NODE_ENV === "development") { + throw new Error(errorMessage); + } + } +} +``` + +## (7) 제어 플래그를 탈출문으로 바꾸기 + +**제어 플래그(Control Flag)**는 반복문의 흐름을 제어하기 위해 사용하는 boolean 변수이다. 하지만 제어 플래그 대신 break, return, continue 같은 탈출문을 사용하면 코드의 의도가 더 명확해진다. + +```tsx +// ❌ 리팩토링 전 +for (const p of people) { + if (!found) { + if (p === "조커") { + sendAlert(); + found = true; + } + } +} + +// ✅ 리팩토링 후 +for (const p of people) { + if (p === "조커") { + sendAlert(); + break; + } +} +``` + +제어 플래그는 "여기서 멈춰야 해"라는 의도를 간접적으로 표현한다. 반면 탈출문은 "지금 당장 여기서 나가"라고 직접적으로 말한다. 코드를 읽는 사람은 제어 플래그를 보면서 "이 플래그가 언제 바뀌지?", "바뀌면 어떻게 되지?"를 추적해야 한다. 탈출문은 이런 추적 없이 바로 의도를 전달한다. + +**적용 시점** + +- 반복문 안에서 boolean 플래그로 흐름을 제어할 때 +- found, done, finished 같은 이름의 플래그 변수가 있을 때 +- 플래그 변수를 검사하는 조건문이 반복문 내에 있을 때 + +```tsx +interface ValidationError { + field: string; + message: string; +} + +interface FormData { + email: string; + password: string; + age: number; +} + +// ❌ 리팩토링 전: 제어 플래그로 첫 번째 에러만 찾기 +function findFirstError_before(data: FormData): ValidationError | null { + let found = false; + let error: ValidationError | null = null; + + const validations = [ + { field: "email", check: () => data.email.includes("@"), message: "이메일 형식 오류" }, + { field: "password", check: () => data.password.length >= 8, message: "비밀번호 8자 이상" }, + { field: "age", check: () => data.age >= 0, message: "나이는 양수" }, + ]; + + for (const v of validations) { + if (!found) { + if (!v.check()) { + error = { field: v.field, message: v.message }; + found = true; + } + } + } + + return error; +} + +// ✅ 리팩토링 후: return으로 즉시 반환 +function findFirstError(data: FormData): ValidationError | null { + const validations = [ + { field: "email", check: () => data.email.includes("@"), message: "이메일 형식 오류" }, + { field: "password", check: () => data.password.length >= 8, message: "비밀번호 8자 이상" }, + { field: "age", check: () => data.age >= 0, message: "나이는 양수" }, + ]; + + for (const v of validations) { + if (!v.check()) { + return { field: v.field, message: v.message }; // 즉시 반환 + } + } + + return null; +} +``` + +