
사내 Studio 팀에서는 FE 코드에서 React17을 사용중이다. 이를 React18로 업그레이드를 하는 과정에서 몇 가지 문제가 발생했다.
첫 번째로는 React의 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에서부터는 defaultProps 대신 구조분해 할당방식을 사용하는 것을 권장하며, 이전 방식을 쓸 경우 Error 콘솔로그가 출력된다.
const Foo = ({ bar = "Bar" }) => {
return <div>{bar}</div>;
};
Foo.propTypes = {
bar: PropTypes.string,
};
export default Foo;
React Dev: DefaultProps Value 할당 방식

이러한 상황으로 인해, React 버전을 올리는 것에 어려움이 있었다.
팀에서 나온 해결 방안은
첫 번째 방법은, 임시방편이고 표준적인 JS 나 React 사용 방식을 준수하기 위해서는 이번에 고치는 것이 적기라 판단하여 기각이 되었다.
두번째 방법은 시간도 오래 걸리고, 휴먼 에러가 생기기 쉽다는 문제가 있어 우려가 있었다.
이러한 문제를 보다가 기억이 난 글이 몇 가지 있었다.
toss tech 블로그에서 확인 했던 JSCodeShift 사용에 관한 아티클이었다.
toss tech: JSCodeShift로 기술 부채 청산하기;
간단하게 요약하자면, AST를 탐색하며 변경하고자 하는 코드블럭을 선택 및 수정한다는 개념이다.
단순 문자열 Find 방식에 비해 동일한 문자열이 등장하더라도 추상 트리 상에서 의미론적으로 다른 구문은 다른 방식으로 처리할 수 있다는 장점이 있다.
관련 내용을 적용해보기 딱 좋은 상황이라 생각하여, default props 문제를 해결하는 스크립트를 작성해보기로 하였다.
.defaultProps 로 정의된 값을 컴포넌트의 props 인자에 구조분해 할당으로 넣어주는 스크립트 작성하가.
이를 해결하려면 몇 가지 배경지식을 학습해야했다.
내가 조작하고 싶은 요소들이 AST 상에서 어떤 요소로 출력되는지를 사전에 학습해야한다.
AST Parser 별로 다를 수 있기 때문에, JSCodeShift에서 사용하난 @babel/parser 기준으로 우선 학습한다.
Studio 에서는 컴포넌트 정의 방식이 3가지가 혼용되고 있었다.
const Foo = ({ bar }) => {
return <div>{bar}</div>;
};
function Foo({ bar }) {
return <div>{bar}</div>;
}
class Foo extends Component {
render() {
const { bar } = this.props;
return <div>{bar}</div>;
}
}
우선 가장 많이 사용되는 Arrow Function을 기준으로 AST 구조를 분석해보았다.
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를 옮기는 과정은 다음과 같이 요약할 수 있습니다.
Foo.defaultProps 값을 추출한다.Foo)의 정의를 찾아, 함수의 매개변수 부분(params)을 확인한다.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;