본 글에서는 널리 알려진 객체 지향 설계의 다섯 가지 원칙, SOLID 원칙 중 단일 책임 원칙과 이 원칙을 지키는 데 도움이 되는 디자인 패턴 중 하나인 옵저버 패턴(Observer Pattern)에 대해 살펴본다.
또한 루니버스 DID 서비스 개발에 사용된 NestJS 프레임워크에서 제공하는 이벤트 기능을 이용해서 옵저버 패턴을 구현한 예제를 살펴본다.
단일 책임 원칙 (Single Responsibility Principle)
하나의 클래스(혹은 컴포넌트, 모듈, 함수)는 하나의 책임만을 가져야 한다는 원칙이다.
왜 하나의 클래스는 하나의 책임만을 가져야 할까? 하나의 클래스가 여러 책임을 가진다면 어떤 문제가 발생할까?
결론부터 말하자면, 하나의 클래스가 여러 책임을 가진 경우, 이 책임으로 인해 클래스에 결합(coupling)을 일으키기 쉽게 된다. 결합을 방지하기 위해 하나의 클래스가 하나의 책임만을 가지도록 설계해야 한다.
다음 코드를 보자.
위 예제에서 Employee 코드는 세 가지의 책임을 지니고 있다.
1. 지급액을 계산하는 책임 (지급 담당자)
2. Employee 오브젝트가 관리하는 데이터를 데이터베이스에 저장하는 책임 (데이터베이스 담당자)
3. 적절한 지급액이 지불되었음을 확인할 수 있는 보고서를 작성하는 책임 (감사 담당자)
괄호 안에 담당자를 언급한 이유는 단일 책임 원칙이 바로 책임자에 대해 생각하자는 것이기 때문이다.
우리는 소프트웨어 모듈을 작성할 때, 모듈에 대한 변경 요청이 한 명 또는 아주 좁게 정의된 비지니스 기능를 대표하는 그룹에서 나오기를 원한다. 왜냐하면, 지급 담당자가 요청한 변경사항으로 인해 감사 담당자가 피해를 보게 하고 싶지 않기 때문이다.
지급 담당자의 요청으로 calculatePay 메소드를 변경했는데, 그로 인해 reportHours 메소드가 동작하지 않게 되었다고 치자. 두 메소드의 로직이 결합되어 있는 경우에 이러한 문제가 발생할 수 있다.
창문이 내려가지 않아서 정비사에게 차를 가져갔다고 생각해보자. 다음날 정비사가 차를 다 고쳤다고 해서 차를 타보았는데 창은 잘 내려간다. 하지만 와이퍼가 움직이지 않는다.
한 클래스가 여러 책임을 가지는 경우, 결합이 강한 코드로 인해 이러한 문제가 발생하기 쉽다.
단일 책임 원칙은 이러한 일이 발생하지 않도록 책임자, 즉 변경의 원인이 되는 사람(혹은 컴포넌트, 서비스나 모듈)을 잘 생각해보고 해당 책임자의 책임만을 가진 클래스를 구현하는 것을 뜻한다.
단점
그러나 단일 책임 원칙은 지키기 매우 어렵다. 설계 시 미리 모든 책임자를 찾아내기란 불가능에 가깝다. 또한, 단일 책임 원칙에 대한 과도한 고려는 소프트웨어 설계의 또 다른 금기인 성급한 최적화로 이어질 수 있다.
그러면 우리는 어떻게 해야 할까? 이러한 상황에서는 일단 할 수 있는 만큼만 설계를 해 보고, 만들어 보면서 테스트해보면 된다. 페이스북 본사에 적혀있다는 말처럼 완료가 완벽보다 더 중요하니 말이다.
우리는 완벽하지 않은 우리가 할 수 있는 만큼만 설계를 하고 소프트웨어를 만들었기 때문에, 항상 문제가 발생할 거라는 사실을 알고 있다. 그래서 문제가 발생하면 문제 원인을 파악하고 사후 처리하는 일이 매우 중요하다만, 이미 상용 서비스 중인 상황에서는 문제를 빠르게 해결하는 것을 더 중요하게 여기기 때문에, 경험상 아쉽게도 대부분 미봉책으로 덮어두는 경우가 많았다.
그래서 더 좋은 방법은 문제 발생 전에 이런저런 요구사항 변경을 테스트해 보는 것이지만, 글 주제를 벗어나는 관계로 다루지 않겠다.
옵저버 패턴(Observer Pattern)
옵저버 패턴은 단일 책임 원칙을 지킬 수 있도록 도와주는 여러 디자인 패턴 중 하나다. 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버들에게 통지하도록 하는 패턴이다.
Pub/Sub 모델이라는 이름으로도 알려져 있다.
대부분의 GUI 라이브러리나 툴킷(toolkit)이 이 패턴을 사용하기 때문에 UI 개발자나, 혹은 http, net 같은 NodeJS의 빌트인 라이브러리의 옵저버 패턴을 이용해 본 개발자에게는 상당히 익숙한 디자인 패턴이다.

