FizzBuzz를 '개발자답게' 구현해보자

FizzBuzz를 '개발자답게' 구현해보자

2018, Dec 02    

들어가며


내가 패스트캠퍼스 웹 프로그래밍 스쿨에서 파이썬을 막 공부할 때, 안수찬 선생님이 파이썬 기초를 짧게 강의하신 적이 있다. 그때 조건문을 공부하면서 FizzBuzz 문제를 풀었었는데, 그때 내 코드는 누구나 만드는, 제품으로서 0원 정도의 가치가 있는 그 정도 수준의 코드였다. 선생님은 마치 그것을 비웃듯 전혀 다른 기절초풍할 방법으로 그 문제를 풀었었는데 그때 내가 느낀 충격은 이루 말할 수 없을 정도였다. 단순히 그 문제를 푸는 것을 넘어서, 프로그래밍에서 내가 생각할 수 있었던 범위를 벗어나는 프로그램의 확장성을 고려한 풀이였기 때문이다. 분명 그때 나는 ‘내가 모르는 신세계가 있구나’하고 느꼈었다.

그리고 며칠 전 프로그래밍 수업을 하시는 김왼손 씨의 특강의 조교로 잠시 일했는데, 거기서도 수강생들이 조건문 훈련의 일환으로 FizzBuzz 문제를 풀었다. 그때 번뜩이며 일반적인 풀이보다 더 훌륭한 풀이가 생각났고 그래서 지금 포스트를 적는다. 알고리즘 공부한 게 헛지랄 한 것은 아니구나… 느끼며 짧게 포스트해보겠다.

FizzBuzz란?


FizzBuzz는 영미권에서 아이들이 나눗셈에 익숙해지기 위해 하는 게임이라고 한다. 그 게임은 어떤 0 이상의 정수 카드를 정수가 3의 배수일 때 ‘Fizz’, 5의 배수일 때는 ‘Buzz’, 둘 모두의 배수일 때는 ‘FizzBuzz’로 교체한다. 이도저도 아니면 그 카드는 그대로 둔다.

아마 프로그래밍을 배우는 초반에 조건문을 다루면서 이 문제를 한 번씩은 접하지 않나 싶다. 혹시 처음 보는 분들은 다음 장으로 넘어가기 전에 이 문제와 함께 어떤 연도가 윤년인지 판단하는 문제를 먼저 풀어보는 것도 괜찮다. 윤년 문제도 조건문 연습하기에 좋기 때문이다.

간단하게 바로 구현


이 문제는 간단하게 조건문으로 구현할 수 있다. 어떤 정수가 들어왔을 때, 그 수에 적용하는 알고리즘은

  1. 3의 배수이고(AND) 5의 배수이면 ‘FizzBuzz’를 출력한다.
  2. 3의 배수이면 ‘Fizz’를 출력한다.
  3. 5의 배수이면 ‘Buzz’를 출력한다.
  4. 이 모두가 아니면 단순히 그 숫자를 출력한다.

이를 바로 구현해보자. 이번 알고리즘에서는 확실한 판단을 위해 1 이상의 정수 N을 받으면 1부터 N까지 알고리즘을 적용해 출력하는 방식으로 만들어보자.

def fizzbuzz(n):
    for i in range(1, n+1):
        if i % 15 == 0:
            print('FizzBuzz')
        elif i % 3 == 0:
            print('Fizz')
        elif i % 5 == 0:
            print('Buzz')
        else:
            print(i)

>>> fizzbuzz(20)

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

설명할 게 있을까? 가장 일반적인 풀이이다. 근데 너무나 재미없는 풀이. 초반에 조건문을 공부하면서 푸는 게 아니라면 그 어떤 흥미도 불러일으키지 않는 코드다. 하지만 문제를 개발자답게 생각해서 이 코드를 더 좋은 프로그램으로 바꿀 수는 없을까??

개발자답게 구현하자!


