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

Rust의 Option 패턴으로 Ruby nil 처리 개선하기

지난 6개월 동안 저는 Rust로 NES 에뮬레이터를 만들어 왔습니다. 예상하시겠지만, 이 과정에서 Rust에 대해 많은 것을 배웠고, NES 내부 구조에 대해서는 더 많이 배웠습니다. 그런데 흥미롭게도 이 경험은 제가 Ruby를 바라보는 방식까지 바꿔 놓았습니다.

특히 nil을 반환하는 메서드를 볼 때면 어딘가 불안해졌습니다.

확실한 원칙이 없으면 무엇이든 속아 넘어가기 마련

Ruby에서 nil은 정확히 무엇을 의미할까요? 사실상 거의 모든 것이 가능합니다. 메서드가 nil을 반환할 때 그 의미는 다음 중 하나일 수 있습니다.

  • 메서드에 반환값이 없는 경우
  • 보통은 반환값이 있지만 이번에는 없는 경우
  • 데이터베이스에서 가져온 값이 NULL인 경우
  • 예상치 못한 일이 발생한 경우

이런 모호함 때문에 코드를 읽기 어려워지고, Ruby에서 가장 흔한 예외인 NoMethodError의 주된 원인이 됩니다. 예외 모니터링 서비스를 함께 운영하는 사람으로서, NoMethodError 덕분에 아이 학비를 벌고 있다고 농담을 할 수 있을 정도입니다.

다음 코드를 살펴보세요. if 문은 조건이 맞지 않고 else 절이 없을 때 nil로 평가되기 때문에, 이 메서드는 대부분의 경우 nil을 반환합니다.

def color_name(rgb)
  if rgb == 0x000000
    "black"
  end
end

color_name("#FFFFFF").titleize
=> NoMethodError: undefined method `titleize' for nil:NilClass

경험 많은 Ruby 개발자라면 이 문제가 훨씬 더 깊은 함정임을 알고 있을 것입니다. 때로는 nil의 여러 의미가 기묘하게 겹쳐서, 예를 들어 데이터베이스의 값이 실제 NULL인지, 아니면 애초에 값 자체가 존재하지 않는지조차 구분할 수 없게 됩니다.

더 나은 방법: Rust의 Option

Rust에는 nil이라는 개념이 없습니다. 대신 함수가 어떤 경우에는 값을 반환하고 어떤 경우에는 "아무것도" 반환하지 않는다는 사실을 나타낼 때 Option을 사용합니다.

Option은 특정 값을 담거나 아무 값도 담지 않는 타입입니다. 코드로 표현하면 다음과 같습니다.

Option::Some(42); // 숫자 42를 Option으로 감쌈
Option::None;     // "결과 없음"을 나타냄

이미 임시변통으로 쓰던 nil보다 낫지만, 진짜 좋은 점은 따로 있습니다. Rust 컴파일러는 None 케이스를 반드시 고려하도록 강제합니다. 실수로 무시하는 것이 불가능합니다.

match my_option {
  Some(x) => do_something_with_x(x),
  // 아래의 `None` 매칭을 제거하면
  // 이 코드는 컴파일되지 않습니다.
  None => do_the_default_thing()
}

그래서 앞서 본 색상 이름 예제를 Rust로 작성하면 다음과 같습니다.

fn color_name(rgb: u32) -> Option<String> {
    if rgb == 0x000000 {
      Some("black".to_owned())
    } else {
      None
    }
}

이제 SomeNone 두 가지 경우를 모두 처리해야만 합니다.

let name = match color_name(0xFFFFFF) {
  Some(value) => value,
  None => "unknown".to_owned(),
};

물론 다소 장황하고 낯설게 보일 수 있습니다. 하지만 함수가 유용한 값을 반환하지 않는 경우를 무시하는 것이 원천적으로 불가능해집니다. 그 결과, 이해하고 유지보수하기 쉬운 코드가 만들어집니다.

Ruby에서 Option 구현하기

Ruby는 엄격함이 부족한 대신 유연함으로 그 부족함을 보완합니다. 그래서 Ruby로 Option과 비슷한 것을 구현해 보면 재미있겠다고 생각했습니다.

Ruby는 인터프리터 언어이므로 컴파일 타임 오류를 만들 수는 없습니다. 하지만 잘못된 코드가 드문 엣지 케이스에서만 예외를 던지는 것이 아니라, 항상 예외를 발생시키도록 만들 수는 있습니다.

먼저 두 개의 클래스를 만들어 보겠습니다. Some은 읽기 전용 값을 담고, None은 비어 있습니다. 겉보기만큼 단순합니다.

class Some
  attr_reader :value
  def initialize(value)
    @value = value
  end
end

class None
end

다음으로 Some 또는 None을 담는 Option 클래스를 만듭니다. 이 클래스는 두 경우 모두에 대한 핸들러를 제공했을 때만 내부 값에 접근할 수 있게 허용합니다.

class Option
  def initialize(value)
    @value = value
  end

  def self.some(value)
    self.new(Some.new(value))
  end

  def self.none()
    self.new(None.new)
  end

  def match(some_lambda, none_lambda)
    if @value.is_a?(Some)
      some_lambda.call(@value.value)
    elsif @value.is_a?(None)
      none_lambda.call()
    else
      raise "Option value must be either Some or None"
    end
  end
end

마지막으로 새로운 Option 클래스를 사용해 색상 이름 예제를 다시 작성해 보겠습니다.

def color_name(rgb)
  if rgb == 0x000000
    Option.some("black")
  else
    Option.none()
  end
end

puts color_name(0x000000).match(
  -> value { value },
  -> { "no match" })

# "black" 출력

결론

아직 실제 프로젝트에서 이 기법을 적용해 보지는 못했습니다. 하지만 운영 환경에 늘 스며드는 NoMethodError를 상당수 예방할 수 있을 것이라 기대합니다. 물론 다소 번거로워 보이고 Ruby스럽지 않다는 점은 인정합니다. 그래도 몇 차례 다듬으면 더 쾌적하고 자연스러운 문법이 나올 거라고 믿습니다.