4. useContext

리액트로 만든 어플리케이션은 여러개의 컴포넌트들로 이루어져 있습니다. 최상위 App 컴포넌트에서 아래로 뻗어나가는 트리 형태로 이루어져 있고, 부모 컴포넌트에서 자식 컴포넌트로 Props가 전달이 되는 구조입니다.

 

만약 컴포넌트가 매우 많이 존재하는 상태에서 특정 Props를 전달해야하는 상황이라면 전달하는 과정이 매우 복잡해 질 것이고 Props를 수정해야 한다면 복잡도는 더욱 올라갈 것입니다.

 

리액트에서는 이러한 문제점을 간편하게 해결해 줄 수 있는 Context API를 제공해 주고 있습니다. 이러한 Context API 전역적으로 사용해야 하는 데이터를 여러 컴포넌트에서 편리하게 가져와 사용할 수 있는 방법을 제공합니다.

 

context를 이용해 전역적으로 공유할 데이터를 지정하고 각 컴포넌트에서는 useContext로 해당 context를 가져와 사용할 수 있습니다. 이러한 context는 꼭 필요한 경우에만 사용해야 합니다.

 

context를 이용해 데이터를 전역적으로 사용할 수 있도록 하는 방법을 알아봅시다.

 

1. 먼저 context를 만들어줍니다.

import {createContext} from 'react';

interface MyContextType = {
	name: string;
    age: number;
    isAdult: boolean;
    setAge: Dispatch<SetStateAction<number | undefined>>;
}

const MyCOntext = createContext<MyContextType>(
	// 데이터 예시 
    // js라면 그냥 null을 넣고 
    // 원하는 데이터를 Provider를 통해 뿌려줄 때 value에 작성하면 됩니다.
    {
    	name : null,
        age : null,
        isAdult : null,
        setAge: () => {},
    }
);

 

2. 만들어준 context의 데이터를 최상위 컴포넌트에서 Provider로 뿌려줍니다.

import {MyContext} from './context/MyContext';
import Component1 from './component/Component1';

export default function App () {
	return (
    <MyContext.Provider value={{name = '이름', age = 23, isAdult = true, setAge}}>
    	<Component1/>
    </MyContext.Provider>
    )
}

 

3. context 데이터를 사용해야 하는 컴포넌트에서 useContext로 받아서 사용하면 됩니다.

import {useContext} from 'react'
import {MyContext} from './context/MyContext';

export default function ChildComponent () {
	
    const {name, age, isAdult, setAge} = useContext(MyContext);
    
	return (
    <div>
    	<p> name : {name} <p>
        {isAdult && 
        	<p> age : {age} </p>
        }
    </div>
    )
}

 



 

 

5. useMemo

참고한 강의 :

 

useMemo에서 Memo는 Memoization을 뜻합니다. 이는 동일한 값을 리턴하는 함수를 반복적으로 호출해야 한다면, 맨 처음 계산한 값을 메모리에 저장하고, 필요할 때마다 호출하는 것이 아니라 저장한 값을 꺼내서 재사용하는 것을 말합니다. 

 

useMemo의 구조

const value = useMemo(()=>{
	//메모이제이션 할 값을 계산해서 반환해 주는 함수
    return 함수();
},[해당 요소의 값이 업데이트 될 때만 콜백 함수를 다시 호출하여 메모이제이션])

만약 의존성 배열이 빈 배열이라면 처음 렌더링 될 때의 값을 메모이제이션 하고 이후에는 메모이제이션 된 값을 가져와 사용합니다. 이런 useMemo도 무분별하고 사용한다면 오히려 성능을 저하시킬 수 있습니다. 메모이제이션 자체가 값을 재활용하기 위해 따로 메모리를 소비하는 것이기 때문에 불필요한 값들까지 캐시해 버린다면 성능이 저하될 것입니다. 그렇기에 꼭 필요할 때만 사용해야 합니다.

 

예제를 통해 이해해보면 좋을 것 같습니다.

import {useState} from 'react'

const hardCal = (number) => {
	console.log("어려운 계산");
    for (let i = 0; i < 999999999; i++) {}
    return number + 10000;
}

export default function App(){
	
    const [hnumber, setHnumber] = useState(1);
    
    const hSum = hardCal(hnumber);

	return (
    	<div>
    		<h3> 어려운 계산 </h3>
            <input 
            	type = 'number'
                value = {hnumber}
                onChange = {(e) => {setHnumber(parseInt(e.target.value))}}
            />
            <span> +10000 = {hSum} </span>
        </div>
    )
}

위와 같은 코드의 경우 state에 10000을 더하는 시간이 엄청 오래 걸리기 떄문에 렌더링 할때마다 계산하는 함수를 호출하는 것은 매우 비효율적입니다. 이 경우 저 hardCal 함수로 변수를 초기화 하는 코드에 useMemo를 이용하면 됩니다. 이럴 경우 특정 조건에만 함수를 다시 호출하기 때문에 불필요하게 시간이 오래걸리는 어려운 계산을 하지 않습니다.

useMemo를 적용한다면 다음 코드와 같습니다. useMemo를 적용할 경우 효과적인 상황을 연출하기 위해 쉬운 계산 함수를 사용하는 코드도 함께 작성했습니다.(강의 참고)

