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

Ruby 타입 검사 완벽 가이드 — 버그가 코드를 망가뜨리기 전에 미리 잡아내세요

재미있는 퀴즈로 글을 시작해 보겠습니다. AppSignal이 Ruby 애플리케이션에서 추적하는 가장 흔한 에러가 무엇일까요?

많은 분들이 NoMethodError라고 답하셨을 것입니다. 이 예외는 객체에 존재하지 않는 메서드를 호출할 때 발생합니다. 가끔은 메서드 이름의 오타 때문에 생기기도 하지만, 대부분은 잘못된 타입의 객체에 메서드를 호출했기 때문입니다. 그리고 그 주범은 십중팔구 예상치 못한 nil입니다. Ruby 개발자로서 이런 에러의 발생 빈도를 줄일 방법은 없을까요?

타입 시스템이 해결책일까?

텍스트 에디터나 프로그래밍 언어 선택 논쟁을 제외하면, 타입 시스템만큼 빠르게 뜨거운 논쟁으로 번질 수 있는 주제는 드뭅니다. 여기서 세부 사항까지 다루기는 어렵지만, Chris Smith의 글 "What To Know Before Debating Type Systems"가 이 주제를 아주 잘 정리해 주고 있으니 참고하시기 바랍니다.

가장 넓은 의미에서 타입 시스템은 정적(static)과 동적(dynamic) 두 가지로 나눌 수 있습니다. 전자는 컴파일러나 별도의 도구를 통해 사전에 수행되는 반면, 후자는 런타임 중에 검사가 이루어지며 실제 타입이 개발자의 기대와 맞지 않으면 예외를 발생시킬 수 있습니다.

두 진영 모두 강한 신념을 가지고 있지만, 안타깝게도 오해도 많습니다. 정적 타이핑이 반드시 방대한 타입 어노테이션을 요구하는 것은 아닙니다. 많은 현대 컴파일러는 '타입 추론(type inference)'이라 불리는 과정을 통해 스스로 타입을 파악할 수 있습니다. 반대로 동적 타입 언어가 정적 타입 언어보다 결함률이 유의미하게 높다는 증거도 없습니다.

덕 타이핑(Duck Typing)

Ruby는 동적으로 타입을 검사하는 언어이며 '덕 타이핑' 방식을 따릅니다.

오리처럼 걷고 오리처럼 꽥꽥 운다면, 그것은 분명 오리다.

즉, Ruby 개발자는 객체의 타입 자체보다 해당 객체가 특정 '메시지'(메서드)에 응답하는지 여부에 더 관심을 둡니다.

'그럼 왜 굳이 Ruby에 정적 타이핑을 도입해야 하는가?'라고 물으실 수 있습니다. 물론 코드를 마법처럼 버그 없게 만들어주는 만능 해결책은 아니지만, 몇 가지 확실한 이점이 있습니다.

  • 정확성: 정적 타이핑은 앞서 언급한 NoMethodError 같은 특정 클래스의 버그를 예방하는 데 탁월합니다.
  • 툴링: 개발 과정에서 정적 타입 정보를 활용할 수 있으면 IDE의 리팩토링 지원 등 더 나은 개발 도구 환경을 누릴 수 있습니다.
  • 문서화: 많은 정적 타입 언어는 훌륭한 내장 문서화 도구를 갖추고 있습니다. Haskell의 Hoogle은 함수를 타입 시그니처로 검색할 수 있는 엔진을 제공하며 이를 큰 강점으로 삼습니다.
  • 성능: 컴파일러에게 제공되는 정보가 많을수록 적용 가능한 성능 최적화의 폭도 넓어집니다.

이 목록이 전부는 아니며, 각 항목마다 반례를 찾을 수 있지만 분명 일리 있는 핵심입니다.

점진적 타입 검사(Gradual Type Checking)

최근 몇 년간 '점진적 타입 검사'라 불리는 접근법이 다양한 동적 타입 언어에 도입되었습니다. JavaScript의 TypeScript, PHP의 Hack, Python의 mypy가 대표적입니다. 이들의 공통점은 전부 아니면 전무(all-or-nothing) 방식이 아니라, 개발자가 판단하기에 적절한 곳부터 변수와 표현식에 점진적으로 타입 정보를 추가할 수 있다는 점입니다. 기존의 대규모 코드베이스에서 특히 유용한데, 시스템의 핵심 부분만 정적으로 검사하고 나머지는 타입 없이 런타임에 검사하도록 남겨둘 수 있기 때문입니다. 이 글에서 소개할 Ruby의 모든 타입 검사 솔루션도 같은 접근법을 취합니다.

