루비 스타일(3) – 말하는 듯한 코딩

20141221174044_JNBCLiYX_SBS_20141221_173820.874

말하는 것처럼 노래해야 마음을 울릴 수 있어요.
-- 박진영

I. 루비 코드의 생명은 가독성

루비 뿐 아니라 다른 많은 객체지향 언어들도 마찬가지이지만, public으로 노출되는 메소드들, 그중에서도 가장 많은 기능들을 제공하는 핵심 메소드들은 머리 속으로 컴파일러를 돌려보지 않아도 그냥 이해되는 형태로 구현이 되는 경향이 강합니다. 무슨 얘기냐면, 수면 밑의 복잡한 로직들은 protect/private 메소드로 감추고(말하자면 레고 블럭 단위들), 이 메소드들을 조립하는 것으로 큰 흐름을 구성해서 중요 메소드들을 구현하는 형태이죠. 이런 메소드들의 구현부를 보면 마치 사람의 언어로(물론 영어로..^^; ) 적은 것처럼 쉽고 명료합니다.

실제 코딩에서 자주 볼 수 있는 예를 한 번 들어봅시다.
stand-alone application이 아닌 Rails app에서는 레고 블럭을 조합해서 비즈니스 로직을 만들어내는 역할을 컨트롤러가 하고 있습니다. 아직 레일즈에 익숙하지 않은 개발자들이 흔히 쓰는 코드들 중에 이런 형태의 컨트롤러 코드를 많이 볼 수 있는데.. (예제 원문은 여기 참조)

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.where(active: true).order(:last_login_at)
  end
end

이런 컨트롤러 코드의 경우, 재사용성도 떨어지고 테스트하기도 어렵습니다. 무엇보다 User라는 모델에 어떤 필드가 있고 어떤 값을 갖고 있는지 컨트롤러 차원에서도 알고 있어야 한다는 게 더 심각한 문제입니다. 따라서 아래와 같이 리팩토링할 필요가 있습니다.

먼저 모델 내의 데이터와 관련된 query method는 모두 감추는 메소드를 모델에 추가합니다. 고맙게도 레일즈에서는 scope를 이용해서 쉽게 구현할 수 있습니다.

# app/models/user.rb
class User < ActiveRecord::Base
  scope :active, -> { where(active: true) }
  scope :by_last_login_at, -> { order(:last_login_at) }
end

그에 맞춰서 컨트롤러도 수정해줍니다.

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.active.by_last_login_at
  end
end

훨씬 직관적이죠? 모델과 관련된 것은 모델 내부에서 처리하도록 하고, 컨트롤러는 가장 high-level의 로직만 다루도록 하면 읽기도 쉽고 구조도 깔끔해지고 테스트도 편리한, 일석삼조의 효과를 얻을 수 있습니다.
(단, 모든 order 쿼리 호출만을 저렇게 scope로 감싸는 건 항상 좋은 방법은 아니라고 생각합니다. 여러 번 호출되는 경우에 중복 제거 차원에서 감싸준다는 느낌이 좋을 것 같습니다.)

뿐만 아니라, 마찬가지 방법으로 추상화의 정점에 있는 메소드들 뿐 아니라 그보다 더 낮은 레벨에 있는 메소드들도 비슷한 방식으로 추상화 시켜서 할 수 있는 한 나눌 수 있을 때까지 나눠서 차츰차츰 복잡도가 올라가도록 구현하는 방식을 선호하는 루비 개발자들이 많습니다.

이렇게 하다보니 루비로 짠 코드의 경우 메소드의 길이가 매우 짧고 메소드 안의 내용도 매우 단순해지는 경향이 있습니다. (당장 rails 내부 소스를 한 번 보세요. 라인 수가 긴 메소드가 별로 없습니다.) 심지어 모든 메소드는 5라인 이내로 작성되어야 한다는 원칙을 고수하는 분들도 있습니다. Sandi Metz’ Rule을 한 번 읽어보세요. 꼭 그대로 따라하지 않는다고 해도 매우 시사하는 점이 많은 원칙들입니다. Sandi의 경우 본인의 원칙은 4라인이지만 다른 사람들을 봐줘서 버퍼로 1줄 더 준거라고 강연에서 얘기한 적이..-_-; 저도 그 정도는 아니지만 메소드 길이는 가급적 4~10 라인 정도로 유지하는 편입니다.

제가 이 얘기를 서두에 먼저 하는 이유는 제가 제시할 해법의 방향을 설명하기 위해서 입니다. 사실 지난 포스팅에서 보여드린 예제만 해도 이미 훌륭하게 문제를 해결하고 있습니다. 이제까지 제가 몇 가지 다른 코드 예시를 보여드리면서 여러가지 지적을 한 이유는 그 해법들이 잘못 되었으니 더 나은 코드를 제시해주겠다는 의미라기보다는, 능숙한 루비 개발자들의 코드에서 공통적으로 보이는 특징들(말하는 것 같은 코딩 적절히 잘게 나눠져서 추상화 되는 클래스와 메소드의 구조, map/reduce의 적절한 활용)에 대해서 생각해보자는 것이었습니다. 이제부터 보여드릴 해법도 할 수 있는 한 실제 코딩에서 사용될 것 같은 평이하고 잘 모듈화된 형태로 보여드리고자 합니다.

II. TDD 소개, 그리고 환경 준비

테스트 주도 개발(TDD)은 선제적으로 테스트를 만들어서 중요한 에러나 실수를 미연에 막아줄뿐만 아니라, divide & conquer 형태로 문제를 쪼개어 해결하는 데에 탁월한 도움을 주는 방법이기 때문에, 단도직입적으로 말하자면 그냥 코딩하는 것보다 TDD가 더 코딩이 빠르기 때문에, TDD를 합니다.
물론 누가 봐도 자명한 코드에도 억지로 테스트를 추가하거나, 맹목적으로 100% 테스트 커버리지를 위해서 의미없는 테스트 코드를 추가하는 것은 매우 어리석은 일이겠지만, 시의적절한 코드와 mock, spy 등의 도움을 충분히 받은 테스트의 가치는 매우 큽니다. 많은 사람이 협업하는 거대한 시스템에서도, 혼자 작업하는 프로젝트에서도 항상 큰 효과를 봐왔기 때문에 제게는 TDD가 아닌 형태로 진행하는 개발 자체를 생각하기 어렵습니다.

그럼 피보나치 문제를 풀기 위한 테스트 환경을 구축하는 것부터 시작해봅시다. 먼저 어떤 테스트 라이브러리를 쓸 것인가부터 결정해야겠군요.
저는 rspec을 매우 사랑하는 사람이지만 MVC 프레임웍 내에서 말하는 것 같은 테스트 코딩 behavier 정의가 매우 편리한 환경에서라면 몰라도 이런 간단한 곳에서는 굳이 시나리오의 description까지 적어가면서 테스트를 짜는 건 솔직히 오버인 것 같습니다.