import {useState, useMemo} from 'react'

const hardCal = (number) => {
	console.log("어려운 계산");
    for (let i = 0; i < 999999999; i++) {}
    return number + 10000;
}

const eazyCal = (number) => {
	console.log("쉬운 계산");
    return number + 10000;
}

export default function App(){
	
    const [hnumber, setHnumber] = useState(1);
    const [enumber, setEnumber] = useState(1);
    
    //const hSum = hardCal(hnumber);
    const hSum = useMemo(()=>{
    	return hardCal(hnumber);
    },[hnumber]);
    const eSum = eazyCal(enumber);

	return (
    	<div>
    		<article>
                <h3> 어려운 계산 </h3>
                <input 
                    type = 'number'
                    value = {hnumber}
                    onChange = {(e) => {setHnumber(parseInt(e.target.value))}}
                />
                <span> +10000 = {hSum} </span>
            <article>
            
            <article>
                <h3> 쉬운 계산 </h3>
                <input 
                    type = 'number'
                    value = {enumber}
                    onChange = {(e) => {setEnumber(parseInt(e.target.value))}}
                />
                <span> +10000 = {eSum} </span>
            <article>
        </div>
    )
}

위처럼 hCal에 대한 반환 값을 useMemo를 이용해 캐싱해 준다면, hnumber가 업데이트 되지 않는한 재 랜더링이 일어날 때, 캐시한 반환 값을 보내주기 때문에 기존 렌더링 할때마다 어려운 계산을 하던 문제를 해결할 수 있습니다. 즉 쉬운 계산의 숫자를 바꾸면 쉬운 계산 함수만 실행되는 것이고, 어려운 계산의 숫자를 바꿔줄 때만 어려운 계산 함수를 호출하며 효율적인 코드를 작성할 수 있습니다.

 

하지만 위와 같은 상황이 실제 개발 환경에서 일어날 경우는 많지 않습니다. 실제로 useMemo를 유용하게 사용하는 상황을 알아봅시다.

import {useState, useEffect} from 'react';


export default function App () {
	const [num, setNum] = useState(0);
    const [isKorea, setIsKorea] = useState(true);
    
    const location = isKorea ? "한국" : "외국"
    
    useEffect(()=>{
    	console.log("useEffect 호출");
    },[location])

	return (
    	<div>
    		<section>
            	<h2>하루에 몇끼 먹음?</h2>
                <input 
                	type = 'number'
                    value = {num}
                    onChange = {(e) => { setNum(e.target.value)} }
                />
            </section>
            
            <section>
            	<h2>어느 나라에 있음?</h2>
                <p> 나라 : {location} </p>
                <button onClick={ () => {setIsKorea(!isKorea)} }> 비행기 탐 </button>
            </section>
        </div>
    )

}

위의 코드의 경우에는 location이 변경 되었을 때만 useEffect가 잘 호출됩니다. 하지만 여기서 location을 객체로 만든다면 어떻게 될까요?? location 값 변경에 관여하는 isKorea가 아닌 num이 변경되도 useEffect가 실행됩니다. 그 이유는 객체 타입은 내부 내용을 메모리 공간에 넣고 해당 메모리에 대한 주소를 변수에 할당되기 때문입니다.

 

여기서 원시 타입과 객체 타입의 차이를 설명드리자면 

원시 타입

  • String
  • Number
  • Boolean
  • Null
  • Undefined
  • Bight
  • Symbol

을 제외한 모든 타입이 객체 타입인데 대표적으로 Object와 Array가 있습니다. 그리고 원시 타입이 변수에 값을 초기화 할 경우 바로  값을 넣을 수 있는 공간을 만들어 저장하는 데, 객체 타입은 그 내부 내용이 너무 크기 때문에 위에서 설명했듯이 메모리 공간을 할당 받아 메모리에 값을 넣어주고 해당 메모리의 주소를 변수에 할당합니다. 이로 인해서 location에 객체를 넣어준다면, 값 변경에 직접적으로 관여하는 isKorea가 아닌 다른 state가 변경되면서 재 렌더링이 되더라도 location은 새로운 메모리 주소를 할당받기 때문에 useEffect는 location이 변경된 것으로 인지합니다.

 

이런 경우에 location을 useMemo에 넣어줌으로써 문제를 해결할 수 있습니다.

즉, location에 할당되는 주소 값을 isKorea가 변경되었을 때만 새로 메모리 주소 값을 할당 받고 변경된 값을 객체에 반영할 수 있게 하는 것입니다. 코드는 다음과 같습니다.

import {useState, useEffect, useMemo} from 'react';


export default function App () {
	const [num, setNum] = useState(0);
    const [isKorea, setIsKorea] = useState(true);
    
    const location = useMemo(()=>{
    	return (
        	{
    			country: isKorea ? "한국" : "외국"
    		}
        )
    },[isKorea]) 
    
    useEffect(()=>{
    	console.log("useEffect 호출");
    },[location])

	return (
    	<div>
    		<section>
            	<h2>하루에 몇끼 먹음?</h2>
                <input 
                	type = 'number'
                    value = {num}
                    onChange = {(e) => { setNum(e.target.value)} }
                />
            </section>
            
            <section>
            	<h2>어느 나라에 있음?</h2>
                <p> 나라 : {location} </p>
                <button onClick={ () => {setIsKorea(!isKorea)} }> 비행기 탐 </button>
            </section>
        </div>
    )

}

 

