Singleton pattern으로 콜스택 터지는 문제 해결 😅: 전역에서 접근 가능한 하나의 인스턴스 생성하기

GY·2023년 3월 11일
0

Vanilla JS Project

목록 보기
18/19
post-thumbnail

🤯 Error: Maximum call stack size exceeded

vanilla js로 웹페이지를 구성하던 도중 콜스택이 자꾸 터지는(ㅎ) 문제가 발생했습니다.

라우팅 기능을 구현하기 위해 만들어놓은 Router 클래스로 인스턴스를 생성하여 클래스에 정의해놓은 메서드를 사용하려고만 하면 이런 에러가 발생했습니다.

RangeError: Maximum call stack size exceeded


🤔 원인

문제의 원인은 재귀적으로 라우터와 라우터를 사용하는 클래스의 인스턴스가 무한 호출되는 것이었습니다.

(이 글의 목적은 라우터의 동작에 대해 설명하고자 함이 아니기 때문에, 기존 코드보다 간호화해서 예시 코드를 가져왔습니다.)


기존 코드

라우팅 기능이 필요한 특정 클래스에서 router 인스턴스를 생성해 메서드를 호출합니다.

// Post/index.js
import { Router } from "../../router";

export class ListPage {
  #post;
  constructor(post) {
	this.router = new Router(); //Router 인스턴스 생성
    this.#init();
  }

  #init() {
    this.getElement.addEventListener("click", () => {
      this.router.navigate("detail", `/${this.#post.id}`);
      //생성한 인스턴스 메서드 사용
    });
  }
  //...
  

Router 클래스에서는 애플리케이션에서 사용하는 routes 데이터를 가지고 있다가
각각의 메서드에 따라서 경로에 맞는 페이지를 렌더링해주기도 하고,
해당 경로의 쿼리 파라미터를 반환하는 등의 동작을 합니다.

(지금은 그렇지 않지만, 당시에는 애플리케이션의 경로와 페이지에 대한 정보를 Router클래스 내부에서 관리하고 있었습니다.)


  
// Router/index.js

export class Router {
  #routes;
  #$app;
  #$listPage;
  #$detailPage;

