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

Ruby 예외 처리 완벽 입문: rescue를 올바르게 사용하는 방법

AppSignal은 Ruby 애플리케이션을 위한 에러 트래킹 서비스를 제공합니다. 이를 위해 애플리케이션이 발생시키는 모든 예외를 수집하고, 개발자에게 실시간으로 알려줍니다.

예외 처리는 제대로 하기가 생각보다 어렵습니다. 이 글에서는 Ruby에서 예외 처리가 어떻게 동작하는지, 잘못된 처리가 어떤 문제를 일으킬 수 있는지, 그리고 예외를 어떻게 올바르게 rescue해야 하는지 설명합니다.

예외 rescue하기

Ruby에서 예외를 rescue하면 무언가 잘못되었을 때 애플리케이션이 즉시 크래시되는 것을 막을 수 있습니다. begin .. rescue 블록을 사용하면 오류 발생 시 애플리케이션이 따라갈 대체 경로를 지정할 수 있습니다.

begin
  File.read "config.yml"
rescue
  puts "No config file found. Using defaults."
end

어떤 예외를 rescue할지 직접 지정하는 것도 가능합니다. 특정 예외 클래스를 지정하면 해당 클래스의 모든 하위 클래스도 함께 캐치됩니다.

begin
  File.read "config.yml"
rescue SystemCallError => e
  puts e.class # => Errno::ENOENT
  puts e.class.superclass # => SystemCallError
  puts e.class.superclass.superclass # => StandardError
end

위 예제에서 부모 클래스인 SystemCallError를 rescue했을 때 자식 예외인 Errno::ENOENT까지 함께 잡히는 것을 확인할 수 있습니다.

예외 계층 구조에서 너무 상위 클래스를 rescue하는 문제

예외 계층 구조에서 너무 상위에 있는 클래스를 rescue하지 않는 것이 중요합니다. 상위 클래스를 rescue하면 그 아래의 모든 하위 예외까지 함께 잡혀서 rescue 블록의 처리 범위가 지나치게 포괄적이 되기 때문입니다.

다음은 프로그램 인자로 전달받은 설정 파일을 읽는 간단한 프로그램입니다.

# $ ruby example.rb config.yml
def config_file
  ARGV.firs # Note the typo here, we meant `ARGV.first`.
end
 
begin
  File.read config_file
rescue
  puts "Couldn't read the config file"
end

에러 메시지는 설정 파일을 읽을 수 없다고 알려주지만, 사실 진짜 문제는 코드 안의 오타였습니다.

begin
  File.read config_file
rescue => e
  puts e.inspect
end
#<NoMethodError: undefined method `firs' for []:Array>

begin .. rescue 블록이 기본적으로 잡는 예외 클래스는 StandardError입니다. 특정 클래스를 지정하지 않으면 Ruby는 StandardError와 그 모든 하위 에러를 rescue하는데, NoMethodError도 바로 그중 하나입니다.

특정 예외 클래스를 rescue하면 관련 없는 에러가 의도치 않게 실패 상태를 유발하는 상황을 예방할 수 있습니다. 나아가 최종 사용자에게 더 유용한 구체적인 커스텀 에러 메시지를 제공할 수도 있습니다.

config_file = "config.yml"
begin
  File.read config_file
rescue Errno::ENOENT => e
  puts "File or directory #{config_file} doesn't exist."
rescue Errno::EACCES => e
  puts "Can't read from #{config_file}. No permission."
end

Exception 클래스를 rescue하는 것의 위험성

그래도 예외 계층 구조의 상위 클래스를 rescue하고 싶은 유혹이 들 수 있습니다. 애플리케이션이 발생시킬 수 있는 모든 에러를 잡아버리면 크래시를 막을 수 있으니까요. (100% 가동률 달성이다!) 하지만 이는 수많은 문제를 초래할 수 있습니다.

Exception 클래스는 Ruby의 최상위 예외 클래스입니다. 다른 모든 예외는 이 클래스의 하위 클래스이며, Exception을 rescue하면 모든 에러가 통째로 잡힙니다.

대부분의 애플리케이션이 rescue해서는 안 되는 대표적인 두 가지 예외가 바로 SignalException과 SystemExit입니다.

SignalException은 외부 요인이 애플리케이션의 중단을 요청할 때 발생합니다. 예를 들어 운영체제가 종료를 준비하거나, 시스템 관리자가 애플리케이션을 중지시키려는 경우가 있습니다.

SystemExit은 Ruby 코드에서 exit이 호출될 때 발생합니다. 이 예외가 발생했다는 것은 개발자가 애플리케이션의 종료를 원한다는 의미입니다.

만약 Exception을 rescue하면 begin ... rescue ... end 블록 실행 도중 이런 예외가 발생해도 프로세스가 정상적으로 종료되지 못합니다.

일반적인 상황에서 Exception을 rescue하는 것은 좋지 않은 습관입니다. Exception을 rescue하면 SignalException과 SystemExit이 동작하지 않을 뿐만 아니라, LoadError, SyntaxError, NoMemoryError 같은 중요한 예외들까지 모두 잡아버립니다. 대신 더 구체적인 예외를 rescue하는 것이 바람직합니다.

테스트에서 발생하는 문제

