루비의 블록(Block), 프록(Proc), 람다(Lambda)는 많은 개발자들이 헷갈려 하는 핵심 개념입니다.
이것들은 정확히 무엇일까요? 어떻게 동작하고, 서로는 어떤 차이가 있을까요?
이 글 하나로 위 질문에 대한 답은 물론, 실무에서 바로 활용할 수 있는 내용까지 모두 정리해 드립니다!
목차
- 1 루비 블록 이해하기
- 2 루비 yield 키워드
- 3 암시적 블록 vs 명시적 블록
- 4 블록 전달 여부 확인 방법
- 5 람다란 무엇인가?
- 6 람다 vs 프록
- 7 클로저(Closure)
- 8 Binding 클래스
- 9 마치며
루비 블록 이해하기
루비 블록은 메서드에 전달할 수 있는 작은 익명 함수입니다.
블록은 do / end 문으로 감싸거나 중괄호 {} 사이에 작성하며, 여러 개의 인수를 가질 수도 있습니다.
인수 이름은 두 개의 파이프 문자 | 사이에 정의합니다.
지금까지 each를 사용해 보셨다면, 이미 블록을 사용하고 계셨던 겁니다!
예시를 살펴보겠습니다:
# 형식 1: 한 줄짜리 블록에 권장
[1, 2, 3].each { |num| puts num }
^^^^^ ^^^^^^^^
블록 블록
인수 본문
# 형식 2: 여러 줄짜리 블록에 권장 [1, 2, 3].each do |num| puts num end
루비 블록이 유용한 이유는 특정 로직(코드)을 저장해 두었다가 나중에 재사용할 수 있기 때문입니다.
예를 들어 파일에 데이터를 쓰거나, 요소 간 동등 여부를 비교하거나, 에러 메시지를 출력하는 등의 작업에 활용할 수 있습니다.
루비 yield 키워드
그렇다면 루비에서 yield는 무엇을 의미할까요?
yield는 사용 시점에 블록을 호출하는 루비 키워드입니다.
바로 이 키워드를 통해 메서드가 블록을 '사용'하는 것입니다!
yield 키워드를 실행하면 블록 안의 코드가 실제로 동작하며 자신의 역할을 수행합니다.
일반적인 루비 메서드를 호출하는 것과 마찬가지입니다.
예시입니다:
def print_once
yield
end
print_once { puts "Block is being run" }
이 코드는 print_once에 전달된 블록을 실행하므로, 화면에 "Block is being run"이 출력됩니다.
알고 계셨나요?
yield는 여러 번 사용할 수도 있습니다.
yield를 호출할 때마다 블록이 실행되기 때문에, 같은 메서드를 반복해서 호출하는 것과 같습니다.
예시:
def print_twice
yield
yield
end
print_twice { puts "Hello" }
# "Hello"
# "Hello"
메서드와 마찬가지로...
yield에는 원하는 만큼 인수를 전달할 수 있습니다.
예시:
def one_two_three
yield 1
yield 2
yield 3
end
one_two_three { |number| puts number * 10 }
# 10, 20, 30
이렇게 전달된 인수들은 그대로 블록의 인수가 됩니다.
위 예시에서는 number가 해당됩니다.
암시적 블록 vs 명시적 블록
블록에는 '명시적(explicit)'과 '암시적(implicit)' 두 종류가 있습니다.
명시적 블록이란 매개변수 목록에서 이름을 지정해 준 블록을 말합니다.
명시적 블록은 다른 메서드에 전달하거나 변수에 저장해 두었다가 나중에 사용할 수 있습니다.
예시를 보겠습니다:
def explicit_block(&block)
block.call # yield와 동일
end
explicit_block { puts "Explicit block called" }
&block 매개변수에 주목하세요...
바로 이 방식으로 블록에 이름을 지정하는 것입니다!
블록 전달 여부 확인 방법
블록 없이 yield를 호출하면 no block given (yield) 오류가 발생합니다.
이때 block_given? 메서드를 사용하면 블록이 전달되었는지 미리 확인할 수 있습니다.
예시:
def do_something_with_block return "No block given" unless block_given? yield end
이처럼 작성하면 누군가 블록 없이 메서드를 호출하더라도 오류를 예방할 수 있습니다.
람다(Lambda)란 무엇인가?
람다는 블록과 그 매개변수를 특별한 문법으로 정의하는 방법입니다.
정의한 람다는 변수에 저장해 두었다가 필요할 때 사용할 수 있습니다.
루비 람다를 정의하는 기본 문법은 다음과 같습니다:
say_something = -> { puts "This is a lambda" }
->대신lambda키워드를 사용하는 대체 문법도 있습니다.
람다를 정의한다고 해서 내부 코드가 바로 실행되지는 않습니다. 메서드를 정의만 한다고 실행되지 않는 것과 같으며, 실행하려면 call 메서드를 사용해야 합니다.
예시:
say_something = -> { puts "This is a lambda" }
say_something.call
# "This is a lambda"
람다를 호출하는 방법은 call 외에도 몇 가지가 있습니다. 존재 자체는 알아두면 좋지만, 가독성을 위해 call 사용을 권장합니다.
전체 목록은 다음과 같습니다:
my_lambda = -> { puts "Lambda called" }
my_lambda.call
my_lambda.()
my_lambda[]
my_lambda.===
람다는 인수도 받을 수 있습니다. 예시를 살펴보겠습니다:
times_two = ->(x) { x * 2 }
times_two.call(10)
# 20
람다에 잘못된 개수의 인수를 전달하면 일반 메서드와 마찬가지로 예외가 발생합니다.
람다 vs 프록
프록(Proc)은 람다와 아주 유사한 개념입니다...
차이점 중 하나는 생성 방식입니다.
예시:
my_proc = Proc.new { |x| puts x }
루비에는 별도의 Lambda 클래스가 존재하지 않습니다. 람다는 특별한 Proc 객체일 뿐입니다. Proc의 인스턴스 메서드를 살펴보면 lambda?라는 메서드가 있는 것을 확인할 수 있습니다.
이제 중요한 부분입니다:
프록은 람다와 다르게 동작하며, 특히 인수 처리에서 그 차이가 두드러집니다:
t = Proc.new { |x,y| puts "I don't care about arguments!" }
t.call
# "I don't care about arguments!"
proc과 lambda의 또 다른 차이점은 return 문에 대한 반응 방식입니다.
lambda는 일반 메서드처럼 정상적으로 return합니다.
하지만 proc은 현재 컨텍스트(context)에서 벗어나려고 시도합니다.
무슨 의미인지 살펴보겠습니다:
아래 코드를 실행하면 proc이 LocalJumpError 예외를 발생시키는 것을 확인할 수 있습니다.
그 이유는 최상위(top-level) 컨텍스트에서는 return할 수 없기 때문입니다.
직접 확인해 보세요:
# 정상 동작
my_lambda = -> { return 1 }
puts "Lambda result: #{my_lambda.call}"
# 예외 발생
my_proc = Proc.new { return 1 }
puts "Proc result: #{my_proc.call}"
만약 proc이 메서드 내부에 있다면, return 호출은 해당 메서드에서 반환하는 것과 동일하게 동작합니다.
다음 예시에서 이를 확인할 수 있습니다.
def call_proc
puts "Before proc"
my_proc = Proc.new { return 2 }
my_proc.call
puts "After proc"
end
p call_proc
# "Before proc"만 출력되고 "After proc"은 출력되지 않음
proc과 lambda의 차이점을 정리하면 다음과 같습니다:
- 람다는
-> {}로, 프록은Proc.new {}로 정의합니다. - 프록은 현재 메서드에서 반환(return)하지만, 람다는 람다 자체에서 반환합니다.
- 프록은 인수 개수가 맞지 않아도 신경 쓰지 않지만, 람다는 예외를 발생시킵니다.
이 목록을 통해 알 수 있는 사실은, lambda가 proc보다 일반 메서드에 훨씬 가깝다는 점입니다.
클로저(Closure)
루비의 프록과 람다는 또 하나의 특별한 속성을 가집니다. 루비 프록을 생성하면, 생성 당시의 실행 스코프(scope)가 함께 저장됩니다.
'클로저(closure)'라고 불리는 이 개념은, proc이 정의된 위치의 지역 변수나 메서드 같은 값들을 함께 품고 다닌다는 의미입니다.
단, 실제 값 자체가 아니라 참조(reference)를 저장하기 때문에, 프록 생성 이후 변수가 변경되면 프록은 항상 최신 값을 사용합니다.
예시를 살펴보겠습니다:
def call_proc(my_proc)
count = 500
my_proc.call
end
count = 1
my_proc = Proc.new { puts count }
p call_proc(my_proc) # 무엇이 출력될까요?
이 예시에는 1로 설정된 지역 변수 count가 있습니다.
또한 my_proc이라는 프록과, 인수로 전달된 모든 프록 또는 람다를(call 메서드를 통해) 실행하는 call_proc 메서드가 있습니다.
이 프로그램은 무엇을 출력할까요?
500이 가장 합리적인 답처럼 보이지만, '클로저' 효과 때문에 실제 출력 결과는 1입니다.
프록이 정의된 위치, 즉 메서드 정의 외부의 count 값을 사용하기 때문입니다.
Binding 클래스
그렇다면 루비의 프록과 람다는 이 스코프 정보를 어디에 저장할까요?
Binding 클래스에 대해 소개해 드리겠습니다...
binding 메서드로 Binding 객체를 생성하면, 코드의 해당 지점을 가리키는 '앵커(anchor)'를 만드는 셈입니다.
그 지점에서 정의된 모든 변수, 메서드, 클래스는 완전히 다른 스코프에 있더라도 이 객체를 통해 나중에 접근할 수 있습니다.
예시:
def return_binding
foo = 100
binding
end
# foo는 binding 덕분에 접근 가능합니다.
# foo가 정의된 메서드 밖에 있음에도 말이죠.
puts return_binding.class
puts return_binding.eval('foo')
# foo를 직접 출력하려 하면 오류가 발생합니다.
# foo는 메서드 밖에서 정의된 적이 없기 때문입니다.
puts foo
즉, binding 객체의 컨텍스트에서 코드를 실행하는 것은, 그 코드가 binding이 정의된 바로 그 위치에 있는 것과 동일합니다('앵커' 비유를 기억하세요).
binding 객체를 직접 사용할 일은 많지 않지만, 이런 개념이 존재한다는 것은 알아두면 좋습니다 🙂
마치며
이 글에서는 블록이 어떻게 동작하는지, 루비의 프록과 람다의 차이점은 무엇인지, 그리고 블록을 생성할 때마다 발생하는 '클로저' 효과까지 함께 살펴보았습니다.
다룰 수 있었지만 생략한 주제가 하나 있는데, 바로 curry 메서드입니다.
이 메서드를 사용하면 필요한 인수 중 일부 또는 전부를 미리 넘겨둘 수 있습니다. 인수를 일부만 전달하면 해당 인수들이 '미리 장착된(pre-loaded)' 새로운 프록이 반환되고, 모든 인수가 채워지는 순간 프록이 실행됩니다.
이 글이 도움이 되셨기를 바랍니다!
아래 폼을 통해 구독하는 것도 잊지 마시고, 친구들에게 공유해 주세요 🙂