더 나은 엔지니어가 되기 위해/읽고 쓰며 정리하기

책 리팩토링을 통해 보는 좋은 코드 원칙

흠시 2020. 4. 24. 23:32

회사에서 특정 로직 개발을 맡게 되면서, 요즘 들어 파이썬과 개발에 관심이 많이 생겼다.
좋은 코드란 무엇인지 자꾸 생각하게 되고, 코드 하나하나 짤 때마다 고민, 신중하게 된다.

이런 내게, 사내 다른 동료 분이 개발 관련 서적을 읽어보라고 권해주셨는데, 마틴 파울러의 "리팩토링" 이라는 책이다. 개발 서적에서 유명한 책인데, 최근 개정 2판이 나와 베타 리더로 책을 받으셨다고 한다. 개정되었어도 뭔가 고전 서적답게 표지부터 읽기 싫게 생겼다. 그래도 유명한 책이니 만큼, 또 좋은 코드에 대한 공부를 하고 싶어 퇴근 후 짬짬이 읽게 되었다.

근데 딱딱해보이는 표지에 비해 내용은 생각보다 부드러웠다.

이 포스팅은 이 책을 읽고 내게 필요한 내용만 정리한 글이다.
책에서는 몇 가지 리팩토링 과정을 500페이지의 분량으로 담아내는데, 이 글에서 나는 모든 내용을 정리하진 않았다.
책은 리팩토링 과정과 더불어 어떤 코드가 좋은 코드인지 그 방향과 원칙을 말해주는데, 나는 여기에 초점을 두고 읽었다.
특히 다음을 기준으로 두고 정리하였다.

  • 리팩토링 과정보단, 리팩토링이 근본적으로 지향하는 좋은 코드의 특징 위주로.
  • Pythonic 하지 않는 리팩토링은 제외
  • 너무 당연해보이는 리팩토링이나 코드 원칙은 제외
  • 당연한 거지만, 그래도 다시 한번쯤 되돌아보고 싶은 것들은 포함.

한마디로 말해서, 처음에 개발할 때 이런 코드 원칙들을 마음에 둬야겠다는 생각으로 정리했다.
사실 이런 것들은 단순히 정리한다고 되는 게 아니고, 직접 개발하고 고민하면서 체득해나가는 것이라고 생각한다.
그래도 정리해두고 두고두고 보면 좋을 거 같아서 적어둔다.

중요한 원칙들은 볼드체로 표현하였다.
코드는 모두 어떤 맥락의 '일부' 로 등장하는데, 이 맥락을 모두 표현하지는 않았다. (책에서도 그렇다...)
어느 정도 맥락을 상상하고 코드를 봐야한다.


코드에서 나는 악취

나쁜 코드들은 좋지 못한 특징들을 가지고 있는데, 이런 코드들은 악취가 난다고 표현한다.
이런 코드들은 리팩토링의 대상이 된다.
꼭 리팩토링이 아니라도, 이런 코드가 나쁘다는 '감' 정도는 가지고 있어야겠다.
책에서는 24가지를 말하는데, 내가 생각하는 중요한 일부만 정리한다.

  • 명료하고 정확하지 않은 이름
    • 변수, 함수, 클래스, 파일 이름 모두 해당된다.
    • 이름은 항상 "무엇을 하는지" 명확하게 드러내야 한다. 이름은 역할과 본질을 나타내 준다.
    • 코딩할 때 가장 어려운 파트이기도 하다.
  • 중복 코드
    • 중복 코드가 있다면, 수정할 때 2번 이상 수정해야 한다. 까먹어서 수정 안 하는 등 위험하다.
    • 3번 이상 중복 코드가 등장하면, 어떻게든 한 곳으로 모아주어야 한다.
  • 긴 함수, 긴 클래스, 긴 매개변수 목록
    • 길수록 보기 어렵다. 이해하기도 어렵다.
    • 길다는 건 너무 많은 역할을 담당하고 있다는 것이다. 쪼개거나 묶어야 할 대상이다.
  • 전역 데이터
    • 전역 데이터는 어디서든 건드릴 수 있고 누가 바꿨는지 찾아내기 힘들다.
  • 추측성 일반화
    • "나중에 필요할거야" 라는 생각으로 작성한 로직과 코드들은 실제로 사용하지 않는 경우가 많다.
    • 실제로 사용하게 되면 다행이지만, 그렇지 않다면 이해와 복잡도를 증가시킬 뿐이다.
  • 주석
    • 항상 나쁘다는 건 아니다. 주석은 필요한 경우에 코드에 향기를 입힌다.
    • 다만 장황한 설명이 달린 주석은 코드를 잘못 작성했기 때문인 경우가 의외로 많다.
    • 주석보다는 함수나 변수 이름으로 그 의도를 명료하게 드러내는 것이 좋다.

