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

Rails에서 dry-monads로 구현하는 철도 지향 프로그래밍(Railway Oriented Programming)

에러 처리는 모든 프로그램에서 필수적인 부분입니다. 코드를 작성할 때 어떤 오류가 발생할 수 있는지 미리 파악하고 능동적으로 대비하는 것이 중요합니다. 또한 발생한 오류가 무엇인지, 애플리케이션의 어느 단계에서 발생했는지 정확히 설명하는 결과를 산출하는 방식으로 처리해야 합니다. 그러면서도 코드의 기능성과 가독성을 해치지 않아야 하죠. 이미 궁금해하셨을 질문부터 시작해 보겠습니다. 철도 지향 프로그래밍(Railway Oriented Programming)이란 무엇일까요?

철도 지향 프로그래밍이란?

특정 목적을 달성하는 하나의 함수는 더 작은 함수들의 조합으로 이루어질 수 있습니다. 이 함수들은 각각 다른 단계를 수행하며, 그 과정이 쌓여 최종 목표에 도달합니다. 예를 들어 데이터베이스에서 사용자의 주소를 업데이트한 뒤 변경 사항을 사용자에게 알리는 함수는 다음과 같은 단계로 구성될 수 있습니다.

사용자 검증 -> 주소 업데이트 -> 업데이트 성공 시 메일 전송

각 단계는 성공하거나 실패할 수 있으며, 어느 하나라도 실패하면 함수의 목적이 달성되지 않았기 때문에 전체 프로세스가 실패로 끝납니다.

Scott Wlaschin이 고안한 철도 지향 프로그래밍(ROP)은 바로 이런 형태의 함수에서 에러 처리에 '철도 분기점(railway switch)' 비유를 적용한 개념입니다. 철도 분기점은 기차를 한 선로에서 다른 선로로 안내하는 장치죠. Scott은 각 단계의 성공/실패 출력이 철도 분기점처럼 작동하여, 우리를 성공 트랙 또는 실패 트랙으로 보낸다는 의미로 이 비유를 사용했습니다.

Rails에서 dry-monads로 구현하는 철도 지향 프로그래밍(Railway Oriented Programming) 성공/실패 출력이 철도 분기점처럼 작동하는 모습

어떤 단계에서 오류가 발생하면 실패 출력에 의해 실패 트랙으로 전환되어 나머지 단계를 건너뛰게 됩니다. 반대로 성공 출력이 나오면 다음 단계의 입력과 연결되어 최종 목적지까지 진행됩니다. 아래 이미지를 참고하세요.

Rails에서 dry-monads로 구현하는 철도 지향 프로그래밍(Railway Oriented Programming) 여러 단계의 성공/실패 출력이 연결된 모습

바로 이 두 개의 선로라는 비유가 철도 지향 프로그래밍의 핵심 아이디어입니다. ROP는 프로세스를 구성하는 모든 단계(즉, 모든 메서드)마다 성공/실패 결과를 반환함으로써, 한 단계의 실패가 곧 전체 프로세스의 실패임을 보장합니다. 오직 모든 단계가 성공적으로 완료되었을 때만 전체가 성공하게 됩니다.

일상 속 예시

The Milk Shakers라는 매장에 직접 가서 우유 한 통을 사야 하는 목표가 있다고 상상해 봅시다. 예상되는 단계는 다음과 같습니다.

집에서 출발한다 -> The Milk Shakers에 도착한다 -> 우유 한 통을 집는다 -> 우유 값을 결제한다

집에서 나갈 수 없다면 첫 번째 단계부터 실패이므로 전체 프로세스도 실패입니다. 그렇다면 집을 나서 월마트(Walmart)에 갔다면 어떨까요? 지정된 매장에 가지 않았기 때문에 이 역시 실패입니다. 월마트에서 우유를 살 수 있다 해도 프로세스가 계속 진행되는 것은 아닙니다. ROP는 월마트에서 프로세스를 멈추고, "매장이 The Milk Shakers가 아니어서 실패했다"는 실패 결과를 반환합니다. 올바른 매장에 갔다면 프로세스는 계속 진행되며, 각 단계의 출력을 확인하면서 종료하거나 다음 단계로 넘어갑니다.

이렇게 하면 if/elsereturn 문으로 개별 단계를 잇지 않고도 더 읽기 쉽고 우아한 에러 처리를 효율적으로 구현할 수 있습니다.

Rails에서는 Dry Monads라는 젬(gem)을 사용해 이 두 개의 선로로 이루어진 철도 출력을 구현할 수 있습니다.

dry-monads 소개와 작동 원리

