>[!Notice]
>이 글의 내용은 이전 글인 [[0. Context manager를 이용한 composition 관계 다루기|Context manager를 이용한 composition 관계 다루기]]와 이어집니다.
## 예시 코드 : BookShelf
이전 글에서 `Book`과 `Page`를 만들었으니, 이제 책들을 넣을 `BookShelf`를 만들자. 전형적인 코드는 아래와 같을 것이다.
```python
#...Book, Page 정의 생략
class BookShelf:
_INSTANCE = None
def __init__(self):
self.books = []
def __new__(cls, *args, **kwargs):
if not cls._INSTANCE:
cls._INSTANCE = super(BookShelf, cls).__new__(cls, *args, **kwargs)
return cls._INSTANCE
def add_book(self, book):
self.books.append(book)
def do_something(self):
for book in self.books:
for page in book.pages:
print(page.content)
bookShelf = BookShelf()
with Book() as book1:
for i in range(3):
Page(f"book1, page : {i}")
bookShelf.add_book(book1)
with Book() as book2:
for i in range(3):
Page(f"book2, page : {i}")
bookShelf.add_book(book2)
bookShelf.do_something()
```
위 코드를 조금 부연하자면, `__new__` 부분은 `BookShelf`를 singleton으로 정의하는 내용이다. 또한, `do_something`은 `BookShelf`에서 이뤄지는 특별한 비즈니스 로직이라고 가정하자. 여기서는 결과 확인을 위해 페이지를 출력하는 함수로 구현했다. 위 코드의 결과는 아래와 같다.
```
# 실행 결과
book1, page : 0
book1, page : 1
book1, page : 2
book2, page : 0
book2, page : 1
book2, page : 2
```
위 코드는 잘 작동하지만, 한 가지 문제점이 있다. 그건 바로 `Book` 인스턴스를 생성하는 코드와 비즈니스 로직을 실행하는 코드가 같은 파일에 존재한다는 점이다.
두 코드의 성격을 잘 생각해보자. 인스턴스 생성 코드는 인스턴스가 변경되거나 늘었을 때마다 수정해야 한다. 반면, 비즈니스 로직을 실행하는 코드는 수정할 일이 별로 없고 수정이 잦으면 안 된다. 이런 상반되는 성격의 코드가 같이 있는 건 유지보수에 좋지 않다.
## 예시 코드 개선 : BookShelf
이 문제점의 해결 방안 중 하나는 인스턴스 별로 파일을 만들고 해당 파일에 생성 코드를 모두 작성하고(1), 이후 비즈니스 로직이 실행될 때 해당 코드를 실행하고 인스턴스들을 불러오는 것이다(2).
### 폴더 구조 변경
일단 먼저 폴더 구조부터 변경한다.
```
# 폴더 구조
./classes/__init__.py # Book, Page, BookShelf 정의
./books/book1.py
./books/book2.py # book 별로 파일 생성 후 생성 코드 작성
./run.py # BookShelf의 비즈니스 로직 실행 파일
```
각 파일 속 코드는 아래와 같다.
```python
# ./classes/__init__.py
class Page:
def __init__(self, content):
self.content = content
if _BOOK:
_BOOK.add_page(self)
class Book:
def __init__(self):
self.pages = []
def add_page(self, page):
self.pages.append(page)
def __enter__(self):
global _BOOK
_BOOK = self
return self
def __exit__(self, *args):
global _BOOK
_BOOK = None
class BookShelf:
_INSTANCE = None
def __init__(self):
self.books = []
def __new__(cls, *args, **kwargs):
if not cls._INSTANCE:
cls._INSTANCE = super(BookShelf, cls).__new__(cls, *args, **kwargs)
return cls._INSTANCE
def add_book(self, book):
self.books.append(book)
def do_something(self):
for book in self.books:
for page in book.pages:
print(page.content)
```
```python
#./books/book1.py
from classes import Book, Page
with Book() as book1:
for i in range(3):
Page(f"book1, page : {i}")
```
```python
#./books/book2.py
from classes import Book, Page
with Book() as book2:
for i in range(3):
Page(f"book2, page : {i}")
```
여기서 두 가지 단계 중 첫 단계, **인스턴스 별로 파일을 만들고 해당 파일에 생성 코드를 모두 작성**(1)이 끝났다.
### run.py 작성
다음 단계는 **비즈니스 로직이 실행될 때 해당 코드를 실행하고 인스턴스들을 불러오는 것**(2)이다. 이 단계는 동적 실행과 네임스페이스를 이용하면 쉽게 달성할 수 있다.
#### 동적 실행과 네임스페이스를 이용한 테크닉
##### `exec`을 통한 동적 실행
동적 실행이란 프로그램 내부 로직으로 코드를 생성하여 실행하는 기능이다. 파이썬에서는 `exec` 함수가 동적 실행 함수이고, `exec` 함수의 첫 번째 인자로 입력한 **문자열**이 코드로 실행된다. 코드 파일도 문자열이기 때문에 아래 코드를 이용하면 `book1.py` 코드와 `book2.py` 코드를 동적 실행할 수 있다.
```python
import os
exec(open("books/book1.py").read())
exec(open("books/book2.py").read())
for page in book1.pages:
print(page.content)
for page in book2.pages:
print(page.content)
```
```
# 실행 결과
book1, page : 0
book1, page : 1
book1, page : 2
book2, page : 0
book2, page : 1
book2, page : 2
```
**위 코드에서 눈여겨볼 점은 동적으로 실행된 코드 속 변수들도 모두 네임스페이스에 저장되었다는 점이다**. 이는 파이썬이 인터프리터 언어라는 점을 생각하면 쉽게 이해할 수 있다. 동적 실행된 코드나 기존 코드나 그냥 한 줄 한 줄 인터프리터에 전달되어 실행되기 때문에 동적 실행된 코드 속 변수들도 네임스페이스에 저장되는 것이다. 위 코드에서도 표면적으로는 따로 `book1`과 `book2`를 생성하지 않았지만, 동적 실행 과정에서 생성 코드가 인터프리터에 전달되어 `book1`과 `book2`가 네임스페이스에 저장되었다.
##### `isinstance`를 통한 변수 필터링
앞으로 `Book` 인스턴스들이 더 추가될 수 있기 때문에 코드 파일을 하나 하나 불러오기 보다는 `./books` 폴더 안에 있는 모든 `.py` 파일을 읽어오도록 코드를 수정해야 한다. 수정한 코드는 아래와 같다.
```python
import os
directory = "./books"
for filename in os.listdir(directory):
if filename.endswith(".py"):
file_path = os.path.join(directory, filename)
with open(file_path) as file:
exec(file.read())
for page in book1.pages:
print(page.content)
for page in book2.pages:
print(page.content)
```
이제 미래에 `book3.py` 코드가 추가된다고 해도 기존의 `book1.py`, `book2.py` 코드와 같이 동적 실행되어 인스턴스가 네임스페이스에 저장될 것이다. 그런데 문제가 하나 있다. 정작 아래에 있는 코드에서는 `book3`를 처리하는 코드가 없다는 점이다.
생각을 달리 해야 한다. 미래에 추가될 인스턴스의 변수명을 알 순 없다. 다만, 해당 인스턴스는 최소한 `Book` 타입인 것은 확실하다. 그러니 알 수 없는 **변수명**이 아니라 **타입**으로 네임스페이스에서 인스턴스를 불러와야 한다. 이때 쓸 수 있는 것이 `isinstance` 함수이다.
```python
import os
directory = "./books"
for filename in os.listdir(directory):
if filename.endswith(".py"):
file_path = os.path.join(directory, filename)
with open(file_path) as file:
exec(file.read())
for var in globals().values():
if isinstance(var, Book):
for page in var.pages:
print(page.content)
```
`globals().values()`는 글로벌 네임스페이스에 저장된 모든 변수들을 불러오는 코드이다.
##### 동적 실행 시 로컬 네임스페이스 지정하기
동적 실행으로 생성된 변수를 글로벌 네임스페이스에서 불러오는 방식은 변수의 수가 많을 때는 사용하기 힘들다. 따라서 더 좋은 방법은 코드 속 변수들을 로컬 네임스페이스에 저장하도록 하는 것이다. 파이썬에서는 아래의 코드로 동적 실행 시 변수들이 로컬 네임스페이스에 저장되도록 설정할 수 있다.[^1]
```python
exec([코드, String], [글로벌 네임스페이스, Dictionary], [로컬 네임스페이스, Dictionary])
```
로컬 네임스페이스에 변수를 저장하도록 수정한 코드는 아래와 같다.
```python
import os
directory = "./books"
for filename in os.listdir(directory):
if filename.endswith(".py"):
file_path = os.path.join(directory, filename)
local_vars = {}
with open(file_path) as file:
exec(file.read(), {}, local_vars)
for var in local_vars.values():
if isinstance(var, Book):
bookShelf.add_book(var)
```
이게 비즈니스 로직을 처리하는 코드만 추가하면 `run.py`를 완성할 수 있다.
#### `run.py` 코드 완성
```python
# ./run.py
import os
from classes import BookShelf, Book
bookShelf = BookShelf()
directory = "./books"
for filename in os.listdir(directory):
if filename.endswith(".py"):
file_path = os.path.join(directory, filename)
local_vars = {}
with open(file_path) as file:
exec(file.read(), {}, local_vars)
for var in local_vars.values():
if isinstance(var, Book):
bookShelf.add_book(var)
bookShelf.do_something()
```
```
# 실행 결과
book1, page : 0
book1, page : 1
book1, page : 2
book2, page : 0
book2, page : 1
book2, page : 2
```
## vs IoC
[[A. 제어의 역전(Inversion of Control)|IoC]] 도 인스턴스 생성 코드와 해당 인스턴스를 처리하는 코드를 분리시키기 때문에 위 방법과 비슷하다고 볼 수 있다. 그러나 조금 다른 것이 IoC는 개발자가 처리 코드를 작성/수정할 수 있는 상황에서 쓰이지만, 위 방법은 처리 코드는 수정할 수 없고 오직 코드 파일을 통한 인스턴스 추가만 허용될 때 쓰인다.
## 활용 사례
- [click CLI command 설정](https://click.palletsprojects.com/en/8.1.x/commands/#custom-multi-commands)
- CLI 설정을 도와주는 click에서 CLI command를 일일이 추가하지 않고, 동적 실행으로 추가할 때 이 방식을 사용한다.
[^1]:[Python Doc](https://docs.python.org/ko/3/library/functions.html#exec)