본문 바로가기

더 나은 엔지니어가 되기 위해/파이썬을 파이썬스럽게

파이썬 클린 코드 1 - 파이썬스러운 코딩을 파이썬 문법 컨셉

들어가며

이 글은 책 파이썬 클린 코드를 읽고 정리한 글이다.

이번에는 책을 직접 구입했다.

클린 코드는 프로그래밍 언어 구분 없이 일반적으로 존재하는 개념인데, 파이썬에서는 좀 특이한 관습이 있다. '파이썬스럽게(Pythonic)' 라는 것인데, 이 때문에 같은 개념과 논리더라도 다른 프로그래밍 언어와는 클린 코드에 대한 구현이 조금 다르다.

그래서 이 책에서는 두 가지를 말한다. 파이썬스러운 코드와 이를 기반으로 한 클린 코드를 짜는 것.
클린 코드에 대한 여러 서적들이 있지만 이 책을 먼저 잡은 이유도 이 때문이다. 클린 코드의 개념을 잘 알면서, 이를 파이썬스럽게 짤 수 있는 능력, 하다 못해 그럴 수 있는 '감'이라도 잡고 싶었다.

정리하는 글은 책의 목차를 그대로 따라가지 않는다. 위에 설명한 두 가지에 초점을 두어 내 나름대로 정리했다.
예제도 책이랑 일부 다르다. 직관적인 예제로 직접 재구성했다.
한편, 적지 않은 글들이 책에는 없는 내 생각의 글들이다. 따라서 틀릴 수 있다.
이 글을 읽는 사람은 어느 정도 파이썬을 써본 사람이라고 생각하고 글을 썼다.

그리고 파이썬스러운 코드가 무엇이고, 그 안에 내재된 파이썬의 문법 컨셉을 알아본다.
그다음으로 클린 코드에 대해 알아가며, 이를 어떻게 파이썬스럽게 구현해나가는지 알아본다.


파이썬스러운 코드

파이썬스럽다는 것

각 프로그래밍 언어마다 특정 작업을 수행하기 위해 해당 작업을 처리하는 고유한 기능 혹은 관용구를 가지고 있다. 이러한 기능과 관용구를 사용하는 이유는 일반적으로 더 나은 성능을 내기 때문이다. 코드도 더 줄일 수 있고, 이해하기도 더 쉽다.
이렇게 파이썬의 고유한 기능과 관용구를 따르는 코드를 파이썬스럽다(Pythonic) 라고 한다.
예를 들어 List comprehension 은 대표적인 파이써닉한 코드이다.

arr = [1, 2, 3, 4]

# 파이썬스럽지 않은 코드
result = []
for i in range(len(arr)):
    if arr[i] % 2 == 1:
        result.append(arr[i])

# 파이썬스러운 코드 (List comprehension 을 사용)
result = [num for num in arr if num % 2]

파이썬스러운 코드로 훨씬 빠르고, 간결하고, 심플하며, 읽기 좋은 코드를 구현할 수 있다.
이게 아직 간결하고 심플하게 느껴지지 않는다면, 아직 파이썬에 익숙해져있지 않다는 의미다.
파이썬을 계속해서 쓰다 보면 이런 식의 파이써닉한 코드에 적응하고 추구하게 된다.

파이썬스럽게 코딩하는 예제는 구글링 해보면 많이 있다.
대표적으로, 아래 링크에서 확인해볼 수 있다.

파이썬스러운 코드 작성을 위한 참고 링크들

이 글에서는 파이썬의 문법 컨셉을 다루며, 사용자가 파이썬스럽게 사용할 수 있도록 클래스를 설계하는 방법을 배운다.
이 과정에서, 그저 사용하기만 하던 파이썬의 다양한 기능들이 어떻게 수행되는지 등을 알게 될 것이다.

 

파이썬 문법 컨셉

1. 첨자형 객체

arr = [1, 2, 3, 4]

# 파이썬스럽지 않은 코드
last = arr[len(arr)-1]  # 4

