콘텐츠 대표 이미지 - Ruby의 블록, Proc, Lambda:클로저의 다양한 형태와 동작 원리
🔥 Ruby 심화 개념

Ruby의 블록, Proc, Lambda:
클로저의 다양한 형태와 동작 원리

✨ 어렵게만 느껴졌던 Ruby 클로저, 이제 친구처럼 쉽게 이해해보자!

야, 솔직히 말해봐. Ruby 공부하다가 블록(Block), Proc, Lambda 이 세 녀석 보고 "이게 다 뭐야?" 하고 멘붕 온 적 있지? 😅

나도 처음엔 그랬어. 근데 이 세 가지를 제대로 이해하면 Ruby가 왜 그렇게 우아하고 강력한 언어인지 비로소 느껴지거든. 오늘은 이 세 녀석을 완전히 해부해서, 각각이 뭔지, 어떻게 다른지, 언제 써야 하는지까지 싹 다 정리해줄게.

특히 클로저(Closure)라는 개념이 이 세 가지를 관통하는 핵심 키워드야. 클로저가 뭔지부터 시작해서 블록 → Proc → Lambda 순서로 차근차근 올라가 보자. 준비됐어? 🚀

클로저(Closure)란 무엇인가? 외부 스코프 (Outer Scope) 변수 name = "Ruby" 변수 count = 0 🔒 클로저 (Closure) — 함수 + 주변 환경을 함께 포획! 📦 Block { |x| puts name } 외부 변수 접근 ✓ 객체 아님 ✗ 🎁 Proc Proc.new { |x| ... } 외부 변수 접근 ✓ 객체임 ✓ λ Lambda lambda { |x| ... } 외부 변수 접근 ✓ 엄격한 인자 ✓ 세 가지 모두 정의된 시점의 변수 환경을 "기억"합니다 — 이것이 클로저! Ruby에서 클로저는 블록, Proc, Lambda 세 가지 형태로 존재합니다

🧠 클로저(Closure)가 뭔데?

클로저를 이해하려면 먼저 스코프(Scope)를 알아야 해. 스코프는 변수가 살아있는 범위야. 보통 함수가 끝나면 그 안의 변수들은 사라지잖아? 근데 클로저는 달라.

📌 클로저(Closure)의 정의 클로저란 함수(코드 블록)와 그 함수가 정의된 시점의 변수 환경(바인딩)을 함께 묶어서 보관하는 것이야.
쉽게 말하면, 함수가 자기 주변의 변수들을 "기억"하고 있는 거야. 함수가 나중에 호출되어도 그 변수들에 접근할 수 있어!

예를 들어볼게:

ruby
def make_counter
  count = 0  # 이 변수를 클로저가 기억함!
  
  increment = lambda do
    count += 1
    puts count
  end
  
  increment
end

counter = make_counter
counter.call  # => 1
counter.call  # => 2
counter.call  # => 3

봐봐! make_counter 메서드가 끝났는데도 count 변수가 살아있어. Lambda가 그 변수를 "포획(capture)"해서 기억하고 있기 때문이야. 이게 바로 클로저의 마법이야! ✨

Ruby에서 클로저는 세 가지 형태로 존재해:

📦 Block가장 기본적인 형태
🎁 Proc객체화된 블록
λ Lambda
함수에 가까운 Proc

이 세 가지가 어떻게 다른지 하나씩 파헤쳐보자!

📦 Block: Ruby의 가장 기본적인 클로저

블록은 Ruby에서 가장 자주 쓰이는 클로저 형태야. 근데 신기한 건, 블록은 객체가 아니야! 그냥 코드 덩어리야. 메서드에 붙여서 전달하는 코드 조각이라고 생각하면 돼.

블록의 두 가지 문법

🌿 do...end 스타일
ruby
[1, 2, 3].each do |n|
  puts n * 2
end

여러 줄일 때 주로 사용. 가독성 굿!

⚡ 중괄호 {} 스타일
ruby
[1, 2, 3].each { |n| puts n * 2 }

한 줄일 때 주로 사용. 간결하고 깔끔!

⚠️ 우선순위 주의! {}는 do...end보다 우선순위가 높아. 메서드 체이닝할 때 이 차이가 중요해질 수 있어!

