UI5

openStart 열고 안 닫으면? — UI5 렌더러 함정 #shorts #SAP #UI5

▶ YouTube에서 보기

1. 이 글에서 다루는 내용과 목표

UI5 커스텀 컨트롤을 만들 때 가장 많은 사고가 나는 곳이 바로 렌더러(renderer)입니다. 화면에는 그럭저럭 나오는데 리렌더링 시 깨지거나, 스크린리더가 읽지 못하거나, 목록에 100개만 놓아도 느려지는 문제 대부분이 렌더러 작성 습관에서 시작됩니다. 이 글은 실무에서 자주 마주치는 렌더러 실수 6가지를 원인부터 해결 코드까지 단계별로 정리합니다.

  • Semantic Rendering(apiVersion 2)의 동작 원리를 이해한다
  • openStart/close 짝 맞추기와 컨트롤 데이터 연결 규칙을 익힌다
  • DOM 직접 조작·비즈니스 로직 혼입을 걷어내는 방법을 안다
  • 접근성 속성과 XSS 인코딩을 렌더러에 반영한다
  • 불필요한 invalidate를 줄여 리렌더링 성능을 확보한다

2. 읽기 전에 갖추면 좋은 배경

JavaScript(ES6 모듈 문법 포함)와 UI5의 MVC 구조, XML 뷰에서 컨트롤을 선언하는 방법을 알고 있다고 가정합니다. sap.ui.core.Control을 상속해 프로퍼티와 이벤트를 정의해 본 경험이 있다면 예제를 그대로 따라올 수 있습니다.

3. 사용한 버전

이 글의 코드는 SAPUI5/OpenUI5 1.120 LTS 이상을 기준으로 작성했으며, 렌더러는 모두 apiVersion: 2(Semantic Rendering)를 사용합니다. 1.71 이후라면 대부분 동일하게 동작하지만, DOM 패칭 최적화는 최신 버전에서 더 안정적입니다. 로컬 개발에는 UI5 Tooling(@ui5/cli 3.x)과 Node.js 18+ 조합이 일반적으로 권장됩니다. 예제 컨트롤은 창고 관리 화면에서 자재 상태를 표시하는 InventoryStatusBadge라는 가상의 커스텀 컨트롤로 진행합니다.

4. 핵심 개념: 렌더러는 "그리는 함수"가 아니라 "설명서"다

렌더러를 이해하는 가장 쉬운 비유는 조립 설명서입니다. 개발자가 렌더러에서 하는 일은 브라우저 DOM을 직접 조립하는 것이 아니라, "이 컨트롤은 이런 구조여야 한다"는 설명서를 RenderManager에게 전달하는 것입니다. 실제 조립(그리고 재조립)은 프레임워크가 담당합니다.

과거의 문자열 방식(rm.write("<div>..."))은 설명서가 아니라 완성품 사진을 통째로 넘기는 방식이었습니다. 프레임워크는 내용을 이해하지 못하므로 프로퍼티 하나만 바뀌어도 DOM 전체를 갈아엎어야 했습니다. 반면 apiVersion: 2의 Semantic Rendering은 openStart → attr/class/style → openEnd → (내용) → close라는 의미 단위 API로 구조를 전달하기 때문에, 리렌더링 시 이전 구조와 비교해 바뀐 속성만 골라 패치(DOM Patching)할 수 있습니다. 포커스가 유지되고, 깜빡임이 줄고, 성능이 좋아지는 이유가 여기에 있습니다.

이 구조에서 파생되는 규칙이 곧 실수 방지 규칙입니다.

  • 렌더러는 순수 함수여야 한다 — 같은 컨트롤 상태면 항상 같은 출력. 모델 조회, 이벤트 등록, 계산 로직은 컨트롤 쪽 책임
  • 루트 요소의 openStart에는 반드시 컨트롤 인스턴스를 넘겨 ID와 내부 메타데이터를 연결한다
  • 여는 호출과 닫는 호출은 태그 이름까지 정확히 짝을 맞춘다
  • 렌더러가 만든 DOM을 밖에서 직접 수정하지 않는다 — 다음 리렌더링에서 전부 사라진다

5. 실전 예제: 실수를 고쳐가며 완성하는 3단계

1단계 — 기본 골격과 흔한 실수 3가지 바로잡기

먼저 자주 보이는 잘못된 렌더러입니다.