모나드(Monad)는 본래 수학적 개념입니다. 핵심적으로 모나드는 여러 특수한 함수의 조합 또는 추상화로, 코드에 적용하면 상태를 지닌 값(stateful value)을 명시적으로 다루는 일을 없애 줄 수 있습니다. 프로그램 로직에 필요한 반복적인 보일러플레이트 코드를 추상화하는 역할도 하죠. 상태를 지닌 값이란 특정 함수에 국한되지 않는 값들로, 입력값, 전역 변수, 출력값 등이 그 예입니다. 모나드에는 bind 함수가 있어 이런 값들이 한 모나드에서 다른 모나드로 전달될 수 있게 하며, 따라서 값을 명시적으로 다룰 필요가 없습니다. 모나드는 예외 처리, 커밋 롤백, 재시도 로직 등을 다루도록 설계할 수도 있습니다. 모나드에 대한 자세한 내용은 관련 문서를 참고하세요.

공식 문서(꼭 읽어보시길 권합니다)에 따르면, dry-monads는 Ruby를 위한 공통 모나드 모음입니다. 모나드는 에러와 예외를 처리하고 함수들을 연결(chaining)하는 우아한 방법을 제공하여, 코드가 훨씬 이해하기 쉬워지고 수많은 if/else 없이도 원하는 수준의 에러 처리를 갖출 수 있게 해 줍니다. 이 글에서는 앞서 말한 성공/실패 출력을 구현하는 데 딱 맞는 도구인 Result Monad에 초점을 맞춥니다.

다음 명령으로 railway-app이라는 새 Rails 앱을 만들어 보겠습니다.

rails new railway-app -T

명령어의 -T 옵션은 test 폴더 생성을 건너뛴다는 의미로, RSpec으로 테스트할 계획이기 때문입니다.

다음으로 Gemfile에 필요한 젬을 추가합니다. 성공/실패 결과를 위해 gem 'dry-monads'를, 테스트 프레임워크로 test와 development 그룹에 gem 'rspec-rails'를 추가하세요. 그다음 앱에서 bundle install을 실행해 추가한 젬을 설치합니다. 테스트 파일과 헬퍼를 생성하려면 아래 명령을 실행합니다.

rails generate rspec:install

함수를 여러 단계로 나누기

함수는 최종 목표를 함께 달성하는 더 작은 메서드들로 나누는 것이 언제나 좋습니다. 이 메서드들의 에러가 있다면 프로세스가 정확히 어느 지점에서 실패했는지 파악하는 데 도움을 주고, 코드도 깔끔하고 읽기 쉽게 유지됩니다.

가장 단순한 예제로, 토요타 자동차 판매점 클래스를 만들어 보겠습니다. 요청한 모델과 색상이 재고에 있고, 제조 연도가 2000년 이전이 아니며, 배송 도시가 근교 도시 목록에 포함되어 있으면 사용자에게 차량을 배송하는 클래스입니다. 재미있겠죠? :)

먼저 배송 프로세스를 몇 가지 단계로 나눕니다.

  • 제조 연도가 2000년 이전이 아닌지 확인한다.
  • 모델이 재고에 있는지 확인한다.
  • 색상이 재고에 있는지 확인한다.
  • 배송 대상 도시가 근교 도시인지 확인한다.
  • 차량이 배송될 것이라는 메시지를 전송한다.

단계가 정해졌으니 이제 코드로 들어가 보겠습니다.

성공/실패 결과 입력하기

app/models 폴더에 car_dealership.rb 파일을 만들고, 중요한 정보들로 클래스를 초기화합니다. 파일 상단에서 dry/monads를 require하고, 클래스 이름 바로 아래에 Dry::Monads[:result, :do]를 include해야 합니다. 이렇게 하면 Result Monad와 do notation(yield 키워드로 여러 모나딕 연산을 조합할 수 있게 해주는 문법)을 사용할 수 있게 됩니다.

require 'dry/monads'

class CarDealership
  include Dry::Monads[:result, :do]

  def initialize
    @available_models = %w[Avalon Camry Corolla Venza]
    @available_colors = %w[red black blue white]
    @nearby_cities    = %w[Austin Chicago Seattle]
  end
end

다음으로 나머지 모든 단계를 포함하고, 모든 단계가 성공하면 성공 메시지를 반환하는 deliver_car 메서드를 추가합니다. yield 키워드로 각 단계를 서로 결합(bind)합니다. 이는 어떤 단계에서든 실패 메시지가 나오면 그것이 곧 deliver_car 메서드의 실패 메시지가 되고, 성공 출력이 나오면 목록상 다음 단계 호출로 넘어간다는 의미입니다.

def deliver_car(year, model, color, city)
  yield check_year(year)
  yield check_model(model)
  yield check_city(city)
  yield check_color(color)

  Success("A #{color} #{year} Toyota #{model} will be delivered to #{city}")
end

이제 나머지 메서드들을 추가하고, 각 검증 결과에 따라 성공/실패 결과를 붙여 줍니다.