위의 지극히 재미 없는 FizzBuzz를 보다 개발자답게 구현해보자. 그런데 그 전에, ‘개발자답다’가 무슨 뜻이지…?

개발자답다??

이 포스트의 제목을 “FizzBuzz를 ‘개발자답게’ 구현해보자”라고 지으며 제목이 적절한가에 대한 생각을 잠깐 했다. 실무를 처리하며 개발자로서 산전수전을 다 겪어보지도 못한 내가 ‘개발자답다’는 표현을 써도 상관없을까? 결론은 그냥 쓰기로 했다. 모두에게 합의된 좋은 개발자의 덕목이 존재하는 것도 아니고, 그 덕목이 한두 가지일 것 같지도 않으니까. 그냥 내가 생각하는 덕목을 밀고 나가면 되겠지.

좋은 개발자는 좋은 프로그램을 만드는 사람이고, 좋은 프로그램은 많은 사용자들이 사용하는 프로그램이라고 생각한다. 많은 사용자들이 사용하는 좋은 프로그램이 되려면 다양한 수요와 취향의 사용자들을 수용하기 위해 그 프로그램의 재사용성, 확장성이 뛰어나야 한다.


생각해보자. 지금 FizzBuzz는 3의 배수, 5의 배수만 처리한다. 그런데 모든 정수가 약수를 3, 5만 갖는 것이 아니니 어떻게 학생들이 모든 나눗셈을 연습할 수 있겠는가! 누구는 7의 배수일 때는 ‘Sizz’, 11의 배수일 때는 ‘Jazz’으로 숫자를 교체하고 싶을지도 모른다. 가령, 정수가 35일 때는 5의 배수이기도 하고 7의 배수이기도 하니 ‘BuzzSizz’라고 써야 하고, 105라면 3, 5, 7의 배수이니 숫자를 ‘FizzBuzzSizz’로 변환해야 할 것이다. 한 마디로 말해 ‘나눌 정수와 단어를 쌍으로 하는 규칙을 사용자 원하는 대로 정할 수 있는 사용자 정의 FizzBuzz를 만들어보자’가 이번 포스트의 주제다.


구현 아이디어

사용자 정의 FizzBuzz는 규칙이 몇 개가 되든, 규칙의 정수와 그때의 단어가 무엇이든 처리할 수 있어야 한다. 이것을 어떻게 해결해야 할까? ‘데이터의 구조가 프로그램의 구조를 결정한다’는 말이 있다. 일단 입력 데이터와 출력 데이터를 정의하자. 입력은 정수와 단어의 튜플을 원소로 하는 Sequence로 하겠다. 이때 Sequence는 리스트나 튜플과 같이 순회 가능하고 순서 있는 자료구조로 생각하면 된다. 정수는 3과 5, 7처럼 배수를 확인할 때 쓸 것이고, 단어는 그때에 붙일 단어이다. 그러니까 입력이 ‘items = [(3, ‘Fizz’), (5, ‘Buzz’), (7, ‘Sizz’)]’ 등과 같으면 출력은 주어진 정수를 ‘BuzzSizz’ 등으로 변환할 것이다.(정수가 5, 7 모두의 배수일 때)


그 다음은 어떻게 할까? 기본적인 ‘FizzBuzz’의 아이디어를 재활용해볼까?

  1. 어떤 정수가 3, 5 모두의 배수이면 ‘FizzBuzz’,
  2. 5의 배수이기만 하면 ‘Buzz’,
  3. 3의 배수이기만 하면 ‘Fizz’,
  4. 이도저도 아니면 정수 그대로 출력.

음… 규칙이 2개만 되도 4번의 분기가 일어난다. 그러면 이를 확장해서 정수가 7의 배수일 때는 ‘Sizz’를 붙이는 규칙을 추가하자. 그러면 이때의 분기는

  1. 어떤 정수가 3, 5, 7 모두의 배수이면 ‘FizzBuzzSizz’,
  2. 5, 7의 배수이면 ‘BussSizz’,
  3. 3, 7의 배수이면 ‘FizzSizz’,
  4. 3, 5의 배수이면 ‘FizzBuzz’,
  5. 7의 배수이면 ‘Sizz’,
  6. 5의 배수이면 ‘Buzz’,
  7. 3의 배수이면 ‘Fizz’,
  8. 이도저도 아니면 정수 그대로 출력.