yield로 블록 실행하기

메서드 안에서 블록을 실행하려면 yield 키워드를 써. 이게 블록을 "호출"하는 방법이야:

ruby
def greet
  puts "안녕하세요!"
  yield  # 여기서 블록이 실행됨
  puts "또 만나요!"
end

greet { puts "저는 Ruby입니다!" }
# => 안녕하세요!
# => 저는 Ruby입니다!
# => 또 만나요!

블록에 값을 넘겨줄 수도 있어:

ruby
def calculate(a, b)
  result = yield(a, b)  # 블록에 a, b를 넘겨줌
  puts "결과: #{result}"
end

calculate(10, 5) { |x, y| x + y }   # => 결과: 15
calculate(10, 5) { |x, y| x * y }   # => 결과: 50

block_given?으로 블록 존재 확인

블록이 없는데 yield를 호출하면 에러가 나. 그래서 block_given?으로 먼저 확인하는 게 좋아:

ruby
def optional_block
  if block_given?
    yield
  else
    puts "블록이 없어요!"
  end
end

optional_block { puts "블록 있음!" }  # => 블록 있음!
optional_block                         # => 블록이 없어요!

블록과 클로저: 외부 변수 포획

블록도 클로저야! 정의된 시점의 변수를 기억해:

ruby
message = "Hello, Ruby!"

[1, 2, 3].each do |n|
  puts "#{n}: #{message}"  # 외부 변수 message에 접근 가능!
end

# => 1: Hello, Ruby!
# => 2: Hello, Ruby!
# => 3: Hello, Ruby!
💡 블록의 핵심 특징 요약 ✅ 객체가 아님 (저장 불가)
✅ yield로 실행
✅ 외부 변수 포획 가능 (클로저!)
✅ 메서드에 딱 하나만 전달 가능
✅ &prefix로 Proc으로 변환 가능

& 연산자: 블록을 Proc으로, Proc을 블록으로

블록을 메서드 파라미터로 받고 싶을 때 & 연산자를 써:

ruby
def capture_block(&block)  # &로 블록을 Proc으로 변환해서 받음
  puts block.class  # => Proc
  block.call        # 블록 실행
end

capture_block { puts "나는 블록이었는데 Proc이 됐어!" }
# => Proc
# => 나는 블록이었는데 Proc이 됐어!

반대로 Proc을 블록으로 변환할 수도 있어:

ruby
double = Proc.new { |n| n * 2 }

[1, 2, 3].map(&double)  # &로 Proc을 블록으로 변환!
# => [2, 4, 6]

# Symbol도 & 연산자로 블록처럼 쓸 수 있어!
["hello", "world"].map(&:upcase)
# => ["HELLO", "WORLD"]
📌 &:symbol 트릭 &:upcase는 { |s| s.upcase }와 같아. Symbol의 to_proc 메서드가 호출되는 거야. Ruby의 우아한 문법 중 하나!
Block vs Proc vs Lambda 핵심 비교 비교 항목 📦 Block 🎁 Proc λ Lambda 객체 여부 ❌ 객체 아님 ✅ 객체 (Proc) ✅ 객체 (Proc) 인자 처리 유연 (에러 없음) 유연 (에러 없음) 엄격 (에러 발생) return 동작 메서드에서 return (위험!) 메서드에서 return (위험!) Lambda 자체에서만 return (안전!) 호출 방법 yield .call / .() / [] .call / .() / [] 주요 사용 시점 each, map, select 등 이터레이터 콜백, 저장, 나중에 실행 함수형 프로그래밍, 고차 함수 세 가지 모두 클로저 — 외부 변수를 포획하고 기억합니다

🎁 Proc: 블록을 객체로 만들다

블록은 객체가 아니라서 변수에 저장하거나 나중에 재사용하기가 불편해. 그래서 등장한 게 Proc(프로시저)야! Proc은 블록을 객체로 만든 거야. 변수에 저장하고, 메서드에 전달하고, 나중에 실행할 수 있어.

Proc 만드는 방법

ruby
# 방법 1: Proc.new
greet = Proc.new { |name| puts "안녕, #{name}!" }