# 파이썬스러운 코드
last = arr[-1]  # 4
odds = arr[::2]  # [1, 3]
evens = arr[1::2]  # [2, 4]
sliced = arr[2:4]  # [3, 4]
reverse = arr[::-1]  # [4, 3, 2, 1]

위와 같이 파이썬의 인덱스와 슬라이스 기능을 잘 사용하면 보다 파이써닉한 코드가 된다.
인덱스와 슬라이스 기능은 다음의 매직 메서드 덕분에 동작한다.

  • __getitem__

이 메서드를 구현한 객체를 첨자형 객체라고 한다. 즉, [] 로 데이터에 접근할 수 있도록 설계한 객체다.
예를 들면 리스트(List) 나 사전(Dict) 이 첨자형 객체다.
만약 내가 설계하는 클래스에 위와 같은 인덱스와 슬라이스 기능을 넣고 싶다면 이 매직 메서드를 정의해주면 된다.
예를 들면 다음과 같다.

class Bag:
    def __init__(self, item_size, user):
        self._items = [None] * item_size
        self._item_cnt = 0

    def add_item(self, item):
        self._items[self._item_cnt] = item
        self._item_cnt += 1

    def __getitem__(self, index):
        return self._items[index]
>>> bag = Bag(10, "heumsi")
>>> bag.add_item("음료수")
>>> bag.add_item("김밥")
>>> bag[0] 
음료수
>>> bag[1]
김밥
>>> bag[2]
None

 

2. 컨텍스트 관리자

# 파이썬스럽지 않은 코드
db_connector = DBConnector(id='id', password='password')
db_connector.connect()
... 
db_connector.close()

# 파이썬스러운 코드
with DBConnector(id='id', password='password') as db_connector:
    ...

파이썬에서 with A as a: ... 문법은 어떤 코드의 시작과 끝에 어떤 동작을 일정하게 취해주어야 할 때 사용된다.
예를 들어, 파일을 열고 닫거나 데이터베이스의 연결을 열고 닫는 등의 동작이 그렇다.
with A as a: ... 구문을 사용하려면 아래 두 메서드를 구현해야 한다.

  • __enter__(self)
  • __exit__(self, ex_type, ex_value, ex_traceback)
    • ex_type, ex_value, ex_traceback 는 예외가 발생한 경우에 받아오는 값이다.
    • 반환 값이 None 이 아니면 예외를 발생시킨다.

이 두 메서드를 구현한 객체를 컨텍스트 관리자라고 한다.
예를 들어 위의 DBConnector 는 내부적으로 다음과 같이 구현되어 있다.

class DBConnector:
    def __init__(self, id, password):
        print("1. DBConnector.__init__")

    def connect(self):
        print("3. DBConnector.connect")

    def close(self):
        print("6. DBConnector.close")

    def __enter__(self):
        print("2. DBConnector.__enter__")
        self.connect()

    def __exit__(self, ex_type, ex_value, ex_traceback):
        print("5. DBConnector.__exit__", end=" / ")
        print(f"ex_type : {ex_type}, ex_value: {ex_value}, exc_tb: {ex_traceback}")
        self.close()
>>> with DBConnector(id="id", password="password") as db_connector:
>>>     print("4. DB 연결 이후 할 일")
1. DBConnector.__init__
2. DBConnector.__enter__
3. DBConnector.connect
4. DB 연결 이후 할 일
5. DBConnector.__exit__ / ex_type : None, ex_value: None, exc_tb: None
6. DBConnector.close

한편, 클래스가 아닌 함수에 적용할 경우가 있다.
이럴 때는 contextlib.ContextDecorator 를 상속받은 클래스를 데코레이터로 사용하여 적용할 수 있다.
예를 들면, 함수의 시간을 측정하는 데코레이터를 다음과 같이 만들 수 있다.

import time
import contextlib

class Timer(contextlib.ContextDecorator):
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        self.start = time.time()

    def __exit__(self, ex_type, ex_value, ex_traceback):
        self.end = time.time()
        operation_time = self.end - self.start
        print(f"{self.name}의 수행시간 : {operation_time}")

@Timer("a()")
def a():
    return [i for i in range(1000000)]