다음 글에서는 useCallback과 useReducer를 정리해 보겠습니다.

최근 프로젝트 위주로 개발을 진행했고, 특정 기술만 주로 사용하다보니 시야가 좁아진다는 느낌을 많이 받았습니다. 그래서 이번 기회에 React Hooks부터 JS, HTML 태그들, CSS까지 전체적으로 복습해보며 시야를 넓히는 연습을 해볼려고 합니다. 이 과정을 공유하여 개발을 공부하는 분들에게도 도움이 되었으면 하는 마음에 이렇게 블로그에 남기게 되었습니다!

 

처음 다루어 볼 녀석은 React의 Hook들입니다. 이 글을 읽고 있는 여러분들도 알고 계시겠지만 Hook이 나오게 된 배경을 아주 간단하게 이야기 하고 바로 본론으로 넘어가겠습니다. 지금부터 편하게 리액트와 훅이라고 부르겠습니당 하핳

 

리액트는 처음에 컴포넌트를 상속 받는 클래스형으로 컴포넌트로 만들었고, state와 리액트의 라이프 사이클을 이용했습니다. 하지만 매번 컴포넌트를 상속 받아서 컴포넌트를 만들어야 하는 방식은 불편했고, 이에 대한 해결책으로 나온 것이 바로 함수형 컴포넌트입니다. 여기서 문제는 함수형 컴포넌트는 기존 클래스형에서 사용하던 state와 리액트의 라이프사이클을 이용하기에는 어려움이 있었다는 것입니다. 이러한 문제를 해결해 준 주인공이 바로 Hook들입니다. 대표적으로 state를 함수형 컴포넌트에서 사용할 수 있는 useState와 리액트 라이프사이클을 편리하게 이용할 수 있는 useEffect 등이 있습니다.

 

이제 본격적으로 리액트 훅들에 대해 공부해보도록 하겠습니다.

그 전에 주의할 점 짚고 시작하겠습니다.

  • JSX 구문이 컴포넌트를 구분할 수 있도록 컴포넌트 이름의 앞 첫글자는 무조건 대문자로 작성해야한다.
  • 컴포넌트에서 return 구문 아래에는 하나의 태그만 존재해야한다.

 

1. useState

먼저 state란 컴포넌트가 가질 수 있는 상태를 말합니다. useState는 이러한 state를 간편하게 생성하고 값을 설정할 수 있도록 해줍니다. 

import {useState} from 'react'

export default function MyComponent () {
	
    // js
    const [state, setState] = useState(초기값);
    
    // ts
    const [state, setState] = useStatr<타입 지정>(초기값);
    // number, string, boolean, number[], string[] 등등
    
	return <></>
}

위와 같이 import를 해주고 초기값과 값을 가지고 있는 state, 값을 설정할 수 있는 setState를 지정해줍니다.

그리고 여기서 중요한 점은 state 값을 변경할 경우 해당 컴포넌트는 재렌더링 된다는 것입니다.

 

또한 state를 변경할 때는 무조건 setState를 사용해야 합니다. 만약에 state가 배열이나 객체일 경우에는 스프레드 연산자를 이용해 state를 복사한 후 값을 변경하고 복사한 녀석을 setState의 인자값으로 넣어주면 됩니다. 

import {useStat} from 'react'

export default function MyComponent () {
	
    const [stateArr, setStateArr] = useState<string[]>(["깁밥", "콩나물", "완두콩"]);
    const [state, setState] = useState<string>("");
	
    const deleteState = (state:string) => {
    	// state 값 복사
        let copy:string[] = [...state];
        
        // 삭제할려는 데이터가 아닌 요소만 가진 배열 만들기
        const delete:string[] = copy.filter((data)=>{
        	return data !== state;
        })
        
        setState(delete);
    }
	
    // 이전 값에 간단한 추가를 하는 경우에는 
    // setState 내부에서 콜백 함수를 사용하여 편리하게 작업할 수 있다.
    const updataData = () => {
    	setStateArr((prev)=>{
        	return [state, ...prev];
        })
    }
    
    
	return (
    <div>
    	<input type='text' value={state} onChange={(e)=>{setState(e.target.value)}}
 		<button onClick = {updateData}>업데이트</button>   	
    	
        {stateArr && stateArr.map((state, index)=>{
        	return <p key={index} onClinck={() => deleteState(state)}>{state}</p>
        })}
    </div>
    )
}

 

 

 

 

그리고 state에 들어가는 초기값이 어떤 무거운 작업을 시행한 후 반환되는 값이라면, 처음 렌더링 될때 한번만 작업을 시행하는 것이 좋습니다. 하지만 그냥 useState(무거운작업함수) 이렇게 넣어버리면 state가 변경될때마다 무거운 작업이 수행되기 때문에 콜백 함수를 사용하여 처음 렌더링 될때만 state의 초기값을 설정하는 함수를 작동시키는 것이 좋습니다.

import {useState} from 'react'

