본문 바로가기

nestjs

[nestjs] transaction 라이브러리 분석하기 - 부록

이 부록은 typeorm의 repository.extend() 메서드를 분석, 수정하여 typeorm에 PR 날린 과정을 담고 있습니다.

 

typeorm 깃허브 예제를 보면, 아래와 같이 repository.extend 메서드를 이용하여 customRepository를 만듭니다.

const Sample33CustomRepositoryConnection = new DataSource({
    type: "sqlite",
    database: "./temp/sqlitedb-1.db",
    logging: true,
    synchronize: true,
})

export const PostRepository = Sample33CustomRepositoryConnection.getRepository(
    Post,
).extend({
    findMyPost() {
        return this.findOne()
    },
})

이 토막글은

typeorm에서 이해하기는 어렵지만, 공부 요소가 많은 repository.extend 메서드를 분석하는 토막글 입니다.

아래는 repository.extend 메서드의 정의입니다. 

extend<CustomRepository>(
    custom: CustomRepository & ThisType<this & CustomRepository>,
): this & CustomRepository {
    // return {
    //     ...this,
    //     ...custom
    // };
    const thisRepo = this.constructor as new (...args: any[]) => typeof this
    const { target, manager, queryRunner } = this
    const cls = new (class extends thisRepo{})(
        target,
        manager,
        queryRunner,
    )
    Object.assign(cls, custom)
    return cls as any
}

간단할 줄 알았는데, 처음 보는 것들이 꽤 있습니다. ↑

 

하나 하나 보겠습니다.

: this & CustomRepository // return type 부분

return type가 교차타입 인데, 현재 클래스와 CustomRepository의 교차타입 입니다. ↑
그럼 이 교차타입에는 현재 클래스에 정의된 속성들CustomRepository에 정의된 속성들 모두 들어있겠네요.

 

const thisRepo = this.constructor as new (...args: any[]) => typeof this

this.constructor은 repository의 생성자 입니다. thisRepo라는 변수가 repository의 생성자를 참조하고 있습니다. ↑

이때 new (...args: any[]) => typeof this 라고 타입을 정해줬습니다.

여기서 new 키워드는 typescript에게 이 함수는 생성자 함수로 사용될 것이다라고 알려주기 위한 것이라고 합니다. (출처)

 

const { target, manager, queryRunner } = this

this는 repository 객체입니다. 위에선 구조 분해 할당 문법을 통해 필요한 값들을 뽑아 오고 있습니다. ↑

 

const cls = new (class extends thisRepo {})(
        target,
        manager,
        queryRunner,
    )

여기가 좀 직관적이지 않습니다. ↑

결론만 얘기하면, thisRepo는 현재 객체의 class를 가리키면서, 동시에 생성자로 사용될 수 있습니다.

그러니까 위 코드는 익명 클래스로 부터 객체를 생성하는 것인데,
이 익명 클래스는 현재 객체의 class를 상속받으면서 thisRepo를 생성자로 사용하는 클래스인 것입니다.

 

위 코드는 공부할 요소가 있기 때문에 자세히 보겠습니다.

thisRepo는 분명 Repository의 생성자를 가리키고 있습니다. 그럼

class extends thisRepo {}

생성자 함수를 상속받는 (익명)클래스를 만드는 것이라고 생각할 수 있습니다.

 

생성자 함수를 상속 받는다?라는게 가능한 건가 싶습니다. 일단 thisRepo가 정확히 어떤 값을 가리키는지 확인해 봅시다.

아래 예시 코드에서 디버깅 모드로 확인해 봅시다.

class TestRepository<T> {
    public val: T;

    constructor(val: T) {
        this.val = val;
    }

    extends<U>(method : U): this & U {
        const thisRepo = this.constructor as new (args: any) => typeof this
        const {val} = this; // 이 라인에 중단점 설정
        const instance = new (class extends thisRepo {}) (
            val
        );
        Object.assign(instance, method);
        return instance as any;
    }
}

const testRepository = new TestRepository<number>(42);
testRepository.extends({
    newMethod1() {
        console.log("newMethod1");
    }, newMethod2() {
        console.log("newMethod2");
    }
})

위 예시코드에서 중단점을 적절히 설정하고, thisRepo 변수에 어떤 값이 들어가 있는지 확인합니다. ↑

 