@Timer("b()")
def b():
    result = []
    for i in range(1000000):
        result.append(i)
>>> a()
a()의 수행시간 : 0.07170891761779785
>>> b()
b()의 수행시간 : 0.12720584869384766

여담이지만, 리스트 컴프리헨션으로 짠 코드가 그렇지 않은 코드보다 수행 시간이 훨씬 빠르다는 걸 알 수 있다.
성능적으로도 파이썬스러운 코드를 써야 하는 이유다.

 

3. 밑줄과 프로퍼티

1) 밑줄(_)로 private 표현
# 파이썬스럽지 않은 코드
class A:
    def __init__(self, private_var):
        self.private_var = private_var

    def private_method(self):
        pass

# 파이썬스러운 코드
class A:
    def __init__(self, private_var):
        self._private_var = private_var

    def _private_method(self):
        pass

파이썬에는 자바에서 등장하는 public 이나 private 등의 접근 제어자가 문법적으로 없다.
다만 밑줄( _) 을 써서, private 을 표현한다.
표현한다고 한 것은, 말 그대로 파이썬 개발자들 간의 관습적인 표현이지, 문법적인 것은 아니다.
따라서 _ 로 시작하는 속성에 접근할 수 있다. 다만 접근하지 말라고 명시적으로 표현한 것이다.

객체의 인터페이스로 공개하는 용도가 아니라면, 모든 멤버에 접두사로 _ 를 달아주는 것이 좋다.

[_ 의 또 다른 의미]

파이썬에서 종종 다음과 같은 코드를 볼 때가 있을 것이다.

def get_two_values():
  return 1, 2

a, _ = get_two_values()

이때 _ 는 사용되지 않는 변수의 이름으로 사용된다.
즉, 위 함수가 반환하는 값 중 두 번째 값은 현재 로직에 필요 없음을 나타내는 것이다.

[__ 은 뭘까?]

파이썬에서 변수나 클래스 메서드 이름 앞에 밑줄 두 개 ( __) 를 붙이는 코드를 볼 때가 있다.
이는 네임 맹글링 (Name Mangling) 으로 불리며, 조금 특별한 효과를 불러일으킨다.
다음 예를 보자.

class Foo:
 def __init__(self):
     self.__bar = 42
>>> foo = Foo()
>>> print(foo.__bar)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Foo' object has no attribute '__bar'
>>> print(foo._Foo__bar)
42

보다시피 __bar 는 _Foo__bar 가 되었다.
이처럼 네임 맹글링(__ 을 붙이는 것)은 변수의 이름 앞에 클래스의 이름을 붙여준다.
이는 여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드 하기 위해 만들어졌다.

이러한 네임 맹글링을 통해 접근 제어자 private 의 효과를 본다고 생각하여, _ 대신 __ 을 사용하여 변수를 사용하는 코드들이 일부 있다. 하지만 이런 목적으로 네임 맹글링을 사용하는 것은 일부 부작용 효과를 불러일으키기 때문에 권장되지 않는다.

private 를 표현하고 싶다면 _ 만 사용하자. 애초에 파이썬은 접근 제어자를 강제하는 철학을 가지고 있지 않다.

참고: Python Name Mangling and How to Use Underscores

 
2) g(s)etter 대신 프로퍼티 사용
# 파이썬스럽지 않은 코드
class User:
    EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+[^@]+")

    def __init__(self, username):
        self.username = username
        self._email = None

    def get_email(self):
        return self._email

    def set_email(self, new_email):
        if not self._is_valid_email(new_email):
            print("유효한 이메일이 아닙니다.")
            return
        self._email = new_email

    def _is_valid_email(self, email):
        return re.match(self.EMAIL_FORMAT, email) is not None
>>> user = User("heumsi")
>>> user.set_email("heumsi")
유효한 이메일이 아닙니다.
>>> user.get_email()
None
>>> user.set_email("heumsi@naver.com")
>>> user.get_email()
heumsi@naver.com

파이썬은 자바에서 흔히 보이는 gettersetter 의 철학을 가지지 않는다.
애초에 private 과 같은 접근제어자가 없는 맥락과 마찬가지.
다만, @property@.setter 라는 어노테이션을 통해 gettersetter 의 역할을 대신한다.

