Angular SSR로 DOM에 안전하게 액세스

제럴드 모나코
제럴드 모나코

지난 1년 동안 Angular는 하이드레이션지연 가능한 뷰와 같은 여러 새로운 기능을 제공하여 개발자가 코어 웹 바이탈을 개선하고 최종 사용자에게 탁월한 경험을 보장하는 데 도움을 주고 있습니다. 스트리밍 및 부분 수분 공급과 같이 이 기능을 기반으로 하는 서버 측 렌더링 관련 추가 기능에 대한 연구도 진행 중입니다.

아쉽게도 한 가지 패턴으로 인해 애플리케이션 또는 라이브러리가 새로 출시되는 이러한 모든 새 기능을 최대한 활용하지 못하게 될 수 있는데, 바로 기본 DOM 구조의 수동 조작입니다. Angular에서는 서버에서 구성 요소가 직렬화될 때부터 브라우저에서 하이드레이션될 때까지 DOM의 구조가 일관되게 유지되어야 합니다. 하이드레이션 전에 ElementRef, Renderer2 또는 DOM API를 사용하여 DOM에서 수동으로 노드를 추가, 이동 또는 삭제하면 이러한 기능이 작동하지 못하게 하는 불일치가 발생할 수 있습니다.

하지만 모든 수동 DOM 조작 및 액세스에 문제가 있는 것은 아니며 필요할 때가 있습니다. DOM을 안전하게 사용하기 위한 핵심은 DOM의 필요성을 가능한 한 최소화하고 사용을 가능한 한 오래 연기하는 것입니다. 다음 가이드라인에서는 이를 실현하고 Angular의 새로운 기능과 앞으로 출시될 기능을 모두 최대한 활용할 수 있는 범용성과 미래 경쟁력이 있는 Angular 구성 요소를 빌드하는 방법을 설명합니다.

수동 DOM 조작 방지

수동 DOM 조작으로 인해 발생하는 문제를 피하는 가장 좋은 방법은 당연히 가능한 한 이러한 문제를 피하는 것입니다. Angular에는 DOM의 대부분의 측면을 조작할 수 있는 기본 제공 API와 패턴이 있습니다. DOM에 직접 액세스하는 대신 이러한 요소를 사용하는 것을 선호합니다.

구성요소의 자체 DOM 요소 변형

구성요소나 지시어를 작성할 때 예를 들어 래퍼 요소를 타겟팅하거나 도입하기보다는 클래스, 스타일 또는 속성을 추가하려면 호스트 요소 (즉, 구성요소나 지시어의 선택기와 일치하는 DOM 요소)를 수정해야 할 수 있습니다. ElementRef에 도달하여 기본 DOM 요소를 변경하고 싶은 마음이 들 수 있습니다. 대신 호스트 결합을 사용하여 값을 표현식에 선언적으로 결합해야 합니다.

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

HTML의 데이터 결합과 마찬가지로, 예를 들어 속성과 스타일에 결합하고 'true'를 필요에 따라 Angular가 값을 자동으로 추가하거나 삭제하는 데 사용할 다른 표현식으로 변경할 수 있습니다.

경우에 따라 를 동적으로 계산해야 합니다. 값의 집합 또는 맵을 반환하는 신호나 함수에 결합할 수도 있습니다.

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

더 복잡한 애플리케이션에서는 ExpressionChangedAfterItHasBeenCheckedError을 피하기 위해 DOM을 수동으로 조작하고 싶을 수 있습니다. 대신 이전 예에서처럼 값을 신호에 바인딩할 수 있습니다. 이 작업은 필요에 따라 수행할 수 있으며 전체 코드베이스에서 신호를 채택할 필요가 없습니다.

템플릿 외부에서 DOM 요소 변형