좀 더 솔직히 얘기하자면.. rspec의 DSL이 정말 자연어로 말하듯이 코딩을 짜기 위한 완벽한 방법이긴 하지만, 사실 S/W 엔지니어들은 사람의 언어보다 프로그래밍 언어를 더 편하게 생각하기 때문에 괜히 사람들이 우리를 오타쿠 내지는 nerd라고 부르는 게 아닙니다.. 자연어에 가까운 로직 흐름이 오히려 덜 직관적으로 느껴집니다. 그러다보니 잘 코딩하자고 도입한 방법론/라이브러리가 단기적으론 사람 잡는 경우가 생깁니다. 저도 지금은 rspec 없이는 못 살게 됐지만 손에 익숙해지기 전까지 상당한 고생을 했습니다.
음.. 생각해보니 구글링을 해봐도 좋은 spec 구현에 대한 딱히 좋은 예제도 찾기 힘든데.. rspec 책을 사면 되지만 매뉴얼 따윈 안 보고 사는 게 엔지니어의 긍지인지라.. rspec 기초에 대한 포스팅도 나중에 해보고 싶다는 생각이 드네요.

어쨌든 그런 이유로 minitest로 테스트를 써보도록 합시다. (물론 minitest로도 rspec과 매우 유사한 spec 정의가 가능하지만 마찬가지 이유로 pass하겠습니다.^^ 테스트 환경을 포함한 모든 소스코드는 RoRLab Github에서 찾으실 수 있습니다.)

rspec 기반이긴 하지만 기본 실행 환경 구축에 대해서는 김대권 님의 좋은 발표자료가 있으니 참고 바랍니다.

III. 문제 해결은 어떻게 하는가?

문제 해결의 방식에 정답이란 있을 수 없습니다. 다만 널리 쓰이는 방법론이자, 여러 사람들에 의해서 좋다고 소문난 방법들 중 하나를 소개해보겠습니다.

Kent Beck의 명저 “TDD(테스트주도개발)”을 봐도 잘 나오지만, todo list 형태로 구현할 기능들을 먼저 적어놓고 개발을 하는 방법을 많이 선호합니다. 이렇게 하면 큰 맥락의(macro한) 문제 해결과 micro한 세부 구현을 나눠서 생각할 수 있어서 효과적으로 집중하기에 특히 좋습니다. 물론 todo list를 처음부터 완벽하게 적을 필요는 없고, 현 수준에서 생각이 가능하면서 우선적으로 구현할 항목들을 먼저 적고, 문제를 해결해나가면서 바꾸고 세분화 시키고 합칠 수 있습니다. 이에 대한 자세한 설명은 TDD 책에 아주 잘 나와있기 때문에 여기서 다루지는 않겠습니다.

우리의 피보나치 문제에서는 아래와 같은 todo 리스트가 나올 수 있겠군요.

  • n번째 피보나치 수를 구하는 기능 만들기
  • 피보나치 수열을 계산하는 기능 추가
  • 수열을 만드는 조건을, 마지막 값이 max 값을 넘지 않는 것으로 한정
  • 만들어진 수열에서 짝수 합계를 구한다

위의 리스트들은 순서대로, 1)항목을 검증하는 테스트 코드 작성 -> 2) 테스트를 통과하는 코드 작성 의 형태로 반복해가면서 todo 항목들을 지워가는 과정인데.. 아무리 복잡하고 어려운 문제도 직관적으로 해결할 수 있게 해주는 좋은 생각의 틀(framework)이라고 생각합니다.

추가적으로 제가 해법 정리를 위해 주로 사용하는 방법은 최종적으로 답을 주는 메소드를 의사 코드(pseudo code)로 만들고, 그 안에서 호출되는 메소드들을 점진적으로 구현해가는 방법입니다. 이번 문제에서 제가 만든 high-level 코드는 아래와 같습니다.

sequence = natural_numbers.convert_fibonacci_sequence.take(n < max)
sequence.sum_even_numbers 

IV. 드디어 문제 해결~

자 그럼 하나씩 todo list를 제거해 나가 봅시다.

1. n번째 피보나치 수를 구하는 기능 만들기

먼저 테스트를 만듭니다. test 폴더에 test_fibonacci_calculator.rb 파일을 아래와 같이 만듭니다.

require "minitest/autorun"
require_relative "../lib/fibonacci_calculator"

class TestFibonacciCalculator < Minitest::Test
  def test_fibonacci_number
    assert_equal 1, FibonacciCalculator.number(1)
    assert_equal 2, FibonacciCalculator.number(2)
  end
end

그리고 테스트를 돌립니다. FibonacciCalculator라는 클래스가 없으니 당연히 에러가 납니다. 이번엔 lib 폴더에 얼른 테스트만 통과하는 코드를 추가합니다.

module FibonacciCalculator
  module_function

  def number(n)
    n
  end
end

드디어 테스트가 통과되었습니다!

기왕 테스트를 만들 것 같으면 제대로 된 로직 검증을 위해 많은 케이스를 추가하지, 왜 장난하는 것도 아니고 n = 1,2일때만 동작하는 테스트를 만들어 놓고 그것만 만족하는 단순한 코드만 만드는 이유가 뭘까요?
실은 여기에 TDD의 묘미가 있습니다. 테스트 없이 코딩을 하다보면 알고리즘에 집중을 하기 어렵게 만드는 잡생각들이 계속 들게 마련인데.. (‘로직과 관계 없이 지금 코드에 실수가 있으면 어떻게 하나’ 같은 불안감 말이죠.) 일단 기본적인 틀이 동작한다는 걸 확인하고 나서 안심을 먼저 한 상태에서는 중요한 알고리즘만 고민할 수 있으니 집중도 더 잘 되고 마음도 편안합니다. Kent Beck 같은 거물 개발자도 이런 바보같아 보이는 방법을 고수하는데에는 이유가 있습니다.

자, 그럼 이제 제대로 된 테스트를 추가해봅시다. 이전에 이미 두 줄이나 중복된 테스트 코드를 넣었는데 또 넣으려니 찜찜하죠? 조금 더 깔끔하게 추가해봅시다. 기왕 중복이 없어진 김에 테스트할 숫자도 늘려봅시다.

class TestFibonacciCalculator < Minitest::Test
  FIBONACCI_SAMPLES = [1, 2, 3, 5, 8, 13, 21, 34, 55].freeze
  MAX_NUMBER = FIBONACCI_SAMPLES.last

  def test_number
    FIBONACCI_SAMPLES.each.with_index(1) { |n, i| assert_equal n, FibonacciCalculator.number(i) }
  end
end

