Ruby 3의 함정들

이 섹션은 Ruby 3 support 작업 중 발견한 여러 문제와, 숨겨진 버그나 이해하기 어려운 테스트 실패로 이어지는 문제들을 문서화합니다. Ruby 코드를 정기적으로 작성하는 GitLab 기여자들은 이러한 문제들을 숙지하는 것을 권장합니다.

Ruby 3 언어와 표준 라이브러리의 변경 사항에 대한 전체 목록을 보려면 Ruby 변경 사항을 참조하세요.

Hash#each는 항상 2개 요소 배열을 lambda에 노출합니다

다음 코드 조각을 고려해보세요.

def foo(a, b)
  p [a, b]
end

def bar(a, b = 2)
  p [a, b]
end

foo_lambda = method(:foo).to_proc
bar_lambda = method(:bar).to_proc

{ a: 1 }.each(&foo_lambda)
{ a: 1 }.each(&bar_lambda)

Ruby 2.7에서, 이 프로그램의 출력은 필수 인자의 개수에 따라 람다에 해시 엔트리를 노출하는 방식이 다르다는 것을 시사합니다.

# Ruby 2.7
{ a: 1 }.each(&foo_lambda) # 출력: [:a, 1]
{ a: 1 }.each(&bar_lambda) # 출력: [[:a, 1], 2]

Ruby 3에서는 이 동작을 일관되게 만들고 항상 해시 엔트리를 단일 [key, value] 배열로 노출하도록 합니다.

# Ruby 3.0
{ a: 1 }.each(&foo_lambda) # `foo': wrong number of arguments (given 1, expected 2) (ArgumentError)
{ a: 1 }.each(&bar_lambda) # 출력: [[:a, 1], 2]

2.7 및 3.0 모두에서 작동하는 코드를 작성하려면 다음 옵션을 고려하세요.

  • 항상 람다 바디를 블록으로 전달하기: { a: 1 }.each { |a, b| p [a, b] }.
  • 람다 인자를 해체하기: { a: 1 }.each(&->((a, b)) { p [a, b] }).

우리는 항상 블록을 명시적으로 전달하고, 블록 매개변수로 두 개의 필수 인자를 선호하는 것을 권장합니다.

더 많은 정보는 Ruby issue 12706을 참조하세요.

Symbol#to_proc는 람다와 일치하는 시그니처 메타데이터를 반환합니다

Ruby에서 자주 사용되는 관용구 중 하나는 &:<symbol>을 사용하여 Proc 객체를 얻고 이를 고계 함수에 전달하는 것입니다.

[1, 2, 3].each(&:to_s)

Ruby는 &:<symbol>Symbol#to_proc로 나타냅니다. 우리는 여기서 (여기서: Integer) 메소드 수신자_를 첫 번째 인자로 하고, 모든 메소드 _인자 (여기서: 없음)를 나머지 인자로하여 호출할 수 있습니다.

이것은 Ruby 2.7 및 Ruby 3 모두에서 동일하게 작동합니다. Ruby 3에서 차이가 나타나는 것은 이 Proc 객체를 캡처하고 그 호출 시그니처를 검사할 때입니다. 이는 보통 DSL을 작성하거나 메타 프로그래밍의 다른 형태를 사용할 때 수행됩니다.

p = :foo.to_proc # 이것은 보통 `&:foo`를 통한 변환을 통해 발생

# Ruby 2.7: 출력 [[:rest]] (-1)
# Ruby 3.0: 출력 [[:req], [:rest]] (-2)
puts "#{p.parameters} (#{p.arity})"

Ruby 2.7는 이 Proc 객체에 대해 필수 인자가 0개이고 선택적 인자가 1개라고 보고하며, Ruby 3는 이 Proc 객체에 대해 필수 인자 1개와 선택적 인자 1개를 보고합니다. Ruby 2.7는 올바르지 않습니다: 첫 번째 인자는 항상 전달되어야 하며, 이것은 Proc 객체가 표현하는 메소드의 수신자이며, 메소드는 수신자 없이 호출될 수 없습니다.

Ruby 3은 이를 수정했습니다: Proc 객체를 검사하는 코드는 이제 깨질 수 있으며 업데이트해야 할 수도 있습니다.

더 많은 정보는 Ruby issue 16260을 참조하세요.

OpenStruct는 필드를 게으르게 계산하지 않습니다

Ruby 3에서 OpenStruct 구현이 일부 변경되어 동작이 변했습니다. Ruby 2.7에서는 OpenStruct가 처음 액세스될 때 메서드를 게으르게 정의했습니다. Ruby 3.0에서는 초기화될 때 이러한 메서드를 즉시 정의하므로, OpenStruct를 상속하고 이러한 메서드를 재정의하는 클래스가 깨질 수 있습니다.

이러한 이유로 OpenStruct를 상속하지 않습니다. 이상적으로는 아예 사용하지 않는 것이 좋습니다. OpenStruct문제가 있다고 여겨집니다. 새 코드를 작성할 때는 보다 단순하지만 덜 유연한 Struct를 대신 사용하는 것이 좋습니다.

RegexpRange 인스턴스가 동결됨

이제는 Regexp 또는 Range 인스턴스를 명시적으로 동결할 필요가 없습니다. Ruby 3은 생성 시 자동으로 그들을 동결시킵니다.