리팩토링

기본적인 리팩토링

1. 함수 추출하기

  • 목적과 구현을 분리한다.
  • 코드를 보았을 때 "어떻게" 보다 "무엇"을 하는지 한 번에 알 수 있도록 함수의 이름을 짓자.
  • 하나의 함수는 한 가지 목적을 가지고 한 가지 일만 해야 한다.
    • 즉 한 가지 일만 할 수 있도록 함수를 쪼개고 추출하자.
before
def printOwing(invoice):
  print_banner()
  outstanding = calcaulate_outstanding()

  print(f"고객명: {invoice.customer}")
  print(f"채무액: {outstanding}")
after
def printOwing(invoice):
  def print_details(outstanding):
    print(f"고객명: {invoice.customer}")
    print(f"채무액: {outstanding}")

  print_banner()
  outstanding = calcaulate_outstanding()
  print_details(outstanding)
  • 추가 설명과 팁
    • 단 한 줄짜리 함수라도 상관없다. 무엇을 하는지 명확하게 드러나야 한다.
    • 함수의 길이는 한눈에 들어와야 한다.
    • 두 번 이상 사용될 코드는 함수로 만들자.
    • 함수 이름을 당장 짓기가 어려우면, 주석으로 먼저 무슨 일을 하는지 적어두자.
    • 반면, 코드 자체로 무엇을 하는지 명확히 보인다면, 굳이 추출하지 않는다.

 

2. 변수 추출하기

  • 복잡한 표현식은 과정을 나누어 표현한다.
  • 각 과정을 잘 드러내는 임시 변수를 사용하자.
before
return order.quantity * order.item_price - max(0, order.quantity - 500) \
    * order.item_price * 0.05 + min(100, order.quantity * order.item_price * 0.1)
after
base_price = order.quantity * order.item_price
quantity_discount = max(0, order.quantity - 500) * order.item_price * 0.05
shipping = min(100, base_price * 0.1)
return base_price - quantity_discount + shipping
  • 추가 설명과 팁
    • 변수 이름을 문맥에 맞게 잘 짓자.
    • 문맥은 함수 내부, 클래스 내부, 전역 등에 따라 달라지므로, 어떻게 사용될지 잘 생각하고 이름을 지어야 한다.
    • 반면, 추출하지 않아도 그 자체로 명확히 보인다면 추출하지 말자. (오히려 더 깔끔하게 압축하자)

 

3. 매개변수 객체 만들기

  • 몰려다니는 데이터 무리를 데이터 구조 하나로 모아주자
  • 데이터 구조로 묶으면 데이터 사이의 관계가 아주 명확해진다.
before
def amount_invoiced(start_date, end_date):
  pass
def amount_recevived(start_date, end_date):
  pass
def amount_overdue(start_date, end_date):
  pass
after
def amount_invoiced(date_range):
  pass
def amount_recevived(date_range):
  pass
def amount_overdue(date_range):
  pass
  • 추가 설명과 팁
    • 객체를 만든다는 것은 어떤 개념을 추상화하는 것이다.
    • 변수들을 하나의 객체로 묶음으로써 하나의 개념을 만들어내고, 이는 더 나은 디자인을 만들어 낼 수 있다.

 

4. 여러 함수를 클래스로 묶기

  • 클래스로 묶으면, 함수들이 공유하는 공통 환경과 목적을 명확히 표현할 수 있다.
  • 또한 함수 매개변수를 줄여서, 호출을 더 간결하게 만들 수 있다.
  • 원하는 함수를 클래스 단위로 빠르게 찾을 수 있다.
before
def base(reading):
  pass
def taxableCarge(reading):
  pass
def calculate_base_charge(reading):
  pass
after
class Reading:
  def __init__(self, reading):
    self.reading = reading
  def base(self):
    pass
  def taxableCarge(self):
    pass
  def calculate_base_charge(self):
    pass

캡슐화

1. 레코드 캡슐화하기

  • 곳곳에 쓰이는 가변 데이터는 레코드가 아니라 객체로 저장하자.
  • 데이터 구조를 명확히 표현할 수 있고, 코드 한 곳에서 관리하고 표현할 수 있게 된다.
before
organization = {"name": "흠시", "country": "Korea"}
after
class Organization:
  def __init__(self, name: str, country; str):
    self.name = name
    self.country = country

 

