Intro
Python 의 ORM 도구인 SQLAlchemy 와 데이터베이스 마이그레이션 도구인 Alembic을 함께 사용하다보면 재밌는 패턴을 하나 발견할 수 있다.
SQLAlchemy에서는 단일 데이터 소스에 대해 하나의 Base 클래스를 선언하고, 데이터소스에 속하는 각 테이블 모델이 이를 상속하도록 작성한다.
1
2
3
4
5
6
7
8
9
10
11
# SQLAlchemy code
# models/__init__.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
from .users import User, UserHistory
from .items import Items, PriceHistory
from .interaction import SearchHistory, ClickHistory, LikeHistory
그리고 Alembic은 각각의 테이블 모델을 전달받는 게 아니라, Base.metadata 즉, 부모클래스의 메타데이터만을 전달받는다.
1
2
3
4
5
6
# Alembic Config
# alembic/env.py
...
from models import Base
target_metadata = Base.metadata
그런데 Alembic 은 이 정보(Base.metadata)만으로도 User, Item, SearchHistory 등 여러 테이블의 구조와 변경사항을 모두 인식한다. Alembic이 각 테이블 모델을 일일이 참조하지 않아도 전체 테이블을 다룰 수 있는 원리는 뭘까?
이번 포스팅에서 그 원리와 이를 이용한 사용 사례를 알아보도록 하자.
1. Registry Pattern
(1) 개념
정답을 먼저 말하자면, 이러한 방식의 패턴을 Registry Pattern 이라고 지칭한다.
Registry Pattern은 객체나 클래스, 함수와 같은 것들을 중앙 저장소에 등록하고, 필요할 때 이를 조회하거나 사용하는 디자인 패턴을 말한다.
이 패턴에서는 등록할 대상을 보관하는 registry 를 생성해두고, 각 구현체(객체, 클래스, 함수 등)를 해당 저장소에 등록한다. 이후 사용하는 쪽에서는 각 구현체를 직접 참조하지 않고, 레지스트리를 통해 필요한 대상을 찾거나 등록된 모든 대상을 일괄 사용할 수 있다.
Registry Pattern의 핵심 구성 요소는 다음과 같다.
- Registry : 등록 대상을 보관하는 중앙 저장소
- 등록 대상 : registry에 등록해 관리하려는 클래스, 객체, 함수 등
- 등록 로직 : 등록 대상을 registry에 추가하는 과정
- 조회 로직 : 등록된 대상을 찾고, 찾은 대상을 사용하는 과정
(2) 자동 등록과 명시적 등록
Registry Pattern은 등록 방식에 따라 명시적 등록과 자동 등록으로 나눌 수 있다.
명시적 등록은 다음과 같이 직접 등록 함수를 호출하는 방식이다.
1
registry.register("part_a", PartA)
반면 자동 등록은 특정 클래스를 상속하거나 데코레이터를 사용하는 순간 구현체가 스스로 Registry에 등록되는 방식이다. 이번 포스팅에서는 이 방식을 더 깊게 알아볼 것이다.
1
2
class PartA(Registry): # -> 초기화 과정에서 registry에 등록됨
pass
(3) 코드
먼저 등록 대상의 공통 부모가 되는 Registry 클래스를 선언한다.
1
2
3
4
5
6
7
8
9
10
11
12
from abc import ABC, abstractmethod
class Registry(ABC):
registry: dict[str, type["Registry"]] = {}
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
Registry.registry[cls.__name__] = cls
@abstractmethod
def introduce(self):
pass
각 요소의 역할은 다음과 같다.
registry: 이 클래스를 상속받는 서브클래스를 보관하는 중앙 저장소__init_subclass__: 서브클래스가 선언될 때 자동으로 호출되는 메서드cls: 새로 선언된 서브클래스Registry.registry[cls.__name__] = cls: 서브클래스의 이름과 클래스 객체를 registry에 등록하는 코드
그리고, 이 Registry 클래스를 상속받는 클래스를 선언해본다.
1
2
3
4
5
6
7
8
9
10
11
class PartA(Registry):
def introduce(self):
print("I'm Part A!")
class PartB(Registry):
def introduce(self):
print("I'm Part B!")
class PartC(Registry):
def introduce(self):
print("I'm Part C!")
각 클래스가 선언될 때 Registry.__init_subclass__()가 자동으로 호출되고, 새롭게 생성된 클래스가 registry에 등록된다.
1
2
3
4
PartA 클래스 선언
→ Registry.__init_subclass__ 호출
→ cls에 PartA 전달
→ Registry.registry에 PartA 등록
등록된 결과는 다음과 같이 확인할 수 있다.
1
print(Registry.registry)
1
2
3
4
5
{
'PartA': <class '__main__.PartA'>,
'PartB': <class '__main__.PartB'>,
'PartC': <class '__main__.PartC'>
}
registry에는 클래스의 인스턴스가 아닌, 클래스 객체 자체가 저장되어 있다. 따라서 실제 사용하려면, 등록된 클래스를 인스턴스화 한 뒤 사용해주면 된다.
1
2
3
4
for part in Registry.registry:
registry = Registry.registry
part_instance = registry.get(part)()
part_instance.introduce()
1
2
3
I'm Part A!
I'm Part B!
I'm Part C!
이 구조를 사용하면 실행 코드가 PartA, PartB, PartC를 각각 직접 참조하지 않아도 되며, 새로운 구현체가 추가되더라도 공통 부모를 상속하기만 하면 Registry에 자동으로 등록된다는 장점이 있다.
(4) 활용 사례
Registry Pattern은 여러 객체나 클래스를 하나의 공통된 방식으로 관리해야 할 때 사용할 수 있다. 예를 들어 애플리케이션이 실행될 때 데이터베이스 연결, 캐시 초기화, 스케줄러 실행과 같은 여러 작업을 수행해야 한다고 가정해보자.
1
2
3
await database.startup()
await redis.startup()
await scheduler.startup()
이러한 방식은 직관적이지만, 새로운 작업이 추가될 때마다 실행 코드를 함께 수정해야 한다. 예를 들어 서비스 디스커버리에 자신을 등록하려면 위 코드에서 한 줄이 더 추가되어야 한다.
반면 각 작업을 Registry에 등록하면, 실행하는 쪽에서는 등록된 클래스들을 순서대로 가져와 실행하기만 하면 된다.
1
2
3
for service_type in LifeCycle.registry:
service = service_type()
await service.startup()
이제 새로운 작업이 추가되더라도 실행 코드를 수정할 필요가 없다. 공통 클래스를 상속받는 새로운 구현체를 작성하면 Registry에 자동으로 등록되기 때문이다.
이러한 구조는 다음과 같은 경우에 활용할 수 있다.
- ORM 모델 및 테이블 정보 관리
- 이벤트 핸들러 관리
- 명령어 및 작업 처리기 관리
- 플러그인 관리
- 애플리케이션의 시작 및 종료 작업 관리
즉, 여러 구현체를 한 군데에서 모아 관리하고, 새로운 구현체가 계속 추가될 수 있는 구조에 어울리는 패턴이다.
(5) 장단점
Registry Pattern을 사용하면 구현체를 사용하는 코드에서 각각의 구현체를 직접 참조하지 않아도 된다는 장점이 있다. 새로운 구현체가 추가되더라도 Registry에 등록하기만 하면 기존 실행 코드를 그대로 사용할 수 있으므로, 확장하기 쉬운 구조를 만들 수 있다.
하지만, 전역 상태 관리를 사용하므로, 과도하게 사용할 경우 애플리케이션 전체가 하나의 레지스트리에 의존하는 중앙 집중식 구조가 되면서, 내부 서비스 간 숨겨진 의존성이 발생하거나 결합도가 올라갈 수 있다.
특히 주의해야 할 경우는 멀티스레드와 같은 환경인데, 이 때에는 레지스트리에 대한 동시 접근과 같은 자원 경쟁이 발생할 수 있으며 이로 인한 병목 현상이 발생할 수 있다. 그리고 접근 시간에 따라 일관성 없는 상태를 반환할 수 있는 문제도 신경써야 한다.
비교적 덜 위험한 단점일 수 있지만, 자동 등록 방식은 등록 과정이 코드에 명확하게 드러나지 않는다는 단점이 있다. 특히 Python에서는 클래스가 작성된 모듈이 먼저 import되어야 클래스 선언이 실행되고, Registry에도 등록된다. 따라서 모듈이 import되지 않았다면 코드상으로 클래스가 존재하더라도 Registry에서는 찾을 수 없다.
2. 실습
이번에는 Registry Pattern을 활용해 애플리케이션의 startup 및 shutdown 작업을 관리하는 구조를 만들어볼 것이다.
startup 작업은 애플리케이션이 요청을 처리하기 전에 수행되어야 하는 사전 준비 작업을 의미한다. 환경 설정 로딩, 데이터베이스 연결 확인, 외부 서비스 등록, 스케줄러 실행 등이 이에 해당한다. 그리고 shutdown 작업은 애플리케이션이 종료될 때 사용하던 자원을 안전하게 정리하는 작업이다. 데이터베이스 연결 종료, 메시지 소비 중단, 백그라운드 작업 종료 등이 이에 해당한다.
이러한 작업을 애플리케이션 진입점에서 직접 호출할 수도 있다.
1
2
3
await database_service.startup()
await redis_service.startup()
await scheduler_service.startup()
하지만 새로운 서비스가 추가될 때마다 애플리케이션의 startup 코드를 수정해야 하며, 종료 작업도 같은 순서에 맞춰 직접 관리해야 하는 단점이 있으므로, 이번 실습에서는 각각의 startup 및 shutdown 작업을 Registry에 자동으로 등록하고, 애플리케이션에서는 등록된 작업을 일괄적으로 실행하도록 구성해본다.
(1) LifeCycle 클래스
먼저 startup과 shutdown 작업이 공통으로 상속할 LifeCycle 클래스를 작성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from abc import ABC, abstractmethod
class LifeCycle(ABC):
registry : dict[type["LifeCycle"]] = {}
def __init_subclass__(cls, **kwargs):
super.__init_subclass__(**kwargs)
LifeCycle.registry[cls.__name__] = cls
@abstractmethod
def startup(self):
pass
@abstractmethod
def shutdown(self):
pass
(2) LifeCycle 구현체 작성
이제 LifeCycle을 상속받는 구현체를 작성해보자.
1
2
3
4
5
6
class DatabaseLifeCycle(LifeCycle):
def startup(self):
print("create Database engine")
def shutdown(self):
print("dispose connection")
1
2
3
4
5
6
class ServiceDiscoveryLifeCycle(LifeCycle):
def startup(self):
print("Service regist success")
def shutdown(self):
print("Service deregist success")
(3) startup, shutdown 작업 실행
애플리케이션이 시작될 때 Registry에 등록된 클래스를 순회하며 startup을 실행한다.
1
2
3
4
5
6
lifecycles = []
for lifecycle_type in LifeCycle.registry.values():
service = lifecycle_type()
service.startup()
lifecycles.append(service)
1
2
3
# 실행 결과
create Database engine
Service regist success
애플리케이션이 종료될 때는 저장해둔 인스턴스를 역순으로 순회하며 shutdown을 실행한다.
1
2
for service in reversed(services):
service.shutdown()
1
2
3
# 실행 결과
dispose connection
Service deregist success
3. MSA에서 볼 수 있는 registry pattern
위에서 살펴본 registry pattern과 완전히 같다곤 할 수 없지만, 개념적으로는 동일한 구조를 MSA(Micro Service Architecture)에서 찾아볼 수 있다. 바로 Service Registry와 이를 이용한 Service Discovery 이다.
(1) Service Registry
MSA에서는 여러 서비스가 서로의 위치를 알아야 한다. 하지만 각 서비스의 IP와 Port를 직접 코드에 작성하면, 인스턴스가 추가되거나 주소가 변경될 때마다 설정을 수정해야 한다. 이를 해결하기 위해 각 서비스는 자신의 위치와 상태를 Service Registry에 등록한다.
1
2
3
User Service ─┐
Order Service ├─→ Service Registry
Payment Service ┘
(2) Service Discovery
다른 서비스는 특정 서비스의 주소를 직접 알고 있는 대신 Registry에 조회한다. 이렇게 Service Registry에 등록된 서비스를 조회하는 과정을 Service Discovery라고 한다.
1
2
3
4
Order Service
→ Payment Service 조회
→ 정상 상태의 Payment Service 주소 반환
→ 요청 전송
이러한 구조는 앞에서 살펴본 Registry Pattern과 유사하다는 것을 알 수 있다.
1
2
3
클래스 등록
→ Registry에서 조회
→ 인스턴스 생성 및 실행
1
2
3
서비스 등록
→ Service Registry에서 조회
→ 서비스 호출
(3) 코드 Registry와의 차이
둘은 관리 대상과 동작 범위는 다르지만, 같은 목적과 개념을 가지고 있다.
| 구분 | 코드 Registry (자동 등록) | MSA Service Registry |
|---|---|---|
| 등록 대상 | 클래스, 객체, 함수 | 실행 중인 서비스 인스턴스 |
| 등록 시점 | 클래스 선언 또는 초기화 시점 | 서비스 기동 시점 |
| 조회 목적 | 구현체 추적, 관리, 실행 | 서비스 주소 탐색 |
| 주요 정보 | 클래스 타입, 이름 | IP, Port, 상태, 메타데이터 |
| 동작 범위 | 하나의 프로세스 내부 | 네트워크와 여러 서버 |
Reference
Registry Pattern - GeeksforGeeks
4 Service Discovery Patterns for your Microservice
[Python] Registry Pattern (레지스트리 패턴)
Comments