[Typeorm] Repository.save 메소드 알아보기 공부/BE, DB

Date 2022. 2. 11. 20:01

Typescript(javascript) 에서 이용할 수 있는 ORM 라이브러리인 Typeorm은 Repository 클래스를 통해 엔티티에 대한 연산(Insert, Remove..)등을 수행할 수 있다.

 

그러한 메소드 중에서는 범용으로 이용할 수 있는 save() 메소드가 존재하는데, 공식 Document의 설명을 요약하면 해당 메소드의 기능은 다음과 같다.

 

    1. 만약 인자로 들어온 엔티티가 이미 디비에 존재하면, 업데이트를 수행한다.

    2. 그렇지 않으면 Insert를 수행한다.

 

이는 Oracle, MySQL 등 여러 dbms가 제공하는 Upsert(Update or Insert)와 비슷한데, 실제로 어떤 방식으로 구현되어 있는지 궁금해서 찾아보게 되었다.

TL;DR

결론부터 말하자면, 다음과 같이 동작한다.

1. entity에 id가 존재하지 않으면 > Insert 동작

2. entity에 id가 존재하면? id를 이용해 SELECT 쿼리를 날린다.

  • 2-1: 존재하면, Update 동작
  • 2-2: 존재하지 않으면: Insert 동작

실행되는 과정을 보고싶다면 TypeORM 커넥션 옵션에 logging 관련 옵션을 주면 된다. query, error 등 로그를 남길 level을 설정할 수 있는데, query를 추가하면 실제 TypeORM이 생성하는 쿼리를 볼 수 있다.

 

Repository.save()

Repository 클래스의 save 메소드는 다음과 같이 정의되어 있다.

 

save(entityOrEntities: Entity|Entity[], options?: RemoveOptions): Promise<Entity|Entity[]> {
  return this.manager.save(this.metadata.target as any, entityOrEntities as any, options);
}

 

save()를 호출하면 EntityManagersave 메소드를 다시 호출한다.

 

EntityManager.save()

EntityManager 클래스의 save 메소드는 다음과 같이 정의되어 있다.

 

save<Entity, T extends DeepPartial<Entity>>(targetOrEntity: (T|T[])|EntityTarget<Entity>, maybeEntityOrOptions?: T|T[], maybeOptions?: SaveOptions): Promise<T|T[]> {

        // ...normalize mixed parameter

        // execute save operation
        return new EntityPersistExecutor(this.connection, this.queryRunner, "save", target, entity, options)
            .execute()
            .then(() => entity);
    } 

 

실제로 실행하는 부분은 EntityPersistExecutor 객체를 생성하고 execute() 메소드를 수행한다.

EntityPersistExecutor.execute()

EntityPersistManager 객체의 execute() 메소드는 다음과 같다. 실제 필요한 부분만 가져왔다.

 

async execute(): Promise<void> {

    //...
    try {
        const executors = await Promise.all(entitiesInChunk.map(async entities => {
            const subjects: Subject[] = [];
            entities.forEach(entity => 
                // ...

                subjects.push(new Subject({
                 metadata: this.connection.getMetadata(entityTarget),
                 entity: entity,
                 canBeInserted: this.mode === "save",
                 canBeUpdated: this.mode === "save",
                 mustBeRemoved: this.mode === "remove",
                 canBeSoftRemoved: this.mode === "soft-remove",
                 canBeRecovered: this.mode === "recover"
                }));
            );
            // ...
            await new SubjectDatabaseEntityLoader(queryRunner, subjects).load(this.mode);
            return new SubjectExecutor(queryRunner, subjects, this.options);
        });
        // ...

        const executorsWithExecutableOperations = executors.filter(...);
        for (const executor of executorsWithExecutableOperations) {
            await executor.execute();
        }
    }
}

 

코드는 크게 세부분으로 구성된다.

 

첫번째로, 실제 실행할 작업이 담긴 SubjectExecutor 배열을 생성한다. 이 때 mode는 "save" 이므로 canBeInsertedcanBeUpdatedtrue 인 Subject 들이 설정된다.


두번째로, SubjectExecutor를 생성하는 과정에서 Subject들에 대한 정보를 얻어오기 위한 다양한 전처리 과정을 거치는데, 이 중 SubjectDatabaseEntityLoader 가 Subject들에 대한 적절한 쿼리를 수행한다.


마지막으로 이렇게 생성된 SubjectExecutor들을 비동기로 수행한다.

Subject

Entity에 대해 어떠한 오퍼레이션을 할 지 결정하는 Subject 객체에 대해 살펴보자.
먼저 생성자는 다음과 같다.

 

 

constructor(options: {
    // ...
    entity? ObjectLiteral,
    canBeInserted?: boolean,
    canbeUpdated?: boolean,
    // ...
}) {
    this.entity = entity;
    this.canBeInserted = options.canBeInserted;
    this.canbeUpdated = options.canBeUpdated;
    //...
    this.recompute();
}

 

생성자 이외에 중요한 getter가 두 개 존재하는데, 다음과 같다.

 

get mustBeInserted() {
    return this.canBeInserted && !this.databaseEntity;
}

get mustbeUpdated() {
    return this.canBeUpdated &&
            this.identifier &&
            (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)) && this.changeMaps.length > 0;
}

 

