본문 바로가기
project/reactify

태그 리터럴을 활용한 JSX 유사 컴포넌트

by qjatjs123123 2025. 2. 18.



안녕하세요!

 

개인 토이 프로젝트로 타입스크립트를 활용하여 프레임워크를 개발하고, 해당 프레임워크로 간단한 웹 페이지 개발을 진행했습니다.

 

그 과정에서 고민했던 아이디어, 직면한 문제, 그리고 그에 대한 해결 방안 등을 공유해보려고 합니다.

 


 

컴포넌트 만들기

리액트를 사용해본 분이라면 컴포넌트의 편리함을 경험하셨을 텐데요.

 

제 프레임워크에서도 컴포넌트 개념을 접목시켰습니다.

 

저는 태그드 리터럴을 사용하여 컴포넌트 기능을 구현하였습니다.

 

템플릿 리터럴은 ``으로 주로 문자열을 동적으로 생성할 때 사용하셨을 겁니다.

 

const name = 'John';
const greeting = `Hello, ${name}!`;

 

 

하지만 태그드 리터럴은 태그 함수에 파라미터로 템플릿 리터럴 문자열을 전달합니다.

 

간단한 예제로 이해해 봅시다.

function myTag(strings, ...values) {
  console.log(strings);  //['안녕하세요',' 입니다.']
  console.log(values);  //['홍범선']
}

var name = '홍범선';

myTag`안녕하세요 ${name} 입니다`;

 

결론부터 말씀드리면 ${}로 감싸진 표현식을 기준으로 split을 했다고 이해하시면 됩니다.

strings에는 ${}로 감싸진 표현식으로 split 을 한 결과값이,

values에는 ${}로 감싸진 표현식이 전달됩니다.

 

 

결국 HTML 템플릿을 문자로 처리할 때 사용합니다.

var 문자 = `
<div>
  <div>
    ${변수명}
  </div>
</div>`;

 //['\n<div>\n  <div>\n    ', '\n  </div>\n</div>']
//['홍범선']

결과는 HTML 코드${}로 감싸진 표현식을 분리가 됩니다.

 

${}로 감싸진 표현식에는 함수 참조 값, 컴포넌트, 배열등이 올 수 있고, 이러한 값들을 잘 처리해야 합니다.
그렇지 않는다면 문자열로 변환할 때 [Object] 등으로 변환이 될 수 있습니다.

 

 


 

 

 

${}로 감싸진 표현식에 컴포넌트가 올 때를 먼저 설명드리겠습니다.

바로 재귀로 해결하는 것인데요
이 결과로 아래 코드와 같이 중첩된 컴포넌트 구조를 만들 수 있습니다.

return html`
  <div class="form-container">
    ${this.fieldInfoView}
    ${this.barView}
    ${tripType === '지구 저궤도 여행' 
      ? this.earthFieldView 
      : tripType === '달 여행' 
      ? this.moonFieldView 
      : this.marsFieldView}

  </div>
`;

 

 

render() 함수가 호출되면, 재귀적으로 현재 컴포넌트의 DOM 요소를 반환하게 됩니다.

render() 함수를 설명하기 앞서 render() 함수의 전체적인 흐름을 먼저 설명드리겠습니다.

가장 중요한 점은 태그드 리터럴이 문자열 형태이기 때문에, 이를 DOM 요소로 변환하려면 innerHTML을 사용해야 한다는 것입니다.

또한 주의해야 할 점은, innerHTML 안에 재귀적으로 반환된 DOM 요소를 직접 넣으면 안 된다는 것입니다. 그 이유는 하위 DOM 요소를 문자열로 변환하기 때문에, 이벤트나 연결 관계 등이 제대로 동작하지 않기 때문입니다.

마지막으로 함수를 적절히 처리해야 합니다. 

 

 

 

이러한 간략한 흐름을 토대로 코드를 통해 설명하겠습니다.

 

 

Step 1.

toHtml(): HTMLElement {
    const [wrapEl, nodeList, funcList] = this.buildHTMLContent();
    this.replaceNode(wrapEl, nodeList);
    this.eventBind(wrapEl, funcList)
    
    return wrapEl.firstElementChild as HTMLElement;
  }

위 코드는 Render() 함수라 봐도 무방합니다.

먼저 buildHTMLContent 함수로 build 작업을 마치고,

DOM 요소를 교체시키고,

Event를 바인딩하고, 

DOM 요소를 리턴하는 것을 볼 수 있습니다.

 

 


 

 

Step 2.

private buildHTMLContent(): [HTMLElement, Map<number, HTMLElement>, any[]] {
    const wrapEl = document.createElement("div");
    const nodeList: Map<number, HTMLElement> = new Map();
    const funcList: any = []

    let template = "";
    [...this.strings].forEach((str, i) => {
      const newValue = this._parseValue(this.values[i] ?? "");
      template += this._stringifyValue(newValue, funcList, nodeList, str);
    });

    wrapEl.innerHTML = template;
    return [wrapEl, nodeList, funcList];
  }
}

render함수의 기초작업을 하는 함수입니다.

[...this.strings]는 HTML 문자열 배열이고, this.values는 ${} 감싸진 표현식 배열입니다.

 

innerHTML 코드가 있는 것을 보면, HTML 문자열로 HTML 요소를 생성하는 과정이라고 추측할 수 있을 것입니다.

parseValue 함수는 this.values 값을 넘기는 것으로 보아, 컴포넌트를 체크하고 재귀 처리를 담당하는 함수라고 추측할 수 있을 것입니다.