# 파이썬스러운 코드
import re

class User:
    EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+[^@]+")

    def __init__(self, username):
        self.username = username
        self._email = None

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, new_email):
        if not self._is_valid_email(new_email):
            print("유효한 이메일이 아닙니다.")
            return
        self._email = new_email

    def _is_valid_email(self, email):
        return re.match(self.EMAIL_FORMAT, email) is not None
>>> user = User("heumsi")
>>> user.email = "heumsi"
유효한 이메일이 아닙니다.
>>> user.email
None
>>> user.email = "heumsi@naver.com"
>>> user.email
heumsi@naver.com

@property.setter 는 어떤 특별한 처리를 해야하는 경우에 활용한다.
단순히 값을 받아오거나 세팅하는 경우에는, 바로 멤버 변수로 바로 접근하여 처리하는 게 일반적이다.

 

4. 반복과 관련된 객체

파이썬에서는 기본적으로 반복 가능한 객체들이 있다. 리스트나 튜플, 세트 등이 그런 것들이다. 이러한 객체들은 for 와 같은 루프 문을 통해 값을 반복적으로 가져올 수 있다.

파이썬에서 for i in A 반복문에서 A 가 될 수 있는 객체는 크게 2가지 있다.

  • 이터러블 객체
    • __iter__ 메서드를 구현한 객체다.
    • __iter__ 메서드는 이터레이터 객체를 반환한다.
      • 이터레이터 객체__next__ 를 구현한 객체다.
        • __next__ 는 반복 요소를 다음으로 이동시키고 기존의 값을 반환한다.
      • 이터레이터의 __next__ 함수가, yield 문을 사용하는 경우가 있는데, 이를 제너레이터라고 한다.
        • return 을 사용하지 않고 yield 를 사용한다.
        • yield 는 어떤 값을 내보낸 뒤, 해당 함수가 다시 호출될 때까지 yield 그 자리에서 대기한다.
  • 시퀀스 객체
    • __len__, __getitem__ 메서드를 구현한 객체다.
    • 첫번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다.

 

파이썬 인터프리터가 반복문을 만나면 어떤 과정을 밟는지 살펴보자.
먼저 for i in A 와 같은 코드를 만나면 A 라는 객체가 이터러블 객체인지, 그리고 시퀀스 객체인지 순서대로 확인한다.

  • 만약 이터러블 객체라면, __iter__ 를 호출하고, 이터레이터 객체를 반환받는다.
    • 그리고 이터레이터의 __next__ 를 매 루프마다 호출한다.
    • StopIteration 을 반환하면 멈춘다.
  • 만약 시퀀스 객체라면, __getitem__ 을 호출한다.
    • IndexError 가 발생할 때까지 인덱스를 증가시키며 반복적으로 호출한다.
  • 둘 다 아니라면 TypeError 를 발생한다.

당장 이해가지 않아도 상관없다. 아래서 직접 이터러블과 시퀀스 객체를 만드는 예제를 보면 쉽게 이해할 수 있을 것이다.

1) 이터러블 객체 만들기
from datetime import timedelta, date

class DateRangeIterable:
    """
    이 클래스는 __iter__ 와 __next__ 를 동시에 구현하고 있으므로,
    이터러블 객체이자, 이터레이터 객체다.
    """
    def __init__(self, start_date, end_date):
        print("__init__")
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        print("__iter__")
        return self

    def __next__(self):
        print("__next__")
        if self._present_day >= self.end_date:
            raise StopIteration
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today
>>> for day in DateRangeIterable(date(2019, 1, 1), date(2019, 1, 5)):
>>>    print(day)
__init__
__iter__
__next__
2019-01-01
__next__
2019-01-02
__next__
2019-01-03
__next__
2019-01-04
__next__

[이터러블, 이터레이터, 제너레이터]

