TypeORM 마개조했습니다.

제목 써놓고 보니 마개조라기에는 조금 부끄럽습니다.

발단

Node + Typescript로 API 서버를 짜다 보면은 ORM으로 TypeORM만한 것이 없습니다. 편하고, 예쁘고, 사용자 많고, 무엇보다 타입스크립트와 찰떡입니다.

그치만 JVM 위에서 JPA 구현체인 Hibernate을 사용하다 온 입장에서 조오금 불편한 점이 있습니다. 타입 애너테이션을 매번 붙여주어야 한다는건 차치하고서라도, 일단 lazy loading이 없어요.

난그렇게살아왔는데

하지만 실망하지는 말아요. 자바스크립트는 그저 다른 방법으로 살아왔을 뿐..

예를 들어 회사 Company가 있고 직원 Employee가 있다고 해보겠습니다. Employee에는 해당 직원이 속한 회사를 나타내는 relation 필드 company가 있을겁니다.

보통 Employee 인스턴스를 가지고 있으면 바로 점을 찍고 .company를 참조하고 싶어집니다. 그런데 될까요?

const employee = await Employee.findOne(userId);

console.log(employee.company.name); // cannot read property 'name' of undefined

뻗습니다. 제대로 작동하게 하려면 findOne을 호출할 시점에 relations 옵션을 넘겨 주어야 합니다.

const employee = await Employee.findOne(userId, {relations: ['company']});

console.log(employee.company.name);

Fetch 시점에 eager 로딩할 필드를 지정할 수 있는 것은 좋습니다. 그런데 이게 매번 반복되면 조금 귀찮습니다. 그냥 엔티티별로 쿼리 시점에 다같이 가져올 필드를 정해 놓고 계속 돌려 쓸 수는 없을까요?

희망회로

이렇게 쓰면 어떨까 싶었습니다.

@Entity()
class Employee extends BaseEntity {
    static relations = ['company'];

    // ... 생략

    @ManyToOne(() => Company)
    @JoinColumn()
    company: Company;

    // ...생략
}

const employee = await Employee.findOne(userId);

console.log(employee.company.name); // 잘 작동

상상만 해도 행복합니다. findfineOne을 부를 때마다 relations 때문에 인자 하나를 추가하고 중괄호를 열어야 하는 부담은… 생각보다 큽니다. 이 귀찮음에서 해방될 수 있다면 당장이라도 타입스크립트 교본을 펼쳐들고 베이스 클래스 설계를 시작할 수 있을 것 같습니다.

빌드업

기존의 엔티티 클래스들은 TypeORM이 제공하는 BaseEntity를 상속받아 쓰고 있습니다. 이제 이 엔티티 클래스와 BaseEntity 사이에 새로 만들 클래스를 끼워넣을겁니다. 이름은 BaseBetterEntity 정도로 하겠습니다.