이에 미묘한 부작용이 있습니다: 이러한 유형에서 메서드 호출을 스텁하는 테스트는 더 이상 실패하게 되어 RSpec은 동결된 객체를 스텁할 수 없기 때문에 오류가 발생합니다.

# Ruby 2.7: 작동함
# Ruby 3.0: 에러 - "can't modify frozen object"
allow(subject.function_returning_range).to receive(:max).and_return(42)

동결된 객체에 대한 메서드 호출을 스텁하지 않고 영향을 받는 테스트를 다시 작성하세요. 위의 예제는 다음과 같이 다시 작성할 수 있습니다:

# 모든 Ruby 버전에서 작동
allow(subject).to receive(:function_returning_range).and_return(1..42)

Ruby 3.0.2에서 테이블 테스트가 실패함

Ruby 3.0.2에는 정수 값으로 이루어진 테이블 값이 있는 경우 테이블 테스트가 실패하는 알려진 버그가 있습니다. 해당 이유는 이슈 337614에 문서화되어 있습니다. 이 문제는 Ruby에서 수정되었으며 해당 수정은 Ruby 3.0.3에 포함될 예정입니다.

이 문제는 패치되지 않은 Ruby 3.0.2를 실행하는 사용자에만 영향을 미칩니다. 이는 수동으로 Ruby를 설치하거나 asdf와 같은 도구를 통해 설치한 경우에 해당됩니다. gitlab-development-kit (GDK) 사용자들도 이 문제의 영향을 받습니다.

빌드 이미지는 이 버그를 해결하는 패치 세트를 포함하고 있기 때문에 영향을 받지 않습니다.

DeprecationToolkit에서 메서드를 스텁할 경우 Deprecation이 감지되지 않음

우리는 Ruby 2에서는 사용이 중단되고 Ruby 3에서는 제거된 기능을 사용할 때 빠르게 실패하도록 deprecation_toolkit에 의존하고 있습니다. Ruby 2에서 Ruby 3으로의 전환 중에 발견된 흔한 문제 중 하나는 Ruby 3.0에서 위치 및 키워드 인자의 분리와 관련이 있습니다.

안타깝게도, 작성자가 이러한 메서드를 테스트에서 스텁했다면 Deprecation은 감지되지 않습니다. 우리는 deprecation_toolkit을 통해 이 경고에 대한 자동 감지를 실행하지만, 이는 Kernel#warn이 경고를 방출하는 사실에 의존하고 있으므로 이 호출을 스텁 처리하면 사실상 경고가 제거되어 deprecation_toolkit이 더 이상 deprecation 경고를 보지 못할 것입니다. 구현을 스텁 처리하면 해당 경고가 제거되고 결국 우리는 이를 인식하지 못하기 때문에 빌드는 성공적으로 실행됩니다.

더 많은 정보는 이슈 364099를 참조하세요.

irbrails console에서의 테스트

또 다른 함정은 irb/rails c에서의 테스트입니다. Ruby 2.7.x에서 irb버그로 인해 중단 경고를 표시하지 않습니다.

코드 작성 및 코드 리뷰 시에 f({k: v}) 형식의 메서드 호출에 특별한 주의를 기울여야 합니다. 이는 Ruby 2에서 fHash 또는 키워드 인자를 사용하는 경우 유효하지만, Ruby 3에서는 fHash를 사용하는 경우에만 유효합니다. Ruby 3에서는 만일 f가 키워드 인자를 받는 경우에만 유효합니다.

Ruby 3 호환성을 위해 이는 다음 호출 중 하나로 변경되어야 합니다:

  • f(**{k: v})
  • f(k: v)

RSpec with 인자 매처는 간략한 Hash 구문에서 실패함

Ruby 3에서 키워드 인자(“kwargs”)가 일급 개념이기 때문에 키워드 인자는 더 이상 내부 Hash 인스턴스로 변환되지 않습니다. 이로 인해 RSpec 메서드 인자 매처가 받는 위치 옵션 해시 대신 키워드 인수를 사용할 때 실패합니다.

def m(options={}); end
expect(subject).to receive(:m).with(a: 42)

Ruby 3에서 이 기대는 다음과 같은 오류로 실패합니다:

  실패/Error:

     #<subject>은(는) 예상치 않은 인자와 함께 :m을(를) 수신받았습니다
       예상: ({:a=>42})
            받은: ({:a=>42})

이는 RSpec이 여기서 kwargs 인자 매처를 사용하기 때문에 발생합니다. 그러나 메서드가 해시를 가져야 하는데 해시가 아닌 경우입니다. 이는 a: 42가 먼저 해시로 변환되어 사용되고 RSpec은 해시 인자 매처를 사용하기 때문에 Ruby 2에서 작동합니다.

해결책은 간략한 구문을 사용하지 않고 실제 Hash를 전달하는 것입니다. 이렇게 알고 있는 메서드가 옵션 해시를 받는다면 실제 Hash를 전달해야 합니다.

# key-value 쌍 주위의 중괄호를 주의하세요.
expect(subject).to receive(:m).with({ a: 42 })

더 자세한 정보는 RSpec의 공식 이슈 보고서를 참조하세요.