Exception을 rescue하면(rescue Exception => e) 애플리케이션 외의 다른 것들까지 고장날 수 있습니다. 대표적으로 테스트 스위트가 실제 에러를 숨겨버릴 수 있습니다.

minitest와 RSpec에서 어설션(assertion)이 실패하면 예외를 발생시켜 테스트 실패를 알립니다. 이때 테스트 프레임워크는 Exception을 상속한 자체 커스텀 예외를 던집니다.

따라서 테스트 코드나 애플리케이션 코드에서 Exception을 rescue하면 어설션 실패가 조용히 묻혀버릴 수 있습니다.

# RSpec example
def foo(bar)
  bar.baz
rescue Exception => e
  puts "This test should actually fail"
  # Failure/Error: bar.baz
  #   <Double (anonymous)> received unexpected message :baz with (no args)
end
 
describe "#foo" do
  it "hides an 'unexpected message' exception" do
    bar = double(to_s: "")
    foo(bar)
  end
end

발생할 예외를 명시적으로 검증하기

어떤 코드는 애초에 예외를 발생시키도록 설계됩니다. 테스트 스위트에서는 예외가 발생했을 때 이를 단순히 무시해서 테스트가 실패하지 않도록 만들 수도 있습니다.

def foo
  raise RuntimeError, "something went wrong"
end
 
foo rescue RuntimeError

하지만 이 방식에는 한 가지 치명적인 문제가 있습니다. 예외가 실제로 발생했는지 검증하지 못한다는 점입니다. 예외가 발생하지 않더라도 테스트는 통과해버리기 때문에, 동작이 여전히 올바른지 판단할 수 없습니다.

예외가 발생하는지, 그리고 발생했다면 어떤 예외인지 명시적으로 어설트하는 것이 올바른 방법입니다.

# expecting_exceptions_spec.rb
# RSpec example
def foo
  raise NotImplementedError, "foo method not implemented"
end
 
describe "#foo" do
  it "raises a RuntimeError" do
    expect { foo }.to raise_error(RuntimeError)
  end
end
1) #foo raises a RuntimeError
   Failure/Error: expect { foo }.to raise_error(RuntimeError)

     expected RuntimeError, got #<NotImplementedError: foo method not implemented> with backtrace:
       # ./expecting_exceptions_spec.rb:4:in `foo'
       # ./expecting_exceptions_spec.rb:9:in `block (3 levels) in <top (required)>'
       # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'
       # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'

예외를 다시 발생시키기 (Re-raise)

애플리케이션은 아주 명확한 이유가 있는 경우에만 Exception 클래스 수준까지 예외를 캐치해야 합니다. 예를 들어 코드 블록을 빠져나가기 전에 반드시 제거해야 하는 임시 파일 삭제처럼 정리(cleanup) 작업이 필요한 경우가 그렇습니다.

어쩔 수 없이 Exception을 rescue해야 하는 상황이라면, 에러 처리가 끝난 후 예외를 다시 발생시키는(re-raise) 것을 권장합니다. 이렇게 하면 그 이후 프로세스의 운명을 Ruby의 예외 처리 메커니즘이 결정할 수 있습니다.

File.open("/tmp/my_app.status", "w") { |f| "running" }
 
begin
  foo
rescue Exception => e
  Appsignal.add_error e
  File.open("/tmp/my_app.status", "w") { |f| "stopped" }
  raise e
end

무엇을 rescue해야 할지 모르겠다면?

앞서 강조했듯이, 어떤 에러를 rescue할지 구체적으로 지정하는 것이 좋습니다.

어떤 작업이 어떤 예외를 발생시킬지 확실하지 않다면, 우선 StandardError를 rescue하는 것부터 시작해보세요. 다양한 시나리오에서 코드를 실행해보면서 어떤 예외가 발생하는지 직접 확인하면 됩니다.

begin
  File.open('/tmp/appsignal.log', 'a') { |f| f.write "Starting AppSignal" }
rescue => e
  puts e.inspect
end
#<Errno::EACCES: Permission denied @ rb_sysopen - /tmp/appsignal.log>

새로운 예외를 발견할 때마다 해당 예외 또는 적절한 부모 클래스에 대한 구체적인 rescue 케이스를 추가해 나가세요. 너무 많은 예외를 한꺼번에 잡는 것보다 필요한 예외만 구체적으로 지정하는 편이 훨씬 안전합니다.

begin
  file = '/tmp/appsignal.log'
  File.open(file, 'a') { |f| f.write("AppSignal started!") }
rescue Errno::ENOENT => e
  puts "File or directory #{file} doesn't exist."
rescue Errno::EACCES => e
  puts "Cannot write to #{file}. No permissions."
end
 
# Or, using the parent error class
begin
  file = '/tmp/appsignal.log'
  File.open(file, 'a')
rescue SystemCallError => e
  puts "Error while writing to file #{file}."
  puts e
end

지금까지 Ruby 예외 처리에 대한 기본 가이드를 마쳤습니다. 더 궁금한 점이나 구체적인 질문이 있다면 @AppSignal으로 알려주세요. 애플리케이션에서 예외가 어디서, 얼마나 자주 발생하는지 더 깊이 파악하고 싶다면 AppSignal을 사용해 보시기 바랍니다.