귀퉁이 서재

[알 스웨이가트] 클린 코드 이제는 파이썬이다 본문

책과 사유

[알 스웨이가트] 클린 코드 이제는 파이썬이다

Baek Kyun Shin 2023. 2. 6. 23:43

로버트 C. 마틴이 쓴 <클린 코드>는 개발자 필독서로 유명하다. 나도 4년 전에 읽었고 꽤 도움을 받은 책이다. 다만 자바를 바탕으로 설명해서 예제는 넘어가며 읽었다. 자바를 잘 모르니까. 그때 '파이썬 버전 클린 코드 책이 있으면 좋을 텐데'라는 생각을 했다. 마침 2022년에 책만 출판사에서 클린 코드 파이썬 버전의 책을 번역해 출간했다. 역자도 <클린 코드>를 번역한 박재호 님이다. 읽으리라 생각하고 있다가 잠깐 짬이 돼서 이번에 읽어봤다. 프로젝트를 하고 있어서 파이썬으로 꽤 긴 코드를 짜고 있는데, 날이 갈수록 코드가 더러워진다. 더 일찍이 읽었으면 좋았을 텐데. 그러면 코드가 좀 더 깔끔해졌을 텐데. 

사용하는 언어는 제외하고, <클린 코드>와 <클린 코드 이제는 파이썬이다>의 차이점은 크게 두 가지 같다.

첫째, <클린 코드 이제는 파이썬이다>에서는 코드 짜는 일에 '절대 법칙'이 있다고 말하진 않는다. 마치 글쓰기와 같다. 글쓰기에도 하지 말아야 할 점, 틀린 점은 있지만 항상 옳은 절대 법칙은 없다. 로버트 C. 마틴의 <클린 코드>가 '반드시 이렇게 하시오.'와 같은 뉘앙스를 풍긴다면 알 스웨이가트의 <클린 코드 이제는 파이썬이다>에서는 '이렇게 하면 좋으나 상황에 따라 예외는 있어요.' 느낌으로 말한다. 이런 점에서 <클린 코드 이제는 파이썬이다>가 좀 더 관대한 선생님 같다. 코딩에 '관대함'이 필요한진 모르겠지만.

둘째, <클린 코드>가 장인 정신을 기르도록 한다면 <클린 코드 이제는 파이썬이다>는 기법에 더 초점을 두는 느낌이다. 물론 <클린 코드>도 상당한 기법을 다루지만 책 전체에서 풍기는 느낌이 그렇다는 말이다. 장인 정신을 배양하는 느낌이다. 반면 <클린 코드 이제는 파이썬이다>는 좀 더 가벼운 분위기로 실제 스킬을 가르쳐준다.

<클린 코드>와 <클린 코드 이제는 파이썬이다> 가운데 꼭 하나만 봐야 한다면 난 <클린 코드>를 고를 거다. 내용이 더 심도 있고 마인드까지 다잡을 수 있으니까. 그렇지 않고 당장 파이썬으로 상당히 긴 코드를 짜야한다면 <클린 코드 이제는 파이썬이다>를 추천한다. 오늘 당장 써먹을 수 있는 기법을 다루기 때문이다.

나중에 다시 보려고 몇 가지 사항을 아래 정리했다. 책 전체 요약은 아니다.


오류 메시지 파악

파이썬 오류 메시지 읽는 법

파이썬의 오류 메시지는 어디서부터 오류가 시작됐는지 친절하게 알려준다. 다시 말해, 추적 정보를 알려준다. 다음 예를 보자.

def a():
    print('Start of a()')
    b()  # b() 함수 호출
    
def b():
    print('Start of b()')
    c()  # c() 함수 호출
    
def c():
    print('Start of c()')
    42 / 0  # 이 식은 0으로 나누기 에러를 발생시킴
    