stringifyValue 함수는  parseValue 함수 리턴 값을 넘기는 것으로 보아, 그 결과를 문자열로 변환하는 역할을 담당한다고 추측할 수 있습니다.

 

 

private _parseValue(value: unknown) {
    
    switch (true) {
      case value instanceof View:
        return (value as View<any>).render();
      case value instanceof Tmpl:
        return (value as Tmpl).toHtml();
      case Array.isArray(value):
        return this._mergeArray(value);
      default:
        return value;
    }
  }

즉, 컴포넌트라면 render 함수를 호출하여 하위 DOM 요소를 반환받고 있습니다.

Tmpl은 배열 예외 처리를 위해 사용되었고,

Array는 map과 같은 배열 값일 때 처리하는 함수입니다.

 

 

 

private _stringifyValue(value: unknown, funcList, nodeList, str: string): string {
    if (value instanceof Function) {
      return this._handleFunction(value, funcList, str);
    } else if (value instanceof HTMLElement) {
      return this._handleHTMLElement(value, nodeList, str);
    } else {
      return str + value;
    }
  }
  
private _handleFunction(value: Function, funcList: any[], str: string): string {
    const funcId = funcList.length;
    const arr = str.split(" ");
    const eventType = arr.pop()?.split("=")[0];
    str = arr.join(" ");
  
    funcList.push([value, funcId, eventType]);
    return str + ` data-func-id=${funcId}`;
  }
  
private _handleHTMLElement(value: HTMLElement, nodeList: Map<number, HTMLElement>, str: string): string {
    const nodeId = nodeList.size;
    nodeList.set(nodeId, value);
    return str + `<div id="custom-id-${nodeId}"></div>`;
  }

 

매핑 처리를 위한 속성을 추가하는 함수입니다.

앞서 설명드렸던 것처럼, 하위 DOM 요소를 그대로 innerHTML에 사용하면 안 되며 예외 처리가 필요합니다.

즉, id 값과 해당 DOM 요소를 매핑한 후, 나중에 id 값을 기준으로 그 위치에 DOM 요소를 교체하는 방식으로 처리합니다.

 

마찬가지로 함수도 비슷하게 동작합니다.

하지만, 함수는 이벤트 바인딩을 위해 사용합니다.

그래서 click, change, focus, submit 등 다양한 이벤트 타입을 파싱해야 합니다.

 

코드를 통해 살펴봅시다.

// change 이벤트 함수
<input change=${(e: Event) => this.changeBorrowRobotEvent(e)} type="number" id="passenger-count" name="passenger-count" min="0" max="5" required>

// click 이벤트 함수
<button click=${() => this.props.func()} class="submit-btn">뒤로가기</button>

 

 

여기서 태그드 리터럴을 사용합니다. 첫 번째 이벤트 함수 기준으로

  • string     -      ['input change=' ,  ' type="number   ....']
  • value      -       [(e: Event) => this.changeBorrowRobotEvent(e)] 

와 같이 파라미터로 전달됩니다.

 

여기서 'input change='를 보게 되면 띄어쓰기 기준으로 항상 마지막에=을 포함한 문자열이 이벤트 타입이라는 것을 유추할 수 있습니다.

 

따라서 해당 값을 추출하여 추후 EventBind 함수에서 이벤트 바인딩 작업을 수행합니다.

 

 


 

 

Step 3.

private eventBind(wrapEl: HTMLElement, funcList: [Function, number, string][]) {
    funcList.forEach(([func, funcId, eventType]) => {
      
      const funcEl = wrapEl.querySelector(`[data-func-id="${funcId}"]`) as HTMLElement;
      // console.log(funcEl, eventType, func);
      funcEl.addEventListener(eventType, (event) => {
        func(event);  
      });
      funcEl.removeAttribute("data-func-id");
    });
  }

위에서 설명드렸던 데로 추출한 event Type을 바인딩 하는 작업입니다. 
그리고 매핑을 위해 임시로 속성을 추가하였는데 실제 DOM 적용하기 전에 속성을 삭제해줍니다.

 

 

 


 

 

 

${}로 감싸진 표현식에 배열이 올 땐 어떨까요?

저는 최상위 임시 태그를 생성한 후, 해당 태그의 자식으로 배열 원소 값을 추가하는 방식으로 해결했습니다.

private _mergeArray = (value: unknown) => {
    if (Array.isArray(value)) {
      const divEl = document.createElement('div') as HTMLDivElement;
      divEl.classList.add('isArray');

      value.forEach(item => {
        const htmlElement = this._parseValue(item);
        divEl.appendChild(htmlElement); 
      });
  
      return divEl;
    }
  
    return value;
  };

먼저 isArray 클래스 속성을 가진 최상위 임시 태그를 생성한 후, 해당 태그에 자식 요소를 추가하는 방식으로 진행했습니다.

 

removeArray() {
    const isArray = this._element?.querySelector('.isArray');
    if (!isArray) return;

    const dropdownContainer = isArray.parentNode as HTMLElement | null;
    if (!dropdownContainer) return;

    Array.from(isArray.children).forEach(child => dropdownContainer.appendChild(child));

    dropdownContainer.appendChild(isArray);
    isArray.remove();
}

이 함수는 isArray 클래스를 가진 최상위 요소를 찾아, 해당 요소의 자식들을 부모 컨테이너로 이동시키고, 최종적으로 isArray 요소를 DOM에서 제거하는 동작을 수행합니다.

 

 

 


 

 

결과

그 결과, 리액트처럼 손쉽게 이벤트를 바인딩하고, map을 활용하여 컴포넌트를 생성할 수 있습니다.