2. 임시 변수를 질의함수로 바꾸기

  • 곳곳에 쓰이는 임시변수 메쏘드로 만들어, 굳이 임시변수를 더 만들지 말자.
before
base_price = self._quantity * self._item_price
if base_price > 1000:
  return base_price * 0.95
else:
  return base_price * 0.98
after
def _get_base_price(self):
  return self._quantity * self._item_price

if self._get_base_price() > 1000:
  return self._get_base_price() * 0.95
else:
  return self._get_base_price() * 0.98
  • 막상 책의 예제를 옮겨보니... before 가 더 가독성이 좋아 보인다.
  • 별로 올바른 예인 거 같지는 않으니, 이 리팩토링의 의도(임시 변수를 줄이려는) 만 기억하자.

 

3. 클래스 추출하기

  • 개발 과정에서 점점 비대해지는 클래스를 적절히 분리한다.
  • 단일 책임 원칙 (SRP) 를 잊지 말자.
before
class Person:
  def __init__(self, ..., office_area_code, office_number):
    ...
    self.office_area_code = office_area_code
    self.office_number = office_number
after
class Person:
  def __init__(self, ..., office_area_code, office_number):
    ...
    self.TelephoneNumber(office_area_code, office_number)

class TelephoneNumber:
  def __init__(self, office_area_code, office_number):
    self.office_area_code = office_area_code
    self.office_number = office_number
  • 추가 설명과 팁
    • 일부 데이터와 메쏘드를 따로 묶을 수 있다면 어서 분리하라는 신호다.
    • 함께 변경되는 일이 많거나, 의존하는 데이터들도 분리한다.
    • 개발 중, 일부 기능만을 사용하기 위해 서브 클래스를 만들어야 한다면 클래스를 나눠야 한다는 신호다.
    • 반대로, 리팩터링을 거치면서 쓸모 없어진 클래스는 이 과정을 반대로 한다.
      합친 뒤에, 다시 살펴보면 새로운 클래스를 추출할 수도 있기 때문이다.

기능 이동

1. 문장 슬라이드 하기

  • 관련된 코드들이 가까이 모여있다면 이해하기 더 쉽다.
    • 데이터 구조를 이용하는 문장들은 한데 모여 있어야 그 쓰임을 정확히 알 수 있다.
before
pricing_plan = receive_pricing_plan()
order = receive_order()
charge = None
charge_per_unit = pricing_plan.unit
after
pricing_plan = receive_pricing_plan()
charge_per_unit = pricing_plan.unit
order = receive_order()
charge = None
  • 추가 설명과 팁
    • 함수 첫머리에 변수를 몽땅 선언하기보다, 처음 사용할 때 선언하자.
    • 관련된 것들은 한데 모아두아야, 추가 리팩토링(함수 추출하기 등)을 시행하기 편하다.

 

2. 반복문 쪼개기

  • 하나의 반복문은 하나의 일만 해야 이해하기도, 관리하기도 쉽다.
    • 한 반복문에 두 가지 일을 하면, 두 가지 일 모두 이해해야 하고, 수정할 때도 신경 써야 한다.
before
average_age = 0
total_salary = 0
for person in people:
  average_age += person.age
  total_salary += person.salary
average_age = average_age / len(people)
after
total_salary = 0
for person in people:
  total_salary += person.salary

average_age = 0
for person in people:
  average_age += person.age
average_age = average_age / len(people)
  • 추가 설명과 팁
    • 성능 최적화는 당장 고려하지 않는다. (사실 대부분 성능에 그렇게 영향을 주지 않는다.)
    • 성능적으로 문제가 있다는 게 밝혀지면, 그때 다시 합치면 된다.
    • 코드 분리는 또 다른 최적화나 디자인 패턴의 길을 열어주기도 한다.

조건부 로직 간소화

1. 조건문 분해하기

  • 긴 조건문은 의도를 드러낼 수 있는 함수로 추출하여, 로직을 명확히 하자.
before
if date.is_before(plan.summer_start) and not date.is_after(plan.summer_end):
  charge = quantity * plan.summer_rate
else:
  charge = quantity * plan.regular_rate + plan.regular_service_charge
after
if is_summer():
  charge = summerCharge()
else:
  charge = regularCharge()

 

2. 조건식 통합하기

  • 하나로 합칠 수 있는 조건식은 합친 뒤, 의도를 드러낼 수 있는 함수로 추출하자.