DOM을 사용하여 일반적으로 액세스할 수 없는 요소(예: 다른 상위 또는 하위 구성요소에 속하는 요소)에 액세스하려고 할 수 있습니다. 그러나 이렇게 하면 오류가 발생하기 쉽고 캡슐화를 위반하며 향후 이러한 구성요소를 변경하거나 업그레이드하기 어렵습니다.

대신 구성요소에서 다른 모든 구성요소를 블랙 박스로 고려해야 합니다. 시간을 들여 동일한 애플리케이션 또는 라이브러리 내에서도 다른 구성요소가 구성요소의 동작 또는 모양과 상호작용하거나 맞춤설정해야 하는 경우와 위치를 고려한 다음 이를 위한 안전하고 문서화된 방법을 노출합니다. 단순한 @Input@Output 속성이 충분하지 않을 때 하위 트리에서 API를 사용할 수 있도록 하려면 계층적 종속 항목 삽입과 같은 기능을 사용하세요.

이전에는 <body>의 끝이나 일부 다른 호스트 요소의 끝부분에 요소를 추가한 다음 거기에서 콘텐츠를 이동하거나 프로젝션하여 모달 대화상자나 도움말과 같은 기능을 구현하는 것이 일반적이었습니다. 하지만 요즘에는 템플릿에서 간단한 <dialog> 요소를 대신 렌더링할 수 있습니다.

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

수동 DOM 조작 연기

이전 가이드라인을 사용하여 직접적인 DOM 조작과 액세스를 가능한 한 최소화한 후에도 피해야 할 부분이 남아 있을 수 있습니다. 이러한 경우 최대한 오래 연기하는 것이 중요합니다. afterRenderafterNextRender 콜백은 이를 처리하는 좋은 방법입니다. Angular가 변경사항을 확인하고 DOM에 커밋한 후에 브라우저에서만 실행되기 때문입니다.

브라우저 전용 자바스크립트 실행

경우에 따라 브라우저에서만 작동하는 라이브러리나 API가 있을 수 있습니다 (예: 차트 라이브러리, 일부 IntersectionObserver 사용 등). 브라우저에서 실행 중인지 또는 서버에서 동작을 스텁 처리하는 대신 조건부로 확인하는 대신 afterNextRender를 사용하면 됩니다.

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

맞춤 레이아웃 실행

도움말 위치 지정과 같이 타겟 브라우저에서 아직 지원하지 않는 레이아웃을 실행하기 위해 DOM을 읽거나 써야 할 수도 있습니다. DOM이 일관된 상태에 있음을 확신할 수 있으므로 afterRender는 이에 탁월한 선택입니다. afterRenderafterNextRenderEarlyRead, Read 또는 Writephase 값을 허용합니다. DOM 레이아웃을 작성한 후에 읽으면 브라우저가 레이아웃을 동기식으로 다시 계산하게 되어 성능에 심각한 영향을 미칠 수 있습니다 (레이아웃 스래싱 참조). 따라서 로직을 올바른 단계로 신중하게 분할하는 것이 중요합니다.

예를 들어 페이지의 다른 요소와 관련하여 도움말을 표시하려는 도움말 구성요소는 두 단계를 사용할 가능성이 높습니다. 먼저 EarlyRead 단계를 사용하여 요소의 크기와 위치를 가져옵니다.

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

그러면 Write 단계에서 이전에 읽은 값을 사용하여 실제로 도움말 위치를 변경합니다.

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

로직을 올바른 단계로 분할함으로써 Angular는 애플리케이션 내의 다른 모든 구성 요소 전반에 걸쳐 DOM 조작을 효과적으로 일괄 처리할 수 있으므로 성능 영향이 최소화됩니다.

결론

사용자에게 훌륭한 환경을 더 쉽게 제공할 수 있도록 하는 것을 목표로 Angular 서버 측 렌더링에 대해 새롭고 흥미로운 개선사항이 많이 도입될 예정입니다. 앞서 다룬 팁이 여러분이 애플리케이션과 라이브러리에서 최대한 활용하는 데 도움이 되기를 바랍니다.