typeORM 이란
공식문서에서 TypeORM은 NodeJS, Browser, React Native 등 Electron 플랫폼에서 실행할 수 있고 TypeScript 및 JavaScript(ES5, ES6, ES7, ES8)와 함께 사용할 수 있는 ORM 이라고 설명되어있습니다.
그렇다면 ORM 은 무엇일까요 ?
ORM이란
Object Relational Mapping (객체 관계 매핑) 의 약자로서, 데이터베이스를 사용하는 서비스를 객체지향적으로 구현하는데 큰 도움을 주는 도구입니다.
- 관계형 데이터베이스와 객체지향 프로그래밍언어의 중간에서 패러다임 일치를 시켜주기위한 기술
- 개발자는 객체지향적으로 프로그래밍을하고, ORM 이 관계형데이터베이스에 맞게 SQL을 대신 생성해서 실행한다
- ORM 을 통해 개발자는 더이상 SQL 에 종속적인 개발을 하지 않아도 된다.
지난번에 배웠던 express와 typeORM을 간단한 API 를 만들어보겠습니다.
요구사항은 다음과 같습니다.
- User-Address 테이블은 사용자와 주소의 관계를 갖는다
- Address 를 등록, 조회, 수정할 수 있다.
- Address 에서 User와 조인된 데이터를 가져올 수 있다(단방향매핑)
- Address 와 User 테이블은 서로에게 접근할 수 있다(양방향매핑)
- 기존 User 정보를 불러와 FK로 연결된 새로운 Address 객체를 저장할 수 있다.
프로젝트 환경설정
- express
npm install express @types/express
- mysql
npm install mysql
-
ts-node
- ts-node 는 TypeScript 언어를 지원하는 Node.js 실행기로서, TypeScript 코드를 자바스크립트로 컴파일하고, 이를 Node.js에서 실행할 수 있게 해줍니다. ts-node를 사용하면, TypeScript 코드를 직접 실행할 수 있기 때문에 개발 속도를 높일 수 있습니다. ts-node 를 간편하게 함께 실행시켜주기 위해 nodemon 을 함께 설치하겠습니다.
npm install typescript ts-node nodemon
- package.json script 추가
"dev":"nodemon --watch './**/*.ts' --exec 'ts-node'
-
typescript,typeORM
npm install typeorm typescript
-
src 폴더 추가 후 하위에 index.ts 파일 추가
-
tsconfig.json 파일 생성
- tsconfig.json 은 TypeScript 프로젝트의 설정 파일로, TypeScript 컴파일러에게 어떤 컴파일 옵션을 사용할지 알려주고, 어떤 파일을 컴파일할지 지정해줍니다.
명령어를 통하는 방법도 있지만 저는 물리적으로 root에 파일을 생성했습니다. 각 옵션에 대한 자세한 설명은 공식문서에서 확인가능합니다.
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": [
"dom",
"es6",
"es2017",
"esnext.asynciterable"
],
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"exclude": ["node_modules"],
"include": ["./src/**/*.tsx", "./src/**/*.ts"]
}
- 실행
- 실행 명령어 입력 시 아래와 같은 로그가 뜨면 환경설정이 완료된 것 입니다.
npm run dev
테이블 생성하기
Entity 클래스를 생성하여 테이블을 생성하겠습니다.
- User
@Entity()
export class User {
@PrimaryGeneratedColumn()
id : number;
@Column()
name : string;
@Column()
age :number;
}
- Address
@Entity()
export class Address {
@PrimaryGeneratedColumn()
id: number;
@Column()
detailAddress: string;
}
typeORM 데코레이터를 통해 데이터베이스 스키마를 정의할 수 있습니다.
-
@Entity : 데코레이터와 모델을 통해서 테이블을 생성할 수 있다.
-
@Column : 데코레이터를 통해 컬럼 추가가 가능하다.
- @Column 데코레이터의 매개변수로 컬럼의 자료형을 입력해 지정할 수 있다.
아래는 대표적인 자료형 지정에 대한 예시이다. 공식문서에서 enum 유형, set 유형, simple-array 유형, simple-json 유형 등 기타 자료형을 지정하는 예시를 찾아볼 수 있다.
@Column("int") @Column({ type: "int" }) @Column("varchar", { length: 200 }) @Column({ type: "int", width: 200 })
- @Column 데코레이터의 매개변수로 컬럼의 자료형을 입력해 지정할 수 있다.
-
@PrimaryColumn : 각 엔티티에는 PK 가 반드시 존재해야 한다.
- PK를 시퀀스에 의해 자동생성된 값으로 설정하고 싶다면 @PrimaryGeneratedColumn 를 사용한다.
DB 연동하기
DB를 연동해 정의한 데이터스키마를 밀어넣어보겠습니다.
- data-source.ts
import "reflect-metadata";
import { DataSource } from "typeorm";
import {Address} from './entity/Address';
import {User} from './entity/User';
export const AppDataSource = new DataSource({
type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "{PASSWORD}",
database: "{DB_NAME}",
entities: [Address, User],
synchronize: true,
logging: true,
migrations: [],
subscribers: [],
});
- index.ts
import { AppDataSource } from "./data-source";
import express from "express";
import userRouter from './routes/userRoutes'
AppDataSource.initialize().then(async () => {
// create express app
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// run app
app.listen(3000);
}).catch(error => console.log(error))
데이터베이스와의 초기 연결을 초기화하고, 모든 엔티티를 등록하고, 데이터베이스 스키마를 동기화하기 위해 initialize() 호출하고 json-parser 미들웨어를 추가했습니다. createConnection 은 구 DB 연결방식이므로 DataSource 사용을 권장합니다. 이제 서버를 실행시키면 테이블들이 생성된 것을 확인할 수 있습니다.
이제 본격적으로 API 를 생성해볼텐데요, 먼저 src 폴더에 controller 와 routes 폴더를 만들었습니다.
추가
User 테이블에 새로운 user 를 추가하는 작업입니다.
- userController
const userRepository = AppDataSource.getRepository(User);
export async function saveUser (req: Request, res: Response){
await userRepository
.save(req.body) //DB저장
.then((user) => {
res.send(user); //저장된 정보 response
})
.catch((err) => console.log(err));
}
Repository 는 데이터베이스에서 Entity를 조작할 수 있는 기능을 제공하는 객체입니다. Repository에서 save() 메서드를 통해 요청된 데이터를 DB에 저장하는 코드를 작성했습니다. POSTMAN에서 요청한 새로운 USER(json 데이터)가 정상적으로 DB에 저장된 것을 확인할 수 있습니다.
조회
- id로 조회하기
export async function getUserById (req: Request, res: Response){
let inputId = parseInt(req.params.id);
await userRepository
.findOne({where: {id: inputId}})
.then((user) =>{
res.send(user);
console.log(user);
})
.catch((err) =>console.log(err));
}
- 전체 조회하기
export async function getAllUser(req: Request, res:Response) {
await userRepository
.find()
.then((user) =>{
res.send(user);
console.log(user);
})
.catch((err) => console.log(err));
}
id로 요청해 조회하는 getUserById와 전체User를 조회하는 getAllUser 메서드를 만들었습니다. 조회를 할 때는 find() 또는 findOne() 을 사용할 수 있습니다.
조회 시 사용하는 기타 Repository의 find 메서드는 다음과 같습니다. (공식문서 예제)
const allPhotos = await photoRepository.find()
const firstPhoto = await photoRepository.findOneBy({id: 1})
const allViewedPhotos = await photoRepository.findBy({ views: 1 })
수정하기
두가지 방법으로 수정을 해보겠습니다.
가. 공식문서에 예제로 명시되어있는 save() 메서드를 이용하는 방법
- save() 는 DB 에 존재할 경우 insert, 이미 존재하면 update 쿼리를 날립니다.
- EntityManager 를 통해 객체의 Repository 에 접근
const photoRepository = AppDataSource.getRepository(Photo)
- Repository 의 findOneBuy() 메서드를 이용해 조건에 알맞은 데이터 가져와서 변수 (photoToUpdate) 에 담기
const photoToUpdate = await photoRepository.findOneBy({id: 1,})
- update 할 프로퍼티를 set
photoToUpdate.name = "Me, my friends and polar bears"
- DB 에 저장
await photoRepository.save(photoToUpdate)
여기서 의문점이 생길 수 있는데요,
- Q. save() 메서드는 어떻게 존재여부를 체크해서 insert or update 쿼리를 날릴지 결정하는걸까 ?
- A. pk 를 통해서 insert / update 를 결정하게된다. Repository 의 findBy() 메서드를 통해 id 가 1인 데이터는 photoToupdate 변수에 매핑된다. 3번 과정을 통해 photoToUpdate 는 name 프로퍼티만 변경되었고, id 는 여전히 1의 값을 갖고있다. save(photoToUpdate) 를 했을 때 EntityManger 는 DB에 id 가 1인 데이터가 존재하는지 확인하고, 이미 존재하니 update 쿼리를 날리게 되는 것이다.
나. QueryBuilder 를 사용해 수정하는 방법
- QueryBuilder 는 typeORM 에서 제공하는 유틸리티로, 안정적인 방법으로 SQL 쿼리를 작성할 수 있도록 해줍니다.
Select 예제를 작성해보겠습니다.
import { getConnection, QueryBuilder } from 'typeorm';
const connection = getConnection();
const queryBuilder = connection.createQueryBuilder();
const result = await queryBuilder
.select('*')
.from('users', 'u')
.where('u.age > :age', { age: 18 })
.getMany();
이 코드는 다음과 같은 SQL 쿼리를 실행합니다.
SELECT * FROM users u WHERE u.age > 18
queryBuilder만 보아도 어떤 쿼리가 실행될지 예상되지 않나요 ?
이번엔 update 예제를 작성해보겠습니다.
import { getConnection, QueryBuilder } from 'typeorm';
const connection = getConnection();
const queryBuilder = connection.createQueryBuilder();
await queryBuilder
.update('users')
.set({ name: 'John' })
.where('id = :id', { id: 1 })
.execute();
이 코드는 다음과 같은 SQL 쿼리를 실행합니다.
UPDATE users SET name = 'John' WHERE id = 1
조인된 테이블을 수정하고싶으신가요 ? 가능합니다 !
await queryBuilder
.update('users', 'u')
.set({ name: 'John' })
.innerJoin('profiles', 'p', 'u.id = p.userId')
.where('p.email = :email', { email: 'john@example.com' })
.execute();
이 코드는 다음과 같은 SQL 쿼리를 실행합니다.
UPDATE users u INNER JOIN profiles p ON u.id = p.userId SET name = 'John' WHERE p.email = 'john@example.com'
공식문서 에서 다양한 유형의 쿼리를 수행하는 데 사용하는 방법의 예를 포함하여 QueryBuilder 및 QueryBuilder의 기능에 대한 자세한 내용을 확인하실 수 있습니다.
queryBuilder 예제를 살펴보았으니, 이제 응요하여 Useer 를 수정하는 updateUser 메서드를 만들겠습니다.
export async function updateUser(req: Request, res:Response) {
await userRepository
.createQueryBuilder()
.update(User)
.set(req.body)
.where({id: req.params.id})
.execute();
}
이렇게 User 테이블에 데이터를 추가, 수정, 조회 하는 방법을 알아봤는데요. User 와 Address 의 관계를 맺어주어야 합니다. 데코레이터(@)를 사용하여 엔티티 간의 연관관계를 맺을 수 있습니다.
@OneToOne
- @OneToOne 은 일대일 연관관계를 맺을 때 사용한다.
- @joinColumn 데코레이션을 통해 관계의 소유자임을 명시할 수 있다. (관계는 한쪽에서만 소유 가능. 소유자 측에서 @joinColumn 사용가능)
- 소유자는 귀속된 엔티티의 PK 를 FK 로 갖게 된다.
@Entity()
export class Address {
@PrimaryGeneratedColumn()
id: number
@Column()
detailAddress: string
@OneToOne(()=> User)//type() => Photo 는 관계를 맺고자 하는 엔티티의 클래스를 반환하는 함수이다. 가독성을 높이기 위해 () => Photo 형식을 사용했다.
@JoinColumn()
user : User //fk : user_id
}
@OneToOne 연관관계를 맺으면 관계의 소유자 Address 는 User 의 PK를 알게되어 User 의 정보를 알 수 있으나, User 는 Address 에 접근할 수 있는 방법이 없습니다. (단방향매핑)
Address Repository 를 통해 findOneBy() 메서드를 사용하여 User_id 로 조회한 데이터를 들고오면 해결되겠지만, User 에서 Address 에 바로 접근해서 데이터를 가져와야하는 상황이 생겼다고 가정해보겠습니다. 이 문제를 해결하기 위해 우선 두 엔티티의 연관관계를 양방향으로 바꿔주어야 합니다. 코드를 다음과 같이 수정하겠습니다.
- 관계의 소유자 Address.ts
- @OneToOne(“user”) 와 같이 간단히 문자열을 사용할 수도 있습니다.
- 양방향 매핑에서도 역시 @joinColumn 데코레이터는 관계의 소유자가 되는 쪽에서만 사용할 수 있습니다.
@Entity()
export class Address {
/* ... other columns */
@OneToOne(() => User, (user) => user.address)
@JoinColumn()
user: User
}
- 관계의 귀속자 User.ts
@Entity()
export class User {
/* ... other columns */
@OneToOne(() => Address, (address) => address.user)
address: Address
}
양방향 매핑을 해서 User 에서도, Address 에서도 서로를 조회 할 수 있게되었습니다. QueryBuilder 를 사용해 양쪽에서 조인쿼리를 날려 조회가 되는지 확인해보겠습니다.
조회
- AddressController.ts (Address 에서 User 접근)
export async function getJoinedAddress(req: Request, res:Response) {
const address = await AppDataSource
.getRepository(Address)
.createQueryBuilder("address")
.leftJoinAndSelect("address.user", "user")
.getMany()
res.send(address);
}
- UserController.ts (User 에서 Address 접근)
export async function getJoinedUser(req: Request, res:Response) {
const user = await AppDataSource
.getRepository(User)
.createQueryBuilder("user")
.leftJoinAndSelect("user.address", "address")
.getMany()
.then((user) => {
res.send(user);
})
.catch((err) => console.log(err));
}
양방향 매핑을 통해 서로를 조회가 되는 것을 확인할 수 있습니다. 저는 이 지점에서 클래스를 멤버변수로 갖고있는 엔티티를 어떻게 DB 에 저장하는지에 대한 궁금증이 생겼습니다. 공식문서에서 아래와 같은 방법을 찾을 수 있었습니다.
const profile = new Profile()
profile.gender = "male"
profile.photo = "me.jpg"
await dataSource.manager.save(profile)
const user = new User()
user.name = "Joe Smith"
user.profile = profile
await dataSource.manager.save(user)
하지만 명쾌하게 해결되진 않았습니다. 위 예제는 한번에 두 객체를 각각 다른 테이블에 저장하는 것이고, 저는 Profile 을 저장한 후 한참 나중에 User 를 저장하고싶으면 어쩌지 ? 라는 의문을 갖고있었기 때문입니다.
- AddressController.ts
export async function saveAddress(req:Request, res: Response){
//Address 객체생성
const address = new Address();
//get User 데이터
await userRepository
.findOne({where: {id: req.body.userId}})
.then((user) =>{
//set Address
if(user) address.user = user;
address.detailAddress = req.body.detailAddress;
})
.catch((err) =>console.log(err));
//save Address
await addressRepository
.save(address)
.then((address)=>{
res.send(address);
})
.catch((err)=> console.error(err));
}
id 를 통해 user의 데이터를 가져와서 수정하는 방법을 사용해 의도하던대로 동작하게 만들었지만 분명 더 좋은 해결방법이 있을 것이라고 생각됩니다.
@OneToMany / @ManyToOne
한명의 사용자가 여러개의 주소를 가질 수 있다고 가정했을 때 ( 집, 회사, 학교 etc .. ) 다음과 같이 변경 가능합니다.- 관계의 소유자 Address.ts
@Entity()
export class Address {
/* ... other columns */
@ManyToOne(() => User, (user) => user.address)
user: User
}
- 관계의 귀속자 User.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@OneToMany(() => Address, (address) => address.user)
addresses: Address[]
}