보통 #each에 #with_index를 또 호출하는 방법보다는 #each_with_index가 효율적으로 동작하기 때문에 후자를 선호합니다만, 이 경우 인덱스의 초기값을 정해줄 수 없다는 단점이 있어 .each.with_index 형태로 호출했습니다.
테스트 코드가 추가되자마자 다시 테스트는 실패로 바뀌게 됩니다. 얼른 lib의 코드를 고쳐봅시다. 이번엔 피보나치 함수를 제대로 구현해보죠.

module FibonacciCalculator
#...
  def number(n)
    n <= 2 ? n : number(n - 1) + number(n - 2)
  end
#...
end

이제 피보나치 숫자가 제대로 구해지는 것을 확인할 수 있습니다. 첫번째 todo 가 해결되었습니다!

2. 피보나치 수열을 계산하는 기능 추가

마찬가지로 테스트부터 추가해봅시다.

class TestFibonacciCalculator < Minitest::Test
#...
  SAMPLE_SIZE = FIBONACCI_SAMPLES.size
#...
  def test_series
    assert_equal FIBONACCI_SAMPLES, FibonacciCalculator.series(SAMPLE_SIZE)
  end
#...
end

그리고 테스트를 성공시키기 위한 코드를 추가합니다.

module FibonacciCalculator
#...
  def series(n)
    (1..n).map { |i| number(i) }
  end
#...
end

됐군요!

3. 수열을 만드는 조건을, 마지막 값이 max 값을 넘지 않는 것으로 한정

테스트 코드를 추가합니다.

class TestFibonacciCalculator < Minitest::Test
#...
  def test_series_less_than
    assert_equal FIBONACCI_SAMPLES, FibonacciCalculator.series_less_than(MAX_NUMBER)
  end
#...
end

사실 2번에서부터 하고 싶었던 일인데.. 제가 처음에 보여드렸던 의사 코드를 보면 알겠지만.. (무한대의) 자연수 수열에서 피보나치를 구하고 그 안에서 원하는만큼 선택을 한다는 개념으로 찾는 게 의미가 깔끔하지 않나 싶습니다. 그럼 이렇게 구현할 수 있겠습니다.

module FibonacciCalculator
#...
  def series_less_than(max_number)
    natural_numbers.map { |i| number(i) }.take_while { |n| n <= max_number }
  end
#...
end

자 그럼 무한한 자연수는 어떻게 구할까요? 간단합니다.

def natural_numbers
  (1..Float::INFINITY)
end 

왜 숫자 클래스(Fixnum)이 아니고 부동 소수점(Float)이냐구요? 정수의 무한대는 Bignum 을 쓰기 때문에 좀 문제가 있습니다.^^; 그런데 그냥 이렇게 처리했다간 무한루프에 빠지고 맙니다. 무한대의 숫자까지 range가 주어졌으니 당연한 일이죠.
하지만 Ruby 2.0 부터 추가된 Lazy Enumerator라는 마법같은 인터페이스를 이용하면 머리 속으로만 될 것 같았던 기능을 실현할 수 있습니다. #lazy 메소드를 호출하면 나중에 #force 메소드가 호출될 때까지 실제 계산을 유보하는 특수한 Enumerator를 리턴해줍니다. 이것을 활용하면 아래와 같이 만들 수 있습니다. (기왕 하는 김에 아까 만들었던 #series도 조금 더 직관적으로 바꿔봅시다. 아까 만들어둔 테스트 코드가 있으니 고치다 실수가 난다해도 바로 알 수 있기 때문에, 안심하고 기존 코드도 손을 댈 수 있습니다.)

module FibonacciCalculator
#...
  def series(n)
    natural_numbers.map { |i| number(i) }.first(n)
  end

  def series_less_than(max_number)
    natural_numbers.map { |i| number(i) }.take_while { |n| n <= max_number }.force
  end

  def natural_numbers
    (1..Float::INFINITY).lazy
  end
#...
end

아름답게 해결이 되었습니다. 참고로, #series에서는 #force 메소드가 호출되지 않았는데도 결과가 잘 나옵니다. #first, #reduce 등의 메소드는 자신이 호출되는 시점에서 더 lazy하게 연산을 미룰 수 없게 되기 때문에 내부적으로 #force를 호출해주기 때문입니다. 즉, 아래의 두 코드는 완전히 동일한 역할을 합니다.

(1..Float::INFINITY).lazy.first(5) # => [1, 2, 3, 4, 5]

(1..Float::INFINITY).lazy.take(5).force # => [1, 2, 3, 4, 5]

4. 만들어진 수열에서 짝수 합계를 구한다

자, 이제 마지막 테스트 코드를 추가해봅시다.

class TestFibonacciCalculator < Minitest::Test
#...
  def test_even_sum
    assert_equal 0, FibonacciCalculator.even_sum(1)
    assert_equal 2, FibonacciCalculator.even_sum(3)
    assert_equal 44, FibonacciCalculator.even_sum(MAX_NUMBER)
  end
#...
end

짝수합을 구하는 코드는 단순히 해결이 될 것 같습니다.

module FibonacciCalculator
#...
  def even_sum(max_number)
    series_less_than(max_number).select(&:even?).reduce(:+)
  end
#...
end

지난 포스팅에서 얘기했던 map/reduce 구조가 나옵니다. 참 쉽죠?
그런데 여전히 테스트는 실패합니다. max_number가 1일 때 결과값이 nil 이 나와버리는군요. ruby의 경우 항상 nil이 나올 수 있는 경우에 대한 상황을 염두에 두고 있어야 합니다. 아닌 경우 낭패를 볼 경우가 많습니다.

series_less_than(max_number).select(&:even?).reduce(:+) || 0

간단하게 || 0 을 추가하는 것으로 쉽게 해결이 되겠군요. =)
이렇게 해서 완성된 클래스는 아래와 같습니다. 매우 단순하게 해결이 되었습니다!

module FibonacciCalculator
  module_function

  def number(n)
    n <= 2 ? n : number(n - 1) + number(n - 2)
  end

  def series(n)
    natural_numbers.map { |i| number(i) }.first(n)
  end

  def series_less_than(max_number)
    natural_numbers.map { |i| number(i) }.take_while { |n| n <= max_number }.force
  end

  def even_sum(max_number)
    series_less_than(max_number).select(&:even?).reduce(:+) || 0
  end

  def natural_numbers
    (1..Float::INFINITY).lazy
  end
end

마지막으로 아래와 같이 호출하면 문제의 해답이 나옵니다.

p FibonacciCalculator.even_sum(400_000)

