typeORM tutorial

typeORM을 사용해 서버 API 제작하기

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

connect db

테이블 생성하기

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 })
  • @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에 저장된 것을 확인할 수 있습니다.

save user

조회

  • 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 쿼리를 날립니다.
  1. EntityManager 를 통해 객체의 Repository 에 접근
const photoRepository = AppDataSource.getRepository(Photo)
  1. Repository 의 findOneBuy() 메서드를 이용해 조건에 알맞은 데이터 가져와서 변수 (photoToUpdate) 에 담기
const photoToUpdate = await photoRepository.findOneBy({id: 1,})
  1. update 할 프로퍼티를 set
photoToUpdate.name = "Me, my friends and polar bears"
  1. 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);
}

Access From User

  • 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)); 
}

Access From User

양방향 매핑을 통해 서로를 조회가 되는 것을 확인할 수 있습니다. 저는 이 지점에서 클래스를 멤버변수로 갖고있는 엔티티를 어떻게 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));

}

save Addess Reference by FK

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[]
}

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com