useRef()가 뭐임?: onbeforeunload 이벤트 시 useState()로 값을 얻을라고 하면 안되는 이유! (ㅎㄷㄷ)

Daniel Oh·2023년 10월 24일
0
post-thumbnail

1. 문제 정의

언제 점수를 서버에 보내야 하나

popcat.click과 비슷한 형식의 웹 페이지를 클론 코딩하고 있었다. 그런데 클릭한 점수를 언제 서버로 보내는 것이 좋은가? 를 고민했을 때, 가장 좋은 시기는 페이지를 벗어나기 전이라고 생각했다. 왜냐하면 다른 대안은...

  • 주기적으로 서버에 점수 보내기
  • 버튼을 클릭할 때마다 서버에 점수 보내기

정도 인데 두 방법 모두 쓸데없는 서버의 호출을 늘리기 때문이다. 주기적으로 보낼 경우 적절한 주기 값을 못찾겠고 (1초 정도가 적당한 것 같기는 한데... 그래도 너무 많이 부른다), 버튼을 클릭할 때마다 서버에 점수를 보내는 것은 서버 지연 시간이 존재하기에 서버가 제대로 동작하지 못한다.

따라서 페이지를 벗어나기 전에 서버에 점수를 보내는 것이 가장 합리적이라고 판단했다.

자 그럼 코드를 짜보자!

코드를 짜보았읍니다

export const CharacterButtonWithScore = ({id, locationId, size}: CharacterButtonWithScoreProps) => {
    const [score, setScore] = useState(0)
    const [locationScore, setLocationScore] = useState(-1000)
    
    ...
    
    const patchLocationScore = async (sum: number) => {
        try {
            axios.patch(
                process.env.NEXT_PUBLIC_API_BASE_PATH + `locations/${locationId}`,
              {"score": sum}
            )
        } catch (e) {
            console.log(e)
        }
    }

    window.addEventListener("beforeunload", (event) => {
        console.log(`locationScore: ${locationScore}, score: ${score}`)
        locationId != 0 && patchLocationScore(locationScore + score);
        console.log("API call before page reload");
    });
  
  ...
}

해당 코드는 복잡해보이지만 간단하다.

a. 시작은 useState()

export const CharacterButtonWithScore = ({id, locationId, size}: CharacterButtonWithScoreProps) => {
    const [score, setScore] = useState(0)
    const [locationScore, setLocationScore] = useState(-1000)

React State 2개를 선언한다. 이들은 scorelocationScore로, 각각 클릭해서 늘릴 점수와 부대의 기존 점수를 보여준다. 즉, 서버에 보내야하는 점수는 score + locationScore 이다.

b. 서버에 점수 보내는 함수: patchLocationScore()

    const patchLocationScore = async (sum: number) => {
        try {
            axios.patch(
                process.env.NEXT_PUBLIC_API_BASE_PATH + `locations/${locationId}`,
              {"score": sum}
            )
        } catch (e) {
            console.log(e)
        }
    }

서버에 점수를 보내는 함수를 작성한다. Axios를 이용해서 작성했다.

c. onbeforeunload 때 서버에 점수 보내는 함수 호출하기

    window.addEventListener("beforeunload", (event) => {
        console.log(`locationScore: ${locationScore}, score: ${score}`)
        locationId != 0 && patchLocationScore(locationScore + score);
        console.log("API call before page reload");
    });
  
  ...
}

서버에 점수를 보내는 함수를 호출한다.

그러나...

잘 동작할 줄 알았건만... 서버에 이상한 값 (-1000)이 들어와 있어 확인해보니 scorelocationScore에 각각 0-1000이 들어가 있었다.

즉, onbeforeunload 이벤트 발생 시 state의 값이 초깃값으로 돌아가는 것이다.

이걸 어떻게든 해결해보려고 3시간 동안 별짓을 다 했는데, 결국 해결 못하고 포기했다. ㅠㅠ

진실된 커밋 메세지 (빡침이 느껴짐)

2. 해결책 탐색

스오플은 신이야

구글링해도 못 찾겠어서, 포기하고 Stack Overflow에 접속했다. 그.런.데.!! 분명 구글링에서는 안나왔던 자료가 Stack Overflow 검색창에서 검색하니 관련 자료를 찾을 수 있었다. (아마 nextjs를 위주로 검색했어서 못 찾았던 것 같다. 바보!)

질문 내용을 요약하면, 페이지를 새로고침했을 때 beforeunload 이벤트가 발생하는데 이때 현재 state를 알고 싶다는 내용이다. 질문자는 페이지를 새로고침할 때 마다 카운터의 값이 0이 된다고 호소하고 있다. 정확히 나의 문제와 일치하는 상황이다!! 이제 멋진 답변만 있으면 된다 ㅋㅋ

답변

해당 문제에 대한 답변은 이벤트 핸들러에 접근하려 하면 상태의 lifetime이 끝나버려서 초기값이 반환된다는 내용이다.

  1. useRef() 훅을 사용하거나,
  2. 관찰하고자 하는 값을 조건으로 useEffect() 훅을 사용하라는 것이다.

나의 경우 1번이 더 편해보여서 useRef() 훅에 대해 공부해보고 나의 상황에 적용시켜보기로 했다.