어떻습니까. 솔직히 최적의 속도로 실행되는 코드는 아니지만 기능들이 잘 쪼개져 있습니다. 물론 효율만 생각하면 #number, #even_sum 두 개의 메소드로 축약하는 것도 가능합니다만, 그에 비해 이렇게 하면 이런 이점들이 있습니다.

  • 높은 가독성: 제일 상위 메소드의 내용만 보면 전체 흐름이 금방 이해가 가고, 더 low-level한 내용은 하위 메소드를 파고들어가면서 단계적으로 이해할 수 있으니 타인이 보기에도 몇 달 뒤의 내가 보기에도 편합니다.
  • 테스트 가능성: 전체 기능이 구현이 안 된 상태에서 단계적으로 조금씩 조금씩 테스트를 하면서 붙여나갈 수 있습니다.
  • 최적화 용이성: 루비 VM은 이전에 비해 비약적으로 성능이 개선되긴 했지만 여전히 타 언어에 비해 느리기 때문에 항상 최적화에 열려있어야 합니다. 기능이 잘 쪼개져 있으면 벤치마크를 통해 어디가 가장 시간이 오래걸리는지도 쉽게 감지할 수 있고 꼭 필요한 부분만 최적화를 시킬 수 있어 코드의 가독성이 크게 떨어지지 않는 방향으로 코드를 손 볼 수 있습니다.

제가 세 번의 긴 글을 통해 얘기하고자 하는 결론은 이겁니다.
제가 생각하는 좋은 루비 코드 스타일, 말하는 듯한 코딩은 머리 속 컴파일러를 발동시키지 않는 잘 추상화 된 high-level한 코드, 그리고 그를 뒷받침 해주는 잘 분산된 클래스/메소드 구조라고 생각합니다. 끗!

V. 번외편: 또다른 좋은 해법들

말씀 드렸듯이 제가 제시한 해법은, 최대한 실제 코드에서 하는 것과 같은 메소드 분포를 갖도록 하는 데에 주안점을 뒀습니다.

근데 피보나치 합 같은 문제는 간단하게 짝수 합만 구하는 것이니 그냥 해법 자체에만 집중해서 문제를 풀어봐도 되지 않을까요? ^^;

(한 때 세계에서 가장 큰 Ruby on Rails 애플리케이션 중 하나였던) KakaoStory 팀에서 활약하고 있는 김기용 군은 이런 해법을 보여주더군요. 훌륭한 해법입니다!

e = Enumerator.new do |yielder|
  a, b = 1, 2
  loop do
    yielder << a
    a, b = b, a + b
  end
end.lazy

p e.take_while { |i| i < 400_000 }.select(&:even?).reduce(:+)

laziness와 generator를 동시에 사용한 아름다운 방법입니다.

아래와 같이 피보나치 수를 구하는 brilliant한 해법도 있습니다.

fibonacci = Hash.new { |h,k| h[k] = k <= 2 ? k : h[k-1] + h[k-2] }

fibonacci[6] # => 13
fibonacci[50] # => 20365011074

훌륭합니다! 익명 함수를 이용해서 피보나치를 구하는 지극히 루비스러운 해법입니다. 게다가 같은 값이 두번째 호출될 때부터는 함수 호출이 아닌 저장된 값을 쓰기 때문에 속도도 훨씬 빠릅니다. (이를 이용한 이후 구현은 동일하니 생략합니다.^^)

자, 그럼 마지막으로 지난 시간의 문제를 한 번 풀어보겠습니다. 어떻게하면 단 한 줄로 피보나치 짝수합을 구할 수 있을까요?

아까 만든 FinbonacciCalculator.even_sum에서 호출되는 함수들을 모두 합치면 대충 되지 않을까요? 거의 될 것 같은데.. number 함수까지 한 줄로 만들기가 조금 애매합니다.

(1..Float::INFINITY).lazy.map { |i| number(i) }.take_while { |n| n <= 400_000 }.select(&:even?).reduce(:+)

그럼 방향을 바꿔봅시다. #with_object 메소드를 이용해서 초기값인 [1, 2]를 미리 배열에 넣어주고 그걸 버퍼로 활용하면 될 것 같습니다.

(1..Float::INFINITY).lazy.with_object([1, 2]).map { |_, last| last[1] = last[0] + (last[0] = last[1]); last[0] }.take_while { |x| x <= 400_000 }.select(&:even?).reduce(:+)

정신없죠? 조금 줄을 나눠볼까요?

(1..Float::INFINITY).lazy.
  with_object([1, 2]).
  map { |_, last| last[1] = last[0] + (last[0] = last[1]); last[0] }.
  take_while { |x| x <= 400_000 }.
  select(&:even?).
  reduce(:+)

map 안의 block 파라미터의 첫번째는 1부터 무한대의 자연수가 Fixnum 형태로 1씩 증가하며 주어지게 되는데, 실제 계산에서는 사용하지 않는 변수이므로 _로 처리했습니다. 그리고 두 번째 last의 경우 with_object에서 정의한 배열을 의미합니다. last 배열에 피보나치 숫자를 구하기 위한 마지막 피보나치 수와 그 전 숫자를 저장해놓고 이를 이용해 map으로 수열을 계속 붙여나갑니다.
아.. 그렇구나.. 라고 생각하는데 뭔가 이상합니다. 분명 한 라인이라고 했는데 map 안에 세미콜론(;)이 있군요. 이거 사기 아녜요? 그런 식이면 열 줄짜리 코드를 만들어 놓고 세미콜론으로 이어도 되는거잖아요? 맞습니다. 죄송합니다..
하지만 방법은 있습니다. 바로 지난 포스팅에서 말씀드렸던 #tap이라는 쓸데 없는 마법의 메소드입니다! #tap은 단순히 self를 리턴해주는 함수가 아니라, block이 실행된 후의 self 값을 리턴해주는 메소드입니다. 그러므로 아래의 두 코드는 완전히 같은 코드입니다.

last[1] = last[0] + (last[0] = last[1]); last[0]

last[0].tap { last[1] = last[0] + (last[0] = last[1]) }

자 드디어 40만을 넘지 않는 피보나치 수의 짝수 합의, 단 한 줄 구현이 완성 됐습니다~ =)

(1..Float::INFINITY).lazy.with_object([1, 2]).map { |_, last| last[0].tap { last[1] = last[0] + (last[0] = last[1]) } }.take_while { |x| x <= 400_000 }.select(&:even?).reduce(:+)

그럼 다음 포스팅에서는 Ruby on Rails 코딩에서 종종 맞닥뜨리는 루비의 독특한 문법에 대해서 함께 생각해보겠습니다.

Advertisements

루비 스타일(2) – 들여쓰기(indent)로부터의 탈출

gordon

그게 나쁘다는 건 아냐. 하지만 훌륭한 기관차는 그러지 않아.
-- 고든, "토마스와 친구들" 중에서

I. 나는 들여쓰기가 싫어요!

많은 루비 개발자(Rubyist)들은 들여쓰기를 싫어하는 경향이 있습니다. 들여쓰기로 이쁘게 코드를 정리하는 걸 싫어한다는 말이 아니라, 들여쓰기가 필요해지는 상황 자체를 싫어한다는 말입니다. 아래와 같이 들여쓰기 없이 뒤에 붙는 if/unless 가 있는 것만 봐도 그렇습니다.

puts a if a.size < 80