아래와 같은 결과가 나왔습니다. this.constructor(=thisRepo 변수)는 해당 객체의 class를 가리키고 있네요.

 

이제 예시 코드에서 기존 코드로 돌아와서 

아래와 같이 정리합시다.

// 현재 객체의 class를 가리킵니다.
// 하지만 new (...args: [any]) => typeof this 타입을 붙여줬습니다.
// 그럼 thisRepo는 class로 사용될 수 있고, 생성자로도 사용될 수 있습니다.
const thisRepo = this.constructor as new (...args: any[]) => typeof this;

// 익명 클래스를 만듭니다.
// 이때 익명 클래스는 thisRepo가 가리키는 class 를 상속받고, thisRepo를 생성자로 사용합니다.
// 이 익명 클래스로부터 객체를 만드는 과정으로 이해할 수 있습니다.
const cls = new (class extends thisRepo {})(
        target,
        manager,
        queryRunner,
    )

(여기서 cls는 continious local storage의 약자가 아닙니다.)

 

Object.assign(cls, custom)
return cls as any

custom은 객체로써, 각 속성들이 "함수이름: 함수정의" 형식으로 존재합니다. ↑

이 속성들을 (익명 클래스로 부터 생성한) cls 객체에 추가하는 것입니다.

그리고 그 cls 객체를 반환합니다.

 

이제, repository.extend 메서드는 아래와 같이 정리하겠습니다.

extend<CustomRepository>(
    custom: CustomRepository & ThisType<this & CustomRepository>,
): this & CustomRepository {
   // 현재 객체의 class를 가리킵니다.
   // new (...args: [any]) => typeof this 타입을 붙여줘서 생성자 함수임을 명시했습니다.
   // 그럼 thisRepo는 class로 사용될 수 있고, 생성자로도 사용될 수 있습니다.
   const thisRepo = this.constructor as new (...args: any[]) => typeof this;

   // 구조 분해 할당을 통해 현재 객체의 속성 값들을 가져옵니다.
   const { target, manager, queryRunner } = this
    
   // 익명 클래스를 만듭니다.
   // 이때 익명 클래스는 thisRepo가 가리키는 class 를 상속받고, thisRepo를 생성자로 사용합니다.
   // 이 익명 클래스로부터 객체를 만드는 과정으로 이해할 수 있습니다.
   const cls = new (class extends thisRepo {})(
        target,
        manager,
        queryRunner,
    )
 
    // 여기서 cls는 continious-local-storge의 약자가 아닙니다.
    // 이 cls 객체에 새로운 속성을 추가합니다.
    // custom은 객체로써, 각 속성들이 "함수이름: 함수정의" 형식으로 존재합니다.
    Object.assign(cls, custom)
    
    // 이렇게 새로 생성한 객체를 반환합니다.
    return cls as any
}

이 토막글은

typeorm이 repository.extend 메서드를 더 단순하고 쉬운 코드로 구현할 수 있을거 같은데,
왜 그렇게 이해하기 어렵게 구현을 했는가에 대해 추측하는 토막글입니다. (결론은 '모른다' 입니다...)

 

Original이라는 클래스의 객체가 있습니다.
이 객체의 속성을 그대로 가지면서, 몇 가지 메서드가 추가된 '새로운' 객체를 만드려면 어떻게 해야 할까요?

(객체 확장 이라고 표현하겠습니다.)

 

단순히 아래처럼 하면 됩니다. ↓

 

class Original {
    public val : string;

    constructor(val : string) {
        this.val = val;
    }

    method () {
        console.log(this.val);
    }

    extends<U>(method : U): Original & U {
        const {val} = this;
        let extendsInstance = new (class extends Original {})(val);
        Object.assign(extendsInstance, method);
        return extendsInstance as any;
    }
}

const originalInstance = new Original("hunjin");
const extendsInstance = originalInstance.extends({
    newMethod() {
        console.log("new Method");
    },
    newMethod2() {
        console.log("new Method2");
    }
});

console.log(extendsInstance.val); // 기존 객체의 속성을 그대로 가짐
extendsInstance.method(); // 기존 객체의 속성을 그대로 가짐
extendsInstance.newMethod(); // 새로 추가된 메서드

 

위 예시는 따로 설명이 필요 없을 정도로 단순합니다. ↑

 

하지만, Original이 일반적인 class가 아닌 generic class 라면 어떻게 해야 목적을 달성할 수 있을까요? ↓

