본문으로 건너뛰기

ts-morph로 개발 컨벤션 검증 자동화하기

· 약 14분
Jake Son

최근에 typescript 환경에서 개발 컨벤션을 확인하는 작업을 자동화하는 방법을 찾다가 ts-morph 를 도입하게 된 과정을 기술하려고 한다.

관련 코드는 아래 링크에서 확인할 수 있다.

컨벤션

아래와 같은 TypeORM 엔티티 파일이 있다고 가정한다.

Profile.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@Column()
email: string;
}
Post.entity.ts
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Profile } from "./Profile.entity";

@Entity()
@Index("idx_post_1", ["profile"]) // profile_id 에 인덱스를 추가
@Index("idx_post_2", ["status"]) // status 필드에 인덱스를 추가
export class Post {
@PrimaryGeneratedColumn()
id: number;

@Column()
content: string;

@Column()
status: string;

// profile 과 1:n 관계이지만 FK 를 설정하지 않음
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
// profile 연관관계 필드 추가
@JoinColumn({ name: "profile_id", referencedColumnName: "id" })
profile: Profile;
}

Post 엔티티는 Profile 과 1:N 관계이고 profile_id 필드가 있지만 외래키를 추가하지 않고 대신 class 상단에 @Index 데코레이터를 이용해 인덱스를 추가한다.
인덱스를 추가하지 않으면 join 성능이 떨어지기 때문에 개발자가 인덱스를 추가하지 않는 실수를 하지 않도록 검증과정을 자동화하려고 했다.

Shell script 로 검증

처음에는 bash 스크립트를 활용해서 각 엔티티 파일을 읽은 후 정규식을 활용해 검증을 시도하려 했지만 다음과 같은 이유로 하지 않았다.

OS

현재 개발팀 모두 mac 을 사용하기에 스크립트를 실행하는데 어려움이 없지만 추후에 윈도우를 사용하는 사람이 올 수도 있다.
스크립트를 CI 에서 실행하면 큰 문제는 없지만 OS 에 관계없이 누구나 로컬에서 편리하게 검증할 수 있도록 하고 싶었다.

유지보수

shell script 에 익숙하지 않은 개발자도 있어서 해당 스크립트를 유지보수 할 수 있는 사람이 적어진다.
개발환경과 같은 언어를 활용해 검증을 한다면 코드를 이해하고 개선할 수 있는 사람이 많아질것이다.

구현

검증을 위해서 파일 내용 중 JoinColumn 데코레이터로 선언한 필드명 (예제에서는 profile) 을 알아내야 하는데 코드 상황에 따라 그 위치가 달라질 수 있다.

  • JoinColumn 라인 바로 아래에 컬럼명이 존재
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({ name: "profile_id", referencedColumnName: "id" })
profile: Profile;
  • JoinColumn 두 라인 아래에 컬럼명이 존재
@JoinColumn({ name: "profile_id", referencedColumnName: "id" })
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
profile: Profile;
  • JoinColumn 세 라인 아래에 컬럼명이 존재
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({
name: "profile_id",
referencedColumnName: "id"
})
profile: Profile;
  • JoinColumn 과 같은 라인에 존재
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({ name: "profile_id", referencedColumnName: "id" }) profile: Profile;

prettier 를 사용하면 몇가지 상황은 배제할 수 있지만 컬럼명을 알아내기 위해 multiline 에 대한 정규식 표현을 사용해야 하는 불편함이 있다.
그리고 컬럼명을 가져온 후 @Index 에 선언된 컬렴명과 비교하는 로직을 shell script 로 구현하기도 복잡하다.

위와 같은 이유로 shell script 보다 개발환경과 같은 언어인 typescript 을 활용하고 싶었고 이전부터 눈여겨본 ts-morph 를 도입해보았다.

ts-morph

ts-morph 는 난해한 typescript ast 를 사용하기 편리하게 만든 wrapper 라이브러리이다.
타입스크립트 코드를 프로그래밍 적으로 분석 및 수정할 수 있게한다.
사용법 자체도 간단해서 이번에 문서를 보고 처음 사용해보았는데 어렵지 않게 작업할 수 있었다.
각 ast 노드를 클래스 인스턴스로 만들어 각 클래스의 메소드를 활용해 자식 노드나 부가정보를 얻을 수 있는 형태로 구현되어있다.