- export default class User extends BaseEntity {
+ export default class User extends BaseBetterEntity {

하나씩 다 바꿔줍니다.

- export default class Event extends BaseEntity {
+ export default class Event extends BaseBetterEntity {

아, BaseBetterEntity를 정의해야죠!

export default class BaseBetterEntity extends BaseEntity {
    static relations: string[] = [];

    // TODO 곧 채울 것
}

이제 구현을 시작해봅시다.

뚱땅뚱땅

기존 클래스가 지원하는 메소드를 오버라이드해서 자식 클래스에게 슈퍼파워를(?) 물려주고 싶습니다.

우리가 손댈 메소드는 BaseEntity에 있는 static 메소드인 find, findOne, findOneOrFail, 요렇게 세 개입니다.

그런데 잠깐! 여기서 골때림 포인트가 발생합니다.

typeorm-find-overloads.png

오버로드가 기본 두세개씩은 있습니다. 이게 왜냐면 사용을 편하게 하기 위함입니다. TypeORM으로 엔티티를 찾을 때 정말 다양한 방법으로 인자를 구성할 수 있습니다.

await Employee.find({name: '그사람'});
await Employee.find({where: {name: '그사람'}, {relations: ['company']}});

await Employee.findOne(36);
await Employee.findOne(36, {relations: ['company']});
await Employee.findOne({name: '그사람'}, {relations: ['company']});
await Employee.findOne({where: {name: '그사람'}, relations: ['company']});

이걸 유연하다 해야 할 지, 아니면 흐물흐물하다 해야 할 지 모르겠습니다만, 최소한 쓸 때에는 편했던 것 같습니다. 아무튼 대대로 엄숙히 내려오던 BaseEntity의 족보에 끼어들어 메소드 호출을 잘 가로채려면 오버로드를 모두 지원해야 합니다.

일단 오버로드 선언을 모두 써준 다음에 구현체를 작성하기 시작합니다.

export default class BaseBetterEntity extends BaseEntity {
    static relations: string[] = [];

    static find<T extends BaseBetterEntity>(this: ObjectType<T>, options?: FindManyOptions<T>): Promise<T[]>;
    static find<T extends BaseBetterEntity>(this: ObjectType<T>, conditions?: FindConditions<T>): Promise<T[]>;
    static find<T extends BaseBetterEntity>(this: ObjectType<T>, optionsOrConditions?: FindManyOptions<T> | FindConditions<T>): Promise<T[]> {
        // 어..이제 어떡하지..?
    }
}

으악: 문제를 마주함

이마탁

그런데 다시 고뇌의 시간이 찾아옵니다. 문제가 두 개가 있는데요, 일단 find 메소드 내에서 this.relations와 같은 접근이 안 됩니다. 왜냐하면 this라는 이름의 파라미터가 있기 때문입니다. 자식 클래스가 오버라이드한 relations에 접근할 수 있어야 이 모든게 작동하는지라, 꼭 해결해야 합니다.

또 다른 문제는 relations 필드를 어디에 끼워야 하는지 판별해야 하는 것입니다. 두 오버로드를 모두 수용할 수 있도록 마지막에 정의된 find 메소드는 인자로 {name: '그사람'}과 같은 FindConditions를 받을 수도 있고 {where: {name: '그사람'}}과 같은 FindManyOptions를 받을 수도 있습니다.

만약 인자가 알고봤더니 FindConditions 타입이었다면 FindManyOptions을 만들 때에 해당 인자를 {where: optionsOrConditions, relations: [...]}과 같이 감싸야 할 것입니다. 반대로 FindManyOptions 타입이었다면 {...optionsOrConditions, relations: [...]}와 같이 써야 할 것입니다.

휴: 해결책이 떠오름

글과 무관합니다. 그냥 이 짤이 써보고 싶었어요 ㅎㅎ

차근차근 생각을 거듭하면서 답을 찾았습니다. 일단 this에 접근하지 못하는 문제는 super에 접근하는 것으로 해결할 수 있습니다. BaseBatterEntityBaseEntity 사이에 클래스를 하나 더 두어 거기에 findInternal 같은 것을 갖다 둔 다음에 super.findInternal(optionsOrConditions)의 결과를 반환하도록 만드는 것이지요! 이렇게요:

// BaseBetterEntity.ts
export default class BaseBetterEntity extends BaseInternalFindExtendedEntity {
    static find<T extends BaseBetterEntity>(this: ObjectType<T>, options?: FindManyOptions<T>): Promise<T[]>;
    static find<T extends BaseBetterEntity>(this: ObjectType<T>, conditions?: FindConditions<T>): Promise<T[]>;
    static find<T extends BaseBetterEntity>(this: ObjectType<T>, optionsOrConditions?: FindManyOptions<T> | FindConditions<T>): Promise<T[]> {
        return super.findInternal(optionsOrConditions);
    }

    static findOne<T extends BaseBetterEntity>(this: ObjectType<T>, id?: EntityId, options?: FindOneOptions<T>): Promise<T | undefined>;
    static findOne<T extends BaseBetterEntity>(this: ObjectType<T>, options?: FindOneOptions<T>): Promise<T | undefined>;
    static findOne<T extends BaseBetterEntity>(this: ObjectType<T>, conditions?: FindConditions<T>, options?: FindOneOptions<T>): Promise<T | undefined>;
    static findOne<T extends BaseBetterEntity>(this: ObjectType<T>, idOrOptionsOrConditions?: EntityId | FindOneOptions<T> | FindConditions<T>, maybeOptions?: FindOneOptions<T>): Promise<T | undefined> {
      return super.findOneInternal(idOrOptionsOrConditions, maybeOptions);
    }

    static findOneOrFail<T extends BaseBetterEntity>(this: ObjectType<T>, id?: EntityId, options?: FindOneOptions<T>): Promise<T>;
    static findOneOrFail<T extends BaseBetterEntity>(this: ObjectType<T>, options?: FindOneOptions<T>): Promise<T>;
    static findOneOrFail<T extends BaseBetterEntity>(this: ObjectType<T>, conditions?: FindConditions<T>, options?: FindOneOptions<T>): Promise<T>;
    static findOneOrFail<T extends BaseBetterEntity>(this: ObjectType<T>, idOrOptionsOrConditions?: EntityId | FindOneOptions<T> | FindConditions<T>, maybeOptions?: FindOneOptions<T>): Promise<T> {
      return super.findOneOrFailInternal(idOrOptionsOrConditions, maybeOptions);
    }
}

// BaseInternalFindExtendedEntity.ts
export default class BaseInternalFindExtendedEntity extends BaseEntity {
    static relations: string[] = [];

    protected static async findInternal<T extends BaseEntity>(optionsOrConditions?: FindManyOptions<T> | FindConditions<T>): Promise<T[]> {
        // 접근 가능! 최종 상속받은 클래스의 relations 정의가 사용될 것입니다.
        this.relations;

        // TODO: 적당한 구현체
    }

    protected static async findOneInternal<T extends BaseEntity>(first?: EntityId | FindOneOptions<T> | FindConditions<T>, second?: FindOneOptions<T>): Promise<T | undefined> {
        // TODO: 적당한 구현체
    }

    protected static async findOneOrFailInternal<T extends BaseEntity>(first?: EntityId | FindOneOptions<T> | FindConditions<T>, second?: FindOneOptions<T>): Promise<T> {
        // TODO: 적당한 구현체
    }
}

그리고 오버로드의 각기 다른 인자를 어떻게 처리할지는 TypeORM 구현체 코드를 보고 답을 얻었습니다.

/**
 * Finds entities that match given find options or conditions.
 */
async find<Entity>(entityClass: EntityTarget<Entity>, optionsOrConditions?: FindManyOptions<Entity>|FindConditions<Entity>): Promise<Entity[]> {
    const metadata = this.connection.getMetadata(entityClass);
    const qb = this.createQueryBuilder<Entity>(entityClass as any, FindOptionsUtils.extractFindManyOptionsAlias(optionsOrConditions) || metadata.name);


    if (!FindOptionsUtils.isFindManyOptions(optionsOrConditions) || optionsOrConditions.loadEagerRelations !== false)
        FindOptionsUtils.joinEagerRelations(qb, qb.alias, metadata);


    return FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, optionsOrConditions).getMany();
}

FindOptionsUtils라는 친구의 도움을 얻어 주어진 인자가 어떤 녀석인지 판단하는 모습입니다.

이제 코드는 이런 모양일 겁니다:

protected static async findInternal<T extends BaseEntity>(optionsOrConditions?: FindManyOptions<T> | FindConditions<T>): Promise<T[]> {
  if (isFindManyOptions(optionsOrConditions)) {
    /** 첫번째 오버로드. */
    return await super.find<T>(this.generateOptionsFromExisting(optionsOrConditions));
  } else {
    /** 두번째 오버로드. */
    return await super.find<T>({where: optionsOrConditions, ...this.generateOptionsFromExisting()});
  }
}

private static generateOptionsFromExisting(optionsLike?: any) {
  return {...(optionsLike || {}), relations: this.relations};
}

이렇게 findInternal 구현을 작성한 다음에 findOneInternal 구현을 시작했습니다. 지금까지 나열한 것의 두 배 정도 되는 삽질을 겪었으나 자세히 쓰지는 않겠습니다…🥲

아무튼 코드는 이렇게 생겼어요:

protected static async findOneInternal<T extends BaseEntity>(first?: EntityId | FindOneOptions<T> | FindConditions<T>, second?: FindOneOptions<T>): Promise<T | undefined> {
    const firstParamIsUndefined = first == null;
    const firstParamIsId = isId(first);
    const firstParamIsFindOptions = isFindOneOptions(first);
    const firstParamIsFindConditions = isFindConditions(first);

    const secondParamsIsUndefined = second == null;
    const secondParamsIsFindOptions = isFindOneOptions(second);

    if ((firstParamIsUndefined || firstParamIsId) && (secondParamsIsUndefined || secondParamsIsFindOptions)) {
      /** 첫번째 오버로드. */
      return await super.findOne<T>(first, this.generateOptionsFromExisting());
    }

    if (firstParamIsFindOptions && secondParamsIsUndefined) {
      /** 두번째 오버로드. */
      return await super.findOne<T>(this.generateOptionsFromExisting(first));
    }

    if ((firstParamIsUndefined || firstParamIsFindConditions) && (secondParamsIsUndefined || secondParamsIsFindOptions)) {
      /** 세번째 오버로드. */
      return await super.findOne<T>(first, this.generateOptionsFromExisting(first));
    }

    throw Error('No matching overload found.');
  }

네… 결국 타입 서포팅 약한 자바스크립트에서 눈물겨운 노력으로 만들어낸,,, 인자 타입을 보고 적절한 오버로드를 런타임에서 찾아서 수동으로 매칭해주는 작업입니다…

결론

그래서 과연 평화가 찾아왔는가?

네 그렇습니다. 이제 엔티티를 찾으러 갈 때에 relations: ['user', 'event', 'event.user', 'event.comments', 'event.likes', 'event.notifications']와 같은 무거운 것들을 달고 다니지 않아도 됩니다.

그저 엔티티 클래스 안에 static relations = [...];와 같이 딱 한번만 적어 주면 됩니다. 덤으로 타입스크립트에서 오버로드 처리하는 방법도 이번에 제대로 익혔네요.

원모어띵

조상이 하는 일은 모두 자식에게 영향을 미칩니다. 이제 모든 엔티티가 BaseBetterEntity를 상속받으니, toString같은 것을 만들기 딱 좋은 상태가 되었습니다.

toString() {
  // @ts-ignore
  return `[id가 ${this.constructor.getId(this)}${this.constructor.name}]`;
}

BaseBatterEntity 안에 이런 메소드를 하나 만들어 줍니다. 이제 모든 엔티티는 toString 메소드를 가지게 될 겁니다. 매번 밖에서 id를 참조할 필요 없이 스스로가 자기를 잘 설명하겠지요!

TMI

TypeORM의 EntityManager 속 findOne 구현 787줄을 보면 첫 번째 인자가 id인지 판단하기 위해 다음과 같은 식을 쓰는 부분이 있습니다:

const passedId =
    typeof idOrOptionsOrConditions === "string" ||
    typeof idOrOptionsOrConditions === "number" ||
    (idOrOptionsOrConditions as any) instanceof Date;

해당 메소드의 인자를 보니, id로 인식되는 인자가 가질 수 있는 타입은 string|string[]|number|number[]|Date|Date[]|ObjectID|ObjectID[]입니다.

그런데 저 passedId를 구할 때에 string, number, Date은 체크하는데 ObjectID 타입에 대한 체크가 빠져있는 것 같아 조금 의아했습니다. ObjectIDFindOneOptions이나 FindConditions는 분명히 아니니 처리가 필요할 것 같아 보였거든요. 어떻게 동작할지 테스트는 안해봤지만 궁금하네요.

긴 글 읽어주셔서 감사합니다.

끝!

References

댓글