Ruby의 raise 구문은 발생시키고 싶은 오류의 종류를 지정하는 몇 가지 방법을 제공합니다. 아래 코드에서는 RuntimeError를 발생시키는 세 가지 방식을 확인할 수 있습니다.
raise "hello"
raise RuntimeError, "hello"
raise RuntimeError.new("hello")
# ...위의 세 가지 코드 모두 "RuntimeError: hello"를 결과로 냅니다.
그런데 예외가 아닌 다른 것을 발생시키고 싶다면 어떻게 될까요? 만약 숫자를 발생시키고 싶다면요? 안타깝게도 Ruby는 이를 허용하지 않으며, 대신 다음과 같은 오류 메시지를 마주하게 됩니다.
raise 1
# TypeError: exception class/object expected
이 오류 메시지만 보면 raise가 매개변수로 반드시 예외 클래스나 예외 객체를 요구하는 것처럼 보입니다. 하지만 사실은 그렇지 않습니다!
exception 메서드의 비밀
raise foo라고 작성했을 때, raise 메서드는 foo가 곧바로 예외 객체이기를 기대하지 않습니다. 대신 foo.exception을 호출했을 때 예외 객체를 돌려받을 수 있기를 기대합니다.
여기서 기억해야 할 핵심은 raise에는 사실상 무엇이든 전달할 수 있다는 점입니다. 단, 그 객체가 예외를 반환하는 exception이라는 이름의 메서드를 가지고 있어야 합니다.
따라서 원한다면 Ruby의 숫자 클래스에 몽키패치를 적용해 숫자를 직접 발생시키는 것도 가능합니다. 실제로 구현하면 다음과 같습니다.
class Fixnum
def exception
RuntimeError.new("I'm number: #{ self }")
end
end
raise 42
# ...결과: raise_number.rb:7:in `<main>': I'm number: 42 (RuntimeError)
언뜻 재미있는 파티 트릭 정도로 보일 수 있지만, 과연 실무에서 유용하게 쓰일 여지는 없을까요? 이 기술의 가장 실용적인 활용처는 예외를 생성하는 로직과 예외를 발생시키기로 결정하는 로직을 분리하는 것입니다. 물론 다소 특수한 케이스이긴 하지만, 어떤 모습으로 활용될 수 있는지 살펴보겠습니다.
실무에서 활용 가능한 예시
어떤 종류의 IO에서 한 줄의 데이터를 읽어야 하는 상황을 가정해 봅시다. 네트워크 IO일 수도 있고, 로컬 파일일 수도 있습니다. 중요한 것은 데이터를 읽어 들인 후 유효성을 검사하고 싶다는 점입니다.
읽어온 데이터가 유효하지 않다면 예외를 발생시키고 싶습니다. 하지만 그 예외는 입력 소스에 맞게 맞춤화되어야 합니다. 네트워크 연결은 로컬 파일과는 다른 디버그 정보를 필요로 하기 때문입니다. 이 문제는 각 입력 클래스마다 커스텀 exception 메서드를 정의함으로써 우아하게 해결할 수 있습니다. 다음은 의사 Ruby(pseudo-ruby) 코드로 표현한 예시입니다.
# 이 세 클래스는 서로 다른 예외를 반환하는 서로 다른 종류의 IO를 나타냅니다.
class NetworkConnection
...
def exception
NetworkConnectionError.new(url: url, ...)
end
end
class LocalFile
...
def exception
FileError.new(path: path, ...)
end
end
class UnixPipe
...
def exception
PipeError.new(...)
end
end
def read_all(*items)
items.each do |item|
if item.readline != "foo"
# item 자체를 raise하면 해당 객체에 정의된 적절한 예외 클래스가 사용됩니다.
raise item
end
end
end
read_all(NetworkConnection.new(url: "example.com"), LocalFile.new("/something"), UnixPipe.new)