선택할 수 있는 옵션들

Ruby 개발 워크플로우에 정적 타입 검사를 추가하고 싶은 이유를 살펴봤으니, 이제 현재 인기 있는 옵션들을 알아볼 차례입니다. 다만 Ruby에 정적 타입 검사를 도입하려는 시도가 새로운 발상은 아니라는 점을 짚고 넘어갈 필요가 있습니다. 메릴랜드 대학교 연구진은 이미 2009년 Diamondback Ruby(Druby)라는 Ruby 확장을 연구했고, 2013년에는 터프츠 대학교 프로그래밍 언어 그룹이 'The Ruby Type Checker' 논문을 발표했습니다. 이는 결국 RDL 프로젝트로 이어졌으며, RDL은 라이브러리 형태로 타입 검사와 계약에 의한 설계(design-by-contract) 기능을 제공합니다.

Sorbet

Stripe가 개발한 Sorbet은 현재 가장 화제가 되는 Ruby 타입 검사 솔루션입니다. Shopify, GitLab, Kickstarter, Coinbase 같은 대형 기업들이 클로즈드 베타 단계부터 얼리 어답터로 참여한 것도 한몫했습니다. 작년 Ruby Kaigi에서 처음 공개되었고 올해 6월 20일 첫 공개 릴리스를 선보였습니다. Sorbet은 모던 C++로 작성되었으며, Matz(마츠모토 유키히로)의 취향(인용: "나는 타입 어노테이션이 싫다")과 달리 타입 어노테이션 기반의 접근법을 선택했습니다. 특히 흥미로운 점은 Ruby의 극도로 동적인 성격과 메타프로그래밍 능력이 정적 타입 시스템에 큰 도전 과제이기 때문에, 정적 검사와 동적 검사를 조합한 방식을 채택했다는 것입니다.

# typed: true
class Test
  extend T::Sig
 
  sig {params(x: Integer).returns(String)}
  def to_s(x)
    x.to_s
  end
end

타입 검사를 활성화하려면 먼저 # typed: true 매직 코멘트를 추가하고 클래스에 T::Sig 모듈을 extend해야 합니다. 실제 타입 어노테이션은 sig 메서드로 지정합니다.

sig {params(x: Integer).returns(String)}

이 코드는 해당 메서드가 Integer 타입의 인자 x 하나를 받아 String을 반환한다는 의미입니다. 잘못된 타입의 인자로 호출하면 에러가 발생합니다.

Test.new.to_s("42")
# Expected Integer but found String("42") for argument x

이런 기본 검사 외에도 Sorbet은 여러 강력한 기능을 갖추고 있습니다. 예를 들어, 우리를 가장 두려워하는 nil에 대한 NoMethodError로부터 구해줄 수 있습니다.

users = T::Array[User].new
user = users.first
user.username
 
# Method username does not exist on NilClass component of T.nilable(User)

위 코드는 비어 있는 User 배열을 정의하고 첫 번째 요소(nil이 반환됨)에 접근하려 합니다. Sorbet은 NilClass에는 username이라는 메서드가 없다고 정확히 경고해 줍니다. 반면 어떤 값이 절대 nil일 수 없다고 확신한다면 T.must를 사용해 Sorbet에게 알려줄 수 있습니다.

users = T::Array[User].new
user = T.must(users.first)
user.username

위 코드는 이제 타입 검사를 통과하지만, 런타임 예외를 유발할 가능성이 있으므로 이 기능은 신중하게 사용해야 합니다.

Sorbet이 할 수 있는 일은 더 많습니다. 데드 코드 감지, 타입 고정(예: 변수에 문자열이 한번 할당되면 정수를 다시 할당할 수 없도록 타입을 고정), 인터페이스 정의 등이 가능합니다.

또한 Sorbet은 현재 작업 디렉토리의 sorbet/ 폴더에 보관하는 'Ruby Interface' 파일(.rbi)과 함께 작동할 수 있습니다. 이를 통해 프로젝트가 사용하는 모든 젬(gem)에 대한 인터페이스 정의를 생성할 수 있고, 더 많은 타입 에러를 찾는 데 도움이 됩니다.

다양한 엄격도 수준이나 메타프로그래밍 플러그인 등 한 글에서 다룰 수 없는 기능이 많지만, 문서화가 상당히 잘 되어 있고 PR도 환영하고 있습니다.