a()  # a() 함수 호출

  Start of a()
  Start of b()
  Start of c()
  Traceback (most recent call last):
    File "abcTrackback.py", line 13, in <module>
      a()  # a() 함수 호출
    File "abcTrackback.py", line 3, in a
      b()  # b() 함수 호출
    File "abcTrackback.py", line 7, in b
      c()  # c() 함수 호출
    File "abcTrackback.py", line 11, in c
      42 / 0  # 이 식은 0으로 나누기 에러를 발생시킴
  ZeroDivisionError: division by zero

마지막 줄만 보면 42를 0으로 나눠서 오류가 발생했다는 사실을 알 수 있다. 그러면 위쪽에 표시된 오류 메시지는 무엇을 뜻하는지 알아보자.

  Trackback (most recent call last)

가장 최근 호출을 마지막에 표시한다는 뜻이다. 맨 처음 호출되는 함수가 a()이므로 오류 메시지에서 맨 처음 출력됐다. 가장 마지막(가장 최근)에 호출된 42 / 0를 가장 마지막에 표시한다.

  File "abcTrackback.py", line 13, in <module>

abcTrackback.py의 13행에서 충돌이 났다는 뜻이다. <module>이라는 문구로 이 행이 전역(global) 범위에 있다는 사실을 알려준다. 이렇듯 오류 메시지의 추적 정보를 보면 오류가 어디서부터 시작했는지 알 수 있다.

그런데 추적 정보가 항상 오류가 발생한 위치를 정확히 알려주지는 못한다. 다음 예를 보자.