이 세 가지 개념이 매우 헷갈릴 수 있다. 따라서 잠깐 정리하고 가려고 한다.
이터러블은 __iter__ 를 구현하여 반복 구문을 사용할 수 있게 정의한 객체다.for i in A: ... 구문을 쓸 수 있도록 하는 객체다.
(시퀀스 객체로도 가능하지만, 일반적으로 반복을 기대한다면 이터러블 객체가 맞다.)
__iter__ 는 이터레이터를 반환해야 한다.

이터레이터는 __next__ 를 구현하여 한 번에 하나씩의 값을 생산하는 로직을 정의한 객체다.
이터러블에서 쓰이지만, 이터러블 그 자체는 아니다. 예를 들어 다음 코드를 보자.

class SequenceIterator:
 def __init__(self, start=0, step=1):
     self.current = start
     self.step = step

 def __next__(self):
     value = self.current
     self.current += self.step
     return value

위 객체는 이터레이터(__next__ 가 존재)지만 이터러블은 아니다. (__iter__ 가 없기 때문)
다만 위가 일반적인 경우는 아니고, 보통은 객체에 이터러블과 이터레이터 둘 다 구현한다.

제너레이터는 yield 문을 포함하고 있는 함수다.
즉 위의 객체에서 __next__return value 가 아니라 yield value 가 되면, __next__ 함수는 제너레이터 된다.
제너레이터를 호출하면 제너레이터의 인스턴스를 만든다. 다음의 예를 통해 확인할 수 있다.

def test_a():
    for i in range(10):
        yield i

>>> test_a()
>>> <generator object test_a at 0x1096c8550>

이를 제너레이터 인스턴스라고 하는데, 이는 이터러블이자 이터레이터 객체다. (함수를 호출했는데 객체가 반환되었다. 굉장히 이상해 보일 수 있는데, 파이썬에서 제너레이터를 약간 특수하게 소개하는 이유이기도 한 것 같다.)
따라서, 이 인스턴스를 가지고 반복문을 돌릴 수 있다!
제너레이터는 미리 값을 생성하지 않기 때문에(Lazy), 보통 메모리를 절약하기 위해 사용된다.

 
2) 시퀀스 객체 만들기
class DateRangeSequence:
    """
    이 클래스는 __getitem__ 과 __len__ 을 구현하고 있으므로
    시퀀스 객체이다.
    """
    def __init__(self, start_date, end_date):
        print("__init__")
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()  # 시퀀스 객체는 이처럼 미리 메모리와 반복될 값들을 초기화한다.

    def _create_range(self):
        print("_create_range")
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days

    def __getitem__(self, day_no):
        print(f"__getitem__({day_no})")
        return self._range[day_no]

    def __len__(self):
        print("__len__")
        return len(self._range)
>>> for day in DateRangeSequence(date(2019, 1, 1), date(2019, 1, 5)):
>>>     print(day)
__init__
_create_range
__getitem__(0)
2019-01-01
__getitem__(1)
2019-01-02
__getitem__(2)
2019-01-03
__getitem__(3)
2019-01-04
__getitem__(4)

[이터러블 객체와 시퀀스 객체의 트레이드 오프]

이터러블은 매 루프마다 하나의 값만 반환하며 반복하는 반면,
시퀀스는 루프 초기에 반복해야 하는 모든 값을 미리 만들어둔 뒤 반복한다.

즉, 이터러블의 경우 메모리를 한번에 쓰지 않지만, 시퀀스는 메모리를 한 번에 쓰는 단점이 있다.
하지만, 이터러블은 특정 인덱스에 접근하는데 O(n) 만큼 걸리고, 시퀀스는 O(1) 만 걸리므로 속도면에서 빠르다.

이는 일반적인 컴퓨터 공학에서의 '공간(메모리)- 시간' 의 트레이드 오프적 관계다.

 

5. 컨테이너 객체

컨테이너 객체는 in 이라는 파이썬 문법에서 사용되는 객체로 다음 매직 메서드를 구현한 객체다.

  • __contains__

즉 아래 두 문장은 같은 문장이다.

element in container
container.__contains__(element)

어떤 객체를 컨테이너 객체로 잘 설계하면 훨씬 가독성이 좋다.
예를 들어, 다음과 같은 코드가 있다고 해보자.

