Angular 에러 “Expression has changed after it was checked” 해결방법

ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ‘ngIf: null’. Current value: ‘ngIf: Cannot push this media: Audio files are not supported.’.

혹시 지금 브라우저 콘솔에서 위와 같은 에러를 보고 계신가요? 그렇다면 제대로 찾아오신겁니다. 지금부터 이 에러가 왜 나는지 어떻게 해결하는지에 대해서 자세히 설명해 드릴게요.

본 포스팅에서는, 아래와 같은 내용을 다룰것입니다.

  • Expression has changed에러가 뭔지, 왜 일어나는지 이해하기
  • Angular Development mode에 대해서 알아보기
  • Template단에서 일어나는 에러 디버깅하는 기술에 대해서
  • Expression has changed에러 고치는 방법

이 에러에 대한 원인규명과 해결방법을 설명하기에 앞서 우선, 에러가 나는 상황을 발생시키고, 디버깅을 하는 방법을 먼저 설명하고 들어갈게요.

이 에러가 나는 상황을 만들어볼게요

이런 종류의 에러는 보통 로직에 들어가기도 전에 나기때문에 이런 에러를 만나면 어떻게 디버깅을 해야할지 참으로 난감하지 않을수가 없습니다. 보통 이런에러는 템플릿에 좀더 복잡한 표현을 구사한경우, 또는 AfterViewInit같은 lifecycle hook을 실행했을때 발생하기도 합니다. 아래 코드는 매우 단순해보이지만 Expression has changed에러를 발생시키는 코드입니다.

<div class="course">
    <div class="spinner-container" *ngIf="dataSource.loading$ | async">
        <mat-spinner></mat-spinner>
    </div>
    <mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource">
        ....
    </mat-table>
    <mat-paginator [length]="course?.lessonsCount" [pageSize]="3"
                   [pageSizeOptions]="[3, 5, 10]"></mat-paginator>
</div>

위의 예제는 Angular Material Data Table을 이용해서 데이타를 가져와서 보여주고, 데이타가 많은 경우 페이지단위로 나눠서 보여주는 코드입니다. 그리고, 추가로 데이타를 가져오는 동안 로딩아이콘을 보여줍니다.

이 코드를 실행하면 아래와 같이 테이블에 데이타가 나열됩니다.

Material Data Table

그리고 다음 페이지를 클릭하면 데이타를 가져오는동안 로딩아이콘을 아래와 같이 보여줍니다.

Material Data Table

이 코드에서 왜 Expression has changed에러가 나는지 확인하기 위해서 아래의 Component코드를 보실게요. 참고로, 이 Component는 실제코드가 아니고 이해를 돕기위해 간략하게 필요한 코드만 가져온거라서 실행은 안되실거에요.

@Component({
    selector: 'course',
    templateUrl: './course.component.html'
})
export class CourseComponent implements AfterViewInit {

    @ViewChild(MatPaginator) paginator: MatPaginator;
       
    ngAfterViewInit() {
        this.paginator.page
            .pipe(
                startWith(null),
                tap(() => this.dataSource.loadLessons(...))
            ).subscribe();
    }
}

위의 컴포넌트는 AfterViewInit를 구현한 클래스니까 ngAfterViewInit()함수는 템플릿 View가 초기화된 후에 호출되겠죠? 그리고 page체인의 맨끝에 붙은 subscribe()함수로 인해 page변수는 관찰대상이 되고, paginator@ViewChild()를 사용할수 있도록 선언했어요.

그러면, 사용자가 페이지를 변경할때마다, 이벤트가 발생하고, dataSource.loadLessons()를 호출하게 될거에요.

참고로 위의 코드에서 보시는 tap 연산자는 기존 RXJS의 do연산자의 새로운 버전으로 파이프와 함께 사용이 가능합니다.

page가 관찰대상으로 선언이되었기 때문에 바로 실행이 안되서, startWith()를 이용해서 초기값을 주도록 했어요. 이로인해 첫번째 페이지에 해당하는 데이타가 바로 로딩이 되겠죠? 이거 없으면 사용자가 페이지를 클릭할때까지 아무것도 안보여주거든요.

아래는 데이타 소스 코드입니다.

export class LessonsDataSource implements DataSource<Lesson> {
    private loadingSubject = new BehaviorSubject<boolean>(false);
    public loading$ = this.loadingSubject.asObservable();
    loadLessons(...) {
        this.loadingSubject.next(true);
        ... load new data page from the backend
    }    
}

여기서도 보시듯이, loading$변수는 관찰대상이고, loadLessons()에서 loadingSubject에 새로운 값을 할당함으로써, loading$의 값을 변경합니다.

