react18 마이그레이션 jscodeshift로 처리하기

김정래·2025년 3월 7일
0
post-thumbnail

문제 상황

사내 Studio 팀에서는 FE 코드에서 React17을 사용중이다. 이를 React18로 업그레이드를 하는 과정에서 몇 가지 문제가 발생했다.

첫 번째로는 React의 Default Props를 넘겨주는 방식의 변경이다.

기존 React17의 Default Props 정의 방법

기존 React 17에서는 다음과 같이 'defaultProps' 를 통해 기본값을 정의해준다.

const Foo = ({ bar }) => {
    return <div>{bar}</div>;
};

Foo.propTypes = {
    bar: PropTypes.string,
};

Foo.defaultProps = {
    bar: "bar",
};

export default Foo;

React18에서의 Default Props 정의 방법

React18에서부터는 defaultProps 대신 구조분해 할당방식을 사용하는 것을 권장하며, 이전 방식을 쓸 경우 Error 콘솔로그가 출력된다.

const Foo = ({ bar = "Bar" }) => {
    return <div>{bar}</div>;
};

Foo.propTypes = {
    bar: PropTypes.string,
};

export default Foo;

React Dev: DefaultProps Value 할당 방식
alt text


해결 방안 고려

이러한 상황으로 인해, React 버전을 올리는 것에 어려움이 있었다.
팀에서 나온 해결 방안은

  • react 에러 출력 규칙에서 default props 에 대한 설정을 음소거 시키는 것
  • 담당자를 정해서 직접 모든 파일에 있는 값을 수정하는 것

첫 번째 방법은, 임시방편이고 표준적인 JS 나 React 사용 방식을 준수하기 위해서는 이번에 고치는 것이 적기라 판단하여 기각이 되었다.
두번째 방법은 시간도 오래 걸리고, 휴먼 에러가 생기기 쉽다는 문제가 있어 우려가 있었다.

이러한 문제를 보다가 기억이 난 글이 몇 가지 있었다.

toss tech 블로그에서 확인 했던 JSCodeShift 사용에 관한 아티클이었다.
toss tech: JSCodeShift로 기술 부채 청산하기;

간단하게 요약하자면, AST를 탐색하며 변경하고자 하는 코드블럭을 선택 및 수정한다는 개념이다.
단순 문자열 Find 방식에 비해 동일한 문자열이 등장하더라도 추상 트리 상에서 의미론적으로 다른 구문은 다른 방식으로 처리할 수 있다는 장점이 있다.

관련 내용을 적용해보기 딱 좋은 상황이라 생각하여, default props 문제를 해결하는 스크립트를 작성해보기로 하였다.

주요 개념

.defaultProps 로 정의된 값을 컴포넌트의 props 인자에 구조분해 할당으로 넣어주는 스크립트 작성하가.
이를 해결하려면 몇 가지 배경지식을 학습해야했다.

AST 문법

내가 조작하고 싶은 요소들이 AST 상에서 어떤 요소로 출력되는지를 사전에 학습해야한다.

AST Parser 별로 다를 수 있기 때문에, JSCodeShift에서 사용하난 @babel/parser 기준으로 우선 학습한다.

Studio 에서는 컴포넌트 정의 방식이 3가지가 혼용되고 있었다.

  1. Arrow Function 정의 방식
const Foo = ({ bar }) => {
    return <div>{bar}</div>;
};
  1. Function 정의 방식
function Foo({ bar }) {
    return <div>{bar}</div>;
}
  1. Class 정의 방식
class Foo extends Component {
    render() {
        const { bar } = this.props;
        return <div>{bar}</div>;
    }
}

우선 가장 많이 사용되는 Arrow Function을 기준으로 AST 구조를 분석해보았다.

ArrowFunction

AST 상에서는 Arrow Function 형식이 정의되는 Node의 타입은 type: "VariableDeclaration" 으로 분류가 된다.
그 내부에 VariableDeclarator 가 있고, 변수명이 정의되는 Identifier 와 평가되는 식이 들어가는 ArrowFunctionExpression 노드가 있다.

그 내부 params 배열에 ObjectPattern 이라는 타입으로 Object 객체가 들어간다.
ObjectPattern 하위에는 key 필드와 value 필드가 각각 존재하게 된다.

이렇게 되면 우선 컴포넌트 즉, 함수가 가지는 구조는 어느정도 이해할 수 있게된다.

Arrow Function의 AST 구조 예시

VariableDeclaration
└── declarations (Array)
    └── VariableDeclarator
        ├── id: Identifier (Foo)
        └── init: ArrowFunctionExpression
            ├── params (Array)
            │   └── ObjectPattern
            │       ├── key: Identifier (bar)  // 객체의 프로퍼티 이름
            │       └── value: Identifier (bar) // 객체의 프로퍼티 값

함수 정의 부분을 알았으면, default props가 정의되는 부분도 파악해야한다.