Steep

Sorbet의 가장 널리 알려진 대안은 Soutaro Matsumoto가 만든 Steep입니다. Steep은 어노테이션을 사용하지 않고 자체적인 타입 추론도 수행하지 않습니다. 대신 sig 디렉토리의 .rbi 파일에 전적으로 의존합니다.

다음과 같은 간단한 Ruby 클래스로 시작해 보겠습니다.

class User
  attr_reader :first_name, :last_name, :address
 
  def initialize(first_name, last_name, address)
    @first_name = first_name
    @last_name = last_name
    @address = address
  end
 
  def full_name
    "#{first_name} #{last_name}"
  end
end

이제 다음 명령으로 초기 user.rbi 파일을 생성할 수 있습니다.

$ steep scaffold user.rb > sig/user.rbi

생성된 파일은 시작점으로 사용하기 위한 것입니다. 모든 타입이 안전성을 전혀 보장하지 않는 any로 지정되어 있다는 점에서 알 수 있습니다.

class User
  @first_name: any
  @last_name: any
  @address: any
  def initialize: (any, any, any) -> any
  def full_name: () -> String
end

하지만 이 상태에서 타입 검사를 실행하면 몇 가지 에러가 발생합니다.

$ steep check
user.rb:11:7: NoMethodError: type=::User, method=first_name (first_name)
user.rb:11:21: NoMethodError: type=::User, method=last_name (last_name)

Steep이 attr_reader를 통해 정의된 메서드를 인식하려면 특별한 주석이 필요하기 때문입니다. 해당 주석을 추가해 보겠습니다.

# @dynamic first_name, last_name, address
attr_reader :first_name, :last_name, :address

추가로 생성된 .rbi 파일에 메서드 정의를 넣어야 합니다. 이참에 시그니처도 any에서 실제 타입으로 변경하겠습니다.

class User
  @first_name: String
  @last_name: String
  @address: Address
  def initialize: (String, String, Address) -> any
  def first_name: () -> String
  def last_name: () -> String
  def address: () -> Address
  def full_name: () -> String
end

이제 모든 것이 기대대로 작동하며 steep check는 어떤 에러도 반환하지 않습니다.

지금까지 살펴본 기능 외에도 Steep은 제네릭(예: Hash<Symbol, String>)과 유니언 타입(여러 타입 중 하나)을 지원합니다. 예를 들어 사용자의 top_post 메서드는 사용자가 작성한 최상위 게시글을 반환하거나, 아직 작성한 글이 없다면 nil을 반환할 수 있습니다. 이는 유니언 타입 (Post | nil)로 표현되며, 해당 시그니처는 다음과 같습니다.

def top_post: () -> (Post | nil)

Steep은 분명 Sorbet보다 기능이 적지만 여전히 유용한 도구이며, Matz가 구상한 Ruby 3의 타입 검사 방향에 더 부합하는 것으로 보입니다.

Ruby Type Profiler

Cookpad의 Yusuke Endoh(Ruby 개발자 커뮤니티에서는 'mame'으로 더 잘 알려져 있음)는 레벨 1 타입 검사기라 불리는 Ruby Type Profiler를 개발하고 있습니다. 여기서 소개한 다른 솔루션들과 달리 시그니처 파일이나 타입 어노테이션이 필요 없으며, 대신 Ruby 프로그램을 파싱하면서 가능한 한 많은 정보를 추론하려 시도합니다. Steep이나 Sorbet보다 훨씬 적은 잠재적 문제를 잡아내지만, 개발자에게 추가 비용이 전혀 들지 않는다는 장점이 있습니다.

마무리

미래를 예측하는 일은 불가능하지만, Ruby의 타입 검사는 이제 확착된 흐름으로 보입니다. 현재 .rbi 파일(Ruby Type Profiler로 생성 가능)에 사용될 'Ruby Signature Language'를 표준화하려는 노력이 진행 중이며, 이를 통해 개발자들은 어떤 도구든 자유롭게 선택할 수 있게 될 것입니다. Steep은 이미 라이브러리 저자가 젬에 타입 정보를 포함해 배포할 수 있게 지원하고, Sorbet 역시 TypeScript 정의 저장소 DefinitelyTyped에서 영감을 받은 sorbet-typed라는 유사한 메커니즘을 갖추고 있습니다. Ruby 타입 검사의 미래를 함께 만들어가고 싶으시다면, 지금이 참여하기 가장 좋은 시기입니다!