Project 생성

먼저 Project 인스턴스를 생성해야 한다. 생성자에 파싱을 위한 소스파일 정보를 주거나 이후에 메소드를 통해 제공할 수 있다.
tsconfig.json 경로를 제공하면 해당 파일에 적용되는 ts 파일들을 자동으로 읽는다.

import { Project } from "ts-morph";

const project = new Project({
tsConfigFilePath: "path/to/tsconfig.json",
});

아니면 인스턴스 생성이후 메소드를 호출해 소스파일을 등록할 수 있다.

const project = new Project();

project.addSourceFilesFromTsConfig("dir1/tsconfig.json"); // tsconfig.json 파일 경로지정
project.addSourceFilesAtPaths("dir3/**/*{.d.ts,.ts}"); // 경로에 해당하는 파일읽기

실제 파일내용을 제공할 수도 있어서 테스트코드 작성할 때 유용하게 쓸 수 있다.

const project = new Project();

const fileText = "enum MyEnum {\n}\n";
const sourceFile = project.createSourceFile("path/to/myNewFile.ts", fileText);

소스파일 경로를 지정하는 방법과 파일내용을 제공하는 방법을 모두 지원하기 위해 아래와 같은 클래스를 만들었다.

EntityValidator.ts
export class EntityValidator {
#project: Project;

constructor(path: string, content?: string) {
this.#project = new Project();

if (content) {
this.#project.createSourceFile(path, content);
return;
}

this.#project.addSourceFilesAtPaths(path);
}
}

SourceFile 가져오기

이제 ast 의 root 인 SourceFile 을 가져온다.

const sourceFiles = this.#project.getSourceFiles();

Entity Class 가져오기

이제 각 SourceFile 배열을 순회하면서 각 파일이 컨벤션을 만족하는지 확인하는 과정이 필요하다.
우선 하나의 SourceFile 을 검증하는 로직을 먼저 구현해보자.
먼저 해당 파일이 Entity 데코레이터가 있는 클래스가 선언되어 있는지 확인해본다.

SourceFile 에는 getClass 메소드가 있는데 파라미터로 필터함수를 주면 조건에 해당하는 첫 번째 클래스를 반환한다.
해당 함수는 클래스의 데코레이터 중 이름이 Entity 가 있을 때 true 를 반환하도록 하였다.

private getClass(sourceFile: SourceFile): ClassDeclaration {
// Entity 데코레이터가 있는 클래스를 가져오기
const entityClass = sourceFile.getClass((declaration) =>
declaration
.getDecorators()
.some((decorator) => decorator.getFullName() === 'Entity'),
);

if (!entityClass) {
throw new Error('Entity 데코레이터가 존재하지 않습니다');
}

return entityClass;
}

JoinColumn 데코레이터가 선언된 필드명을 가져오기

이제 해당 클래스에서 JoinColumn 데코레이터가 선언된 필드명을 가져온다.
코드를 보면 getProperties 메소드를 통해 필드를 가져온 후 데코레이터 이름이 JoinColumn 인것만 필터링 후 getName 을 통해 필드명을 가져온다.
메소드명만 읽어도 어떤 작업을 하는지 파악이 가능하기에 유지보수가 쉽고 코드를 읽기 쉽다는 장점이있다.

private getJoinColumns(entityClass: ClassDeclaration): string[] {
return entityClass
.getProperties()
.filter((property) =>
property
.getDecorators()
.some((decorator) => decorator.getFullName() === 'JoinColumn'),
)
.map((property) => property.getName());
}

JoinColumn 에 매칭되는 Index 데코레이터가 있는 필드 가져오기

JoinColumn 에 해당하는 필드명 중에 클래스에 선언된 Index 데코레이터의 두 번째 파라미터와 일치하는게 존재하는 것만 필터링한다.
단순히 모든 Index 데코레이터 정보를 사용하지 않고 필터링 해야하는 이유는 JoinColumn 이 없는 필드에 대해서도 Index 데코레이터를 사용하는 경우가 있기 때문이다.

