Ruby에서 rescue 구문을 사용하면 어떤 종류의 예외를 처리할지 지정할 수 있습니다. 다음과 같이 예외 클래스 목록을 넘겨주기만 하면 됩니다.
begin
raise RuntimeError
rescue RuntimeError, NoMethodError
puts "rescued!"
end
하지만 코드를 작성하는 시점에 어떤 예외 클래스가 발생할지 알 수 없다면 어떻게 해야 할까요? 가장 단순한 방법은 일단 모든 예외를 받은 뒤 조건을 검사하고, 조건에 맞지 않으면 다시 raise하는 것입니다. 대략 이런 식이죠.
begin
raise "FUBAR! The ship's going down!"
rescue => e
raise unless e.message =~ /^FUBAR/
... do something ...
end
그런데 이 방식은 너무 지루합니다! 게다가 DRY하지도 않고요. rescue 구문에 "우리 조건에 맞는 예외만 골라서 잡아"라고 지시할 수 있다면 훨씬 더 우아할 것입니다. 그리고 다행히도, 여기가 Ruby니까 실제로 가능합니다!
rescue는 예외를 어떻게 매칭할까?
예외가 발생하면 Ruby 인터프리터는 해당 예외의 클래스를 여러분이 제공한 예외 클래스 목록과 비교합니다. 매칭되는 항목이 있으면 그 예외가 rescue됩니다.
매칭 과정은 대략 다음과 같습니다.
exception_classes_to_rescue.any? do |c|
c === raised_exception.class
end
Ruby의 다른 모든 연산자와 마찬가지로 ===도 사실은 그저 하나의 메서드입니다. 정확히는 c의 메서드죠. 그렇다면 우리가 직접 === 메서드를 정의하면 무엇을 할 수 있을까요?
아래 예제에서는 Anything === x가 x의 값과 무관하게 항상 true를 반환하는 Anything 클래스를 만들어 봅니다. 이 클래스를 rescue의 인자로 넘기면 모든 예외가 잡히게 됩니다.
class Anything
def self.===(exception)
true
end
end
begin
raise EOFError
rescue Anything
puts "It rescues ANYTHING!"
end
모든 예외를 잡는 데에는 사실 훨씬 더 좋은 방법들이 있지만, 이 코드가 흥미로운 이유는 두 가지 중요한 사실을 보여주기 때문입니다.
Exception을 상속하지 않는 클래스라도===만 구현하고 있다면 rescue 구문에 전달할 수 있습니다.===를 제어할 수 있다면, 어떤 예외가 잡힐지도 완전히 제어할 수 있습니다.
메시지 기반으로 예외 잡기
원리를 알았으니, 예외 메시지가 특정 패턴과 일치할 때만 예외를 잡는 코드를 아주 간단히 작성할 수 있습니다.
class AllFoobarErrors
def self.===(exception)
# FOOBAR로 시작하는 메시지를 가진 모든 예외를 rescue
exception.message =~ /^FOOBAR/
end
end
begin
raise EOFError, "FOOBAR: there was an eof!"
rescue AllFoobarErrors
puts "rescued!"
end
커스텀 속성 기반으로 예외 잡기
예외 객체에 접근할 수 있으므로, 매처(matcher)는 그 객체 안에 담긴 어떤 데이터든 자유롭게 활용할 수 있습니다.
예를 들어 "severity(심각도)"라는 커스텀 속성을 가진 예외가 있다고 상상해 보세요. "낮은 심각도"의 예외는 조용히 삼키되, "높은 심각도"의 예외는 그대로 통과시키고 싶을 수 있습니다. 이런 경우 다음과 같이 구현할 수 있습니다.
class Infraction < StandardError
attr_reader :severity
def initialize(severity)
@severity = severity
end
end
class LowSeverityInfractions
def self.===(exception)
exception.is_a?(Infraction) && exception.severity == :low
end
end
begin
raise Infraction.new(:low)
rescue LowSeverityInfractions
puts "rescued!"
end
동적으로 만들기
여기까지의 내용은 꽤 멋지지만, 보일러플레이트 코드가 상당히 필요합니다. 매처마다 별도의 클래스를 일일이 정의해야 하는 건 과해 보이죠. 다행히 약간의 메타프로그래밍을 활용하면 이 부분을 훨씬 DRY하게 만들 수 있습니다.
아래 예제에서는 매처 클래스를 자동으로 생성해 주는 메서드를 정의합니다. 매칭 로직은 블록으로 전달하고, 제너레이터는 그 블록을 자신의 === 메서드 내부에서 사용하는 새 클래스를 만들어 냅니다.
def exceptions_matching(&block)
Class.new do
def self.===(other)
@block.call(other)
end
end.tap do |c|
c.instance_variable_set(:@block, block)
end
end
begin
raise "FOOBAR: We're all doomed!"
rescue exceptions_matching { |e| e.message =~ /^FOOBAR/ }
puts "rescued!"
end
사용하기 전에 생각해 볼 점
Ruby의 멋진 트릭들이 그렇듯, 이 기법이 미친 짓인지 아니면 훌륭한 아이디어인지 솔직히 판단이 잘 서지 않습니다. 어쩌면 둘 다일지도 모르죠. 이 방법을 첫 번째 선택지로 삼자고 권하고 싶지는 않지만, 위의 예처럼 심각도에 따라 예외를 선별적으로 처리하고 싶은 상황에서는 충분히 유용할 수 있습니다. 어느 쪽이든, 도구 상자에 또 하나의 도구가 늘어난 셈입니다!