Contents

Node.js에서 OOP 기반 리포지토리에 적용한 싱글톤 디자인패턴

<표지 사진출처: Unsplash)>  

Project Tech Stack Overview
  • Language: TypeScript

  • Platform: Node.js

  • Web Framework: Express.js

  • Database: MySQL

  • ORM: TypeORM

  • Cloud Storage: AWS S3

  • Development Tool: WebStorm

  • Version Control and Issue Tracking: GitHub Issue

 

얼마 전, 모든 코드를 OOP로 리팩토링한 뒤, 한 문제를 발견했다.
리포지토리 역시 클래스 기반으로 내보내고 있었는데 때문에 각각의 서비스 로직에서는 이를 new Class() 형태로 불러오면서 단일 인스턴스가 아닌 여러 인스턴스의 생성 위험이 있었다.
 

OOP 리팩토링 전, 함수기반에서는 최초 호출된 datasource 인스턴스 하나를 계속해서 사용하기에 문제가 없었는데 이 부분을 간과했다.

싱글톤 디자인 패턴 적용

싱글톤 디자인 패턴의 장점

일단 Node.js는 싱글스레드 방식이라 단일 인스턴스가 가지는 멀티스레드에서의 동시성 문제 걱정이 없다.
OOP로 작성된 클래스의 리포지토리를 서비스 로직에서 각각 new RepositoryClass() 방식으로 불러오는건 메모리 낭비이며 각 인스턴스간의 상태 동기화 문제 발생 가능성이 있다.
 

처음 시도한 코드

export class EntityRepository extends Repository {
	private static instance: EntityRepository;
	private constructor() {
	super(Entity, dataSource.createEntityManager());
	}
	public static getInstance(): EntityRepository {
		if (!EntityRepository.instance) {
		EntityRepository.instance = new Repository();
		}
	return Repository.instance;
	}
	// ... 이후 로직
}

 

현재 프로젝트에서는 총 5개의 리포지토리가 사용되고 있었고, 최초 코드를 각 리포지토리에 적용하였다.

싱글톤 패턴 확인

// 단일 인스턴스 확인 함수
function isSingleton(repository: { getInstance(): T }): string {
const instance1: T = repository.getInstance();
const instance2: T = repository.getInstance();
const isInstanceEquel: boolean = instance1 === instance2;
const message: string = 'only instance is ';
const result: string = message + isInstanceEquel;

return result;
}

// 적용
router.get('/singletons', (req: Request, res: Response) => {
const testUserRepository = isSingleton(UserRepository);
const testFeedRepository = isSingleton(FeedRepository);
const testFeedListRepository = isSingleton(FeedListRepository);
const testCommentRepository = isSingleton(CommentRepository);
const testFeedsymbolRepository = isSingleton(FeedSymbolRepository);

res.status(200).json({
testUserRepository,
testFeedRepository,
testFeedListRepository,
testCommentRepository,
testFeedsymbolRepository,
});
})

간단하게 위와 같이 함수를 만들고 테스트용 API 주소를 하나 만들고, 라우터에서 적용해보았다.  

결과는 다음과 같이 확인할 수 있다.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

Res:
{
"testUserRepository": "only instance is true",
"testFeedRepository": "only instance is true",
"testFeedListRepository": "only instance is true",
"testCommentRepository": "only instance is true",
"testFeedsymbolRepository": "only instance is true"
}

 

리팩토링 완성 후, 이 단일 인스턴스 확인 함수를 테스트코드에서 사용할 수 있다.  

리팩토링 시도

여러개의 리포지토리 클래스의 상단에 꽤 여러 줄이 들어가다보니 이를 줄이고 싶었고, 다음 3가지 방법을 시도해보았다.

  1. singletonFactory() - 단일 인스턴스를 관리하는 함수를 만들어 사용
  2. 싱글톤 디자인패턴으로 만드는 factory Class를 만들고 각 리포지토리를 extends 를 이용하여 확장
  3. OOP에서의 컨트롤러를 내보내는 방식처럼 애초에 클래스 생성 후, 단일 인스턴스로 내보내는 방법

1. singletonFactory() 함수 도전

// 함수 생성
export function singletonFactory<T extends {
	instance: T | null;
	new (): T;
	}> (Class: T): T {
	if (!Class.instance) {
		Class.instance = new Class();
		}
	return Class.instance;
};

// 적용
const entityRepository = singletonFactory(EntityRepository);
const anotherRepository = singletonFactory(AnotherRepository);

 