규칙이 하나 늘었을 뿐인데 8번의 분기가 일어난다. 그러니까 규칙의 개수가 \(n\)일 때 \(2^n\)의 분기가 일어나는 것. 이것을 어떻게 다 입력하겠는가… 그런 일은 일어나서는 안 된다. 그렇다면 방법은 바로 재귀이다.


생각을 조금만 다르게 해보자. 어떤 정수 \(n\)이 입력되었을 때, 위처럼 규칙 모두를 사악하게 합치는 것이 아니라 최종적으로 반환할 빈 문자열을 만들고 규칙마다에서 정수 \(n\)이 규칙의 정수의 배수일 때 단어를 하나씩 추가해나가는 방법을 쓰는 것이다.

우리의 입력 데이터는 Sequence 이기 때문에 규칙마다를 순회하면서 정수가 규칙의 정수의 배수이면 규칙의 단어를 추가한다. 최종적으로 순회가 끝났을 때 결과물이 빈 물자열이면 그 어떤 규칙의 선택도 받지 못했기 때문에 숫자 그대로 출력한다.

이를 재귀 함수로 구현해보자.


구현

def scalable_fizzbuzz(n, items=((3, 'Fizz'), (5, 'Buzz'))):
    """A scalable FizzBuzz program

    :input:
        n : A number to check. It should be an int and over 1.
	items: An iterable of tuples. All tuples should contain a divisor and a word for it.

    :return:
        A concatenated string of words in items without spaces \
	if n is divisible by the divisors.
	If none of divisors works, just return n in int.
    """

    # 0.
    N = len(items)

    def generate(n, i, tmp_str):
        # 1.
        if i == N:
            return tmp_str if tmp_str else n

        divisor, word = items[i]

        # 2.
        if n % divisor == 0:
            ans = generate(n, i+1, tmp_str+word)
        else:
            ans = generate(n, i+1, tmp_str)
        return ans

    # 3.
    return generate(n, 0, '')


N = 200
length = len(str(N))
for i in range(1, N+1):
    # Python string formatting: 다수의 출력을 깔끔하게 처리하기 위해 사용함.
    print(f'{i:<{length}} : {scalable_fizzbuzz(i, ((3, "Fizz"), (5, "Buzz"), (7, "Sizz"))):<20}', end='')
    if i % 3 == 0:
        print()


1   : 1                   2   : 2                   3   : Fizz                
4   : 4                   5   : Buzz              6   : Fizz                
7   : Sizz               8   : 8                   9   : Fizz 
...

확장성 있는 새로운 ‘scalable_fizzbuzz’를 만들었다. 이 함수는 정수 \(n\)과 규칙의 모음 \(items\)를 받는데 규칙이 튜플의 튜플이라는 것이 의미 있다. 일단 함수 정의 시에 이렇게 기본값을 입력한 것은 사용자가 사용자 정의 FizzBuzz가 아닌 우리가 아는 일반적인 FizzBuzz를 하고 싶을 수 있기 때문에 초기값을 줬다. 입력을 튜플로 한 것은 튜플이 Immutable한 자료구조이기 때문이다. 피보나치 알고리즘 포스트에서도 소개했었는데, 함수 정의부에 입력한 자료구조는 함수가 존재하는 한 반영구적으로 보존되기 때문에 값이 변하는(Mutable)한 자료구조를 두면 차후에 원인을 찾기 힘든 버그가 발생할 수 있기 때문이다.