const havyWork = () => {
	console.log('무거운 작업');
    return []
}


export default function MyComponent () {
	
    const [state, setState] = useState(() => {
    	return havyWork
    });
    

	return (
    	<div>
        	<p> {state} </p>
    	</div>
    )
}

 

 



 

2. useEffect

리액트의 라이프 사이클을 이용할 수 있도록 도와주는 Hook입니다.

리액트의 라이프 사이클을 크게 3개로 나눌 수 있는데 Mount(처음 렌더링) > Update(재렌더링) > Unmount(화면에서 사라짐)입니다. useEffect를 통해 원하는 시점에 원하는 작업을 수행할 수 있습니다.

 

1. 처음 렌더링 될때

useEffect(()=>{
	//원하는 작업
},[])

 

2. 렌더링이 될때마다 실행

useEffect(()=>{
	//원하는 작업
})

 

3. 특정 state가 변경될 때

useState(()=>{
	//원하는 작업
},[특정 값])

 

여기서 특정 값을 작성하는 배열은 '의존성 배열'이라고 부릅니다. 이러한 useEffect에서의 clean up에 대해 한 번 알아봅시다. clean up은 useEffect를 통해 특정 작업을 한 후 해당 작업에 대한 정리 작업을 실행하는 것을 이야기합니다.

 

여기서 의존성 배열에 아무것도 존재하지 않는 다면, 컴포넌트가 마운트 됐을때 부수 작업이 실행되고, 언마운트될때 정리 작업이 실행됩니다.

import {useEffect} from 'reat';


export default function Timer () {
	
    useEffect(()=>{
    	// 부수 작업
        const timer = setInterval(()=>{
        	console.log("타이머 실행 중");
        }, 1000);
        
        // useEffect에서 return을 사용하면 해당 컴포넌트가 Unmount 됐을 때 실행이 됩니다.
        return () => {
        	// 정리 작업
            clearInterval(timer);
            console.log("타이버 종료")
        }
    },[]);
    
	return (
    	<div>
        	<span> 타이머 컴포넌트여 </span>
        </div>
    )
}

 

만약에 의존성 배열에 특정 값이 존재한다면, 해당 값이 처음 변경 되었을 때는 부수 작업을 실행하고, 다음 작업부터 기존 state에 대한 정리 작업을 진행한 후 부수 작업을 진행합니다.

 

그리고 브라우저 종속성이 있는 작업을 진행하는 경우 useEffect로 묶는데 그 이유는 리액트가 가상 DOM을 사용하기 때문에 직접적인 DOM 조작은 렌더링 이후에 이루어져야합니다. useEffect는 렌더링 후에 실행되기 때문에 브라우저 종속성이 있는 작업을 실행할 때 useEffect로 감싸는 것입니다.

 

useEffect의 실행 시점

  • 초기 렌더링 후: 컴포넌트가 처음 렌더링된 직후에 useEffect의 부수 효과 코드가 실행됩니다.
  • 리렌더링 후: 의존성 배열에 포함된 값이 변경되어 컴포넌트가 리렌더링될 때, 이전의 정리 함수가 실행된 후에 새로운 부수 효과 코드가 실행됩니다.
  • 언마운트 직전: 컴포넌트가 언마운트되기 직전에 useEffect의 정리 함수가 실행됩니다.

 



 

3. useRef

ref 객체를 반환해주는 Hook입니다. useRef를 이용해 설정하는 초기값은 ref 객체의 current에 저장됩니다.

이러한 ref는 크게 두 가지 용도로 사용됩니다.

 

1. 저장 공간

ref의 값을 변경해도 리렌더링 되지 않기 때문에  컴포넌트 내의 변수들의 값을 유지한 채 특정 값을 변경하고 저장하고 싶을 때 유용합니다. 그리고 컴포넌트가 렌더링 되어도 ref의 값은 유지됩니다.  즉, ref의 값은 컴포넌트가 마운트 된 이후 언마운트될 때까지 값이 유지되는 것입니다.

 

버튼을 누르면 값을 증가시켜 주는 예제를 통해 차이를 비교해보겠습니다.

import {useState, useRef} from 'react';

export default function CountComponent () {
	
    const [state, setState] = useState<number>(0);
    const ref = useRef<number>(0)
    // 요소를 참조할 경우 const ref = useRef<HTML요소이름Element>(null);
    
    console.log("렌더링 ")
    
    return (
    <div>
    	<p> state: {state} </p>
        <p> ref : {ref} </p>
        <button onClick = {() => { setState( state + 1) }}> state 값 올리기 </button>
    	<button onClick = {() => { ref.current = ref.current + 1} }> ref 값 올리기 </button>
    </div>
    )

}

위의 코드를 실행하고 'state 값 올리기' 버튼을 올리면 "렌더링"이 콘솔 창에 버튼을 누를 때마다 뜨고 변경된 값이 바로바로 화면에 적용되어 보일 것입니다, 하지만 'ref 값 올리기'를 클리하면 렌더링이 일어나지 않기 때문에 ref의 값이 버튼을 클리할 때마다 1씩 증가하고 있지만 그 변화가 화면에 반영되어 나타나지는 않습니다. 이 때 'state 값 올리기' 버튼을 클릭할 경우 증가한 ref의 값도 반영되어 화면에 표시될 것입니다.

 