말씀드린대로,loading$는 관찰대상이기때문에 ngIf의 조건으로 썼다면, 로딩아이콘을 보여줬다 감췄다 할수 있게 되는거죠. 이렇게 에러를 발생시키는 코드가 완료되었습니다.

에러메세지

위의 코드는 아래의 에러를 발생시킵니다.

CourseComponent.html:13 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: ‘[object Object]’. Current value: ‘true’. at viewDebugError (core.js:9515) at expressionChangedAfterItHasBeenCheckedError (core.js:9493) at checkBindingNoChanges (core.js:9662)

이 에러는 템플릿의 Expression에 문제가 생겼다고 말하고 있습니다. 그런데 도대체 어떤 Expression에 무슨 문제가 있다는건지 그걸 모르겠다는 말이죠.

자바스크립트 디버거 사용방법

자바스크립트 코드에서 에러가 났다면, Angular에서는 코드의 어디서나 debugger;를 추가해서 브레이크 포인트를 설정할수 있지만, Template에서는 그 방법이 통하지 않습니다. 지금부터 크롬 브라우저의 자바스크립트 디버거를 이용해서 Template에러가 난 위치를 찾아내는 방법을 알려드릴거에요. 이 방법은 Template에서 발생하는 에러를 디버깅하는데 매우 유용한 팁이 될거에요.

지금 아마도 여러분들은 DevTool을 열고 콘솔의 에러를 보고 계실거에요. 에러가 나는 함수명들 중에 가장 첫번째것을 클릭합니다. 여기서는 at viewDebugError (core.js:9515)의 링크를 클릭하시는 거에요. 그러면 아래와 같은 코드가 나옵니다. 그 함수안의 코드의 첫번째 라인의 맨앞에 번호를 클릭하여 그곳을 브레이크 포인트로 지정합니다.

Debugging ExpressionChangedAfterItHasBeenCheckedError

이제 화면을 새로고침하면, 해당 위치에서 코드가 딱 멈출거에요. 그러면 거기서부터는 위로 아래로 움직여가면서 변수에 할당된 값도 보고 하면서 디버깅이 가능하게 됩니다.

이제 브레이크 포인트를 설정했으면 새로 고침을 해서 문제의 코드에서 디버깅을 시작합니다. 자바스크립트 디버거에 보면 Call Stack이라는 탭이 있는데, 그걸 클릭하면, 어떤 함수가 어떤 경로로 호출이 되어 현재 지점까지 이르게 되었는지 아래와 같이 실행된 함수들이 순서대로 나열이 되어있습니다.

Debugging ExpressionChangedAfterItHasBeenCheckedError

그중 여러분이 만든 템플릿의 이름이 보인다면 그걸 클릭해서 그쪽으로 거슬러 올라갑니다. 위의 템플릿 코드에서 보듯이, ngIf에 로딩관련 변수의 값을 할당하는 과정에서 문제가 생겼다는걸 알수 있습니다. 그럼 이제 문제가 되는 Expression은 찾으신거에요.

에러메세지 이해하기

문제의 템플릿 코드 ngIf는 아무리 봐도 크게 문제가 없어보이는데, 도대체 뭐가 문제가 되는 건지 도무지 감이 잡히지 않습니다. 라고 생각하시는 분들을 위해 코드가 실행되는 순간 어떤일이 벌어지고 어느부분이 문제가 되는지를 순차적으로 되짚어 볼게요.

  • 해당 ngIf문에 할당되는 loading$의 초기값은 false입니다. 화면을 열자마자 무조건 로딩아이콘을 보여줄 이유가 없기때문에 일단 로딩아이콘은 특별한 이유가 없는한 초기값은 안보여주는것으로 합니다.
  • loading$은 관찰대상으로써, 마지막으로 변경된 값이 false인 경우에는 ngIf로 인해 컨테이너 전체가 숨겨지고 아무 데이타도 로딩될수 없게됩니다.
  • Angular가 현재로써 최종정보인 loading$=false를 기준으로 화면을 구성하기 위해 준비하는동안, 그 과정에서 ngAfterViewInit이 호출됩니다.
  • ngAfterViewInit은 첫번째 페이지의 데이타를 Backend에서 가져오는 dataSource.loadLessons()을 실행합니다.
  • Backend에서 가져오는 데이타는 시간이 좀 걸립니다. 그래서 다른 서버에서 데이타를 가져올때는 비동기적(asynchronously)으로 실행이 됩니다. 누구나 Backend데이타를 가져올때는 어느정도 시간이 걸릴것을 예상합니다. 누구도 결과값을 “즉각적“으로 가져올것이라는 기대는 하지 않습니다.
  • 문제 바로 여기에 있습니다. 비동기 함수 dataSource.loadLessons()이 호출되는 순간 데이타를 가져오기 전에this.loadingSubject.next(true);를 실행하는데, 이는 loading$변수의 값을 true로 “즉각적“으로 바꾸게 됩니다.