# 방법 2: proc 메서드 (Proc.new와 동일)
greet2 = proc { |name| puts "안녕, #{name}!" }

# 실행 방법들
greet.call("Ruby")   # => 안녕, Ruby!
greet.("Python")     # => 안녕, Python!
greet["JavaScript"]  # => 안녕, JavaScript!

세 가지 호출 방법이 있는데, 가장 명시적인 .call을 주로 써. .()는 Ruby 1.9+에서 추가된 문법이고, []는 좀 특이하게 생겼지만 유효한 문법이야.

Proc의 유연한 인자 처리

Proc은 인자 개수에 대해 매우 관대해. 인자가 부족하면 nil로 채우고, 넘치면 그냥 무시해:

ruby
flexible = Proc.new { |a, b, c| puts "#{a}, #{b}, #{c}" }

flexible.call(1, 2, 3)     # => 1, 2, 3
flexible.call(1, 2)        # => 1, 2,    (c는 nil)
flexible.call(1, 2, 3, 4)  # => 1, 2, 3 (4는 무시)
flexible.call              # => , ,      (모두 nil)
⚠️ Proc의 return은 위험해! Proc 안에서 return을 쓰면 Proc 자체가 아니라 Proc을 포함한 메서드에서 리턴해버려. 이게 예상치 못한 동작을 일으킬 수 있어!
ruby
def test_proc_return
  my_proc = Proc.new { return "Proc에서 리턴!" }
  my_proc.call
  puts "이 줄은 절대 실행 안 됨!"  # 실행 안 됨!
end

puts test_proc_return  # => "Proc에서 리턴!"

Proc 안의 return이 메서드 전체를 종료시켜버렸어. 이게 의도한 동작이라면 괜찮지만, 모르고 쓰면 버그의 원인이 돼!

Proc으로 클로저 활용하기

Proc이 클로저라는 걸 실제로 느껴볼 수 있는 예제야:

ruby
def make_multiplier(factor)
  Proc.new { |n| n * factor }  # factor를 포획!
end

double = make_multiplier(2)
triple = make_multiplier(3)

puts double.call(5)   # => 10
puts triple.call(5)   # => 15
puts double.call(10)  # => 20

make_multiplier 메서드가 끝났는데도 factor 변수가 살아있어! 각각의 Proc이 자기만의 factor를 기억하고 있는 거야. 이게 클로저의 진짜 힘이야! 💪

Proc을 이용한 콜백 패턴

ruby
class Button
  def initialize(label)
    @label = label
    @on_click = nil
  end
  
  def on_click(&block)
    @on_click = block  # 블록을 Proc으로 저장
  end
  
  def click!
    puts "#{@label} 버튼 클릭!"
    @on_click.call if @on_click
  end
end

btn = Button.new("확인")
btn.on_click { puts "확인 버튼이 눌렸어요! 🎉" }
btn.click!
# => 확인 버튼 클릭!
# => 확인 버튼이 눌렸어요! 🎉

이런 콜백 패턴은 실제 Ruby 프레임워크(Rails 등)에서 엄청 많이 쓰여. 이제 Rails 코드 볼 때 이해가 될 거야! 😄

λ Lambda: 함수에 가장 가까운 클로저

Lambda는 Proc의 특별한 버전이야. 기술적으로는 Proc 객체인데, 두 가지 중요한 차이점이 있어. Lambda는 더 함수답게 동작해!

Lambda 만드는 방법

ruby
# 방법 1: lambda 키워드
greet = lambda { |name| puts "안녕, #{name}!" }

# 방법 2: -> 화살표 문법 (Ruby 1.9+, 가장 많이 씀!)
greet2 = ->(name) { puts "안녕, #{name}!" }

# 여러 줄 Lambda
process = ->(x, y) do
  result = x + y
  puts "결과: #{result}"
  result
end

greet.call("Ruby")    # => 안녕, Ruby!
greet2.call("Python") # => 안녕, Python!
process.call(3, 4)    # => 결과: 7
📌 lambda? 메서드로 확인하기 proc_or_lambda.lambda?로 해당 객체가 Lambda인지 Proc인지 확인할 수 있어!
lambda { }.lambda? → true
Proc.new { }.lambda? → false

