Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 예외 처리 완벽 입문 가이드: 초보자를 위한 핵심 정리

얼마 전 저는 Ruby 기본 문법은 알지만 예외(exception)가 정확히 무엇인지, 왜 유용한지 잘 모르는 초보자를 위한 Ruby 예외 입문 자료를 찾아보았습니다. 마땅한 자료를 찾기 어려워서 직접 작성해 보기로 했습니다. 이 글이 도움이 되기를 바랍니다. 혹시 이해하기 어려운 부분이 있다면 @StarrHorne으로 트윗을 보내주세요. :)

예외(Exception)란 무엇일까요?

예외는 Ruby가 예상치 못한 상황에 대처하는 방식입니다.

코드를 작성하다 오타를 내서 프로그램이 SyntaxErrorNoMethodError 같은 메시지와 함께 멈춘 경험이 있다면, 이미 예외를 목격한 것입니다.

Ruby에서 예외가 발생(raise)하면 모든 진행이 멈추고 프로그램은 종료 절차에 들어갑니다. 아무것도 이 과정을 막지 않으면, 프로그램은 결국 에러 메시지를 출력하며 종료됩니다.

예제를 살펴보겠습니다. 아래 코드에서는 0으로 나누기를 시도합니다. 이는 불가능한 연산이므로 Ruby는 ZeroDivisionError라는 예외를 발생시키고, 프로그램은 종료되며 에러 메시지를 출력합니다.

1 / 0
# 프로그램이 비정상 종료되며 출력: "ZeroDivisionError: divided by 0"

프로그램이 갑자기 멈추면 사용자들은 화를 내기 마련입니다. 그래서 우리는 보통 이 종료 과정을 중단시키고, 오류에 지능적으로 대응하고 싶어 합니다.

이것을 예외를 "구조한다(rescuing)", "처리한다(handling)", "잡는다(catching)"라고 표현합니다. 셋 다 같은 의미이며, Ruby에서는 다음과 같이 작성합니다:

begin
  # 이 안에서 발생하는 예외는...
  1/0
rescue
  # ...이 코드를 실행하게 됩니다
  puts "예외가 발생했지만, 지능적으로 대응하고 있어요!"
  do_something_intelligent()
end

# 이 프로그램은 비정상 종료되지 않습니다.
# 출력: "예외가 발생했지만, 지능적으로 대응하고 있어요!"

예외는 여전히 발생하지만, "구조(rescue)"되었기 때문에 프로그램이 멈추지는 않습니다. 종료되는 대신 Ruby는 rescue 블록 안의 코드를 실행하여 메시지를 출력합니다.

좋지만 여기에는 큰 한계가 하나 있습니다. 이 방식은 "무언가 잘못되었다"고만 알려줄 뿐, 무엇이 잘못되었는지는 알려주지 않습니다.

무엇이 잘못되었는지에 대한 모든 정보는 예외 객체(exception object)에 담겨 있습니다.

예외 객체(Exception Objects)

예외 객체는 평범한 Ruby 객체입니다. 방금 구조한 예외에 대한 "무슨 일이 일어났는지"의 모든 데이터를 담고 있습니다.

예외 객체를 얻으려면 약간 다른 rescue 문법을 사용합니다.

# 모든 에러를 구조하고, 예외 객체를 `e`에 담습니다
rescue => e

# ZeroDivisionError만 구조하고, 예외 객체를 `e`에 담습니다
rescue ZeroDivisionError => e

위 두 번째 예제에서 ZeroDivisionErrore에 담긴 객체의 클래스입니다. 지금까지 언급한 예외의 "종류"들이 사실은 모두 클래스 이름이라는 점을 기억하세요.

예외 객체는 디버깅에 유용한 정보도 함께 담고 있습니다. ZeroDivisionError의 예외 객체를 살펴보겠습니다.

begin
  # 이 안에서 발생하는 예외는...
  1/0
rescue ZeroDivisionError => e
  puts "Exception Class: #{ e.class.name }"
  puts "Exception Message: #{ e.message }"
  puts "Exception Backtrace: #{ e.backtrace }"
end

# 출력:
# Exception Class: ZeroDivisionError
# Exception Message: divided by 0
# Exception Backtrace: ...배열 형태의 백트레이스...

대부분의 Ruby 예외처럼 클래스 이름과 함께 메시지(message)와 백트레이스(backtrace)를 포함하고 있습니다.

직접 예외 발생시키기

지금까지는 예외를 구조하는 방법만 살펴봤습니다. 하지만 스스로 예외를 발생시킬 수도 있습니다. 이 과정을 "발생시키기(raising)"라고 부르며, raise 메서드를 호출해서 수행합니다.

직접 예외를 발생시키면 어떤 종류의 예외를 사용할지 선택할 수 있고, 에러 메시지도 직접 설정할 수 있습니다.

예제를 보겠습니다:

begin
  # "you messed up!"라는 메시지와 함께 ArgumentError를 발생시킵니다
  raise ArgumentError.new("You messed up!")
rescue ArgumentError => e
  puts e.message
end

# 출력: You messed up!

보시다시피 커스텀 메시지("You messed up!")를 담은 새로운 에러 객체(ArgumentError)를 생성해서 raise 메서드에 전달하고 있습니다.

Ruby답게 raise는 여러 가지 방식으로 호출할 수 있습니다:

# 가장 명시적이라 개인적으로 선호하는 방식
raise RuntimeError.new("You messed up!")

# ...동일한 결과를 만듭니다
raise RuntimeError, "You messed up!"

# ...역시 동일한 결과입니다. 단, 이 방식으로는
# RuntimeError만 발생시킬 수 있습니다
raise "You messed up!"