바로 이 새로운 loading$의 값 true가 이 에러를 만들어내는 주범입니다.

자 그러면 이제, 왜 View construction이 진행되는 동안 ngIf에 할당되는 flag의 값이 변하는것이 문제가 되는지 알아보도록 하겠습니다.

View Updates Itself 시나리오

여기서 문제점은, 바로 View를 만들어가는 과정에 있습니다. 그 과정에는 ngAfterViewInit이 실행되는 문제가 포함되어있구요. 또한, 그 과정에서 우리가 처음에 보여주려고 시도했던 데이타를 중간에 변경해버리는 상황도 문제의 일부라고 볼수 있습니다. 다시한번 정리해볼게요:

  • 로딩아이콘은 초기값 flase로 시작합니다.
  • 그로인해 우리는 로딩아이콘이 없는 화면구성을 시도합니다.
  • 화면구성의 일부과정으로 ngAfterViewInit가 비동기적으로 실행이 됩니다.
  • ngAfterViewInit는 화면구성을 마치기도 전에 원래 의도했던 화면구성에 설정된 값을 변경해버립니다.
  • 화면구성이 완료된 시점에서 로딩아이콘의 지시자 loading$의 값은 true가 되어버립니다.
  • 이때 Angular는 처음에 지시받는대로 로딩아이콘이 안보이도록 화면구성을 완료했는데, 로딩아이콘을 보여줄지 말지 결정하는 변수는 보여주라는 값을 가지고 있는 앞뒤가 맞지 않는 상황이 생겨버립니다.

그렇다면, 이 상황에서 로딩아이콘을 보여주는 지시자 loading$의 값은 true가 되는 걸까요? 아니면 false로 봐야할까요?

지금부터 Angular Development Mode를 이용해서 이 문제를 해결할텐데, Angular Development Mode에 대해서 익숙하지 않으신 분은 여기를 참고하세요. 그러면 지금부터 어떻게 이 문제를 해결하는지 그 방법에 대해서 알아보도록 하겠습니다.

해결방법 이해하기

해결방법은 다음과 같습니다: ngAfterViewInit()에서 paginator.page를 호출하면 안됩니다. 그 이유는 paginator.page가 데이타 소스를 가져오고, 그 과정에서 로딩아이콘의 flag값을 변경해버리기 때문입니다. Angular가 초기의 설정값으로 화면구성을 마치기도 전에 설정값이 변경됨으로 인해 loading$의 값이 true인지 false인지 애매모호해지는 상황이 생겨버리는거죠.

이 문제를 해결하기 위해서는 우선 Angular가 초기설정값으로 화면구성을 마칠때까지 기다려주어야합니다. 그리고 화면구성이 완료된 이후에 데이타를 가져오도록 만들어야합니다. 데이타를 가져오는 과정의 일부가 화면구성을 설정하는 값에 영향을 미치지 않는다면 문제가 되지 않겠지만, 현재 로딩아이콘을 보여주지 않고, 보여주고 하는 그 일련의 과정들이 그 데이타를 가져오는 로직 사이에 존재하기 때문에 이런 상황 자체를 만드는 일을 하지 말아야하는거에요.

초간단 해결방법

ngAfterViewInit안의 코드를 기존에 돌고있는 자바스크립트 코드의 실행이 끝난 다음 단계에서 실행이 되도록 만드는 방법은 아래와 같습니다.

ngAfterViewInit() {
    setTimeout(() => {
        this.paginator.page
            .pipe(
                startWith(null),
                tap(() => this.dataSource.loadLessons(...))
            ).subscribe();
    });
}

코드 앞에 setTimeout하나 추가 했을 뿐인데, 이미 에러가 사라졌어요!

setTimeout()을 두번째 인자, 시간값을 주지 않고 사용하면, 해당 코드를 Javascript Virtual Machine의 다음 차례로 등록하게 되면서, 앞서 실행중인 코드가 완료될때까지 기다리게 됩니다.

RxJs를 이용한 세련된 코드