Lambda vs Proc: 두 가지 핵심 차이

차이 1: 인자 처리 — Lambda는 엄격해!

ruby
# Proc: 인자 개수 달라도 OK
my_proc = Proc.new { |a, b| puts "#{a}, #{b}" }
my_proc.call(1)        # => 1,    (에러 없음, b는 nil)
my_proc.call(1, 2, 3)  # => 1, 2 (에러 없음, 3은 무시)

# Lambda: 인자 개수 다르면 에러!
my_lambda = lambda { |a, b| puts "#{a}, #{b}" }
my_lambda.call(1, 2)   # => 1, 2 (OK)
my_lambda.call(1)      # => ArgumentError: wrong number of arguments!
my_lambda.call(1,2,3)  # => ArgumentError: wrong number of arguments!

차이 2: return 동작 — Lambda는 안전해!

ruby
# Proc의 return: 메서드 전체를 종료
def proc_return_test
  my_proc = Proc.new { return "Proc return" }
  my_proc.call
  "이 줄은 실행 안 됨"  # 실행 안 됨!
end

# Lambda의 return: Lambda 자체만 종료
def lambda_return_test
  my_lambda = lambda { return "Lambda return" }
  result = my_lambda.call  # Lambda만 종료, 메서드는 계속!
  puts "Lambda 결과: #{result}"
  "메서드 정상 종료"  # 이 줄도 실행됨!
end

puts proc_return_test    # => "Proc return"
puts lambda_return_test
# => Lambda 결과: Lambda return
# => 메서드 정상 종료
💡 언제 Lambda를 써야 할까? ✅ 인자 개수를 엄격하게 검사하고 싶을 때
✅ return이 메서드에 영향을 주면 안 될 때
✅ 함수형 프로그래밍 스타일로 코딩할 때
✅ 고차 함수(map, filter, reduce)에 전달할 때

Lambda로 고차 함수 만들기

Lambda는 함수형 프로그래밍에서 빛을 발해:

ruby
# 함수를 반환하는 함수 (고차 함수)
def compose(f, g)
  ->(x) { f.call(g.call(x)) }
end

double = ->(x) { x * 2 }
add_one = ->(x) { x + 1 }

double_then_add = compose(add_one, double)
add_then_double = compose(double, add_one)

puts double_then_add.call(5)  # => 11  (5*2=10, 10+1=11)
puts add_then_double.call(5)  # => 12  (5+1=6, 6*2=12)

커링(Currying): Lambda의 강력한 기능

커링은 여러 인자를 받는 함수를 하나씩 인자를 받는 함수들의 체인으로 변환하는 거야:

ruby
add = ->(a, b) { a + b }

# curry로 커링된 함수 생성
curried_add = add.curry

add5 = curried_add.(5)   # 첫 번째 인자만 적용
puts add5.(3)             # => 8  (5 + 3)
puts add5.(10)            # => 15 (5 + 10)

# 실용적인 예: 세금 계산기
tax_calculator = ->(rate, price) { price * (1 + rate) }
vat_10 = tax_calculator.curry.(0.1)  # 10% 세율 고정

puts vat_10.(1000)  # => 1100.0
puts vat_10.(5000)  # => 5500.0

커링을 쓰면 코드 재사용성이 엄청 올라가! 함수형 프로그래밍의 핵심 기법이야. 🔥

return 동작 차이: Proc vs Lambda 🎁 Proc의 return def my_method Proc.new { return "값" } ⬆ return 실행! 메서드 전체가 종료됨 puts "이 코드는 실행 안 됨" ❌ 스킵됨! end ⚠️ 메서드 전체 종료! λ Lambda의 return def my_method lambda { return "값" } ⬆ return 실행! Lambda 자체만 종료 puts "이 코드도 실행됨!" ✅ 정상 실행! end ✅ 메서드 정상 계속! Lambda의 return은 Lambda 스코프 내에서만 동작합니다

🔬 클로저 심화: 변수 포획과 바인딩

클로저가 변수를 "포획"한다고 했는데, 이게 정확히 어떻게 동작하는지 더 깊이 파보자. Ruby에서 클로저는 변수의 값이 아니라 참조(reference)를 포획해. 이게 무슨 말이냐면...