// 잘못된 예: 문자열 조립 + 컨트롤 데이터 누락 + 태그 짝 불일치
renderer: function (rm, oControl) {
  rm.write("<div class='invBadge'>");        // (1) 구식 API
  rm.write(oControl.getStatusText());            // (2) 인코딩 없음 → XSS 위험
  rm.write("</span>");                      // (3) div로 열고 span으로 닫음
}

같은 컨트롤을 Semantic Rendering으로 올바르게 작성하면 다음과 같습니다.

sap.ui.define(["sap/ui/core/Control"], function (Control) {
  "use strict";
  return Control.extend("acme.warehouse.control.InventoryStatusBadge", {
    metadata: {
      properties: {
        statusText: { type: "string", defaultValue: "" },
        state: { type: "string", defaultValue: "InStock" } // InStock|Low|OutOfStock
      }
    },
    renderer: {
      apiVersion: 2,                       // Semantic Rendering 활성화
      render: function (rm, oControl) {
        rm.openStart("div", oControl);     // 컨트롤 연결: ID·data-* 자동 처리
        rm.class("acmeInvBadge");
        rm.class("acmeInvBadge" + oControl.getState());
        rm.openEnd();
        rm.text(oControl.getStatusText()); // text()가 자동 인코딩
        rm.close("div");                   // 태그 이름까지 짝 맞춤
      }
    }
  });
});

openStart("div", oControl)처럼 두 번째 인자로 컨트롤을 넘기지 않으면 oControl.getDomRef()가 null을 반환하고 리렌더링 시 기존 DOM을 찾지 못해 요소가 중복 생성되는 전형적인 버그가 발생합니다.

2단계 — 실무 시나리오: 자식 렌더링, 접근성, 방어 로직

실무에서는 뱃지 안에 아이콘 aggregation을 넣고, 스크린리더 지원과 잘못된 입력에 대한 로깅까지 필요합니다. 여기서 자주 나오는 실수가 두 가지입니다. 자식 컨트롤을 rm.renderControl() 대신 직접 그리려다 실패하는 것, 그리고 접근성 속성을 통째로 누락하는 것입니다.

renderer: {
  apiVersion: 2,
  render: function (rm, oControl) {
    var sText = oControl.getStatusText();
    var sState = oControl.getState();

    // 방어 로직은 최소화하되, 계약 위반은 로그로 남긴다
    if (["InStock", "Low", "OutOfStock"].indexOf(sState) === -1) {
      sap.ui.require("sap/base/Log").warning(
        "InventoryStatusBadge: unknown state '" + sState + "', fallback to InStock",
        oControl.getId(), "acme.warehouse");
      sState = "InStock";
    }

    rm.openStart("div", oControl);
    rm.class("acmeInvBadge").class("acmeInvBadge" + sState);
    rm.attr("role", "status");                    // 상태 변경을 보조기술에 알림
    rm.accessibilityState(oControl, {             // labelledby/describedby 자동 처리
      label: sText || undefined
    });
    rm.openEnd();

    var oIcon = oControl.getAggregation("icon");  // 0..1 aggregation
    if (oIcon) {
      rm.renderControl(oIcon);                    // 자식은 반드시 renderControl로
    }

    rm.openStart("span", oControl.getId() + "-txt"); // 내부 요소는 접미사 ID
    rm.class("acmeInvBadgeText");
    rm.openEnd();
    rm.text(sText);
    rm.close("span");

    rm.close("div");
  }
}

포인트 세 가지입니다. 첫째, 자식 컨트롤은 renderControl()에 위임해야 자식의 렌더러·이벤트·생명주기가 정상 동작합니다. 둘째, 내부 요소 ID는 getId() + "-txt"처럼 접미사 규칙을 지켜야 충돌이 없습니다. 셋째, rm.accessibilityState()ariaLabelledBy 같은 연관관계를 일반적으로 권장되는 방식으로 풀어주므로 aria 속성을 수작업으로 조립하는 실수를 줄여줍니다.

3단계 — 프로덕션: 성능·보안·테스트

렌더러 자체가 완성돼도 컨트롤 로직이 잘못되면 성능이 무너집니다. 대표 사례가 setter마다 invalidate()(전체 리렌더링)를 유발하는 패턴입니다. 텍스트 한 글자 바뀔 때마다 DOM 트리를 다시 그릴 필요는 없습니다.

// 컨트롤 쪽: 가벼운 변경은 리렌더링 없이 DOM만 갱신
setStatusText: function (sText) {
  this.setProperty("statusText", sText, true); // true = suppressInvalidate
  var oTxt = this.getDomRef("txt");            // 접미사 ID 덕분에 조회 가능
  if (oTxt) {
    oTxt.textContent = sText;                  // textContent는 인코딩 불필요
  }
  return this;
}