(우리의 목적은 객체 확장 입니다.)

 

class Original<T> {
    public val : T;

    constructor(val : T) {
        this.val = val;
    }

    method () {
        console.log(this.val);
    }

    extends<U>(method : U): Original<T> & U {
        const {val} = this;
        let extendsInstance = new (class extends Original<T> {})(val);
        Object.assign(extendsInstance, method);
        return extendsInstance as any;
    }
}

const originalInstance = new Original("hunjin")
const extendsInstance = originalInstance.extends({
    newMethod() {
        console.log("new Method");
    },
    newMethod2() {
        console.log("new Method2");
    }
});

console.log(extendsInstance.val); // 기존 객체의 속성을 그대로 가짐
extendsInstance.method(); // 기존 객체의 속성을 그대로 가짐
extendsInstance.newMethod(); // 새로 추가된 메서드

 

위 예시도 따로 설명이 필요 없을 정도로 단순합니다. ↑

 

하지만, 위 예시 코드는 다음의 요구사항을 만족할 수 있을까요? 

originalInstance.extends({~~}).extends({~~~}).extends({~~~~}) 처럼 체이닝 콜을 했을 때,
추가한 속성들이 '모두' 잘 들어가게끔 extends가 동작해야 한다.

안됩니다.

 

위 구현 코드상에서는 마지막 extends 호출에서 넣어준 속성값만 들어가고, 첫 번째와 두 번째 호출의 속성값들은 들어가지 않습니다.

typeorm에서 repository.extends 메서드는 아마도 위 요구사항을 반영하기 위해 조금은 복잡해 보이게 구현한 거 같습니다.

틀린 추측이었습니다. 현재 typeorm의 repository.extends 메서드 역시 위 요구사항을 만족하지 못합니다.

 

결국,

위에 예시처럼 extend 메서드를 단순하게 구현해도 '객체 확장'이라는 목적을 달성하기에 충분했을 거 같은데,
왜 그렇게 복잡해 보이는 구현 방식을 택했는지는 잘 모르겠습니다...


(번외) 이 요구사항을 만족하려면 어떻게 구현해야 할까요?

originalInstance.extends({~~}).extends({~~~}).extends({~~~~}) 처럼 체이닝 콜을 했을 때,
추가한 속성들이 '모두' 잘 들어가게끔 extends가 동작해야 한다.

먼저, 간단한 구현 방식인 예시 코드에서는 왜 체이닝이 안 됐을까요?

예시 코드에서는 아래와 같이 extends 호출시 상속해주는 부모클래스를 코딩 단계에서 미리 딱 정했습니다.

class Original {
    public val : string;

    constructor(val : string) {
        this.val = val;
    }

    method () {
        console.log(this.val);
    }

    extends<U>(method : U): Original & U {
        const {val} = this;
        // 아래에서 상속해주는 부모 클래스를 미리 정해두었습니다
        let extendsInstance = new (class extends Original {})(val);
        Object.assign(extendsInstance, method);
        return extendsInstance as any;
    }
}

 

하지만 체이닝이 가능한 extends에서 extends 호출시 상속해주는 부모클래스를 코딩 단계에서 딱 정할 수 없습니다.

extends 호출시 상속해주는 부모클래스는 런타임에 그때그때 정해지는 것이고 코딩도 그렇게 해야합니다.

 

'코딩도 그렇게 한다'는 것은 아래와 같이 하는 것을 의미합니다. ↓ (동적 클래스 참조라고 부르겠습니다)

extends<U>(methods : U): this & U {
        // thisRepo는 현재 객체의 클래스를 참조한다고 보면 됩니다
        const thisRepo = this.constructor as new (args: any) => typeof this
        
        // 나머지 생략
    }

 

TypeORM의 repository.extend는 왜 체이닝이 안 됐을까요?

repository.extend는 동적 클래스 참조를 하고 있습니다. 그럼에도 불구하고 체이닝은 안 됩니다.

왜냐하면 prototype에 대한 변경을 전혀 하지 않기 때문입니다.

 

좀 더 구체적으로 얘기해봅시다. ↓

extend<CustomRepository>(
    custom: CustomRepository & ThisType<this & CustomRepository>,
): this & CustomRepository {
   
   // thisRepo가 동적 클래스 참조
   const cls = new (class extends thisRepo {})(
        target,
        manager,
        queryRunner,
    )
    // 속성 추가
    Object.assign(cls, custom)
    // 이후 생략
}

