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

RBS 완벽 이해하기: Ruby 3의 새로운 타입 주석 시스템

RBS는 Ruby를 위한 새로운 타입 정의 문법 형식 언어입니다. RBS를 사용하면 .rbs라는 새로운 확장자를 가진 파일에 Ruby 코드의 타입 주석을 추가할 수 있습니다. 다음과 같은 형태입니다:

class MyClass
  def my_method : (my_param: String) -> String
end

RBS로 타입 주석을 제공하면 다음과 같은 이점을 얻을 수 있습니다:

  • 코드베이스의 구조를 깔끔하고 간결하게 정의할 수 있습니다.
  • 클래스를 직접 수정하는 대신 별도의 파일을 통해 레거시 코드에 안전하게 타입을 추가할 수 있습니다.
  • 정적 및 동적 타입 검사기와 보편적으로 통합될 가능성이 있습니다.
  • 메서드 오버로딩, 덕 타이핑, 동적 인터페이스 등을 처리하는 새로운 기능을 제공합니다.

잠깐만요! 이미 Sorbet이나 Steep 같은 정적 타입 검사기가 있지 않나요?

네, 존재하며 훌륭한 도구들입니다. 하지만 4년간의 논의와 커뮤니티에서 만든 여러 타입 검사기를 거친 후, Ruby 커미터 팀은 타입 검사 도구 구축을 위한 표준을 정의할 때가 되었다고 판단했습니다.

RBS는 공식적으로 하나의 언어이며, Ruby 3와 함께 탄생하고 있습니다.

게다가 Ruby의 동적 타이핑 특성과 덕 타이핑, 메서드 오버로딩 같은 일반적인 패턴 때문에 최선의 접근 방식을 보장하기 위한 몇 가지 대비책이 마련되고 있는데, 이 내용은 곧 자세히 살펴보겠습니다.

Ruby 3 설치

여기서 소개하는 예제를 따라하려면 Ruby 3를 설치해야 합니다.

공식 설치 가이드를 따르거나, 여러 버전의 Ruby를 관리해야 한다면 ruby-build를 통해 설치할 수 있습니다.

또는 현재 프로젝트에 rbs 젬(gem)을 직접 설치할 수도 있습니다:

gem install rbs

정적 타이핑 vs 동적 타이핑

더 나아가기 전에 이 개념을 먼저 명확히 해보겠습니다. 동적 타입 언어와 정적 타입 언어는 어떻게 다를까요?

Ruby나 JavaScript 같은 동적 타입 언어에서는 인터프리터가 런타임 중 금지된 연산이 발생했을 때 어떻게 처리해야 할지 미리 알려주는 사전 정의된 데이터 타입이 없습니다.

이것은 정적 타이핑의 기대와 정반대입니다. Java나 C 같은 정적 타입 언어는 컴파일 시점에 타입을 검증합니다.

다음 Java 코드 조각을 참고해 보세요:

int number = 0;
number = "Hi, number!";

정적 타이핑에서는 두 번째 줄에서 에러가 발생하므로 이런 코드는 불가능합니다:

error: incompatible types: String cannot be converted to int

이제 같은 예제를 Ruby에서 실행해 보겠습니다:

number = 0;
number = "Hi, number!";
puts number # 성공적으로 "Hi, number!" 출력

Ruby에서 변수의 타입은 즉석에서 바뀔 수 있습니다. 즉, 인터프리터가 한 타입에서 다른 타입으로 동적으로 전환하는 방법을 알고 있는 것입니다.

이 개념은 흔히 강타입(strongly typed) vs 약타입(weakly typed) 언어와 혼동되곤 합니다.

Ruby는 동적 타입 언어일 뿐만 아니라 강타입 언어이기도 합니다. 이는 런타임 중 변수의 타입이 변경되는 것은 허용하지만, 무분별한 타입 혼합 연산은 허용하지 않는다는 의미입니다.

앞선 예제를 변형한 다음 예제(Ruby)를 살펴보세요:

number = 2;
sum = "2" + 2;
puts sum

이번에는 서로 다른 타입(IntegerString)에 속한 두 값을 더하려고 시도합니다. Ruby는 다음과 같은 에러를 발생시킵니다:

main.rb:2:in `+': no implicit conversion of Integer into String (TypeError)
  from main.rb:2:in `<main>'

즉, Ruby는 서로 다른 타입이 얽힐 수 있는 복잡한 계산을 수행하는 일은 전적으로 개발자의 몫이라고 말하는 것입니다.

반면 약타입이면서 동시에 동적 타입 언어인 JavaScript는 같은 코드를 다른 결과로 허용합니다:

> number = 2
  sum = "2" + 2
  console.log(sum)
> 22 // 두 값을 문자열로 연결(concatenate)

RBS는 Sorbet과 어떻게 다른가?

우선, 코드에 타입을 주석으로 달 때 각 도구가 취하는 접근 방식이 다릅니다. Sorbet은 코드 전체에 명시적으로 주석을 추가하는 방식으로 작동하는 반면, RBS는 단순히 .rbs 확장자를 가진 새 파일을 생성하기만 하면 됩니다.

이 방식의 가장 큰 장점은 레거시 코드베이스를 마이그레이션할 때 드러납니다. 원본 파일이 영향을 받지 않으므로 프로젝트에 RBS 파일을 훨씬 안전하게 도입할 수 있습니다.

RBS 제작자들에 따르면, RBS의 주요 목표는 Ruby 프로그램의 구조(structure)를 기술하는 것입니다. 클래스/메서드 시그니처 정의에만 초점을 맞춥니다.

RBS 자체는 타입 검사를 수행할 수 없습니다. 그 목표는 Sorbet이나 Steep 같은 타입 검사기가 제 역할을 할 수 있도록 구조를 정의하는 기반을 마련하는 데 국한됩니다.

간단한 Ruby 상속 예제를 살펴보겠습니다:

class Badger
    def initialize(brand)
      @brand = brand
    end

    def brand?
      @brand
    end
end

class Honey < Badger
  def initialize(brand: "Honeybadger", sweet: true)
    super(brand)
    @sweet = sweet
  end

  def sweet?
    @sweet
  end
end

좋습니다! 몇 가지 속성과 추론된 타입을 가진 두 개의 클래스일 뿐입니다.

아래는 가능한 RBS 표현 방식입니다:

class Brand
  attr_reader brand : String

  def initialize : (brand: String) -> void
end

class Honey < Brand
  @sweet : bool

  def initialize : (brand: String, ?sweet: bool) -> void
  def sweet? : () -> bool
end

꽤 비슷하죠? 가장 큰 차이점은 타입입니다. 예를 들어 Honey 클래스의 initialize 메서드는 하나의 String 매개변수와 하나의 boolean 매개변수를 받고 아무것도 반환하지 않습니다.

한편, Sorbet 팀은 RBI(Sorbet의 기본 타입 정의 확장자)와 RBS 간의 상호 운용성을 가능하게 하는 도구 제작 작업을 진행 중입니다.

목표는 Sorbet을 비롯한 모든 타입 검사기가 RBS의 타입 정의 파일을 활용하는 방법을 이해할 수 있는 토대를 마련하는 것입니다.

스캐폴딩 도구

RBS를 처음 시작하면서 이미 진행 중인 프로젝트가 있다면, 어디서 어떻게 타입 지정을 시작해야 할지 감잡기 어려울 수 있습니다.

이를 고려하여 Ruby 팀은 기존 클래스의 타입을 스캐폴딩(scaffolding)할 수 있는 매우 유용한 CLI 도구인 rbs를 제공합니다.

사용 가능한 명령어 목록을 확인하려면 콘솔에 rbs help를 입력하고 결과를 확인하면 됩니다:

RBS 완벽 이해하기: Ruby 3의 새로운 타입 주석 시스템 CLI 도구에서 사용 가능한 명령어 목록.

목록에서 가장 중요한 명령어는 아마도 prototype일 것입니다. 이 명령은 매개변수로 제공된 소스 코드 파일의 AST를 분석하여 "근사적인(approximate)" RBS 코드를 생성합니다.