변수 참조 포획의 의미

ruby
x = 10

my_proc = Proc.new { puts x }
my_proc.call  # => 10

x = 20  # x를 변경!
my_proc.call  # => 20  (변경된 값이 반영됨!)

봐봐! Proc이 만들어진 시점의 값(10)이 아니라, 현재 x의 값(20)을 출력해. 이건 Proc이 x라는 변수 자체를 참조하고 있기 때문이야. 값을 복사한 게 아니야!

Binding 객체

Ruby에는 Binding이라는 특별한 객체가 있어. 현재 스코프의 변수 환경을 캡처하는 거야:

ruby
def get_binding(value)
  x = value
  binding  # 현재 바인딩을 반환
end

b = get_binding(42)
puts b.eval("x")  # => 42  (메서드가 끝났는데도 x에 접근!)

# ERB 템플릿에서 많이 쓰임
require 'erb'
name = "Ruby"
template = ERB.new("안녕, <%= name %>!")
puts template.result(binding)  # => 안녕, Ruby!

클로저로 상태 관리하기

클로저를 이용하면 객체 없이도 상태를 관리할 수 있어:

ruby
def make_stack
  data = []
  
  push = ->(item) { data.push(item) }
  pop = -> { data.pop }
  peek = -> { data.last }
  size = -> { data.size }
  
  { push: push, pop: pop, peek: peek, size: size }
end

stack = make_stack

stack[:push].call(1)
stack[:push].call(2)
stack[:push].call(3)

puts stack[:peek].call  # => 3
puts stack[:size].call  # => 3
puts stack[:pop].call   # => 3
puts stack[:size].call  # => 2

이게 바로 함수형 프로그래밍 스타일의 상태 관리야! 클래스 없이도 데이터를 캡슐화할 수 있어. 멋지지 않아? 😎

메모이제이션(Memoization)과 클로저

클로저를 이용한 메모이제이션은 성능 최적화의 핵심 기법이야:

ruby
def memoize(func)
  cache = {}
  ->(n) {
    cache[n] ||= func.call(n)
  }
end

# 피보나치 수열 (느린 버전)
slow_fib = ->(n) {
  n <= 1 ? n : slow_fib.call(n-1) + slow_fib.call(n-2)
}

# 메모이제이션 적용
fast_fib = memoize(slow_fib)