  constructor() {
    this.#$app = document.querySelector(".root");
    this.#$listPage = new ListPage();
    this.#$detailPage = new DetailPage();

    this.#routes = { "/": this.#$listPage, "/detail": this.#$detailPage };

그러니 이렇게 무한 반복하며 인스턴스를 계속 서로 생성하게 된 것이죠 😅

ListPage 클래스에서는 router 인스턴스를 생성하고, 
router 인스턴스에서는 ListPage 인스턴스를 생성하고, 
ListPage 인스턴스에서는 router 인스턴스를...
...

시도 #1 정적 메서드

처음에는 필요한 메서드를 인스턴스 메서드가 아닌 정적 메서드로 선언한 뒤 사용하는 방식으로 해결해보려 했습니다.
단순히 클래스 내에서 인스턴스를 생성하지 않으면 재귀적으로 인스턴스가 무한 생성되는 것을 방지할 수 있다고 생각했기 때문입니다.

static method (정적 메서드)

Router내에서 정적 메서드만 선언해 필요한 함수를 호출하는 방식입니다.

장점:

이 방식을 생각한 이유는 애플리케이션 전역에서 사용할 유틸리티 함수를 전역함수로 정의하지 않고,
클래스를 하나의 네임스페이스로 사용해 정적 메서드를 모아 놓고 라우팅 관련 함수들을 구조화할 수 있기 때문입니다.

단점:

하지만 다시 여러 인자(routes배열, 렌더링할 app요소, path parameter)들을 받아서 처리해야 하기 때문에 깔끔한 구조가 나오지 않는다는 단점이 있습니다.
예를 들어 Router클래스는 따로 있고, routes 배열로 각 라우팅 주소와 주소에 따라 보여줄 페이지 요소는 app에서 별도로 선언한 뒤 인자로 넣어주어야 합니다.

// 예를 들면,
class ArticleListPage 

하지만...
결국 라우팅에 대한 경로와 렌더링할 페이지 컴포넌트 클래스의 인스턴스를 담은 데이터가 라우터 클래스에 주입되어야 했고,
라우터의 메서드 (예를 들면 주입받은 경로 중에서 현재 경로에 대응되는 페이지를 찾아 렌더링하거나, 현재 쿼리 파라미터를 반환하는)를 사용 하기 위해
매번 스태틱 메서드에 이 데이터를 넘겨주면서 호출할 수는 없었습니다.


시도 #2 싱글턴 패턴

그래서 사용한 것이 싱글턴 패턴입니다.
마침 공부했던 싱글턴 패턴이 떠오르면서

"무한 인스턴스를 생성하는 것이 원인이라면, 
어떤 곳에서 호출해도 하나의 인스턴스만 생성되도록 만들면 되니 
싱글턴 패턴을 사용하자!"

라고 생각했습니다.

장점:

이 방식을 선택한 이유는 계속해서 인스턴스가 재귀적으로 생성되는 것을 막아줄 뿐 아니라,
1번 방법이 가지는 단점을 보완할 수 있기 때문입니다.
Router 클래스 내에서 라우팅관련 주소와 페이지 요소, 메서드를 모아 사용할 수 있습니다.


💎 싱글턴 패턴이란

한 클래스에서 인스턴스를 한 개만 생성하도록 제한하는 디자인 패턴입니다.

인스턴스가 하나도 생성되지 않았다면 인스턴스를 새로 생성하고,
이미 생성된 인스턴스가 있다면 그 인스턴스의 참조를 반환합니다.

이렇게 구현하면 어디에서 해당 클래스의 인스턴스를 새로 생성하던지 단 하나의 동일한 인스턴스를 반환받아 사용하게 됩니다.

사용하는 이유

  • 하나의 어플리케이션에서 특정 작업을 한 곳에서 처리해야 할 때 유용
  • 시스템 로깅, 애플리케이션 설정,앱의 상태 설정 등 리소스를 관리하는데 사용
  • 한 인스턴스에서 관리. 앱 내에서 전역적으로 접근할 수 있도록 함

특징

  • 하나의 객체 인스턴스만 존재
  • static함수로 객체접근

장점

  • 하나의 인스턴스에서 리소스를 관리할 수 있음
  • 코드 중복을 방지
  • static 메서드로 앱 전역에서 접근 가능

싱글턴 패턴을 구현하는 예시 코드는 다음과 같습니다.

class Singleton {
  constructor() {
    if(Singleton.instance) {
      return console.warn('warning: singleton class already instantiated');
    }
    Singleton.instance = this; //클래스의 인스턴스에 대해 this 바인딩
    this.version = Date.now();
    this.config = 'test';
  }
  
  static getInstace() {
    if (!this.instance) {// 생성된 인스턴스가 없다면
      this.instance = new Singleton();//새로운 인스턴스 반환
    }
    return this.instance;//생성된 인스턴스가 있다면 해당 인스턴스 반환
  }
}

const s1 = new Singleton();
console.log(s1); 
//{version: 34234,/...}
const s2 = new Sintleton();
console.log(s2);
// warning:...

const s1 = Singleton.getInstance();
console.log(s1);
//{version: 34234,/...}
const s2 = Singleton.getInstance();
console.log(s1 === s2);
//{version: 34234,/...}

싱글턴 패턴 적용

구현한 내용은 다음과 같습니다:
먼저 Router 클래스에 싱글턴 패턴을 적용했습니다.

export class Router {
  #$app;

  constructor() {
    Router.instance = this;
    this.routes = [];
    this.init();
    this.#$app = document.querySelector(".root");
  }

  static getInstance() {
    if (!this.instance) {
      this.instance = new Router();
    }
    return this.instance;
  }

  init() {
    this.routes = [
      { path: "/post", page: new ListPage() },
      { path: "/post/:id", page: new DetailPage() },
    ];
  }

그리고 정의해둔 정적 메서드인 getInstance()를 통해 외부에서 인스턴스를 생성해 Router클래스에 접근하도록 했습니다.

export class Post extends BaseComponent {
  #post;
  constructor(post) {
    super();
    //...
    this.#post = post;
    this.#init();
  }

  #init() {
    this.getElement.addEventListener("click", () => {
      Router.getInstance().navigate("post", `/${this.#post.id}`);
      //getInstance static메서드를 사용해 접근
    })

싱글턴 패턴을 적용하면서

디자인 패턴, 이래서 사용하는 구나!

디자인 패턴에 생소했었기 때문에 왜 굳이 디자인 패턴을 사용해야 하는지에 대해 머리로는 알고 있었지만 와닿지는 않았었는데,
보편적으로 발생하는 문제에 대한 해답을 정형화된 패턴으로 만들어두었기 때문에 효과적인 코드 구조를 고안하는데 도움이 된다는 점에서 디자인 패턴의 필요성을 느꼈습니다.

라우터에 특징에 맞게 개선

더불어 처음에는 발생한 에러를 해결하기 위한 고민을 하다가 싱글턴 패턴을 적용하게 되었지만, 라우팅 기능을 담당하는 Router는 애플리케이션 내에서 단 하나만 존재하면 되기 때문에 이 특징에 맞게 더 적절한 구조로 코드를 개선할 수 있었습니다.

이후에는 라우터 내부에서 라우팅에 필요한 페이지경로와 렌더링할 페이지 인스턴스를 관리하지 않고 외부에서 이 데이터를 주입하도록 변경했습니다.
Router클래스를 하나의 모듈로 볼 때, 각기 다른 앱에서 사용한다면 각 앱에 맞응 라우팅 정보를 주입하고 라우터가 이 데이터에 맞게 동작하는 것이 보다 적절한 구조였기 때문입니다.

싱글턴 패턴이 적용되었기 때문에 한번 라우팅 경로를 최상위 클래스에서 주입해 인스턴스를 생성하면,어디에서 접근하던지 해당 정보를 갖고 있는 동일한 인스턴스에 전역적으로 접근할 수 있어 리팩토링이 가능했습니다.

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글