사용자들은 '내 사물함' 페이지에서 간단한 메모를 저장해둘 수 있다. 기존에는 localStorage에 저장되는 방식이었는데 브라우저를 바꾼다든가, 컴퓨터를 껐다 켰다든가 하면 날아가는 부분 등이 문제가 되어 DB에 저장하는 방식으로 변경했다.
그런데 얼마 전 수정취소 버튼을 만들면서 필요없어 보이는 일부 로직을 수정한 것이 원인이 되었는지, 수정확인 버튼을 눌렀을 때 API 요청 및 DB 저장은 정상적으로 되는데도 새로고침하면 기본값("필요한 내용을 메모해주세요")으로 돌아가 있는 문제가 발생했다. (그저께 분명 잘 돌아가는 걸 확인했는데?)
사진 속 페이지의 위치 정보나 대여일 등 모든 정보를 동일한 객체 myLentInfo
에서 받아오고 있어서, 다른 데이터는 잘 표시되는데 메모 부분만 문제가 생기는 것으로 보아 API 응답에는 문제가 없고 메모 부분 업데이트 로직의 문제일 것이라고 생각했다.
문제의 원인을 정확히 짚어내는 데 헤맸던 원인은 다음과 같이 진단했다:
📌 TL;DR
'메모장 내부 텍스트' 라는 하나의 view 요소에 관여하는 변수가 currentContent, textValue, inputValue로 3가지나 되고, 각자 선언 혹은 업데이트되는 시점이 모두 다른 로직에 있다는 점이 코드 가독성을 떨어뜨리는 큰 요인이 되었던 것 같다.
위 사진 속 비밀스러운 메모장
요소의 구조는 아래와 같다.
<LentTemplate>
<LentInfo />
<LentTextField />
<EditButton />
</LentTextField>
</LentInfo>
<ReturnButton />
</LentTemplate>
LentTemplate
: atomic design pattern의 template 단위 요소로, 구성 요소들을 적절하게 배치하는 역할LentInfo
: organism 단위 요소로, 사용자의 대여 정보 데이터를 받아 하위 요소들에게 뿌려주는 역할LentTextField
: Molecule 단위 요소로, 사물함 제목 및 사용자 메모장에 사용된다.EditButton
: atom 단위 요소로, 수정 창을 열고 수정내용을 서버로 보내거나 수정을 취소하는 기능을 지닌다.ReturnButton
: Molecule 단위 요소로, 사물함 반납 기능을 지닌다.여기서 ReturnButton과 LentInfo가 모두 cabinet_type 데이터를 필요로 하면서 같은 계층에 있으므로, API 호출은 공통 부모인 LentTemplate에서 하게 되었다.
문제는 LentTextField와 EditButton의 역할 분리였다.
LentTextField는 수정하지 않고 있을 때 보여지는 값인 textValue와 수정창에서 보여지는 inputValue를 state로 갖고 있다. 여기서 textValue는 최초 렌더링 시 useEffect에 의해 props로 받아온 currentContent 데이터에 따라 아래와 같이 업데이트된다.
useEffect(() => {
console.log(currentContent);
if (currentContent) {
setTextValue(currentContent);
} else {
setTextValue(
contentType === "title"
? "방 제목을 입력해주세요"
: "필요한 내용을 메모해주세요"
);
}
}, []);
즉 데이터 흐름이 다음과 같아진다:
myLentInfo
state에 저장myLentInfo.cabinet_memo
를 currentContent
props로 전달textValue
state 업데이트inputValue
state 업데이트inputValue
에 따라 textValue 수정 후 서버로 전달여기서 textValue 업데이트 로직이 LentTextField와 EditButton에 모두 존재하는 부분이 혼동을 가중시켰다. 또한 inputValue는 전적으로 textValue의 변경에 의존하여 변경되지만, 수정을 취소하는 경우에는 예외적으로 setInputValue에 의해 변경되어야 한다는 점 역시 코드를 다시 파악하는 과정에서 헷갈리게 만들었다.
애초에 스스로 짠 코드를 다시 파악하는 데 시간이 걸렸다는 점부터 반성해야 할 것 같지만 🥲
로직이 여러 군데 있을수록, 또 같은 view 요소(여기서는 메모장 내부의 텍스트)에 관여하는 변수가 많을수록 데이터 흐름과 디버깅 과정이 어려워진다는 점을 깊이 체감했다.
📌 TL;DR
useEffect가 '컴포넌트 마운트 직전에 실행되는 것'으로 잘못 알고 있었던 점으로 인해 원인이 비동기 동작에 있는 것으로 오인하고 불필요하게 시간을 지체시켰다.
페이지가 처음 렌더링될 때 여기저기 console.log를 찍어서 확인해 보곤(어떤 값인지는 여기서 별로 중요하진 않다), 처음에 undefined와 빈 배열이 뜨는 것에 멘붕했다. 아예 데이터가 안 들어가고 있는 거라고?
애초에 페이지 구조가 위에서 이야기한 바와 같은데 LentTemplate부터 순서대로 console.log가 찍히지 않고 LentTextField부터 console.log가 찍히는 것도 당황스러웠다.
그래서 문제의 원인이 비동기 동작에 있는 줄 착각하고 잘못된 부분을 파고드느라 시간을 썼다. 왜냐하면 console.log를 전부 useEffect 내부에서 찍고 있었기 때문인데, setState는 동기성을 보장하지 않는다고 얕게 알고 있었던 부분과 맞물려 "setState가 실행되는 동안 아래의 return문이 먼저 실행되는구나!" 라고 문제의 핵심을 잘못 파악했다.
내가 기대한 흐름은 다음과 같았다:
즉 상위 컴포넌트의 useEffect 실행 ➔ 그려짐 ➔ 자식 컴포넌트의 useEffect 실행 ➔ 그려짐 ➔ ...
과 같이 무조건 top-down 방식으로 렌더링될 것이라고 착각한 것이다.
여기서 가장 큰 착각은 useEffect가 컴포넌트 마운트 직전에 실행된다고 생각한 점이다. useEffect는 컴포넌트가 렌더링된 직후에 실행되는 hook이다.
정확히는 useEffect에 등록해둔 effect(부수효과함수)가 별도 메모리에 올라가 있다가 렌더링 이후에 실행되는 것이지만, 편의상 아래에선 그냥 useEffect가 실행된다고 하겠다.
컴포넌트 마운트 직전에 실행하고 싶은 코드가 있다면 useLayoutEffect를 사용해야 한다. 그래야 브라우저가 화면에 Paint 되기 전에, 해당 hook에 등록해둔 effect가 '동기'로 실행된다. 이 때 state, redux store 등의 변경이 있다면 한번 더 재렌더링 된다.
따라서 useEffect를 사용한 내 코드의 실제 동작은 다음과 같아진다:
내가 예상한 방향과 정반대로 컴포넌트들이 마운트되고 있었던 것이다.
따라서 위에 첨부된 사진과 같이 LentTextField의 console.log가 먼저 찍힌 건, 비동기 동작이 아니라 내가 컴포넌트 렌더링 과정을 착각한 데서 발생한 문제였다. 모든 console.log를 useEffect 내부에서 찍고 있었기 때문인데, console.log를 useEffect 바깥으로 빼면 아래와 같이 top-down 방향으로 잘 찍힌다.
앞서 보았듯 문제의 근원은 useEffect를 잘못 사용하고 있는 부분임을 알아냈다. 기존 코드의 textValue 업데이트 로직은 아래와 같았다.
// LentTextField.tsx
useEffect(() => {
if (currentContent) {
setTextValue(currentContent);
} else {
setTextValue(
contentType === "title"
? "방 제목을 입력해주세요"
: "필요한 내용을 메모해주세요"
);
}
}, []);
currentContent
: 조상인 LentTemplate
이 렌더링될 때 호출되는 API로부터 받아오는 데이터 (LentTemplate ➔ LentInfo ➔ LentTextField)위 코드에 내가 기대한 동작은 이렇다:
이 상태에서 수정 후 저장 버튼을 누르면 다음과 같은 동작이 발생할 것이다:
하지만 LentTextField의 실제 동작은 아래와 같았다:
따라서 저장버튼을 누른 직후에는 값이 잘 들어간 것처럼 보인다. 하지만 새로고침을 누르면 위의 잘못된 동작으로 인해 무조건 textValue는 기본값으로 설정되는 문제가 생긴 것이다.
이 문제는 같은 42cabi 프론트 팀의 hybae님이 간단히 해결해 주셨다. 위 useEffect의 dependency array에 currentContent를 넣으면 된다.
// LentTextField.tsx
useEffect(() => {
if (currentContent) {
setTextValue(currentContent);
} else {
setTextValue(
contentType === "title"
? "방 제목을 입력해주세요"
: "필요한 내용을 메모해주세요"
);
}
}, [currentContent]);
이러면 수정 후 새로고침 시, 위의 문제 동작은 같지만 추가적으로 아래와 같은 재렌더링 과정이 일어난다:
따라서 결과적으로는 새로고침해도 업데이트된 값이 잘 보이게 된다!
같은 데이터를 사용하는 LentInfo
의 나머지 부분들은 아예 useEffect를 사용하지 않고 있어서 문제가 확인되지 않은 것이었다.
위와 같은 방식으로 문제는 해결되었지만, 순식간에 렌더링이 두 번 일어나는 방식이기 때문에 새로고침 시 기본값이 잠깐 깜빡거렸다가 값이 들어오는 문제가 발생한다. 또한 애초에 기대했던 로직이 아니라 문제 상황은 여전히 발생하고, 재렌더링으로 그 위에 덮어씌우는 방식이기도 하다.
useLayoutEffect든 useEffect든 return 실행 및 렌더링 단계 이후에 effect가 실행되므로 재렌더링을 아예 막을수는 없다.
하지만 useLayoutEffect의 경우, effect의 실행 과정에서 state가 변경되면 재렌더링 된 후에 비로소 컴포넌트를 화면에 paint하므로 적어도 사용자에게는 깜빡거리지 않고 한번에 최신 컴포넌트가 보여지게 된다.
즉 렌더링 ➔ paint ➔ 재렌더링 ➔ paint
와 렌더링 ➔ 재렌더링 ➔ paint
차이인 것 같다. 따라서 useLayoutEffect를 사용해서 다시 개선해볼 수 있는 코드인 것 같기도 하다.
참고: