Python_Day.6

조현기·5일 전

Python_Day.6

패키지와 import, 예외 처리(try/except/else/finally, raise, 사용자 정의 예외), 클로저, 데코레이터(함수형/클래스형).

Day.5에서 파일 하나를 모듈로 만들어 import 하는 것까지 했다. 오늘은 그걸 폴더 단위(패키지)로 확장하는 것에서 시작해서, 프로그램이 죽지 않게 하는 예외 처리, 그리고 함수를 값처럼 다루는 클로저와 데코레이터까지 정리했다.


1. 패키지

파이썬 프로그램은 패키지로 나뉜 개별 모듈들로 구성된다. 패키지는 모듈(.py)을 담은 폴더이고, 폴더 안에 폴더(하위 패키지)를 둘 수 있다.

실습에서는 이런 구조를 만들었다. (폴더 구조 스크린샷은 Notion에서 읽을 수 없어서, 코드에 맞춰 구조를 다시 적었다.)

project/
├── main.py
└── pro_test/                 # 패키지
    ├── __init__.py
    ├── test2/                # 하위 패키지
    │   ├── __init__.py
    │   └── module1.py        # mod1_test(), mod1_test2()
    └── test3/                # 하위 패키지
        ├── __init__.py
        └── module2.py        # mod2_test(), mod2_test2()

각 폴더의 __init__.py는 "이 폴더는 패키지다"라는 표시다. (파이썬 3.3부터는 없어도 동작하지만, 패키지임을 분명히 하고 초기화 코드를 넣을 수 있어서 두는 편이 일반적이다.)

import 하는 세 가지 방법

1) import 패키지.하위패키지.모듈: 호출할 때도 전체 경로를 다 써야 한다.

import pro_test.test2.module1
import pro_test.test3.module2

pro_test.test2.module1.mod1_test()
pro_test.test3.module2.mod2_test()

2) from 패키지.하위패키지 import 모듈: 모듈 이름만 쓰면 된다. as로 별명도 붙일 수 있다.

from pro_test.test2 import module1
module1.mod1_test()

from pro_test.test3 import module2 as m2
m2.mod2_test()

3) from 패키지.하위패키지 import *: 패키지 안의 것을 한꺼번에 가져온다.

from pro_test.test2 import *
from pro_test.test3 import *

module1.mod1_test()
module2.mod2_test()

3번은 주의가 필요하다. 필기 코드에서는 위에서 이미 module1을 import 해 둔 상태라 동작했다. import *만 단독으로 쓰면 NameError: name 'module1' is not defined가 난다. 패키지에서 *로 가져올 모듈은 __init__.py에 __all__ = ['module1']처럼 목록을 적어 줘야 한다. 이름 충돌 위험도 있어서 1번이나 2번을 권장한다.

상대 경로 import

필기 맨 위에 ..(부모 디렉토리), .(현재 디렉토리)라고 적어 둔 것이 상대 경로 import 표기다. 패키지 안의 모듈끼리 서로 가져올 때 쓴다.

# pro_test/test3/module2.py
from ..test2 import module1     # .. : 부모(pro_test) 아래의 test2
from . import helper            # .  : 현재 패키지(test3)

def mod2_test2():
    module1.mod1_test()
    helper.hello()
  • .은 현재 패키지, ..은 한 단계 위 패키지를 뜻한다.
  • 상대 경로 import는 패키지의 일부로 import 될 때만 동작한다. 그 파일을 python module2.py처럼 직접 실행하면 에러가 난다. 직접 실행하는 파일은 앞 장에서 본 __name__ == "__main__"이 되고, 패키지 소속이 아니기 때문이다.

2. 예외 처리

2-1. 자주 만나는 예외

에러가 나면 파이썬은 종류에 맞는 예외(Exception)를 던진다. 각각 어떤 코드에서 나는지 실제로 확인해 보았다.

예외코드메시지
SyntaxErrorprint('hi'))unmatched ')'
NameErrorprint(z) (정의 안 한 변수)name 'z' is not defined
IndexErrora=[1,2,3]; a[5]list index out of range
ZeroDivisionError4/0division by zero
KeyError{'a':1}['c']'c'
ValueError[1,2,3].remove(5), int('abc')list.remove(x): x not in list
FileNotFoundErroropen('b.txt','r')No such file or directory: 'b.txt'
TypeError[1,2] + 'python'can only concatenate list (not "str") to list

TypeError는 x + list(y)처럼 자료형을 맞춰 주면 해결된다. SyntaxError는 실행하기도 전에 코드를 읽는 단계에서 나기 때문에, 이 오류가 있는 줄 위쪽의 print도 실행되지 않는다.

2-2. try / except / else / finally

예외가 나더라도 프로그램이 멈추지 않게 처리하는 문법이다.

li = ['db', 'python', 'react']

try:                                # 예외가 날 수도 있는 코드
    x = 'java'
    y = li.index(x)                 # 예외가 나면 바로 except로 넘어간다
    print('try 블럭 수행')

except Exception as e:              # 예외가 났을 때 실행
    print(e)                        # 'java' is not in list

else:                               # 예외가 없을 때만 실행
    print('else')

finally:                            # 예외 여부와 상관없이 항상 실행
    print('finally')

실행 결과는 이렇다.

'java' is not in list
finally

x를 'db'로 바꾸면 이렇게 된다.

try 블럭 수행
else
finally
블록실행 시점
try예외가 날 수 있는 코드. 예외가 나는 순간 나머지는 건너뛴다
except예외가 발생했을 때
else예외가 발생하지 않았을 때
finally무조건 (파일 닫기 같은 마무리 작업에 쓴다)
  • except ValueError:처럼 특정 예외만 잡을 수도 있고, except Exception as e:로 대부분의 예외를 잡을 수도 있다. Exception은 거의 모든 예외의 부모 클래스다. e에 예외 내용이 들어 있어서 print(e)로 메시지를 볼 수 있다.
  • 필기의 앞부분 코드는 except ValueError:로 잡고 있었다. list.index()로 없는 값을 찾으면 ValueError가 나기 때문이다.
  • 예외끼리도 상속 관계가 있다. KeyError와 IndexError는 LookupError의 자식이고, FileNotFoundError는 OSError의 자식이며, 전부 Exception의 자손이다.

2-3. raise: 일부러 예외 발생시키기

def calc(msg, *args):
    result = 0

    try:
        if msg == 'add':
            for i in args:
                result += i
            print(result)
        elif msg == 'mul':
            result = 1
            for i in args:
                result *= i
            print(result)
        elif msg == 'avg':
            for i in args:
                result += i
            print(result / len(args))
        else:
            raise Exception('잘못된 연산이다')     # 일부러 예외 발생
    except Exception as e:
        print(e)


calc("add", 1, 2, 3)         # 6
calc("mul", 1, 2, 3, 4)      # 24
calc("avg", 10, 20, 30)      # 20.0
calc("al", 10, 20, 30)       # 잘못된 연산이다
calc("avg")                  # division by zero  (인자가 없으면 len(args)가 0)
  • raise는 조건에 맞지 않을 때 직접 예외를 던지는 명령어다. 던져진 예외는 바깥의 except가 받는다.
  • *args는 인자를 튜플로 받는다. (Day.3)
  • 필기 코드에는 except 안에 return None이 있었는데, 파이썬 함수는 return이 없으면 어차피 None을 반환하므로 빼도 같다.
  • 마지막 줄처럼 인자 없이 avg를 호출하면 ZeroDivisionError가 나는데, except Exception이 이것도 같이 잡아 준다.

2-4. 사용자 정의 예외

Exception을 상속해서 내 예외 클래스를 만들 수 있다.

class MyError(Exception):
    def __str__(self):                       # 예외를 출력할 때의 문자열을 정한다
        return '허용되지 않는 별명입니다.'


def say_nick(nick):
    if nick == '바보':
        raise MyError()                      # 금지어면 예외 발생
    print(nick)


try:
    say_nick('천사')      # 천사
    say_nick('바보')      # 여기서 예외 발생
except MyError as e:
    print(e)              # 허용되지 않는 별명입니다.

__str__은 print(객체)를 했을 때 보일 문자열을 정하는 특수 메소드다. 예외 객체 e를 print(e)하면 이 문자열이 나온다.

2-5. 사용자 정의 예외에서 헷갈린 부분

나이를 입력받아 0 이하면 예외를 내는 코드를 두 가지로 만들어 봤다. 첫 번째는 이렇다.

class MyError(Exception):
    def __str__(self):
        return '숫자를 입력해주세요'

try:
    age = int(input('나이를 입력해주세요'))
    if age <= 0:
        raise MyError('나이는 0 이상이어야 해')

except Exception:
    raise MyError()

else:
    print('입력완료')

이 코드는 -5를 입력하든 abc를 입력하든 둘 다 같은 숫자를 입력해주세요 에러로 끝난다. 이유는 세 가지다.

  • raise MyError('나이는 0 이상이어야 해')로 넘긴 메시지는 쓰이지 않는다. __str__을 오버라이딩해서 항상 같은 문자열을 반환하기 때문이다. (메시지는 e.args에는 들어 있지만 출력되지 않는다.)
  • MyError도 Exception의 자식이라서, try 안에서 던진 MyError를 바로 아래 except Exception이 다시 잡고, raise MyError()로 새로 던진다. 결국 -5를 입력해도 "숫자를 입력해주세요"가 나온다.
  • except 안에서 다시 raise했으니 처리되지 않은 예외가 되어 프로그램이 트레이스백과 함께 종료되고, else도 실행되지 않는다.

두 번째 방식은 메시지를 __init__에서 부모에게 넘긴다.

class MyError(Exception):
    def __init__(self):
        super().__init__('0보다 커야해')     # 부모(Exception)에 메시지 전달

try:
    age = int(input('나이를 입력해주세요'))
    if age <= 0:
        raise MyError()

except ValueError as e:
    print('숫자가 아니야')

else:
    print('입력완료')

이쪽은 abc를 입력하면 숫자가 아니야가 잘 나온다. 그런데 -5를 입력하면 except ValueError는 MyError를 잡지 못하므로 MyError: 0보다 커야해와 함께 프로그램이 종료된다. MyError를 처리하는 except가 없기 때문이다. 두 가지를 모두 처리하도록 정리하면 이렇게 된다.

class AgeError(Exception):
    def __init__(self):
        super().__init__('나이는 0보다 커야 한다')

try:
    age = int(input('나이를 입력해주세요'))
    if age <= 0:
        raise AgeError()

except ValueError:
    print('숫자가 아니다')       # abc 입력
except AgeError as e:
    print(e)                      # -5 입력 → 나이는 0보다 커야 한다
else:
    print('입력완료')             # 5 입력
입력출력
abc숫자가 아니다
-5나이는 0보다 커야 한다
5입력완료

예외 종류마다 except를 나눠 쓰면 상황별로 다른 메시지를 줄 수 있다. 또 except Exception을 쓸 때는 그 안에서 내가 던진 예외까지 같이 잡히는지 확인해야 한다.

사용자 정의 예외에서 메시지를 정하는 방법은 두 가지다.

방법코드
__str__ 오버라이딩출력 문자열을 고정한다. 던질 때 넘긴 메시지는 무시된다
__init__에서 super().__init__(메시지)부모에게 메시지를 넘긴다

3. 클로저

클로저는 함수 안에서 함수를 정의하고, 그 안쪽 함수를 결과로 반환하는 구조다. 콜백 함수나 데코레이터에 쓰인다.

def add(n1):
    def wrapper(n):          # 내부 함수 (inner function)
        return n1 + n        # 바깥 함수의 n1을 사용
    return wrapper           # 내부 함수를 반환


a1 = add(10)       # n1=10을 저장한 채로 wrapper가 a1에 들어감
print(a1(10))      # 20  (wrapper(10) → 기억해 둔 10 + 10)

a2 = add(20)       # n1=20을 기억하는 또 다른 wrapper
print(a2(10))      # 30

흐름을 풀어 보면 이렇다.

  1. add(10)이 실행되면 n1 = 10이 만들어진다.
  2. 안쪽 wrapper 함수가 만들어져서 반환되고, a1이 그 함수를 가리킨다.
  3. add는 이미 끝났지만, wrapper는 n1 = 10을 기억하고 있다. 이것이 클로저의 특징이다.
  4. a1(10)은 wrapper(10)이므로 기억하던 10과 더해 20이 된다.

add(10)과 add(20)이 서로 다른 n1을 각자 기억하는 함수를 만든다는 점이 핵심이다. 기억해 둔 값은 a1.__closure__[0].cell_contents로 확인할 수 있고, 값은 10이다.

기억한 값을 바꾸고 싶을 때: nonlocal

내부 함수에서 바깥 함수의 변수를 읽는 것은 되지만, 값을 바꾸려면 nonlocal을 선언해야 한다. (Day.4의 global과 같은 역할이고 범위가 바깥 함수다.)

def counter():
    c = 0
    def inc():
        nonlocal c          # 바깥 함수의 c를 쓰겠다
        c += 1
        return c
    return inc

f = counter()
print(f(), f(), f())    # 1 2 3

호출할 때마다 c가 유지되며 늘어난다. 전역 변수를 쓰지 않고도 상태를 함수에 숨겨 두는 방법이다.


4. 데코레이터

데코레이터는 "장식하는 도구"로, 기존 함수의 코드를 건드리지 않고 기능을 확장할 때 쓴다. 함수 위에 @이름을 붙이는 문법이고, 안에서 클로저를 사용한다.

4-1. 클로저로 직접 만들어 보기

def trace(func):
    def wrapper():
        print('시작')
        func()               # func는 함수 자체를 받았으므로 뒤에 ()를 붙여 호출
        print('끝')
    return wrapper           # 함수를 반환


def hi():
    print('hi')

def hello():
    print('hello')


t1 = trace(hi)
t1()
# 시작
# hi
# 끝

t2 = trace(hello)
t2()
# 시작
# hello
# 끝

trace는 함수를 인자로 받고, 함수를 반환한다. 반환된 wrapper가 원래 함수 앞뒤에 시작/끝 출력을 덧붙인다.

4-2. @ 문법

위의 t1 = trace(hi) 과정을 @로 줄일 수 있다.

@trace                # hi = trace(hi) 와 같다
def hi():
    print('hi')

@trace                # hello = trace(hello) 와 같다
def hello():
    print('hello')


hi()
# 시작
# hi
# 끝

hello()
# 시작
# hello
# 끝

필기에는 "hi()를 호출하면 hi = trace(hi)를 자동으로 실행해준다"고 적어 뒀는데, 정확히는 함수를 정의하는 시점에 hi = trace(hi)가 한 번 실행된다. 그 뒤로 hi라는 이름은 원래 함수가 아니라 wrapper를 가리킨다. 그래서 hi()를 호출하면 wrapper()가 실행되는 것이다.

4-3. 인자와 반환값이 있는 함수에 쓰기

인자의 개수나 형태가 제각각인 함수에도 쓰려면 *args, **kwargs로 받는다.

def trace(func):
    def wrapper(*args, **kwargs):
        # *args   : 위치 인자를 튜플로 받는다
        # **kwargs: 키=값 인자를 딕셔너리로 받는다
        result = func(*args, **kwargs)
        print(args, kwargs, result)
        return result                 # 원래 함수의 반환값을 그대로 돌려준다
    return wrapper


@trace
def big(*args):
    return max(args)

@trace
def mina(**kwargs):
    return min(kwargs.values())


print(big(10, 20))
# (10, 20) {} 20
# 20

print(mina(x=20, y=30, z=40))
# () {'x': 20, 'y': 30, 'z': 40} 20
# 20

흐름은 이렇다.

  1. @trace가 big = trace(big)를 실행해서 big이 wrapper가 된다.
  2. big(10, 20)은 wrapper(10, 20)이 되어 args = (10, 20)로 들어온다.
  3. func(*args, **kwargs)로 원래 big을 호출해 max(10, 20)인 20을 얻는다.
  4. 인자와 결과를 출력하고, result를 반환해서 호출한 곳에서도 20을 받는다.

wrapper에서 return result를 빼면 원래 함수의 반환값이 사라져서 print(big(10, 20))이 None이 된다.

