개발자 포지션으로 두 번째 회사인 이 곳에서... 새로운 기술스텍을 많이 발견하고 있는 중이다.
그 중 데이터베이스와 관련된 TypeORM을 공부하면서 정리해보자.
자... Type + ORM 에서 ORM이란, Object Relational Mapping 의 약자이다. 그리고 이것은 기술이다.
어떤 기술이냐? 객체지향 프로그래밍과 관계형 데이터 베이스를 연결해주는 기술이라고 구글 선생님께서 말하신다.
즉, ORM은 SQL과 일반적으로 코드 에디터(Visual Studio Code 같은...) 우리가 작업하는 코드(자바스크립트)를 연결시켜준다고 이해하면 쉬울 것 같다. 뭐, 더 거창한 설명이 분명히 있을 것이다.
그리고 ORM 앞에 붙은 Type은 자바스크립트의 확장개념인 TypeScript를 말한다.
개념들에 대한 내용은 공식문서에 잘 서술되어 있기 때문에 실무에서 사용할 CRUD를 기반으로 하나씩 알아보자.
TypeORM에서 SELECT를 실행시킬 수 있는 방법은 크게 세 가지가 있다.
① Repository API 의 find 메서드
② DataSource API 의 createQueryBuilder 메서드
③ Entity Manager API 의 query 메서드
이 중 대중적으로 많이 사용되고 있는 find*와 createQueryBuilder을 알아보자.
find* 메서드는 위에서 언급한 다른 두 개의 방법 보다 제한적인 기능을 가지고 있다. 하지만 객체를 옵션으로 사용할 수 있어서 가독성이 좋고 코드량이 적다.
[0] Find 메서드의 종류
- find: Entities 를 담고 있는 배열을 반환한다. (인자로 FindManyOptions 객체를 받는다.)
- findBy: Entities 를 담고 있는 배열을 반환한다. find 와 다르게 'where' 옵션만 사용할 수 있다. (FindOptionsWhere)
- findAndCount: Entities 를 담고 있는 배열과 조회된 entity 의 갯수를 배열로 반환한다.
const [users, count] = await userRepository.findAndCount({
where: { isActive: true },
relations: ['profile'],
order: { createdAt: 'DESC' },
skip: 0,
take: 2,
});
//result
{
"users": [
{
"id": 4,
"name": "Dave",
"isActive": true,
"createdAt": "2023-01-04T13:00:00.000Z",
"profile": {
"id": 2,
"bio": "Bob's bio"
}
},
{
"id": 2,
"name": "Bob",
"isActive": true,
"createdAt": "2023-01-02T11:00:00.000Z",
"profile": {
"id": 2,
"bio": "Bob's bio"
}
}
],
"count": 3
}
- findAndCountBy: 반환값은 findAndCount와 동일하지만 'where' 옵션만 사용가능하다.
- findOne: 하나의 Entity 만 조회한다.
- findOneBy: 하나의 Entity 만 조회한다. 'where' 옵션만 사용가능하다.
- findOneOrFail: 만약 찾고자 하는 값이 없을 경우, 에러를 발생시킨다. 만약 find 로 조회했을때 값이 없으면 undefined 를 반환하는데 상황에 따라 undefined 말고 에러가 필요하다면 해당 메서드를 사용하면 될듯하다.
- findOneByOrFail
** 보면 알겠지만 -By 는 FindOptionsWhere 객체만 옵션으로 넣을 수 있다.
[1] FindManyOptions (링크)
- select: 테이블에서 불러오고 싶은 컬럼명들을 객체의 키로 넣어주고 값을 true 로 설정하면 된다. (SQL 의 SELECT 절과 같다.)
- relations: 제일 까다로움. 간단히 말해서 JOIN(관계를 맺을) 테이블의 (entity)을 설정해주는 옵션이다. (여기서 entity는 컬럼과 다른 개념이다. 바로 entity.ts에서 ManyToOne 혹은 OneToMany와 같은 데코레이터로 만든 프로퍼티를 말하는 것이다.)
- where → 어떤 데이터를 불러올지 조건을 설정하는 옵션.
옵션etc: order, skip, take 등 더 있다.
// 유형1
userRepository.find({
relations: {
profile: true,
photos: true,
videos: true,
},
})
// 유형2 - videos 테이블과 videoAttributes 테이블을 sub-relation으로 사용할 수 있다.
userRepository.find({
relations: {
profile: true,
photos: true,
videos: {
videoAttributes: true,
}
}
})
// SELECT * FROM "user"
// LEFT JOIN "profile" ON "profile"."id" = "user"."profileId"
// LEFT JOIN "photos" ON "photos"."id" = "user"."photoId"
// LEFT JOIN "videos" ON "videos"."id" = "user"."videoId"
// LEFT JOIN "video_attributes" ON "video_attributes"."id" = "videos"."video_attributesId"
// 유형3
userRepository.find({
relations: ['profile', 'profile', 'videos']
}
})
[준비단계 - Entity 만들기]
[관계 맺기(relation) - OneToMany, ManyToOne]
관계(relations)를 맺어보자. 관계를 맺을 때 OneToMany, ManyToOne 을 사용한다. 아래 그림으로 조금 더 쉽게 이해해보자.
포켓몬들은 고유의 id를 가지고 있고, 주요 기술(컬럼)과 서브 기술(컬럼)을 가지고 있다. 주요 기술과 서브 기술은 포켓몬 각자 유일하게 가지고 있는 것이 아니다. 어느 포켓몬이든 가질 수 있다. 이상해씨와 파이리는 A라는 하나의 동일한 기술을 각자 따로가질 수 있다.
포켓몬의 입장(테이블에서 하나의 row)에서 기술(컬럼에 들어올 데이터)은 하나 이상으로 습득할 수 있고, 동시에 습득된 기술은 다른 포켓몬들도 동일하게 습득할 수 있다는 것!!!
★ 포켓몬은 많고, 서로 같은 기술을 가질 수 있다.
★ 기술은 하나이고, 다양한 포켓몬들이 사용할 수 있다.
Entity 클래스는 위와 같이 코드를 만들면 될 것이다. 여기서 @ManyToOne 과 @OneToMay의 메타 데이터를 살표보자.
각각 최대 3개의 값이 들어갈 수 있다.
첫 번째: 내가 관계를 맺을 대상(Entity)을 콜백함수의 반환값으로 설정하면 된다.
두 번째: 콜백함수의 매개변수에 관계맺을 대상 entity 가 있고, 대상 entity 의 프로퍼티에 접근해 반환값으로 불러온다.
세 번째: 옵션설정 eager, cascade, onDelete, nullable, orphanedRowAction이 "Relations Options"들이다. (링크)
(두 번째 인자에 .점 뒤에 실제 Entity명을 입력하지 않아도 되더라.. 왜지..)
@JoinColumn 데코레이터는 무엇이냐? 단어에서 유추해 볼 수 있듯이 기존에 있는 컬럼으로 다른 테이블과 조인시켜주겠다는 말이다.
SQL 에서 ON의 역할이다. 어떤 컬럼을 연결할지 정해준다.
(SELSECT * FROM 테이블A LEFTJOIN 테이블B ON 테이블A.컬럼1 = 테이블B.컬럼1)
JoinColumn 은 테이블A 또는 테이블B 에서 연결하고자 하는 컬럼에 반드시 정해줘야 한다.
이렇게 Entity 클래스를 만들고 관계를 맺을 대상도 설정했으니 테이블 쪽 준비는 끝났다. 이제 실제 호출하는 방법에 대해 알아보자.
[조회 단계(service.ts)]
■ Find 메서드
Repository 패턴으로 디렉토리를 구성했다면 repository.ts 파일에서 구현하면 될 것이다. (controller 부분은 생략) 이제 테이블에서 SELECT를 하면 된다.
게시글 제일 위에서 언급했던 find* 또는 createQueryBuilder을 사용하면 된다. 복잡한 쿼리문이 필요하지 않기 때문에 find* 메서드로 SELECT문을 구현해보자.
pokemon.entity.ts에서 보면 ManyToOne 데코레이터로 p_ability, s_ability 프로퍼티가 생성되어 있다. 이 두개를 아래와 같이 코드를 짜면 된다.
// 1
Repository.find({
relations: {
p_ability: true,
s_ability: true
}
})
// 2
Repository.find({
relations: ['p_ability', 's_ability ']
})
그리고 where 옵션을 추가해 id 로 포켓몬을 조회하면 아래와 같은 결과가 나온다.
여기서 팁을 하나 알려주자면 내가 원하는데로 SQL이 작성되는지를 알아보려면 root 모듈에 있는 TypeOrmModule 메타데이터에 "logging: true" 항목을 추가하면 HTTP 요청이 있을때 마다 query를 터미널에서 보여준다.
query를 살펴 보면,
SELECT
"Pokemon"."ID" AS "Pokemon_ID",
"Pokemon"."NAME" AS "Pokemon_NAME",
"Pokemon"."NUMBER" AS "Pokemon_NUMBER",
"Pokemon"."HEIGHT" AS "Pokemon_HEIGHT",
"Pokemon"."WEIGHT" AS "Pokemon_WEIGHT",
"Pokemon"."TYPE_ID" AS "Pokemon_TYPE_ID",
"Pokemon"."PRIMARY_ABILITY" AS "Pokemon_PRIMARY_ABILITY",
"Pokemon"."SUB_ABILITY" AS "Pokemon_SUB_ABILITY",
"Pokemon__Pokemon_p_ability"."ID" AS "Pokemon__Pokemon_p_ability_ID",
"Pokemon__Pokemon_p_ability"."NAME" AS "Pokemon__Pokemon_p_ability_NAME",
"Pokemon__Pokemon_p_ability"."DESCRIPTION" AS "Pokemon__Pokemon_p_ability_DESCRIPTION",
"Pokemon__Pokemon_s_ability"."ID" AS "Pokemon__Pokemon_s_ability_ID",
"Pokemon__Pokemon_s_ability"."NAME" AS "Pokemon__Pokemon_s_ability_NAME",
"Pokemon__Pokemon_s_ability"."DESCRIPTION" AS "Pokemon__Pokemon_s_ability_DESCRIPTION"
FROM pokemon "Pokemon"
LEFT JOIN "ability" "Pokemon__Pokemon_p_ability" ON "Pokemon__Pokemon_p_ability"."ID"="Pokemon"."PRIMARY_ABILITY"
LEFT JOIN "ability" "Pokemon__Pokemon_s_ability" ON "Pokemon__Pokemon_s_ability"."ID"="Pokemon"."SUB_ABILITY"
WHERE ( "Pokemon"."ID" IN (9) )
작성된 query문 아래 LEFT JOIN 절에 ON 조건을 살펴 보면 p_ability와 s_ability가 있는 것을 알 수 있다. pokemon Entity에서 @ManyToOne 데코레이터에 의해 정의된 프로퍼티명이다. 그리고 JoinColumn에서 옵션으로 넣은 컬럼명 PRIMARY_ABILITY와 SUB_ABILITY가 있다.
결국 LEFT JOIN ability.ID = pokemon.PRIMARY_ABILITY, LEFT JOIN ability.ID = pokemon.SUB_ABILITY가 typeorm에 의해 쿼리문으로 작성된 것이다.
ManyToOne, OneToMany, OneToOne, ManyToMany의 관계를 맺을 때 사용되는 데코레이터가 있는데... 이해하는데 시간이 오래 걸린 것 같다. 다음에는 서브쿼리에 대해 정리해보자.
9마리의 포켓몬 데이터를 뽑았을때, 위 find* 메서드와 @ManyToOne, @OneToMany 관계가 설정되어 있는 코드의 경우 50 ms 이내에 조회가 되었다.
하지만 관계를 설정하지 않고, 1차원 적인 방법으로 접근한다면...
포켓몬 리스트를 뽑아온 후 각 row에 있는 id 로 다시 ability 테이블을 조회해 뽑아온 포켓몬에 일일이 PRIMARY_ABILITY, SUB_ABILITY를 넣어준다. 이때 소요되는 시간은 대략 100 ~ 130 ms 사이였다.
만약 100 또는 1000개의 row를 후자와 같은 방법으로 뽑아온다면 그 소요시간은 엄청날것이다. 그래서 쿼리를 잘짜야 한다...
■ QueryBuilder
ManyToOne, OneToMany와 같이 Entity 클래스에 관계 설정을 하지 않고도 사용할 수 있다.
빠르게 코드 부터 보자면,
결과는,
query문을 확인해 보니,
SELECT
"pokemon"."ID" AS "pokemon_ID",
"pokemon"."NAME" AS "pokemon_NAME",
"pokemon"."NUMBER" AS "pokemon_NUMBER",
"pokemon"."HEIGHT" AS "pokemon_HEIGHT",
"pokemon"."WEIGHT" AS "pokemon_WEIGHT",
"pokemon"."TYPE_ID" AS "pokemon_TYPE_ID",
"pokemon"."PRIMARY_ABILITY" AS "pokemon_PRIMARY_ABILITY",
"pokemon"."SUB_ABILITY" AS "pokemon_SUB_ABILITY",
"ability"."ID" AS "ability_ID",
"ability"."NAME" AS "ability_NAME",
"ability"."DESCRIPTION" AS "ability_DESCRIPTION"
FROM "pokemon" "pokemon"
LEFT JOIN "ability" "ability" ON "pokemon"."PRIMARY_ABILITY" = "ability"."ID"
LEFT JOIN "ability" "b" ON "pokemon"."SUB_ABILITY" = "b"."ID"
WHERE "pokemon"."ID" = 9
createQueryBuilder을 통해 실행하면 Find 메서드를 실행했을 때와 같은 결과물이 나온다.
과연 무엇이 더 좋은가?
간단한 것 같으면서도 어려운 질문이다. 역시 상황에 맞게, 소스코드, 비지니스 로직의 복잡도를 고려해서 판단해야 겠지만, 내 생각은 전자의 방법을 쓰는게 맞아 보인다. TypeORM에서 제시하는 목표는 "객체지 프로그래밍"과 "관계형 데이터베이스의 연결"에 목적을 두고 있다.
객체 중심의 사고와 코딩방법에 초점을 두었을때 전자의 방법이 맞는 것 같다. 그냥 node.js로 데이터 베이스에서 데이터만 뽑아 오는 것이 목적이라면 후자의 방법이 맞아 보인다. 심지어 코드량이 대폭 줄어들기도 한다. 무언가 심오하고 딥한 대답을 하고 싶지만 역시.. 아는 만큼 보인다고.. 아는 것의 깊이가 낮다 보니 이렇게 밖에 말 못한다..
'Backend > TypeORM' 카테고리의 다른 글
@DeleteDateColumn 은 AND 절을 마음대로 추가한다 (0) | 2024.06.26 |
---|---|
Entity Embadding (0) | 2024.06.07 |
다양한 Column 들 (Column Annotation) (1) | 2024.06.07 |
save 와 upsert (0) | 2023.04.25 |
스칼라 서브쿼리 (0) | 2023.04.07 |
댓글