제어 흐름 패턴
제어 흐름 패턴¶
토픽: Programming 레슨: 4 of 16 선수 지식: What Is Programming, Programming Paradigms, Data Types & Abstraction 목표: 제어 흐름 메커니즘 — 분기, 루프, 재귀, 반복자, 에러 처리 — 을 마스터하고 각 패턴을 언제 사용할지 학습합니다.
순차 실행(Sequential Execution)¶
기본적으로 프로그램은 순차적으로 실행됩니다 — 한 문장씩, 위에서 아래로.
Python:
print("Step 1")
x = 10
print("Step 2")
y = x + 5
print("Step 3")
print(f"Result: {y}")
# Output:
# Step 1
# Step 2
# Step 3
# Result: 15
JavaScript:
console.log("Step 1");
let x = 10;
console.log("Step 2");
let y = x + 5;
console.log("Step 3");
console.log("Result:", y);
Java:
System.out.println("Step 1");
int x = 10;
System.out.println("Step 2");
int y = x + 5;
System.out.println("Step 3");
System.out.println("Result: " + y);
이것이 가장 간단한 제어 흐름 형태입니다. 하지만 실제 프로그램은 결정과 반복이 필요합니다.
조건 분기(Conditional Branching)¶
조건에 따라 다른 코드 경로를 실행합니다.
If/Else¶
Python:
age = 18
if age >= 18:
print("Adult")
elif age >= 13:
print("Teenager")
else:
print("Child")
JavaScript:
let age = 18;
if (age >= 18) {
console.log("Adult");
} else if (age >= 13) {
console.log("Teenager");
} else {
console.log("Child");
}
Java:
int age = 18;
if (age >= 18) {
System.out.println("Adult");
} else if (age >= 13) {
System.out.println("Teenager");
} else {
System.out.println("Child");
}
C++:
int age = 18;
if (age >= 18) {
std::cout << "Adult" << std::endl;
} else if (age >= 13) {
std::cout << "Teenager" << std::endl;
} else {
std::cout << "Child" << std::endl;
}
Switch/Match¶
여러 개별 케이스를 위한 구문.
Java (switch):
int day = 3;
String dayName;
switch (day) {
case 1:
dayName = "Monday";
break;
case 2:
dayName = "Tuesday";
break;
case 3:
dayName = "Wednesday";
break;
default:
dayName = "Unknown";
break;
}
System.out.println(dayName); // Wednesday
JavaScript (switch):
let day = 3;
let dayName;
switch (day) {
case 1:
dayName = "Monday";
break;
case 2:
dayName = "Tuesday";
break;
case 3:
dayName = "Wednesday";
break;
default:
dayName = "Unknown";
}
console.log(dayName); // Wednesday
C++ (switch):
int day = 3;
std::string dayName;
switch (day) {
case 1:
dayName = "Monday";
break;
case 2:
dayName = "Tuesday";
break;
case 3:
dayName = "Wednesday";
break;
default:
dayName = "Unknown";
break;
}
std::cout << dayName << std::endl; // Wednesday
패턴 매칭(Pattern Matching) (현대 언어)¶
Python (3.10+):
def describe(value):
match value:
case 0:
return "zero"
case 1 | 2 | 3:
return "small"
case int(x) if x < 0:
return "negative"
case int():
return "positive integer"
case str():
return "string"
case _:
return "unknown"
print(describe(2)) # small
print(describe(-5)) # negative
print(describe("hi")) # string
Rust (match):
fn describe(value: i32) -> &'static str {
match value {
0 => "zero",
1..=3 => "small",
x if x < 0 => "negative",
_ => "positive",
}
}
println!("{}", describe(2)); // small
println!("{}", describe(-5)); // negative
패턴 매칭의 이점: 더 표현력 있고, 완전성 검사 (컴파일러가 모든 케이스가 커버되었는지 확인).
삼항 연산자(Ternary Operator)¶
간결한 조건 표현식.
Python:
age = 20
status = "adult" if age >= 18 else "minor"
print(status) # adult
JavaScript:
let age = 20;
let status = age >= 18 ? "adult" : "minor";
console.log(status); // adult
Java:
int age = 20;
String status = age >= 18 ? "adult" : "minor";
System.out.println(status); // adult
C++:
int age = 20;
std::string status = age >= 18 ? "adult" : "minor";
std::cout << status << std::endl; // adult
가드 절(Guard Clauses)¶
중첩을 줄이고 가독성을 향상시키는 조기 반환(early returns).
전 (중첩됨):
def process_user(user):
if user is not None:
if user.is_active:
if user.has_permission("admin"):
print("Processing admin user")
# ... complex logic ...
else:
print("No permission")
else:
print("Inactive user")
else:
print("No user")
후 (가드 절):
def process_user(user):
if user is None:
print("No user")
return
if not user.is_active:
print("Inactive user")
return
if not user.has_permission("admin"):
print("No permission")
return
print("Processing admin user")
# ... complex logic ...
이점: 중첩 감소, 더 명확한 에러 처리, 주요 로직이 마지막에 위치.
JavaScript:
function processUser(user) {
if (!user) {
console.log("No user");
return;
}
if (!user.isActive) {
console.log("Inactive user");
return;
}
if (!user.hasPermission("admin")) {
console.log("No permission");
return;
}
console.log("Processing admin user");
// ... main logic ...
}
루프(Loops)¶
반복은 프로그래밍의 기본입니다.
For 루프¶
고정된 횟수만큼 또는 컬렉션을 순회합니다.
Python:
# Range-based
for i in range(5):
print(i) # 0, 1, 2, 3, 4
# Iterating over collection
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
# With index
for index, fruit in enumerate(fruits):
print(f"{index}: {fruit}")
JavaScript:
// Traditional for loop
for (let i = 0; i < 5; i++) {
console.log(i); // 0, 1, 2, 3, 4
}
// For-of (ES6)
let fruits = ["apple", "banana", "cherry"];
for (let fruit of fruits) {
console.log(fruit);
}
// For-in (iterates over keys/indices)
for (let index in fruits) {
console.log(index, fruits[index]);
}
Java:
// Traditional for loop
for (int i = 0; i < 5; i++) {
System.out.println(i); // 0, 1, 2, 3, 4
}
// Enhanced for loop (for-each)
String[] fruits = {"apple", "banana", "cherry"};
for (String fruit : fruits) {
System.out.println(fruit);
}
C++:
// Traditional for loop
for (int i = 0; i < 5; i++) {
std::cout << i << std::endl; // 0, 1, 2, 3, 4
}
// Range-based for loop (C++11)
std::vector<std::string> fruits = {"apple", "banana", "cherry"};
for (const auto& fruit : fruits) {
std::cout << fruit << std::endl;
}
While 루프¶
조건이 참인 동안 반복합니다.
Python:
count = 0
while count < 5:
print(count)
count += 1
JavaScript:
let count = 0;
while (count < 5) {
console.log(count);
count++;
}
Java:
int count = 0;
while (count < 5) {
System.out.println(count);
count++;
}
Do-While 루프¶
최소 한 번 실행하고, 조건이 참인 동안 반복합니다.
Java:
int count = 0;
do {
System.out.println(count);
count++;
} while (count < 5);
// Executes at least once even if condition is false
int x = 10;
do {
System.out.println("Runs once");
} while (x < 5); // Still runs once
JavaScript:
let count = 0;
do {
console.log(count);
count++;
} while (count < 5);
C++:
int count = 0;
do {
std::cout << count << std::endl;
count++;
} while (count < 5);
참고: Python에는 do-while 루프가 없습니다. while True와 break를 사용하세요:
count = 0
while True:
print(count)
count += 1
if count >= 5:
break
루프 제어: Break와 Continue¶
Break: 즉시 루프를 종료합니다.
Python:
for i in range(10):
if i == 5:
break # Exit loop when i is 5
print(i) # 0, 1, 2, 3, 4
Continue: 현재 반복의 나머지를 건너뛰고 다음으로 진행합니다.
Python:
for i in range(10):
if i % 2 == 0:
continue # Skip even numbers
print(i) # 1, 3, 5, 7, 9
JavaScript:
for (let i = 0; i < 10; i++) {
if (i === 5) break; // Exit at 5
console.log(i); // 0, 1, 2, 3, 4
}
for (let i = 0; i < 10; i++) {
if (i % 2 === 0) continue; // Skip even
console.log(i); // 1, 3, 5, 7, 9
}
루프 불변량(Loop Invariants)¶
루프 불변량은 각 반복 전후에 참인 조건입니다. 정확성에 대한 추론에 유용합니다.
예시: 배열에서 최댓값 찾기
불변량: 각 반복 시작 시, max는 지금까지 검사한 모든 요소의 최댓값을 담고 있습니다.
Python:
def find_max(numbers):
if not numbers:
return None
max_val = numbers[0] # Invariant: max_val is max of numbers[0:0+1]
for i in range(1, len(numbers)):
# Invariant: max_val is max of numbers[0:i]
if numbers[i] > max_val:
max_val = numbers[i]
# Invariant maintained: max_val is max of numbers[0:i+1]
return max_val
불변량을 이해하면 올바른 루프를 작성하고 문제가 발생했을 때 디버그하는 데 도움이 됩니다.
재귀(Recursion)¶
자기 자신을 호출하는 함수. 모든 재귀 함수는 다음이 필요합니다: 1. 기저 사례(Base case): 재귀를 멈추는 조건 2. 재귀 사례(Recursive case): 더 간단한 입력으로 자신을 호출
팩토리얼(Factorial)¶
수학적 정의:
- factorial(0) = 1 (기저 사례)
- factorial(n) = n × factorial(n-1) (재귀 사례)
Python:
def factorial(n):
if n == 0:
return 1 # Base case
else:
return n * factorial(n - 1) # Recursive case
print(factorial(5)) # 120 (5 × 4 × 3 × 2 × 1)
JavaScript:
function factorial(n) {
if (n === 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
console.log(factorial(5)); // 120
Java:
public static int factorial(int n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
System.out.println(factorial(5)); // 120
C++:
int factorial(int n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
std::cout << factorial(5) << std::endl; // 120
피보나치(Fibonacci)¶
정의:
- fib(0) = 0, fib(1) = 1 (기저 사례)
- fib(n) = fib(n-1) + fib(n-2) (재귀 사례)
Python:
def fibonacci(n):
if n <= 1:
return n
else:
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(6)) # 8 (0, 1, 1, 2, 3, 5, 8)
참고: 이것은 반복 계산으로 인해 비효율적입니다 (지수 시간). 더 나은 성능을 위해 메모이제이션이나 반복을 사용하세요.
트리 순회(Tree Traversal)¶
재귀는 트리 구조에서 빛을 발합니다.
Python:
class TreeNode:
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
def inorder_traversal(node):
"""Left → Root → Right"""
if node is None:
return # Base case
inorder_traversal(node.left)
print(node.value)
inorder_traversal(node.right)
# Example tree:
# 1
# / \
# 2 3
# / \
# 4 5
root = TreeNode(1,
TreeNode(2, TreeNode(4), TreeNode(5)),
TreeNode(3)
)
inorder_traversal(root) # Output: 4, 2, 5, 1, 3
꼬리 재귀(Tail Recursion)¶
재귀 호출이 함수의 마지막 연산이면 꼬리 재귀입니다. 일부 컴파일러는 꼬리 재귀를 루프로 최적화합니다 (스택 성장 없음).
꼬리 재귀가 아님 (팩토리얼):
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n - 1) # Multiplication AFTER recursive call
꼬리 재귀 (누산기가 있는 팩토리얼):
def factorial_tail(n, acc=1):
if n == 0:
return acc
else:
return factorial_tail(n - 1, n * acc) # Recursive call is last operation
print(factorial_tail(5)) # 120
꼬리 호출 최적화가 있는 언어: Scheme, Scala, 일부 Rust, 일부 JavaScript 엔진.
재귀 vs 반복 언제 사용할까¶
재귀가 더 나을 때: - 문제가 자연스럽게 재귀적 (트리, 그래프, 분할 정복) - 코드가 더 명확하고 우아함
반복이 더 나을 때: - 성능이 중요 (스택 오버헤드 피함) - 문제가 자연스럽게 반복적 (단순 루프)
예시: 팩토리얼을 반복적으로
Python:
def factorial_iterative(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
print(factorial_iterative(5)) # 120
더 효율적이지만 (재귀 호출 없음), 복잡한 문제에서는 재귀가 종종 더 명확합니다.
반복자와 제너레이터(Iterators and Generators)¶
반복자(Iterators)¶
한 번에 하나씩 값 시퀀스를 생성하는 객체.
Python:
# Lists are iterable
numbers = [1, 2, 3, 4, 5]
iterator = iter(numbers)
print(next(iterator)) # 1
print(next(iterator)) # 2
print(next(iterator)) # 3
JavaScript:
let numbers = [1, 2, 3, 4, 5];
let iterator = numbers[Symbol.iterator]();
console.log(iterator.next().value); // 1
console.log(iterator.next().value); // 2
console.log(iterator.next().value); // 3
이점: 메모리 효율적 (전체 컬렉션을 메모리에 둘 필요 없음), 지연 평가.
제너레이터(Generators)¶
값을 한 번에 하나씩 yield하며, yield 사이에 일시 중지하는 함수.
Python:
def count_up_to(n):
count = 1
while count <= n:
yield count # Pause here, return count
count += 1
# Create generator
gen = count_up_to(5)
print(next(gen)) # 1
print(next(gen)) # 2
# Or use in loop
for num in count_up_to(5):
print(num) # 1, 2, 3, 4, 5
JavaScript:
function* countUpTo(n) {
let count = 1;
while (count <= n) {
yield count;
count++;
}
}
let gen = countUpTo(5);
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
// Or use in loop
for (let num of countUpTo(5)) {
console.log(num); // 1, 2, 3, 4, 5
}
이점: - 지연 평가: 필요할 때 값 계산 - 메모리 효율적: 전체 시퀀스를 저장하지 않음 - 무한 시퀀스: 무한 스트림 표현 가능
예시: 무한 시퀀스
Python:
def infinite_count():
count = 0
while True:
yield count
count += 1
# Only compute as needed
gen = infinite_count()
print(next(gen)) # 0
print(next(gen)) # 1
print(next(gen)) # 2
# ... can continue forever
코루틴과 Async/Await(Coroutines and Async/Await)¶
코루틴(Coroutines): 일시 중지하고 재개할 수 있는 함수로, 협력적 멀티태스킹을 가능하게 합니다.
Async/Await 패턴¶
Python:
import asyncio
async def fetch_data(url):
print(f"Fetching {url}...")
await asyncio.sleep(2) # Simulate network delay
print(f"Done fetching {url}")
return f"Data from {url}"
async def main():
# Run concurrently
task1 = fetch_data("https://api1.com")
task2 = fetch_data("https://api2.com")
result1, result2 = await asyncio.gather(task1, task2)
print(result1, result2)
# Run
asyncio.run(main())
JavaScript:
async function fetchData(url) {
console.log(`Fetching ${url}...`);
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate delay
console.log(`Done fetching ${url}`);
return `Data from ${url}`;
}
async function main() {
let task1 = fetchData("https://api1.com");
let task2 = fetchData("https://api2.com");
let [result1, result2] = await Promise.all([task1, task2]);
console.log(result1, result2);
}
main();
스레드와의 주요 차이점: 코루틴은 협력적(명시적으로 제어를 양보)이지, 선점적(OS가 언제든 스레드를 중단 가능)이 아닙니다.
에러 흐름(Error Flow)¶
오류와 예외 상황을 어떻게 처리할까요?
예외(Exceptions)¶
Try/Catch/Finally
Python:
def divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero")
return None
finally:
print("Cleanup (always runs)")
divide(10, 2) # 5.0, "Cleanup"
divide(10, 0) # "Error", None, "Cleanup"
JavaScript:
function divide(a, b) {
try {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
} catch (error) {
console.log("Error:", error.message);
return null;
} finally {
console.log("Cleanup (always runs)");
}
}
divide(10, 2); // 5, "Cleanup"
divide(10, 0); // "Error: Cannot divide by zero", null, "Cleanup"
Java:
public static Double divide(int a, int b) {
try {
return (double) a / b;
} catch (ArithmeticException e) {
System.out.println("Error: " + e.getMessage());
return null;
} finally {
System.out.println("Cleanup (always runs)");
}
}
C++:
double divide(int a, int b) {
try {
if (b == 0) {
throw std::runtime_error("Cannot divide by zero");
}
return static_cast<double>(a) / b;
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << std::endl;
return 0.0;
}
}
Result/Either 타입¶
함수형 접근법: 성공 또는 실패를 나타내는 타입 반환.
Rust:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
match divide(10, 2) {
Ok(result) => println!("Result: {}", result),
Err(error) => println!("Error: {}", error),
}
이점: 에러가 타입 시그니처에 명시적. 컴파일러가 처리를 강제합니다.
에러 전파(Error Propagation)¶
Rust의 ? 연산자:
fn read_file_length(path: &str) -> Result<usize, std::io::Error> {
let contents = std::fs::read_to_string(path)?; // Propagate error if it occurs
Ok(contents.len())
}
read_to_string이 Err를 반환하면 즉시 read_file_length에서 반환됩니다. 그렇지 않으면 계속합니다.
Java의 throws:
public static String readFile(String path) throws IOException {
return new String(Files.readAllBytes(Paths.get(path)));
}
// Caller must handle
try {
String content = readFile("file.txt");
} catch (IOException e) {
System.out.println("Error reading file");
}
단락 평가(Short-Circuit Evaluation)¶
논리 연산자 && (AND)와 || (OR)는 단락 평가를 사용합니다: 결과가 결정되는 즉시 평가를 멈춥니다.
Python:
def is_positive(x):
print(f"Checking {x}")
return x > 0
# AND: stops at first false
result = is_positive(5) and is_positive(10) and is_positive(-3)
# Output: Checking 5, Checking 10, Checking -3
# Result: False
# OR: stops at first true
result = is_positive(-5) or is_positive(10) or is_positive(20)
# Output: Checking -5, Checking 10
# Result: True (doesn't check 20)
JavaScript:
function check(x) {
console.log(`Checking ${x}`);
return x > 0;
}
let result = check(5) && check(10) && check(-3);
// Logs: Checking 5, Checking 10, Checking -3
let result2 = check(-5) || check(10) || check(20);
// Logs: Checking -5, Checking 10
// Doesn't log Checking 20 (short-circuited)
사용 사례: null/undefined 에러 피하기
JavaScript:
let user = getUser();
if (user && user.isActive && user.hasPermission("admin")) {
console.log("Admin user");
}
// If user is null, doesn't try to access user.isActive (would error)
구조적 프로그래밍(Structured Programming)¶
구조적 프로그래밍 (1960년대-70년대)이 주장한 것:
- goto 금지: 대신 루프와 함수 사용
- 단일 진입, 단일 종료: 함수는 하나의 진입점과 하나의 반환 (하지만 지금은 여러 반환이 일반적)
- 하향식 설계: 문제를 더 작은 프로시저로 나눔
나쁨 (goto로 비구조화):
// Don't do this
int i = 0;
start:
printf("%d\n", i);
i++;
if (i < 5) goto start;
좋음 (루프로 구조화):
for (int i = 0; i < 5; i++) {
printf("%d\n", i);
}
현대적 합의: 구조적 프로그래밍 원칙은 좋지만, 조기 반환과 break/continue의 실용적 사용은 가독성을 향상시킵니다.
연습 문제¶
연습 문제 1: 가드 절로 리팩토링¶
이 중첩된 코드를 가드 절을 사용해 리팩토링하세요:
Python:
def process_order(order):
if order is not None:
if order.is_valid():
if order.total > 0:
if order.user.is_verified:
print("Processing order")
else:
print("User not verified")
else:
print("Order total must be positive")
else:
print("Invalid order")
else:
print("No order")
연습 문제 2: 재귀 vs 반복¶
배열의 합계를 재귀적으로와 반복적으로 모두 구현하세요:
재귀:
def sum_recursive(numbers):
# Base case: empty array
# Recursive case: first element + sum of rest
pass
반복:
def sum_iterative(numbers):
# Use a loop
pass
어느 것이 더 명확한가요? 어느 것이 더 효율적인가요?
연습 문제 3: 제너레이터¶
피보나치 수열을 무한정 생성하는 제너레이터를 작성하세요:
def fibonacci_gen():
# Yield 0, 1, 1, 2, 3, 5, 8, 13, ...
pass
# Usage
gen = fibonacci_gen()
for i in range(10):
print(next(gen)) # First 10 Fibonacci numbers
연습 문제 4: 패턴 매칭¶
언어가 패턴 매칭을 지원하면 (Python 3.10+, Rust, Scala), 값을 분류하는 함수를 작성하세요: - 0이면: "zero" - 1-10이면: "small" - 11-100이면: "medium" - 100보다 크면: "large" - 음수이면: "negative" - 그 외: "unknown"
연습 문제 5: 에러 처리¶
a / b의 결과를 반환하는 safe_divide(a, b) 함수를 작성하세요:
- 0으로 나누기를 우아하게 처리
- 한 구현에서는 예외 사용 (try/catch)
- 다른 구현에서는 Result 타입 (또는 유사) 사용
Python:
# Exception version
def safe_divide_exception(a, b):
try:
return a / b
except ZeroDivisionError:
return None
# Result version (using a tuple)
def safe_divide_result(a, b):
if b == 0:
return (False, "Cannot divide by zero")
else:
return (True, a / b)
# Usage
success, value = safe_divide_result(10, 2)
if success:
print(f"Result: {value}")
else:
print(f"Error: {value}")
연습 문제 6: 꼬리 재귀¶
피보나치 함수를 꼬리 재귀로 다시 작성하세요:
힌트: 누산기가 있는 헬퍼 함수 사용.
def fibonacci_tail(n, a=0, b=1):
# Base case: n == 0
# Recursive case: call with updated accumulators
pass
요약¶
제어 흐름은 실행 순서를 결정합니다:
- 순차적: 기본, 위에서 아래로
- 분기:
- If/else, switch/match, 삼항 연산자
- 가드 절: 명확성을 위한 조기 반환
- 루프:
- For, while, do-while
- 제어를 위한 Break/continue
- 정확성에 대한 추론을 위한 루프 불변량
- 재귀:
- 기저 사례 + 재귀 사례
- 최적화를 위한 꼬리 재귀
- 문제가 자연스럽게 재귀적일 때 사용
- 반복자 & 제너레이터:
- 지연 평가, 메모리 효율적
- 필요에 따라 값 생성
- 코루틴 & Async/Await:
- 협력적 멀티태스킹
- 실행 일시 중지/재개
- 에러 처리:
- 예외: try/catch/finally
- Result 타입: 명시적 에러 값
- 에러 전파:
?연산자,throws - 단락 평가:
&&와||로 로직 최적화 - 구조적 프로그래밍: goto 피하기, 구조화된 구조 사용
핵심 통찰: 문제에 맞는 올바른 제어 흐름 패턴을 선택하세요. 가드 절은 중첩을 줄입니다. 재귀는 트리와 그래프에서 빛을 발합니다. 제너레이터는 무한 시퀀스를 가능하게 합니다. 예외는 예외적인 경우를 처리합니다. 각 패턴에는 제자리가 있습니다.