위의 기능을 모듈화한 라이브러리가 있는데 그게 바로 rxjs입니다. rxjs에서는 자바스크립트 단위업무의 완료를 기다린뒤 다음 코드를 실행하도록 하는 delay라는 기능을 pipe로 사용할수 있도록 제공합니다. 아래와 같이 실행옵션으로 pipe함수 안에 delay(0)을 함께 넣어 실행하면, setTimeout을 사용했을때보다 코드도 간소화되고 보기에도 깔끔하죠.

import { startWith, tap, delay } from 'rxjs/operators';

ngAfterViewInit() {
  this.paginator.page
      .pipe(
          startWith(null),
          delay(0),
          tap(() => this.dataSource.loadLessons(...))
      ).subscribe();
}

setTimeout이나 delay(0)가 어떻게 문제를 해결한다는 거죠?

이번에는 setTimeout을 사용한 경우에 로직이 어떻게 흘러가는지 차근히 알아볼게요.

  • 로딩아이콘의 지시자 loading$의 초기값은 false입니다. 이는 로딩아이콘을 화면에 보여주지 말라는 뜻입니다.
  • 화면구성도중에 ngAfterViewInit가 호출됩니다. 이때 setTimeout을 감지하고, 데이타소스를 가져오는 코드를 바로 실행하지 않고, Javascript VM에 다음실행할 코드로 등록합니다.
  • Angular가 화면구성을 마쳤습니다. 그리고 Javascript VM에 작업을 마쳤다고 알려줍니다.
  • 잠시후, setTimeout으로 등록했던 코드가 실행됩니다. 이때야 비로소 데이타소스를 가져오는 코드가 실행되고, 데이타를 가져오기 전에 로딩아이콘의 지시자 loading$true로 변경합니다.
  • 이미 화면구성을 마친 Angular는 관찰대상인 loading$의 값이 변경되었다는 것을 눈치채고, 화면에 로딩아이콘을 보여줍니다.

이렇게 기존 작업이 마무리 되기를 기다려줌으로 인해서 로직의 애매모호함이 없어지고 이로인해 에러메세지도 함께 사라졌습니다.

더 좋은 해결방법 ngOnInit()

문제의 쟁점은 ngAfterViewInit()는 화면구성이 완료되기 전에 호출이 된다는것, 그리고 우리가 ngAfterViewInit()startWith(null)과 함께 호출되면서 처음 한번 자동으로 그 안의 코드가 실행된다는 점, 그리고 안의 코드에서는 화면구성에 사용되었던 변수의 값을 수정한다는 것, 결정적으로 이 모든 일련의 작업들이 Angular가 화면구성을 마치기전에 일어난가는 것이었습니다.

그렇다면 startWith(null)ngAfterViewInit()에서 빼고, 처음에 한번 자동으로 실행되어야하는 코드를 ngOnInit()에서 실행하도록 합니다.

@Component({
    selector: 'course',
    templateUrl: './course.component.html'
})
export class CourseComponent implements AfterViewInit, OnInit {
    @ViewChild(MatPaginator) paginator: MatPaginator;
    ngOnInit() {
      // load the initial page
      this.dataSource.loadLessons(...);
    }
    ngAfterViewInit() {
        this.paginator.page
            .pipe(
                tap(() => this.dataSource.loadLessons(...))
            ).subscribe();
    }
}

ngOnInit()은 컴포넌트의 호출과 동시에 일어나는 Angular의 화면구성이 마무리된 이후 가장 먼저 호출되는 함수입니다. 이제 ngAfterViewInit에서는 아무런 값의 변화가 일어나지 않고, 오직 화면구성이 마친 이후에 데이타를 가져오는 코드가 실행되므로 이제 더이상 에러가 나지 않습니다.

결론

결론적으로, Angular는 로직의 완결함을 지키기 위해 Expression has changed after it was checked에러를 발생시킵니다. 이 에러를 처음 보셨다고 매우 막막하고 무섭게 여겨지셨을 수도 있지만, 이 에러를 통해서 우리는 로직을 보완할수 있게되니 우리에게 매우 유용한 에러가 아닐수 없습니다.

이 에러는 우리가 실수로 만들어내는 로직의 구멍을 찾아내어, 때로는 무한루프에 빠질수 있는 상황에서 우리를 구해주기도 합니다. 그리고 때로 이런 종류의 에러는 다시 재현하기도 쉽지 않을때가 많습니다. 이 에러가 발생하지 않는다면 우리는 로직의 구멍을 간과하게 되고, 때로 이런 부분들이 제품을 완성도를 떨어뜨리게 되는것이죠. 이 포스팅이 여러분의 Angular개발에 도움이 되셨기를 바랍니다.

Source: https://blog.angular-university.io/angular-debugging/