class Coord:
    def __init__(self, x, y):
        self.x = x
        self.y = y

...
if 0 <= coord.x <= 100 and 0 <= coord.y <= 100:
    ...

위 코드에서 if 구문은 보기에 조금 난잡하다.
다음과 같이 파이썬스럽게 설계하면 훨씬 보기 좋아진다.

class Grid:
    def __init__(self, x_min, x_max, y_min, y_max):
        self.x_min = x_min
        self.x_max = x_max
        self.y_min = y_min
        self.y_max = y_max

class Coord:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __contains__(self, grid):
        if grid.x_min <= self.x <= grid.x_max and grid.y_min <= self.y <= grid.y_max

...
grid = Grid(0, 100, 0, 100)
if coord in grid:
      ...

 

6. 객체의 동적인 속성

파이썬에서 myObject.attribute 와 같이 객체의 속성을 호출하는 경우, 다음의 과정을 거친다.

  • myObject.attribute 호출
  • __getattribute__ 호출
    • attribute 가 객체의 __dict__ 에 없는 경우 __getattr__ 를 호출한다.
class MyClass:
    def __init__(self, attribute):
        self.attribute = attribute

    def __getattribute__(self, item):
        print(f"__getattribute__({item})")
        return super().__getattribute__(item)

    def __getattr__(self, attribute):
        print(f"__getattr({attribute})")
        if attribute.startswith("fallback_"):
            name = attribute.replace("fallback_", "")
            return f"{name} 란 속성은 없습니다."
        raise AttributeError
>>> my_class = MyClass("hi~")
>>> my_class.attribute
__getattribute__(attribute)
hi~
>>>
>>> my_class.fallback_attribute
__getattribute__(fallback_attribute)
__getattr(fallback_attribute)
attribute 란 속성은 없습니다.
>>>
>>> my_class.not_exist_attribute
__getattribute__(not_exist_attribute)
__getattr(not_exist_attribute)
Traceback (most recent call last):
...
AttributeError

 

7. 호출형 객체

다음 매직 메서드를 구현하면, 클래스도 함수같이 호출형으로 사용할 수 있다.

  • __call__

다음 예제를 보면 쉽게 이해할 수 있다.

from collections import defaultdict

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]
>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3

 

요약

문장 매직 메서드 파이썬 컨셉
obj[key]
obj[i:j]
obj[i:j:k]
__getitem__(key) 첨자형 객체
with obj: ... __enter__ / __exit__ 컨텍스트 관리자
for i in obj: ... - __iter__ / __next__
- __len__ / __getitem__
- 이터러블 객체
- 시퀀스
obj.attribute __getattribute__ / __getattr__ 동적 속성 조회
obj() __call__ 호출형 객체

마무리

책 내용의 1/3 정도를 다룬 것 같다.
데코레이터, 디스크립터, 코루틴 등의 내용은 배제하였다.
위 기능들은 API 개발이나 프레임워크 개발 등, 개발자를 위한 개발 코드를 사용할 때 쓰일만한 것들이라 생각한다. 적어도 서비스 도메인 개발 코드를 다루는 내 입장에선 현재 필요하지 않기 때문에... 나중에 쓸 일이 있을 때 정리해보려 한다.

파이썬스러운(Pythonic) 코드는 계속해서 관심 가질 예정이다.
책, 파이썬답게 코딩하기 의 목차를 보니 뭔가 읽어볼 만한 것 같다. 추후 읽고 정리할 날이 왔으면..?!

남은 포스팅은, 클린 코드에 대한 본격적인 내용이다.
클린 코드 이야기가 주지만, 파이썬도 띄워놓을 수가 없다. 파이썬 자체가 '가독성 좋은' 철학을 지향하기 때문에 클린 코드의 지향점을 문법적으로 구현한 것도 있기 때문이다. 또 PEP8 등, 코드 스타일에서 어느 정도 '아름다움' 을 정의해놓는 언어이기 때문에 개발하는 사람 입장에서는 이런 것을 알고 개발할 필요가 있다.
아무튼 다음 글에서 마저 정리해보겠다.

반응형