루비 스타일 가이드에 보면 다른 언어의 스타일 가이드에는 보기 힘든 재밌는 원칙이 나옵니다. 요약하자면 이런 말입니다.(원문 참조)

“중첩해서 조건문이 계속 나오는 상황을 피하라.
로직 흐름 값이 유효하지 않은 경우 즉시 return, next, break 등으로 이탈하도록 하라.”

아래의 예제를 참고해보세요.

# 나쁜 예
def compute_thing(thing)
  if thing[:foo]
    update_with_bar(thing)
    if thing[:foo][:bar]
      partial_compute(thing)
    else
      re_compute(thing)
    end
  end
end

# 좋은 예
def compute_thing(thing)
  return unless thing[:foo]
  update_with_bar(thing[:foo])
  return re_compute(thing) unless thing[:foo][:bar]
  partial_compute(thing)
end

# 나쁜 예
[0, 1, 2, 3].each do |item|
  if item > 1
    puts item
  end
end

# 좋은 예
[0, 1, 2, 3].each do |item|
  next unless item > 1
  puts item
end

참고로, 이렇게 else나 elsif로 넘어가지 않고 바로 return 등으로 이탈시키는 구문을 “guard clause”라고 부릅니다. Java나 C# 등에서도 종종 쓰이지만, 루비의 경우 이것이 훨씬 더 쉽게 되어 있기도 합니다. guard clause를 통해 여러 번의 조건문이 중첩되는 상황을 회피하게 해주고 이로 인해 리팩토링이 손쉬워진다는 장점도 함께 있어서 많은 루비 개발자들의 사랑을 받고 있습니다.

II. 루비의 꽃: Enumerator

들여쓰기 없는 코딩의 백미는 Enumerable 모듈을 활용한 문제해결에 있습니다. 루비 초급 개발자와 중급 개발자의 코드 스타일에서 큰 차이 중의 하나가 Enumerable의 메소드를 얼마나 잘 쓰는가라는 걸 많이 봐왔습니다.
특히 Enumerable의 메소드들 중에 유독 #each 메소드 하나만 이용하면서 그걸로 for/while 문을 대체하는 것으로 만족하는 경우도 많은데, 실력있는 개발자일수록 #each보다는 #map -> #select -> #reduce 형태의 메소드 연쇄 호출(chaining) 형태의 코드를 많이 짭니다.

먼저 Enumerable에 대해서는 김대권 님의 블로그 포스팅보다 더 나은 한글 자료를 보기는 어려울 것 같습니다. 아주 탁월하게 잘 정리된 글이니, 아래로 넘어가기 전에 한 번 꼭 보시길 권해드립니다.

[링크: 루비의 꽃, 열거자 Enumerable 모듈]

역시 매우 유용하면서도 보다 쉽고 재미있는 자료로 Michael Hartl (예, Rails Tutorial의 바로 그 분입니다!)의 강의 동영상이 있습니다. 자막은 없지만 라이브 코딩이라 그냥 코드만 보셔도 무슨 말인지 알아듣는데 전혀 문제가 없을겁니다. =)

자, 그럼 Enumerable을 사용한 문제 해결 예를 봅시다. 예를 들면 Ruby on Rails 개발에서 컨트롤러나 decorator 쪽에서 자주 접하게 되는 상황으로 아래와 같은 상황이 있는데..

“a, b, c 세 개의 변수에 각각 문자열 또는 nil 값이 들어있다. nil 값은 무시하고, 각 문자열들은 쉼표로 구분해서 하나의 문자열로 합치려면 어떻게 해야할까?”

말하자면 params로 들어온 여러 개의 문자열을 특정 구분자(delimiter)를 넣어서 하나의 문자열로 합쳐서 페이지에 뿌려줘야 한다든가 하는 상황입니다. 일단 거칠게 코딩을 해보면 아래와 같이 될 것 같군요.

# merge("a", nil, "c") ==> "a,c"
def merge(a, b, c, delimiter = ",")
  retval = a
  if b
    retval << delimeter if retval
    retval << b
  end
  if c
    retval << delimiter if retval && retval[-1] != delimeter
    retval << c
  end
  retval
end

아아 눈이 썩어들어갈 것 같은 코드입니다. 안구 정화가 필요합니다. 일단 코드 중복을 제거하기 위한 메소드 분리도 하고.. bra bra bra 여러 과정을 거치고 파라미터 갯수도 세 개로 고정이 아니라 제한 없이 가변적으로 받는 것도 보여드리고 싶지만.. 그 와중에 보기 싫은 코드를 계속 보다보면 안구암에 걸릴지도 모르겠군요. 바로 Enumerable의 메소드로 문제를 해결해봅시다.

