문제 상황
쉐리어 프로젝트 개발계에서 포트폴리오 업로드 기능 확인 중이었다.
포트폴리오 업로드는 포트폴리오 가이드(모달)을 확인 후 확인 버튼을 클릭하면 파일을 업로드할 수 있는 인풋폼을 노출시키는 형태였다.
하지만 포트폴리오 가이드(모달)을 확인 후 확인 버튼을 누르고 파일탐색기에서 포트폴리오를 업로드하니 업로드가 되지않았다.
시간이 지난 후에도 업로드가 되지 않아 다시 한 번 업로드를 시도하니 이번에는 파일 업로드가 되었다.
여러 반복 끝에 이 상황이 버그라고 판단했고 즉시 사수님께 보고하여 문제해결을 시도했다.
코드
// 부모 컴포넌트 코드
const [shown, setShown] = useState(false); // 모달 확인 여부
const [showPop, setShowPop] = useState(false); // 모달 오픈 여부
const fileInput = useRef<any>(null); // 인풋을 참조할 ref
// 모달에서 확인 버튼 클릭 시 작동할 함수
const fileSearch = () => {
fileInput.current.click(); // 인풋폼 클릭 이벤트 작동
};
// 모달 컴포넌트 호출
<ModalUpload
show={showPop} // 모달 오픈 전달
setShow={setShowPop} // 모달 오픈 상태 변경 함수 전달
confirm={fileSearch} // 확인 버튼 클릭 시 작동할 함수 전달
setShown={setShown} // 모달 확인 여부 전달
/>
// 인풋 컴포넌트
<InputFile
type="file"
onChange={fileInputHandler} // 무시해도됨( 이 글과 무관)
value={portfolio} // 무시
ref={fileInput} // 인풋 참조
/>
// 모달 컴포넌트 코드
const onConfirmButtonClickHandler = () => {
setShow(false); // 모달 닫기
setShown(true); // 모달 확인 여부 업데이트(모달은 한 번만 보여줘야하기 때문에 존재함)
confirm(); // 모달 내 확인 버튼 클릭 시 작동되는 함수
};
<ShareerButton
onClick={onConfirmButtonClickHandler}
type="primary"
disabled={!check}
>
코드는 보안상 일부만 제공됩니다.
문제 분석
문제는 2가지 좁혀졌다.
- 왜 처음 업로드 시 업로드가 되지 않는가?
- 왜 처음 시도 이후부터는 업로드가 정상적으로 작동하는가?
문제 원인 추측
모달에서 확인 버튼 클릭 시 onConfirmButtonClickHandler() 함수가 실행되며 해당 함수는 아래 코드를 작동시킵니다.
1. setShow(false) - 모달 오픈 여부를 false로 변경하는 함수(비동기)
2. setShown(true) - 모달 열람 여부를 true로 변경하는 함수(비동기)
3. confirm(); - props로 전달받은 함수이며 모달에서 확인 버튼 클릭 시 실행된다.
(confirm으로 인풋폼 클릭 이벤트를 발생시키는 fileSearch함수가 전달되므로 동기로 작동한다.)
위를 토대로 onConfirmButtonClickHandler() 함수가 실행됐을 때 (콜스택 & 백그라운드 & 마이크로테스크큐 & 리렌더링) 관점으로 리액트의 작동 과정을 추측해보았습니다.
1. setShow(false) 실행 - 콜스택에 함수가 담기고 백그라운드로 옮겨진 후 작업이 완료되면 마이크로테스크 큐로 옮겨집니다.
=> (콜스택 : [ <anonymous>, setShow ])
=> (콜스택 : [ <anonymous> ])
=> (백그라운드 : [ setShow ])
2. setShown(true) 실행 - 콜스택에 함수가 담기고 백그라운드로 옮겨진 후 작업이 완료되면 마이크로테스크 큐로 옮겨집니다.
=> (콜스택 : [ <anonymous>, setShown ])
=> (콜스택 : [ <anonymous> ])
=> (백그라운드 : [ setShow, setShown ])
3. confirm() 실행 - 콜스택에 함수가 담기고 즉시 실행됩니다. 즉, 인풋폼 ref 참조의 click이벤트를 발생시킵니다.
=> (콜스택 : [ <anonymous>, confirm ])
=> (콜스택 : [ <anonymous> ])
=> (백그라운드 : [ setShow, setShown ])
===== 콜스택의 <anonymous> 실행 =====
4. 마이크로테스크 큐 > setShow() 실행 - 테스크큐에 있던 setShow를 이벤트 루프가 콜스택으로 이동시키고 setShow가 실행됩니다.
이 때, setShow로 인해 show 상태가 변화되고 리렌더링이 발생합니다. (리렌더링 과정에서 인풋의 ref 참조 변경)
=> (콜스택 : [ ]) ( 마이크로테스크 큐: [ setShow, setShown ])
=> (콜스택 : [ setShow ]) ( 마이크로테스크 큐: [ setShown ])
=> (콜스택 : [ ]) (마이크로테스크 큐: [ setShown ])
5. 마이크로테스크 큐 > setShown() 실행 - 테스크큐에 있던 setShown를 이벤트 루프가 콜스택으로 이동시키고 setShown가 실행됩니다.
이 때, setShown로 인해 shown 상태가 변화되고 리렌더링이 발생합니다. (리렌더링 과정에서 인풋의 ref 참조 변경)
=> (콜스택 : [ ]) ( 마이크로테스크 큐: [ setShown ])
=> (콜스택 : [ setShow ]) ( 마이크로테스크 큐: [ ])
=> (콜스택 : [ ]) ( 마이크로테스크 큐: [ ])
6. 끝
리액트의 작동과정을 생각해보니 버그의 원인을 추측할 수 있었습니다.
1. 페이지가 마운트되며 fileInput(ref)이 인풋폼을 참조합니다. (이 때 참조한 인풋폼을 인풋폼1이라 칭하겠습니다.)
2. 1번, 2번 과정에서 부모 컴포넌트의 상태(state)를 변경하는 함수(setState)를 비동기로 실행합니다.
3. 해당 상태 변경 함수들은 콜스택에서 백그라운드로 옮겨져 비동기 작업을 수행합니다.
(백그라운드로 옮겨진 함수(setState)들은 비동기 작업을 마친 후 마이크로테스크 큐로 이동하여 이벤트 루프가 작동될 때까지 대기합니다.)
4. 3번 과정에서 인풋폼1의 참조(fileInput)의 클릭 이벤트를 작동시켜 파일 선택창을 띄웠습니다.
(이 때, 사용자의 파일 선택창은 인풋폼1을 참조하고 있습니다.)
5. 콜스택이 비워지고 이벤트 루프가 작동되어 마이크로테스크 큐의 콜백 함수들이 실행됩니다.
(이 때, 부모 컴포넌트에 상태변화가 발생합니다.)
6.상태 변화에 따라 부모 컴포넌트가 리렌더링되며 이 과정에서 fileInput은 다시 선언되어 새로운 인풋폼인 인풋폼2를 참조합니다.
7. 사용자가 파일을 업로드해도 사용자의 파일 선택창은 인풋폼1을 참조하고 있었기 때문에 해당 파일은 업로드 되지 않습니다.
해결 방법
두가지 해결 방법을 찾았습니다.
1. 마이크로테스크 큐와 매크로테스크 큐의 우선순위를 이용해 상태 변화 이후에 인풋폼 클릭 이벤트 실행
// 부모 컴포넌트 코드
const fileSearch = () => {
setTimeout(() => { // setTimeOut은 매크로테스크 큐이다.
fileInput.current.click();
}, 0);
};
<ModalUpload
show={showPop}
setShow={setShowPop}
confirm={fileSearch}
setShown={setShown}
/>
위와 같이 수정하면 상태변화함수(setState)는 마이크로테스크 큐로, 시간 지연 함수(setTimeOut)는 매크로테스크 큐로 들어간다.
이 때, 작동 우선순위는 마이크로테스크 큐 > 매크로테스크 큐 이기 때문에 항상 상태변화함수가 먼저 실행된다.
따라서, 위 코드는 상태변화가 일어난 후 즉, 상태변화로 인한 참조 변경(인풋폼1 => 인풋폼2)이 완료된 후 인풋폼 클릭 이벤트 실행한다.
참조가 변경된 후 사용자에게 파일 선택창을 열어주기 때문에 파일 업로드가 정상적으로 이루어진다.
2. 상태변화(setState)를 동기적 흐름으로 실행(async / await)
// 모달 컴포넌트 코드
const onConfirmButtonClickHandler = async () => {
await setShow(false);
await setShown(true);
confirm();
};
<ShareerButton
onClick={onConfirmButtonClickHandler}
type="primary"
disabled={!check}
>
위와 같이 수정하면 상태 변화 코드의 작업 완료를 기다린 후 confirm()함수가 실행된다.
즉, 상태변화를 동기적으로 실행하고 상태변화 이후 인풋 참조의 클릭 이벤트를 발생시킨다.
이 방법도 위와 마찬가지로 상태변화 이후 인풋 참조의 클릭 이벤트를 발생시킨다는 점에서 원리가 같다.
느낀점
오늘도 상태관리의 중요성에 대해 느꼈다.
사소한 버그이지만 사용자 경험을 저하시키는 버그였다.
사용자 경험은 나아가 서비스 신뢰도, 서비스 재방문에 큰 영향을 끼친다고 생각하기 때문에 오늘의 버그를 발견하고 핫픽스한 내 자신이 자랑스러웠다.
앞으로도 팀피어에서 쉐리어를 개발하며 사용자경험을 중시하는 프론트엔드 개발자로 성장하고 싶다고 생각했다.
'프로젝트' 카테고리의 다른 글
[팀피어] 사이드 프로젝트에서 내가 개발한 기능이 처음으로 운영 서비스에 반영됐다!! (0) | 2024.11.11 |
---|---|
[포켓몬 맞추기 게임] 데이터 패칭 리팩토링 (2) | 2024.10.19 |
첫 외주 (0) | 2024.02.07 |