때문에 값이 자주 바뀌는 경우 ref를 사용하는 게 성능상 유리할 것입니다. 그리고 변화를 감지해야 하지만 변화를 감지할 때 렌더링을 발생시키지 않아야하는 경우에 사용하기 유용할 것입니다.

 

 

2. DOM 요소 접근

ref를 지정한 요소에 접근할 수 있고, ref.current.focus(), ref.current.value, ref.current.style.backgroundColor, ref.current.checked 등 기존 document.querySelector로 불러와서 할 수 있는 작업들을 동일하게 할 수 있습니다.


이런식으로 사용하기 위해선 태그의 ref 속성으로 useRef를 넣어주면 DOM 요소에 쉽게 접근할 수 있는 것입니다.

이러한 ref를 사용하는 다양한 예제를 보면서 이해하면 좋을 것 같습니다.

 

1. 렌더링 될 때, 특정 input 요소에 포커싱

import { useRef, useEffect } from 'react';

export default function FocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // input 요소에 포커스를 설정
  }, []);

  return <input ref={inputRef} type="text" />;
}

 

 

2. DOM 요소의 값을 읽고 설정

import { useRef } from 'react';

// input의 value 가지고 와서 설정하기
export default function ReadWriteInput() {
  const inputRef = useRef(null);

  const handleClick = () => {
    alert(inputRef.current.value); // input 요소의 현재 값을 읽음
    inputRef.current.value = 'Hello, World!'; // input 요소의 값을 설정
  };

  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={handleClick}>Show and Set Value</button>
    </div>
  );
}


// input이 체크되어 있는지 확인하고 변경하기
export default function RadioComponent() {
  const radioRef = useRef(null);

  const handleClick = () => {
    // 현재 체크 상태를 읽음
    console.log('Radio button is checked:', radioRef.current.checked);

    // 체크 상태를 설정
    radioRef.current.checked = !radioRef.current.checked;
  };

  return (
    <div>
      <input ref={radioRef} type="radio" name="example" />
      <button onClick={handleClick}>Toggle Radio Button</button>
    </div>
  );
}

 

 

3. DOM 요소 스타일 변경

import { useRef } from 'react';

export default function ChangeStyle() {
  const divRef = useRef(null);

  const handleClick = () => {
    divRef.current.style.backgroundColor = 'yellow'; // div 요소의 배경색 변경
  };

  return (
    <div>
      <div ref={divRef} style={{ width: '100px', height: '100px', backgroundColor: 'blue' }}></div>
      <button onClick={handleClick}>Change Color</button>
    </div>
  );
}

 

 

4. 스크롤 조작

import React, { useRef } from 'react';

export default function ScrollBox() {
  const boxRef = useRef(null);

  const handleScrollToBottom = () => {
    boxRef.current.scrollTop = boxRef.current.scrollHeight; // 스크롤을 가장 아래로 이동
  };

  return (
    <div>
      <div
        ref={boxRef}
        style={{ width: '200px', height: '100px', overflow: 'auto', border: '1px solid black' }}
      >
        <p>Some content here...</p>
        <p>More content here...</p>
        <p>Even more content...</p>
        <p>And more...</p>
        <p>More and more...</p>
        <p>Last content...</p>
      </div>
      <button onClick={handleScrollToBottom}>Scroll to Bottom</button>
    </div>
  );
}

 

 

5. 타미어 및 애니메이션 제어

import React, { useRef, useEffect } from 'react';

export default function TimerComponent() {
  const timerIdRef = useRef(null);

  useEffect(() => {
    // setInterval을 사용하여 1초마다 로그를 찍는 타이머 설정
    timerIdRef.current = setInterval(() => {
      console.log('Timer tick');
    }, 1000);

    return () => {
      // 컴포넌트 언마운트 시 타이머 제거
      clearInterval(timerIdRef.current);
    };
  }, []);

  return <div>Timer is running. Check the console for ticks.</div>;
}

이렇게 하는 이유는 컴포넌트가 다시 렌더링되어도 해당 타이머 및 애니메이션이 유지될 수 있도록 하기 위함입니다.

 

위와 같이 useRef를 이용해 다양한 작업들을 할 수 있습니다. 해당 글을 여기서 마무리하도록 하고 다음 글에 이어서 useContext, useMemo,  useCallback, useReducer에 대해 정리하겠습니다.

 

서비스 워커란?

 서비스 워커란 웹 애플리케이션을 네이티브 앱처럼 로컬에 설치하여 실행 성능을 향상 시키는 역할을 하고, 전통적인 웹 개발 모델을 확장하는 개념으로 사용됩니다.  즉, 웹 애플리케이션에 서비스 워커를 추가하는 것은 애플리케이션을 progressive Web App으로 전환하는 과정 중 하나의 단계라고 할 수 있습니다.

 

 조금 더 자세히 설명드리자면, 서비스 워커는 애플리케이션의 캐시를 관리하기 위해 웹 브라우저가 백그라운드에서 실행하는 스크립트입니다. 이러한 서비스 워커는 네트워크와 브라우저 사이의 프록시 서버 역할을 하며, 애플리케이션에서 보내는 HTTP 요청을 모두 인터셉트해서 해당 요청에 대한 응답을 직접 보낼 수 있습니다. 그래서 이미 응답을 받은 요청이 있다면, 해당 응답을 로컬에 캐싱해 뒀다가 재사용할 수 있습니다.이러한 서비스 워커로 푸시 알림이나 오프라인 상태에서는 백그라운드 동기화 같은 기능을 구현할 수 있습니다.

 

 프록시 기능은 fetch와 같은 API뿐만 아니라 HTML 파일이 참조하는 리소스에도 적용할 수 있습니다. 웹 페이지와 별개로 생명주기를 갖고 동작하며, 보안상의 이유로 HTTPS와 localhost에서만 실행됩니다.

 

 

