소개
안녕하세요! 프론트엔드 개발자 김동규입니다.
저는 실무에서 프론트엔드 동료분들을 위한 사내 라이브러리를 개발하고 있어요. 최근에 로깅을 위한 라이브러리를 제공하던 중에 로그 포맷이 변경되었어요. 그런데 이 로깅 라이브러리를 한두군데서만 사용하는 것이 아닌데 변경된 포맷을 적용하려면 상당한 시간과 인력이 필요해요. 개발자분들이 비즈니스 업무에만 집중할 수 있도록 효율적으로 로그 포맷을 변경하는 방법은 없는 걸까요?
이번 포스트에서는 간단하게 jscodeshift를 사용하는 방법 그리고 개발자 경험 향상을 위해 제가 어떤 고민을 했고 어떤 방법으로 해결했는지 공유합니다.
jscodeshift란?
jscodeshift는 Meta에서 만든 라이브러리로 JavaScript 또는 TypeScript 파일에 작성된 코드를 변경할 수 있도록 도와줘요. 여기서 변경이란, 기존 코드를 삭제하거나 수정이 가능하고 신규 코드를 추가할 수 있는 기능들을 의미해요.
jscodeshift의 정확한 역할은 AST(Abstract Syntax Tree)를 수정하는 도구입니다. jscodeshift는 코드를 파싱하여 AST로 변환하고, 이 AST를 기반으로 코드를 분석하고 변환해요. 변환이 완료되면 수정된 AST를 다시 코드로 변환하여 파일에 저장해요. 이를 통해 코드의 구조를 변경하거나 특정 패턴을 대규모로 수정할 수 있어요.
AST란?
AST(Abstract Syntax Tree, 추상 구문 트리)는 프로그래밍 언어로 작성된 소스 코드를 트리 구조로 표현한 것인데요. 각 노드는 코드의 구성 요소(변수, 함수, 연산자 등)를 나타내며, 컴파일러와 코드 분석 도구가 코드의 구조를 이해하고 변환하는 데 사용돼요.
간단한 예시를 볼까요?
다음과 같은 JavaScript 코드를 AST로 표현해 볼게요(AST의 양이 상당한 관계로 일부분만 보여드릴게요).
const name = "po4tion";
IDE에서 사용된 단 23글자이지만 AST에서는 정말 많은 정보를 보여주고 있는 것을 확인할 수 있어요. 해당 정보는 프로그래밍 언어는 JavaScript, 파서는 @babel/parser를 사용했어요.
함수 선언도 봐볼까요?
function name() {}
상수 선언과 함수 선언의 AST를 보면 일련의 규칙과 구분자들이 있다는 것을 알 수 있는데요. 상수 선언문은 첫 번째 type이 VariableDeclarator, 함수 선언문은 FunctionDeclaration으로 구분되어 있어요. jscodeshift는 AST의 이런 구분자들에 쉽게 접근할 수 있는 메소드들을 제공하여 코드를 변경하도록 도와줘요.
현재까지의 정보들만 보자면 복잡하고 어려울 수 있어요. 그러면 이런 생각이 들 수도 있어요.
"그냥 IDE 내장 기능을 사용하면 안 되나요?"
IDE 내장 기능을 사용하지 못한 이유
아마 대다수의 프론트엔드 개발자분이 Visual Studio Code(이하, VSC)를 사용하실 거예요. 제 동료 개발자분들도 VSC를 사용하고 있어요. VSC에는 Search & Replace 기능이 있어요.
이 기능은 전체 파일에서 사용되고 있는 공통 코드들을 한 번에 변경할 수 있도록 도와줘요. 또한 정규 표현식까지 제공해 줘요. 너무나 powerful 해요. 그런데 왜 이 기능을 사용하지 못했을까요?
그 이유를 설명해 드릴게요. 이번에 로그 포맷이 변경되면서 해결해야 할 세 가지 문제가 생겼었어요.
Problem 1. Logger가 사용된 실제 파일의 위치를 추적해야 한다
이번에 변경된 로그 포맷에는 filepath가 들어가야 해요. 처음에는 node.js가 기본 제공해 주는 __filename을 사용하면 될 줄 알았어요. 하지만 이건 정말 큰 착각이었어요.
현재 로깅 라이브러리를 사용하고 있는 대다수의 프로젝트는 Next.js 프레임워크로 구성되어 있어요. Next.js는 'next dev' 또는 'next build' 명령어가 실행할 때에 내부적으로 모듈 번들러가 동작하여 .next 폴더가 생성되는데요. 이 때문에 __filename
을 사용할 수 없었어요.
이유가 뭘까요?
Next.js가 제공하는 .next 폴더를 살펴보면 그 원인을 쉽게 파악할 수 있는데요. components, utils 등의 폴더에서 선언된 모듈들이 동작하기 위해서는 결국에는 전부 page.tsx
파일에서 사용되어야 하고 번들러가 여러 개의 파일을 하나로 묶어줘요. 이는 각기 다른 폴더에서 선언된 __filename
이 빌드 후에 생긴 page.js
경로를 갖게 됨을 의미해요.
위의 사진은 순서대로 app/page.tsx > utils/isServer.ts > components/Component.tsx > app/layout.tsx에서 호출된 __filename
값들이에요. 제가 원하는 건 실제 파일의 위치이지 빌드된 결과물이 놓인 위치가 아니에요.
이 문제를 확인하고 저는 고민을 하게 되었어요.
Problem 2. 개발자의 몰입을 방해하지 말자
아무리 생각해도 수많은 Logger
에 일일이 새로운 전달인자를 추가해달라고 하는 것은 최악이라고 생각했어요. 만약에 로그 포맷이 또 바뀌어 신규 전달인자가 추가되거나 삭제되면 어떻게 될까요? 저의 경우에는 파라미터 하나만 추가하거나 삭제하면 되지만 로깅 라이브러리를 사용하는 개발자들은 무수히 많은 반복 작업을 해야 할 거에요.
또한 이커머스 서비스 특성상 로깅은 굉장히 중요해요. 이른 시일내에 해당 포맷이 적용되어야 했어요. 저는 개발자들을 위한 개발을 하면서 항상 생각하는 게 있어요.
"최대한 그들의 몰입을 방해하지 말자."
이를 상기하며 이번 업무의 목표는 개발자들이 그저 로깅 라이브러리의 업데이트 버전만 바꿔주면 더는 신경 쓸 게 없도록 하는 게 돼버렸어요.
Problem 3. catch문의 error를 Logger와 연계해야 한다
우리들이 작성하는 코드에는 수많은 try-catch문이 존재해요. 사내 개발자분들도 try-catch 문에서는 로거를 작성해야 한다는 것을 알고 있지만 결국에 휴먼 에러는 피할 수 없었어요.
휴먼 에러는 어디서든 존재하기 때문에 catch 문에서 error 로거를 깜빡하고 사용하지 않은 것은 어쩔 수 없어요. 하지만 사용했음에도 error 메시지를 로거에 넘기지 않은 것은 이른 시일내로 해결할 수 있는 문제라고 생각했어요.
문제를 해결할 방법
위의 세 가지 문제를 해결하기 위해서는 VSC로는 불가능했어요. Logger
를 사용 중인 각 파일의 filename을 넣는 것은 물론이고 VSC로 해결한다는 것 자체가 개발자들의 노력이 필요한 일이에요. 또한 catch 문의 error 메시지를 Logger
에 넣어줄 수도 없어요. 이와 같은 이유로 IDE 내장 기능을 사용하지 못했어요.
그래서 어떤 방법으로 이 문제를 해결할 수 있을까 생각하던 차에, 평소에 자주 읽던 테크 블로그들이 생각나서 이곳저곳 찾아봤어요. 다행히 토스의 테크 블로그에서 "JSCodeShift로 기술 부채 청산하기" 글을 찾았고 여기서 'AST를 사용해서 대규모 코드 변경을 하면 되겠구나'라는 큰 영감을 얻었어요. 이후 리서치를 통해 ts-morph와 jscodeshift가 후보군에 올랐었는데요.
먼저, 사용할 기술을 적용하기 위해 로깅 포맷을 변경하는 과정에서 ts-morph를 사용한다고 가정해봤어요. 일단, TypeScript Compiler API의 필요성을 느끼지 못했어요. AST의 노드 타입말고 TypeScript의 타입이 필요할 정도의 코드 로직은 아니었기 때문이에요.
jscodeshift를 사용한다고 가정해보면, jscodeshift가 ts-morph 보다 star 수가 2배(9.2k)나 많아요. 그리고 Meta에 직접 관리하고 있고, 레퍼런스가 jscodeshift쪽이 훨씬 다양하게 많았어요. 또, 관련된 커뮤니티까지 있었어요. jscodeshift는 CLI로 TypeScript와 TSX확장자를 지원해주기 때문에 TypeSciprt 기반의 프로젝트에 적용하는데 큰 문제도 없었어요.
이러한 고민 끝에, 세 가지 문제를 해결할 방법으로 jscodeshift를 선택했습니다. 그럼 이제, 제가 어떤 식으로 jscodeshift를 사용했는지 볼까요?
jscodeshift 사용법 빠르게 익히기
본격적으로 들어가기에 앞서 AST를 보면서 jscodeshift를 어떻게 사용해야 하는지 공유해 드릴게요. 생략하실 분들은 jscodeshift로 개발자 경험 향상시키기 섹션으로 이동해주세요.
지금부터 작성되어 있는 코드들은 전부 https://github.com/po4tion/jscodeshift-dx-improvement에서 확인하실 수 있어요.
Find
무엇이든 좋아요! 내가 원하는 무언가를 찾아볼까요?
// ./transform/find.test.ts
const myName: string = "po4tion";
저는 문자열 상수를 선언했어요. 그리고 "po4tion"이라는 문자열을 갖고 있는 파일들의 경로를 찾아볼게요.
import { API, FileInfo } from "jscodeshift";
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
// 첫번째 방법 - 범위를 한정적으로 좁혔으므로 속도가 더 빠르다(상수 또는 변수로 선언된 'po4tion'을 찾는 경우)
source
.find(j.VariableDeclarator, {
init: {
type: "StringLiteral",
},
})
.forEach((path) => {
if (path.node.init?.type === "StringLiteral") {
if (path.node.init.value === "po4tion") {
console.log(`🚀 ${file.path}`);
}
}
});
// 두번째 방법 - 범위가 더 포괄적이지만 직관적임(그저 문자열인 'po4tion'을 찾는 경우)
source.find(j.StringLiteral).forEach((path) => {
if (path.node.value === "po4tion") {
console.log(`🚀 ${file.path}`);
}
});
}
transformer 내부의 코드를 이해하기 위해서는 AST를 봐야 해요. 저는 https://astexplorer.net/를 추천드려요. 언어는 JavaScript, parser는 babel/parser를 선택해 주세요.
find 메소드는 AST의 "type" 명을 갖고 파일들을 순회해요. 두 번째 인자는 여과할 노드의 정보를 전달할 수 있어요. 첫 번째 방법으로 말씀드리자면, VariableDeclarator가 하나의 파일에 여러 개 선언되어 있을 수도 있으니 forEach
메소드를 사용해요. forEach
콜백의 파라미터로 사용되는 path에는 node 정보가 담겨있는데요. 각 node 정보는 AST에서 보이는 key-value 쌍들을 갖고 있어요.
그럼 코드를 실행시켜 볼까요?
pnpm jscodeshift -t ./transform/find.ts --extensions=ts --parser=ts './transform/find.test.ts' --print
터미널에 파일 경로가 찍혔다면 성공입니다!
Remove
이제는 지워볼까요?
// ./transform/remove.test.ts
function remove() {
const myName = "po4tion";
const myName2 = "po4tion";
}
remove
함수를 선언하고 그 안에 2개의 상수를 선언했어요. 저는 이 중에서 myName2로 선언된 node를 제거해 볼게요.
import { API, FileInfo } from "jscodeshift";
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
source
.find(j.VariableDeclarator)
.filter(
(path) => path.node.id.type === "Identifier" && path.node.id.name === "myName2"
)
.remove();
return source.toSource();
}
AST를 봐볼까요?
2개의 "type"과 1개의 "name" 값을 통해 파일들을 순회하면서 해당 node를 지우는 코드에요. 한번 실행시켜 볼까요?
pnpm jscodeshift -t ./transform/remove.ts --extensions=ts --parser=ts './transform/remove.test.ts' --print
스크립트의 맨 마지막에 있는 --print 옵션을 사용하면 터미널에 변경된 소스코드가 보이게 돼요. 터미널만 아니라 실제 파일에서도 myName2
변수는 사라졌어요.
Modify
이번에는 수정입니다.
// ./transform/modify.test.ts
const person = {
name: "김동규",
};
person 객체의 "김동규"라는 문자열을 "홍길동"으로 수정해 볼게요.
// ./transform/modify.ts
import { API, FileInfo } from "jscodeshift";
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
source
.find(j.ObjectProperty, {
value: {
type: "StringLiteral",
value: "김동규",
},
})
.forEach((path) => {
if (path.node.value.type === "StringLiteral") {
path.node.value.value = "홍길동";
}
});
return source.toSource();
}
이제 어느 정도 감이 오셨나요? jscodeshift는 AST를 보면서 함께한다면 코드 작성에 있어서 큰 도움을 받을 수 있어요!
pnpm jscodeshift -t ./transform/modify.ts --extensions=ts --parser=ts './transform/modify.test.ts' --print
홍길동으로 수정이 잘 되었네요!
Add
이제 마지막 추가 기능을 봐볼까요?
// ./transform/add.test.ts
const IAM = {
name: "김동규",
};
IAM이라는 객체의 키-값으로 job-developer를 추가해 볼게요!
import { API, FileInfo } from "jscodeshift";
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
source.find(j.ObjectExpression).forEach((path) => {
path.node.properties.push(
j.property("init", j.identifier("job"), j.stringLiteral("developer"))
);
});
return source.toSource();
}
property 메소드를 통해서 "job" 이름을 가진 Identifier를 만들고 StringLiteral 값으로 "developer"를 넣어줍니다.
pnpm jscodeshift -t ./transform/add.ts --extensions=ts --parser=ts './transform/add.test.ts' --print
IAM 객체에 새로운 값이 잘 추가되었네요! jscodeshift를 통해서 find, remove, modify, add 기능을 알아보았는데요. 다음에 소개해 드릴 내용은 실무에서 jscodeshift를 사용한 방법입니다.
jscodeshift로 개발자 경험 향상시키기
jscodeshift로 Logger에 원본 파일의 위치를 추가하기
Next.js가 build 되기 전, jscodeshift를 통해 전체 파일을 순회하면서 Logger
를 찾아 신규 전달인자를 삽입하는 방법으로 __filename
문제를 해결할 수 있었어요.
제가 만든 로깅 라이브러리는 winston
을 내부적으로 사용하고 있어요. 따라서 총 7개의 logging level을 갖고 있는데요. 따라서 jscodeshift를 사내에서 사용하고 있는 모든 logging level을 찾아내어 원본 파일의 위치를 추가해야 했어요.
하지만 이번 섹션에서 공유해 드리는 코드는 public repository로 공개하고자 하므로 실무에서 사용하는 실제 코드와는 다소 다르다는 점 참고 부탁드릴게요. 최대한 핵심이 되는 코드만 공유해 드리고자 해요.
// ./transform/findAndModifyToLogger.test.ts
console.debug({
developer: "po4tion1",
});
const a = () => {
console.info({
developer: "po4tion2",
});
};
function b() {
console.error({
developer: "po4tion3",
});
}
class C {
constructor() {
console.warn({
developer: "po4tion3",
});
}
}
테스트용 코드에서는 Logger
를 node.js가 기본 제공해 주는 console 모듈을 사용할게요.
총 4개 레벨의 console
과 4개의 다른 표현식에서 선언된 console
에 jscodeshift를 사용하여 filepath를 추가해 볼게요.
import { API, ASTPath, FileInfo, ObjectExpression } from "jscodeshift";
const IDENTIFIER = {
filepath: "filepath",
} as const;
/**
* Returns Changed AST
* @param file - The first input FileInfo
* @param param1 - The second input API
* @returns Changed AST string
*/
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
source
.find(j.CallExpression, {
callee: {
type: "MemberExpression",
object: {
type: "Identifier",
name: "console",
},
property: {
type: "Identifier",
},
},
})
.forEach((path) => {
const loggerArguments: ASTPath<ObjectExpression[]> = path.get("arguments");
const hasObjectExpression =
loggerArguments.value.length > 0 &&
j.ObjectExpression.check(loggerArguments.value[0]);
// console의 argument에 객체 표현식이 있는지 검증
if (hasObjectExpression) {
const { properties } = loggerArguments.value[0];
const hasFilepath = properties.some((property) => {
// property's type guard
if (j.ObjectProperty.check(property)) {
const { key } = property;
return j.Identifier.check(key) && key.name === IDENTIFIER.filepath;
}
return false;
});
// filepath 값이 선언되어 있지 않은 경우에만 AST 수정
if (!hasFilepath) {
properties.push(
j.property(
"init",
j.identifier(IDENTIFIER.filepath),
j.stringLiteral(file.path)
)
);
}
}
});
return source.toSource();
}
위의 코드에서 다음과 같은 절차를 밟게 됩니다.
console을 찾는다
console 모듈의 메소드(info, debug 등)의 첫 번째 argument가 객체 표현식인지 검증한다
filepath가 이미 입력되어 있는지 확인한다
filepath가 입력되어 있지 않으면 소스코드를 수정한다
pnpm jscodeshift -t ./transform/findAndModifyToLogger.ts --extensions=ts --parser=ts './transform/findAndModifyToLogger.test.ts' --print
jscodeshift를 실행해 보면 다음과 같이 모든 console의 객체 argument에 filepath값이 추가된 것을 확인할 수 있어요.
jscodeshift로 Logger에 catch문의 error 추가하기
catch의 error 매개변수를 console
에 추가하는 기능은 filepath
추가 기능과 뼈대는 유사해요.
// ./transform/catchErrorToLogger.test.ts
try {
// ...
} catch (error) {
console.error({
developer: "po4tion",
});
}
try {
// ...
} catch (err) {
console.error({
developer: "po4tion",
});
}
테스트 코드는 두 가지 유형인데요. 이유는 객체의 속성 축약도 테스트해야 하기 때문이에요. 실무에서 사용하는 Logger
는 argument로 error 값을 넘길 수 있는데요. 이를 console
을 사용해서 유사하게 구현해 볼게요.
import { API, FileInfo } from "jscodeshift";
const IDENTIFIER = {
error: "error",
} as const;
/**
* Returns Changed AST
* @param file - The first input FileInfo
* @param param1 - The second input API
* @returns Changed AST string
*/
export default function transformer(file: FileInfo, { jscodeshift: j }: API) {
const source = j(file.source);
source.find(j.CatchClause).forEach((path) => {
const param = path.node.param;
if (param) {
// param이 Identifier 타입인지 확인
if (param.type === "Identifier") {
const { name: paramName } = param;
j(path)
.find(j.CallExpression, {
callee: {
object: { name: "console" },
property: { name: "error" },
},
})
.forEach((consolePath) => {
const args = consolePath.node.arguments;
if (args.length === 1 && args[0].type === "ObjectExpression") {
const properties = args[0].properties;
const hasErrorProperty = properties.some((property) => {
if (j.ObjectProperty.check(property)) {
const { key } = property;
return j.Identifier.check(key) && key.name === IDENTIFIER.error;
}
return false;
});
if (!hasErrorProperty) {
if (paramName === IDENTIFIER.error) {
// 매개변수 이름이 'error'일 때 객체 속성 축약 문법을 사용한다
properties.push(
j.property.from({
kind: "init",
key: j.identifier("error"),
shorthand: true,
value: j.identifier("error"),
})
);
} else {
properties.push(
j.property(
"init",
j.identifier(IDENTIFIER.error),
j.identifier(paramName)
)
);
}
}
}
});
}
}
});
return source.toSource();
}
catch의 에러를 인식하여 console
로 넘기는 코드 로직은 filepath
를 추가하는 로직보다 복잡한데요. 다음과 같은 절차를 밟는다고 생각하시면 돼요.
catch 문을 사용하는지 확인한다
catch 문이 파라미터를 사용하는지 확인한다
catch 문이 파라미터를 사용하면 catch 문 내에서 console.error를 사용하는지 확인한다
console.error의 첫 번째 인자가 객체 표현식인지 확인한다
console.error의 첫 번째 인자에 error가 이미 입력되어 있는지 확인한다.
입력되어 있지 않다면 error를 키값으로 하고 catch의 에러 파라미터 이름을 값으로 추가한다
매개변수 이름이
Logger
에서 제공하는 error 이름과 동일하면 객체 속성 축약을 사용한다소스코드를 수정한다
pnpm jscodeshift -t ./transform/catchErrorToLogger.ts --extensions=ts --parser=ts './transform/catchErrorToLogger.test.ts' --print
jscodeshift를 실행해 보면 다음과 같이 모든 console.error의 객체 argument에 error 값이 추가된 것을 확인할 수 있어요.
How? 개발자의 몰입을 방해하지 않기
jscodshift로 작성된 모듈들을 로깅 라이브러리의 배포 파일에 포함하여 버전 변경 후에 배포하였습니다. 개발자들은 변경된 버전으로만 바꿔주기만 하면 끝입니다.
맞아요. 그래서 처음에는 각 서비스의 package.json scripts의 prebuild 명령어에 jscodeshift 실행문을 추가하려고 했어요. 그런데 생각해 보니 현재 로깅 라이브러리를 사용하여 개발되고 있는 서비스들은 모두 Jenkins를 통해 배포되고 있었어요. 그래서 jscodeshift 모듈을 실행하는 스크립트를 Jenkins의 실행 스크립트 파일에 포함했습니다.
결국 저는 최대한 개발자가 손대지 않는 영역에서 jscodeshift를 사용하여 변경된 로그 포맷을 적용하는 데 성공했어요.
마무리
이번에 jscodeshift를 적용하면서 개발자들이 변경된 로그 포맷을 적용하는데 들이는 시간과 노력을 절감할 수 있었어요. 다시 로그 포맷이 변경된다고 하더라도 jscodeshift를 사용하여 유연하게 대처할 수 있는 환경을 갖추게 되었어요. 이번 경험을 통해 플랫폼 개발자로서 정말 큰 보람을 느꼈어요.
만약, 저처럼 대용량의 코드에 변경이 필요한 상황이라면 jscodeshift 사용을 적극 추천해요!
즐거운 하루 되세요! 감사합니다!
참고
AST explorer : https://astexplorer.net/
codeshiftcommunity : https://www.codeshiftcommunity.com/
jscodeshift github : https://github.com/facebook/jscodeshift
Source code : https://github.com/po4tion/jscodeshift-dx-improvement