기존 멤버필드를 이용해 필요한 getter들을 정의했다. 이름에서 보면 알겠지만 나중에 실행할 쿼리를 결정한다.

 

여기서 구체적인 예시를 통해 진행하고자 Subject의 Constructor에 break point를 잡고 test suite를 작성해 돌려봤다.

 

대략적인 코드는 아래와 같다.

 

const repository = connection.getRepository(MyEntity);
const plainEntity = new MyEntity();
plainEntity.title = "plainEntity";
await repository.save(plainEntity); // 여기서 Subject의 constructor가 호출된다.

const persistedEntity = await repository.findOneOrFail({title: "plainEntity"});
persistedEntity.title = "persistedEntity";
await repository.save(persistedEntity); // 여기서 Subject의 constructor가 호출된다.

 

여기서 실제 Subject 객체들의 필드값들은 다음과 같다.

 

1. plainEntity를 save할 떄 생성되는 Subject
{
  mustBeInserted: true,
  mustBeUpdated: undefined,
  canBeInserted: true,
  canBeUpdated: true,
  ...
}

2. persistedEntity를 save할 때 생성되는 Subject
{
  mustBeInserted: true,
  mustBeUpdated: false,
  canBeInserted: true,
  canBeUpdated: true
}

 

두 경우 모두 mustBeInserted가 true이고, mustBeUpdated는 undefined 또는 false이다.(?)

 

이렇게 생성된 Subject들은 SubjectDatabaseEntityLoader를 통해 갱신된다.

SubjectDatabaseEntityLoader

위에서 EntityPersistExecutor 객체는 SubjectDatabaseEntityLoader 객체를 생성하여 load 메소드를 비동기로 수행한다.

여기서 operationType은 "save"가 될 것이다.

 

async load(operationType: "save"|"remove"|"soft-remove"|"recover"): Promise<void> {

    const allIds: ObjectLiteral[] = [];
    const allSubjects: Subject[] = [];
    subjectGroup.subjects.forEach(subject => {    
        if (subject.databaseEntity || !subject.identifier)
        return;
        allIds.push(subject.identifier);
        allSubjects.push(subject);
    });


    if(operationType === "save" || ..) {
        const entities = await this.queryRunner.manager
        .getRepository<ObjectLiteral>(subjectGroup.target)
        .findByIds(allIds, findOptions);    
    }
    entities.forEach(entity => {
        const subjects = this.findByPersistEntityLike(subjectGroup.target, entity);
        subjects.forEach(subject => {
            subject.databaseEntity = entity;
            if (!subject.identifier)
            subject.identifier = subject.metadata.hasAllPrimaryKeys(entity) ? subject.metadata.getEntityIdMap(entity) : undefined;
        });
    });
}

 

즉 여기서 id가 존재하면 한번더 쿼리가 실행되고, 여기서 찾아진 entity들의 subject들의 databaseEntity 값이 채워지게 된다.

여기서 이미 존재하는 엔티티들의 subject은 mustBeUpdated가 true로 변경된다.

 

get mustBeInserted() {
    return this.canBeInserted && !this.databaseEntity;
}

get mustbeUpdated() {
    return this.canBeUpdated &&
            this.identifier &&
            (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)) && this.changeMaps.length > 0;

 

SubjectExecutor

돌아와서 이러한 Subject를 기반으로 생성한 SubjectExecutor의 execute 메소드를 실행하게 된다.


해당 메소드는 Subject들을 Insert, Update, (Soft)Remove, Recover할 Subject들로 나누고, 각각의 Subject들에 대해 executeXXOperations() 메소드를 실행한다.

 

마지막으로 이 executeXXOperation() 메소드들은 내부적으로 queryBuilder를 생성해 쿼리 오퍼레이션을 수행한다.

결론

Repository의 save 메소드는 INSERT 쿼리를 수행하거나, SELECT -> UPDATE로 이뤄지는 쿼리를 수행한다.

여기서 알 수 있는점은 여러가지가 있는데, 대표적으로

  1. Repository를 통해 업데이트를 수행할 때 find -> update object -> persist(save, update) 하는데,
    이때 save 보다는 update 메소드가 낫다.

    save는 쿼리를 두번 수행함으로써 네트워크 통신도 두번 하게 되고 상대적으로 디비에 부하(?)를 주는 반면
    update는 한번에 쿼리만 수행한다. update 메소드 주석에는 다음과 같은 문장이 적혀있다.
* Executes fast and efficient UPDATE query. Does not check if entity exist in the database.

 

  1. 반대로 신뢰할 수 있는 자바스크립트 객체일 경우 바로 save를 수행하는게 낫다. 즉 업데이트를 수행할 때 1의 과정을 거치기 보다는 find 하지 않고 객체를 수정하여 바로 save 하면 된다는 것이다.

 

References

  • TypeORM Doc
  • typeorm src(0.2.40)

Recent Posts

Popular posts

Recent Comments

Tag List

테라폼 인가 알고리즘 컨테이너 GCP JavaScript 리눅스 API 파이썬 IAC 운영체제 k8s AWS DNS 클라우드 TypeScript 네트워크 인증 JWT 도커 DB 백준 네임스페이스 ORM
Total : Today : Yesterday :
Blog powered by Tistory, Designed by hanarotg