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

Gerald Monaco
Gerald Monaco

지난 한 해 동안 Angular는 하이드레이션지연 가능한 뷰와 같은 많은 새로운 기능을 제공하여 개발자가 Core Web Vitals를 개선하고 최종 사용자에게 탁월한 경험을 보장하는 데 도움이 되었습니다. 스트리밍 및 부분 수화 등 이 기능을 기반으로 하는 서버 측 렌더링 관련 추가 기능에 대한 연구도 진행 중입니다.

불행히도, 한 가지 패턴으로 인해 애플리케이션이나 라이브러리가 앞으로 새롭게 등장한 기능을 모두 활용하지 못하게 됩니다. 바로 기본 DOM 구조를 수동으로 조작하는 것입니다. Angular를 사용하려면 구성 요소가 서버에 의해 직렬화된 시점부터 브라우저에서 하이드레이션될 때까지 DOM의 구조가 일관적이어야 합니다. 하이드레이션 전에 ElementRef, Renderer2 또는 DOM API를 사용하여 DOM에서 노드를 수동으로 추가, 이동 또는 삭제하면 불일치가 발생하여 이러한 기능이 작동하지 않을 수 있습니다.

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

수동 DOM 조작 피하기

수동 DOM 조작으로 인해 발생하는 문제를 피하는 가장 좋은 방법은 당연히 가능한 한 이러한 문제를 완전히 피하는 것입니다. Angular에는 DOM의 대부분의 측면을 조작할 수 있는 API와 패턴이 내장되어 있습니다. DOM에 직접 액세스하는 것보다 이러한 API와 패턴을 사용하는 것이 좋습니다.

구성요소의 자체 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에 커밋한 후에 브라우저에서만 실행되기 때문입니다.

브라우저 전용 JavaScript 실행

경우에 따라 브라우저에서만 작동하는 라이브러리나 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 서버 측 렌더링에 새롭고 흥미로운 개선사항이 많이 도입될 예정입니다. 앞서 말씀드린 팁이 여러분의 애플리케이션과 라이브러리에서 최대한 활용되는 데 도움이 되기를 바랍니다.