커스텀 예외 만들기

Ruby의 내장 예외들도 훌륭하지만, 모든 사용 사례를 커버하지는 못합니다.

예를 들어 사용자 시스템을 만들면서, 사용자가 사이트의 제한된 영역에 접근하려 할 때 예외를 발생시키고 싶다고 해봅시다. Ruby의 표준 예외 중에는 딱 맞는 것이 없으므로, 새로운 종류의 예외를 만드는 것이 최선입니다.

커스텀 예외를 만들려면 StandardError를 상속받는 새로운 클래스를 만들기만 하면 됩니다.

class PermissionDeniedError < StandardError

end

raise PermissionDeniedError.new()

이것은 그저 평범한 Ruby 클래스입니다. 따라서 다른 클래스처럼 메서드와 데이터를 추가할 수 있습니다. "action"이라는 속성을 추가해 보겠습니다:

class PermissionDeniedError < StandardError

  attr_reader :action

  def initialize(message, action)
    # 부모의 생성자를 호출해 메시지를 설정합니다
    super(message)

    # action을 인스턴스 변수에 저장합니다
    @action = action
  end

end

# 이후 사용자가 삭제 권한이 없는 데이터를
# 삭제하려고 시도할 때 이렇게 활용할 수 있습니다:
raise PermissionDeniedError.new("Permission Denied", :delete)

예외 클래스 계층 구조

방금 우리는 StandardError를 상속받아 커스텀 예외를 만들었는데, StandardError 자체는 Exception을 상속받습니다.

실제로 Ruby의 어떤 예외든 클래스 계층 구조를 따라 올라가면 결국 Exception에 도달합니다. 직접 증명해 보겠습니다. 아래는 Ruby의 주요 내장 예외들을 계층 구조로 표현한 것입니다:

Exception
 NoMemoryError
 ScriptError
   LoadError
   NotImplementedError
   SyntaxError
 SignalException
   Interrupt
 StandardError
   ArgumentError
   IOError
     EOFError
   IndexError
   LocalJumpError
   NameError
     NoMethodError
   RangeError
     FloatDomainError
   RegexpError
   RuntimeError
   SecurityError
   SystemCallError
   SystemStackError
   ThreadError
   TypeError
   ZeroDivisionError
 SystemExit

물론 이 모든 것을 외울 필요는 없습니다. 이 계층 구조 개념이 매우 중요한 이유가 있기 때문에 보여드리는 것입니다.

특정 클래스의 에러를 구조하면, 그 자식 클래스의 에러들도 함께 구조됩니다.

다시 한번 강조해 볼까요?

rescue StandardError를 실행하면 StandardError 클래스의 예외뿐 아니라 그 자식들의 예외까지 모두 구조합니다. 위 차트를 보면 알 수 있듯이 그 범위가 상당합니다: ArgumentError, IOError 등이 해당됩니다.

반면 rescue Exception을 하면 모든 예외를 구조하게 되는데, 이것은 아주 나쁜 생각입니다.

모든 예외 구조하기 (나쁜 방법)

혼나고 싶다면 Stack Overflow에 아래처럼 생긴 코드를 올려보세요:

// 이렇게 하지 마세요
begin
  do_something()
rescue Exception => e
  ...
end

위 코드는 모든 예외를 구조합니다. 절대 하지 마세요! 프로그램을 이상한 방식으로 망가뜨릴 수 있습니다.

그 이유는 Ruby가 에러 외의 용도로도 예외를 사용하기 때문입니다. 운영체제로부터 오는 "시그널(Signal)"이라는 메시지를 처리할 때도 예외를 활용합니다. 프로그램을 종료하려고 "ctrl-c"를 누른 적이 있다면, 바로 시그널을 사용한 것입니다. 모든 예외를 억제하면 이런 시그널들마저 억제하게 됩니다.

또한 문법 오류처럼 원래 프로그램을 멈춰야 하는 예외들도 있습니다. 이것들을 억제하면 오타나 실수를 했을 때조차 알아차릴 수 없게 됩니다.

모든 에러 구조하기 (올바른 방법)

다시 클래스 계층 차트를 살펴보면, 구조하고 싶은 에러들이 모두 StandardError의 자식임을 알 수 있습니다.

즉, "모든 에러"를 구조하고 싶다면 StandardError를 구조해야 합니다.

begin
  do_something()
rescue StandardError => e
  # 애플리케이션의 예외만 흡수됩니다.
  # SyntaxError 같은 것들은 그대로 통과합니다.
end

사실 예외 클래스를 명시하지 않으면, Ruby는 자동으로 StandardError를 의미하는 것으로 간주합니다.

begin
  do_something()
rescue => e
  # StandardError를 구조하는 것과 동일합니다
end

특정 에러 구조하기 (가장 좋은 방법)

모든 에러를 구조하는 방법을 알았으니, 이제 그것이 대체로 나쁜 관행(code smell)이며 피해야 한다는 점도 알아야 합니다.

광범위한 rescue는 대개 "어떤 구체적인 예외를 구조해야 하는지 파악하기 귀찮았다"는 신호입니다. 그리고 거의 항상 나중에 발목을 잡습니다.

따라서 시간을 들여 제대로 하는 것이 좋습니다. 구체적인 예외를 구조하세요.

begin
  do_something()
rescue Errno::ETIMEDOUT => e
  // Errno::ETIMEDOUT 예외만 구조합니다
end

심지어 하나의 rescue 블록에서 여러 종류의 예외를 동시에 구조할 수도 있으니, 이제 핑계가 없겠죠? :)

begin
  do_something()
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED => e
end