Enumerable을 통한 해결 방식은 아래 방법들의 조합으로 이뤄집니다.(누가 정해놓은 건 아니기 때문에 더 있을 수도 있습니다. 어디까지나 제 짧은 경험상..^^; )

  • Map: 일단 필요한 것들을 배열로 묶는다. (주로 #map 사용)
  • Select: 필요 없는 것들은 솎아내고 선별한다. (주로 #select, #reject, #compact 사용)
  • Reduce: 결과물을 하나로 모은다. (주로 #reduce, #join 사용)
  • Find: 하나의 항목만 필요할 경우 검색으로 해결한다. (주로 #find 사용)

지금 예에서는 1) 파라미터 숫자가 고정되어 있으니 그냥 배열로 묶고, 2) nil은 #compact 로 없애주고, 3) #join으로 하나로 묶어주면 되겠군요.

def merge(a, b, c, delimiter=",")
  [a, b, c].compact.join(delimiter)
end

위와 같은 코드가 됩니다. 안구도 마음도 함께 정화됩니다. 여러 개의 항목을 합쳐서 하나의 문자열로 만들 때 각 항목마다 쉼표 같은 걸로 구분을 해주거나 하는 것도 귀찮은 일인데 고맙게도 #join의 경우 파라미터로 받은 문자열을 구분자로 추가해주기 때문에 쉽게 해결이 됩니다.

실제로는 a, b, c 세 개가 아니라 1~n개의 문자열을 파라미터로 받게 될텐데, 이것을 첫째로 보여드린 예의 방식으로 해결하려면 loop를 사용하는 더욱 끔찍한 코드가 들어가게 되지만, Enumeration을 이용하면 그럴 필요없이 아래와 같이 해결가능합니다.

def merge(*args, delimiter: ",")
  args.compact.join(delimiter)
end

이런 식으로 데이터를 묶고(Map) -> 선별하고(Select) -> 합치는(Reduce) 형태로 데이터를 처리하는 패러다임이 생소해보이기도 하고 중간에 배열이 계속 생겼다 사라지니 메모리 공간도 많이 쓰고 속도도 느린 (가뜩이나 자바 VM에 비해서 GC 성능도 느린데..) 방식이라 다른 언어에서 전향하신 분들의 경우 거부감을 많이 느낄 수도 있습니다. 하지만 쓰면 쓸 수록 데이터를 처리하는 문제를 해결하는 데에 놀랍도록 탁월한 방법임을 알게 됩니다.
위의 예제에서도 볼 수 있듯이 대부분의 언어에서는 if, loop 등으로 처리하는 문제들을, 데이터의 집합을 처리한다는 생각으로 해결을 하면 훨씬 더 짧은 코드로 문제 해결이 가능하기 때문입니다.

이런 연유로 #reduce는 단순히 #inject의 alias 임에도 불구하고 이제는 #inject는 쓰지 않는 게 루비 스타일 가이드의 권장사항이 되었습니다. 보통 reduce(&:+) 와 같이 합계를 구할 때 많이 쓰는 메소드의 이름이 “축약하다”(reduce)라니 황당하게 느껴질 수도 있겠지만 저런 패러다임에서 보자면 당연한 것이지요.
같은 이유로 #map의 alias인 #collect도, 어떤 의미에선 뜻이 더 명확한 단어임에도, 사용하지 않는 것이 권장사항입니다.

메소드 호출을 .을 이용해서 체인처럼 계속 연결해서 쓰는 부분도, 익숙하지 않은 분들에게는 생소해서 오히려 가독성을 해치는 이상한 것이 아니냐고 여길 수 있지만 조금만 익숙해지면 아주 아름다운 코드임을 알게 됩니다.
루비 개발자들이 얼마나 이런 코드를 사랑하는가는 Object#tap 메소드(참조)의 존재만 봐도 알 수 있습니다. #tap은 아래와 같은 형태로 사용되기 위해 만들어진 특이한 메소드입니다.

(1..10)                .tap {|x| puts "original: #{x.inspect}"}.
  to_a                 .tap {|x| puts "array: #{x.inspect}"}.
  select {|x| x.even? }.tap {|x| puts "evens: #{x.inspect}"}.
  map {|x| x*x}        .tap {|x| puts "squares: #{x.inspect}"}

바로 . 으로 계속 이어지는 연쇄 호출을 끊지 않고 이어지게 하는 메소드입니다. “아니, 뭐 저런 희한하고 쓸데없는 메소드가 다 있지?”라는 생각이 드실 수도 있는데, 이 메소드의 묘미에 대해서는 나중에 따로 다뤄보겠습니다. 겨우 2회째에 마구 쏟아지는 떡밥들..

III. 다시 클래스 설계로..

자, 그럼 우리가 해결하려는 피보나치 문제로 돌아가볼까요?
지난 포스팅 말미에 클래스 설계의 중요성에 대해서 짚었습니다. 그럼 짝수 피보나치 수열의 합을 구하는 클래스는 어떻게 구성하면 좋을까요? 아래와 같은 방향을 생각해볼 수 있겠군요.

1. 유틸리티 클래스: 클래스 내부에 데이터는 전혀 갖지 않고, 메소드들만 갖고 있도록 설계
2. immutable한 추상화 클래스: 한 인스턴스 내에서는 주요 데이터의 수정이 안 되는(immutable) 클래스 형태로 설계

1번, 2번 모두 좋은 방향입니다. 각 경우 어떻게 설계를 하면 될까요?

1번 접근법: 메소드들만을 가지는 모듈을 만듭니다. 단, 하나의 메소드에 모든 내용을 넣지 않고 세부 기능들이 잘 분산되도록 메소드를 만듭니다.

module FibonacciCalculator
  module_function

  def even_sum(max_value)
    # 내부에서 #series 호출
  end

  def sum(max_value)
    # 내부에서 #series 호출
  end

  def series(max_value)
    # 내부에서 #number 호출
  end

  def number(nth)
    # ...
  end
end

이렇게 내부에 데이터를 갖지 않는 유틸리티 클래스를 만들 때는 모듈 형태로 만듭니다. 그리고 Mixin의 형태로 사용하는 것은 아니므로 module_function 을 둬서 내부의 메소드들을 클래스 메소드처럼 사용할 수 있도록 해줍니다. 그리고 클래스 이름도 직관적으로, “피보나치”보다는 “피보나치 계산기”나 “피보나치 생성기”로 해주면 이름만 봐도 어떤 일을 하는지 금방 와닿겠죠? =)
메소드 구성을 볼까요? 피보나치 수열을 구하는 #series와 모든 합을 구하는 #sum, n번째 숫자를 구하는 #number의 경우, 문제에서 요구했던 건 아니기 때문에 private으로 만드는 게 좋을 수도 있겠지만, TDD 형태로 테스트를 해가면서 점진적으로 기능을 구현하기 위해서, 그리고 범용성을 위해서도 public으로 노출하는 게 좋을 것 같습니다.

2번 접근법: 피보나치 수열 자체를 추상화합니다. 초기화 시에 고정된 크기의 피보나치 수열을 만들어 데이터로 갖고 있고, 이 수열을 기반으로 한 계산을 메소드로 제공하도록 설계합니다.

class FinbonacciSeries
  def initialize(max_value)
    @max_value = max_value
  end

  def even_sum
  end

  def sum
  end

  # ... 그외에 #display 등 피보나치 수열을 다른 측면에서 보여주기 위한 메소드 추가

  def series
    # 첫번째 실행됐을 때 #fibonacci_number를 호출해서 @series를 채워넣음.
    @series
  end

  private
  attr_accessor :max_value

  def fibonacci_number(val)
    # ...
  end
end

생성시에 모든 값이 결정되고, max_value 같은 파라미터를 바꾸고 싶으면 다른 인스턴스를 생성하는 형태입니다. 또한, 수열이 실제로 생성되는 것은 생성자에서 생성되는 시점이 아니라 첫번째 피보나치 수열을 구하는 시점(#sum이나 #even_sum에 의해 첫 호출될 때)으로 lazy하게, 최대한 뒤로 미룹니다.

지난 포스팅에서 소개한 해법의 경우, 2번의 형태로 구현하려고 하다가 시간 제한으로 정리가 덜 되서 애매한 구성이 되어버린 것으로 보입니다만.. 결과적으로 위의 소스와 같은 형태가 되었다면 나쁘지 않았을 겁니다.

일반적인 경우라면 2의 접근법(즉, 계산이나 처리의 결과물을 immutable object로 유지하도록 하는 형태)이 더 타당한 경우가 대부분입니다. 하지만 이번 경우는 피보나치 수열을 만들어내는 “생성기” 내지는 “계산기”를 클래스화 시키는 방향이 향후 재활용성 측면에서 더 좋을 것 같습니다. (2에 해당되는 경우는 나중에 더욱 재미있는 케이스를 갖고 소개해드리겠습니다.)

IV. 또 다른 예제 분석

그럼 이번엔 더 잘 만들어진 코드를 예로 보겠습니다. 테스트 코드를 포함한 전체 소스는 여기서 보실 수 있습니다.

class Fibonacci
  def even_sum(maxval)
    total = 0
    list = [0, 1]
    loop do
      value = list.last(2).reduce(:+)
      break if value > maxval
      total = total + value if value.even?
      list << value
    end
    total
  end

  def make_fibonacci(maxval)
    list = [0, 1]
    loop do
      value = list.last(2).reduce(:+)
      break if value > maxval
      list << value
    end
    list
  end

  def sum(values)
    total = 0
    values.each do |v|
      total += v if v.even?
    end
    total
  end
end

지난 포스팅에서 본 예제보다 훨씬 나은 코드네요. 일단 C처럼 보이는 코드가 전혀 없어서 안구가 편안합니다.

이 코드에서는 이전보다 루비에서만 지원되는 기능들이 많이 보입니다. Enumerable 모듈의 메소드들이 반갑게 느껴지는군요. #last, #reduce, #each 가 그것입니다.

6번째 라인에서는 연쇄 호출도 보이네요. #last는 Enumerable의 마지막 n개를 얻어와서 배열로 리턴해주고 #reduce는 이를 합쳐줍니다. 김대권 님 글을 다 보셨겠지만 그래도 복습 차원에서 #reduce 메소드에 대해서 짚어보면, #reduce의 정의는 이렇게 되어 있습니다.

reduce(initial) { |memo, obj| block }

그러므로 1, 2, 3의 합을 구하고 싶으면 아래와 같이 쓰실 수 있습니다.

[1, 2, 3].reduce(0) { |sum, i| sum = sum + item }

그런데 reduce의 초기값은 default가 0이므로 파라미터를 생략할 수 있습니다.

[1, 2, 3].reduce { |sum, i| sum = sum + item }

그리고 블록 내에서 하나의 매개변수만 가진 단일 메소드 호출 형태로만 이뤄질 경우 해당 메소드를 block 형태로 넘길 수 있습니다.(이건 특이한 예외가 아니라 block을 사용하는 다른 어떤 메소드에도 유사하게 쓸 수 있습니다.)

[1, 2, 3].reduce(&:+)

특이하게도 #reduce의 경우는 블럭 대신 메소드를 심볼 형태로도 받을 수 있습니다. (#map 등 다른 메소드는 대부분 그렇지 않기 때문에 &을 붙여줘야 합니다.)

[1, 2, 3].reduce(:+)

참고로, Ruby on Rails에서는 Enumerable에서 reduce(:+)와 동일한 #sum이라는 확장된 메소드가 따로 있어 더욱 편리합니다.

[1, 2, 3].sum

다시 6번째 라인의 코드를 보면 아래의 두 개의 코드는 완전히 동일한 역할을 하게 됩니다.

value = list.last(2).reduce(:+)

value = list[-2] + list[-1]

이 경우에는 두 개만 꺼내어 쓸거니 첫번째처럼 꼬인 코드보다는 그냥 두번째 방식이 낫지 않나 하는 생각도 듭니다만, 어느 쪽이든 나쁘지 않습니다.

결과적으로 지난 포스팅의 예에 비해 매우 만족스럽습니다. 하지만..

  • 여전히 하나의 메소드에 모든 기능이 들어있군요. 간단한 문제이기 때문에 충분하긴 합니다만, 그래도 개선해보고 싶습니다.
  • if가 여전히 필요 이상으로 많군요. loop도 좀 거슬립니다. map->select-reduce 형태로 어떻게 해결이 될 수도 있지 않을까요?
  • 중복된 코드도 있군요. 이런 코드는 리팩토링을 한 번 하면 좋을 것 같네요.;-)
  • 사족이지만 기왕이면 클래스 이름도 조금 더 와닿게 바꾸면 더 좋을 것 같고..(나쁘지는 않습니다)
  • 어차피 내부에 attribute(멤버변수)를 갖기 않기 때문에 class 대신에 new를 할 필요 없는 module을 사용하는 것으로 바꾸면 더욱 좋을 것 같군요. 아래의 세 경우 중 어떤 게 더 나은지는 직접 판단을 해보세요. =)
