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

Gerald Monaco
Gerald Monaco

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

안타깝게도 애플리케이션이나 라이브러리가 이러한 새로운 기능을 모두 최대한 활용하지 못하게 하는 패턴이 하나 있습니다. 바로 기본 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에 커밋한 후에 브라우저에서만 실행되기 때문입니다.

브라우저 전용 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에 써야 하는 경우가 있습니다. DOM이 일관된 상태임을 확신할 수 있으므로 afterRender가 이 작업에 적합합니다. afterRenderafterNextRenderphaseEarlyRead, Read 또는 Write를 허용합니다. 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 서버 측 렌더링에는 사용자에게 우수한 환경을 더 쉽게 제공할 수 있도록 하는 것을 목표로 하는 여러 가지 새롭고 흥미로운 개선사항이 예정되어 있습니다. 앞서 말씀드린 팁이 여러분의 애플리케이션과 라이브러리에서 최대한 활용되는 데 도움이 되기를 바랍니다.