서비스 워커 적용하기

 public 폴더에 sw.js 파일을 만들어 어떤 작업을 할 것인지 정의해주고, useEffect 훅으로 감싸준 후 등록 가능한 애플리케이션에 대해서 설정한 sw.js 파일을 등록하면 됩니다. 브라우저에 종속적이기 때문에 useEffect 훅으로 감싸줘야 합니다.

정상적으로 등록이 된다면 개발자 도구에서 확인이 가능합니다. (아래에서 이미지와 함께 조금 더 자세히 설명드리겠습니다.) 이러한 서비스 워커 캐싱은 몇가지 패턴이 존재하는데 상황에 맞는 패턴으로 개발을 진행하면 됩니다.

출저 : https://80000coding.oopy.io/2c8097c6-117f-41b4-ae65-2bd0beec5f18

 제가 프로젝트에 적용하고자 하는 패넡은 Cache, falling back to Network 입니다. 즉 캐싱한 응답이 있다면 캐싱된 데이터를 보내주고, 그렇지 않다면 원래의 요청을 보내는 것입니다.

 

이제 직접 프로젝트에 적용해보겠습니다.


1. public 폴더에 sw.js 파일을 만들어 어떤 작업을 할 것인지 정의해 줍니다.

// public/sw.js

const cacheName = 'useServiceWorker'
const cacheList = []

// fetch가 일어날 때
self.addEventListener('fetch', event => {
  // 응답을 가져와서 수정
  event.respondWith(
    
    // request에 대한 response를 캐싱했는지 확인
    caches.match(event.request).then(response => {
      
      // 캐싱했다면 캐싱한 데이터 반환
      if (response) {
        return response
      }

      const fetchRequest = event.request.clone()
			
			// 캐싱한 데이터가 없다면 원래 요청을 보내주기
      return fetch(fetchRequest).then(response => {
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response
        }

        const requestUrl = event.request.url
        const responseToCache = response.clone()
				
				// POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱하지 않음
        if (
          !requestUrl.startsWith('chrome-extension') &&
          event.request.method !== 'POST'
        ) {
          // 캐시에 요청에 대한 응답 저장
          caches.open(cacheName).then(cache => {
            cache.put(event.request, responseToCache)
          })
        }
				
				// 요청 반환
        return response
      })
    }),
  )
})

 

 

2. 서비스 워커 등록 컴포넌트를 만들어줍니다.

 최상위 컴포넌트가 마운트 될 때 서비스 워커를 등록해 주면 되는데 위에서 언급했듯이 서비스워커는 브라우저에 종속적이기 때문에 useEffect로 묶어서 사용해 줘야 합니다. 처음에 적용할 때 서비스워커가 아예 작동하지 않는 문제가 있었는데 이 문제도 짚어가면서 등록한 과정을 작성하겠습니다.

 

  • 처음 작성했던 등록 코드

 최상위 컴포넌트인 layout.tsx는 서버 컴포넌트를 유지 시켜 줘야 하기 때문에 따로 컴포넌트를 만들어서 넣었습니다.

// /utils/RegisterServiceWorker.tsx

'use client'

import {useEffect} from 'react'

export default function RegisterServiceWorker () {
	useEffect(()=>{
		if('serviceWorker' in navigator) {
			window.addEventListener('load', () => {
				navigator.serviceWorker.register('/sw.js')
				.then(registration => {
          console.log('SW registered: ', registration)
        })
        .catch(registrationError => {
	        console.log('SW registration failed: ', registrationError)
        })
			})
		}
	},[])
	
	// 렌더링하는 요소는 없기 때문에 null을 반환해준다.
	return null
}

 

 이 코드를 작성하면서 문제를 깨달았어야 했는데, 그러지 못했습니다.. 하핳