private getIndexColumns(
entityClass: ClassDeclaration,
joinColumns: string[],
): string[] {
return entityClass
.getDecorators()
.filter((decorator) => decorator.getFullName() === 'Index')
// getArguments 는 데코레이터의 파리마터를 반환한다
.map((decorator) => decorator.getArguments())
// Node.getFullText 메소드는 실제 파라미터 타입이 아닌 코드에 있는 문자열 그대로 반환한다.
// 따라서 ['column'] 과 같이 배열로 선언되어 있어도 문자열이 되며
// parseArgument 메소드는 특수문자를 모두 제거해서 컬럼명만 남긴다.
.map(([_, indexField]) => this.parseArgument(indexField))
.filter((columnName) => joinColumns.includes(columnName));
}

private parseArgument(node?: Node): string {
if (!node) {
return '';
}

// 특수문자를 모두 제거
return node.getFullText().replace(/[\W\s]/gi, '');
}

검증하기

위 메소드를 활용해서 각 SourceFile 를 순회해 JoinColumn 의 개수와 Index 개수가 일치하지 않으면 에러를 발생시킨다.

validate(): void {
const sourceFiles = this.#project.getSourceFiles();

sourceFiles.forEach((sourceFile) => {
const entityClass = this.getClass(sourceFile);
const joinColumns = this.getJoinColumns(entityClass);
const indexColumns = this.getIndexColumns(entityClass, joinColumns);

if (indexColumns.length !== joinColumns.length) {
throw new Error(
`JoinColumn 에 매칭되는 Index 선언이 누락되었습니다: ${JSON.stringify(
{
entity: entityClass.getName(),
joinColumns,
indexColumns,
},
null,
2,
)}`,
);
}
});
}

테스트

이제 위 클래스가 정상 동작하는지 테스트를 작성해본다.
EntityValidator 생성자의 두 번째 파라미터에 파일내용을 직접 넣어서 테스트 할 수 있다.

EntityValidator.spec.ts
import { EntityValidator } from "./EntityValidator";

describe("EntityValidator", () => {
it("JoinColumn 에 선언된 컬럼에 인덱스가 없으면 에러가 발생한다", () => {
// given
const path = "post.ts";
const content = `
@Entity()
export class Post {
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({ name: 'profile_id', referencedColumnName: 'id' })
profile: Profile;
}`;
const checker = new EntityValidator(path, content);

// when
const validate = () => checker.validate();

// then
expect(validate).toThrowError();
});

it("JoinColumn 에 선언된 컬럼에 인덱스가 있으면 검증에 성공한다", () => {
// given
const content = `
@Entity()
@Index('idx_post_1', ['profile'])
export class Post {
@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({ name: 'profile_id', referencedColumnName: 'id' })
profile: Profile;
}`;
const checker = new EntityValidator(content);

// when
const validate = () => checker.validate();

// then
expect(validate).not.toThrowError();
});

it("JoinColumn 이 아닌 컬럼에 대한 인덱스가 있어도 검증에 성공한다", () => {
// given
const content = `
@Entity()
@Index('idx_post_1', ['profile'])
@Index('idx_post_2', ['name'])
export class Post {
@Column()
name: string

@ManyToOne(() => Profile, { createForeignKeyConstraints: false })
@JoinColumn({ name: 'profile_id', referencedColumnName: 'id' })
profile: Profile;
}`;
const checker = new EntityValidator(content);

// when
const validate = () => checker.validate();

// then
expect(validate).not.toThrowError();
});
});

마지막으로 실제 엔티티 파일이 있는 경로를 지정해서 컨벤션을 만족하는지 검증한다.
테스트코드를 통해 검증하기 때문에 로컬과 CI 에서 손쉽게 사용할 수 있다는 장점이있다.

it("엔티티 파일들은 JoinColumn 에 대한 인덱스가 존재한다", () => {
// given
const path = join(__dirname, "../src/**/*.entity.ts");
const checker = new EntityValidator(path);

// when
const validate = () => checker.validate();

// then
expect(validate).not.toThrowError();
});

마무리

ts-morph 를 활용해 컨벤션 검증과정을 살펴보았다.
해당 라이브러리는 소스코드를 수정하는 기능도 있기 때문에 컨벤션에 맞지 않으면 자동으로 해결하는 기능도 추가할 수 있다.
또한 클래스 외에 함수, 인터페이스, Enum 등 다른 유형의 코드도 파싱할 수 있기 때문에 다양한 컨벤션을 검증하는 로직을 만들 수 있다.

참고문서