이 부록은 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도 체이닝이 가능하도록 바꾸는 작업을 한 것이었습니다. 구현 방식은 저와 다르지만 이런 우연적인 상황이 신기하고 그러하였습니다... (링크)
'nestjs' 카테고리의 다른 글
[nestjs] transaction 라이브러리 분석하기 - 3 (1) | 2023.08.08 |
---|---|
[nestjs] transaction 라이브러리 분석하기 - 2 (0) | 2023.08.01 |
[nestjs] transaction 라이브러리 분석하기 - 1 (0) | 2023.07.26 |
[nestjs] transaction 라이브러리 분석하기 - 0 (0) | 2023.07.11 |