그 다음은 규칙에 따라 문자열을 생성할 \(generate\)를 만들었다. 이 함수는 입력을 우리가 처음으로 입력 받는 \(n\)과 현재 살펴 볼 규칙 모음 \(items\)의 인덱스 \(i\), 중간에 완성 중인 \(tmp\_str\)을 받는다. 이 함수의 동작 과정을 살펴보면 다음과 같다.

  1. 재귀 함수의 탈출조건을 정했다.
    • 인덱스 \(i\)가 규칙 모음의 길이 N 과 같아졌다는 것은 모든 규칙을 살펴봤다는 것이니 탈출해야 한다. 이때 \(tmp\_str\)이 빈 문자열이라면 주어진 정수에 규칙의 정수에 하나도 나눠지지 않는다는 것이니 숫자 그대로 반환하고 하나라도 나눠졌으면 그 문자열을 반환한다.
  2. 규칙은 첫 원소가 나눌 정수(divisor), 그때의 단어(word)로 되어 있다.
    • i 번째의 현재 규칙에서 정수가 divisor 로 나눠지면 generate 의 \(tmp\_str\)에 word 를 추가해 실행한다. 나눠지지 않으면 단어를 추가하지 않고 실행한다. 인덱스에 i+1 이라는 것도 참고하자. 현재 규칙을 살폈으니 당연히 다음 규칙으로 넘어가야 하지 않겠는가. :)
  3. 마지막으로 generate 를 구동한다.
    • 먼저 첫 원소를 살펴야 하니 두 번째 인자가 0, 현재 만든 문자열이 없으니 “’'”으로 시작한다.


그리고 테스트를 했다. 기존 FizzBuzz에 7의 배수일 때 ‘Sizz’ 규칙을 추가했다. 정수를 1부터 200까지 테스트했는데 print 함수가 특이하다. 숫자가 크기 때문에 그냥 출력하면 한 숫자에 한 줄씩 차지해 제대로 보기 힘들다. 그래서 등간격으로, 한 줄에 3개씩 숫자를 보기 위해 Python f-string formatting을 써서 출력을 제어했다. 당장 이해가 힘들면 일단 이건 넘어가자. 일단 시행해보라. 등간격으로 맞추는 것이 얼마나 아름다운지 :) 그리고 숫자가 105일 때 ‘FizzBuzzSizz’가 출력되는 것을 보면 함수는 잘 작동한다.


마지막으로 훌륭한 프로그램의 마지막 조건, 프로그램의 help 문서를 만들었다. 사용자 정의 프로그램은 사용자가 사용하기 편해야 하고, 그렇기 때문에 문서화는 필수다. 함수 정의 시 함수 이름 바로 밑에 “”” “"”를 입력한 후 ‘help(함수명)’과 같이 입력하면 이 문자열이 출력된다. 문서화는 최소한 함수의 입력과 처리, 출력을 개괄적으로나마 표현할 수 있어야 한다.

이제 우리의 확장성 있는 FizzBuzz는 빛을 발하고, 어린 친구들은 나눗셈 연습을 더 잘할 수 있게 되었다.


마치며


FizzBuzz 포스트가 끝났는데 솔직히 잘했는지 모르겠다. 별 것도 아닌 기능이 쓸데없이 길어진 것이 아닌지 좀 우려스러운데 평가는 다른 분들께 맡긴다.

원래 예고된 이번 알고리즘 포스트는 사실 1차원 벡터 회전 알고리즘이었는데 그게 설명이 쉽지 않아 조금 두렵다. 조금 얘기하면 4가지의 알고리즘을 소개해야 하는데 그중 분할정복 회전 알고리즘은 도식화해 설명하고 싶은데 도식화 툴을 아직 못 찾고 있다. 조금 더 고민해보겠다.

이 블로그가 찾는 사람들이 전보다는 많아진 것 같다. 전에는 하루에 1명 찾고 그 1명이 나였는데.. ㅋㅋ 조금은 뿌듯하기도 하다.

이상 FizzBuzz 알고리즘 포스트를 마칩니다.