print('Hello!'
print('How are you?')

    File "example.py", line 2
      print('How are you?')
      ^
  SyntaxError: invalid syntax

실제 오류는 첫 번째 줄에서 발생했지만, 오류 메시지는 두 번째 줄을 가리킨다. 파이썬 인터프리터가 두 번째 행을 읽기 전까지 구문 오류를 인지하지 못하기 때문이다. 추적 정보는 어디가 잘못됐는지를 나타낼 수 있지만, 항상 오류 발생 위치와 같지는 않다.

린터를 활용한 오류 방지

린터(linter)라는 소프트웨어를 사용하면 소스 코드에서 잠재적인 오류를 미리 발견할 수 있다. 예를 들어 Pyflakes라는 린팅 모듈이 있다. 다음 코드로 설치할 수 있다.

pip install --user pyflakes

이해하기 쉬운 이름

변수 이름은 너무 길어서도 너무 짧아서도 안 된다.

예외적으로 짧은 변수 이름이 좋은 경우도 있다. 예를 들어 숫자 범위나 리스트의 인덱스에 대해 반복하는 경우 for 문에서 i(index를 줄인)를 변수 이름으로 사용하고, 중첩된 루프가 있는 경우 j와 k(알파벳에서 i 뒤에 오기 때문)를 사용하는 것은 일반적인 용례다.

for i in range(10):
    for j in range(3):
        print(i, j)

또 다른 예로는 데카르트 좌표에서 x와 y를 사용하는 것이다. 이를 제외한 대부분 경우, 단일 문자 변수를 사용하지 않는 게 좋다. 너비(weight)와 높이(height)도 w와 h로 사용하거나, 숫자(number)도 n으로 사용하고 싶겠지만 분명하게 쓰는 게 좋다.

덧붙이자면, 짧은 구문을 활용해서 코드를 마치 일반 영어 문장처럼 읽을 수 있게 만들자. 예를 들어 그냥 number_trials라고 쓰기보다 number_of_trials가 훨씬 더 읽기 쉽다.

payment 같은 짧은 변수명은 단일 함수 안에서 지역 변수로 쓰기엔 괜찮다. 하지만 코드가 긴 프로그램 안에서 전역 변수로 쓰기에는 부적절하다. sales_client_monthly_payment나 annual_electric_bill_payment와 같이 좀 더 설명적인 이름이 바람직하다. 

부족한 설명보다는 지나친 설명이 차라리 낫다.

다만, Cat이라는 클래스 안에서 weight 속성을 넣는 경우, 분명 weight는 고양이 무게를 가리킬 테므로 속성 이름을 cat_weight라고 하면 불필요한 중복이다. 이럴 땐 그냥 weight라고 하자.

이름에 단위를 추가하면 유용한 경우가 있다. weight의 단위가 모호한 경우 weight_kg와 같은 변수를 사용하는 것이 현명한 선택일 수 있다.

모호한 변수명도 쓰지 않아야 한다. 예를 들어, 온도 데이터의 통계적 분산을 나타내는 변수를 만들어야 하는 경우 temp_var_data는 형편없는 변수명이다. 명확하게 temperature_variance로 사용하자.


코드 악취

주석 처리 코드

주석 처리된 코드는 죽은 코드다. 이를 보는 프로그래머가 해당 코드를 프로그램의 실행 가능한 부분이라고 오해할 수 있기 때문에 코드 악취다. 코드를 주석 처리하기보다는 깃과 같은 버전 관리 시스템으로 변경사항을 추적하는 게 바람직하다.

숫자 접미사가 붙은 변수

예를 들어, 오타 방지를 위해 비밀번호를 두 번 입력하도록 요청하는 등록 폼을 처리하는 경우를 생각해보자. 비밀번호 문자열을 password1과 password2라는 변수에 저장할 수 있다. 그렇지만 이 숫자 접미사는 변수가 무엇을 포함하고 변수 사이에 어떤 차이점이 있는지를 설명하지 못한다. 숫자 접미사를 붙이는 대신, password와 confirm_password가 더 바람직한 변수 이름일 것이다.

다른 예를 들어보자. 출발지 좌표와 목적지 좌표를 다루는 함수가 있다고 할 때, x1, y1, x2, y2라는 파라미터가 있을 것이다. 이 파라미터 대신 start_x, start_y, end_x, end_y가 더 괜찮은 파라미터명이다.

플래그 인수는 나쁘다?

부울(boolean) 인수를 플래그(flag) 인수라고 부르기도 한다. 플래그 인수는 항상 나쁘다는 건 잘못된 통념이다. 만약 다음과 같이 플래그 인수에 따라 전혀 다른 일을 하는 함수가 있다고 하자.

def someFunction(flagArgument):
    if flagArgument:
        # 특정 코드 실행...
    else:
        # 완전히 다른 특정 코드 실행...

함수가 이와 같다면, 플래그 인수를 통해 함수의 코드 절반을 실행할지 말지를 결정하기보다는 두 개의 함수를 별도로 만들어야 한다. 그러나 sorted() 함수처럼 reverse 인수에 부울 값을 전달해 정렬 순서를 결정하는 경우도 있다. 이 함수를 sorted()와 reverseSorted()라는 두 함수로 나눠도 개선되는 게 없다. 

체이닝 비교 연산자

파이썬에서는 체이닝 비교 연산자가 있기 때문에 and 연산자를 굳이 쓸 필요가 없는 경우가 있다. 다음 예를 보자.

# 파이썬답지 않은 예
if 42 < spam and spam < 99:

# 파이썬다운 예
if 42 < spam < 99:

파이썬 세상의 프로그래밍 용어

반복가능 객체 vs. 반복자

for 루프문에는 반복가능 객체를 사용하다. 

for i in range(3):
    print(i)
    
for i in ['a', 'b', 'c']:
    print(i)

여기서 range(3)와 ['a', 'b', 'c']는 반복가능 객체다. for 루프문을 조금 더 들여다보면, for 루프문에서는 파이썬 내장 함수인 iter()와 next() 함수를 호출한다. 반복가능 객체를 iter() 함수로 전달해서, 반복자 객체를 반환한다. for 루프문의 각 반복에서 반복자 객체는 next() 함수로 전달되어 다음 아이템을 반환한다. iter()와 next() 함수를 수동으로 호출해보자. for 루프문이 어떻게 작동하는지 확인할 수 있다.

iterableObj = range(3)  # 반복가능 객체

iteratorObj = iter(iterableObj)  # 반복자 객체

i = next(iteratorObj)
print(i)

i = next(iteratorObj)
print(i)

i = next(iteratorObj)
print(i)

0
1
2

여기서 한 번 더 i = next(iteratorObj)를 실행하면 StopIteration 예외를 발생시킨다. 파이썬의 for 루프문은 이 예외를 잡아서 루프문을 언제 중지해야 할지 판단한다.

구문 에러 vs. 런타임 에러 vs. 의미 에러

구문(syntax)이란 지정된 프로그래밍 언어의 유효한 명령어에 대한 규칙 집합이다. 괄호 누락, 쉼표 대신 마침표 표기, 단순 오타 등이 있으면 즉시 구문 에러(SyntaxError)를 발생시킨다. 

런타임 에러(runtime error)는 수행할 수 없는 작업을 실행할 때 발생한다. 예를 들어, 없는 파일을 열려고 하거나 숫자를 0으로 나누는 것과 같은 작업 등이 런타임 에러를 발생시킨다.

의미 에러(semantic error)는 프로그래머가 의도하지 않은 방식으로 실행되는 버그다. 에러 문구를 발생시키진 않는다. 다만 원치 않는, 의도하지 않은 결과를 낸다. 논리적인 오류라고 보면 된다.

파라미터 vs. 인수

파라미터(parameter)는 def 문의 괄호 사이 변수 이름을 말한다. 반면 인수(argument)는 함수 호출 시 전달되는 값을 뜻한다. 

def greeting(name):
    print("Hello,", name)
    
greeting("Chris")

여기서 name은 파라미터고, "Chris"는 인수다.

라이브러리 vs. 패키지

라이브러리는 제3자가 만든 모든 코드 모음이다. 라이브러리에는 함수, 클래스, 기타 코드 조각들이 포함될 수 있다. 파이썬 라이브러리는 패키지나 패키지 집합, 심지어 단일 모듈의 형태를 취할 수 있다.


파이썬에서 빠지기 쉬운 함정들

루프문 진행 중에는 리스트에서 아이템을 추가/삭제하지 말자

for나 while 루프문이 리스트를 반복하는 동안 리스트에 아이템을 추가하거나 삭제하면 버그가 발생할 수 있다. 대신 새로운 리스트를 활용해 추가하거나 삭제하는 작업을 해야 한다.

copy.copy()나 copy.deepcopy() 없이 가변 값을 복사하지 말자

다음 예를 먼저 보자.

spam = ['cat', 'dog', 'eel']
cheese = spam
spam[2] = 'MOOSE'

이때, spam과 cheese를 출력해보면 모두 ['cat', 'dog', 'MOOSE']가 나타난다. spam의 요소만 바꿨는데 cheese의 요소도 같이 바뀐 것이다. cheese는 spam 객체를 복사한 게 아니라 단순히 객체에 대한 참조만 복사한다. 따라서 id(spam)과 id(cheese)는 같다. 두 변수 모두 동일한 리스트 객체를 가리킨다. 원하는 대로 객체 자체를 복사하려면 copy.copy()를 사용하면 된다.

import copy

spam = ['cat', 'dog', 'eel']
cheese = copy.copy(spam)
spam[2] = 'MOOSE'

이때, spam은 ['cat', 'dog', 'MOOSE']로 바뀌지만, ham은 ['cat', 'dog', 'eel']로 그대로 유지된다. copy.copy()를 활용했기 때문에 cheese는 ham을 단순히 참조한 게 아니라 객체의 복사본을 만든 것이다. 

한편, copy.deepcopy()는 리스트에 다른 리스트가 포함되어 있을 때 사용한다. copy.copy()보다 약간 느리지만 미묘한 버그까지 예방하려면 copy.deepcopy()를 쓰는 편이 더 안전하다.


파이썬다운 함수 만들기

* 혹은 **를 사용해 가변인수 함수 만들기

def 문에서 * 구문을 사용하면 가변적인 위치기반 인수를 받아들일 수 있다. 이를 가변인수 함수라고 한다. 다음 예를 보자.

def product(*args):
    result = 1
    for num in args:
        result += num
    return result

product(3, 3)
# 9
product(2, 1, 2, 3)
# 12

인수를 두 개 넣을 수도 있고, 네 개 넣을 수도 있다. 개수가 달라진다면 *args로 파라미터를 지정해줄 수 있다. 반면 **를 사용해 가변인수 함수를 만들 수도 있다. **kwargs는 딕셔너리의 키-값(key-value) 쌍으로 인수를 할당할 때 사용한다. 다음 코드와 같이 말이다.

def formMolecules(**kwargs):
    if len(kwargs) == 2 and kwargs['hydrogen'] == 2 and kwargs['oxygen'] == 1:
        return 'water'
    elif len(kwargs) == 1 and kwargs['unobtanium'] == 12:
        return 'aether'
        
formMolecules(hydrogen=2, oxygen=1)
# 'water'

formMolecules(unobtanium=12)
# 'aether'

*와 **를 모두 사용해서 함수를 만들 수도 있다.

def printLower(*args, **kwargs):
    args = list(args)
    for i, value in enumerate(args):
        args[i] = str(value).lower()
    return print(*args, **kwargs)
    
name = 'Albert'
printLower('Hello', name)
# hello, albert

printLower('DOG', 'CAT', 'MOOSE', sep=', ')
# dog, cat, moose

참고로 def 함수에서 *args 파라미터가 **kwargs 파라미터 앞에 와야 한다.

함수형 프로그래밍

함수형 프로그래밍(functional programming)이란 전역 변수나 어떠한 외부 상태(하드 드라이브의 파일, 인터넷 연결, 데이터베이스 등)도 수정하지 않고 계산 수행 목적의 함수 작성을 강조하는 프로그래밍 패러다임이다. 다시 말해 부수효과(side-effect)가 없는 프로그램을 뜻한다. 부수효과란 함수가 자신의 코드와 지역 변수 바깥에 존재하는 프로그램의 각 부분에 가하는 모든 변화를 말한다. 

이와 관련된 개념으로 결정론적 함수(deterministic function)가 있다. 결정론적 함수란 같은 인수에 대해 항상 같은 결괏값을 반환하는 함수를 말한다. 예를 들어 subtract(123, 23)은 항상 100을 반환한다. round(3.14)는 항상 3을 반환한다. 이와 반대로 비결정론적 함수(nondeterministic function)는 동일한 인수를 전달한다고 해서 항상 동일한 값을 반환하지 않는다. 가령 random.randint(1, 10)을 호출하면 1에서 10 사이의 무작위 정수가 반환된다. time.time()도 그때그때 다른 반환값이 나온다. 

결정론적 함수는 이점이 있다. 결괏값을 캐시할 수 있다는 점이다. 값을 캐시하면 다음에 사용할 때 속도가 빠르다. subtract(123, 23)가 항상 100을 반환한다면 100을 저장해두면 편하다. 시간과 공간의 트레이드오프를 하는 것이다.

결정론적이고 부수효과가 없는 함수를 순수 함수(pure function)이라고 한다. 함수형 프로그래머는 순수 함수만 만들기 위해 애를 쓴다. 순수 함수의 장점은 다음과 같다.

  • 외부 자원을 설정할 필요가 없어서 단위 테스트에 적합하다.
  • 버그를 재현하기 쉽다.
  • 순수 함수에서 다른 순수 함수를 호출해도, 순수 함수 상태를 그대로 유지할 수 있다.
  • 멀티스레드 환경에서도 안전하다.

일반 함수를 순수 함수로 만드는 방법은 함수에서 전역 변수를 쓰지 말고, 파일이나 인터넷, 시스템 시계, 난수 등 외부 자원과 상호작용하지 않는 것이다.

Comments