원래window.addEventListener(’load’, () ⇒ {})를 사용한 이유는 페이지 로드가 완료된 후에 서비스워커를 등록함으로써 페이지 로드 속도에 영향을 미치지 않을려고 작성했습니다. 하지만 이로 인해 서비스워커가 등록되지 않았습니다.

 

 왜냐하면 useEffect(()⇒{},[]) 내에서 window.addEventListener(’load’, () ⇒ {내부코드})를 사용할 경우 내부코드가 실행되지 않기 때문이었습니다.

 

 이 이유를 찾기 위해 챗지피티한테 물어보기도 하고 구글링도 하고 직접 다른 코드들을 작성하며 도달한 결론은 다음과 같습니다. 각 이벤트가 호출되는 기준 때문이라고 생각합니다. load 이벤트는 모든 리소스가 다운로드 된 다음에 이벤트가 호출되는데 리액트 기준에서는 load 이벤트가 일어나지 않았기 때문에 window로 load 이벤트 이후에 서비스 워커를 등록할려고 한 작업은 실행되지 않은 것입니다. 여기서 확실하게 파악하지 못한 부분은 컴포넌트가 마운트 되기 이전에 이미 페이지 로드가 되었다고 판단하여 이벤트가 실행되지 않는 것인지 컴포넌트가 마운트된 이후에 업데이트가 계속 발생하기 때문에 페이지가 로드되었다고 판단하지 않는 것입니다. 하지만 DOM 트리를 완성하는 시점에 이벤트를 호출할 수 있는 DOMContentLoaded를 사용할 경우 서비스 워커 등록이 아주 잘되는 것을 확인하고, 후자가 맞지 않나라고 생각하고 있습니다. 이 부분은 더 찾아보고 공부해야할 것 같습니다.

 

그리고 사실 따로 이벤트를 설정할 필요 없이 컴포넌트가 렌더링 되는 시점에 서비스 워커를 등록하면 문제없이 잘 실행됩니다. 여기서 주의해야할 점은 navigator.serviceWorker.register는 Promise 객체를 반환하기 때문에 .then이나 catch를 사용하거나 async/await을 사용해야 합니다. 저는 가독성이 좋은 async/await을 사용했습니다.

 

  • 실험 코드
// /utils/RegisterServiceWorker.tsx

'use client'

import { useEffect } from 'react'

export default function RegisterServiceWorker() {
  useEffect(() => {
    console.log(navigator.serviceWorker)

    if ('serviceWorker' in navigator) {
      const registerServiceWorker = async () => {
        try {
          const registration = await navigator.serviceWorker.register('/sw.js')
          console.log('SW registered: ', registration)
        } catch (error) {
          console.log('SW registration failed: ', error)
        }
      }
      
      registerServiceWorker() // 실행 됨

      window.addEventListener('DOMContentLoaded', () => {
        registerServiceWorker() // 실행 됨
      })

      window.addEventListener('load', () => {
        console.log('페이지 로드 완료')
        registerServiceWorker() // 실행 안됨
      })
    }
  }, [])

  return null
}

 

  • 최종 코드
// /utils/RegisterServiceWorker.tsx

'use client'

import { useEffect } from 'react'

export default function RegisterServiceWorker() {
  useEffect(() => {
    console.log(navigator.serviceWorker)

    if ('serviceWorker' in navigator) {
      const registerInit = async () => {
        const registration = await navigator.serviceWorker.register('/sw.js')
        registration.waiting?.postMessage({ type: 'SKIP_WAITING' })
      }
      registerInit()

      // 그냥 naviaator.serviceWorker.register('/sw.js') 사용하는 경우
      //   navigator.serviceWorker
      //     .register('/sw.js')
      //     .then(registration => {
      //       console.log('Service Worker registered: ', registration)
      //     })
      //     .catch(registrationError => {
      //       console.log('SW registration failed: ', registrationError)
      //     })
    }
  }, [])

  return null
}
// /app/layout.tsx

import type { Metadata } from 'next'
import ReactQueryProvider from '../utils/provider/ReactQueryProvider'
import RegisterServiceWorker from '@/utils/client/RegisterSeviceWorker'
import WebView from '@/Components/WebView/WebView'
import { WeatherProviderByContext } from '../../contexts/WeatherContext'
import './globals.css'

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ko">
      <title>옷늘날씨</title>
      <body>
        <ReactQueryProvider>
          <RegisterServiceWorker />
          <WebView>
            <WeatherProviderByContext>{children}</WeatherProviderByContext>
          </WebView>
        </ReactQueryProvider>
      </body>
    </html>
  )
}

 

 

3. 서비스 워커 잘 작동되는지 확인

개발자 도구의 application으로 들어가서 확인할 수 있습니다.

캐시된 데이터 크기는 Storage에서 확인 가능합니다.


위와 같이 적용하고 배포까지 했는데 문제가 발생했습니다. 서비스 워커를 적용한 이후 서비스 워커에서 데이터를 가져오는 방식이 기존 fetch를 이용한 방식보다 더 느리다는 것이었습니다. 코드를 보면서 뭐가 문제인지 알아보겠습니다.

 

// public/sw.js

const cacheName = 'useServiceWorker'
const cacheList = []

// fetch가 일어날 때
self.addEventListener('fetch', event => {
  // 응답을 가져와서 수정
  event.respondWith(
    
    // request에 대한 response를 캐싱했는지 확인
    caches.match(event.request).then(response => {
      
      // 캐싱했다면 캐싱한 데이터 반환
      if (response) {
        return response
      }

      const fetchRequest = event.request.clone()
			
			// 캐싱한 데이터가 없다면 원래 요청을 보내주기
      return fetch(fetchRequest).then(response => {
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response
        }

        const requestUrl = event.request.url
        const responseToCache = response.clone()
				
				// POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱하지 않음
        if (
          !requestUrl.startsWith('chrome-extension') &&
          event.request.method !== 'POST'
        ) {
          // 캐시에 요청에 대한 응답 저장
          caches.open(cacheName).then(cache => {
            cache.put(event.request, responseToCache)
          })
        }
				
				// 요청 반환
        return response
      })
    }),
  )
})

