React

리액트 - 데이터 의존성과 렌더링 타이밍을 고려한 비동기 함수의 호출 순서

게로망 2024. 12. 13. 10:46
언어 Java 17, JavaScript (ES6+)
프레임워크 Spring Boot 3.3.3, React 18.3.1
DB MySQL 8.x
빌드 도구 Gradle 7.x

 

제목은 참 난해해 보이지만 상황은 간단하다. 

 

리액트 컴포넌트를 만들던 와중에 테이블태그를 사용할 일이 있었고, 총 4개의 <tr> 그룹으로 이루어진 테이블이었다. 

 

예를들면 이런 식이다. 

 

function SomeManage() {
    const SomeRef = useRef();
    const { handleSubmit } = useForm();
    const [SomeIdCd, setSomeIdCd] = useState([]);

    const [mvInoutDiv, setMvInoutDiv] = useState([]);
    const [sexCd, setSexCd] = useState([]);
    const [gnrtCd, setGnrtCd] = useState([]);

    let defaultSexList = [];
    let defaultGnrtList = [];
    let defaultMvList = [];

    const setDefaultList = (targetList, codeType, codeKey) => {
        getCommonCodeList(codeType).then((data) => {
            targetList.push(
                ...data.map((item) => ({
                    [codeKey]: item.code,
                    name: item.name,
                }))
            );
        });
    };

    setDefaultList(defaultSexList, "SEX", "sex");
    setDefaultList(defaultGnrtList, "GNRT", "gnrt");
    setDefaultList(defaultMvList, "MV_INOUT_DIV", "mvInoutDiv");

    const [mvList, setMvList] = useState(defaultMvList);
    const [sexList, setSexList] = useState(defaultSexList);
    const [gnrtList, setGnrtList] = useState(defaultGnrtList);
    const [totalList, setTotalList] = useState([defaultTotalList]);

    useEffect(() => {
        setCommonCodeState("CLNT_CODE", setClntIdCd);
        setCommonCodeState("MV_INOUT_DIV", setMvInoutDiv);
        setCommonCodeState("SEX", setSexCd);
        setCommonCodeState("GNRT", setGnrtCd);
        setTotalList(defaultTotalList);
        search();
    }, []);
    
    ...

 

위 소스는 해당 페이지의 state 초기화 및 데이터 설정 로직의 일부분이다. 

간략한 설명을 하자면 DB에서 이사여부, 성별 등의 공통코드를 한 테이블에서 관리하고 있고 

setDefaultList()는 서버에서 데이터를 가져오기 전에 메타데이터를 공통코드테이블에서 미리 가져와서 화면을 구성하기 위한 용도의 함수이다. 

setCommonCodeState(), getCommonCodeList() 는 API를 호출해 해당 코드와 코드명을 매칭시켜주는 공통모듈 비동기 함수이다. 

 

해당화면의 요구사항은 데이터의 유무 관계없이 화면 렌더링과 동시에 테이블의 좌측에 해당 열의 구분을 쏴줘야 하는 것 이었다.  

 

이런식으로 

남자, 여자, 10대, 20대  이런 열 구분이 페이지 로드와 동시에 렌더링 되어야 한다

 

여기서 문제가 발생했다.

'이사여부' 관련 <tr>블록이 렌더링 되지 않는 것이었다. (정확히는 될 때도 있고, 안될 때도 있었다)

나머지 '성별', '연령대', '합계'도 마찬가지로 렌더링이 되지 않을 때가 있었다.

새로고침을 누르면 해결되긴 했으나 이건 명백한 오류였다. 

 

현재까지 주어진 정보로 위 소스만 보고 이유를 찾을 수 있겠는가?

 

 

 

나는 먼저 비동기 함수의 실행 순서를 의심했다.

setDefaultList(defaultSexList, "SEX", "sex");
setDefaultList(defaultGnrtList, "GNRT", "gnrt");
setDefaultList(defaultMvList, "MV_INOUT_DIV", "mvInoutDiv");

 이렇게 되어있던 setDefaultList() 함수 호출 순서를

setDefaultList(defaultMvList, "MV_INOUT_DIV", "mvInoutDiv");
setDefaultList(defaultSexList, "SEX", "sex");
setDefaultList(defaultGnrtList, "GNRT", "gnrt");

 

이렇게 열구분 순서에 맞게 바꾸었더니 문제가 현저히 줄었다.

 

순서를 바꾼 것만으로 차이가 있었던 이유는 비동기 작업의 실행 타이밍이 달라졌기 때문이다.

 

setDefaultList 함수는 데이터를 가져오는 비동기 작업(getCommonCodeList)을 수행한다. 이 작업이 완료되기 전에 화면이 렌더링되면서 초기화되지 않은 데이터를 참조해 화면이 제대로 보이지 않았던 것.

 

defaultSexList부터 비동기 작업이 시작되는데 이후 defaultGnrtList, defaultMvList의 작업이 차례로 호출되지만 각 작업의 완료 시점은 실행 순서와 다를 수 있었다. 수정 전 코드로는 defaultMvList가 렌더링 시점에 비어 있을 가능성이 컸던 것이다.

 

수정후 defaultMvList의 비동기 작업이 가장 먼저 실행되고, 렌더링 시점에 defaultMvList의 데이터가 초기화될 가능성이 높아져서 정상적으로 동작할 수 있었다. 

 

함수 호출 순서를 변경함으로써 운 좋게(?) 비동기 함수의 실행 완료 순서는 호출 순서에 따라 다를 수 있다는 것을 알게 되었다. 

 

 

다시한번 핵심을 정리하면,

 

비동기 함수 자체는 호출 순서에 상관없이 실행 타이밍이 독립적으로 이루어진다. 즉, 비동기 함수의 실행 순서와 완료 순서는 다를 수 있다.

 

하지만 비동기 함수 호출 순서가 중요한 경우가 있는데, 

 

1) 데이터 의존성이 있을 때