장점
단점
물론 옵저버 패턴이 좋기만 한 것은 아니다. 과신하지 말고 다음 단점을 생각해서 적용해야 한다.
- 옵저버를 등록하는 것과 제거하는 것이 런타임에 발생하는 경우에는 프로그래머가 등록/제거의 짝맞춤에 신경 쓰지 않는 경우 메모리 누수가 쉽게 발생한다.
- 옵저버 패턴을 사용하는 클래스가 너무 많으면 통지가 연쇄적으로 일어나 상태 관리가 어려워지고, 코드 가독성이 떨어진다.
NestJS에서 옵저버 패턴 구현 방법
NestJS는 이벤트 처리를 위한 기능을 @nestjs/event-emitter 모듈을 통해 제공하고 있다. 이 모듈을 이용하면 옵저버 패턴을 다음과 같이 간단히 구현할 수 있다.
먼저 어플리케이션에 루트 모듈로 등록한다.
@Module({
imports: [
EventEmitterModule.forRoot()
],
})
export class AppModule {}
이제 NestJS가 제공하는 Dependency Injection 기능으로 이벤트를 발행하고자 하는 서비스에 디펜던시를 주입한다.
constructor(private eventEmitter: EventEmitter2) {}
이제 해당 모듈에서 다음과 같이 이벤트를 dispatch 할 수 있게 되었다. 첫 번째 파라미터는 이벤트 이름이고 나머지 파라미터는 이벤트 핸들러에 전달되는 값들이다.
(참고: emitAsync는 등록된 event handler를 Promise.All로 처리한다.)
this.eventEmitter.emitAsync('ProjectCreated', entityManager, createdProject)
이벤트를 구독하는 방법은 다음과 같다.
@OnEvent('ProjectCreated')
async handleProjectCreatedEvent(
entityManager: EntityManager,
createdProject: Project) {
// handle and process "ProjectCreatedEvent" event
}
단일 책임 원칙을 지키기 어려운 비즈니스 요구사항
애플리케이션 내 Project라는 어떤 리소스가 하나 생성된 경우, ProjectService 클래스는 Project 엔티티를 데이터베이스에 저장하는 책임을 갖고 있었다고 가정해보자.
그리고 그와 동시에 필요한 비즈니스 요구사항으로 프로젝트 리소스 생성에 따라 다음과 같은 동작이 동시에 진행되기를 요구한다고 상상해보자.
1. 요금 계산을 위해 프로젝트 생성 시점에 별도의 요금 계산용 엔티티를 생성하여 기록
2. 통계를 위한 별도의 리모트 서비스에 프로젝트 생성을 통지
3. 운영자에게 프로젝트 생성을 이메일 발송으로 통지
4. 슬랙 채널에 프로젝트 생성에 대한 알림 메시지 발송
이렇게 하나의 리소스 변경으로 많은 파생 작업이 있는 경우, 옵저버 패턴을 이용하는 것을 고려해볼 수 있다.
먼저 옵저버 패턴 없이 ProjectService 클래스에서 해당 기능을 제공하는 각 클래스를 의존성 주입을 통해 이용하는 방안을 생각해보도록 하겠다.
예를 들어,
요금 계산을 위한 BillingService
통계 보고서 작성을 위한 StatisticsService
이메일 발송을 위한 EmailService
슬랙 메시지 발송을 위한 SlackService
이들을 각각 transaction으로 묶은 후 해당 서비스의 메서드를 호출하는 방법이다.
참고로, NestJS가 추천하는 ORM인 TypeORM을 사용하면, transaction 메서드를 통해 전달받은 entityManager를 사용하여 transaction에 작업을 추가할 수 있고, 오류가 발생하면 자동으로 rollback된다.
async createProjectFromRequest(
createProjectRequest: CreateProjectRequest,
requester: LuniverseRequester,
): Promise<ProjectVo> {
const newProject = await this.connection.transaction(
async (entityManager): Promise<Project> => {
const projectRepository = entityManager.getCustomRepository(ProjectRepository);
const createdProject = await this.createAndSaveProjectFromRequest(
projectRepository,
createProjectRequest,
requester,
);
await this.billingService.createBillingForProject(entityManager, createdProject)
const receiverEmailAddresses = this.accountService.getEmailAddressesForUser(requester)
await this.emailService.createEmailForProject(entityManager, createdProject, receiverEmailAddresses)
await this.slackService.createSlackForProject(entityManager, createdProject, projectCreatedChannel, requester)
await this.statisticsService.createStatisticsForProject(entityManager, requester, createdProject)
return createdProject;
},
);
return this.converterService.convertProjectToVo(newProject);
}
이제 ProjectService는 단일 책임 원칙을 어기고 프로젝트 엔티티 저장뿐 아니라 빌링, 통계, 이메일 발송, 슬랙 메시지 발송에 대한 간접적인 책임을 추가적으로 가지게 되었다.
만약 유닛 테스트를 작성한다면 네 가지 파생 서비스에 대한 목킹은 정말이지 고통스러울 것이다.
그리고 만약 각 서비스가 필요에 의해 method signature를 변경하면, 아무 상관 없는 ProjectService까지 함께 변경해야 할 가능성이 높아진다.
다음은 옵저버 패턴을 적용한 ProjectService의 예제다.
async createProjectFromRequest(
createProjectRequest: CreateProjectRequest,
requester: LuniverseRequester,
): Promise<ProjectVo> {
const newProject = await this.connection.transaction(
async (entityManager): Promise<Project> => {
const projectRepository = entityManager.getCustomRepository(ProjectRepository);
const createdProject = await this.createAndSaveProjectFromRequest(
projectRepository,
createProjectRequest,
requester,
);
await this.eventEmitter.emitAsync('ProjectCreated', entityManager, requester, createdProject);
return createdProject;
},
);
return this.converterService.convertProjectToVo(newProject);
}
옵저버들, 즉 예제에서 BillingService, StatisticsService, EmailService, SlackService 등은 다음과 같이 구현을 추가한다.
@OnEvent('ProjectCreated')
async handleProjectCreatedEvent(ProjectCreatedEvent) {
// handle and process "ProjectCreatedEvent" event
}
이렇게 하면 ProjectService는 파생 작업에 대한 타 클래스 의존성 없이도 같은 동작을 하게 된다.
기존과 동일하게 옵저버들이 등록한 핸들러 메서드가 실패한 경우에도 물론 트랜잭션 롤백이 동작한다.
SMS로 통지하는 추가 요구사항이 전달되었다고 상상해보자. ProjectCreated 이벤트를 핸들링하는 SmsService만 추가로 구현하고, ProjectService에 대한 변경은 필요 없다는 사실을 알 수 있을 것이다.
결론
이상으로 단일 책임 원칙을 위한 옵저버 패턴을 설명하고, NestJS에서 옵저버 패턴을 구현하는 예제를 통해 단일 책임 원칙을 지키는 방법을 살펴보았다.
필자는 글을 쓰기 전에 도메인 드리븐 디자인(DDD, Domain-Driven Design)의 도메인 이벤트 개념을 구현하는 방법에 대해 고민을 하던 중이었다. 많은 글들을 찾아보았지만, 대부분 메시징 인프라 구축을 통한 마이크로서비스 간의 통합 메시지에 대해 설명하거나, 이벤트 소싱, CQRS와 같이 엔티티 저장 및 로드에 대한 패러다임을 바꾸는 부담스러운 작업이 필요한 것처럼 보였다. 구글링에 의존하다 보니 이벤트를 이용하는 방식으로 변경하면 무조건 메시지 큐 같은 별도 인프라 구축이 필요하다거나, DB 트랜잭션 처리가 불가능해 결과적 일관성으로 구현해야 한다고 막연히 추측했었는데, 구글 검색을 중단하고 골똘히 생각해보니 별도의 큰 작업 없이 옵저버 패턴만 적용하면 될 것 같다는 결론에 이르렀다.
아쉽게도 이번 프로젝트에서는 적당한 적용처를 찾지 못해 실제로 구현을 해보지는 못했지만, 필요한 상황이 오면 꼭 적용해보고 싶다.