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

Ruby 모듈 중첩 시 반드시 피해야 할 함정들

모듈(그리고 클래스)은 본래 중첩되어 사용하도록 설계되었습니다. ActiveRecord::RecordNotFound 같은 코드 조각은 너무나 익숙해서 우리는 두 번 생각하지 않고도 사용합니다. 하지만 Ruby의 중첩(nesting) 구현 내부와 Rails의 autoload 시스템에는 몇 가지 함정이 숨어 있어, 코드가 이상하고 기묘한 방식으로 실패할 수 있습니다. 이 글에서는 이러한 함정의 원인과 이를 피하는 방법을 살펴보겠습니다.

상수란 무엇인가?

이 글은 모듈에 관한 이야기지만, 모듈을 제대로 이해하려면 먼저 상수(constant)를 이해해야 합니다. 대부분의 언어에서 상수는 아래 예제처럼 간단한 데이터를 저장하는 용도로만 사용됩니다.

# 간단한 상수들
MAX_RETRIES = 5
DEFAULT_LANGUAGE = "en"

그런데 Ruby에서는 클래스와 모듈 자체도 상수입니다. 이를 보여주기 위해 간단한 예제를 준비했습니다. 아래 모듈에는 숫자, 클래스, 그리고 중첩된 모듈이라는 세 개의 상수가 있습니다. 중첩된 클래스나 모듈에 접근할 때 Ruby는 단순한 숫자형 상수를 찾을 때와 완전히 동일한 규칙으로 탐색합니다.

module MyModule
  MY_FAVORITE_NUMBER = 7

  # 클래스도 상수입니다
  class MyClass
  end

  # 모듈 역시 마찬가지입니다
  module MyEmbeddedModule
  end
end

puts MyModule.constants.inspect # => [:MY_FAVORITE_NUMBER, :MyClass, :MyEmbeddedModule]

흔히 모듈은 부모에서 정의된 상수에 접근할 수 있습니다. 그렇기 때문에 다음과 같은 코드를 작성할 수 있는 것입니다.

module X
  MARCO = "polo"
  module Y
    def self.n 
      puts MARCO
    end
  end
end

X::Y.n() # => "polo"

하지만 Y가 X의 상수 MARCO에 접근할 수 있는 이유가 부모/자식 관계 때문이라고 생각한다면, 그것은 오해입니다.

흔히 겪는 문제

위 코드를 약간 다른 방식으로 다시 작성하면 놀라운 일이 벌어집니다. Y가 더 이상 X::MARCO에 접근할 수 없게 됩니다. 도대체 무슨 일이 일어난 걸까요?

module A
  MARCO = "polo"
end

module A::B
  def self.n 
    puts MARCO  # => uninitialized constant A::B::MARCO (NameError)
  end
end

A::B.n()

사실 부모의 상수를 자식이 "상속"받는 것처럼 보이는 현상은 부모/자식 관계 때문이 아니라 어휘적(lexical) 특성 때문입니다. 즉, 이는 코드가 만들어내는 객체 구조가 아니라 코드 자체의 물리적 구조에 기반한다는 뜻입니다.

중첩 검사하기

Ruby가 중첩된 상수를 어떻게 탐색하는지 더 깊이 이해하고 싶다면 Module.nesting 함수를 살펴볼 가치가 충분합니다.

이 함수는 특정 스코프에서 상수를 찾기 위한 "탐색 경로"를 구성하는 객체들의 배열을 반환합니다. 앞서 살펴본 예제들의 중첩 상태를 확인해 보겠습니다.

첫 번째 예제에서는 중첩이 [A::B, A]로 나타납니다. 이는 MARCO 상수를 사용할 때 Ruby가 먼저 A::B에서 찾고, 찾지 못하면 그다음 A에서 찾는다는 의미입니다.

module A
  MARCO = "polo"
  module B
    def self.n 
      puts Module.nesting.inspect  # => [A::B, A]
      puts MARCO # => "polo"
    end
  end
end

두 번째 예제에서는 중첩에 A::B만 포함되고 A는 포함되지 않습니다. B가 A의 "자식"임에도 불구하고, 코드를 작성한 방식이 두 모듈이 중첩된 것으로 보이지 않기 때문에 상수 탐색 관점에서는 둘이 중첩되지 않은 것이나 마찬가지입니다.

module A
  MARCO = "polo"
end

module A::B
  def self.n 
    puts Module.nesting.inspect  # => [A::B]
    puts MARCO # => uninitialized constant A::B::MARCO (NameError)
  end
end

Rails autoload가 만드는 복잡성

Rails를 사용할 때 파일을 일일이 require하지 않아도 된다는 점을 눈치채셨나요? 모델이 필요하면 그냥 바로 사용하면 됩니다.

이것이 가능한 이유는 Rails가 autoload 시스템을 구현하고 있기 때문입니다. Rails는 Module.const_missing을 활용해 아직 로드되지 않은 상수를 참조하려는 시도를 감지하고, 해당 상수를 담고 있다고 판단되는 파일들을 자동으로 로드합니다. 대부분의 경우 잘 작동하지만, 여기에는 함정이 숨어 있습니다.

Rails는 모듈이 항상 가능한 가장 큰 중첩을 가진다고 가정합니다. 즉, A::B::C 모듈은 [A::B::C, A::B, A]라는 중첩을 가질 것이라고 전제합니다. 실제로 그렇지 않다면 예상치 못한 동작이 발생합니다.

아래 코드에서 모듈 B는 원래 A::MARCO에 접근할 수 없어야 합니다. 일반적인 Ruby에서는 B의 중첩이 [A::B]뿐이므로 접근이 불가능하고, 따라서 예외가 발생해야 정상입니다. 하지만 Rails의 autoload는 예외를 던지지 않습니다. 대신 A::MARCO를 반환해 버립니다.

# a.rb
module A
  MARCO = "polo"
end

# a/b.rb
module A::B
  def self.n 
    puts MARCO # => "polo"
  end
end

# some_controller.rb
A::B.n()

결론

여기까지 내용이 꽤 많습니다. 저는 가능한 한 고민을 줄이는 편을 선호하기 때문에 A::B 형태의 모듈 문법은 사용하지 않으려고 노력합니다. 모듈 중첩을 의도적으로 조작해야 할 필요성을 느낀 경우가 거의 없기 때문입니다. 혹시 그런 사례를 알고 계신다면 공유해 주시면 감사하겠습니다!