실패!
우선, 함수에서는 적용하려는 클래스에서 static instance를 생성할 수가 없다. **즉, 리포지토리 클래스에서 결국 하나하나 static instance`를 넣어줘야 했다.**
이렇게 되면 리포지토리 클래스 코드작성에도 신경써야하면서 굳이 함수로 불러와 써야하다보니 혹떼려다 혹 하나 더 붙인 꼴이 되었다.
 

생각해보니 만약 함수를 성공적으로 만들었다 하더라도 export하고 다른 서비스로직에서 import할 때 역시 꽤 번거롭다. 서비스 로직에서 리포지토리를 불러올 때 일반적인 code convention상의 문제로써, 리포지토리 클래스가 대문자가 아닌 소문자 형태의 함수로 불러와지다 보니 코드 가독성은 차치하더라도 새로운 서비스 로직에서 사용할 때, 이 함수를 간과하고 그대로 리포지토리를 불러와 작성할 혼동 역시 예상되었다.
코드 작성시 이렇게 고려해야할 요소들이 늘어나는건 좋지 않다.

// 현재 불러오는 형태
const entityRepository = EntityRepository.getInstance();

// 함수형에서 불러오는 형태
const entityRepository = singletonFactory(EntityRepository);

 

2. 상속을 통한 팩토리 클래스

실패
두번째 extends factory Class 방식을 시도해보았는데, 리포지토리는 factory 클래스와 Repository를 모두 상속해야하는 터라 메서드 상속이 도저히 되지 않았다.
찾아보니 JavaScript에서는 다중상속이 불가능하다고 한다.1

3. export default new Repository() 방식은?

repository class를 생성한 후, export default new EntityRepository로 내보내는건 어떨까?
 
결과는 성공
테스트를 해보니 단일 인스턴스는 보장되었다. 또한 전역 접근도 가능하다.
하지만, 모듈이 로드될 때 인스턴스가 생성됨으로 테스트 코드 작성시 모의하기가 굉장히 까다로워졌다.
 

반면, 최초의 코드인 정적메서드 Repository.getInstance()를 사용하는 싱글톤 패턴에서는 메서드가 호출될 때 생성되는 지연 초기화(Laze Initialization) 방식이기에 테스트 용이성이 확실히 좋다.  

최종 확정 코드

export class EntityRepository extends Repository {
private static instance: EntityRepository;
private constructor() {
super(Entity, dataSource.createEntityManager());
}
public static getInstance(): EntityRepository {
if (!this.instance) {
this.instance = new this();
}
return this.instance;
}
// ... 이후 로직
}

 

결국 위와 같이 최초 코드에서 this를 사용하는 정도로 타협(?)하고 이를 Webstorm의 Live Template로 만들어 사용하기로 하였다.2

// WebStorm - Live Template Code
export class $1$ extends Repository<$2$> {
  private static instance: $1$;
  private constructor() {
    super($2$, dataSource.createEntityManager());
  }

  public static getInstance(): $1$ {
    if (!this.instance) {
      this.instance = new this();
    }
    return this.instance;
  }

 

그런데 this는 실행 컨텍스트를 따지는 까다로운 키워드라 혹시 서비스로직에서 사용되다 다른 객체를 참조하진 않을까 우려되었다.
몇가지 테스트를 해보고 문서를 찾아보니 다행히 static 메소드 안에서의 this는 해당 클래스에 바인딩 되듯이 작동하였고 전혀 문제 없었다.3

적용 후, 차이점 확인

싱글톤 디자인패턴의 장점 중 하나인 메모리효율은 적용 전과 비교하여 과연 얼마나 차이가 날까?
Node --inspect4 와 Chrome DevTools5 를 이용해 적용 전후의 수치를 확인해보았다.

/images/20240128%20싱글톤%20전환%20후%20메모리차이.png

DevTools-Comparison view에서의 컬럼 설명 보기 (click)
  • Size Delta (크기 차이):
    • 이 값은 두 스냅샷 간의 총 메모리 사용량의 차이를 나타낸다.
    • 양수 값은 메모리 사용량이 증가했음을, 음수 값은 감소했음을 의미하며 메모리 사용량의 변화를 직접적으로 보여준다.
  • New (새로 생성됨) 및 Deleted (삭제됨):
    • 새로 생성된 객체와 삭제된 객체의 수를 비교한다.
    • 많은 수의 객체가 생성되었지만 삭제되지 않는 경우, 메모리 누수나 비효율적인 메모리 사용이 발생했을 가능성이 있다.
  • Delta (차이):
    • 객체 유형별로 얼마나 많은 객체가 추가되거나 제거되었는지를 보여준다.
    • 특정 유형의 객체가 지속적으로 증가하는 경향을 보이는 경우, 이는 메모리 누수의 신호일 수 있다.
  • Alloc. Size (할당 크기) 및 Freed Size (해제 크기):
    • 할당된 메모리와 해제된 메모리의 양을 비교한다.
    • 메모리 할당과 해제가 균형을 이루고 있는지 확인할 수 있다.

  • 총 메모리 사용율
    • before: 57.179 MB
    • after: 54.386 MB
  • 차이 : 2.793 MB
  • 메모리 감소율 약 -4.8%

SnapShot의 Comparison view에서 보이는 바와 같이 싱글톤 코드 작업 전과 비교했을 때, 일단 전체적으로 메모리 사용율이 확연히 줄었다.
그리고 객체 수에서도 Repository(파란 동그라미 부분)를 살펴보면 확실하게 하나의 객체만을 보여주고 있다.


github 관련 커밋 보기 - github-inchan code CHANGED | 3.0.1