급히 구현하다보니 여러 행에 대한 처리가 필요할 때 for 문 안에서 쿼리를 실행하는 부분이 많이 있었습니다.
리팩토링을 진행하며 이런 부분을 모두 한 번의 쿼리로 처리할 수 있도록 변경했습니다.
그 중 TypeORM으로 여러 행을 INSERT
, UPDATE
하는 방법에 대해 글을 써보려고 합니다.
와이어 샤크로 찍어본 Request Query 패킷
서버는 클라이언트와 통신할 때처럼 쿼리를 실행할 때마다 데이터베이스에 Request를 보내고, 응답을 기다리게 됩니다.
이 때 대기하는 동안 논리 흐름이 중단됩니다. 즉 CPU가 놀게 됩니다.
이를 I/O burst라고 합니다.
(I/O는 Input, Output을 뜻합니다. 네트워크 전송을 입력/출력으로 표현하는 이유는 컴퓨터 내부적으로 네트워크 요청을 처리하는 엔드포인트가 file discriptor이기 때문입니다.)
이처럼 쿼리를 날릴 때마다 I/O burst가 일어나기 때문에 쿼리 횟수는 최대한 줄이는 게 좋습니다.
이전에는 TypeORM의 save
메소드를 사용하고 있었습니다.
save
메소드는 1) SELECT
쿼리로 중복 행이 존재하는지 확인한 뒤 2) 있으면 UPDATE
, 없으면 INSERT
하는 두 단계로 나뉩니다.
이 때문에 인자로 엔티티의 배열을 넘겨도 한번에 INSERT
하지 않고, 한 행씩 SELECT - INSERT
하게 됩니다.
SELECT `Excrements`.`id` AS `Excrements_id`, `Excrements`.`journal_id` AS `Excrements_journal_id`, `Excrements`.`dog_id` AS `Excrements_dog_id`, `Excrements`.`type` AS `Excrements_type`, ST_AsText(`Excrements`.`coordinate`) AS `Excrements_coordinate` FROM `excrements` `Excrements` WHERE ((`Excrements`.`journal_id` = ?) AND (`Excrements`.`dog_id` = ?) AND (`Excrements`.`type` = ?)) LIMIT 1 -- PARAMETERS: [228,1,"FECES"]
INSERT INTO `excrements`(`id`, `journal_id`, `dog_id`, `type`, `coordinate`) VALUES (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)) -- PARAMETERS: [228,1,"FECES","POINT(10.4 180)"]
SELECT `Excrements`.`id` AS `Excrements_id`, `Excrements`.`journal_id` AS `Excrements_journal_id`, `Excrements`.`dog_id` AS `Excrements_dog_id`, `Excrements`.`type` AS `Excrements_type`, ST_AsText(`Excrements`.`coordinate`) AS `Excrements_coordinate` FROM `excrements` `Excrements` WHERE ((`Excrements`.`journal_id` = ?) AND (`Excrements`.`dog_id` = ?) AND (`Excrements`.`type` = ?)) LIMIT 1 -- PARAMETERS: [228,1,"URINE"]
INSERT INTO `excrements`(`id`, `journal_id`, `dog_id`, `type`, `coordinate`) VALUES (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)) -- PARAMETERS: [228,1,"URINE","POINT(30.4 180)"]
TypeORM의 또 다른 메소드인 insert
는 이러한 확인 과정을 거치지 않습니다.
이에 따라 인자로 받은 엔티티를 한 번에 INSERT
해줍니다.
insert(entity: QueryDeepPartialEntity<Entity> | QueryDeepPartialEntity<Entity>[]): Promise<InsertResult>;
INSERT INTO `excrements`(`id`, `journal_id`, `dog_id`, `type`, `coordinate`) VALUES (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)), (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)), (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)), (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)), (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)), (DEFAULT, ?, ?, ?, ST_GeomFromText(?, 4326)) -- PARAMETERS: [227,1,"FECES","POINT(10.4 180)",227,1,"URINE","POINT(30.4 180)",227,2,"FECES","POINT(10.4 180)",227,2,"URINE","POINT(30.4 180)",227,3,"FECES","POINT(10.4 180)",227,3,"URINE","POINT(30.4 180)"]
확인 절차를 거치지 않는 만큼, 중복 조건에 걸리는 행이 있는 경우 에러가 발생하니 주의가 필요합니다.
bulk UPDATE
는 조금 더 복잡합니다.
우선 여러 행을 업데이트 하되 모두 같은 값으로 업데이트 할 때는, Repository에 정의된 update
메소드를 사용할 수 있습니다.
await update({ id: In([1, 2, 3]) }, 10);
하지만 각 행을 서로 다른 값으로 UPDATE 할 때는 다른 방법이 필요합니다.
update(criteria: string | string[] | number | number[] | Date | Date[] | ObjectId | ObjectId[] | FindOptionsWhere<Entity>, partialEntity: QueryDeepPartialEntity<Entity>): Promise<UpdateResult>;
2번 인자인 partialEntity: QueryDeepPartialEntity가 업데이트할 엔티티인데, 어째서인지 배열 형태로 받지 못하게 되어있습니다.
우선 SQL로는 어떻게 구현하는지 찾아보았습니다.
UPDATE a
SET fruit = (CASE id WHEN 1 THEN 'apple'
WHEN 2 THEN 'orange'
WHEN 3 THEN 'peach'
END)
WHERE id IN(1,2 ,3);
SELECT -CASE
구문과 WHERE - IN
을 조합해 조건에 따라 다른 값으로 행을 업데이트 하는 방법입니다.UPDATE a
SET fruit = IF (id = 1, 'apple',
IF (id = 2, 'orange',
IF (id = 3, 'peach', fruit)));
IF
를 활용하는 방법입니다. 위와 대체로 비슷합니다.INSERT into `table` (id,fruit)
VALUES (1,'apple'), (2,'orange'), (3,'peach')
ON DUPLICATE KEY UPDATE fruit = VALUES(fruit);
INSERT
하되, 중복되는 행이 있으면 해당하는 값으로 UPDATE
하는 방법입니다. 대체로 가장 추천되는 방법이었습니다.INSERT
를 하지 않는데도 PK가 증가해 추천하지 않는다는 글을 보았는데 보았는데, 실제로 해보니 증가하지 않았습니다. MySQL 버전이 업데이트 되며 고쳐진 버그인 것 같습니다.3번이 구문도 간단하고, typeorm에 구현된 메소드도 있어 사용하기로 했습니다.
Reposiotry에는 없고, QueryBuilder에 정의되어 있는 메소드입니다.
orUpdate(overwrite: string[], conflictTarget?: string | string[], orUpdateOptions?: InsertOrUpdateOptions): this;
/**
* Creates INSERT express used to perform insert query.
*/
await this.entityManager
.createQueryBuilder(TodayWalkTime, 'todayWalkTime')
.insert()
.into(TodayWalkTime, ['id', 'duration'])
.values(updateEntities)
.orUpdate(['duration'], ['id'])
.execute();
아래와 같은 쿼리가 실행됩니다.
INSERT INTO `today_walk_time`(`id`, `duration`) VALUES (?, ?), (?, ?), (?, ?) ON DUPLICATE KEY UPDATE `duration` = VALUES(`duration`) -- PARAMETERS: [1,172801,2,172802,3,205200]
이렇게 한 번의 쿼리로 INSERT
, UPDATE
를 실행해 성능을 향상시킬 수 있습니다.
여러 행을 DELETE 할 때는 옵션으로 WHERE IN
조건을 사용하면 됩니다.