2) 렌더링 타이밍이 중요한 경우

 

이런경우이다.

 

즉, 어떤 비동기 함수의 결과를 다른 함수에서 사용해야 하는 경우, 또는  React에서 특정 비동기 데이터가 준비된 후 상태를 업데이트하고 이를 기반으로 컴포넌트를 렌더링해야 하는 경우, 호출 순서와 완료 순서를 명확히 해야 한다. 

 

 

 

이제 근본적인 문제 해결을 해보자. 

function SomeManage() {
    const searchRef = useRef();
    const { handleSubmit } = useForm();
    const [SomeCd, setSomeCd] = useState([]);

    const [mvInoutDiv, setMvInoutDiv] = useState([]);
    const [sexCd, setSexCd] = useState([]);
    const [gnrtCd, setGnrtCd] = useState([]);
    
    let defaultMvList = [];
    let defaultSexList = [];
    let defaultGnrtList = [];
    
    const setDefaultList = (targetList, codeType, codeKey) => {
    	return getCommonCodeList(codeType).then((data) => {
        	targetList.push(
            	...data.map((item) => ({
                		[codeKey]: item.code,
                		name: item.name,
                      }))
                 );
            });
       };

    const [mvList, setMvList] = useState(defaultMvList);
    const [sexList, setSexList] = useState(defaultSexList);
    const [gnrtList, setGnrtList] = useState(defaultGnrtList);
    const [totalList, setTotalList] = useState([defaultTotalList]);

    useEffect(() => {
        initialize();
    }, []);
    
      const initialize = async () => {
            await Promise.all([
                setDefaultList(defaultMvList, "MV_INOUT_DIV", "mvInoutDiv"),
                setDefaultList(defaultSexList, "SEX", "sex"),
                setDefaultList(defaultGnrtList, "GNRT", "gnrt"),
            ]);
            setTotalList(defaultTotalList);
            search();
        };
    
    ...

이건 문제를 해결한 최종 코드이다.

 

제일먼저 setCommonCodeState(), getCommonCodeList() 이 두 함수가 중복로직이라 하나를 제거했다.

 

그리고 useEffect에 setDefaultList 함수들을 위치시켰다. 페이지가 로드됨과 동시에 해당 함수들을 실행하기 위해서였다. 하지만 문제는 여전히 해결되지 않았었다. 

 

그래서 Promise.all 를 사용했다. 모든 비동기 작업이 완료될 때까지 기다린 후 다음 줄로 넘어가도록 만들기 위함이다. search(); 는 최초에 데이터를 한번 뿌려주기 위해 넣어놨는데 이것보다 위에서 사용했다. 즉, 배열에 기본값이 완전히 세팅된 후에야 데이터를 불러온 것. 

 

그리고 setDefaultList 함수가 데이터를 초기화한 후 Promise를 반환하도록 수정했다. 이를 통해 호출한 Promise.all이 각 작업의 완료 여부를 감지할 수 있었다.

 

순서를 정리하면 이렇다.

 

1) useEffect가 실행되면 initialize 함수가 호출됨

2) initialize는 Promise.all로 setDefaultList를 호출하여 defaultMvList, defaultSexList, defaultGnrtList를 병렬로 초기화 함

3) 이후 search를 호출하여 초기 데이터를 뿌림

4) 데이터가 완전히 준비된 후에 화면이 렌더링됨

 

 

이 작업은 하드코딩을 피하기 위함이기도 했다. 처음에는 서버에서 공통코드를 가져오지 않고 그냥 객체배열을 만들어서 썼었는데 유지보수 측면에서 매우 비효율적이라 저렇게 동적으로 가져오도록 바꿨다.

 

 

P.S. 다음날 대리님이 코드 읽기 힘들다고 싹 갈아엎었다...