해당 코드를 보면 캐싱한 데이터가 없을 경우 이벤트의 request를 클론하여 원래 요청을 진행하는데요 생각해보면 이 방식은 기존 요청이 있는 상태에서 다시 요청하는 방식이기 때문에 불필요한 중복 요청을 발생시키고 있습니다. 이러한 중복 요청을 제거하겠습니다.

 

수정 코드

// /public/sw.js

const cacheName = 'useServiceWorker'

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(response => {
      if (response) {
        return response
      }

      // request 클론 과정 삭제
      // const fetchRequest = event.request.clone()

      return fetch(event.request).then(response => {
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response
        }

        const requestUrl = event.request.url
        const responseToCache = response.clone()

        if (
          !requestUrl.startsWith('chrome-extension') &&
          event.request.method !== 'POST'
        ) {
          caches.open(cacheName).then(cache => {
            cache.put(event.request, responseToCache)
          })
        }

        return response
      })
    }),
  )
})

 

해당 코드로 배포 후 테스트 진행해보겠습니다. 성공적으로 서비스 워커를 이용해 fetch로 가져오는 데이터를 캐싱할 수 있었고, 기존 로딩 시간을 97% 단축했습니다.

 

기존 리소스 로딩 시간

 

서비스 워커 적용 후 리소스 로딩 시간 (이미지 포맷도 변경)

 

이상입니다,

깃허브에 PR을 잘못했는데 Merge까지 해버린 경우가 있어서 해당 PR을 제거할려고 시도한 경험이 있습니다.

이러한 PR을 완전히 제거하는 방법은 생각보다 간단합니다.

 

https://support.github.com/request

 

해당 링크로 들어가서 다음 과정을 수행하면 됩니당.

 


잘못 올린 PR 완전히 제거하기

 

1. '내가 가지고 있거나 제어하는 리포지토리에서 데이터 제거하기'를 클릭합니다. 

 

 

2. 끌어오기 요청 제거하기를 클릭합니다.

 

3. 여러개의 PR을 제거하고 싶다면 Multiple, 하나의 PR을 제거하고 싶다면 Single을 선택하면 됩니다.

 

4. 이후 해당 질문이 왔을때, PR을 제거할려고 하는 repository의 주소를 입력해줍니다.

 

5. 삭제할려고 하는 PR의 번호를 작성하면 2시간 이내에 삭제가 됩니다.


 

하지만, 저는 위와 같은 메세지를 받았고, PR이 삭제되지 않았습니다.

위의 답변을 보면 알 수 있듯이 삭제할려는 PR과 연관된 브랜치에서 각 PR에 대한 commit을 삭제해줘야 PR을 완전히 삭제할 수 있는데 해당 작업이 시행되어 있지 않았기 때문이었습니다. 이를 위해선 삭제할려는 PR이 있는 repository를 클론하고 git-filter-repo BFG Repo-Cleaner 를 이용하여 PR과 연관된 각 브랜치에서 commit을 삭제해줘야합니다.

 

 

git-filter-repo를 이용해 제거한 과정을 정리해볼려고 합니다.

 


1. repository 클론하기

git clone --mirror https://github.com/your-repository.git
cd your-repository.git

 

--mirror를 사용하는 주요 이유는 다음과 같습니다:

  1. 모든 브랜치와 태그 포함: 전체 저장소의 모든 브랜치와 태그를 포함하여 완전한 복사본을 만듭니다. 이는 특정 커밋을 모든 브랜치와 태그에서 제거하려는 경우 필수적입니다.
  2. 원격 참조 유지: 원격 저장소의 모든 참조를 로컬에 그대로 복사하므로, 원격 저장소의 구조를 로컬에서 정확히 재현할 수 있습니다.
  3. 작업 편의성: git filter-repo와 같은 도구를 사용하여 모든 브랜치와 태그에서 커밋을 제거하는 작업을 한 번에 수행할 수 있습니다

 

2. git-filter-repo 설치

pip install git-filter-repo

 

 

3. 제거할려고 하는 커밋 확인 

git log

 를 이용해 제거할려는 커밋의 해시를 알아냅니다.

 

4. 제거할려는 커밋의 해시를 입력해 해당 커밋 삭제

git filter-repo --commit-callback 'if commit.hash == b"{커밋 해시}": commit.ignore = True'

# 예시
git filter-repo --commit-callback '
def callback(commit, metadata):
    if commit.original_id == b"27d6475280e0d347d8bc88e4edcb149bad3fb248":
        commit.ignore = True
'

 

5. 깃허브 저장소 주소 연결

git remote add https://github.com/your-repository.git

 

6. push

git push --force --all

 


위의 방법으로 삭제할려고 하는 PR과 관련된 커밋을 각 브랜치에서 삭제할 수 있습니다. 이렇게 처리한 뒤에 문의할 때 제공받은 티켓에서 댓글로 삭제했다고 연락하면 담당자가 PR을 완전히 제거해 줍니다. 이상입니다. 글 읽어주셔서 감사합니다.

+ Recent posts