Foo.defaultProps = { bar = "bar"} 과 같은 부분은 ExpressionStatement 로 시작하는 Node에 들어간다.
그중 값을 할당 하는 노드는 AssignmentExpression 로 타입이 분류 되며, 등호('=')를 기준으로 left는 Foo.defaultProps, right 는 { bar = "bar"} 가 된다.
이때 right 에 들어가는 값은 Object 이므로 타입은 ObjectExpression 이 될 것이다.

DefaultProps 값 할당 영역 AST 구조 예시

ExpressionStatement
└── expression: AssignmentExpression
    ├── left: MemberExpression
    │   ├── object: Identifier (Foo)
    │   └── property: Identifier (defaultProps)
    ├── operator: "="
    └── right: ObjectExpression
         └── properties (Array)
              └── Property
                  ├── key: Identifier (bar)
                  └── value: Literal ("bar")

문제 해결 방안 수립

AST 개념을 적용하면 defaultProps를 옮기는 과정은 다음과 같이 요약할 수 있습니다.

  1. 모든 Foo.defaultProps 값을 추출한다.
  2. 해당 컴포넌트(여기서는 Foo)의 정의를 찾아, 함수의 매개변수 부분(params)을 확인한다.
  3. 추출한 defaultProps 값을 구조분해 할당 패턴에 맞게 매개변수에 추가한다.
  4. 변환 후 기존의 defaultProps 관련 ExpressionStatement는 삭제한다.

구현 사항

/**
 * AST에서 defaultProps 할당문 검색
 */
function findDefaultPropsAssignments(root, j) {
    return root.find(j.ExpressionStatement, {
        expression: {
            type: "AssignmentExpression",
            left: {
                type: "MemberExpression",
                property: { name: "defaultProps" },
            },
        },
    });
}

/**
 * 구조분해된 params에서 기본값 추가/교체하며 실제 수정이 발생했는지 여부 반환
 */
function transformDefaultsInObjectPattern(objPattern, defaultPropsObj, j) {
    let modified = false;

    defaultPropsObj.properties.forEach((prop) => {
        const keyName = prop.key.name || prop.key.value;
        if (!keyName) return;

        const index = objPattern.properties.findIndex((p) => {
            if (
                p.type === "AssignmentPattern" &&
                p.left.type === "Identifier"
            ) {
                return p.left.name === keyName;
            }
            if (p.type === "Property" && p.key.type === "Identifier") {
                return p.key.name === keyName;
            }
            return false;
        });

        if (index >= 0) {
            const existing = objPattern.properties[index];
            if (existing.type === "Property") {
                objPattern.properties[index] = j.assignmentPattern(
                    j.identifier(keyName),
                    prop.value
                );
                modified = true;
            }
        } else {
            objPattern.properties.push(
                j.assignmentPattern(j.identifier(keyName), prop.value)
            );
            modified = true;
        }
    });

    return modified;
}

/**
 * Arrow Function + 구조분해 된 params가 있는 컴포넌트를 찾아 변환을 적용
 */
function transformComponentDefaults(root, j, componentName, defaultPropsObj) {
    const compDecl = root.find(j.VariableDeclarator, {
        id: { name: componentName },
        init: { type: "ArrowFunctionExpression" },
    });

    let modified = false;

    compDecl.forEach((compPath) => {
        const arrowFunc = compPath.value.init;
        if (
            arrowFunc.params.length > 0 &&
            arrowFunc.params[0].type === "ObjectPattern"
        ) {
            const objPattern = arrowFunc.params[0];
            const changed = transformDefaultsInObjectPattern(
                objPattern,
                defaultPropsObj,
                j
            );
            if (changed) {
                modified = true;
            }
        }
    });

    return modified;
}

/**
 * defaultProps 할당 제거 및 컴포넌트 내부로 기본값 이동
 */
function transformDefaultProps(root, j) {
    let wasModified = false;

    const defaultPropsAssignments = findDefaultPropsAssignments(root, j);
    defaultPropsAssignments.forEach((path) => {
        const assignment = path.value.expression;
        const componentName = assignment.left.object.name;
        const defaultPropsObj = assignment.right;

        if (defaultPropsObj.type !== "ObjectExpression") return;

        const changed = transformComponentDefaults(
            root,
            j,
            componentName,
            defaultPropsObj
        );

        if (changed) {
            j(path).remove();
            console.log(`[MODIFIED] ${componentName}`);
            wasModified = true;
        }
    });

    return wasModified;
}

/**
 * 컴포넌트 변환
 */
function transformer(file, api) {
    const j = api.jscodeshift;
    const root = j(file.source);

    console.log(`[FILE] Processing ${file.path}`);
    transformDefaultProps(root, j);

    return root.toSource({
        quote: "double",
        trailingComma: false,
        tabWidth: 4,
        wrapColumn: 80,
        lineTerminator: "\n",
        reuseWhitespace: false,
    });
}

module.exports = transformer;
profile
https://github.com/Jeong-Rae/Jeong-Rae

0개의 댓글