Python Clean Code(2)

노하람·2022년 2월 9일
0

관용구와 Pythonic

오늘은 아이디어를 파이썬으로 표현하는 방식과 그 특수성을 살펴본다.
프로그래밍에서 관용구(idiom)는 특정 작업을 수행하기 위해 코드를 작성하는 특별한 방법이다.
매번 동일한 구조를 반복하고 따르는 것이 일반적이다.
이것은 디자인 패턴과는 다르다.
가장 큰 차이점은 디자인 패턴은 언어와 무관한 고차원의 개념으로 코드로 즉시 변환되지 않는다는 것이다.
반면에 관용구는 실제 코딩으로 변환된다. 특정 작업에 사용할 수 있는 실제 코드이다.

관용구는 코드이므로 언어에 따라 다르고, 모든 언어는(C, C++에서 파일 열고 쓰는 방법 같은) 고유한 관용구를 가지고 있다.
이 관용구를 따르는 코드를 관용적이라 부르고 특히 파이썬에서는 파이썬스럽다(Pythonic)고 한다.

관용구를 따르는 이유는 일반적으로 더 나은 성능을 내기 때문이다.
또한 코드도 더 작고 이해하기 쉽다.(효율적인 코드의 일반적인 특징)
또한 이전 장에 소개했듯이 전체 개발팀이 동일한 패턴과 구조에 익숙해지면 실수를 줄이고 문제의 본질에 집중할 수 있다.

이 장의 목표는 다음과 같다.

  • 인덱스와 슬라이스 이해, 인덱싱 가능한 객체를 올바른 방식으로 구현하기
  • 시퀀스와 이터러블 구현
  • 컨텍스트 관리자를 만드는 모범 사례 연구
  • 매직 메서드를 사용해 보다 관용적인 코드 구현
  • 파이썬에서 부작용을 유발하는 흔한 실수 피하기

인덱스와 슬라이스

다른 언어와 마찬가지로 파이썬의 일부 데이터 구조나 타입은 자신이 가진 요소에 인덱스를 통해 접근하는 것을 지원한다.
일반적인 언어는 첫 요소의 인덱스가 0부터 시작하지만, 파이썬은 색다른 방법으로 접근을 지원한다.

  1. C의 경우 배열의 마지막 요소는 배열의 길이-1으로 접근, 파이썬의 경우 음수 인덱스 이용 [-1]

  2. 하나의 요소를 얻는 것 외에도 slice를 이용해 특정 구간을 구할 수 있다. [2:5]

    • 슬라이스의 시작 인덱스는 포함, 끝 인덱스는 제외하고 선택 구간의 값을 가져오는 것에 유의 (2~4 인덱스)
    • 시작, 끝 또는 간격 파라미터 중 하나를 제외할 수 있다. 또한 간격도 조정할 수 있다. [:3], [3:], [::], [1:7:2]는 전체를 가져옴
    • [::]는 원래 오브젝트의 복사본을 가져온다.

그래서 뭐??
튜플, 문자열, 리스트의 특정 요소를 가져오려고 한다면 for 루프를 돌며 수작업으로 요소를 선택하지 말고 이와 같이 슬라이스를 활용해라!!

자체 시퀀스 생성

방금 설명한 기능은 __getitem__ 이라는 매직 메서드 덕분에 동작한다.
이것은 myobject[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다.

특히 시퀀스는 __getitem____len__을 모두 구현하는 개체이므로 반복이 가능하다.
리스트, 튜플과 문자열은 표준 라이브러리에 있는 시퀀스 객체의 예이다!

이 섹션에선 시퀀스나 이터러블 객체를 만들지 않고 키로 객체의 특정 요소를 가져오는 방법에 대해 다룬다.
이터러블 객체는 Python Clean Code(7)에서 진행할 것이다.

업무 도메인에서 사용하려는 사용자정의 클래스에 __getitem__을 구현하려는 경우 파이썬스러운 접근 방식을 따르기 위해 몇 가지를 고려해야 한다.

클래스가 표준 라이브러리 객체를 감싸는 래퍼인 경우 기본 객체에 가능한 많은 동작은 위임할 수 있다.
즉, 클래스가 리스트의 래퍼인 경우 리스트의 동일한 메서드를 호출하여 호환성을 유지할 수 있다.
다음 클래스는 객체가 어떻게 리스트를 래핑하는지 보여준다.
필요한 메서드가 있는 경우 그저 list 객체에 있는 동일한 메서드에 위임하면 된다.

class Items:
	def __init__(self, *values):
    	self._values = list(values)
    
    def __len__(self)
    	return len(self._values)
        
    def __getitem__(self, item):
    	return self._values.__getitem__(item)

이 예제는 캡슐화 방식을 사용했다.
다른 방법으로 상속을 사용할 수도 있다.
이 경우는 collections.UserList 부모 클래스를 상속해야 한다.
이때의 고려사항과 주의사항은 이 장의 마지막 부분에서 언급한다.

그러나 만약 래퍼도 아니고 내장 객체를 사용하지도 않은 경우는
자신만의 시퀀스를 구현할 수 있다.
이때는 다음 사항에 유의한다.

  1. 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다.
  2. slice에 의해 제공된 범위는 파이썬이 하는 것처럼 마지막 요소는 제외해야 한다.

첫 규칙은 미묘한 오류에 대한 것이다.
리스트의 일부는 리스트이다.
튜블의 특정 range는 튜플이다.
substring의 결과는 문자열이다.
각각의 경우에 결과가 원본 객체와 동일한 타입이라는 것을 알 수 있다.
날짜의 간격을 나타내는 시퀀스를 직접 만들었다고 가정해보자.
그런데 특정 간격의 range를 요청했을 때 리스트나 튜플을 반환하는 실수를 할 수 있다.
원래는 새로운 간격을 설정하여 동일한 클래스의 인스턴스를 반환해야 한다.
이렇게 잘 처리하는 가장 좋은 예는 range 함수를 가진 표준 라이브러리에 있다.
파이썬3에서는 interval을 지정하여 range를 호출하면 선택한 범위의 값을 생성하는 방법을 알고 있는 이터러블 객체를 반환한다.
range의 간격을 지정하면 당연하게도 리스트가 아닌 새로운 range를 얻게 된다!
range(1,100)[25:50] = range(26,51)

두번째 규칙은 일관성에 관한 것이다.
코드 작성 시 파이썬과 일관성을 유지한다면 코드를 사용하는 사람은 보다 사용하기 쉬울 것이다.
파이썬 개발자들은 이미 slice와 range 함수 등에 익숙하다.
사용자정의 클래스에서 예외를 만들면 혼란이 생길 수 있다.
즉, 기억하기 어려워서 버그가 생길 수 있다.

컨텍스트 관리자

컨텍스트 관리자는 파이썬이 제공하는 유용한 기능이다.
유용한 이유는 패턴에 잘 대응되기 때문이다.
이 패턴은 사실상 모든 코드에 적용될 수 있으며, 사전조건과 사후조건을 가지고 있다.
즉, 주요 동작의 전후에 작업을 실행하려고 할 때 유용하다.

일반적으로 리소스 관리와 관련하여 컨텍스트 관리자를 자주 볼 수 있다.
예를 들어 일단 파일을 열면 파일 디스크럽터 누수를 막기 위해 작업이 끝나면 적절히 닫히길 기대한다.
또는 서비스나 소켓에 대한 연결을 열었을 때도 적절하게 닫거나 임시 파일을 제거하는 등의 작업을 해야한다.

이 모든 경우에 일반적으로 할당된 모든 리소스를 해제해야 한다.
하지만 예외가 발생하거나 오류를 처리해야 하는 경우는 어떻게 될까?
가능한 모든 조합과 실행 경로를 처리하여 디버깅하는 것이 어렵다는 점을 감안할 때 이 문제를 해결하는 가장 일반적인 방법은 finally 블록에 정리 코드를 넣는 것이다.
예를 들어 다음과 같은 간단한 예제를 보자.

fd = open(filename)
try:
	process_file(fd)
finally:
	fd.close()

그렇지만 똑같은 기능을 매우 우아하고 파이썬스러운 방법으로 구현할 수도 있다.

with open(filename) as fd:
	process_file(fd)

with문은 컨텍스트 관리자로 진입하게 한다.
이 경우 open 함수는 컨텍스트 관리자 프로토콜을 구현한다.
즉, 예외가 발생한 경우에도 블록이 완료되면 파일이 자동으로 닫힌다.

컨텍스트 관리자는 __enter____exit__ 두 개의 매직 메서드로 구성된다.
첫 번쨰 줄에서 with 문은 __enter__ 메서드를 호출하고 이 메서드가 무엇을 반환하든 as 이후에 지정된 변수에 할당한다.
사실 __enter__ 메서드가 특정한 값을 반환할 필요는 없다.
설사 값을 반환한다 하더라도 필요하지 않으면 변수에 할당하지 않아도 된다.

이 라인이 실행되면 다른 파이썬 코드가 실행될 수 있는 새로운 컨텍스트로 진입한다.
해달 블록에 대한 마지막 문장이 끝나면 컨텍스트가 종료되며 이는 파이썬이 처음 호출한 원래 컨텍스트 관리자 객체의 __exit__ 메서드를 호출함을 의미한다.
(이런 내용을 모르고 단이 with문으로 파일을 열면 자동으로 닫힌다! 라고 배우고 쓰고 있었는데 지식이 깊어지네요)

컨텍스트 관리자 블록 내에 예외 또는 오류가 있는 경우에도 여전히 __exit__ 메서드가 호출되므로 정리 조건을 안전하게 실행하는데 편하다.
실제로 __exit__ 메서드는 블록에서 예외가 발생한 경우 해당 예외를 파라미터로 받기 때문에 임의의 방법으로 처리할 수도 있다.

컨텍스트 관리자가 파일이나 커넥션 등의 리소스 관리에서 매우 자주 사용되지만 오직 이런데만 사용하는 것은 아니고, 블록의 전후에 필요로 하는 특정 논리를 제공하기 위해 자체 컨텍스트 관리자를 구현할 수도 있다.

컨텍스트 관리자는 관심사를 분리하고 독립적으로 유지되어야하는 코드를 분리하는 좋은 방법이다.
왜냐하면 이들을 섞으면 로직을 관리하기가 더 어려워지기 때문이다.

예를 들어 스크립트를 사용해 DB를 백업하려는 경우를 보자.
주의 사항은 백업은 오프라인 상태에서 해야 한다는 것이다.
즉 데이터베이스가 실행되고 있지 않은 동안에만 백업을 할 수 있으며, 백업을 위해 서비스를 중지해야 한다.
백업이 끝나면 백업 프로세스가 성공됐는지에 관계없이 DB 프로세스를 다시 시작해야 한다.

첫 번째 방법은 서비스를 중지하고 백업을 하고 예외 및 특이사항을 처리하고 서비스를 다시 시작하는 거대한 단일 함수를 만드는 것이다.
진짜 이렇게 구현하는 경우가 있기 때문에(지금의 나라면 당연히 이렇게 하고 있을텐데...!?)
컨텍스트 관리자를 이용해 문제를 해결하기 전에 잘못된 코드를 살펴보자.

run=print

def stop_database():
    run("systemctl stop postgresql.service")


def start_database():
    run("systemctl start postgresql.service")


class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()


def db_backup():
    run("pg_dump database")
    
def main():
	with DBHandler():
    	db_backup()

이 예제에서도 DBHandler를 사용한 블록 내부에서 컨텍스트 관리자의 결과를 사용하지 않았다.
적어도 이 경우에 __enter__의 반환 값은 쓸모가 없다.
컨텍스트 관리자를 디자인할 때 블록이 시작된 후에 무엇이 필요한지 고려해야 한다.
일반적으로 필수는 아니지만 __enter__에서 무언가를 반환하는 것이 좋은 습관이다.

main() 함수에서는 유지보수 작업과 상관없이 백업을 실행한다. 또한 백업에 오류가 있어도 여전히 __exit__를 호출한다.

__exit__ 메서드의 서명을 주목할 필요가 있다. 블록에서 발생한 예외를 파라미터로 받는다. 블록에 예외가 없으면 모두 None이다.

__exit__의 반환 값을 잘 생각해보자. 특별한 작업을 할 필요가 없다면 아무것도 반환하지 않아도 된다. 만약 __exit__가 True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전달하지 않고 멈춘다는 것을 뜻한다.
일반적으로 발생된 예외를 삼키고 지나가는 것은 좋지 않은 습관이다.

실수로 __exit__에서 True를 반환하지 않도록 유의한다. 만약 True를 반환한다는 이것이 정말 원하는 결과인지, 충분한 이유가 있는지 확인해야한다.

내일 이어서 컨텍스트 관리자를 구현해봅시다!!
(22.02.09 19:53)

====

profile
MLOps, MLE 직무로 일하고 있습니다😍

0개의 댓글