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

Ruby에서 예외 발생 시점의 지역 변수와 인스턴스 변수 로깅하기

쉽게 재현할 수 없는 버그를 경험해 본 적이 있으신가요? 앱을 한동안 사용한 사용자에게만 발생하는 것처럼 보이는데, 에러 메시지와 백트레이스는 놀랍도록 도움이 되지 않죠.

바로 이런 순간에 예외가 발생하기 직전의 앱 상태를 스냅샷으로 찍을 수 있다면 정말 유용할 것입니다. 예를 들어 모든 지역 변수와 그 값들의 목록을 확보할 수 있다면 말이죠. 사실 가능합니다. 그것도 생각보다 어렵지 않습니다!

이 글에서는 예외가 발생한 시점에 지역 변수를 캡처하는 방법을 소개합니다. 하지만 그 전에 주의사항부터 말씀드려야겠습니다. 이 기법들은 절대 프로덕션 환경에서 사용해서는 안 됩니다. 스테이징, 프리프로덕션, 개발 환경 등에서는 사용할 수 있지만 프로덕션은 안 됩니다. 여기서 사용할 gem들은 상당히 무거운 인트로스펙션(introspection) 마법에 의존하는데, 최선의 경우 앱 속도가 느려질 뿐이지만 최악의 경우에는... 아무도 장담할 수 없습니다.

binding_of_caller 소개

binding_of_caller gem은 현재 스택의 어떤 수준이든 바인딩(binding)에 접근할 수 있게 해줍니다. 그래서... 이게 정확히 무슨 의미일까요?

"스택(stack)"이란 단순히 현재 "진행 중인" 메서드들의 목록입니다. caller 메서드를 사용하면 현재 스택을 살펴볼 수 있습니다. 간단한 예제를 보겠습니다:

def a
  puts caller.inspect # ["caller.rb:20:in `<main>'"]
  b()
end

def b
  puts caller.inspect # ["caller.rb:4:in `a'", "caller.rb:20:in `<main>'"]
  c()
end

def c
  puts caller.inspect # ["caller.rb:11:in `b'", "caller.rb:4:in `a'", "caller.rb:20:in `<main>'"]
end

a()

바인딩은 현재 실행 컨텍스트의 스냅샷입니다. 아래 예제에서는 메서드의 바인딩을 캡처한 다음, 이를 활용해 해당 메서드의 지역 변수에 접근합니다.

def get_binding
  a = "marco"
  b = "polo"
  return binding
end

my_binding = get_binding

puts my_binding.local_variable_get(:a) # "marco"
puts my_binding.local_variable_get(:b) # "polo"

binding_of_caller gem은 현재 실행 스택의 어떤 수준이든 바인딩에 접근할 수 있게 해줍니다. 예를 들어 이를 활용하면 c 메서드가 a 메서드의 지역 변수에 접근하도록 만들 수 있습니다.

require "rubygems"
require "binding_of_caller"

def a
  fruit = "orange"
  b()
end

def b
  fruit = "apple"
  c()
end

def c
  fruit = "pear"

  # "두 단계 위"의 바인딩을 가져와서 그 바인딩의 지역 변수 "fruit"를 요청합니다
  puts binding.of_caller(2).local_variable_get(:fruit) 
end

a() # "orange" 출력

여기까지 읽으셨다면 두 가지 상반된 감정을 느끼고 계실 겁니다. 하나는 흥분입니다. 정말 멋지니까요. 다른 하나는 혐오감입니다. DHH라고 말하기도 전에 지저분한 의존성의 늪으로 빠져들 수 있으니까요.

예외 발생 시점에 지역 변수 로깅하기

이제 binding_of_caller를 마스터했으니, 예외 발생 시점에 모든 지역 변수를 로깅하는 것은 식은 죽 먹기입니다. 아래 예제에서는 raise 메서드를 오버라이드합니다. 새로 만든 raise 메서드는 자신을 호출한 메서드의 바인딩을 가져온 뒤, 모든 지역 변수를 순회하며 출력합니다.

require "rubygems"
require "binding_of_caller"

module LogLocalsOnRaise
  def raise(*args)
    b = binding.of_caller(1)
    b.eval("local_variables").each do |k|
      puts "Local variable #{ k }: #{ b.local_variable_get(k) }"
    end
    super
  end
end

class Object
  include LogLocalsOnRaise
end

def buggy
  s = "hello world"
  raise RuntimeError
end

buggy()

실제 동작 모습은 다음과 같습니다:

Ruby에서 예외 발생 시점의 지역 변수와 인스턴스 변수 로깅하기

연습 문제: 인스턴스 변수 로깅하기

지역 변수와 함께 인스턴스 변수까지 로깅하는 부분은 독자 여러분의 연습 과제로 남겨두겠습니다. 힌트를 드리자면, my_binding.eval("instance_variables")my_binding.instance_variable_getmy_binding.eval("local_variables")my_binding.local_variable_get을 사용하는 것과 정확히 같은 방식으로 활용할 수 있습니다.

더 쉬운 방법

꽤 멋진 트릭이긴 합니다. 하지만 로그 파일을 일일이 뒤져가며 버그를 수정하는 것은 가장 편리한 방법은 아닙니다. 특히 앱이 스테이징 환경에 있고 여러 사람이 동시에 사용하고 있다면 더욱 그렇습니다. 게다가 유지보수해야 할 코드만 늘어나는 셈이죠.

혹시 Honeybadger로 앱의 에러를 모니터링하고 계신다면, 지역 변수를 자동으로 캡처할 수 있습니다. Gemfile에 binding_of_caller gem을 추가하기만 하면 됩니다:

# Gemfile

group :development, :staging do
  # 이 gem을 포함하면 Honeybadger를 통한 지역 변수 캡처가 활성화됩니다
  gem "binding_of_caller"
  ...
end

이제 예외가 발생할 때마다 백트레이스, 파라미터 등과 함께 모든 지역 변수에 대한 리포트를 받아볼 수 있습니다.

Ruby에서 예외 발생 시점의 지역 변수와 인스턴스 변수 로깅하기