한창 진행중인 Vue 기반 프로젝트를 마무리하고, 방학기간이라 여유가 생겨서 정말 오랜만에 React 기반의 SsocoTimer를 열어보니 머리가 지끈지끈해졌습니다.
리액트 진짜 너무 어렵네요 ㅋㅋㅋㅠ
issue에 쌓여있는 내용들을 하나씩 쳐내기 위해서 코드를 변경하고 있는데, useState가 원하는대로 동작하지 않는 현상이 발생했습니다.
사실 이전에 useState와 useEffect의 동작방식에 대해 하나씩 확인해보면서 "이건 이벤트 핸들러(이벤트 처리 함수)가 아닌 useEffect로 처리하면 된다"는 것을 경험적으로 깨달았기 때문에 문제 수정 자체는 크게 어렵지 않았지만, 리액트의 라이프사이클에 대해서 조금 더 정리하고 넘어가야 할 필요성을 느끼기도 했고, 비슷한 고민을 다시 하고 있는 나를 보니 언젠가 과거의 나의 도움을 받고 있을 나를 상상하게 되어서.. 이렇게 글을 작성하게 되었습니다.
(지금 보니까 과거의 제가 useEffect를 이용한 side-effect 관리에 대한 글을 작성했는데, 고새 또 까먹어버려서 이번에는 저번 글의 실전편? 느낌으로 실제로 코드를 개선하면서 동시에 블로그 글을 작성해보았습니다 ㅎㅎ)
목차
0. 문제 정의
그렇다면 도대체 어디에서 해당 문제가 발생했는지부터 보겠습니다.
아래 사진은 Ssoco Timer 1.6.0 버전의 디데이 탭입니다.
4월은 30일까지밖에 없죠? 그래서 4월인 상태에서는 31일이 입력이 되지 않습니다.
하지만 5월은 31일까지 있기 때문에 31일이 입력이 됩니다.
이 상태로 월만 4월로 바꾸게 되면, 4월 31일이 화면에 출력이 됩니다.
JavaScript의 Date함수를 이용해서 구현했기 때문에 날짜 계산 자체에는 문제가 없습니다.
4월 31일도 알아서 5월 1일로 인식하고 계산을 해주니까요.
하지만 사용자는
4월 31일? 이게 말이나 되는 이야기야?
라고 생각할 수 있기 때문에 고칠 필요가 있다고 생각해서 저저번달에 이슈에 넣어놓고 바빠서 건드리질 못했네요.
게으른 나.. 반성합니다.
1. 1차 시도 (이벤트 핸들러)
문제를 파악했으니 바로 해결을 시도하려고 했습니다.
설명을 위해 간단하게 현재 코드 구조를 말씀드리면, input창에다가 내가 원하는 연, 월, 일을 작성(변경)하게 되면 onChange이벤트에 의해서 이벤트 처리 함수가 작동하게 되고, 정규식이나 이것저것 다른 친구들이 작동하면서 day상태값을 변경하기도 하고 화면에 떠있는 숫자가 처리되기도 하고.. 그런 방식으로 동작합니다.
dayOnChange함수를 보면 40줄에 maxDay를 미리 계산해둔게 있죠?
maxDay라는 상수값은 year, month 상태값을 이용해서 현재 월의 마지막 날짜를 가집니다.
이걸 바탕으로 44줄에서 입력값이 maxDay를 초과하게되면 return처리를 해버립니다.
그래서 제가 처음에 생각한건 이미 여기서 maxDay를 계산해뒀으니, 이걸 바탕으로 day가 maxDay보다 크면 day라는 상태를 maxDay로 바꾸면 되지 않을까 생각했습니다.
그래서 dayOnChange 이벤트 핸들러가 아닌 yearOnChange, monthOnChange 이벤트 핸들러에 해당 로직을 추가했습니다. 그리고 돌려보니??
원하는대로 동작하지 않고 한 박자 늦게 동작하는 불상사가 발생했습니다.
4월로 바꾸면 그대로 31일인데 3월로 바꾸면 그제서야 4월의 마지막 날인 30일로 변경되는 것이었죠.
2. 2차 시도 (useEffect)
그래서 이번에는 useEffect에서 처리하도록 로직을 수정했습니다.
위에서 시도한 로직과 정확히 일치하기 때문에 추가 설명은 따로 하지 않겠습니다.
위 코드만 추가하고 실제로 애플리케이션을 실행해서 테스트하면??
원하는 대로 잘 동작하는 모습을 확인할 수 있습니다!!
3. 왜.. 그럴까?
문제를 해결했으니, 이제 왜 해결됐을까에 대해서 살펴보겠습니다.
위에서 처리한 코드들을 보면 console.log가 보일겁니다.
먼저 이 친구들이 어떤 순서로 출력되어 있는지부터 확인해보죠.
useEffect에서는 출력값 앞에 test를 붙였고, 이벤트 핸들러 함수에서는 출력값 앞에 why를 붙였다는 것을 기억합시다!
useEffect 함수는 마운트/언마운트/리렌더링이 발생했을 때 작동하게 되고, 이벤트 핸들러 함수는 해당 DOM 위치에서 onChange 이벤트가 발생했을 때 작동합니다.
(언마운트 될 때는 useEffect 내부의 return 구문이 동작합니다!)
그래서 디데이 탭을 눌렀을 때 마운트가 발생해서 test가 출력되는 것이고, 요소들을 변경시킬 때는 why와 test가 출력되는 것이죠!
이제 더 나아가서, 왜 한 박자 늦게 작동하는가에 대해서 알아봅시다.
3.0. 이벤트 핸들러 케이스
5월 31일에서 5를 4로 바꿨다고 생각해봅시다.
그러면 onChange이벤트에 의해 monthOnChange 이벤트 핸들러가 동작하게 됩니다.
27줄부터 29줄에 의해서 day와 maxDay를 계산하는데, 이 계산은 입력값이 아닌 상태값을 기준으로 계산됩니다.
따라서 나는 month를 4로 바꾸려고 했지만 실제 상태는 여전히 5가 되는 것이죠.
즉 아래에서 열심히 연산을 해봤자 maxDay는 기존값을 기준으로 적용되기 때문에 한 박자 늦게 출력이 됩니다.
그렇다면 maxDay계산과 setDay함수를 setMonth 아래로 내리면 되지 않을까라고 생각할 수 있겠죠?
하지만 이것도 정상적으로 동작하지 않습니다.
그 이유는 바로 setState는 비동기로 동작하기 때문입니다.
위에서 setMonth함수가 동작했다고 하더라도 바로 month 상태가 변경되는 것이 아닙니다.
해당 상태 변경은 이벤트 핸들러가 종료된 이후에 react에 의해 일괄적으로 처리됩니다.
그리고 상태 변경 이후에 동작하는 내용들을 side-effect라고 부르고, 리액트는 side-effect를 useEffect에서 처리하는 것을 권장합니다.
setState 함수가 알괄로 처리되는 것은 해당 함수 내에서 setState함수가 여러번 발생했을 때 매번 리렌더링이 되는 경우 비용이 너무나도 크기 때문에 리액트에서 렌더링 최적화를 위해 설정해놓은 나름의 장치라고 볼 수 있습니다.
결국 이 장치때문에 하나의 함수 내에서 하나의 상태를 여러 번 변경하더라도 마지막 요소만 변경되는 것이죠!
(매개변수로 함수를 넣는 방식으로 동기적으로 작동하게 하는 트릭을 쓸 수도 있지만, 해당 내용이 핵심이 되는게 아니라 비동기로 동작한다는 것이 핵심이므로 여기서는 자세한 설명을 생략합니다!!)
결국 해당 이벤트 핸들러 안에서 특정 순서로 setState를 한다고 하더라도 일괄적으로 처리가 되기 때문에 setState가 된 이후의 상태값을 불러오는 것이 아니기 때문에 우리가 원하는 대로 setDay가 동작을 하지 않는다는 결론에 이르게 됩니다.
3.1. useEffect 케이스
그럼 반대로 왜 useEffect 내부에서 같은 로직을 사용했을 때 우리가 원하는 대로 동작하는 것일까요?
45줄에 의해서 setDay가 작동한다고 생각해봅시다.
day라는 상태값이 변화했기 때문에 리렌더링이 발생하게 되고, 리렌더링 작업이 끝나면 useEffect가 다시 동작하게 되겠죠?
여기서도 useState는 예외없이 비동기로 동작합니다.
따라서 45줄이 작동하더라도 아래 46줄부터 55줄까지의 코드들도 작동하게 됩니다.
그걸 확인하기 위해서 46줄에 console.log를 찍어보았고, input값을 변경했을 때 useEffect함수 전체가 2번 동작하는 것을 아래 움짤을 통해 확인할 수 있습니다.
그렇다면 왜 input값을 1번 변경했는데 콘솔이 2번이나 동작할까요..?
먼저 input값을 변경시키면 monthOnChange함수 내부에서 setMonth에 의해 month상태가 변경됩니다.
상태값이 변경되었기 때문에 리렌더링이 발생하고, useEffect가 1번 동작합니다.
근데 useEffect 내부에서 아까 만든 예외조건때문에 setDay이 작동하게 되고, 다시 day라는 상태가 변경되었기 때문에 useEffect가 1번 추가로 더 동작하게 됩니다.
이 과정에서 setState함수와는 관계 없이 전체 함수가 돈다는 것도 위에서 이야기했죠?
따라서 useEffect는 총 2번 동작해서 콘솔창에 2개의 "변해도 콘솔 찍히나?" 가 뜨게 되는 것이죠.
결국 setDay라는 함수가 정상적인 조건에서 처리될 수 있는지가 해당 내용의 핵심이었던 것이고, 이것을 useState의 비동기동작과 엮어서 바라보았을 때 이벤트 핸들러 내부가 아닌 useEffect함수 내부에서 처리해줘야 우리가 원하는대로 애플리케이션이 동작할 수 있다는 것을 알게 되었습니다!
4. 결론
useState가 비동기로 동작하고, useEffect가 마운트, 언마운트, 리렌더링이 발생했을 때 동작하고, side-effect를 처리하는 도구로 사용될 수 있다는 사실은 너무나도 단순하고 당연한 내용일 수 있으나, 처음 리액트를 접하거나 오랜만에 리액트를 접하는 경우 이렇게 헷갈리는 경우가 발생할 수 있습니다.
hooks가 어떻게 동작하는지, 리액트의 라이프사이클이 어떻게 작동하는지에 대해서 충분한 이해가 되어있어야 기능 추가나 디버깅에 있어서 시간 절약이 될 수 있기 때문에 따로 시간을 내서 공부하고 정리하는 것이 리액트를 사용하는 프론트엔드 개발자에게 있어서 필수적이라고 생각이 듭니다!! ㅎㅎ
최근댓글