메서드 내부에서 동적 클래스 참조하여 인스턴스를 만들고, 이 인스턴스(객체)에 대해 속성 추가 작업을 하고 있습니다.

(인스턴스의) prototype은 전혀 변화가 없습니다. (prototype에는 custom1이 추가되지 않았습니다)

 

이렇게 생성된 인스턴스에 대해 custom2 속성을 추가하기 위해 extends 메서드를 호출한다고 합시다.(체이닝)

이번에도 동적 클래스 참조를 할 텐데, 결국 (인스턴스의) prototype을 참조할 것입니다.

 

이 ptototype에는 custom1 속성이 없습니다. 따라서 custom1 속성 없는 새 인스턴스를 생성할 것이고,
그 인스턴스에 custom2 속성 추가 작업을 할 것입니다.

 

따라서 체이닝이 가능 하려면 prototype에 대한 조작을 해야 할 거 같습니다.

이제  체이닝이 되게끔 수정해봅시다.

class Parent<T> {
    public val: T;

    constructor(val: T) {
        this.val = val;
    }

    method() {
        console.log("method");
    }

    extends<U>(methods : U): this & U {
        // thisRepo는 현재 객체의 클래스를 참조한다고 보면 됩니다
        const thisRepo: any = this.constructor;
        
        const { val } = this
        // ChildClass 정의합니다
        // thisRepo를 상속받습니다
        // ChildClass의 생성자는 인자를 받고, 그 인자를 부모의 생성자에게 그대로 전달합니다
        const ChildClass = class extends thisRepo {
            constructor(val: T) {
                super(val);
            }
        }
        // 커스텀 메서드들을 ChildClass.prototype에 하나씩 추가해줍니다
        for (const key in methods) {
            // ChildClass[key] = methods[key]; 하면 안됨 ***
            ChildClass.prototype[key] = methods[key];
        }
        return new ChildClass(val) as any;
    }
}

let instance = new Parent<number>(42);

const childInstance = instance.extends({
    newMethod1() {
        console.log("new Method1");
    },
    newMethod2() {
        console.log("new Method2");
    }
});

const childChildInstance = childInstance.extends({
    newMethod3() {
        console.log("new Method3");
    }
});

childChildInstance.method();
childChildInstance.newMethod1();
childChildInstance.newMethod2();
childChildInstance.newMethod3();
console.log(childChildInstance.val);

메서드 내부에서 동적 클래스 참조하고 그 클래스를 상속받는 ChildClass 를 만듭니다.

ChildClass의 prototype 대해 속성 추가 작업을 하고 있습니다. 그 후 ChildClass의 인스턴스를 생성하여 반환합니다.

 

실행 결과를 보면, 체이닝의 결과로 반환된 childChildInstance는 모든 메서드들이 정상적으로 들어가 있음을 확인할 수 있습니다.

 

아까는 동적 클래스 참조 방식이 가독성 안좋고 어렵다고 불평을 하였습니다.
하지만 체이닝 가능한 extends를 구현하려면 동적 클래스 참조 방식을 사용해야 합니다. 


정리하면, typeorm-transacional은 repository.extend 메서드에서 동적 클래스 참조 방식을 사용합니다. 

이는 처음보면 가독성 떨어지고 어렵지만, 새로운 요구사항(ex. 체이닝 가능한 extend 구현)을 효율적으로 반영할 수 있습니다.
repository.extend 메서드를 조금 수정하면 체이닝 호출이 가능하게 바꿀수 있습니다.


ps. 이렇게 체이닝 가능하도록 구현을 한것이 조금 뿌듯하기도 하여.. typeorm 에 PR을 날려볼까 고민을 해보았습니다만... 이렇게 체이닝을 할 상황이 잘 없을 거 같다고 생각하였습니다만... 그래도 일단 날려보았습니다... (링크)

 

ps2. 제가 PR을 날리고 난 뒤에 안 사실인데, 위 PR을 날리기 '하루 전'에 다른 분이 PR을 올리셨는데, 그 PR도 체이닝이 가능하도록 바꾸는 작업을 한 것이었습니다. 구현 방식은 저와 다르지만 이런 우연적인 상황이 신기하고 그러하였습니다... (링크)