puts fast_fib.call(10)  # => 55
puts fast_fib.call(20)  # => 6765
# cache가 클로저로 유지되어 이미 계산한 값을 재사용!
📌 재능넷에서도 이런 개념이! 재능넷(https://www.jaenung.net)처럼 다양한 기능을 제공하는 플랫폼의 백엔드를 Ruby on Rails로 구현할 때, 이런 클로저 기반의 콜백과 메모이제이션 패턴이 실제로 많이 활용돼. Rails의 before_action, after_action 같은 콜백들이 다 이런 원리야!

🚀 실전 활용 패턴

이론은 충분히 봤으니까 이제 실제로 어떻게 쓰이는지 보자. 실무에서 자주 만나는 패턴들이야!

패턴 1: 전략 패턴 (Strategy Pattern)

ruby
class Sorter
  def initialize(strategy = nil)
    @strategy = strategy || ->(a, b) { a <=> b }
  end
  
  def sort(array)
    array.sort(&@strategy)
  end
end

# 기본 정렬
sorter = Sorter.new
puts sorter.sort([3, 1, 4, 1, 5]).inspect
# => [1, 1, 3, 4, 5]

# 역순 정렬 전략
reverse_sorter = Sorter.new(->(a, b) { b <=> a })
puts reverse_sorter.sort([3, 1, 4, 1, 5]).inspect
# => [5, 4, 3, 1, 1]

# 문자열 길이 기준 정렬
words = ["banana", "apple", "kiwi", "strawberry"]
length_sorter = Sorter.new(->(a, b) { a.length <=> b.length })
puts length_sorter.sort(words).inspect
# => ["kiwi", "apple", "banana", "strawberry"]

패턴 2: 데코레이터 패턴

ruby
def with_logging(func)
  ->(* args) {
    puts "호출: 인자 = #{args.inspect}"
    result = func.call(*args)
    puts "결과: #{result}"
    result
  }
end

def with_timing(func)
  ->(* args) {
    start = Time.now
    result = func.call(*args)
    elapsed = Time.now - start
    puts "실행 시간: #{elapsed.round(4)}초"
    result
  }
end

calculate = ->(a, b) { a ** b }

# 데코레이터 적용
logged_calc = with_logging(calculate)
timed_logged_calc = with_timing(logged_calc)

timed_logged_calc.call(2, 10)
# => 호출: 인자 = [2, 10]
# => 결과: 1024
# => 실행 시간: 0.0001초

패턴 3: 이벤트 시스템

ruby
class EventEmitter
  def initialize
    @listeners = Hash.new { |h, k| h[k] = [] }
  end
  
  def on(event, &handler)
    @listeners[event] << handler
  end
  
  def emit(event, *args)
    @listeners[event].each { |handler| handler.call(*args) }
  end
end

emitter = EventEmitter.new

# 이벤트 리스너 등록 (클로저!)
user_name = "Alice"
emitter.on(:login) { |id| puts "#{user_name}(#{id}) 로그인!" }
emitter.on(:login) { |id| puts "로그인 로그 기록: #{id}" }
emitter.on(:logout) { |id| puts "#{id} 로그아웃" }

emitter.emit(:login, 42)
# => Alice(42) 로그인!
# => 로그인 로그 기록: 42

emitter.emit(:logout, 42)
# => 42 로그아웃

패턴 4: 파이프라인 처리

ruby
class Pipeline
  def initialize
    @steps = []
  end
  
  def pipe(&step)
    @steps << step
    self  # 메서드 체이닝을 위해 self 반환
  end
  
  def process(input)
    @steps.reduce(input) { |data, step| step.call(data) }
  end
end

result = Pipeline.new
  .pipe { |s| s.strip }
  .pipe { |s| s.downcase }
  .pipe { |s| s.gsub(/\s+/, '_') }
  .pipe { |s| "user_#{s}" }
  .process("  Hello World  ")

puts result  # => "user_hello_world"
💡 파이프라인 패턴은 데이터 처리, ETL, 미들웨어 구현에 자주 쓰여. Ruby의 Rack 미들웨어가 이 패턴을 사용해!
클로저 실전 활용 패턴 λ 클로저 📞 콜백 패턴 이벤트 핸들러 비동기 처리 🔧 고차 함수 map, filter 함수 합성 💾 상태 관리 카운터, 스택 메모이제이션 🍛 커링 부분 적용 함수 재사용 🔗 파이프라인 패턴 입력 데이터 " Hello " → strip "Hello" → downcase "hello" → prefix 추가 "user_hello" → 최종 결과 "user_hello" 각 단계가 클로저로 구현되어 유연하게 조합 가능

⚠️ 자주 하는 실수와 주의사항

클로저를 쓰다 보면 자주 만나는 함정들이 있어. 미리 알아두면 디버깅 시간을 엄청 줄일 수 있어!

실수 1: 루프 변수 포획 문제

ruby
# 잘못된 예 (JavaScript에서 자주 보는 실수, Ruby에서도 주의!)
procs = []
[1, 2, 3].each do |i|
  procs << Proc.new { puts i }
end

procs.each(&:call)
# => 1
# => 2
# => 3
# Ruby의 each 블록은 각 반복마다 새로운 i를 만들어서 OK!

# 하지만 이런 경우는 주의:
procs2 = []
i = 0
while i < 3
  current_i = i  # 현재 값을 별도 변수에 저장
  procs2 << Proc.new { puts current_i }
  i += 1
end

procs2.each(&:call)  # => 0, 1, 2 (OK)

실수 2: Proc의 return으로 인한 LocalJumpError

ruby
# 위험한 상황: 메서드 밖에서 Proc의 return 호출
def make_proc
  Proc.new { return "값" }
end

my_proc = make_proc
# my_proc.call  # => LocalJumpError! (return할 메서드가 없음)

# 해결책: Lambda 사용 또는 return 제거
safe_proc = Proc.new { "값" }  # return 없이 마지막 값 반환
my_lambda = lambda { return "값" }  # Lambda는 안전

puts safe_proc.call   # => "값"
puts my_lambda.call   # => "값"

실수 3: 클로저가 너무 많은 변수를 포획

ruby
# 메모리 누수 위험: 큰 객체를 포획하는 클로저
def process_data(large_array)
  # large_array 전체를 포획!
  result_proc = Proc.new { large_array.first }
  result_proc
end

# 해결책: 필요한 값만 추출해서 포획
def process_data_safe(large_array)
  first_item = large_array.first  # 필요한 값만 추출
  Proc.new { first_item }  # 작은 값만 포획
end
⚠️ 메모리 주의! 클로저가 큰 객체를 포획하면 그 객체가 GC(가비지 컬렉션)되지 않아. 클로저가 살아있는 동안 포획된 객체도 메모리에 남아있어. 필요한 값만 추출해서 포획하는 습관을 들이자!

실수 4: 블록과 Proc의 혼동

ruby
# 블록은 객체가 아니라 저장 불가
# my_block = { puts "hello" }  # SyntaxError!

# Proc으로 만들어야 저장 가능
my_proc = Proc.new { puts "hello" }
my_proc.call  # OK!

# 메서드에 블록으로 전달할 때와 Proc으로 전달할 때의 차이
def test(x, &block)
  block.call(x)
end

test(5) { |n| puts n * 2 }  # 블록으로 전달
my_proc2 = ->(n) { puts n * 2 }
test(5, &my_proc2)  # Proc을 블록으로 변환해서 전달

⚡ 성능 이야기

실무에서는 성능도 중요하잖아. 블록, Proc, Lambda의 성능 차이를 알아보자.

일반적인 성능 순서

🥇
가장 빠름
Block (yield)
🥈
중간
Lambda
🥉
상대적으로 느림
Proc

블록(yield)이 가장 빠른 이유는 객체 생성 오버헤드가 없기 때문이야. 하지만 현대 Ruby(3.x)에서는 이 차이가 많이 줄었어. 대부분의 경우 성능보다 코드 가독성과 유지보수성을 우선시하는 게 좋아.

ruby
require 'benchmark'

n = 1_000_000
arr = [1, 2, 3, 4, 5]

Benchmark.bm do |x|
  x.report("block:  ") { n.times { arr.each { |i| i * 2 } } }
  x.report("proc:   ") { 
    p = Proc.new { |i| i * 2 }
    n.times { arr.each(&p) }
  }
  x.report("lambda: ") {
    l = ->(i) { i * 2 }
    n.times { arr.each(&l) }
  }
end
# 결과는 환경마다 다르지만 block이 보통 가장 빠름
💡 성능 최적화 팁 ✅ 반복 횟수가 많은 루프 안에서는 블록(yield) 사용
✅ Proc/Lambda는 루프 밖에서 한 번만 생성하고 재사용
✅ Ruby 3.x에서는 Ractor를 이용한 병렬 처리도 고려
✅ 대부분의 경우 성능 차이는 무시할 수 있는 수준

📅 Ruby 버전별 클로저 관련 변화

Ruby가 발전하면서 클로저 관련 문법과 기능도 계속 발전해왔어. 주요 변화들을 정리해볼게:

1.8
Ruby 1.8 — 기본적인 블록, Proc 지원. lambda 키워드 도입.
1.9
Ruby 1.9 — -> 화살표 문법 도입! .() 호출 문법 추가. lambda? 메서드 추가. 블록 로컬 변수 지원 (|x; local|).
2.0
Ruby 2.0 — 키워드 인자 지원으로 Lambda에서도 키워드 인자 사용 가능.
2.6
Ruby 2.6 — Proc#<<와 Proc#>>로 함수 합성 연산자 도입!
3.0
Ruby 3.0+ — 성능 대폭 향상 (MJIT/YJIT). Ractor 도입으로 병렬 처리 지원.

Ruby 2.6+ 함수 합성 연산자

ruby
# Ruby 2.6+에서 추가된 함수 합성 연산자
double = ->(x) { x * 2 }
add_one = ->(x) { x + 1 }

# >> : 왼쪽 먼저 실행
double_then_add = double >> add_one
puts double_then_add.call(5)  # => 11 (5*2=10, 10+1=11)

# << : 오른쪽 먼저 실행 (수학적 함수 합성 표기법)
add_then_double = double << add_one
puts add_then_double.call(5)  # => 12 (5+1=6, 6*2=12)

# 여러 함수 합성
strip_and_upcase = :strip.to_proc >> :upcase.to_proc
puts strip_and_upcase.call("  hello  ")  # => "HELLO"

이 연산자 덕분에 함수 합성이 훨씬 우아해졌어! 수학에서 f∘g 표기법이랑 비슷하지? 😊

🎯 결론: 언제 뭘 써야 할까?

이제 세 가지를 다 알았으니까, 실제로 어떤 상황에서 뭘 써야 하는지 정리해보자!

Block을 써야 할 때

📦 Block 사용 시나리오 ✅ each, map, select, reduce 등 이터레이터 사용할 때
✅ 코드를 한 번만 실행하고 저장할 필요 없을 때
✅ 메서드에 간단한 동작을 전달할 때
✅ DSL(Domain Specific Language) 만들 때 (Rails의 routes.rb 같은 거)
✅ 성능이 중요한 반복 처리

Proc을 써야 할 때

🎁 Proc 사용 시나리오 ✅ 블록을 변수에 저장해서 나중에 실행해야 할 때
✅ 콜백 함수로 저장해야 할 때
✅ 여러 메서드에 같은 블록을 재사용해야 할 때
✅ 인자 개수가 유동적일 때
✅ 메서드에서 return으로 메서드 전체를 종료하고 싶을 때 (의도적으로)

Lambda를 써야 할 때

λ Lambda 사용 시나리오
✅ 함수형 프로그래밍 스타일로 코딩할 때
✅ 인자 개수를 엄격하게 검사해야 할 때
✅ return이 메서드에 영향을 주면 안 될 때
✅ 고차 함수(함수를 인자로 받거나 반환하는 함수) 만들 때
✅ 커링(currying)이 필요할 때
✅ 함수 합성(>>, <<)을 사용할 때

마지막으로 한 가지 더! 재능넷 같은 플랫폼에서 Ruby 개발자를 찾을 때, 이런 클로저 개념을 제대로 이해하고 있는지가 실력을 가늠하는 중요한 기준이 돼. 블록, Proc, Lambda를 자유자재로 다룰 수 있다면 Ruby 개발자로서 한 단계 업그레이드된 거야! 🎉

Block / Proc / Lambda 선택 가이드 코드 블록이 필요해! 블록을 저장하거나 나중에 실행해야 해? NO YES 📦 Block yield로 실행 인자 검사가 엄격해야 해? 또는 return이 안전해야 해? NO YES 🎁 Proc 유연한 인자, 저장 가능 λ Lambda 엄격한 인자, 안전한 return 세 가지 모두 클로저 — 외부 변수를 포획합니다. 상황에 맞게 선택하세요!

🎊 마무리: 클로저의 세계에 오신 걸 환영해!

자, 이제 Ruby의 블록, Proc, Lambda를 제대로 이해했지? 처음엔 복잡해 보였는데 사실 핵심은 간단해:

1
세 가지 모두 클로저야. 정의된 시점의 변수 환경을 기억해.
2
Block은 가장 가볍고 빠르지만 객체가 아니야. 이터레이터에 최적!
3
Proc은 블록을 객체로 만든 거야. 저장하고 재사용할 수 있어. 인자에 관대해.
4
Lambda는 Proc의 특별한 버전이야. 인자에 엄격하고 return이 안전해. 함수형 프로그래밍에 최적!

Ruby가 이렇게 우아하고 표현력이 풍부한 언어인 이유 중 하나가 바로 이 클로저 시스템이야. 블록으로 DSL을 만들고, Proc으로 콜백을 구현하고, Lambda로 함수형 프로그래밍을 하는 게 Ruby의 진짜 매력이거든.

이제 Rails 코드 볼 때 before_action { ... }이나 scope :active, -> { where(active: true) } 같은 코드가 왜 저렇게 생겼는지 이해가 되지? 😄

계속 연습하다 보면 자연스럽게 손에 익을 거야. Ruby의 클로저 세계에 오신 걸 환영해! 🎉🎉🎉

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.