4-4. 데코레이터의 부작용과 functools.wraps

데코레이터를 붙인 함수는 이름이 바뀐다.

@trace
def hi():
    print('hi')

print(hi.__name__)    # wrapper   (hi가 아니다)

hi가 실제로는 wrapper이기 때문이다. 디버깅하거나 함수 이름을 쓸 때 헷갈릴 수 있어서, 실무에서는 functools.wraps를 붙여 원래 함수의 이름과 설명을 유지한다.

import functools

def trace(func):
    @functools.wraps(func)          # func의 이름 등 정보를 wrapper에 복사
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(args, kwargs, result)
        return result
    return wrapper

@trace
def big(*args):
    return max(args)

print(big.__name__)    # big

4-5. 클래스형 데코레이터와 __call__

데코레이터는 함수만이 아니라 클래스로도 만들 수 있다.

class Tr:
    def __init__(self, func):
        self.func = func

    def __call__(self):
        print(self.func.__name__, '시작')
        self.func()
        print(self.func.__name__, '끝')


@Tr                    # hi = Tr(hi)
def hi():
    print('hi')


hi()
# hi 시작
# hi
# hi 끝

동작 순서를 따라가 보면 이렇다.

  1. @Tr이 함수를 정의하는 시점에 hi = Tr(hi)를 실행한다.
  2. Tr.__init__(self, func)가 호출되고 원래 hi 함수가 self.func에 저장된다.
  3. 이제 hi라는 이름은 함수가 아니라 Tr의 인스턴스다. type(hi)를 찍으면 <class '__main__.Tr'>가 나온다.
  4. hi()처럼 인스턴스에 괄호를 붙여 호출하면, 파이썬이 __call__을 자동으로 호출한다.
  5. __call__ 안에서 self.func.__name__으로 이름(hi)을 출력하고, self.func()로 원래 hi를 실행한 뒤 다시 이름을 출력한다.

핵심은 __call__을 정의한 클래스의 인스턴스는 함수처럼 호출할 수 있다는 점이다.

class Adder:
    def __init__(self, n):
        self.n = n
    def __call__(self, x):
        return self.n + x

ad = Adder(5)
print(ad(3))           # 8   (인스턴스를 함수처럼 호출)
print(callable(ad))    # True

클래스형 데코레이터가 클로저와 다른 점은, 기억해 둘 값을 인스턴스 변수(self.func)에 저장한다는 것이다. 클로저 방식에서는 n1처럼 바깥 함수의 변수가 그 역할이었다.

구분함수형 (클로저)클래스형
원래 함수 보관바깥 함수의 변수(func)인스턴스 변수(self.func)
실행되는 것반환된 wrapper__call__
@ 적용 후 이름이 가리키는 것wrapper 함수클래스의 인스턴스

마무리

  • 패키지: 모듈을 폴더로 묶은 것이다. import 패키지.모듈 또는 from 패키지 import 모듈로 가져오고, 패키지 안에서는 ./.. 상대 경로를 쓴다. import *는 __all__ 없이는 기대대로 안 될 수 있다.
  • 예외 처리: try(시도) → except(예외 시) → else(예외 없을 때) → finally(항상). raise로 직접 예외를 던지고, Exception을 상속해 내 예외를 만든다. except Exception은 내가 던진 예외까지 잡는다는 점에 주의한다.
  • 사용자 정의 예외 메시지는 __str__ 오버라이딩 또는 super().__init__(메시지)로 정한다.
  • 클로저: 내부 함수가 바깥 함수의 변수를 기억한 채 반환되는 구조다. 값을 바꾸려면 nonlocal이 필요하다.
  • 데코레이터: @trace는 정의 시점에 func = trace(func)를 실행한다. 인자 형태를 가리지 않으려면 *args, **kwargs를 쓰고, 원래 이름을 유지하려면 functools.wraps를 쓴다. 클래스형은 __call__로 동작한다.

Tags: Python 패키지 모듈 예외처리 try-except 사용자정의예외 클로저 데코레이터 __call__ 개발자 학습기록

profile
난 뭘까

0개의 댓글