8. 컴포넌트 생명 주기

2020. 9. 22. 16:09재주껏 하는 Front-End/Svelte (준비중)

반응형

이번 글에서는 Svelte 컴포넌트의 생명 주기에 대해서 알아보도록 하겠다. 컴포넌트 기반의 UI 프레임워크들은 컴포넌트를 효과적으로 관리하기 위해 생명 주기를 적용하고 있으며, 생성부터 소멸까지의 과정에 필요한 작업들을 정의하기 위해 생명 주기 이벤트를 제공한다.

 

아래는 VueJS의 컴포넌트 생명 주기를 나타낸 것이다.

 

 

Svelte 역시 컴포넌트 생명 주기에 대한 이벤트들을 제공한다. 단, 다른 UI 프레임워크들과 달리 아직 많이 세분화되지 않았는데 차후에 버전이 올라가면 좀 더 세분화되지 않을까 생각한다. 아래는 Svelte의 생명 주기를 나타낸 것이다.

 

 

이제 Svelte에서 제공하는 생명 주기에 대해 알아보자.

 

onMount

 

onMount 이벤트는 컴포넌트가 DOM에 렌더링 된 후에 실행되는 이벤트로 VueJS에서는 mounted 생명 주기와 동일하다. 컴포넌트가 화면에 출력된 이후에 호출되기 때문에 생명 주기에 관련된 대부분의 작업들은 onMount 이벤트 핸들러 내부에서 수행된다. 사용 방법은 아래와 같다.

 

<script>
  import {onMount} from 'svelte'

  onMount(() => {

    // 작업 정의

  })
</script>

 

onMount 함수의 인자 값으로 콜백 함수가 전달되는 것을 확인할 수 있다. 내부적으로 onMount가 발생하면 전달된 콜백 함수를 실행하는 구조로 되어 있기 때문이며 비동기 통신이 필요한 경우에는 아래와 같이 콜백 함수에 async 키워드를 붙여 사용한다.

 

<script>

  import {onMount} from 'svelte'


  onMount(async () => {


    // 작업 정의


  })

</script>

 

사용 예제는 아래와 같다.

 

<script>
	import {onMount} from 'svelte'
	
	let photos = [];
	
	onMount(async () => {
		const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
		photos = await res.json();
	})
</script>

<style>
	.photos {
		width: 100%;
		display: grid;
		grid-template-columns: repeat(5, 1fr);
		grid-gap: 8px;
	}

	figure, img {
		width: 100%;
		margin: 0;
	}
</style>

<h1>Photo album</h1>

<div class="photos">
	{#each photos as photo}
		<figure>
			<img src={photo.thumbnailUrl} alt={photo.title}>
			<figcaption>{photo.title}</figcaption>
		</figure>
	{:else}
		<!-- this block renders when photos.length === 0 -->
		<p>loading...</p>
	{/each}
</div>

 

실행 결과는 아래와 같다.

 

 

onDestroy

 

컴포넌트가 제거되면 호출되는 이벤트로 주로 메모리에 할당된 자원들을 소거시킬 때 사용한다. 예를 들어 컴포넌트의 생성과 동시에 타이머를 이용하여 주기적으로 데이터를 요청하는 기능이 필요하다고 가정해보자. 코드는 위의 예제 코드를 참고하였다.

 

<script>
	import {onMount} from 'svelte'
	
	let photos = [];
	let timerObj = null;
	
	onMount(() => {
		timerObj = setInterval(async () => {
			photos = [];
            
			const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
			photos = await res.json();
		}, 5000)
	})
</script>

<style>
	.photos {
		width: 100%;
		display: grid;
		grid-template-columns: repeat(5, 1fr);
		grid-gap: 8px;
	}

	figure, img {
		width: 100%;
		margin: 0;
	}
</style>

<h1>Photo album</h1>

<div class="photos">
	{#each photos as photo}
		<figure>
			<img src={photo.thumbnailUrl} alt={photo.title}>
			<figcaption>{photo.title}</figcaption>
		</figure>
	{:else}
		<!-- this block renders when photos.length === 0 -->
		<p>loading...</p>
	{/each}
</div>

 

이 코드를 실행하면 5초마다 서버에 이미지를 요청하는 것을 알 수 있다. 이 상태에서 다른 페이지로 이동하거나 컴포넌트를 교체한다고 가정해보자.

 

페이지를 이동하거나 컴포넌트를 다른 것으로 교체한다면 사진 목록을 불러오는 컴포넌트는 파괴될 것이다. 하지만, 타이머 객체는 메모리에 여전히 남아있다. setInterval로 생성된 타이머 객체는 clearInterval로 초기화하지 않으면 자동으로 소멸되지 않기 때문이다.

 

이 과정을 반복하면 브라우저에 할당된 메모리가 꽉 차게 될 것이고 브라우저는 응답 없음으로 멈춰버릴 것이다. 실제 개발에서 객체를 초기화하지 않아 메모리 누수가 일어나는 경우가 매우 많다는 것을 알아두자. 이 문제를 해결하기 위해서는 컴포넌트가 파괴될 때 호출되는 onDestory 이벤트 내부에서 타이머 객체를 소멸시켜주면 된다. 아래의 코드를 확인해보자.

 

<script>
	import {onMount, onDestroy} from 'svelte'
	
	let photos = [];
	let timerObj = null;
	
	onMount(() => {
		timerObj = setInterval(async () => {
			photos = [];
			const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
			photos = await res.json();
		}, 5000)
	})
	
	onDestroy(() => {
		clearInterval(timerObj)
	})
</script>

<style>
	.photos {
		width: 100%;
		display: grid;
		grid-template-columns: repeat(5, 1fr);
		grid-gap: 8px;
	}

	figure, img {
		width: 100%;
		margin: 0;
	}
</style>

<h1>Photo album</h1>

<div class="photos">
	{#each photos as photo}
		<figure>
			<img src={photo.thumbnailUrl} alt={photo.title}>
			<figcaption>{photo.title}</figcaption>
		</figure>
	{:else}
		<!-- this block renders when photos.length === 0 -->
		<p>loading...</p>
	{/each}
</div>

 

실행 결과는 전과 동일하지만, 컴포넌트가 파괴될 때 타이머 객체도 초기화되므로 메모리 누수 문제는 해결된다. 예제와 같이 실제 프로젝트에서 메모리 누수에 관련된 문제가 빈번하므로 컴포넌트 생명 주기를 이용하여 반드시 누수가 일어나지 않도록 초기화하자.

 

beforeUpdate & afterUpdate

 

컴포넌트의 상태가 변경될 경우 발생하는 이벤트로 상태가 변경되기 전에 호출되는 beforeUpdate와 이후에 호출되는 afterUpdate가 있다. 컴포넌트가 마운트 된 이후에 상태가 변할 경우 beforeUpdate <-> afterUpdate 이벤트가 번갈아가며 호출되는 것을 확인할 수 있다. 아래의 코드를 살펴보자.

 

<script>
	import {onMount, beforeUpdate, afterUpdate, onDestroy} from 'svelte'
	
	let photos = [];
	let timerObj = null;
	
	beforeUpdate(() => {
		console.warn('Before update call!!')
	})
	
	afterUpdate(() => {
		console.warn('After update call!!')
	})
	
	onMount(() => {
		timerObj = setInterval(async () => {
			photos = [];
			const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
			photos = await res.json();
		}, 5000)
        
        console.warn('Component mounted !!')
	})
	
	onDestroy(() => {
		clearInterval(timerObj)
	})
</script>

<style>
	.photos {
		width: 100%;
		display: grid;
		grid-template-columns: repeat(5, 1fr);
		grid-gap: 8px;
	}

	figure, img {
		width: 100%;
		margin: 0;
	}
</style>

<h1>Photo album</h1>

<div class="photos">
	{#each photos as photo}
		<figure>
			<img src={photo.thumbnailUrl} alt={photo.title}>
			<figcaption>{photo.title}</figcaption>
		</figure>
	{:else}
		<!-- this block renders when photos.length === 0 -->
		<p>loading...</p>
	{/each}
</div>

 

위의 코드를 실행하면 브라우저의 콘솔에 아래와 같이 출력되는 것을 확인할 수 있다. 특이한 점은 마운트 이벤트 전에도 beforeUpdate 이벤트가 호출된다는 것이다. 나중에 마운트 전에 수행해야 할 작업들이 있다면 beforeUpdate 이벤트와 onMount 이벤트를 사용하여 구현할 수 있을 것이다. (마치 VueJS의 created 생명 주기처럼)

 

"Before update call!!"
"Component mounted!!"
"After update call!!"
"Before update call!!"
"After update call!!"
...

 

부모 / 자식 관계에서 컴포넌트 생명 주기

 

만약 부모, 자식 관계를 가진 컴포넌트가 있는 경우에는 생명 주기가 어떻게 동작할까? 아래와 같이 컴포넌트를 구성해보자.

 

// 부모 컴포넌트

<script>
	import Child from './Child.svelte'
	import {onMount, beforeUpdate, afterUpdate, onDestroy} from 'svelte'
	
	beforeUpdate(() => {
		console.warn('Parent : Call Before Update !!')
	})
	
	afterUpdate(() => {
		console.warn('Parent : Call After Update !!')
	})
	
	onMount(() => {
		console.warn('Parent : Component mounted !!')
	})
	
	onDestroy(() => {
		console.warn('Parent : Component destroy !!')
	})
</script>

<div>
	<h1>
		Parent Component
	</h1>
	<Child/>
</div>


// 자식 컴포넌트

<script>
	import {onMount, beforeUpdate, afterUpdate, onDestroy} from 'svelte'
	
	beforeUpdate(() => {
		console.warn('Child : Call Before Update !!')
	})
	
	afterUpdate(() => {
		console.warn('Child : Call After Update !!')
	})
	
	onMount(() => {
		console.warn('Child : Component mounted !!')
	})
	
	onDestroy(() => {
		console.warn('Child : Component destroy !!')
	})
</script>

<h2>
	Child component ...
</h2>

 

위의 코드를 실행하면 아래와 같이 출력되는 것을 확인할 수 있다. 특이한 것은 소멸은 부모 컴포넌트가 먼저 제거된 후 자식 컴포넌트가 소멸되고, 생성은 부모 컴포넌트가 마운트 되기 전에 자식 컴포넌트가 먼저 마운트 된다는 것이다.

 

"Parent : Component destroy!!"
"Child : Component destroy!!"

"Parent : Call Before Update!!"
"Child : Call Before Update!!"
"Child : Component mounted!!"
"Child : Call After Update!!"
"Parent : Component mounted!!"
"Parent : Call After Update!!"

 

컴포넌트의 생명 주기 순서는 익혀두는 것이 좋다. 컴포넌트를 개발할 때 생명 주기 순서를 모를 경우, 특정 요소를 참조하지 못하거나 비동기 프로그래밍 등에서 요소를 찾지 못하는 등의 타이밍 문제들이 발생할 수 있기 때문이다.

 

Tick

 

Tick 함수는 VueJS의 $nextTick과 동일한 기능의 함수로, 컴포넌트의 상태가 DOM에 반영된 이후의 시점을 보장하는 함수다. 사용 방법은 아래와 같다.

 

<script>
  import { tick } from 'svelte';

  async function eventHandler () {

    await tick();  

    // 컴포넌트가 DOM에 마운트가 확실하게 된 이후에 수행해야 되는 작업...

  }
</script>

 

컴포넌트의 상태가 변경되더라도 실제 DOM에 반영하기까지 시간이 걸리기 때문에 DOM에 반영된 이후의 작업들이 필요하다면 Tick 함수를 사용하여 시점을 맞춰야 한다. 아래의 예제 코드를 살펴보자. 아래의 컴포넌트는 문자열을 다중 선택한 상태에서 Tab 키를 누르면 대문자로 변경하는 기능을 가진 컴포넌트이다.

 

<script>
	let text = `Select some text and hit the tab key to toggle uppercase`;

	async function handleKeydown(event) {
		if (event.key !== 'Tab') return;

		event.preventDefault();

		const { selectionStart, selectionEnd, value } = this;
		const selection = value.slice(selectionStart, selectionEnd);

		const replacement = /[a-z]/.test(selection)
			? selection.toUpperCase()
			: selection.toLowerCase();

		text = (
			value.slice(0, selectionStart) +
			replacement +
			value.slice(selectionEnd)
		);

		// this has no effect, because the DOM hasn't updated yet
		this.selectionStart = selectionStart;
		this.selectionEnd = selectionEnd;
	}
</script>

<style>
	textarea {
		width: 100%;
		height: 200px;
	}
</style>

<textarea value={text} on:keydown={handleKeydown}></textarea>

 

위의 코드를 실행하면 아래와 같다.

 

 

이 컴포넌트의 문제점은 <textarea> 내에 있는 텍스트를 마우스로 드래그한 후 탭을 누르면 문자열은 대문자로 변경되지만, 커서가 맨 끝으로 이동하는 문제점을 가지고 있다. 커서의 위치를 그대로 유지하기 위해 keyDown 핸들러 맨 밑에 아래와 같이 커서의 위치를 다시 설정하게끔 되어있지만 제대로 동작하지 않는다.

 

this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;

 

이러한 문제가 발생한 이유는 위에서 설명한 대로 DOM에 반영되기 전에 커서 위치를 업데이트하기 때문이다. DOM에 반영되기 전 커서 위치를 업데이트했지만, DOM에 반영된 이후에 브라우저는 새로운 값으로 바뀐 것을 알고 커서를 맨 끝으로 다시 이동시키는 것이다. 실제로 이러한 경우는 프로젝트에서 매우 빈번하게 일어난다.

 

아래와 같이 Tick 함수를 이용하여 이 기능을 제대로 동작하게끔 바꿔보자.

 

<script>
	import { tick } from 'svelte';

	let text = `Select some text and hit the tab key to toggle uppercase`;

	async function handleKeydown(event) {
		if (event.key !== 'Tab') return;

		event.preventDefault();

		const { selectionStart, selectionEnd, value } = this;
		const selection = value.slice(selectionStart, selectionEnd);

		const replacement = /[a-z]/.test(selection)
			? selection.toUpperCase()
			: selection.toLowerCase();

		text = (
			value.slice(0, selectionStart) +
			replacement +
			value.slice(selectionEnd)
		);

		await tick();
		this.selectionStart = selectionStart;
		this.selectionEnd = selectionEnd;
	}
</script>

<style>
	textarea {
		width: 100%;
		height: 200px;
	}
</style>

<textarea value={text} on:keydown={handleKeydown}></textarea>

 

다시 블록 선택 후 탭키를 누르면 커서가 원래 위치를 유지하는 것을 알 수 있다.

 

 

Tick 함수는 DOM이 업데이트되기 전까지 Promise Pending 상태를 유지하다가, DOM에 반영되는 순간 Promise를 리턴하는 방법을 사용하여 DOM에 실제 반영되는 타이밍을 정확하게 체크할 수 있다. 아마 VueJS든지 Svelte던지 간에 시점 문제가 발생하면 80% 정도는 Tick 함수로 해결할 수 있을 것이다.

 

오늘은 여기까지이며 다음 글에서는 반응형 동작의 핵심이라 할 수 있는 Store에 대해 이야기해보도록 하겠다. Svelte에 대해 궁금한 많은 사람들이 도움이 됐으면 하며 오늘은 여기까지~

반응형

'재주껏 하는 Front-End > Svelte (준비중)' 카테고리의 다른 글

10. Slot  (0) 2020.10.29
9. 스토어  (0) 2020.10.07
7. 바인딩  (0) 2020.09.14
6. 이벤트 처리  (0) 2020.09.08
5. 논리 문법 / Props  (0) 2020.09.04