def check_year(year)
  year < 2000 ? Failure("We have no cars manufactured in year #{year}") : Success('Cars of this year are available')
end

def check_model(model)
  @available_models.include?(model) ? Success('Model available') : Failure('The model requested is unavailable')
end
def check_color(color)
  @available_colors.include?(color) ? Success('This color is available') : Failure("Color #{color} is unavailable")
end

def check_city(city)
  @nearby_cities.include?(city) ? Success("Car deliverable to #{city}") : Failure('Apologies, we cannot deliver to this city')
end

클래스와 필요한 모든 메서드가 준비되었습니다. 실제로는 어떻게 동작할까요? 이 클래스의 인스턴스를 만들고 다양한 인자로 deliver_car 메서드를 호출해 확인해 봅시다.

good_dealer = CarDealership.new

good_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
# => Failure("We have no cars manufactured in year 1990")

good_dealer.deliver_car(2005, 'Rav4', 'red', 'Austin')
# => Failure("The model requested is unavailable")

good_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
# => Failure("Color yellow is unavailable")

good_dealer.deliver_car(2000, 'Venza', 'red', 'Surrey')
# => Failure("Apologies, we cannot deliver to this city")

good_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
# => Success("A blue 2000 Toyota Avalon will be delivered to Austin")

위에서 볼 수 있듯이 deliver_car 메서드의 실패 결과는 어느 단계에서 실패했는지에 따라 달라집니다. 해당 단계의 실패가 곧 전체 메서드의 실패가 되고, 모든 단계가 성공하면 자신만의 성공 결과를 반환합니다. 또한 이 단계들은 deliver_car와 독립적으로 호출할 수 있는 개별 메서드라는 점도 잊지 마세요. 예시는 아래와 같습니다.

good_dealer.check_color('wine')
# => Failure("Color wine is unavailable")

good_dealer.check_model('Camry')
# => Success('Model available')

RSpec으로 테스트하기

위 코드를 테스트하려면 spec 폴더의 spec/models 경로에 car_dealership_spec.rb 파일을 생성합니다. 첫 줄에 rails_helper를 require합니다. 먼저 실패 케이스를 테스트한 후 성공 케이스를 작성하겠습니다.

require 'rails_helper'

describe CarDealership do
  describe "#deliver_car" do
    let(:toyota_dealer) { CarDealership.new }

    context "failure" do
      it "2000년 이전 제조 차량은 배송하지 않는다" do
        delivery = toyota_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'We have no cars manufactured in year 1990'
      end

      it "재고에 없는 색상의 차량은 배송하지 않는다" do
        delivery = toyota_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'Color yellow is unavailable'
      end
    end
  end
end

위와 같이 result.failure 또는 result.success를 통해 실패/성공 결과에 접근할 수 있습니다. 성공 케이스의 테스트는 다음과 같이 작성할 수 있습니다.

context "success" do
  it "모든 조건을 충족하면 차량을 배송한다" do
    delivery = toyota_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
    expect(delivery.success).to eq 'A blue 2000 Toyota Avalon will be delivered to Austin'
    expect(delivery.failure).to eq nil
  end
end

이제 deliver_car 메서드에 전달하는 인자를 바꿔가며 실패 케이스에 다른 테스트를 추가해 보세요. 잘못된 인자가 주어지는 경우(예: year 변수에 문자열이 들어오는 경우 등)에 대한 검증을 코드에 추가하는 것도 좋습니다. 터미널에서 bundle exec rspec을 실행하면 테스트가 돌아가고 모두 통과하는 것을 확인할 수 있습니다. 참고로 하나의 메서드 출력에서 성공과 실패가 동시에 존재할 수는 없기 때문에, 원칙적으로는 성공과 실패 검증을 같은 테스트에 함께 넣을 필요가 없습니다. 여기서는 실패 결과가 반환될 때 성공 결과가 어떤 모습인지(그리고 그 반대도) 이해를 돕기 위해 함께 넣었을 뿐입니다.

결론

지금까지 dry-monads의 소개와, 이를 활용해 애플리케이션에서 철도 지향 프로그래밍을 구현하는 방법을 살펴봤습니다. 여기서 얻은 기본 개념은 더 복잡한 연산과 트랜잭션에도 확장해서 적용할 수 있습니다. 살펴본 것처럼 ROP를 사용하면 더 깔끔하고 읽기 쉬운 코드를 작성할 수 있을 뿐만 아니라, 에러 처리도 상세해지고 부담이 크게 줄어듭니다. 프로세스를 구성하는 각 메서드에 간결한 성공/실패 메시지를 붙이는 습관을 들이세요. 덕분에 오류가 어디서, 왜 발생했는지 파악하기가 훨씬 쉬워집니다. ROP에 대해 더 알고 싶다면 Scott Wlaschin의 발표 영상을 시청해 보시길 추천합니다.