# 위의 예에서 여러 번 메소드를 호출한다면..
generator = Fibonacci.new
puts generator.even_sum(400_000)
puts generator.even_sum(6_000_000)

# 위의 예에서 한 번만 메소드를 호출한다면..
puts Fibonacci.new.even_sum(400_000)

# 제가 만든 코드 예에서 메소드를 호출한다면..
puts FibonacciCalculator.even_sum(400_000)
puts FibonacciCalculator.even_sum(6_000_000)

자, 그럼 위에서 지적한 문제들만 해결하면 잘 만들어진 오픈소스들처럼 이해하기 쉬우면서도 깔끔하고 line 수도 짧은 코드가 나올 것 같은데 어떻게 하면 좋을까요? 이에 대한 힌트는, 당연히 Enumerable의 적절한 활용에 있습니다. 여러분도 다시 한 번 시도해보세요. 다음 포스팅에서 이에 대한 해답과 함께, 제가 생각하는 가장 좋은 답을 함께 제시해 보겠습니다.(양념으로 테스트주도개발TDD에 대한 내용도 함께 살짝 다뤄보겠습니다.)

그리고, 보너스로 퀴즈를 하나 내보겠습니다.
Enumerable의 기능을 잘 활용하면, 그리고 가독성을 포기하면 #even_sum 메소드를 단 한 줄로 구현할 수 있습니다. 어떻게 할 수 있을지 한 번 고민해보세요.

루비 스타일(1) – C로부터의 탈출

wallpaper-keep-calm-and-code-in-ruby

* 이곳에 나온 예제 코드들은 RoRLab의 Playground에서 결과물로 나온 코드임을 밝혀둡니다. 매우 짧은 제한 시간에 만들어진 코드이기 때문에 실제 실력에 비해 잘 나오지 못한 코드임을 감안해주시기 바라며, 흔쾌히(?) 코드를 공유해주신 회원님들께 감사드립니다. *

앞으로 세 번에 걸쳐 보게 될 글들은 아래와 같은 간단한 문제에 대한 해결책을 담고 있습니다. 여러분도 한 번 풀어보시길 권해드립니다.

1 과 2로 시작하는 피보나치 수열의 10 번째 까지 숫자는 다음과 같다.
1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ......
40만이 넘지 않는 피보나치 수열중 짝수인 숫자의 합을 구하라

이에 대한 해법 중 하나를 봅시다.

class Fibo
  attr_accessor :limit, :even_sum, :array

  def initialize(limit)
    @limit = limit
    @array = Array.new
  end

  def even_sum()
    bb=1
    b=1
    sum=0

    c=0
    begin
      c=bb+b
      if(c < @limit && c.even?)
        @array << c
        sum+=c
      end
      bb=b
      b=c
    end while c < @limit
    sum
  end
end

