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

Ruby에서 예외 발생 시 재시도하는 방법: retry부터 Continuation까지

모든 오류가 치명적인 것은 아닙니다. 어떤 오류는 단순히 "다시 시도하라"는 신호일 뿐입니다. 다행히 Ruby는 이러한 재시도를 손쉽게 구현할 수 있는 몇 가지 흥미로운 메커니즘을 제공합니다. 다만 그 방법들이 모두 직관적이거나 널리 알려진 것은 아닙니다. 이 글에서는 이러한 메커니즘들이 실제 환경에서 어떻게 동작하는지 살펴보겠습니다.

retry 키워드 소개

이 방법은 존재 자체만 안다면 꽤 자명합니다. 필자 역시 Ruby 개발 경력이 상당히 쌓인 후에야 retry 키워드의 존재를 알게 되었습니다.

retry는 Ruby의 예외 처리(rescue) 시스템에 기본으로 내장되어 있으며, 동작 방식은 매우 단순합니다. rescue 블록 안에서 retry를 호출하면 예외가 발생했던 begin 블록의 코드 전체가 다시 실행됩니다. 예제를 살펴보겠습니다.

begin
  retries ||= 0
  puts "try ##{ retries }"
  raise "the roof"
rescue
  retry if (retries += 1) < 3
end

# ... outputs the following:
# try #0
# try #1
# try #2

여기서 몇 가지 주목할 점이 있습니다.

  • retry가 호출되면 begin과 rescue 사이의 코드 전체가 다시 실행됩니다. 중단된 지점부터 이어서 실행되는 것이 절대 아니라는 점을 반드시 기억해야 합니다.

  • 재시도 횟수를 제한하는 장치를 두지 않으면 무한 루프에 빠지게 됩니다.

  • begin 블록과 rescue 블록 모두 상위 스코프의 동일한 retries 변수에 접근할 수 있습니다.

retry의 한계

retry는 훌륭하지만 몇 가지 제약이 있습니다. 가장 큰 문제는 begin 블록 전체가 통째로 다시 실행된다는 점인데, 때로는 이것이 바람직하지 않을 수 있습니다.

예를 들어, 메서드 호출 한 번으로 트위터, 페이스북 등 여러 SNS에 상태 업데이트를 올려주는 젬(gem)을 사용한다고 가정해 보겠습니다. 대략 다음과 같은 형태일 것입니다.

SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")

# ...posts to Twitter API
# ...posts to Facebook API
# ...etc

API 중 하나가 응답하지 않으면 이 젬은 SocialMedia::TimeoutError를 발생시키고 작업을 중단합니다. 이 예외를 잡아 retry하면 어떻게 될까요? 재시도는 처음부터 다시 시작되기 때문에, 이미 성공한 트위터 게시물이 중복으로 올라가는 결과를 낳습니다.

begin
  SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
rescue SocialMedia::TimeoutError
  retry
end

# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# and so on

"페이스북은 건너뛰고, 나머지 API 목록은 계속 진행해 줘"라고 젬에게 지시할 수 있다면 얼마나 좋을까요?

다행히도 Ruby는 정확히 그런 일을 가능하게 해줍니다.

참고: 물론 이 문제의 근본적인 해결책은 해당 SNS 라이브러리를 다시 설계하는 것입니다. 하지만 여기서 소개할 기법은 이 경우에만 유용한 것이 아닙니다.

Continuation(연속체)의 등장

Continuation은 많은 개발자에게 어렵고 낯설게 느껴집니다. 자주 사용되지 않고 생김새도 조금 특이하기 때문입니다. 하지만 기본 원리를 이해하고 나면 생각보다 단순합니다.

Continuation은 코드 안의 "세이브 포인트"와 같습니다. 비디오 게임의 저장 지점처럼, 다른 작업을 하다가 세이브 포인트로 되돌아가면 그 당시 상태 그대로 이어서 진행할 수 있습니다.

완벽한 비유는 아니지만 감은 잡히실 겁니다. 코드를 살펴보겠습니다.

require "continuation"
counter = 0
continuation = callcc { |c| c } # define our savepoint
puts(counter += 1)
continuation.call(continuation) if counter < 5 # jump back to our savepoint

조금 이상해 보이는 부분이 있을 겁니다. 하나씩 짚어보겠습니다.

  • Continuation 객체는 callcc 메서드를 통해 생성합니다. 이를 위한 깔끔한 객체지향 문법은 별도로 존재하지 않습니다.

  • continuation 변수가 처음 할당될 때는 callcc 블록의 반환값이 들어갑니다. 그래서 블록이 반드시 필요합니다.

  • 세이브 포인트로 되돌아갈 때마다 continuation 변수에는 call 메서드에 전달한 인자가 새로 할당됩니다. 그래서 다소 생소한 continuation.call(continuation) 문법을 사용하는 것입니다.

참고: callcc는 현재 구식(deprecated) 기능으로 간주되며 실무에서는 Fiber 등 다른 방식을 사용하는 것이 권장됩니다. 본문의 예제는 학습 목적으로 참고하세요.

예외에 Continuation 적용하기

이제 continuation을 활용해 모든 예외 객체에 skip 메서드를 추가해 보겠습니다. 아래 예제는 의도한 동작을 보여줍니다. 예외를 rescue한 뒤 skip을 호출하면, 예외를 발생시킨 지점이 마치 예외 없이 정상 실행된 것처럼 다음 코드부터 계속 진행됩니다.

begin
  raise "the roof"
  puts "The exception was ignored"
rescue => e
  e.skip
end

# ...outputs "The exception was ignored"

이를 구현하려면 몇 가지 "대죄"를 저질러야 합니다. Exception도 결국 하나의 클래스이므로, 몽키패치(monkeypatch)로 skip 메서드를 추가할 수 있습니다.

class Exception
  attr_accessor :continuation
  def skip
    continuation.call
  end
end

다음으로 모든 예외에 continuation 속성을 설정해야 하는데, 놀랍게도 raise는 그저 하나의 메서드이므로 이를 오버라이드할 수 있습니다.

참고로 아래 코드는 Advi의 훌륭한 슬라이드 "Things You Didn't Know About Exceptions"에서 거의 그대로 가져온 것입니다. 이보다 더 나은 구현 방법을 떠올릴 수가 없었습니다.

require 'continuation'
module StoreContinuationOnRaise
  def raise(*args)
    callcc do |continuation|
      begin
        super
      rescue Exception => e
        e.continuation = continuation
        super(e)
      end
    end
  end
end

class Object
  include StoreContinuationOnRaise
end

이제 어떤 예외든 skip 메서드를 호출하면, 마치 예외가 발생하지 않았던 것처럼 프로그램이 계속 실행됩니다.

실전 적용 시 주의사항

이 기법은 매우 강력하지만 프로덕션 코드에 그대로 적용하기에는 위험 요소가 많습니다. Object에 모듈을 포함시켜 전역 raise를 덮어쓰면 성능 저하와 예기치 못한 부작용이 발생할 수 있으며, callcc 자체도 구식 기능입니다. 따라서 이 글의 continuation 기법은 Ruby의 예외 처리 내부 동작을 깊이 이해하기 위한 학습 자료로 활용하고, 실제 재시도 로직에는 retry와 함께 검증된 재시도 젬(retryable 등)을 사용하는 것을 권장합니다.