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

Ruby 예외 처리 마스터하기: ensure로 실행 보장, retry로 재시도, raise로 예외 재발생

Ruby에서 발생한 예외는 rescue로 잡아 문제가 생겼을 때 다른 코드 경로를 실행할 수 있습니다. 하지만 예외 처리 방법은 이것만이 아닙니다. 이번 AppSignal Academy 아티클에서는 retryensure 키워드를 살펴보고, 잡은 예외를 다시 던지는(재발생시키는) 방법까지 알아보겠습니다.

신뢰할 수 없는 웹 API와 통신한다고 가정해 보겠습니다. 이 API는 가끔씩 다운되기도 하고, 너무 느려서 요청 하나에 몇 초씩 걸리기도 합니다. 우리 라이브러리는 이 API에 의존하고 있으므로 최대한 탄력적으로 동작하도록 만들어야 합니다.

ensure로 코드 실행 보장하기

ensure 키워드는 예외가 발생하더라도 특정 코드 블록이 반드시 실행되도록 보장하는 데 사용됩니다.

우리 라이브러리에서는 Net::HTTP.start로 연 TCP 연결이, 예를 들어 요청이 타임아웃으로 실패하더라도 반드시 닫히도록 보장하고 싶습니다. 이를 위해 먼저 요청 코드를 begin/ensure/end 블록으로 감싸겠습니다. ensure 부분의 코드는 앞선 begin 블록에서 예외가 발생하더라도 항상 실행됩니다.

ensure 블록에서는 http 변수가 nil이 아닌 경우에만 Net::HTTP#finish를 호출해 TCP 연결을 닫도록 합니다. http 변수가 nil이 될 수 있는 경우는 TCP 연결 열기 자체가 실패했을 때인데, 이때 역시 예외가 발생합니다.

require "net/http"
 
begin
  puts "Opening TCP connection..."
  http = Net::HTTP.start(uri.host, uri.port)
  puts "Sending HTTP request..."
  puts http.request_get(uri.path).body
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end

참고: 나중에 재시도할 때 연결을 재사용할 수 있도록 TCP 연결을 직접 닫았습니다. 하지만 Net::HTTP.start는 연결 종료를 보장해 주는 블록 형태로도 사용할 수 있으므로, 위 예제는 ensure 없이 다시 작성할 수도 있습니다. 흥미롭게도 Net::HTTP의 내부 구현 역시 바로 이 ensure 블록 방식으로 되어 있습니다.

retry로 실패한 코드 재시도하기

retry 키워드는 블록 안의 코드를 다시 시도할 수 있게 해 줍니다. rescue 블록과 함께 사용하면 연결 열기에 실패했거나 API 응답이 너무 늦을 때 다시 시도할 수 있습니다.

이를 위해 Net::HTTP.start 호출에 read_timeout 옵션을 추가해 타임아웃을 10초로 설정하겠습니다. 10초 내에 요청에 대한 응답이 오지 않으면 Net::ReadTimeout 예외가 발생합니다.

또한 Errno::ECONNREFUSED도 함께 매칭하여 API가 완전히 다운되어 TCP 연결 자체를 열 수 없는 경우도 처리하겠습니다. 이때 http 변수는 nil입니다.

예외를 잡은 후 retry를 호출하면 begin 블록이 처음부터 다시 실행되어, 타임아웃이 발생하지 않을 때까지 같은 요청을 계속 시도하게 됩니다. 이미 http 객체가 존재한다면 연결을 담고 있는 이 객체를 재사용합니다.

require "net/http"
 
http = nil
uri = URI("https://localhost:4567/")
 
begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 10)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  puts "Timeout (#{e}), retrying in 1 second..."
  sleep(1)
  retry
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end

이제 요청은 1초마다 재시도되며, Net::ReadTimeout이 더 이상 발생하지 않을 때까지 반복됩니다.

$ ruby retry.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
... (in an endless loop)

이렇게 하면 어떤 타임아웃에도 예외가 발생하지 않도록 할 수 있지만, 이런 식으로 API를 무차별적으로 두드리는 것은 API 복구에 전혀 도움이 되지 않습니다. 문제는 API가 계속 응답하지 않으면 이 코드가 영원히 루프를 돈다는 점입니다. 따라서 재시도 간격을 점점 벌리고, 일정 횟수를 넘기면 포기하도록 만들어야 합니다.

포기하기: raise로 예외 재발생시키기

예외를 rescue하면 발생한 예외 객체가 rescue 블록으로 전달됩니다. 이를 활용하면 로그에 메시지를 출력하는 것처럼 예외에서 데이터를 추출할 수 있을 뿐만 아니라, 동일한 스택 트레이스를 유지한 채 정확히 같은 예외를 다시 던질 수도 있습니다.

begin
  raise "Exception!"
rescue RuntimeError => e
  puts "Exception happened: #{e}"
  raise e
end

rescue 블록에서 예외 객체에 접근할 수 있으므로, 콘솔이나 에러 모니터링 도구에 에러를 기록할 수 있습니다. 실제로 AppSignal의 통합 기능들이 에러를 추적하는 방식도 바로 이처럼 예외를 잡았다가 다시 던지는 방식입니다.

참고: Ruby는 마지막으로 발생한 예외를 $!라는 변수에 저장하며, raise 키워드는 기본적으로 이 변수를 사용합니다. 따라서 인자 없이 raise를 호출하면 마지막 예외가 그대로 재발생됩니다.

우리 라이브러리에서는 예외 재발생을 활용해 몇 번의 재시도 후에는 API에 가해지는 부담을 줄일 수 있습니다. 이를 위해 시도한 재시도 횟수를 retries 변수로 추적하겠습니다.

타임아웃이 발생할 때마다 카운트를 증가시키고, 그 값이 3 이하인지 확인합니다. 최대 세 번까지만 재시도하고 싶기 때문입니다. 3 이하면 retry를 호출하고, 그렇지 않으면 raise로 마지막 예외를 재발생시킵니다.

require "net/http"
 
http = nil
uri = URI("https://localhost:4567/")
retries = 0
 
begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 1)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  if (retries += 1) <= 3
    puts "Timeout (#{e}), retrying in #{retries} second(s)..."
    sleep(retries)
    retry
  else
    raise
  end
ensure
  if http
    puts 'Closing the TCP connection...'
    http.finish
  end
end

sleep 호출에 retries 변수를 사용함으로써, 재시도할 때마다 대기 시간을 점진적으로 늘릴 수 있습니다.

$ ruby reraise.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 2 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 3 second(s)...
Executing HTTP request...
Closing the TCP connection...
/lib/ruby/2.4.0/net/protocol.rb:176:in `rbuf_fill': Net::ReadTimeout (Net::ReadTimeout)
...
from reraise.rb:13:in `<main>'

요청은 세 번 재시도된 후 코드가 포기하고 마지막 에러를 재발생시킵니다. 이후 한 단계 위의 코드에서 이 에러를 처리하거나, API 응답 없이는 작업을 완료할 수 없다면 앱을 크래시시킬 수도 있습니다.

탄력적인 웹 API 클라이언트 완성

이 방법들을 조합하면 약 20줄의 코드로 탄력적인 웹 API 클라이언트를 만들 수 있습니다. API가 다운되거나 응답하지 않으면 요청을 재시도하고, 끝내 복구되지 않으면 깔끔하게 포기합니다.

예외 처리에 대해 새로 배운 점이 있기를 바랍니다. 이 글(또는 AppSignal Academy 시리즈의 다른 글들)에 대한 의견이나, 더 알아보고 싶은 Ruby 주제가 있다면 언제든지 알려주세요.