useRef()가 머임?

리액트 공식 문서를 참고하여 공부해보자! (요것도 참고함)

문서를 한번 훑어보고 깨달은 점은, 결국 useState() 훅과 같이 변수를 정의하는 또 다른 방법일 뿐이다. 허나 useState()와 중요한 차이점이 있는데, 바로 변수의 값이 바뀌더라도 컴포넌트의 re-render를 진행하지 않는다는 것이다. 기존 useState()는 state가 바뀔 때마다 해당 컴포넌트를 다시 그린다. 즉 컴포넌트에 그려져야 하는 변수는 useState()로 관리하고, 아닌 변수는 useRef()로 관리하는 것 같다.

정의

useRef(initialValue)

요런 느낌으로 정의한다.

import { useRef } from 'react';

function MyComponent() {
  const intervalRef = useRef(0);
  const inputRef = useRef(null);
  // ...

useRef()는 property로 current 하나를 가진다. 요걸로 읽고 쓰고 다한다.

값 참조하기 (카운터 예시)

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

위에서 보면 count 값 (ref.current)는 컴포넌트에 그려지는 것이 아니라 알림창에만 적힌다.

즉, 컴포넌트에 그려지는 것이 아니므로
-> 변수가 바뀔 때마다 re-render를 할 필요가 없고
-> useRef()를 사용하는 것이 합리적이라는 결론에 도달한다.

3. 적용해서 해결해보기

export const CharacterButtonWithScore = ({id, locationId, size}: CharacterButtonWithScoreProps) => {
    const [score, setScore] = useState(0)
    const scoreRef = useRef(0)
    const [locationScore, setLocationScore] = useState(-1000)
    const locationScoreRef = useRef(-1000)
    
    ...
    
    const patchLocationScore = async (sum: number) => {
        try {
            axios.patch(
                process.env.NEXT_PUBLIC_API_BASE_PATH + `locations/${locationId}`,
                {"score": sum}
                )
            } catch (e) {
            console.log(e)
        }
    }
    
    // patch onbeforeunload
    scoreRef.current = score
    locationScoreRef.current = locationScore
    window.addEventListener("beforeunload", (event) => {
    console.log(`locationScore: ${locationScoreRef.current}, score: ${scoreRef.current}`)
    locationId != 0 && patchLocationScore(locationScoreRef.current + scoreRef.current);
  });

요렇게 코드를 짜서 해결할 수 있었다! useRef() 덕분에 페이지를 새로고침할 때나 나갈 때마다 현재 값을 서버에 정확하게 patch한다.

a. ref도 같이 정의해주기

export const CharacterButtonWithScore = ({id, locationId, size}: CharacterButtonWithScoreProps) => {
    const [score, setScore] = useState(0)
    const scoreRef = useRef(0)
    const [locationScore, setLocationScore] = useState(-1000)
    const locationScoreRef = useRef(-1000)

state를 선언할 때 이 친구들을 담을 ref도 같이 선언해주었다.

b. patchLocationScore()

얘는 바뀐게 없어서 Skip~

c. ref 값들로 patch하기

    // patch onbeforeunload
    scoreRef.current = score
    locationScoreRef.current = locationScore
    window.addEventListener("beforeunload", (event) => {
    console.log(`locationScore: ${locationScoreRef.current}, score: ${scoreRef.current}`)
    locationId != 0 && patchLocationScore(locationScoreRef.current + scoreRef.current);
  });

이벤트 함수를 정의하기 전에 ref 값들을 현재 state 값으로 업데이트해주고, locationScoreRef.current, scoreRef.current 로 읽고 쓰고를 다 한다.

로컬 서버에서 돌려보니 잘 돌아간다! 뿌듯하다!

하지만...

window is not defined... 라는 새로운 오류를 맞닥드렸다.

4. 부록: NextJS에서 window is not defined 에러 해결법

알아보니 window 객체가 실제로 정의되지 않았다는 말이 아니라, window 객체가 정의되지 않았을 수도 있다는 말인 것 같다. 따라서 window를 사용하기 전에 window 객체가 존재한다는 증명을 해주어야 한다.

해당 내용은 taese0ng.log님이 잘 정리해주셔서 이를 참고하여 해결했다.

고친 코드는 다음과 같다.

    scoreRef.current = score
    locationScoreRef.current = locationScore
    if (typeof window !== "undefined") { // window가 존재한다면 코드를 실행하도록 바꿈 -> 오류 안남
        window.addEventListener("beforeunload", (event) => {
            console.log(`locationScore: ${locationScoreRef.current}, score: ${scoreRef.current}`)
            locationId != 0 && patchLocationScore(locationScoreRef.current + scoreRef.current);
        });
    }

5. 소혜

이 문제만 거의 3일 동안 고민했었는데, 괜찮은 StackOverFlow 글 하나를 찾으니 뒤에는 술술 풀렸다. 조금 더 적극적으로 질문 사이트를 이용하고 검색해야겠다는 생각이 들었다. 리액트 훅을 다루는 실력도 조금 늘은 것 같다. 역시 직접 부딪혀보는 것만큼 좋은 해결책은 없는 듯!

profile
안녕하세요. 오도열입니다.

0개의 댓글