before
if employee.seniority < 2: return 0
if employee.month_disabled > 12: return 0
if employee.is_part_time: return 0
after
def is_not_eligible_for_disability():
  return (employee.seniority < 2 or
          employee.month_disabled > 12 or
          employee.is_part_time)

if is_not_eligible_for_disability(): return 0

 

3. 특이 케이스 추가하기

  • 특수한 경우의 공통 동작을 요소 하나에 모아서 사용하면 관리하기 편하다.
before
if customer == "미확인 고객":
  customer_name = "거주자"
after
class UnknownCustomer:
  def __init__(self):
    self.name = "거주자"
  • 추가 설명과 팁
    • 일반적으로, 특정 값에 대해 똑같이 반응하는 코드가 여러 곳이면, 그 반응을 한 데로 모으는 게 효율적이다.
    • 모으는 것은 리터럴 객체나 따로 정의한 클래스에 모을 수 있는데,
    • 데이터를 담기만 하는 경우 리터럴 객체(dict 와 같은...)를 쓰면 되고, 어떤 동작을 수행해야 하면 클래스로 추출하면 된다.
    • 널 객체 패턴이라고도 한다.

 

4. 어서션 추가하기

  • 어서션은 어떤 상태임을 가정한 채 실행되는지 다른 개발자에게 알려주는 소통 도구다.
before
if self.discount_rate:
  base -= self.discount_rate * base
after
assert self.discount_rate >= 0
if self.discount_rate:
  base -= self.discount_rate * base
  • 추가 설명과 팁
    • 어서션이 있고 없고가 프로그램의 정상 동작에 아무런 영향을 주지 않도록 작성되어야 한다.
    • 즉, 어서션은 실패해서는 안된다. 실패한다면 어딘가 잘못 구현한 코드가 있는 것이다.
    • 어서션은 개발자 간의 커뮤니케이션 도구임을 잊지 말자.

마무리

책 본문 중간중간에 등장하는 좋은 코드에 대한 팁, 제언은 다음과 같이 따로 정리해둔다.
지금 나에게 이 책은... 구체적인 리팩토링 보다 이런 제언과 팁이 더 눈에 들어온다.

추가 팁들

  • 좋은 이름이 떠오르지 않는다면, 주석을 이용해 목적을 설명하자. 그러다 보면 주석이 멋진 이름이 되어 돌아올 때가 있다.
  • 데이터 구조와 이를 사용하는 함수가 근처에 있지 않으면 발견하기 어렵다.
  • 자고로, 변수는 한 번만 계산(할당) 되어야 하고, 이후에는 읽기만 해야 한다.
    • 변수에 값이 두 번 대입된다면, 이는 여러 가지 역할을 하고 있다는 신호다.
    • 역할이 둘 이상인 변수가 있다면 쪼개야 한다.

좋은 코드란

  • 코드는 명료하고 읽기 쉬워야 한다.
  • 잘 쪼개고, 잘 묶고, 잘 위치시켜야 한다.
    • 해당 기능과 관련된 작은 일부만 이해하고 수정해도, 동작 가능한 코드를 짜자.
    • 보통은 이해도가 높아질수록 소프트웨어를 더 잘 묶는 방법을 깨우치게 된다.
  • 프로그램의 상당 부분은 동작을 구현하는 코드로 이뤄지지만, 프로그램의 진짜 힘은 데이터 구조에서 나온다.
    • 적합한 데이터 구조를 활용하면 동작 코드는 자연스럽게 단순하고 직관적으로 짜여진다.
    • 반면, 데이터 구조를 잘못 선택하면 아귀가 맞지 않는 데이터를 다루기 위한 코드로 범벅이 된다.

책의 저자인 마틴 파울러 아저씨는, 프로젝트 코드를 더 개발하면 할수록 실제 도메인 문제를 더 잘 이해하게 된다고 했다. 그러다 보면, 내가 처음에 설계한 코드나 동작이 잘못 설계된 걸 깨닫게 되는 경우가 많다고 한다. 개발 핵 고수인 아저씨도 저렇게 말하는데, 하물며 이제 개발을 시작해보는 나는 얼마나 많이 경험하게 될까.

나는 아직 책에서 다루는 거대한 리팩토링(?)을 할 만큼의 코드를 다뤄본 적이 없어서, 리팩토링의 구체적인 과정보다는 어떤 코드를 추구해야 하는지에 더 관심을 두고 읽어보았다.
몇 년 뒤, 내가 좀 더 개발 경험을 하고 다시 읽으면 다르게 보일까?
정말 그렇게 느낄지 모르겠지만, 뭔가 시간이 흐른 뒤 다시 읽어봐야겠다는 느낌은 든다.