잘 동작하는 코드이고 C 언어 기준으로 봤을 때 특별히 나쁘지 않은 코드입니다. 불필요한 @array 변수가 있는 것만 빼면 인터프리터 입장에선 군더더기 없는 형태라 실행 속도 측면에서도 훌륭해보입니다…만, 루비 개발자들이 추구하는 코드와는 상당한 차이가 있습니다. 애초에 기계가 좋아할 최적화된 코드를 선호하시는 분이라면 루비를 선택하시면 안..

루비의 코딩 스타일의 핵심은 (루비 고유의 방법대로) 가독성의 극대화를 추구하는 것에 있다고 저는 믿습니다. 잘 만들어진 루비 코드를 보면 공통점이 머리 속으로 컴파일러를 가동(mental compilation)시키지 않아도 이해할 수 있다는 점인데(이에 대해서는 나중에 다른 포스팅에서 Rails 코드를 얘기하면서 다뤄보겠습니다) 이 코드의 경우 기계가 이해하기에는 편하겠지만, 딱 봐서 의미를 알 수 없는 변수와 하나의 메소드에 모든 로직이 전부 구현된 형태이다 보니 머리 속으로 조금 컴파일을 해보지 않으면 이해하기 힘든 부분이 있고, begin/end, if/end 구문이 중첩되어 있어 (많지는 않지만) 가독성을 떨어뜨리고 있습니다.

일단 몸풀기로.. 코딩 스타일을 잠시 짚고 넘어가겠습니다. 자세한 내용은 각 항목의 [링크]를 눌러보세요.

  • 연산자 전후에는 공백을 넣는 것이 좋습니다. [자세한 내용]
  • 파라미터가 없는 메소드에는 호출할 때나 정의할 때나 괄호를 넣지 않는 게 좋습니다. [자세한 내용]
  • begin/end/while은 권장하지 않습니다. 대신 Kernel#loop와 break를 사용하는 것이 좋습니다. [자세한 내용]
  • 해시와 배열 생성은 (생성자에 파라미터를 넣어줘야하는 경우가 아니라면) Array.new 대신 []를, Hash.new 대신 {}를 이용하는 것이 좋습니다. [자세한 내용]
  • 그리고 끝으로 어떤 언어든 마찬가지지만.. 의미를 알 수 없는 변수명은 피하시는 게 좋습니다. ^^;

이에 따라 수정을 하면 아래와 같이 되겠군요.

# 1차 수정
class Fibo
  attr_accessor :limit, :even_sum, :seq

  def initialize(limit)
    @limit = limit
    @seq = [] # 변수명 수정, Array.new -> []
  end

  def even_sum     # 괄호 생략
    first = 1      # 띄어쓰기 추가, 변수명 수정
    second = 1     # 띄어쓰기 추가, 변수명 수정
    sum = 0        # 띄어쓰기 추가

    next_number = 0       # 띄어쓰기 추가
    loop do
      next_number = first + second  # 띄어쓰기 추가
      if next_number < @limit && c.even?  # 띄어쓰기 추가
        @seq << next_number
        sum += next_number
      end
      first = second
      second = next_number
      break if next_number > limit
    end
    sum
  end
end

코딩 스타일을 정리한 것만으로도 좀 더 소스가 이해하기 편해졌죠?

그 다음은 클래스 설계를 볼 필요가 있습니다. 클래스 설계는 클래스가 어떤 책임(responsibility)를 갖고 있냐를 조정하는 예술이라고 할 수 있습니다. 풀어서 얘기하면 클래스가 어떤 역할을 하게 될 것인가, 그리고 그를 뒷받침하기 위한 내부 데이터는 어디까지 공개하고 어디까지 숨길 것인가, 메소드들은 어떻게 나눌 것인가 에 대한 문제를 적절히 해결하는 것입니다.

  • 책임을 적절히 분산시킨다
  • 각 기능이 가장 적절한 클래스에 들어있도록 설계한다
  • 클래스와의 coupling이 일어나지 않도록 한다

대략 이런 원칙들을 통해 클래스를 설계하게 됩니다.(이에 대해서는 다른 포스팅을 통해서 다뤄보겠습니다.)

그럼 다시 예로 든 코드로 돌아와서 Fibo 클래스의 경우 무엇이 들어 있는가 보면..
1) 피보나치 수열의 최대값에 대한 접근자(accessor)
2) 설정된 최대값까지의 짝수 수열의 합을 리턴하는 단일 메소드
3) 2의 메소드가 실행되면서 만들어진 피보나치 배열에 대한 접근자
이렇게 다소 혼란스럽고 일관성이 떨어집니다.

이 클래스로 40만 이하의 짝수 수열 합을 구하는 코드는 아래와 같이 되는데..

fibo = Fibo.new(400_000)
puts fibo.even_sum

이후 다른 limit 값으로 짝수 합을 구하려면 #even_sum을 다시 호출하기 전에 fibo.limit = 100 과 같은 식으로 직접 limit 값을 바꿔줘야 합니다. 그리고 보조 결과물인 seq 배열은 #even_sum의 리턴 형태로 받는 것이 아니기 때문에 값이 채워지는 시점을 따로 알고 있어야 합니다.

위 코드는 이렇게 바뀌어야 하지 않을까요?

seq 필드 제거: 애초에 피보나치 수열 자체는 의미있는 정보가 아니기 때문에 굳이 노출할 필요가 없는 정보입니다. 다만, TDD로 진행을 하면서 unit test로 결과를 검증하기 위해서 필요할 텐데, 이 경우 피보나치 수열만 만들어주는 로직을 별도로 빼는 것이 합리적이지 않을까요?

피보나치 수열을 계산하는 로직을 분리: 위에서 얘기한 테스트를 편리하게 만들기 위한 것도 있지만 무엇보다 모든 로직을 하나의 메소드에 다 구현하는 것은 매우 좋지 못한 방식입니다. 논리적으로 분리되는 내용은 다른 메소드로 추출(extract)하는 것이 좋습니다.

limit 값을 #even_sum 메소드의 파라미터로 이동: limit 값에 영향을 받는 것이 애초에 even_sum 메소드 하나밖에 없다면 이것은 클래스의 소속이 아닌 메소드 안으로 들어가는 것이 맞습니다.

even_sum 접근자 제거: even_sum을 attr_accessor 를 이용해 정의했는데 even_sum 변수에 값을 써도 그것을 쓸 수 있는 코드가 어디에도 없습니다.

결론적으로 모든 accessor를 삭제하고 피보나치 배열을 만드는 서브로직을 별도로 빼면 되겠군요.
그런데 그렇게만 바꾸면 좋은 루비 코드가 나올까요? 사실 여기서 언급된 문제들은 Java 같은 다른 OOP에서도 똑같이 요구되는 것들입니다.

그럼 위의 문제를 모두 해결하면서도 좀 더 루비만의 특징을 이용한 깔끔한 코드는 어떻게 만들 수 있을까요?
다음 포스팅에서 다뤄보겠습니다.