근사적이라는 이유는 100% 정확하지 않기 때문입니다. 레거시 코드는 대부분 타입이 지정되어 있지 않으므로, 스캐폴딩된 내용 대부분도 그대로 나옵니다. 예를 들어 명시적인 할당이 없으면 RBS는 일부 타입을 추측할 수 없습니다.

또 다른 예제를 참고해 보겠습니다. 이번에는 계층적 상속 구조를 가진 세 개의 서로 다른 클래스가 등장합니다:

class Animal
    def initialize(weight)
      @weight = weight
    end

    def breathe
      puts "Inhale/Exhale"
    end
end

class Mammal < Animal
    def initialize(weight, is_terrestrial)
      super(weight)
      @is_terrestrial = is_terrestrial
    end

    def nurse
      puts "I'm breastfeeding"
    end
end

class Cat < Mammal
    def initialize(weight, n_of_lives, is_terrestrial: true)
        super(weight, is_terrestrial)
        @n_of_lives = n_of_lives
    end

    def speak
        puts "Meow"
    end
end

속성과 메서드를 가진 단순한 클래스들입니다. 그중 하나는 기본 boolean 값이 제공되는데, 이는 RBS가 스스로 추론할 수 있는 능력을 시연하는 데 중요합니다.

이제 이 타입들을 스캐폴딩하기 위해 다음 명령을 실행해 보겠습니다:

rbs prototype rb animal.rb mammal.rb cat.rb

원하는 만큼 많은 Ruby 파일을 전달할 수 있습니다. 다음은 실행 결과입니다:

class Animal
  def initialize: (untyped weight) -> untyped

  def breathe: () -> untyped
end

class Mammal < Animal
  def initialize: (untyped weight, untyped is_terrestrial) -> untyped

  def nurse: () -> untyped
end

class Cat < Mammal
  def initialize: (untyped weight, untyped n_of_lives, ?is_terrestrial: bool is_terrestrial) -> untyped

  def speak: () -> untyped
end

예상했듯이, RBS는 클래스를 만들 때 우리가 의도했던 대부분의 타입을 이해하지 못했습니다.

여러분의 주된 작업은 untyped 참조를 실제 타입으로 수동 변경하는 것이 될 것입니다. 이를 더 잘 수행할 수 있는 방법을 찾기 위한 논의가 현재 커뮤니티에서 진행되고 있습니다.

메타프로그래밍

메타프로그래밍과 관련해서는 그 동적인 특성 때문에 rbs 도구가 큰 도움이 되지 못합니다.

다음 클래스를 예로 들어 보겠습니다:

class Meta
    define_method :greeting, -> { puts 'Hi there!' }
end

Meta.new.greeting

이 타입을 스캐폴딩한 결과는 다음과 같습니다:

class Meta
end

덕 타이핑(Duck Typing)

Ruby는 객체의 본질(타입)에 대해 크게 신경 쓰지 않지만, 객체가 무엇을 할 수 있는지(행동)는 중요하게 생각합니다.

덕 타이핑은 다음 격언에 따라 작동하는 유명한 프로그래밍 스타일입니다:

"어떤 객체가 오리처럼 행동한다면(말하고, 걷고, 날는다면), 그것은 오리다"

다시 말해, 원래의 정의와 타입이 오리를 나타내도록 설계되지 않았더라도 Ruby는 항상 그것을 오리처럼 다룹니다.

그러나 덕 타이핑은 코드 구현 세부 사항을 숨길 수 있어, 찾거나 읽기 어려운 문제로 쉽게 번질 수 있습니다.

RBS는 구체적인 클래스나 모듈에 의존하지 않는 메서드 집합인 인터페이스 타입(interface types) 개념을 도입했습니다.

앞선 동물 상속 예제를 가져와서, Cat이 상속받을 육상 동물을 위한 새로운 계층 레벨을 추가한다고 가정해 보겠습니다:

class Terrestrial < Animal
    def initialize(weight)
        super(weight)
    end

    def run
        puts "Running..."
    end
end

육상 동물이 아닌 자식 객체가 달리지(run) 못하도록 하려면, 해당 동작에 대한 특정 타입을 검사하는 인터페이스를 만들 수 있습니다:

interface _CanRun
  # `Terrestrial` 객체를 받는 `<<` 연산자 필요.
  def <<: (Terrestrial) -> void
end

RBS 코드를 실제 run 메서드에 매핑하면 다음과 같은 시그니처가 됩니다:

def run: (_CanRun) -> void

누군가 Terrestrial 객체가 아닌 것을 메서드에 전달하려 할 때마다, 타입 검사기가 반드시 에러를 기록합니다.

유니언 타입(Union Types)

Ruby 개발자들 사이에서는 서로 다른 타입의 값을 담는 표현식도 흔합니다.

def fly: () -> (Mammal | Bird | Insect)

RBS는 파이프(pipe) 연산자로 타입들을 연결하는 방식으로 유니언 타입을 지원합니다.

메서드 오버로딩(Method Overloading)

또 다른 흔한 관례(실제로 많은 프로그래밍 언어에서 사용됨)는 메서드 오버로딩을 허용하는 것입니다. 이는 클래스가 이름은 같지만 시그니처(매개변수의 타입이나 개수, 순서 등)가 다른 여러 메서드를 가질 수 있음을 의미합니다.

동물이 가장 가까운 진화적 친척들을 반환하는 예제를 살펴보겠습니다:

def evolutionary_cousins: () -> Enumerator[Animal, void] | { (Animal) -> void } -> void

이렇게 하면 RBS를 통해 특정 동물이 단일 진화적 친척을 가질지 아니면 여러 친척을 가질지 명시적으로 결정할 수 있습니다.

TypeProf

한편, Ruby 팀은 typeprof라는 새로운 프로젝트도 시작했습니다. 이는 RBS 콘텐츠를 분석하고 (시도하든) 생성하는 것을 목표로 하는 실험적인 타입 수준의 Ruby 인터프리터입니다.

추상 해석(abstract interpretation) 방식으로 작동하며 아직 개선의 첫걸음을 내디디고 있는 단계이므로, 프로덕션 용도로 사용할 때는 주의가 필요합니다.

설치는 프로젝트에 젬을 추가하기만 하면 됩니다:

gem install typeprof

Ruby 2.7 이상 버전이 필요하다는 점에 유의하세요.

다음 버전의 Animal 클래스를 살펴보겠습니다:

class Animal
    def initialize(weight)
      @weight = weight
    end

    def die(age)
      if age > 50
        true
      elsif age <= 50
        false
      elsif age < 0
        nil
      end
    end
end

Animal.new(100).die(65)

die 메서드 내부에서 일어나는 일과 해당 메서드의 호출을 기반으로, TypeProf는 코드에서 다뤄지는 타입을 지능적으로 추론할 수 있습니다.

typeprof animal.rb 명령을 실행하면 다음과 같은 출력 결과를 얻을 수 있습니다:

## Classes
class Animal
  @weight: Integer

  def initialize: (Integer weight) -> Integer
  def die: (Integer age) -> bool?
end

이미 방대한 코드가 존재하는 프로젝트에 많은 것을 제공할 수 있는 강력한 도구입니다.

VS Code 통합

현재 포매팅, 구조 검사 등 RBS를 다루기 위한 VS Code 플러그인은 많지 않습니다. RBS가 아직 비교적 새로운 기술이기 때문입니다.

하지만 마켓플레이스에서 "RBS"를 검색하면 아래와 같이 문법 강조(syntax highlighting)에 도움을 주는 ruby-signature라는 플러그인을 찾을 수 있습니다:

RBS 완벽 이해하기: Ruby 3의 새로운 타입 주석 시스템 VS Code에서의 RBS 문법 강조 모습.

결론

RBS는 아주 신선한 기술이지만 이미 더 안전한 Ruby 코드베이스를 향한 중요한 한 걸음을 의미합니다.

시간이 지나면 Rails 애플리케이션을 위한 RBS 파일을 생성해주는 RBS Rails처럼 이를 지원하는 새로운 도구와 오픈소스 프로젝트들이 생겨날 것입니다.

Ruby 커뮤니티에는 더 안전하고 버그 없는 애플리케이션이라는 멋진 미래가 기다리고 있습니다. 그 미래가 빨리 오기를 기대해 봅니다!