ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TypeORM] repository.save()의 동작과 upsert()
    Javascript, Typescript 2021. 12. 19. 13:44
    반응형

    TypeORM에서 Data Mapper 패턴으로 repository를 이용해서 엔티티를 다루는 경우, 새로운 엔티티를 생성하고 데이터베이스에 저장할 때 repository.save() 메서드를 사용하게 된다.

    const newUser = new User();
    await userRepository.save(newUser);

    또한 save 메서드는 다음처럼 엔티티를 조회한 후에 엔티티에 변경을 가하고 변경사항을 저장할 때에도 사용할 수 있다.

    const user = await userRepository.findOne();
    user.changeName('New Name');
    
    await userRepository.save(user);

    개발자가 실제 쿼리를 작성하지 않아도 되는 ORM을 사용할 때는, 실제 ORM이 생성해주는 쿼리가 무엇인지 확인해주는게 중요하다. TypeORM 레포지토리의 save 메서드의 실제 동작을 TypeORM의 logging 옵션을 통한 쿼리 로깅으로 확인해보자.

    repository.save() 메서드의 동작

    다음과 같은 간단한 엔티티를 하나 준비한다.

    @Entity()
    class User {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column()
      name: string;
    
      @Column({
        default: true,
        name: 'is_active',
      })
      isActive: boolean;
      
      static create(name: string, isActive = false) {
        const user = new User();
        user.name = name;
        user.isActive = isActive;
        return user;
      }
    
      disable() {
        this.isActive = false;
      }
    }

    엔티티를 하나 생성해서 저장하는 코드를 작성하고, 실제 전달되는 쿼리를 확인해보자.

    const userRepository = connection.getRepository(User);
    
    const user = User.create('user1');
    await userRepository.save(user);

    트랜잭션으로 INSERT INTO 쿼리가 실행된 것을 확인할 수 있다.

    그렇다면 다음과 같이 엔티티를 조회해서 변경을 가한 다음, save() 메서드를 실행한 뒤에 전달되는 쿼리를 확인해보자.

    const userRepository = connection.getRepository(User);
    
    const user = await userRepository.findOne(1);
    user.disable();
    await userRepository.save(user);

    두 번의 SELECT를 이용한 조회가 이루어졌다. 첫번째 SELECT 쿼리는 findOne() 메서드를 실행할 때 전달된 쿼리지만 두 번째 SELECT는 의도하지 않은 조회 쿼리이다.

    이를 통해 save() 메서드는 PK가 포함된 엔티티를 저장할 때는 먼저 SELECT를 이용해서 id로 조회한 뒤에, 레코드가 존재하면 변경된 컬럼에 대해 UPDATE쿼리를 수행하는 것을 알 수 있다.

    한번의 쿼리로 해결할 수는 없을까?

    필자가 원하는 것은 레코드가 존재한다면 업데이트를, 존재하지 않는다면 생성을 실행하는 것을 한 번의 쿼리로 해결하는 메서드이다. 이 개념을 UPSERT라고 하는데, postgresql에서는 9.5 버전부터 ... ON CONFLICT DO ... 라는 SQL로 위 명령을 실행할 수 있다.

    SQL로 나타내면 다음과 같다.

    INSERT INTO "user"(id, "name", is_active)
    VALUES(1, 'user1', TRUE)
    ON CONFLICT (id)
    DO UPDATE SET "name"='user1', is_active=TRUE;

    repository.upsert()

    최근 TypeORM의 버전 0.2.40repository.upsert()라는 메서드가 추가됐다. 위와 같은 ON CONFLICT DO UPDATE 쿼리를 생성해주는 메서드이다.

    첫 번째 파라미터로 엔티티를 넘겨주고, 두 번째 파라미터로 ON CONFLICT에 들어갈 컬럼을 넘겨주는 방식으로 사용한다.

    const user = await userRepository.findOne(1);
    user.disable();
    await userRepository.upsert(user, ['id']);

    해결됐을까?

    하지만 위 예제에서 upsert 메서드를 사용할 때 TypeORM이 생성해주는 쿼리를 보면, id를 INSERT VALUES에 포함시키지 않고 있다.

    id가 INSERT VALUES에 없기 때문에 CONFLICT가 일어지 않는다. 그래서 ON CONFLICT ("id") ...쿼리는 사실상 의미가 없다. 위 쿼리를 실행하게 되면 새로운 id에 name은 user1, is_active는 false인 새로운 레코드가 하나 추가된다.

    INSERT VALUES에 id가 포함되지 않는 동작은 save(), insert(), upsert()에서 동일하게 일어나는데, 엔티티의 id 프로퍼티가 @PrimaryGeneratedColumn() 데코레이터로 설정되어있기 때문이다.

    그렇기 때문에 upsert() 메서드는 id 같은 PK 컬럼에 사용할 수는 없고, email 같은 UNIQUE 컬럼에만 사용할 수 있다.

    QueryBuilder의 orUpdate() 사용

    결국 원하던 upsert를 구현하기 위해서 QueryBuilder를 이용해서 앞에서 작성한 SQL문을 직접 구현하게 되었다. 동일한 쿼리를 구현하기 위해 orUpdate() 메서드를 이용했다.

    const user = await userRepository.findOne(1);
    user.disable();
    await userRepository.createQueryBuilder()
      .insert()
      .into(User, ['id', 'name', 'isActive'])
      .values(user)
      .orUpdate(['name', 'is_active'], ['id'])
      .execute()

    원하던 쿼리가 실행되는 것을 확인할 수 있다.

    커스텀 레포지토리를 만들어서 메서드로 등록하면 원하는 서비스에서 깔끔하게 사용할 수 있다.

    @EntityRepository(User)
    class UserRepository extends Repository<User> {
      upsertById(user: User) {
        return this.createQueryBuilder()
          .insert()
          .into(User, ['id', 'name', 'isActive'])
          .values(user)
          .orUpdate(['name', 'is_active'], ['id'])
          .execute()
      }
    }
    const userRepository = connection.getCustomRepository(UserRepository)
    
    const user = await userRepository.findOne(1);
    user.disable();
    await userRepository.upsertById(user);

    Conclusion

    • PK컬럼값이 있는 데이터를 저장하는 경우, save() 메서드는 PK로 조회, 레코드 업데이트 두 개의 쿼리가 실행된다.
    • @PrimaryGeneratedColumn() 데코레이터가 붙은 컬럼은 repository의 메서드를 이용할때 INSERT VALUES에 포함되지 않는다.
    • INSERT VALUES에 포함하고 싶으면 QueryBuilder의 orUpdate를 사용하자.

    References

    반응형

    댓글

Designed by Tistory.