단, 이 최적화는 렌더러 출력과 DOM 갱신 결과가 항상 일치할 때만 안전합니다. 구조가 바뀌는 변경(state 변경으로 클래스 교체 등)은 그냥 기본 setter에 맡겨 리렌더링하는 편이 유지보수에 유리합니다. 보안 측면에서는 rm.text()rm.attr()가 값을 인코딩해 주지만, 부득이 unsafeHtml()을 써야 한다면 sap/base/security/encodeXML로 직접 인코딩한 값만 넣어야 합니다.

렌더러 검증은 QUnit으로 자동화합니다.

QUnit.test("리렌더링 후에도 DOM 구조가 유지된다", async function (assert) {
  var oBadge = new InventoryStatusBadge({ statusText: "재고 부족", state: "Low" });
  oBadge.placeAt("qunit-fixture");
  await nextUIUpdate();                        // sap/ui/test/utils/nextUIUpdate

  assert.ok(oBadge.getDomRef(), "루트 DOM이 컨트롤과 연결됨");
  assert.strictEqual(oBadge.getDomRef("txt").textContent, "재고 부족");

  oBadge.setState("OutOfStock");
  await nextUIUpdate();
  assert.ok(oBadge.$().hasClass("acmeInvBadgeOutOfStock"), "상태 클래스 패치 확인");
  oBadge.destroy();
});

6. 자주 막히는 지점과 삽질 노트

Q1. 화면에 아무것도 안 나오고 콘솔에 "cannot be rendered" 류 오류가 납니다. 대부분 renderer를 정의하지 않았거나, apiVersion: 2를 선언하고도 rm.write() 같은 구식 API를 섞어 쓴 경우입니다. Semantic Rendering에서는 구식 API 호출이 허용되지 않으므로 전부 openStart/attr/class/openEnd/text/close로 통일해야 합니다.

Q2. onAfterRendering에서 붙인 클래스나 jQuery로 바꾼 스타일이 자꾸 사라집니다. 리렌더링이 일어나면 렌더러의 출력이 진실이 되고, 바깥에서 직접 만진 DOM 변경은 사라집니다. 해결책은 그 상태를 프로퍼티로 승격해 렌더러가 그리게 만드는 것입니다. onAfterRendering은 서드파티 라이브러리 초기화처럼 정말 DOM이 필요한 작업에만 쓰고, 등록한 리스너는 onBeforeRendering이나 exit에서 반드시 해제해야 메모리 누수를 막을 수 있습니다.

Q3. 리렌더링할 때마다 입력 포커스가 날아갑니다. 루트 openStart에 컨트롤을 안 넘겼거나, 렌더링 결과가 호출마다 달라져(예: 렌더러 안에서 Date.now() 기반 ID 생성) DOM 패칭이 실패하는 경우입니다. 렌더러를 순수 함수로 유지하면 대부분 해결됩니다.

Q4. 목록에 컨트롤 수백 개를 넣으니 스크롤이 버벅입니다. setter가 부모까지 invalidate시키고 있는지 확인하세요. 텍스트류 변경은 suppressInvalidate + 부분 DOM 갱신, 구조 변경만 리렌더링으로 분리하는 것이 일반적인 처방입니다.

7. 더 파볼 주제

렌더러 기본기를 잡았다면 다음 주제로 확장해 보세요. 컨트롤에 드래그·리사이즈 같은 상호작용을 붙일 때는 이벤트 위임과 ontap 계열 핸들러 규칙을, 테마 대응이 필요하면 LESS 기반 테마 파라미터와 sap/ui/core/theming/Parameters를, 대량 데이터 표시라면 aggregation 바인딩과 가상화 전략을 살펴보는 순서가 자연스럽습니다. 기존 표준 컨트롤을 조금만 바꾸고 싶다면 처음부터 만들지 말고 sap.m 컨트롤 확장(extend + 렌더러 재사용)을 먼저 검토하는 것이 권장됩니다.

8. 핵심 한 줄

UI5 커스텀 컨트롤 렌더러는 DOM을 직접 그리는 자리가 아니라 구조를 선언하는 자리다 — Semantic Rendering API로 순수하게 구조만 선언하고, 상태 변경·이벤트·로직은 전부 컨트롤 쪽으로 넘기면 리렌더링 버그와 성능 문제 대부분이 사라진다.

댓글 0

아직 댓글이 없습니다.