루비 스타일(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 메소드를 단 한 줄로 구현할 수 있습니다. 어떻게 할 수 있을지 한 번 고민해보세요.

Advertisements

루비 스타일(2) – 들여쓰기(indent)로부터의 탈출”에 대한 1개의 생각

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중